Promotion Module Overview
Which discount applies to a basket and why — evaluation, the derived lifecycle, and the redemption contract checkout inherits.
Audience: Product owners, QA, frontend and backend developers Scope: The promotion engine, its derived lifecycle, and the redemption contract checkout inherits
Promotion Module - Overview
1. What the module is
The promotion engine answers "what discount applies to this basket, and why". One customer route evaluates promotions against the caller's live cart; six admin routes manage them. Cart discounts and free shipping only — the engine takes new promotion types without changing any API.
Nothing is redeemed by any HTTP call. Evaluation is a preview. A promotion's usage limit is only consumed at checkout, which does not exist yet.
The cart module was not modified — zero files. Promotion depends on cart, never the reverse. GET /api/mobile/cart returns exactly what it returned before — the storefront calls the two endpoints separately and composes them.
2. The routes
| Surface | Route | Permission |
|---|---|---|
| Customer | POST /api/mobile/promotions/evaluate | — |
| Admin | GET /api/admin/promotions | Promotions_READ |
| Admin | GET /api/admin/promotions/:promotionId | Promotions_READ |
| Admin | POST /api/admin/promotions | Promotions_CREATE |
| Admin | PATCH /api/admin/promotions/:promotionId | Promotions_UPDATE |
| Admin | DELETE /api/admin/promotions/:promotionId | Promotions_DELETE |
| Admin | POST /api/admin/promotions/:promotionId/restore | Promotions_RESTORE |
There is no customer list or detail route, deliberately: publishing the catalogue would hand out every coupon code the merchant is running. Customers receive promotions that apply; they do not browse them.
3. The rules that matter
- The lifecycle is DERIVED, never stored.
stateholds only what an operator chose —draft,published,disabled.scheduled,activeandexpiredare computed fromstarts_at/ends_atagainst the clock. A stored status would need a background job to flip it — and between the boundary instant and that job's next run, a campaign that ended last night would still pay out this morning. There is no "activate" or "expire" button: publishing with a future start date IS scheduling. - An ineligible coupon is a 200, not a 4xx. "This coupon does not apply to your cart" is a successful answer. Branch on
eligibility.valid/eligibility.reason, never the status code. shippingDiscount: nullis "not yet knowable", not zero — the difference between "free shipping unlocked — choose an address" and "worth nothing".- Percentages are BASIS POINTS.
1000is 10.00%,750is 7.5%. Whole percent cannot express a 7.5% campaign, and nothing money-adjacent in this system is a float. coupon_code IS NULLIS "automatic". There is noisAutomaticflag and there must never be one — two facts that can disagree produce either a coupon nobody can redeem or a discount applied to every cart by accident.- Redemption has NO HTTP surface.
reserve/confirm/releaseexist, are tested against a real database, and are called by nobody — they are the contract checkout inherits. Whichever module reserves must own the release edge, including the order-cancellation path, which is whyreleaseaccepts a confirmed redemption.
Page guide
| Page | Read it for |
|---|---|
| Features and flows | Actor journeys, the derived lifecycle, rejection reasons, edge cases |
| Backend | The gate SQL, the ledger-gated release, the two limits on expiry, reconciliation |
| API | All seven endpoints, DTOs and error codes |