100 lines
4.6 KiB
Markdown
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).
|