Stage M7
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user