Happy House - Ecommerce Docs
Developer ResourcesPromotion

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

SurfaceRoutePermission
CustomerPOST /api/mobile/promotions/evaluate
AdminGET /api/admin/promotionsPromotions_READ
AdminGET /api/admin/promotions/:promotionIdPromotions_READ
AdminPOST /api/admin/promotionsPromotions_CREATE
AdminPATCH /api/admin/promotions/:promotionIdPromotions_UPDATE
AdminDELETE /api/admin/promotions/:promotionIdPromotions_DELETE
AdminPOST /api/admin/promotions/:promotionId/restorePromotions_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

  1. The lifecycle is DERIVED, never stored. state holds only what an operator chose — draft, published, disabled. scheduled, active and expired are computed from starts_at/ends_at against 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.
  2. 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.
  3. shippingDiscount: null is "not yet knowable", not zero — the difference between "free shipping unlocked — choose an address" and "worth nothing".
  4. Percentages are BASIS POINTS. 1000 is 10.00%, 750 is 7.5%. Whole percent cannot express a 7.5% campaign, and nothing money-adjacent in this system is a float.
  5. coupon_code IS NULL IS "automatic". There is no isAutomatic flag 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.
  6. Redemption has NO HTTP surface. reserve/confirm/release exist, 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 why release accepts a confirmed redemption.

Page guide

PageRead it for
Features and flowsActor journeys, the derived lifecycle, rejection reasons, edge cases
BackendThe gate SQL, the ledger-gated release, the two limits on expiry, reconciliation
APIAll seven endpoints, DTOs and error codes