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

4.6 KiB

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