Promotions Engine
Dynamic discount rules engine supporting automatic promotions for sales and orders.
Architecture
promotions/
├── promotions.module.ts # NestJS module
├── domain/
│ └── promotions-repository.domain.ts # Repository interfaces (ports)
├── application/
│ ├── promotions.service.ts # CRUD, status lifecycle, application recording
│ ├── promotion-evaluator.service.ts # Cart evaluation engine
│ └── events/
│ └── on-promotion-discount-applied.handler.ts # Event listener for discount recording
├── infrastructure/
│ ├── promotions.repository.ts # Kysely implementation
│ └── promotion-applications.repository.ts # Usage tracking implementation
└── interfaces/
├── promotions.controller.ts # REST endpoints
└── dtos/
├── create-promotion.dto.ts
├── update-promotion.dto.ts
└── evaluate-promotions.dto.ts
Layer responsibilities:
| Layer | Responsibility |
|---|---|
| Domain | Repository interfaces (IPromotionsRepository, IPromotionApplicationsRepository). No framework deps. |
| Application | Business logic: CRUD (PromotionsService), evaluation (PromotionEvaluatorService), event handling. |
| Infrastructure | Kysely database queries, injected via PROMOTIONS_REPOSITORY / PROMOTION_APPLICATIONS_REPOSITORY tokens. |
| Interfaces | HTTP controller, DTOs with class-validator + Swagger decorators. |
Domain Concepts
Promotion Scopes
| Scope | Description | Example |
|---|---|---|
line | Per-item discount on qualifying items | 20% off all items in "Blue" category |
cart | Whole-cart discount | $10 off orders over $100 |
bundle | Buy X get Y | Buy 2 shirts, get 1 free |
Condition JSON
Defines when a promotion applies. All present fields are AND-ed together.
{
"itemFilter": {
"categoryIds": ["cat-1"],
"productIds": ["prod-1"],
"variantIds": ["var-1"]
},
"minQuantity": 2,
"minCartTotal": 100,
"rewardItemFilter": { "categoryIds": ["cat-2"] },
"daysOfWeek": [1, 2, 3, 4, 5]
}
itemFilteruses OR logic across dimensions (category OR product OR variant)rewardItemFilteris for bundle scope only (defines reward items separate from qualifying items)
Benefit JSON
Defines the reward when conditions are met.
{
"method": "percent",
"value": 20,
"maxDiscount": 50,
"rewardQuantity": 1,
"overridePrice": 0
}
Status Lifecycle
Draft → Active or Scheduled
Scheduled → Active or Expired (auto on startAt)
Active ↔ Paused → Expired (auto on endAt)
Expired → (terminal)
Time-based transitions (reconcileStatuses) run automatically before queries.
Location Scoping
- Promotions with no
locationIdsapply everywhere - Promotions with specific
locationIdsonly apply at those locations
Usage Limits
maxUses: global limit across all customersmaxUsesPerCustomer: per-customer limitcurrentUses: atomic counter, incremented on each application
API Endpoints
| Method | Path | Description |
|---|---|---|
POST | /promotions/evaluate | Evaluate cart against active promotions |
GET | /promotions/applications/summary | Aggregated usage stats |
POST | /promotions | Create promotion |
GET | /promotions | List promotions (filterable) |
GET | /promotions/:id | Get promotion detail |
GET | /promotions/:id/applications | Paginated application history |
PATCH | /promotions/:id | Update promotion |
PATCH | /promotions/:id/status | Transition status |
DELETE | /promotions/:id | Delete (hard or soft) |
All endpoints require businessId query parameter and Bearer auth.
Evaluation Flow
- Reconcile time-based status transitions
- Fetch active promotions for business + location
- Optionally include preview promotions (draft/scheduled)
- For each promotion: check usage limits, time window, days of week
- Evaluate per scope (line/cart/bundle)
- Return applicable promotions with discount entries and total savings
Event-Driven Application Recording
When a discount from a promotion is applied to a sale/order, the discount.promotion.applied event is emitted. The OnPromotionDiscountAppliedHandler listens for this and calls recordApplication, which:
- Verifies usage limits haven't been exceeded (race condition guard)
- Creates a
promotionApplicationrecord - Atomically increments
currentUsescounter
Design Decisions
- JSON conditions/benefits: Stored as JSONB, allowing flexible rule evolution without schema migrations
- Soft deletes: Promotions with application history transition to "expired" instead of being deleted
- Atomic counters:
incrementCurrentUsesuses SQLSET currentUses = currentUses + 1to prevent race conditions - Reconcile-on-read: Status transitions happen lazily when promotions are queried, not via a scheduled job
- Dependency inversion: Repositories injected via Symbol tokens, services depend only on domain interfaces
Related
- Discount Architecture — base discount system
- Promotions Design Doc — original design decisions
- Bruno Collection — API request collection