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).
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR-0021: A review is anchored to an order line
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
|
||||
## Context
|
||||
|
||||
Product reviews are worth having only if they are worth believing. The usual
|
||||
shape — an open form on the product page, with a "verified purchase" badge
|
||||
awarded to reviews the system can match back to an order — gets this backwards.
|
||||
The badge becomes the exception, unverified reviews become the bulk of the
|
||||
content, and moderation becomes a full-time job rather than a screen someone
|
||||
checks.
|
||||
|
||||
The badge is also weaker than it looks. Matching on product + email means
|
||||
anyone who guesses a customer's address can post as them, and anyone who bought
|
||||
once can review every colourway.
|
||||
|
||||
Meanwhile this store has no customer accounts yet (M8). A design that depends
|
||||
on login would mean no reviews until then.
|
||||
|
||||
## Decision
|
||||
|
||||
**`Review.orderLineId` is unique and required.** A review hangs off the exact
|
||||
purchased item, not off a product plus a claim about who is writing.
|
||||
|
||||
This makes "verified purchase" **structural**. There is no unverified review
|
||||
because there is no row to put one in. `isVerifiedPurchase` is sent to the
|
||||
storefront as a constant `true` — not because the check is skipped, but because
|
||||
the schema already made it unfalsifiable.
|
||||
|
||||
The unique constraint gives **one review per item purchased** for free. Buying
|
||||
the same jacket twice earns two reviews; buying it once earns one.
|
||||
|
||||
**Authorisation is the order id plus the email on that order.** The same bar
|
||||
the guest order lookup already sets, and the same capability URL the
|
||||
confirmation page uses (ADR-0019's neighbour: the id is a UUIDv7, unguessable,
|
||||
and holding the link is the authorisation). No account required, which is what
|
||||
lets reviews ship before M8.
|
||||
|
||||
Wrong email and unknown order return the **same 404**, so the endpoint cannot
|
||||
be used to test whether an order exists.
|
||||
|
||||
**Everything starts `PENDING`.** Publication requires a decision. The
|
||||
alternative — publish then moderate — means the first person to read abuse on a
|
||||
product page is a customer.
|
||||
|
||||
**The rating aggregate lives on `products` as `rating_sum` + `rating_count`,**
|
||||
and is **recomputed**, never incremented. Two integers rather than a stored
|
||||
average, because approving one more 4-star review is `sum + 4, count + 1`:
|
||||
exact, and reversible. See Consequences.
|
||||
|
||||
## Consequences
|
||||
|
||||
A shopper cannot review a product they own but bought elsewhere. That is the
|
||||
correct trade here: the alternative is an open submission endpoint, which is a
|
||||
spam surface needing a moderation _team_ rather than a moderation _screen_.
|
||||
|
||||
Reviews work for guests today and keep working when accounts arrive — an
|
||||
`orderLine` already reaches a `customerId` through its order when there is one.
|
||||
|
||||
Recompute-not-increment is what makes a moderator's decision reversible. Every
|
||||
incremental scheme needs a compensating delta per path (approve, reject,
|
||||
un-reject, edit), and the first missed path leaves a product displaying a
|
||||
rating no review supports, with nothing to notice it by. Recomputing from the
|
||||
approved rows is one statement that is correct from any starting state — and it
|
||||
is exercised: rejecting an approved review takes its stars back out.
|
||||
|
||||
Moderation drops the whole catalog cache. `products.rating_*` is part of the
|
||||
read model, `cache-keys.ts` states the contract as "invalidated on write, TTL
|
||||
as a safety net", and without this a moderator approves a review, reloads the
|
||||
product page, sees the old figure for up to five minutes and concludes the
|
||||
button is broken.
|
||||
|
||||
The distribution is sent alongside the average because they answer different
|
||||
questions. A 3.0 of straight 3s is a mediocre product; a 3.0 of 5s and 1s is a
|
||||
product with a sizing problem. Only the bars distinguish them.
|
||||
|
||||
Ratings are constrained `BETWEEN 1 AND 5` in the database as well as in Zod.
|
||||
The column feeds a stored SUM, so one bad row skews a product's average
|
||||
silently and permanently.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Open reviews with a verified badge.** More content, most of it unverifiable,
|
||||
and a badge that a determined poster can forge by guessing an email.
|
||||
|
||||
**Require an account.** Cleanest identity story, and it would have meant no
|
||||
reviews at all until M8 — while making guest buyers, who are most of them,
|
||||
second-class.
|
||||
|
||||
**Store the average directly.** One column instead of two, and it must be
|
||||
recomputed from scratch to stay honest anyway — at which point it is strictly
|
||||
worse than the pair, since it also accumulates floating-point drift.
|
||||
|
||||
**Publish immediately, moderate later.** Faster for the honest majority, and it
|
||||
puts the store's name under whatever the first bad actor writes.
|
||||
|
||||
**Gate on delivery ("only review what arrived").** Correct in principle and
|
||||
currently unknowable: the store has no shipping integration until M10, so
|
||||
"delivered" is not a fact the system holds. Cancelled orders are refused; the
|
||||
rest are allowed, and the gate tightens when the data exists.
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR-0022: Editorial content is Markdown, not a page builder
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
|
||||
## Context
|
||||
|
||||
The store needs a journal and a handful of static pages — returns policy,
|
||||
shipping, about. That is a small requirement with a large gravitational pull:
|
||||
every CMS starts as "just some pages" and ends as a block tree, because the
|
||||
first time an editor wants two columns, someone adds a two-column block.
|
||||
|
||||
Once that happens the storefront's design stops being code. Layout decisions
|
||||
move into the database, the React components become a rendering engine for
|
||||
whatever an editor assembled, and the careful type scale and spacing this
|
||||
project has spent seven milestones establishing become suggestions. That is the
|
||||
outcome this entire project exists to avoid — it is why it is not WooCommerce.
|
||||
|
||||
## Decision
|
||||
|
||||
**One `ContentEntry` with a `type` of `PAGE` or `POST`,** rather than two
|
||||
tables. The shared surface — per-locale slug, title, body, SEO, publish state,
|
||||
soft delete — is nearly all of it. What differs is placement: a POST is listed
|
||||
in a feed newest-first and carries an excerpt and a cover; a PAGE is addressed
|
||||
directly and never listed. Same reasoning as ADR-0020.
|
||||
|
||||
**The body is one Markdown column.** No blocks, no tree, no layout. An editor
|
||||
chooses _what it says_; the storefront decides _what it looks like_.
|
||||
|
||||
**Markdown is rendered to React elements, never to an HTML string.**
|
||||
`react-markdown` parses to a component tree, so `dangerouslySetInnerHTML`
|
||||
appears nowhere in this path. A `<script>` in a body is escaped text because it
|
||||
never becomes a tag — injection is structurally impossible rather than
|
||||
sanitised away, which is the same move as anchoring reviews to order lines
|
||||
(ADR-0021).
|
||||
|
||||
**Every element is mapped explicitly.** Unstyled `<h2>`/`<p>` inheriting
|
||||
browser defaults is precisely how a CMS page ends up looking like a different
|
||||
website. Body `<h1>` is demoted to `<h2>` because the page already renders the
|
||||
title as `<h1>`, and internal links go through the locale-aware `Link` — a raw
|
||||
`<a href="/men">` in a Vietnamese post drops the locale prefix, a bug already
|
||||
fixed three times elsewhere in this storefront.
|
||||
|
||||
**Images in bodies are dropped.** They would bypass the media library, hotlink
|
||||
to arbitrary hosts, and arrive without dimensions — a layout shift on every
|
||||
page they appear on. Posts get one cover image, from the media library, with
|
||||
known dimensions.
|
||||
|
||||
**`publishedAt` is stamped once, on first publish, and never rewritten.**
|
||||
Re-stamping on save would jump a post to the top of the feed because somebody
|
||||
fixed a typo, and would silently change a date a reader may already have cited.
|
||||
|
||||
**Slugs are per-locale**, like products, because `/en/blog/how-to-layer` and
|
||||
`/vi/blog/cach-phoi-do` are the SEO surface. A slug from any locale resolves,
|
||||
then redirects to the canonical one for the locale being browsed.
|
||||
|
||||
**Pages live at `/pages/[slug]`.** Not at the locale root: a catch-all there
|
||||
would compete with `/men`, `/cart` and `/search`, and make every genuine 404
|
||||
ambiguous. `/pages/returns` is uglier than `/returns` and cannot silently
|
||||
shadow a real route.
|
||||
|
||||
## Consequences
|
||||
|
||||
An editor writes in Markdown. That is a real constraint on non-technical staff,
|
||||
and the correct one at this size — the alternative is a rich-text editor whose
|
||||
output must then be sanitised, which is a larger surface than the whole feature.
|
||||
|
||||
Publishing is one decision with two buttons: _Save draft_ and _Publish_. A
|
||||
status dropdown plus Save reads as neither, and publishing is the consequential
|
||||
action.
|
||||
|
||||
Deleting is soft, and also unpublishes. A soft-deleted row still marked
|
||||
`PUBLISHED` is one forgotten `deletedAt: null` away from being live again.
|
||||
|
||||
Translations are replaced wholesale on save rather than upserted per locale, so
|
||||
removing the English version of a post actually stops `/en/blog/<slug>`
|
||||
resolving instead of serving the copy the editor just deleted.
|
||||
|
||||
The `/content/{posts,pages}/slugs` endpoints return titles alongside slugs.
|
||||
They serve three callers — `generateStaticParams`, the footer's page list, and
|
||||
a sitemap when one is built — which is cheaper than three endpoints returning
|
||||
the same rows.
|
||||
|
||||
Homepage blocks are **not** built. The module's original sketch mentioned them;
|
||||
they are the exact feature that turns this into a page builder, and the
|
||||
homepage is better served by code until there is a concrete editorial need.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A block/section tree.** Maximum editorial flexibility, and it hands layout to
|
||||
the database. Rejected — see Context.
|
||||
|
||||
**MDX with embedded components.** Lets a post drop in a product carousel, and
|
||||
makes content executable code that must be built and deployed. Rejected for
|
||||
content authored through an admin UI at runtime.
|
||||
|
||||
**HTML stored directly with sanitisation.** Familiar to editors, and every
|
||||
sanitiser is a denylist that someone eventually gets past. Rendering to React
|
||||
elements needs no denylist.
|
||||
|
||||
**Separate `pages` and `blog_posts` tables.** Clearer names, two admin screens,
|
||||
and two sets of publishing rules that must agree forever.
|
||||
@@ -28,6 +28,9 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
|
||||
| [0017](./0017-shadcn-for-infrastructure-hand-built-for-brand.md) | shadcn/ui for infrastructure, hand-built for brand | Accepted |
|
||||
| [0018](./0018-orders-snapshot-everything-they-display.md) | Orders snapshot everything they display | Accepted |
|
||||
| [0019](./0019-order-placement-is-idempotent-by-client-key.md) | Order placement is idempotent by client key | Accepted |
|
||||
| [0020](./0020-promotions-and-coupons-are-one-entity.md) | Promotions and coupons are one entity with one engine | Accepted |
|
||||
| [0021](./0021-a-review-is-anchored-to-an-order-line.md) | A review is anchored to an order line | Accepted |
|
||||
| [0022](./0022-editorial-content-is-markdown-not-a-page-builder.md) | Editorial content is Markdown, not a page builder | Accepted |
|
||||
|
||||
## Decisions deliberately NOT recorded yet
|
||||
|
||||
|
||||
Reference in New Issue
Block a user