The customer gets the best available discount on their basket, with a clear reason when a coupon does not apply; the operator schedules and manages campaigns without a single "activate" job
Actors
Customer (signed in), admin, checkout (future)
Main entry points
POST /api/mobile/promotions/evaluate, /api/admin/promotions (6 routes)
Main outputs
Evaluation with applied/rejected promotions, reasons, savings; admin CRUD with a derived lifecycle badge
A customer enters a code at checkout. The backend normalises it (case-insensitive, whitespace-tolerant), evaluates it against the live cart, and returns applied + rejected promotions with reasons. Evaluation is a preview — nothing is redeemed, and no usage limit is consumed by loading a page.
There is no "activate" button and no "expire" button. Publishing with a future start date IS scheduling. The admin form edits state; status is a read-only badge — a form that tries to PATCH a status is rejected.
Eligible promotions sort by (priority DESC, computed discount DESC, id ASC). With every promotion at the default priority of 0, the customer gets best-value by default; a merchant raises priority to override. The id tiebreaker makes the order total — without it two equal candidates are ordered by whatever the planner returned. At most one promotion per type: one cart discount and one free shipping, never two of either.
Permission Promotions_CREATE, ADMIN_WRITE 10/min. Field rules the database enforces anyway (a 409 is a worse experience than a disabled submit button): promotionType ↔ discountType must agree (free_shipping on one requires it on the other); fixed_amount requires discountAmount and forbids discountPercentageBps; percentage requires discountPercentageBps (1–10000) and forbids discountAmount; free_shipping forbids both; maxDiscountAmount is percentage-only; omit couponCode for automatic (there is no isAutomatic flag); endsAt strictly after startsAt; usageLimitPerCustomer ≤ usageLimit.
PATCH requires version. A concurrent edit makes it stale → 409 PROMOTION_VERSION_CONFLICT with the current version in the message. Re-fetch and re-apply.
DELETE is a soft delete. Redemption history is kept — a hard delete of anything redeemed is refused by the database — and the coupon code is released for reuse, so next year's campaign can reuse this year's code. Restore can fail with 409 PROMOTION_COUPON_CODE_TAKEN if another promotion claimed the code while this one was retired.
Covered in §5.2 — the only stored transitions are draft → published, published → disabled, disabled → published, published → draft (editable). Everything else is derived from the clock.