Promotion System
1. Overview
| Property | Value |
|---|---|
| Status | Beta — CRUD only; discount calculation not yet wired into either pipeline |
| Owner | pricing-team |
| Services | PromotionService (CRUD); promotion-compute.service.ts-disable (calculation, disabled) |
| Controllers / Routes | PromotionController /promotions (POST /aggregate, PATCH /{id}/aggregate), PromotionMethodController /promotion-methods |
| Depends on | Promotion, PromotionMethod, Rule schemas (@nx/core) |
Manages promotional campaigns through Promotions (campaign + eligibility rules), PromotionMethods (discount method with source/target rules), and Rules (context conditions). Supports manual codes, automatic promotions, percentage/fixed discounts, BuyGet campaigns, and multi-allocation strategies.
⚠️ Discount calculation is not implemented. The compute service is disabled; neither v1 nor v2 applies promotions, so calculate endpoints return
discount: '0'. CRUD works fully. Discounts must be applied externally until the calculator lands.
The aggregate endpoints create/update a promotion with its method and rules atomically in one transaction (one round-trip). Service catalogue and identity card: see Pricing Service index. REST reference: live at
/v1/api/pricing/doc/openapi.json.
2. Data Model
2.1. Promotion Types
| Type | Description | Use Case |
|---|---|---|
| STANDARD | Simple percentage or fixed discount | "10% off entire order", "$5 off shipping" |
| BUYGET | Conditional buy X get Y | "Buy 2 get 1 free", "Buy $100 get 20% off accessories" |
2.2. Promotion Status Lifecycle
| Status | Description |
|---|---|
| DRAFT | Created but not yet active — can be edited freely |
| ACTIVATED | Live promotion — can be applied to orders |
| DEACTIVATED | Temporarily paused — can be reactivated |
| EXPIRED | Past effectiveTo date — read-only |
| ARCHIVED | Permanently disabled — historical record only |
2.3. Target Types
| Type | Applies To | Example |
|---|---|---|
| ITEMS | Individual order items matching target rules | "10% off shoes category" |
| ORDER | Entire order subtotal | "15% off orders over $100" |
| SHIPPING | Shipping cost | "Free shipping for orders over $50" |
2.4. Allocation Strategies
| Strategy | Behavior | Example |
|---|---|---|
| EACH | Discount applied to each matching item individually | "10% off each item in Electronics" → 3 items = 3× discount |
| ACROSS | Discount distributed across all matching items | "$10 off Electronics" → $10 split among all matching items |
| ONCE | Discount applied once regardless of quantity | "Buy 2 get 1 free" → Applied once per qualifying set |
2.5. Rule Context Convention
Rules are distinguished by metadata.context:
| Context | Attached To | Purpose | Example |
|---|---|---|---|
| eligibility | Promotion | Who can use this promotion? | Customer group = "VIP" |
| source | PromotionMethod | What must be bought? (BuyGet only) | Product category = "Laptops" AND quantity >= 2 |
| target | PromotionMethod | What gets discounted? | Product category = "Accessories" |
3. Promotion Creation Flow
3.1. Simple Promotion (CRUD)
POST /promotions
Content-Type: application/json
Authorization: Bearer <token>
{
"code": "SUMMER2026",
"name": { "en": "Summer Sale", "vi": "Khuyến mãi mùa hè" },
"type": "STANDARD",
"status": "DRAFT",
"isAutomatic": false,
"effectiveFrom": "2026-06-01T00:00:00Z",
"effectiveTo": "2026-08-31T23:59:59Z",
"usageLimit": 1000
}3.2. Promotion Aggregate (Promotion + Method + Rules)
Create a complete promotion with method and all rules in a single atomic transaction:
POST /promotions/aggregate
Content-Type: application/json
Authorization: Bearer <token>
{
"promotion": {
"code": "NEWUSER20",
"name": { "en": "New User 20% Off" },
"type": "STANDARD",
"isAutomatic": false,
"effectiveFrom": "2026-03-01T00:00:00Z",
"usageLimit": 500
},
"promotionMethod": {
"targetType": "ORDER",
"allocation": "ONCE",
"type": "PERCENTAGE",
"value": "20",
"currency": "VND"
},
"promotionRules": [
{
"attribute": "customer.registeredAt",
"operator": "GTE",
"dataType": "TEXT",
"tValue": "2026-01-01T00:00:00Z",
"priority": 1,
"metadata": { "context": "eligibility" }
}
],
"sourceRules": [],
"targetRules": [
{
"attribute": "order.subtotal",
"operator": "GTE",
"dataType": "NUMBER",
"nValue": "500000",
"priority": 1,
"metadata": { "context": "target" }
}
]
}Transaction guarantee: All entities created or none (rollback on any error).
4. Promotion Application
⚠️ Not yet functional. The
PromotionComputeService(promotion-compute.service.ts) is currently disabled (renamed to.ts-disable). The examples below document the intended API contract; actual discount computation is not active.
4.1. Manual Application
POST /promotions/apply
Content-Type: application/json
Authorization: Bearer <token>
{
"promotionCode": "SUMMER2026",
"context": {
"customer": {
"id": "customer-123",
"groupId": "vip"
},
"order": {
"subtotal": "1000000"
},
"items": [
{
"id": "item-1",
"productId": "prod-1",
"variantId": "var-1",
"quantity": 2,
"price": "500000"
}
]
}
}Response:
{
"isApplicable": true,
"discountAmount": "100000",
"itemDiscounts": [
{
"itemId": "item-1",
"discountAmount": "100000",
"reason": "SUMMER2026 - 10% off"
}
]
}4.2. Automatic Promotions
Automatic promotions (isAutomatic: true) are evaluated during checkout without requiring a promo code:
// During checkout flow
const promotions = await promotionRepository.findAutomaticPromotions({
effectiveDate: new Date()
});
for (const promotion of promotions) {
const result = await promotionComputeService.applyPromotion({
promotion,
context: orderContext
});
if (result.isApplicable) {
// Apply discount to order
}
}5. BuyGet Promotion Example
Scenario: "Buy 2 laptops, get 1 mouse free"
{
"promotion": {
"code": "LAPTOP2GET1",
"type": "BUYGET",
"name": { "en": "Buy 2 Laptops Get 1 Mouse Free" }
},
"promotionMethod": {
"targetType": "ITEMS",
"allocation": "ONCE",
"type": "PERCENTAGE",
"value": "100",
"buyGetSourceMinQuantity": 2,
"buyGetTargetQuantity": 1
},
"sourceRules": [
{
"attribute": "product.categoryId",
"operator": "EQ",
"dataType": "TEXT",
"tValue": "laptops",
"metadata": { "context": "source" }
}
],
"targetRules": [
{
"attribute": "product.categoryId",
"operator": "EQ",
"dataType": "TEXT",
"tValue": "accessories",
"metadata": { "context": "target" }
},
{
"attribute": "product.subcategoryId",
"operator": "EQ",
"dataType": "TEXT",
"tValue": "mouse",
"metadata": { "context": "target" }
}
]
}Logic:
- Evaluate source rules: Find items where
categoryId = "laptops"ANDsum(quantity) >= 2 - If source passes, evaluate target rules: Find items where
categoryId = "accessories"ANDsubcategoryId = "mouse" - Apply 100% discount to lowest-priced mouse (up to
buyGetTargetQuantity = 1)
6. Promotion Management Operations
6.1. Activate Promotion
PUT /promotions/:id/activate
Authorization: Bearer <token>Changes status from DRAFT → ACTIVATED. Validates that promotion has a valid method attached.
6.2. Deactivate Promotion
PUT /promotions/:id/deactivate
Authorization: Bearer <token>Changes status from ACTIVATED → DEACTIVATED. Promotion can be reactivated later.
6.3. Delete Promotion (Cascade)
DELETE /promotions/:id
Authorization: Bearer <token>Soft-deletes:
- Promotion entity
- Associated PromotionMethod
- All attached Rules (eligibility, source, target)
All deletions are transactional (rollback on error).
7. Repository Operations
7.1. Promotion Repository
| Method | Description |
|---|---|
findByCode() | Find active promotion by code (excludes deleted, filters by time) |
findAutomaticPromotions() | Get all auto-apply promotions active at given date |
findActivePromotions() | Get all active promotions within date range |
isUsageLimitReached() | Check if promotion has reached usage limit |
incrementUsageCount() | Atomic increment of usageCount (raw SQL) |
updateRulesCount() | Update denormalized rulesCount |
findByStatus() | Filter promotions by status |
7.2. PromotionMethod Repository
| Method | Description |
|---|---|
findByPromotionId() | Get method for promotion (1:1 relationship) |
updateSourceRulesCount() | Update denormalized sourceRulesCount |
updateTargetRulesCount() | Update denormalized targetRulesCount |
7.3. Rule Repository (Promotion-Specific)
| Method | Description |
|---|---|
findEligibilityRulesByPromotionId() | Get eligibility rules (metadata.context = 'eligibility') |
findSourceRulesByMethodId() | Get BuyGet source rules (metadata.context = 'source') |
findTargetRulesByMethodId() | Get target rules (metadata.context = 'target') |
deleteByPromotionId() | Cascade delete all promotion eligibility rules |
deleteByMethodId() | Cascade delete all method rules (source + target) |
8. Integration with Pricing Flow
8.1. Current State (Disabled)
In PricingService.calculate(), discount is hardcoded because the compute service is disabled:
// Hardcoded — PromotionComputeService is disabled
discount: '0', // TODO: Apply promotions here8.2. Planned Integration
// Future implementation
const applicablePromotions = await this.promotionComputeService.applyAutomaticPromotions({
context: pricingContext
});
const discount = applicablePromotions.reduce((sum, promo) =>
sum + parseFloat(promo.discountAmount), 0
);9. Key Design Decisions
| Decision | Rationale |
|---|---|
| Polymorphic Rule system | Single Rule table serves Fare, Tax, and Promotion — reduces schema complexity |
| Rule context metadata | Uses metadata.context convention instead of separate fields — flexible and extensible |
| 1:1 Promotion:Method | Simplifies model vs Medusa.js's ApplicationMethod array — sufficient for current needs |
| Denormalized counts | rulesCount, sourceRulesCount, targetRulesCount — avoids joins for common queries |
| i18n support | All user-facing text (name, description) stored as JSONB — multi-language ready |
| Status lifecycle | 5 states (DRAFT → ACTIVATED → DEACTIVATED → EXPIRED → ARCHIVED) — clear state machine |
| Atomic aggregate creation | Single transaction for Promotion + Method + Rules — data consistency |
| Separate compute service | Calculation logic isolated from CRUD — separation of concerns |
10. Implementation Status
| Component | Status | Notes |
|---|---|---|
| Schemas | ✅ Complete | Promotion, PromotionMethod, Rule (polymorphic) |
| Repositories | ✅ Complete | Extended with promotion-specific methods |
| Controllers | ✅ Basic CRUD | Using ControllerFactory pattern |
| Management Service | ✅ Complete | PromotionService with 6 methods (CRUD, activate, deactivate, delete) |
| Compute Service | ❌ Disabled | promotion-compute.service.ts renamed to .ts-disable — intentionally excluded from build |
| Pricing Integration | ❌ TODO | Not yet integrated into dynamic pricing flow |
| Request Models | ✅ Complete | Create, update, aggregate, apply requests |
| Response Models | ✅ Complete | Aggregate and application responses |
11. Related Documentation
- Pricing Service — Package overview and architecture
- Fare System — Rule evaluation patterns (shared with promotions)
- Tax System — Tax calculation flow
- Sale Service — Order management and checkout integration
- Core Database Schema — Promotion table definitions