Promotion Backend Documentation
Backend architecture, data model, services, and operational behavior for the Promotion module.
Promotion - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/promotion/ module files | Customer + admin leaves, shared module |
| Controllers | customer/promotion-customer.controller.ts, admin/promotion-admin.controller.ts | Routes, permissions, rate limits |
| Services | shared/promotion-evaluation.util.ts, promotion-evaluation.service.ts, promotion-redemption.service.ts, promotion-reconciliation.service.ts | Derived lifecycle, gate, ledger release, reconciliation |
| Schema | packages/db/src/schema/promotion/{promotion,promotion-redemption,enums}.ts | Rules as columns, basis points, CHECKs |
| Error registry | apps/api/src/common/types/error-codes.ts (// PROMOTION) | PROMOTION_* codes |
2. Backend Scope and Boundaries
Owns
- The
promotionandpromotion_redemptiontables and the seven routes. - Evaluation (derived lifecycle, eligibility, selection), and the redemption contract (
reserve/confirm/release) that checkout inherits.
Does Not Own
- The cart — promotion depends on cart, never the reverse (zero cart files changed).
- Checkout's edges: the module writes no
checkout_lockedand consumes no cart slot. It only exposes the redemption methods checkout will call. - Shipping-method, district or customer-group rules — no such entities exist; the scope enum deliberately has no member nothing can produce.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Operator choice | promotion.state (draft/published/disabled) | |
| Effective lifecycle | Derived from state + starts_at/ends_at vs the clock | Never stored |
| Redemptions | promotion_redemption ledger | usage_count is its denormalisation |
| Rules | Columns on promotion, tied by CHECKs | No EAV table |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
PromotionCustomerModule | Leaf | customer/ | PromotionCustomerController | evaluation service | — | The evaluate route |
PromotionAdminModule | Leaf | admin/ | PromotionAdminController | admin service | — | CRUD |
PromotionSharedModule | Leaf | shared/ | None | evaluation util, redemption, reconciliation | Services | Shared engine + redemption contract |
4. File and Directory Map
apps/api/src/modules/promotion/
customer/
promotion-customer.controller.ts
promotion-customer.service.ts
dto/
admin/
promotion-admin.controller.ts
promotion-admin.service.ts
dto/
shared/
promotion-evaluation.util.ts # pure: lifecycle, eligibility, selection order
promotion-evaluation.service.ts # orchestrates cart + promotions
promotion-evaluation.types.ts
promotion-redemption.service.ts # the gate and the upsert (reserve/confirm/release)
promotion-reconciliation.service.ts# one-statement usage_count repair
promotion.constants.ts
packages/db/src/schema/promotion/
promotion.ts promotion-redemption.ts enums.ts
packages/db/src/migrations/0009_promotion.sqlKey files:
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/promotion-evaluation.util.ts | Pure engine | lifecycle derivation, eligibility, selection | No NestJS imports |
shared/promotion-redemption.service.ts | The gate + ledger | reserve, confirm, release | Called by nobody yet |
shared/promotion-reconciliation.service.ts | Counter repair | reconcile | One statement, must stay one |
5. Data Model
5.1 Schema Source
packages/db/src/schema/promotion/
promotion.ts promotion-redemption.ts enums.ts5.2 Tables
promotion
Rules are columns, not an EAV table — every rule the engine can evaluate is a typed column with a CHECK.
| Column | Type | Notes |
|---|---|---|
id / public_id | serial / uuid v7 | |
state | promotion_state enum | draft/published/disabled — the only stored lifecycle |
starts_at / ends_at | timestamptz | ends_at strictly after starts_at when both present; omitted ends_at = open-ended |
promotion_type / discount_type | enums | free_shipping on one requires it on the other (CHECK) |
discount_amount | bigint | minor units; fixed_amount requires it, percentage/free_shipping forbid it |
discount_percentage_bps | integer | basis points (1000 = 10.00%); percentage requires it (1–10000), others forbid it |
max_discount_amount | bigint | percentage only — a cap on a fixed amount is a smaller fixed amount |
shipping_discount_amount | bigint | The free-shipping waiver in minor units |
coupon_code | varchar | IS NULL = automatic — no isAutomatic flag, ever; 3–64 chars, upper-cased, unique among live rows |
min_order_amount / max_order_amount | bigint | Minor units |
min_quantity | integer | Units in the cart |
usage_limit / usage_limit_per_customer | integer | per_customer <= usage_limit (CHECK) |
usage_count | integer | Denormalised from the ledger; CHECK <= usage_limit |
priority | integer | Default 0; selection (priority DESC, value DESC, id ASC) |
combination_mode | enum | stackable / exclusive |
version | integer | Optimistic lock — required on PATCH |
deleted_at | timestamptz | Soft delete; releases the coupon code |
Why two nullable value columns instead of one polymorphic discount_value: a single column would be minor units in one row and basis points in the next; the version that reads it wrong is off by a factor of a hundred and still looks like a plausible price. The CHECK tying each column to discount_type makes the unit part of the schema.
Why basis points: whole percent cannot express a 7.5% campaign, and nothing money-adjacent in this system is a float.
promotion_redemption
The ledger. One row per reservation; status reserved / confirmed / released; expires_at for lapsed holds; unique per (promotion_id, customer_id) for the per-customer limit. ON DELETE RESTRICT from promotion — a hard delete of anything redeemed is refused (SQLSTATE 23001, not 23503; 23503 is what NO ACTION raises and code that checks only that would let this fall through as a 500).
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 PromotionEvaluationService / PromotionEvaluationUtil
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
evaluate() | POST /promotions/evaluate | cart, promotions, district | — | — | SHIPPING_DISTRICT_NOT_FOUND (bad explicit district) |
| util: lifecycle | evaluate | — | — | — | — |
| util: eligibility + selection | evaluate | — | — | — | — |
The lifecycle mapping (derived, never stored):
state | clock | status |
|---|---|---|
draft | — | draft |
disabled | — | disabled |
published | now < starts_at | scheduled |
published | inside the window | active |
published | now ≥ ends_at | expired |
The reason it is derived: a stored status needs a background job to flip it, and between the boundary instant and that job's next run the value is wrong — in a discount engine that means a campaign that ended last night still paying out this morning. There is consequently no "activate" or "expire" job; publishing with a future start date IS scheduling.
Selection is total: (priority DESC, computed discount DESC, id ASC). With every promotion at default priority 0 this is best-value by default; a merchant raises priority to override; the id tiebreaker removes planner dependence.
Eligibility respects the cart's own rules: only valid lines count toward subtotal/quantity thresholds, matching how the cart computes its own subtotal. At most one promotion per type. usageRemaining is reported only for a coupon the customer named, never for automatic promotions.
6.2 PromotionRedemptionService — the contract checkout inherits
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
reserve() | nobody yet | promotion + ledger | ledger row, usage_count | cache clear | PROMOTION_USAGE_LIMIT_REACHED, PROMOTION_CUSTOMER_LIMIT_REACHED, PROMOTION_NOT_ELIGIBLE |
confirm() | nobody yet | redemption | status | cache clear | — |
release() | nobody yet (incl. order cancellation) | redemption (even confirmed) | status, usage_count | cache clear | — |
The reservation gate is one statement doing four jobs, and it carries its own eligibility predicates:
UPDATE promotion SET usage_count = usage_count + 1, updated_at = now()
WHERE id = $1 AND state = 'published' AND deleted_at IS NULL
AND starts_at <= now() AND (ends_at IS NULL OR ends_at > now())
AND (usage_limit IS NULL OR usage_count < usage_limit)
RETURNING usage_count;Not only usage_limit — also state, deleted_at and the date window are evaluated under the row lock against the database clock. Evaluation runs against a caller-supplied timestamp OUTSIDE the lock, so between evaluation and reservation an operator can disable or delete the campaign, or its end date can pass; without these predicates the reservation is still taken — a discount granted for a promotion that was pulled. The first version of this module had exactly that hole and a review caught it.
Release is gated on the LEDGER transition, not on the counter. usage_count > 0 is a floor, not idempotency: a retried release — ordinary under at-least-once delivery and plain HTTP retries — would decrement twice and hand out a slot nobody reserved. That drift is downward, which chk_promotion_usage_count_within_limit does not bound — a campaign capped at 100 could redeem 100+N times with nothing to notice. This was the second review blocker.
The two usage limits behave differently when a hold lapses. The per-customer limit is a live count(*) filtered on expires_at > now(), so an expired hold stops counting for free. The TOTAL limit is the denormalised usage_count, and nothing decrements it when a hold merely lapses — so the sweep job is not housekeeping: it is what stops a campaign capped at 100 being permanently exhausted by 100 abandoned checkouts. Getting only the first half right is what the original plan did.
6.3 PromotionReconciliationService
UPDATE promotion
SET usage_count = (SELECT count(*) FROM promotion_redemption WHERE ... )
WHERE usage_count <> (SELECT count(*) FROM promotion_redemption WHERE ... )One statement, and it must stay one. Splitting it into read-then-write lets a concurrent reserve's increment be discarded, and the resulting counter reads LOW — which the gate then admits redemptions past. The CHECK does not help here: it bounds overshoot, and this failure is undershoot.
7. Runtime Flows
7.1 Evaluate
7.2 Reserve (future — checkout calls this)
8. Cache
| Cache | TTL | Notes |
|---|---|---|
| Automatic promotion set | 30s (CACHE_TTL.VOLATILE) | The only cached read |
The coupon lookup is deliberately NOT cached while the automatic set is. Caching a coupon lookup would mean a coupon an operator just disabled kept paying out for thirty seconds — the one staleness window anyone holding the code can exploit. Evaluation results are never cached at all: a discount shown against a cart that has since changed is not a discount.
9. Jobs and Workers
The sweep job for lapsed holds (reservations past expires_at whose total-limit slots must be returned) — see §6.2. There is no lifecycle-flip job by design. Redemption methods themselves are synchronous and called by nobody yet.
10. Security and Authorization
- Customer evaluate:
JwtAuthGuard,CUSTOMER_COUPON_ATTEMPT20/min account-keyed — a coupon code is a bearer value, so this is a credential-attempt budget wearing a read's clothing, but generous enough that a customer retrying after fixing their cart is never locked out. - Admin:
JwtAuthGuard+RoleGuardwithPromotions_READ/CREATE/UPDATE/DELETE/RESTORE;ADMIN_READ30/min,ADMIN_WRITE10/min. - Anti-disclosure: a rejected
draft,disabledornot_startedpromotion comes back blank — emptyname,promotionType,discountType,combinationMode,description: null— because those campaigns were never public.expiredis not blanked: that campaign WAS public, and naming it is what makes support answerable.failedRulesis empty for lifecycle rejections for the same reason — a campaign the customer cannot use does not disclose its thresholds. - Coupon codes are normalised (upper-cased, trimmed) before lookup; a single string, never an array — "coupon + coupon is not allowed" is enforced by the shape of the request.
11. What is NOT built, and why
- No shipping-method rule — no such entity exists.
- No district or customer-group rules — no such entity exists.
- No product/category/brand scopes — the scope enum deliberately has no member nothing can produce.
- No customer-facing promotion browsing — the specification forbids it; publishing the catalogue would hand out every coupon code.