Saltar al contenido principal

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:

LayerResponsibility
DomainRepository interfaces (IPromotionsRepository, IPromotionApplicationsRepository). No framework deps.
ApplicationBusiness logic: CRUD (PromotionsService), evaluation (PromotionEvaluatorService), event handling.
InfrastructureKysely database queries, injected via PROMOTIONS_REPOSITORY / PROMOTION_APPLICATIONS_REPOSITORY tokens.
InterfacesHTTP controller, DTOs with class-validator + Swagger decorators.

Domain Concepts

Promotion Scopes

ScopeDescriptionExample
linePer-item discount on qualifying items20% off all items in "Blue" category
cartWhole-cart discount$10 off orders over $100
bundleBuy X get YBuy 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]
}
  • itemFilter uses OR logic across dimensions (category OR product OR variant)
  • rewardItemFilter is 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 locationIds apply everywhere
  • Promotions with specific locationIds only apply at those locations

Usage Limits

  • maxUses: global limit across all customers
  • maxUsesPerCustomer: per-customer limit
  • currentUses: atomic counter, incremented on each application

API Endpoints

MethodPathDescription
POST/promotions/evaluateEvaluate cart against active promotions
GET/promotions/applications/summaryAggregated usage stats
POST/promotionsCreate promotion
GET/promotionsList promotions (filterable)
GET/promotions/:idGet promotion detail
GET/promotions/:id/applicationsPaginated application history
PATCH/promotions/:idUpdate promotion
PATCH/promotions/:id/statusTransition status
DELETE/promotions/:idDelete (hard or soft)

All endpoints require businessId query parameter and Bearer auth.

Evaluation Flow

  1. Reconcile time-based status transitions
  2. Fetch active promotions for business + location
  3. Optionally include preview promotions (draft/scheduled)
  4. For each promotion: check usage limits, time window, days of week
  5. Evaluate per scope (line/cart/bundle)
  6. 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:

  1. Verifies usage limits haven't been exceeded (race condition guard)
  2. Creates a promotionApplication record
  3. Atomically increments currentUses counter

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: incrementCurrentUses uses SQL SET currentUses = currentUses + 1 to 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