Happy House - Ecommerce Docs
Developer ResourcesPromotion

Promotion Backend Documentation

Backend architecture, data model, services, and operational behavior for the Promotion module.

Promotion - Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/promotion/ module filesCustomer + admin leaves, shared module
Controllerscustomer/promotion-customer.controller.ts, admin/promotion-admin.controller.tsRoutes, permissions, rate limits
Servicesshared/promotion-evaluation.util.ts, promotion-evaluation.service.ts, promotion-redemption.service.ts, promotion-reconciliation.service.tsDerived lifecycle, gate, ledger release, reconciliation
Schemapackages/db/src/schema/promotion/{promotion,promotion-redemption,enums}.tsRules as columns, basis points, CHECKs
Error registryapps/api/src/common/types/error-codes.ts (// PROMOTION)PROMOTION_* codes

2. Backend Scope and Boundaries

Owns

  • The promotion and promotion_redemption tables 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_locked and 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

ConcernSource of TruthNotes
Operator choicepromotion.state (draft/published/disabled)
Effective lifecycleDerived from state + starts_at/ends_at vs the clockNever stored
Redemptionspromotion_redemption ledgerusage_count is its denormalisation
RulesColumns on promotion, tied by CHECKsNo EAV table

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
PromotionCustomerModuleLeafcustomer/PromotionCustomerControllerevaluation serviceThe evaluate route
PromotionAdminModuleLeafadmin/PromotionAdminControlleradmin serviceCRUD
PromotionSharedModuleLeafshared/Noneevaluation util, redemption, reconciliationServicesShared 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.sql

Key files:

FilePurposeKey ExportsNotes
shared/promotion-evaluation.util.tsPure enginelifecycle derivation, eligibility, selectionNo NestJS imports
shared/promotion-redemption.service.tsThe gate + ledgerreserve, confirm, releaseCalled by nobody yet
shared/promotion-reconciliation.service.tsCounter repairreconcileOne statement, must stay one

5. Data Model

5.1 Schema Source

packages/db/src/schema/promotion/
  promotion.ts  promotion-redemption.ts  enums.ts

5.2 Tables

promotion

Rules are columns, not an EAV table — every rule the engine can evaluate is a typed column with a CHECK.

ColumnTypeNotes
id / public_idserial / uuid v7
statepromotion_state enumdraft/published/disabled — the only stored lifecycle
starts_at / ends_attimestamptzends_at strictly after starts_at when both present; omitted ends_at = open-ended
promotion_type / discount_typeenumsfree_shipping on one requires it on the other (CHECK)
discount_amountbigintminor units; fixed_amount requires it, percentage/free_shipping forbid it
discount_percentage_bpsintegerbasis points (1000 = 10.00%); percentage requires it (1–10000), others forbid it
max_discount_amountbigintpercentage only — a cap on a fixed amount is a smaller fixed amount
shipping_discount_amountbigintThe free-shipping waiver in minor units
coupon_codevarcharIS NULL = automatic — no isAutomatic flag, ever; 3–64 chars, upper-cased, unique among live rows
min_order_amount / max_order_amountbigintMinor units
min_quantityintegerUnits in the cart
usage_limit / usage_limit_per_customerintegerper_customer <= usage_limit (CHECK)
usage_countintegerDenormalised from the ledger; CHECK <= usage_limit
priorityintegerDefault 0; selection (priority DESC, value DESC, id ASC)
combination_modeenumstackable / exclusive
versionintegerOptimistic lock — required on PATCH
deleted_attimestamptzSoft 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

MethodCalled ByReadsWritesSide EffectsErrors
evaluate()POST /promotions/evaluatecart, promotions, districtSHIPPING_DISTRICT_NOT_FOUND (bad explicit district)
util: lifecycleevaluate
util: eligibility + selectionevaluate

The lifecycle mapping (derived, never stored):

stateclockstatus
draftdraft
disableddisabled
publishednow < starts_atscheduled
publishedinside the windowactive
publishednow ≥ ends_atexpired

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

MethodCalled ByReadsWritesSide EffectsErrors
reserve()nobody yetpromotion + ledgerledger row, usage_countcache clearPROMOTION_USAGE_LIMIT_REACHED, PROMOTION_CUSTOMER_LIMIT_REACHED, PROMOTION_NOT_ELIGIBLE
confirm()nobody yetredemptionstatuscache clear
release()nobody yet (incl. order cancellation)redemption (even confirmed)status, usage_countcache 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

CacheTTLNotes
Automatic promotion set30s (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_ATTEMPT 20/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 + RoleGuard with Promotions_READ/CREATE/UPDATE/DELETE/RESTORE; ADMIN_READ 30/min, ADMIN_WRITE 10/min.
  • Anti-disclosure: a rejected draft, disabled or not_started promotion comes back blank — empty name, promotionType, discountType, combinationMode, description: null — because those campaigns were never public. expired is not blanked: that campaign WAS public, and naming it is what makes support answerable. failedRules is 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.