Files
web_sport/docs/adr/0020-promotions-and-coupons-are-one-entity.md
T
2026-08-13 23:20:23 +07:00

100 lines
4.6 KiB
Markdown

# 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).