# ADR-0020: Promotions and coupons are one entity with one engine - **Status:** Accepted - **Date:** 2026-08-12 ## Context A storefront needs two things that look different and are not: promotions that apply themselves ("20% off jackets this week") and coupons that apply when someone types a code ("SUMMER20"). Modelled separately they get separate tables, separate admin screens and separate rule evaluation. Then both have to answer the same questions — does this stack with that, what happens when the total would go negative, which wins when two apply, how is a percentage rounded — and the two answers drift. The drift is not caught by tests, because each engine is self-consistent. It is caught by a customer whose total is wrong. ## Decision **One `Discount` entity with a `trigger`.** `AUTOMATIC` applies on its own; `CODE` requires a code. Everything else — type, scope, value, window, usage limit, stacking, priority — is shared, because it genuinely is. **The arithmetic is a pure function.** `discount-engine.ts` takes a snapshot of candidates and lines and returns amounts. No database, no clock, no I/O. This is the one place in the system where a rounding mistake is a financial one, so it is the one place that can be exhaustively tested without a database — and it is. **Eligibility is resolved outside it.** Whether a discount is live, within its window, or has uses left depends on state the engine deliberately cannot see. `PromotionsService` answers those and hands the engine a decided list. **Stacking is `stackable` plus `priority`.** Ascending priority, then id, so the outcome never depends on the order rows came back in. A non-stackable discount that applies ends evaluation; one that would apply after another already has is rejected as `NOT_COMBINABLE`. **Percentages floor, never round.** Rounding up hands out a fraction of a đồng the merchant never agreed to, on every order. **Each discount applies to what is left, not the original subtotal.** Two 50% offers take 75%, not 100%. **Usage limits are claimed with a conditional UPDATE**, guarded on the limit — the same shape as stock reservation (ADR forthcoming in §15 of architecture.md). Two shoppers redeeming the last use simultaneously must not both win. **Redemptions are recorded with a snapshot amount**, and cancelling an order hands its uses back. ## Consequences One admin screen, one permission, one set of rules to reason about. A coupon is a promotion that needs a code typed, and the data model says so. `coupon.manage` was deleted from the permission catalog rather than left unused. A permission nobody checks is worse than no permission: it reads as a capability a role can be granted, and the first person to grant it will expect it to do something. The one place the merge is visible as a compromise is the editor, where `trigger` is the first control on the form — it decides whether the rest of the dialog reads as "a promotion that runs by itself" or "a code a shopper types", so it cannot sit further down. Status on the list is derived, never `isActive` alone. A discount that expired last week or burned its last use is still `isActive: true`, and a screen that reports a promotion as running when it is not is worse than no screen. The engine's purity is what makes the money maths trustworthy: twelve tests cover flooring, over-discounting, compounding, determinism and empty carts without touching Postgres. The cost is that some fields are meaningless for some triggers — an automatic promotion has no `code`. That is enforced in validation rather than by the schema, which is the usual trade for avoiding two near-identical tables. The editor disables the field rather than hiding it, so the rule is visible instead of mysterious. Retiring is a soft delete, because `DiscountRedemption.discount` is `onDelete: Restrict`: an order that received a discount must keep pointing at the thing it received. So a retired discount stops applying rather than ceasing to exist, and the admin says "Retire" rather than "Delete" for that reason. Per-customer limits are absent: they need customer identity, which arrives with M8. The column is deliberately not there yet rather than present and ignored. ## Alternatives considered **Separate `Promotion` and `Coupon` tables.** Clearer names, two engines that must agree forever. Rejected — the agreement is the hard part, and it does not hold. **A rules DSL stored as JSON.** Maximum flexibility, no type safety, and every rule change becomes a data migration nobody can review. Rejected as premature. **Computing discounts on the client.** Instant feedback, and it makes the total a negotiation. Every price in this system is the API's to decide (ADR-0011).