diff --git a/AGENTS.md b/AGENTS.md index bd4760a..badc42a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ MCP servers: Supabase (connected), Stripe (connected), Vercel (connected), Playw - [src/features/cart/AGENTS.md](src/features/cart/AGENTS.md): the signed cart cookie, cart actions and caps, the expired cart cron - [src/features/checkout/AGENTS.md](src/features/checkout/AGENTS.md): the checkout form, `startCheckout` and the Stripe session, the confirmation page - [src/features/orders/AGENTS.md](src/features/orders/AGENTS.md): the Stripe webhook, order transitions, the reconcile cron, the expired order PII purge cron, admin orders +- [src/features/discounts/AGENTS.md](src/features/discounts/AGENTS.md): admin promo code pages, the discount rule and caps, code lock and use counting, redemptions freed on expiry - [src/features/settings/AGENTS.md](src/features/settings/AGENTS.md): the admin settings page, the shipping fee and free delivery threshold - [src/features/account/AGENTS.md](src/features/account/AGENTS.md): customer accounts, sign up and sign in, the customer session and lock, order history, the address book, settings - [src/lib/shipping/AGENTS.md](src/lib/shipping/AGENTS.md): the shared delivery fee rule, address schema and settings reads diff --git a/app/admin/(panel)/discounts/[id]/page.tsx b/app/admin/(panel)/discounts/[id]/page.tsx new file mode 100644 index 0000000..e9d7937 --- /dev/null +++ b/app/admin/(panel)/discounts/[id]/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; + +import { adminMetadata, requireAdmin } from "@/features/admin-auth/require-admin"; +import { getDiscountCodeForEdit } from "@/features/discounts/admin-queries"; +import { DiscountEditor, DiscountNotFound } from "@/features/discounts/components/discount-editor"; +import { env } from "@/lib/env"; + +// Reads the session and params at the top level; allowed to block until converted to Suspense +// (spec 0005, Caching model). +export const instant = false; + +export function generateMetadata(): Promise { + return adminMetadata({ title: "Edit code" }); +} + +export default async function AdminDiscountPage({ params }: PageProps<"/admin/discounts/[id]">) { + await requireAdmin(); + const code = await getDiscountCodeForEdit((await params).id); + if (!code) return ; + return ( + + ); +} diff --git a/app/admin/(panel)/discounts/new/page.tsx b/app/admin/(panel)/discounts/new/page.tsx new file mode 100644 index 0000000..836e958 --- /dev/null +++ b/app/admin/(panel)/discounts/new/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; + +import { adminMetadata, requireAdmin } from "@/features/admin-auth/require-admin"; +import { NewDiscountPage } from "@/features/discounts/components/discount-editor"; +import { env } from "@/lib/env"; + +// Reads the session at the top level; allowed to block until converted to Suspense +// (spec 0005, Caching model). +export const instant = false; + +export function generateMetadata(): Promise { + return adminMetadata({ title: "New code" }); +} + +export default async function AdminNewDiscountPage() { + await requireAdmin(); + return ; +} diff --git a/app/admin/(panel)/discounts/page.tsx b/app/admin/(panel)/discounts/page.tsx new file mode 100644 index 0000000..035c267 --- /dev/null +++ b/app/admin/(panel)/discounts/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; + +import { adminMetadata, requireAdmin } from "@/features/admin-auth/require-admin"; +import { getDiscountCodes } from "@/features/discounts/admin-queries"; +import { DiscountsList } from "@/features/discounts/components/discounts-list"; +import { parseDiscountListParams } from "@/features/discounts/list-params"; +import { env } from "@/lib/env"; + +// Reads the session and search params at the top level; allowed to block until converted to +// Suspense (spec 0005, Caching model). +export const instant = false; + +export function generateMetadata(): Promise { + return adminMetadata({ title: "Discounts" }); +} + +export default async function AdminDiscountsPage({ searchParams }: PageProps<"/admin/discounts">) { + await requireAdmin(); + const params = parseDiscountListParams(await searchParams); + return ( + + ); +} diff --git a/app/admin/(panel)/orders/page.tsx b/app/admin/(panel)/orders/page.tsx index 46ec5da..6829dc3 100644 --- a/app/admin/(panel)/orders/page.tsx +++ b/app/admin/(panel)/orders/page.tsx @@ -1,7 +1,11 @@ import type { Metadata } from "next"; import { adminMetadata, requireAdmin } from "@/features/admin-auth/require-admin"; -import { getAdminOrders, parseAdminOrdersParams } from "@/features/orders/admin-queries"; +import { + getAdminOrders, + parseAdminOrdersParams, + resolveDiscountFilter, +} from "@/features/orders/admin-queries"; import { AdminOrdersList } from "@/features/orders/components/admin-orders-list"; import { env } from "@/lib/env"; @@ -15,12 +19,15 @@ export function generateMetadata(): Promise { export default async function AdminOrdersPage({ searchParams }: PageProps<"/admin/orders">) { await requireAdmin(); - const params = parseAdminOrdersParams(await searchParams); + const { params, discountCode } = await resolveDiscountFilter( + parseAdminOrdersParams(await searchParams), + ); const page = await getAdminOrders(params); return ( ); diff --git a/docs/reviews/2026-10-18-feat-discounts-promo-codes.md b/docs/reviews/2026-10-18-feat-discounts-promo-codes.md new file mode 100644 index 0000000..11ce427 --- /dev/null +++ b/docs/reviews/2026-10-18-feat-discounts-promo-codes.md @@ -0,0 +1,40 @@ +# Review, feat/discounts-promo-codes, 2026-10-18 + +**Reviewed by**: Sonnet 5.5 (author on same family, fresh context) +**Scope**: 71 files, branch vs main (plus uncommitted edits) +**Verdict**: Approve with nits + +## Summary +Adds promo codes end to end: pure rule module, row locked redemption at Pay, one off Stripe coupon per order, redemption release in `markExpired`/`markCancelled`, admin CRUD with lock and stale checks, and display rows. The focus areas hold up: the code row is locked `FOR UPDATE` after the customer and cart locks and before any insert, uses are counted under that lock, the discount is recomputed from DB prices, settings and the DB clock at Pay, and every path that leaves `pending_payment` (checkout restart, reconcile cron, webhook expiry, admin cancel) goes through the two transitions that free the use. No blockers or majors; a few minors. + +## Minor +### 🟑 Hot code serializes every checkout inside a 5 s interactive transaction, `src/features/checkout/actions/start-checkout.ts:381` +**Problem**: The code lock is held until the whole order transaction commits (order, lines, redemption, address save), and the Prisma interactive transaction uses the default 5 s timeout (no override in `src/lib/db.ts`). +**Why it matters**: During a campaign, many concurrent Pays on one code queue on the lock; later ones can hit the transaction timeout and surface as a thrown 500 rather than a result value. +**Suggested fix**: Raise the transaction `timeout`/`maxWait` for this transaction, or move non-essential writes (address save) out of the locked section. Acceptable to defer for a small store, but note it. + +### 🟑 Stripe `amount_total == total_cents` is only asserted against mocks, `tests/db/discounts-checkout.db.test.ts:215` +**Problem**: The invariant "Stripe's amount equals our total" (coupon applies to items, never to the shipping rate) is covered by mocked `stripe.*` calls; the real hosted page check lives in the `@stripe` e2e, which is skipped unless `STRIPE_E2E=1`. +**Why it matters**: If Stripe ever spread `amount_off` over shipping, every discounted order would be flagged with an amount mismatch by the webhook and nothing in CI would catch it. +**Suggested fix**: Run the `@stripe` discount scenario at least once before merge (record it in verify.md), and consider a nightly or manual CI job for it. + +### 🟑 Any Pay refusal expires the customer's earlier open session first, `src/features/checkout/actions/start-checkout.ts:103` +**Problem**: `clearPendingOrder` runs before the code is checked, so a refused code (invalid, expired, customer limit) has already ended the earlier Stripe session and freed its hold. The spec accepts this for the "used up" window only. +**Why it matters**: A customer with an open payment tab who mistypes a code loses that tab's session and, for a last use code, their own held use. +**Suggested fix**: Optional. Document it in the checkout AGENTS.md gotchas, or pre-check the code before clearing when the cart has a pending order. + +## Nits +- βšͺ `src/features/checkout/schemas.ts:29`, `discountCode: z.unknown()` skips the "Zod on every input" rule at the schema; it is validated later by `parseCodeText`, so a comment or `z.string().max(64).optional().catch(undefined)`-style schema would match the convention. +- βšͺ `src/lib/dates.ts:~75`, `zonedDateTimeToUtc` accepts a wall time that does not exist (spring forward gap) and shifts it instead of returning null as its comment says. +- βšͺ `src/features/checkout/actions/start-checkout.ts:196`, reusing a coupon on `resource_already_exists` does not check its `amount_off`; safe today because the id is per order, but one comparison would make that explicit. +- βšͺ `docs/specs/0014-discounts-promo-codes/index.md` AC-19 and Code layout, spec says the migration adds an `orders.discount_code_id` index and a `queries.ts`; the index already existed (migration comment says so) and the file is `admin-queries.ts`. The migration also adds two unlisted FK indexes (reasonable). Update the spec on /sync. + +## Strengths +- Locking discipline is right and documented: customer, cart, then code; admin update and delete take the same code lock first and recheck "has uses" under it, so an edit cannot race a redemption. +- One pure `evaluateDiscount` feeds Apply, prefill and Pay; the AC-2 caps (free delivery threshold, Stripe minimum) are deterministic, integer only, and BigInt in the line split. +- Use accounting covers every exit from pending (all four callers of `markExpired`, plus pending cancel); a paid order keeps its use; stale holds age out by the DB clock. +- Apply leaks only totals or a reason; logs never carry the email or the typed text of an unknown code; every admin page and action calls `requireAdmin()`; no new table so RLS rule is untouched. +- Webhook path is unchanged and idempotent; its existing amount check remains a real alarm because Stripe never computes our discount. + +## Test coverage +Strong. DB tests cover the last use race for total and per email limits, refusal order, lifecycle (expire, cancel, paid kept, restart with limit 1), prefill, Pay re-reading price and shipping from the DB, DB clock expiry, coupon failure and reuse, refund suggestions on discounted orders, and admin create/update/lock/stale/delete/list/filter. Unit tests cover rule edges, split, schemas, snapshot, list params, dates and the session params. E2E covers apply, refusals, Enter runs Apply, axe and keyboard. Gaps: the real Stripe `amount_total` check is gated off by default, and there is no test of lock wait behaviour under many concurrent Pays. diff --git a/docs/scope/scope.md b/docs/scope/scope.md index b38f490..b644db6 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -27,7 +27,7 @@ _These are recommendations to keep your build orderly, not requirements. Skip an | 11 | Order emails | Slice 5 | done | | 12 | Customer accounts | Slice 6 | done | | 13 | Categories, search & filters | Slice 7 | done | -| 14 | Discounts & promo codes | Slice 8 | planned | +| 14 | Discounts & promo codes | Slice 8 | done | | 15 | Admin sales dashboard | Slice 9 | planned | | 16 | Legal pages & cookie consent | Launch | planned | | 17 | SEO for storefront pages | Launch | planned | @@ -264,10 +264,20 @@ spec [0013](../specs/0013-categories-search-filters/index.md) Β· code in [src/fe ## Slice 8: Discounts -### 14. Discounts & promo codes Β· needs a decision Β· GA +### 14. Discounts & promo codes Β· done Β· GA Admin created promo codes (percent or fixed amount, with dates and usage limits), applied at checkout. **Done when:** a valid code lowers the total correctly before payment; expired, used up, or invalid codes are rejected with a clear message; the discount is saved on the order and in refunds. -- [ ] Design it (spec): `/architect discounts & promo codes` +Spec: [0014](../specs/0014-discounts-promo-codes/index.md) Β· code in [src/features/discounts/](../../src/features/discounts/), [src/lib/discounts/](../../src/lib/discounts/) +- [x] Design it (spec): `/architect discounts & promo codes` +- [x] Build it: `/develop discounts & promo codes` + - [x] Thin thread: migration, the pure discount rule and split, code lock, counts and redemption lifecycle, admin create, Apply and Pay with the Stripe coupon, the admin order row, e2e through Stripe test mode (AC-1, AC-2, AC-4 to AC-8, AC-11, AC-14, AC-17, AC-19) + - [x] Refusals and prefill: every refusal message on Apply and Pay, focus and live region, coupon failure, logs, returning to checkout with the code (AC-1, AC-3, AC-4, AC-7, AC-9, AC-18) + - [x] Admin list, edit, delete and the orders filter: uses, discount given, status, search, edit lock, stale check, delete refusal, nav (AC-10, AC-12 to AC-14, AC-17, AC-18) + - [x] Customer views and safety net: code on the complete page, account order page and email, refund check, e2e with axe and keyboard (AC-15, AC-16, AC-20) +- [x] Verify it: `/check verify discounts & promo codes` +- [x] Test it: `/test discounts & promo codes` +- [x] Review it (fresh model): `/check review discounts & promo codes` +- [x] Document it: `/document discounts & promo codes` ## Slice 9: Insights @@ -299,6 +309,7 @@ The last step: ship to Vercel and the production Supabase project, and turn on t - [ ] Build it: `/develop production deploy` - [ ] Admin sign in, from the runbook [docs/runbooks/admin-accounts.md](../runbooks/admin-accounts.md): production Supabase auth settings, Resend SMTP, recovery template, redirect URLs, asymmetric JWT keys, the first real admin (spec 0004 AC-10, AC-11) - [ ] Vercel Firewall rate limit on `POST` to `/admin/sign-in`, `/admin/mfa`, `/admin/forgot-password`: 10 per minute per IP, deny 10 minutes (spec 0004 AC-15) + - [ ] Vercel Firewall rate limit on `POST /checkout` (promo code Apply and Pay), generous enough for real Pay clicks (spec 0014 Follow-up) - [ ] Verify it: `/check verify production deploy` ## Deferred diff --git a/docs/specs/0014-discounts-promo-codes/index.md b/docs/specs/0014-discounts-promo-codes/index.md new file mode 100644 index 0000000..00d1b71 --- /dev/null +++ b/docs/specs/0014-discounts-promo-codes/index.md @@ -0,0 +1,263 @@ +# 0014. Discounts and promo codes + +**Date**: 2026-10-17 +**Status**: Accepted + +## Summary + +Admins create promo codes (a percent or a fixed amount off the items, with optional dates, use limits and a minimum spend) on new `/admin/discounts` pages, and customers type one code on `/checkout`. Apply shows the new total right away, and Pay checks the code again under a database lock, saves the discount on the order and its lines, and holds one use of the code until the order is paid or expires. Stripe's hosted page shows the same discount through a one off coupon (a Stripe discount object) we create per order with our exact amount, so the amount Stripe charges always equals our total. The tables already exist from spec 0002; one small migration adds a note, who created or edited a code, a format check and an index. + +## Requirements + +**User stories**: +- As a customer, I want to type a promo code at checkout and see my new total before I pay, so I know the code worked and what I will be charged. +- As a customer, I want a clear reason when a code does not work, so I can fix it (spend more, try another code) or move on. +- As an admin, I want to create, edit, deactivate and delete promo codes with dates and limits, so I can run promotions without a deploy. +- As an admin, I want to see how often each code was used, how much discount it gave, and which orders used it, so I can judge a promotion. +- As an admin, I want each order and refund to show the code and the discount it got, so support and refunds use the right amounts. + +**Acceptance criteria**: +- **AC-1**: The `/checkout` order summary shows a "Promo code" field (visible label) with an Apply button above the totals. The typed text is trimmed and uppercased. Pressing Enter in the field runs Apply, never Pay. On success the summary shows a "Discount (CODE)" row with the amount as a negative price, recomputes the delivery row and the total (delivery uses `subtotal - discount`, spec 0007 AC-3, so a code can take an order back under the free delivery threshold), shows a Remove button in place of Apply, and the Pay button total matches. Remove drops back to the totals without a code. The result is announced in a polite live region; a refusal shows its message under the field (linked with `aria-describedby`) and focus stays on the field. +- **AC-2**: The discount is worked out on the items subtotal only, at the prices the customer pays (sale prices included); delivery is never discounted. Percent: `round_half_up(subtotal * value / 100)`, in integer math. Fixed amount: `value`. Then, with `S` the subtotal, `fee(x)` the spec 0007 delivery for an items amount `x`, `T` the free delivery threshold (when set) and `M` = `minimumChargeCents(STORE_CURRENCY)`: + 1. `d = min(raw, S)`. + 2. Never cost more than no code: when `T` is set, `S >= T` and `S - d < T`, also try `d2 = S - T` (keeps delivery free) and keep whichever of `d` and `d2` gives the lower `S - x + fee(S - x)`; on a tie keep the larger. + 3. Minimum charge: when `S - d + fee(S - d) < M`, lower `d` to the largest value with `S - d + fee(S - d) >= M`, computed as `S + fee(S - d) - M` and, if that changes `fee`, recomputed once with the new fee (two steps at most, since `fee` only takes two values). + + The summary and the order show the final amount. A code never makes the total higher than the same cart without a code. A cart whose total is already below `M` without a code gets today's `below_minimum` before any code check, on Apply and on Pay. +- **AC-3**: A code is refused with exactly these messages, checked in this order, the first that applies wins: + 1. text not matching `^[A-Z0-9-]{3,32}$` after trim and uppercase, no such code, `active` false, or `starts_at` after now: "This code isn't valid." + 2. `ends_at` at or before now: "This code has expired." + 3. redemptions of the code at or above `max_redemptions`: "This code has been used up." + 4. subtotal below `min_subtotal_cents`: "Add more to use this code." (amount = minimum minus subtotal, formatted in `STORE_CURRENCY`) + 5. redemptions of the code with this order email at or above `per_customer_limit`: "You've already used this code." + 6. the AC-2 caps leave a discount of 0: "This order is too small for this code." + + A redemption counts as a use when its order has ever been paid (`paid_at` set), or is `pending_payment` and was created less than 35 minutes ago (the session lives at most 31). An older pending order, such as one left without a session by a crash or flagged for attention, no longer holds a use. On Apply and the prefill, both counts also leave out the redemption of this cart's own pending order (Pay replaces that order first); Pay counts every use under the code lock, because `clearPendingOrder` has already expired that order. Apply runs check 5 only when the email is known: signed in (the account email), or the email field holds a valid email. Pay always runs all six. Emails are compared after the one shared normaliser (`normalizeEmail` in `src/lib/discounts/schema.ts`: trim, lowercase), which the checkout email schema also uses. Starts are inclusive, ends exclusive, and "now" is the database clock. +- **AC-4**: Pay sends the applied code with the form. Inside the order transaction, after the customer lock and the cart lock, `startCheckout` locks the code row (`SELECT ... FOR UPDATE`), reruns AC-2 and AC-3 against the locked cart, the live `store_settings` and the order email (account email when signed in). On a refusal it returns `discount_refused` with the reason, creates no order, keeps every form field, shows the AC-3 message under the code field, moves focus there, and the summary drops back to the totals without a code. Otherwise it writes the order with `discount_code_id`, `discount_code`, `discount_type`, `discount_value`, `discount_cents`, the lines with their split (AC-6), and one `discount_redemptions` row (code id, order id, lowercased email) in the same transaction. +- **AC-5**: Two checkouts racing for the last use of a code (or the last use for one email) end with exactly one redemption; the other customer gets "This code has been used up." (or "You've already used this code.") and no order. +- **AC-6**: The order discount is split across lines in proportion to each line's `unit_price_cents * quantity`, each share rounded down, then the leftover cents go one each to the lines with the largest remainders (ties by cart line order). The line discounts sum to the order discount, no line discount exceeds its line, and the spec 0002 CHECKs hold (`line_total = unit_price * quantity - discount`, `sum(line_total) = subtotal - discount`, `total = subtotal - discount + shipping`). +- **AC-7**: When the discount is above 0, after the order commits and before the Checkout Session, `startCheckout` creates a Stripe coupon with the fixed `id` `order_`, `amount_off` = `discount_cents`, `currency` = `STORE_CURRENCY`, `duration: "once"`, `max_redemptions: 1`, `name` = the code, `metadata.order_id`, idempotency key `coupon:`, and passes it as the session's `discounts: [{ coupon }]`. A create that answers "already exists" for that id reuses it. The session's `amount_total` equals the order's `total_cents`. If creating the coupon fails, the order is expired with the reason "Stripe coupon could not be created" (which frees the use, AC-8), `checkout.coupon_failed` is logged, and the customer gets `payment_unavailable`. An `amount_too_small` from Stripe on an order with a code is logged as an error (the AC-2 cap should make it impossible) and still answers `below_minimum`. Without a code the session params are unchanged from today. Before building, check with the `stripe-docs` skill whether Checkout Session create accepts an inline coupon in `discounts`; if it does, use that instead of the separate create, and drop the coupon failure path. +- **AC-8**: `markExpired` and `markCancelled` (from `pending_payment`) delete the order's redemption in the same transaction, which frees that use. A paid order keeps its redemption through cancel, refund, undo cancel and account delete. Restarting checkout expires the earlier pending order (and frees its use) before the new order takes one. +- **AC-9**: Coming back to `/checkout` (Stripe's back link, a reload) after a Pay with a code: `checkoutPrefill` also returns the code of this cart's latest order, only when that order is `pending_payment` or `expired` and was created in the last 24 hours (with that order's email as the prefilled email, or the account email when signed in), and the page shows it applied when it still passes AC-3 for the prefilled email, or in the field with its refusal message when it does not. +- **AC-10**: `/admin/discounts` (new "Discounts" entry in the admin nav) lists codes newest first. Search and the status filter apply first (status is derived in the SQL with the database clock, the same rule as `codeStatus`), then at most 200 rows are shown, with "Showing the newest 200 codes" when more match. Uses and discount given come from grouped subqueries in the same query. Columns: code, discount ("10%" or " off", every amount in this spec shown through `formatMoney` in `STORE_CURRENCY` and `STORE_LOCALE`), status (the first that applies of Inactive, Used up, Expired, Scheduled, Active), uses ("12 / 100", or "12" without a limit, counted by the AC-3 use rule, so recent pending holds count and older pending orders do not), starts and ends in `STORE_TIMEZONE` ("No end" when null), discount given (sum of `orders.discount_cents` over orders with the code and `paid_at` set), and an "Orders" link to `/admin/orders?discount=`. A search box matches the code (case insensitive substring) and a status filter narrows the list. Empty state: "No codes yet" with a "New code" button. +- **AC-11**: `/admin/discounts/new` creates a code with: Code (required, trimmed, uppercased, `^[A-Z0-9-]{3,32}$`), Type (Percent or Fixed amount), Value (percent: whole number 1 to 100; fixed: money through `parseMoney`, above 0), Starts at and Ends at (optional `datetime-local`, read in `STORE_TIMEZONE`, stored UTC, end after start), Total use limit and Per customer limit (optional whole numbers above 0), Minimum subtotal (optional money above 0), Active (default on), Note (optional, at most 500 characters). Each invalid field shows its message from *Validation messages*; the first invalid field gets focus. It sets `created_by_admin_id`. A duplicate code shows "A code with this name already exists." on the Code field. +- **AC-12**: `/admin/discounts/[id]` edits a code with the same form. Once the code has any redemption, Code, Type and Value show as read only with the hint "Locked after first use", and the action refuses a change to them ("This code has been used, so its code, type and value can't change."), checked under the code row lock. A form older than the row's `updated_at` is refused with "This code changed since you opened it. Reload to see the latest." Lowering a limit below the current uses is allowed (the code shows Used up). It sets `updated_by_admin_id`. The page also shows the uses, the discount given and the Orders link from AC-10. +- **AC-13**: The edit page offers Delete (with a confirm dialog) only while the code has no redemption. The action rechecks under the code row lock and refuses with "This code has been used. Deactivate it instead." Orders that once pointed at a deleted code (expired or cancelled while pending) keep their snapshot text. +- **AC-14**: `/admin/orders?discount=` lists only orders with that `discount_code_id`, with a removable chip showing the code; an id that is not a uuid or not a code is ignored like the list's other bad params. `/admin/orders/[number]` shows "Discount (SUMMER10, 10%)" or "Discount (SUMMER10, 5.00 off)" from the order's snapshot, linking to the code while it still exists. +- **AC-15**: When `discount_cents` is above 0, `/checkout/complete`, `/account/orders/[number]` and the order confirmation email show the row as "Discount (SUMMER10)" with the amount. This amends spec 0011 AC-4 and spec 0012 AC-12, which showed "Discount" alone. Stripe's hosted page shows the coupon under the code's name. +- **AC-16**: Refunds use the discounted line totals: refunding every unit plus delivery suggests exactly `total_cents`, a partial refund suggests from the discounted `line_total_cents` (spec 0010), and the order keeps its discount snapshot after any refund. +- **AC-17**: Every `/admin/discounts` page and action calls `requireAdmin()`. Apply is public, reads the cart only from the signed cookie and prices, settings and code rows only from the database, and its answer carries only the AC-1 totals or an AC-3 refusal, never a code's limits, counts or note. +- **AC-18**: Logs: `discount.created`, `discount.updated` (changed field names, compared with the row read under the code lock), `discount.deleted` (code id, admin id); `checkout.discount_refused` (reason, code id when the code exists); `checkout.started` gains `discountCents` and `discountCodeId`. Never an email, and never the typed text of a code that does not exist. +- **AC-19**: One migration adds `discount_codes.note` (text, nullable, CHECK at most 500 characters), `created_by_admin_id` (uuid, NOT NULL, references `admin_users`, Restrict), `updated_by_admin_id` (uuid, nullable, references `admin_users`, Restrict), CHECK `code ~ '^[A-Z0-9-]{3,32}$'`, and an index on `orders.discount_code_id`. No data change (no code exists yet). +- **AC-20**: `/checkout` with a code applied and with a code refused, `/admin/discounts`, and the new and edit forms pass axe with no violations and work by keyboard alone. + +## Decision + +**Chosen option**: Option 1: Our own codes, checked twice, discount split onto lines, a one off Stripe coupon per order + +Codes live in our database and are checked on Apply and again under a row lock on Pay; the order and its lines carry the discount; Stripe gets a single use amount coupon with our exact amount, so `amount_total` always equals our total. + +**Implementation skills**: `stripe-best-practices` (`stripe/ai`, `.agents/skills/stripe-best-practices/`) Β· `stripe-docs` (`stripe/ai`, `.agents/skills/stripe-docs/`) Β· `prisma-client-api` (`prisma/skills`, `.agents/skills/prisma-client-api/`) Β· `supabase-postgres-best-practices` (`supabase/agent-skills`, `.agents/skills/supabase-postgres-best-practices/`) Β· `shadcn` (`shadcn-ui/ui`, `.agents/skills/shadcn/`) + +**Decisions made in the conversation**: items subtotal only, delivery never discounted Β· code entered on `/checkout` only, held in form state Β· applies on top of sale prices Β· specific refusal messages Β· discount capped to keep the total at Stripe's minimum Β· per customer limit counted by order email Β· all four optional limits (dates, total uses, per customer, minimum subtotal) Β· a pending order holds a use until it expires or is cancelled Β· code, type and value lock after the first use Β· delete only when never used Β· list shows uses, status, discount given and an orders link Β· Pay refuses a code that stopped being valid and keeps the form Β· note, created by and updated by columns plus the index Β· code format `A-Z 0-9 -`, 3 to 32 Β· redemptions kept on account delete Β· one off amount coupon per order on Stripe Β· rate limit through a Vercel Firewall rule at deploy Β· Apply checks the per customer limit when the email is known Β· dates with time in `STORE_TIMEZONE` Β· code shown on the admin order page, account order page, confirmation email and `/checkout/complete` Β· a paid order's use is never given back Β· one page admin list, no paging. + +**Decisions made while writing (with the runner up)**: +- **Percent rounding: half up** on the integer product. Runner up: round down (always in the store's favour); half up matches what a customer works out by hand. +- **Line split: largest remainder** in proportion to line value, ties by cart line order. Runner up: put the whole discount on the most expensive line; proportional keeps partial refunds fair per unit. +- **Pure rules in `src/lib/discounts/`**, admin UI in `src/features/discounts/`. Checkout and the admin both need the rules, and a feature may not import another feature's internals. Runner up: rules inside `src/features/checkout/`, which would force the admin to import checkout internals. +- **One preview function for Apply and the prefill** (`previewDiscount`), so the page render and the button can never disagree. Runner up: the prefill fills the field and the client calls Apply on mount, which flashes the undiscounted total first. +- **Admin edits use the `updated_at` stale check** the order actions use. Runner up: last write wins, which can silently undo another admin's limit change. +- **Orders filter by code id** (`?discount=`), not by code text. Runner up: by text, which breaks for a deleted code whose text is reused. + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). + +## Feature design + +### Code layout + +| Path | Owns | +|---|---| +| `src/lib/discounts/rule.ts` | Pure. `discountCents(code, subtotalCents)` (AC-2 raw amount), `cappedDiscount({ raw, subtotalCents }, settings, minimumCents)` (both caps, delivery recomputed), `splitDiscount(lines, discountCents)` (AC-6), `refusal(code, facts)` (AC-3 order, returns the first reason or null), `codeStatus(code, uses, now)` (AC-10 label), `discountLabel(type, value, currency)` ("10%" / "5.00 off"), `refusalMessage(reason, money)`. | +| `src/lib/discounts/schema.ts` | Pure. `discountCodeTextSchema` (trim, uppercase, format), safe to run twice. | +| `src/lib/discounts/codes.ts` | `import "server-only"`. `findCode(client, text)`, `lockCode(tx, id)` (`FOR UPDATE`), `countRedemptions(client, codeId, { email?, exceptCartId })`, `redeem(tx, { codeId, orderId, email })`, `previewDiscount({ cartId, codeText, email })`. | +| `src/lib/orders/snapshot.ts` | `snapshotOrder(sources, settings, discount)` gains `{ codeId, discountCents } \| null`, already final from `cappedDiscount` (the cap logic lives only in `rule.ts`), and fills the line split; `NO_DISCOUNT_CENTS` goes away. | +| `src/lib/orders/transitions.ts` | `markExpired` and `markCancelled` (pending path) delete the order's redemption (AC-8). | +| `src/features/checkout/actions/apply-discount.ts` | `applyDiscountCode` (AC-1, AC-3). | +| `src/features/checkout/actions/start-checkout.ts`, `schemas.ts`, `stripe-session.ts`, `queries.ts` | `discountCode` input, lock and redeem, coupon and `discounts`, prefill code (AC-4, AC-7, AC-9). | +| `src/features/checkout/components/discount-field.tsx` | The code field, Apply and Remove, live region; the summary rows become a client part fed by the server totals or the Apply answer. | +| `src/features/discounts/` | Admin: `schemas.ts`, `actions/` (`createDiscountCode`, `updateDiscountCode`, `deleteDiscountCode`), `queries.ts` (list with uses and discount given, one code), `components/` (list, form, delete dialog), `log.ts`, `AGENTS.md` later by `/sync`. | +| `app/admin/(panel)/discounts/page.tsx`, `new/page.tsx`, `[id]/page.tsx` | Thin routes. | +| `src/lib/dates.ts` | `zonedDateTimeToUtc(text, timeZone)` and `utcToZonedInput(date, timeZone)` for `datetime-local`. | + +### Data model sketch + +Tables exist from spec 0002; **bold** is new in this spec's migration. + +| Table | Columns this feature uses | Rules | +|---|---|---| +| `discount_codes` | id, code, type `discount_type` (`percent`, `fixed_amount`), value int, starts_at?, ends_at?, max_redemptions?, per_customer_limit?, min_subtotal_cents?, active (default true), **note text?**, **created_by_admin_id uuid β†’ admin_users Restrict**, **updated_by_admin_id uuid? β†’ admin_users Restrict**, created_at, updated_at | code unique; CHECK `code = upper(code)`; **CHECK `code ~ '^[A-Z0-9-]{3,32}$'`**; **CHECK `char_length(note) <= 500`**; percent value 1 to 100, fixed above 0; ends_at > starts_at; limits above 0. Code, type and value never change once a redemption exists (action rule, under the row lock). | +| `discount_redemptions` | id, discount_code_id β†’ discount_codes Restrict, order_id β†’ orders Cascade (unique), email (lowercased), created_at | Inserted by `startCheckout` under the code lock; deleted by `markExpired` and `markCancelled` from `pending_payment`; kept otherwise. Index (discount_code_id, email) exists. | +| `orders` | discount_code_id? β†’ discount_codes SetNull, discount_code?, discount_type?, discount_value?, discount_cents | **Index on discount_code_id**. Snapshot written once at Pay, never changed. | +| `order_lines` | discount_cents, line_total_cents | Split per AC-6. | + +Relationships: code 1:N redemptions; redemption 1:1 order; code 1:N orders (snapshot link); admin 1:N codes created, 1:N codes last edited. RLS is already on both tables with no policies. Migration via `pnpm db:migrate`; the CHECKs go in the migration SQL (Prisma does not model them). The Prisma models gain `note`, `createdByAdminId`, `updatedByAdminId` and the relations, and `Order` gains `@@index([discountCodeId])`. + +### State transitions + +No new order states. A redemption exists while its order is `pending_payment` (a held use) or has ever been paid (a spent use): + +- Pay with a code: no redemption β†’ redemption (same transaction as the order). +- `pending_payment` β†’ `expired` (`markExpired`) or β†’ `cancelled` (`markCancelled`, pending path): redemption deleted. +- `pending_payment` β†’ `paid` and every later move (shipped, delivered, cancelled, undo cancel, refunds): redemption kept. + +Code status (derived, never stored): `active = false` β†’ Inactive; else uses β‰₯ `max_redemptions` β†’ Used up; else `ends_at` ≀ now β†’ Expired; else `starts_at` > now β†’ Scheduled; else Active. + +### API surface + +| Surface | Kind | Key inputs | Key outputs | Auth | Key errors | +|---|---|---|---|---|---| +| `applyDiscountCode` | server action, read only | `code: string` (req), `email: string` (opt, used only when valid) | `{ code, discountCents, shippingCents, totalCents }` | public; reads the same cart the `/checkout` page renders (the account cart when signed in), never merges carts | `below_minimum` (before any code check); `discount_refused` with `reason` and `minimumGapCents` for reason `below_minimum_subtotal`; `cart_changed` (with `merged` when a guest cart still waits to join the account cart) when there is no cart or it cannot be bought | +| `startCheckout` | server action (extended) | today's fields + `discountCode: string` (opt) | `{ url }` | public, cart from cookie | today's codes + `discount_refused` (`reason`, `minimumGapCents`) | +| `/checkout` page | page (extended) | prefill from `checkoutPrefill` | summary with applied code or refusal | public, `noindex` | none new | +| `/admin/discounts` | page | `q`, `status` (`active`, `scheduled`, `expired`, `used_up`, `inactive`) | list per AC-10 | `requireAdmin()` | bad params ignored | +| `/admin/discounts/new`, `/admin/discounts/[id]` | pages | `id` | form | `requireAdmin()` | unknown id: not found | +| `createDiscountCode` | server action | form fields (AC-11) | `{ id }` | `requireAdmin()` | `validation` (`fields`), `code_taken` | +| `updateDiscountCode` | server action | `id`, `updatedAt`, form fields | `{ id }` | `requireAdmin()` | `validation`, `code_taken`, `locked_fields`, `stale`, `not_found` | +| `deleteDiscountCode` | server action | `id` | `{}` | `requireAdmin()` | `used`, `not_found` | +| `/admin/orders` | page (extended) | `discount=` | filtered list, chip | `requireAdmin()` | bad id ignored | +| Stripe `coupons.create` | outbound | per AC-7 | coupon id | secret key | any error β†’ order expired, `payment_unavailable` | + +Refusal reasons (`DiscountRefusal`): `invalid`, `expired`, `used_up`, `below_minimum_subtotal`, `customer_limit`, `too_small`. + +### Validation messages + +| Field | Rule | Message | +|---|---|---| +| Code | empty | "Enter a code." | +| Code | format | "Use 3 to 32 letters, digits or hyphens." | +| Code | taken | "A code with this name already exists." | +| Type | missing | "Choose a type." | +| Value (percent) | not a whole number 1 to 100 | "Enter a whole percent from 1 to 100." | +| Value (fixed) | not money above 0 | "Enter an amount above 0." | +| Ends at | not after Starts at | "The end must be after the start." | +| Starts at, Ends at | not a valid date and time | "Enter a valid date and time." | +| Total use limit, Per customer limit | not a whole number above 0 | "Enter a whole number above 0, or leave it empty." | +| Minimum subtotal | not money above 0 | "Enter an amount above 0, or leave it empty." | +| Note | over 500 characters | "Keep the note under 500 characters." | + +### Value sourcing + +| Action | Value | Source | +|---|---|---| +| `applyDiscountCode`, `previewDiscount` | cart lines and subtotal | cart id from the signed cookie, live variant prices from the DB (no lock; Pay rechecks) | +| same | delivery and total | `orderCharges` (spec 0007) with `readShippingSettings()` and the capped discount | +| same | Stripe minimum | `minimumChargeCents(env.STORE_CURRENCY)` | +| same | the email for check 5 | signed in: `getCustomer().email`; guest: the `email` input when it parses with the checkout email schema, else check 5 skipped | +| same | redemption counts | `countRedemptions` over `discount_redemptions` joined to `orders`, under the AC-3 use rule, leaving out (Apply and prefill only) the row whose order is this cart's `pending_payment` order | +| same | "now" for dates | the DB clock (`now()`) in the query, so Apply and Pay use one clock | +| same | "Add more" | `min_subtotal_cents - subtotal`, formatted with `formatMoney` in `STORE_CURRENCY` and `STORE_LOCALE` | +| `startCheckout` | discount, line split, totals | `snapshotOrder(lockedLines, settings, lockedCode)` inside the order transaction | +| same | order email for the redemption and check 5 | account email when signed in, else the typed email (as today), lowercased | +| same | `discount_code`, `discount_type`, `discount_value` | the locked `discount_codes` row | +| same | coupon `amount_off`, `currency`, `name` | `orders.discount_cents`, `STORE_CURRENCY`, `orders.discount_code` | +| `checkoutPrefill` | the code to show | `orders.discount_code` of the cart's latest order, then `previewDiscount` with the prefilled email | +| `/admin/discounts` | uses | count of `discount_redemptions` per code under the AC-3 use rule (paid, or pending and under 35 minutes old, by the DB clock) | +| same | discount given | `sum(orders.discount_cents)` where `discount_code_id` = the code and `paid_at` is not null | +| same | status | `codeStatus` over the row, its uses and the request time | +| same, forms | dates shown and parsed | `STORE_TIMEZONE` through the new `src/lib/dates.ts` helpers | +| `createDiscountCode`, `updateDiscountCode` | `created_by_admin_id`, `updated_by_admin_id` | `requireAdmin()` result | +| admin order page | discount row text | `orders.discount_code`, `discount_type`, `discount_value`, `discount_cents`, `currency` | +| complete page, account order page, confirmation email | "Discount (CODE)" | `orders.discount_code`, `discount_cents`, `currency` | + +### Key invariants + +- The only place a discount amount is worked out is `src/lib/discounts/rule.ts`; Apply, the prefill and Pay all call it. +- Pay is the truth. Whatever Apply showed, `startCheckout` rechecks under the code lock and charges what it computes there; Stripe's hosted page shows that amount before the customer pays. +- Lock order: customer, then cart, then code. The code lock is taken before the order, line and redemption inserts (whose foreign keys touch the code row). Every writer of a code row (`updateDiscountCode`, `deleteDiscountCode`) takes the code lock first, checks under it, and sets `updated_at = now()` itself (raw SQL skips `@updatedAt`). +- Order of checks on Pay: input validation, cart, `below_minimum` without a code, then the code (AC-3). When Pay's numbers differ from what Apply showed (a price or a setting changed) but nothing is refused, Pay goes on with the new numbers, as it does today for delivery; Stripe's page shows the final total. +- At most one redemption per order (unique `order_id`); a redemption exists only for an order that is `pending_payment` or was paid. +- `discount_cents <= subtotal_cents`, `total_cents >= minimumChargeCents` whenever a code applies, and the spec 0002 total and line CHECKs hold. +- Stripe's `amount_total` equals `orders.total_cents` (the webhook's existing amount mismatch check would flag anything else). +- A code's code, type and value never change after its first redemption, so every order's snapshot matches its code. +- No transaction is held across a Stripe call: the coupon and the session are created after the order commits. + +### Security model + +- Admin pages and actions: `requireAdmin()` in each (the proxy is only a first gate). Codes, limits, counts and notes are admin only. +- Apply and Pay: public. Inputs are the typed code and email only; prices, settings and code rows come from the database, the cart from the signed cookie. Answers carry totals or a refusal reason only. +- Code guessing: the specific messages tell a guesser that a code exists but is expired or used up. Accepted for a small store; slowed by a Vercel Firewall rate limit rule on `POST /checkout` at deploy (Follow-up). +- Personal data: `discount_redemptions.email` is order data, kept with the order (same as `orders.email`); never logged. Expired orders lose their redemption at expiry, before the spec 0008 purge. +- Money: the discount is computed server side only and sent to Stripe as an amount we computed; Stripe never computes our discount. + +### Observability + +`discount.created`, `discount.updated` (changed field names only), `discount.deleted` with code id and admin id; `checkout.discount_refused` with reason and code id when the code exists (Apply and Pay both log it, with `stage: "apply" | "pay"`); `checkout.started` gains `discountCents` and `discountCodeId`; `checkout.coupon_failed` (error) with order id and `stripeCode`. Never the email, never typed text of an unknown code. + +### Configuration required + +None. Stripe keys, `STORE_CURRENCY`, `STORE_TIMEZONE` and `STORE_LOCALE` already exist. + +### Critical test scenarios + +- Happy path: admin creates `SUMMER10` (10%), customer applies it, sees the discount and new total, pays in Stripe test mode, the order has the snapshot and split lines, the admin order page shows "Discount (SUMMER10, 10%)", verifies **AC-1**, **AC-4**, **AC-6**, **AC-7**, **AC-11**, **AC-14** +- Rule edges: percent rounding half up, fixed above subtotal capped, cap to Stripe minimum with delivery recomputed across the free threshold, cap leaving 0, split remainders and ties, verifies **AC-2**, **AC-3**, **AC-6** +- Refusal order: each of the six reasons, and an earlier reason winning over a later one, verifies **AC-3** +- Pay recheck: code deactivated after Apply, Pay refused with the message and no order, verifies **AC-4** +- Concurrency: two transactions racing for the last use and for the last per email use, exactly one redemption, verifies **AC-5** +- Lifecycle: expire and pending cancel delete the redemption; paid cancel, refund and undo cancel keep it; restarting checkout with a per customer limit of 1 still succeeds, verifies **AC-8**, **AC-9** +- Stripe: session params carry `discounts` and `amount_total` equals `total_cents`; coupon failure expires the order and frees the use, verifies **AC-7** +- Admin rules: duplicate code, locked fields after first use, stale form, delete refused when used, verifies **AC-11**, **AC-12**, **AC-13** +- Display: list status labels, uses, discount given, orders filter; complete page, account page and email show the code, verifies **AC-10**, **AC-14**, **AC-15** +- Refund: full refund suggestion equals `total_cents` on a discounted order, including a 100% code with line totals of 0 where only delivery is left to refund, verifies **AC-16** +- Never dearer: a code that would push the subtotal under the free threshold is trimmed so the total is not above the no code total, verifies **AC-2** +- Stale holds: a pending order older than 35 minutes no longer counts as a use, verifies **AC-3** +- Webhook: a paid discounted order records no amount mismatch, verifies **AC-7** +- Auth/permission: a signed out visitor and a customer get the admin gate on every `/admin/discounts` page and action; Apply's answer has no limits or counts, verifies **AC-17** +- Accessibility: axe and keyboard on checkout (applied, refused) and the admin pages, verifies **AC-20** + +## Build plan + +Tracer Bullet: tasks 1 to 5 push one real code through every layer (migration, rule, admin create, Apply, Pay with the lock and redemption, Stripe coupon, webhook paid, admin order page). Later tasks thicken each segment. + +1. [x] Migration for the new columns, CHECKs and index; update the Prisma models; DB test for the format and note CHECKs, satisfies **AC-19** +2. [x] `src/lib/discounts/rule.ts` and `schema.ts` with Vitest tests for every rule edge (rounding, both caps, split, refusal order, status, label); `snapshotOrder` takes the applied code and fills the split, `snapshot.test.ts` updated, satisfies **AC-2**, **AC-3**, **AC-6** +3. [x] `src/lib/discounts/codes.ts` (find, lock, count leaving out the cart's own pending order, redeem, `previewDiscount`); `markExpired` and `markCancelled` delete the redemption; DB tests for the counts, the delete, and the last use race for total and per email limits, satisfies **AC-3**, **AC-5**, **AC-8** +4. [x] `src/features/discounts/`: schemas, `createDiscountCode`, the form and `/admin/discounts/new` with `requireAdmin()`, the `datetime-local` helpers in `src/lib/dates.ts`, Vitest for the schema and action, satisfies **AC-11**, **AC-17** +5. [x] Checkout thread: `applyDiscountCode`, `DiscountField` and the client summary rows; `startCheckout` takes `discountCode`, locks, rechecks, writes the snapshot and the redemption, creates the coupon (check params with the `stripe-docs` skill) and passes `discounts`; `checkoutSessionParams` and its tests; the admin order page discount row; update `tests/db/start-checkout.db.test.ts`; e2e happy path through Stripe test mode (`@stripe`), satisfies **AC-1**, **AC-4**, **AC-7**, **AC-14** +6. [x] Every refusal end to end: messages, `minimumGapCents`, focus and live region on Apply and on Pay's `discount_refused`, the summary dropping back, logs `checkout.discount_refused` and the `checkout.started` fields, coupon failure path, satisfies **AC-1**, **AC-3**, **AC-4**, **AC-7**, **AC-18** +7. [x] Prefill: `checkoutPrefill` returns the code, the page renders it through `previewDiscount`; DB test for the restart with a per customer limit of 1, satisfies **AC-8**, **AC-9** +8. [x] Admin list, edit and delete: queries (uses, discount given, status, search, filter, 200 cap), `updateDiscountCode` (lock, stale check, locked fields), `deleteDiscountCode`, the nav entry, logs; Vitest and DB tests, satisfies **AC-10**, **AC-12**, **AC-13**, **AC-17**, **AC-18** +9. [x] Admin orders `discount` filter and chip, the link from the code pages, satisfies **AC-10**, **AC-14** +10. [x] Customer facing rows: `/checkout/complete`, `/account/orders/[number]`, the confirmation email template and its preview fixture, satisfies **AC-15** +11. [x] Refund check: DB test that a discounted order's full refund suggestion equals `total_cents` and a partial one uses the discounted line totals, satisfies **AC-16** +12. [x] Playwright in `tests/e2e/checkout/` and `tests/e2e/admin/`: apply, remove, each visible refusal, Enter runs Apply, Pay refused after deactivation, admin create, edit lock, delete refusal, list and filter, axe and keyboard on every touched page, satisfies **AC-1**, **AC-3**, **AC-4**, **AC-10** to **AC-13**, **AC-20** + +## Consequences + +**Positive**: +- One rule module feeds Apply, the prefill and Pay, and the database CHECKs from spec 0002 catch any total that does not add up. +- Refunds, emails, the account page and the coming sales dashboard need no discount logic of their own: the discount sits in the order and line snapshot. +- Stripe never computes our discount, so the webhook's amount check stays a real alarm. + +**Negative / tradeoffs**: +- One Stripe coupon is created per discounted checkout, including checkouts never paid. They pile up in the Stripe dashboard (harmless, single use). +- Pay makes one more Stripe call when a code is used, one more thing that can fail (handled by expiring the order). +- Specific messages tell a guesser which codes exist; the rate limit only arrives with the deploy. +- A pending order holds a use for up to 35 minutes, so a code with one use left can show "used up" to a second customer while the first is still on Stripe's page. A delayed payment method that settles after 35 minutes can take a code one over its limit (accepted, rare). +- When a customer restarts checkout, their own hold is freed before Pay takes the lock, so another customer can take the last use in between; the first then sees "used up" (accepted). +- A guest can dodge a per customer limit with a different email, including `+tag` or dotted variants of the same inbox (accepted for a small store). +- Uses of paid orders are never given back, even after a refund, so "Used up" does not reset when orders are refunded. + +**Neutral**: +- `snapshotOrder` gains a third parameter, and every caller and test changes. +- Specs 0011 (AC-4) and 0012 (AC-12) are amended to show the code text. +- Two spec 0002 follow ups are settled here: the discounted total stays above Stripe's minimum (AC-2) and the line split rule (AC-6). + +## Follow-up + +- [ ] Add a Vercel Firewall rate limit rule on `POST /checkout` (where Apply and Pay post) with the production deploy, feature 19 (spec 0001's planned promo rate limit). +- [ ] Tick spec 0002's two feature 14 follow ups once this ships (minimum charge, line split). +- [ ] Feature 15 (sales dashboard) can reuse "discount given" from `src/features/discounts/queries.ts` instead of a second query. +- [ ] If Stripe dashboard clutter from single use coupons ever matters, add a cleanup of coupons whose order expired (not needed now). diff --git a/docs/specs/0014-discounts-promo-codes/rationale.md b/docs/specs/0014-discounts-promo-codes/rationale.md new file mode 100644 index 0000000..8240ab3 --- /dev/null +++ b/docs/specs/0014-discounts-promo-codes/rationale.md @@ -0,0 +1,68 @@ +# 0014. Discounts and promo codes: rationale + +Decision record for [index.md](index.md). `/develop` builds from the index; this file explains why. + +## Context + +BeStore sells in one country and one currency with flat rate delivery (spec 0001), and the scope promises admin created promo codes, percent or fixed amount, with dates and usage limits, applied at checkout. The scope's done condition is strict: a valid code lowers the total correctly before payment, expired, used up or invalid codes are refused with a clear message, and the discount is saved on the order and carried into refunds. This is a GA tier feature because it changes how much money is taken. + +Most of the ground is already laid. Spec 0002 created `discount_codes`, `discount_redemptions` and the `orders.discount_*` snapshot columns, defined the redemption pattern (lock the code row, count, insert) and its race test, and left two questions for this feature: how to keep a discounted total above Stripe's minimum charge, and how to split an order discount across lines. Spec 0006 freezes the cart into a pending order before payment and sends Stripe inline prices, spec 0007 makes delivery depend on `subtotal - discount`, spec 0010 refunds from per line totals, and specs 0011 and 0012 already show a discount row when it is above 0. + +The forces: the amount Stripe charges must equal our order total exactly (the webhook flags any mismatch); the last use of a limited code must never be sold twice; prices and rules must come from the database, never the browser; checkout must stay a single form with no extra pages; and the store has a few admins and a small catalog, so the admin side should be simple. Leaving this undecided would mean the first build guesses the Stripe hand off and the rounding, which are the two places a wrong guess costs money or trips the mismatch alarm. + +## Options considered + +### Option 1: Our own codes, checked twice, discount split onto lines, a one off Stripe coupon per order + +Codes and limits live in our tables. Apply previews the discount; Pay rechecks under the code row lock, writes the discount onto the order and its lines, and holds a use. After the order commits, we create a single use Stripe coupon with our exact amount and attach it to the Checkout Session. + +**Pros**: +- Stripe's `amount_total` equals our total by construction; no rounding gap between two calculators. +- Stripe's page shows a proper discount row under the code's name. +- Limits, dates and per customer rules use the lock pattern spec 0002 already tested. + +**Cons**: +- One extra Stripe call per discounted checkout, and coupons pile up in the Stripe dashboard. +- We own all the discount math and its tests. + +### Option 2: Our own codes, discount baked into line prices + +Same checks, but the discount lowers each line's `unit_amount` sent to Stripe, with no Stripe discount object. + +**Pros**: +- No extra Stripe call and nothing created in Stripe. + +**Cons**: +- A line discount that does not divide by the quantity forces splitting a line into two Stripe lines with different unit prices. +- Stripe's page shows lower prices with no discount row, which looks like a price change, not a promo. + +### Option 3: Mirror each admin code as a Stripe coupon or promotion code + +Each code is created once in Stripe (percent or amount), and either we pass it to the session or Stripe's own promo code box collects it. + +**Pros**: +- Stripe does the math and shows the discount; Stripe's own box needs no field on our side. + +**Cons**: +- Stripe's percent rounding and per line allocation can differ from ours, tripping the amount mismatch check and making our order snapshot disagree with the charge. +- With Stripe's box, the code is applied after our order is frozen, so our order, delivery threshold, limits and redemptions would not know about it until the webhook. +- Two systems hold the code's rules and must be kept in step on every edit. + +### Option 4: Code stored on the cart, applied on `/cart` + +Keep Option 1's Stripe hand off, but store the applied code on the cart so `/cart` and `/checkout` both show it. + +**Pros**: +- Customers see the saving earlier. + +**Cons**: +- A new cart column and a second place to check and clear the code (cart changes, expiry, merges on sign in). +- The per customer limit cannot be checked on `/cart` (no email yet). + +## Rationale + +Option 1 is the only one where the amount Stripe charges is our number by construction. That matters more than anything else here: spec 0006's webhook compares `amount_total` to `orders.total_cents` and flags a mismatch, and spec 0010's refunds work from our line totals, so if Stripe computed the discount (Option 3) every rounding difference would become a flagged order and a refund that does not match the charge. Option 2 also keeps our number, but it hides the discount from the customer on Stripe's page and forces split lines; one coupon call per order is a cheaper price than that. + +Checking twice (Apply, then Pay under the lock) follows the existing checkout rule that Pay reads everything live inside the cart lock (specs 0006, 0007). Apply is only a preview; the lock at Pay is what makes "the last use is never sold twice" true, using spec 0002's tested redemption pattern. Holding the use while the order is pending is what lets the lock work: counting only paid orders would let two customers pay with the last use. + +Keeping the code on `/checkout` only (not Option 4) keeps the cart free of discount state and puts the code next to the email, which the per customer limit needs. The cap to Stripe's minimum and the largest remainder split settle the two questions spec 0002 left open, in a way that never blocks a customer and keeps per unit refunds fair. Rules sit in `src/lib/discounts/` because checkout and the admin both need them and the project forbids one feature importing another's internals. diff --git a/docs/specs/0014-discounts-promo-codes/verify.md b/docs/specs/0014-discounts-promo-codes/verify.md new file mode 100644 index 0000000..8f1ea48 --- /dev/null +++ b/docs/specs/0014-discounts-promo-codes/verify.md @@ -0,0 +1,52 @@ +# Verify: Discounts & promo codes Β· spec 0014 Β· updated 2026-10-17 +_Steps derived from spec 0014 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._ + +## UI / manual +- [x] On `/checkout`, type a valid code in lowercase with spaces, press Apply β†’ the field shows it uppercased, a "Discount (CODE)" row shows a negative amount, Remove replaces Apply, and the Pay button total equals the Total row β†’ AC-1 +- [x] Press Enter in the code field β†’ Apply runs; the page stays on `/checkout` and the payment form shows no errors β†’ AC-1 +- [x] Press Remove β†’ the discount row goes, the totals go back, focus returns to the empty field β†’ AC-1 +- [x] Set a flat fee of 15.00 and a free delivery threshold of 50.00, put 52.00 of items in the cart, apply a 10% code β†’ the discount is trimmed to 2.00 so delivery stays free; the total is never above the total without a code β†’ AC-2 +- [x] Apply a fixed code worth more than the cart with no delivery fee β†’ the total stays at the Stripe minimum (0.50 EUR) β†’ AC-2 +- [x] Apply, one at a time: an unknown code, `10% off`, an inactive code, a code that has not started, an expired code, a used up code, a code with a minimum subtotal above the cart, a code this email already used, and a code the caps leave at 0 β†’ each shows exactly its AC-3 message under the field, linked by `aria-describedby`, focus stays in the field, and the live region announces it β†’ AC-3 +- [x] As a guest, apply a per customer limited code with the email field empty β†’ it applies; type the email that used it and apply again β†’ "You've already used this code." β†’ AC-3 +- [x] Apply a code, deactivate it in `/admin/discounts/[id]`, then press Pay β†’ "This code isn't valid." under the field, focus there, the discount row gone, every form field kept, and no new order for the cart β†’ AC-4 +- [x] Pay with a code (Stripe test mode) β†’ Stripe's page shows the coupon under the code's name and the same total as our summary; the order has the code snapshot, line discounts that sum to the order discount, and one redemption β†’ AC-4, AC-6, AC-7, AC-15 +- [ ] Two browsers race for a code with one use left β†’ exactly one reaches Stripe; the other sees "This code has been used up." β†’ AC-5 +- [x] Leave Stripe's page with the back link β†’ `/checkout` shows the code still applied with the same totals β†’ AC-9 +- [x] Wait for the pending order to expire (or cancel it in the admin) β†’ its redemption is gone and the code's use count drops β†’ AC-8 +- [x] `/admin/discounts` β†’ the Discounts nav entry is there; the list shows code, discount ("10%" or "€5.00 off"), status, uses ("1 / 5" or "1"), starts, ends ("No end"), discount given and an Orders link; search and the status filter narrow it β†’ AC-10 +- [x] `/admin/discounts/new` submitted empty β†’ each field shows its message and the first gets focus; a duplicate code shows "A code with this name already exists." on Code β†’ AC-11 +- [x] Open a used code β†’ Code, type and value are read only with "Locked after first use."; lowering the total limit below the uses saves and shows Used up; a save from a page opened before another save shows the stale message β†’ AC-12 +- [x] A never used code offers Delete with a confirm dialog; a used one has no Delete β†’ AC-13 +- [x] "Orders with this code" β†’ `/admin/orders?discount=` shows only those orders and a removable chip; `?discount=not-a-uuid` is ignored; the order page shows "Discount (CODE, 10%)" linking to the code β†’ AC-14 +- [x] `/checkout/complete`, `/account/orders/[number]` and the confirmation email of a discounted order show "Discount (CODE)" with the amount β†’ AC-15 +- [x] On a discounted paid order, choose every unit plus delivery in the refund form β†’ the suggestion equals the order total; a single line suggests its discounted line total β†’ AC-16 +- [x] A signed out visitor is sent to sign in, and a signed in customer gets the store 404, on all three `/admin/discounts` pages β†’ AC-17 +- [x] Run axe and a keyboard pass on `/checkout` with a code applied and refused, on `/admin/discounts`, and on the new and edit forms β†’ no violations, everything reachable by Tab β†’ AC-20 + +## Value sourcing +- [ ] Change a variant's price after Apply, then Pay β†’ Pay charges the new price with the discount computed on it (cart lines come from the DB, never the browser) β†’ Value sourcing: cart lines and subtotal +- [ ] Change the delivery fee in `/admin/settings` after Apply, then Pay β†’ Stripe's total uses the new fee β†’ Value sourcing: delivery and total +- [ ] Switch `STORE_CURRENCY` to PLN on a dev run β†’ the minimum cap keeps the total at 2.00 PLN β†’ Value sourcing: Stripe minimum +- [ ] Signed in, type another email in the browser devtools and apply a per customer code the account email already used β†’ refused (the account email wins) β†’ Value sourcing: the email for check 5 +- [ ] Apply on a cart whose own pending order holds the code's last use β†’ it still applies; another cart is refused β†’ Value sourcing: redemption counts +- [ ] Set a code's end to one minute from now in the store time zone, wait past it β†’ Apply says "This code has expired." (the database clock decides) β†’ Value sourcing: now for dates +- [x] A code with a 50.00 minimum on a 20.00 cart β†’ "Add €30.00 more to use this code." β†’ Value sourcing: Add more +- [x] Pay β†’ the order's `discount_cents`, line split and totals match what `snapshotOrder` computes inside the order transaction β†’ Value sourcing: discount, line split, totals +- [ ] Pay as a guest with `Ada@Example.com` β†’ the redemption email is `ada@example.com` β†’ Value sourcing: order email for the redemption +- [x] Rename nothing, then compare the order's `discount_code`, `discount_type`, `discount_value` with the code row β†’ identical β†’ Value sourcing: snapshot columns +- [x] In the Stripe dashboard, the coupon `order_` has `amount_off` = `discount_cents`, currency EUR, name = the code β†’ Value sourcing: coupon fields +- [ ] Come back to `/checkout` 25 hours after a Pay with a code β†’ the code is no longer prefilled β†’ Value sourcing: checkoutPrefill code +- [ ] A pending order older than 35 minutes does not count in the list's uses β†’ Value sourcing: uses +- [x] Discount given sums only paid orders with the code β†’ Value sourcing: discount given +- [x] Enter a start of 2026-07-01 09:30 with `STORE_TIMEZONE=Europe/Warsaw` β†’ stored as 07:30 UTC and shown back as 09:30 β†’ Value sourcing: dates shown and parsed +- [x] `created_by_admin_id` and `updated_by_admin_id` are the signed in admin β†’ Value sourcing: admin ids +- [ ] After deleting an unused code that an expired order pointed at, the order page shows the snapshot text without a link β†’ Value sourcing: admin order page discount row + +## Commands +- [x] `pnpm test` β†’ all unit tests pass (rule, schema, dates, snapshot, session params, list params, email) β†’ AC-2, AC-3, AC-6, AC-7, AC-11 +- [x] `pnpm test:db tests/db/discounts-checkout.db.test.ts tests/db/discounts-admin.db.test.ts tests/db/orders.db.test.ts` β†’ pass β†’ AC-3 to AC-5, AC-8 to AC-14, AC-16, AC-18, AC-19 +- [x] `pnpm test:e2e tests/e2e/checkout/discounts.spec.ts tests/e2e/admin/discounts.spec.ts` β†’ pass on desktop and mobile β†’ AC-1, AC-3, AC-4, AC-9 to AC-15, AC-17, AC-20 + +## Acceptance-criteria coverage +- AC-1 UI 1 to 3 Β· AC-2 UI 4, 5 Β· AC-3 UI 6, 7 Β· AC-4 UI 8, 9 Β· AC-5 UI 10 Β· AC-6 UI 9 Β· AC-7 UI 9 Β· AC-8 UI 12 Β· AC-9 UI 11 Β· AC-10 UI 13 Β· AC-11 UI 14 Β· AC-12 UI 15 Β· AC-13 UI 16 Β· AC-14 UI 17 Β· AC-15 UI 18 Β· AC-16 UI 19 Β· AC-17 UI 20 Β· AC-18 db suites (log assertions) Β· AC-19 orders.db suite Β· AC-20 UI 21 diff --git a/emails/order-confirmation.tsx b/emails/order-confirmation.tsx index 908c86d..c1a10bb 100644 --- a/emails/order-confirmation.tsx +++ b/emails/order-confirmation.tsx @@ -31,8 +31,8 @@ export type OrderConfirmationProps = { readonly paidDate: string; readonly lines: readonly OrderConfirmationLine[]; readonly subtotal: string; - // Null when the order has no discount. - readonly discount: string | null; + // Null when the order has no discount. The label names the code (spec 0014, AC-15). + readonly discount: { readonly label: string; readonly amount: string } | null; // "Free" when the delivery fee is 0. readonly delivery: string; readonly total: string; @@ -92,7 +92,7 @@ export function OrderConfirmationEmail(props: OrderConfirmationProps) { {props.discount === null ? null : ( - + )} @@ -144,9 +144,9 @@ OrderConfirmationEmail.PreviewProps = { { name: "Canvas tote", variant: null, quantity: 1, unitPrice: "€19.00", lineTotal: "€19.00" }, ], subtotal: "€97.00", - discount: null, + discount: { label: "Discount (SUMMER10)", amount: "€9.70" }, delivery: "€9.99", - total: "€106.99", + total: "€97.29", address: { fullName: "Anna Kowalska", line1: "ul. DΕ‚uga 12", diff --git a/prisma/migrations/20261017152429_discount_code_admin/migration.sql b/prisma/migrations/20261017152429_discount_code_admin/migration.sql new file mode 100644 index 0000000..6dc6422 --- /dev/null +++ b/prisma/migrations/20261017152429_discount_code_admin/migration.sql @@ -0,0 +1,26 @@ +-- spec 0014, AC-19. created_by_admin_id is NOT NULL with no default: no code exists before this +-- feature ships, so the table is empty when this runs. +-- AlterTable +ALTER TABLE "discount_codes" ADD COLUMN "created_by_admin_id" UUID NOT NULL, +ADD COLUMN "note" TEXT, +ADD COLUMN "updated_by_admin_id" UUID; + +-- CreateIndex +CREATE INDEX "discount_codes_created_by_admin_id_idx" ON "discount_codes"("created_by_admin_id"); + +-- CreateIndex +CREATE INDEX "discount_codes_updated_by_admin_id_idx" ON "discount_codes"("updated_by_admin_id"); + +-- AddForeignKey +ALTER TABLE "discount_codes" ADD CONSTRAINT "discount_codes_created_by_admin_id_fkey" FOREIGN KEY ("created_by_admin_id") REFERENCES "admin_users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "discount_codes" ADD CONSTRAINT "discount_codes_updated_by_admin_id_fkey" FOREIGN KEY ("updated_by_admin_id") REFERENCES "admin_users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- ─── Raw SQL: what Prisma cannot express (spec 0014) ──────────────────────── +-- CHECK constraints are invisible to `prisma migrate dev` drift detection, so they live only here. +-- orders.discount_code_id already has its index (orders_discount_code_id_idx, spec 0002). + +ALTER TABLE "discount_codes" + ADD CONSTRAINT "discount_codes_code_format_check" CHECK ("code" ~ '^[A-Z0-9-]{3,32}$'), + ADD CONSTRAINT "discount_codes_note_length_check" CHECK (char_length("note") <= 500); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5e8b9e4..606e738 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -280,9 +280,11 @@ model AdminUser { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) - orderEvents OrderEvent[] - refunds Refund[] - stockMovements StockMovement[] + orderEvents OrderEvent[] + refunds Refund[] + stockMovements StockMovement[] + discountCodesCreated DiscountCode[] @relation("DiscountCodeCreatedBy") + discountCodesUpdated DiscountCode[] @relation("DiscountCodeUpdatedBy") @@map("admin_users") } @@ -526,7 +528,8 @@ model RefundLine { @@map("refund_lines") } -// Codes are uppercased by Zod before any write; a CHECK in the migration enforces it. +// Codes are uppercased by Zod before any write; CHECKs in the migration SQL enforce the case and +// the format (spec 0014, AC-19). Code, type and value never change once a redemption exists. model DiscountCode { id String @id @default(uuid(7)) @db.Uuid code String @unique @@ -539,12 +542,21 @@ model DiscountCode { perCustomerLimit Int? @map("per_customer_limit") minSubtotalCents Int? @map("min_subtotal_cents") active Boolean @default(true) + // Admin only, at most 500 characters (a CHECK); never shown to customers or logged. + note String? + createdByAdminId String @map("created_by_admin_id") @db.Uuid + updatedByAdminId String? @map("updated_by_admin_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + createdBy AdminUser @relation("DiscountCodeCreatedBy", fields: [createdByAdminId], references: [id], onDelete: Restrict) + updatedBy AdminUser? @relation("DiscountCodeUpdatedBy", fields: [updatedByAdminId], references: [id], onDelete: Restrict) orders Order[] redemptions DiscountRedemption[] + @@index([createdByAdminId]) + @@index([updatedByAdminId]) + @@map("discount_codes") } diff --git a/src/components/layout/admin-nav.ts b/src/components/layout/admin-nav.ts index 855506d..92defa3 100644 --- a/src/components/layout/admin-nav.ts +++ b/src/components/layout/admin-nav.ts @@ -4,6 +4,7 @@ import { PackageIcon, ReceiptIcon, SettingsIcon, + TicketPercentIcon, } from "lucide-react"; import type { NavItem } from "./nav"; @@ -17,5 +18,6 @@ export const adminNavItems: readonly AdminNavItem[] = [ { href: "/admin/products", label: "Products", icon: PackageIcon }, { href: "/admin/categories", label: "Categories", icon: FolderTreeIcon }, { href: "/admin/orders", label: "Orders", icon: ReceiptIcon }, + { href: "/admin/discounts", label: "Discounts", icon: TicketPercentIcon }, { href: "/admin/settings", label: "Settings", icon: SettingsIcon }, ]; diff --git a/src/features/account/components/order-detail.tsx b/src/features/account/components/order-detail.tsx index 1a73ff5..9cb6ecb 100644 --- a/src/features/account/components/order-detail.tsx +++ b/src/features/account/components/order-detail.tsx @@ -9,6 +9,7 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { requireCustomer } from "@/lib/customer/session"; import { formatDate } from "@/lib/dates"; +import { discountRowLabel } from "@/lib/discounts/rule"; import { env } from "@/lib/env"; import { orderStatusLabels } from "@/lib/orders/status-labels"; @@ -106,7 +107,7 @@ export async function OrderDetail({ params }: { readonly params: Promise<{ numbe {order.discountCents > 0 ? (
- Discount + {discountRowLabel(order.discountCode)} βˆ’ diff --git a/src/features/account/queries.ts b/src/features/account/queries.ts index b9d8e18..48a525a 100644 --- a/src/features/account/queries.ts +++ b/src/features/account/queries.ts @@ -91,6 +91,8 @@ export type AccountOrder = { readonly currency: string; readonly subtotalCents: number; readonly discountCents: number; + // spec 0014, AC-15: the code's snapshot text, or null. + readonly discountCode: string | null; readonly shippingCents: number; readonly totalCents: number; readonly refundedCents: number; @@ -123,6 +125,7 @@ export async function getAccountOrder( currency: true, subtotalCents: true, discountCents: true, + discountCode: true, shippingCents: true, totalCents: true, refundedCents: true, diff --git a/src/features/cart/components/cart-contents.tsx b/src/features/cart/components/cart-contents.tsx index 2aaac0d..02785e9 100644 --- a/src/features/cart/components/cart-contents.tsx +++ b/src/features/cart/components/cart-contents.tsx @@ -16,7 +16,6 @@ import { import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { loadOwnedCart } from "@/lib/cart/owned"; -import { NO_DISCOUNT_CENTS } from "@/lib/orders/snapshot"; import { freeDeliveryGapCents, orderCharges } from "@/lib/shipping/rule"; import { getShippingSettings } from "@/lib/shipping/settings"; @@ -51,7 +50,8 @@ export async function CartContents() { ); } - const basis = { subtotalCents: cart.subtotalCents, discountCents: NO_DISCOUNT_CENTS }; + // A promo code is typed on /checkout only (spec 0014), so the cart never shows a discount. + const basis = { subtotalCents: cart.subtotalCents, discountCents: 0 }; const { shippingCents: shipping, totalCents } = orderCharges(basis, settings); const gap = freeDeliveryGapCents(basis, settings); diff --git a/src/features/checkout/AGENTS.md b/src/features/checkout/AGENTS.md index d21fcb6..2ac2b90 100644 --- a/src/features/checkout/AGENTS.md +++ b/src/features/checkout/AGENTS.md @@ -16,6 +16,7 @@ Everything from `/checkout` to the order confirmation. The customer types their | `queries.ts` | `checkoutPrefill(cartId)` for a return visit and `getCompletion(sessionId)` for the confirmation page. | | `components/checkout-form.tsx` | The client form (React Hook Form plus zodResolver), mapping each `StartCheckoutError` code to a message, a field error or a redirect. | | `components/checkout-summary.tsx`, `checkout-complete.tsx`, `complete-refresher.tsx` | The order summary with the Pay total, the confirmation page, and the refresh every 3 seconds while the webhook is on its way. | +| `actions/apply-discount.ts`, `components/discount-field.tsx`, `checkout-payment.tsx` | `applyDiscountCode` (read only preview) and the code field with Apply, Remove and the live region (spec 0014). | | `log.ts`, `mask-email.ts` | `checkout.*` log events; the `kβ€’β€’β€’@gmail.com` email mask. | ## Conventions @@ -23,6 +24,7 @@ Everything from `/checkout` to the order confirmation. The customer types their - `startCheckout` takes only what the customer typed. The cart comes from the signed cookie, and prices and the delivery fee come from the database inside the transaction that locks the cart (`SELECT ... FOR UPDATE`), never from the browser or the cache. - Expected failures are `StartCheckoutError` codes. A validation refusal returns a `fields` map with the same messages the form shows, and the form focuses the first invalid field. Every field missing or of the wrong type still gets its own message. - A signed in checkout (spec 0012, AC-15) uses the account email, whatever was typed, first merges a guest cookie cart into the account cart (`cart_changed` with `merged` when the account cart already held lines), and takes the customer lock before the cart lock. Saving the address goes through `src/lib/customer/addresses.ts`. +- A promo code (spec 0014) is rechecked on Pay under `lockCode`, taken after the customer and cart locks, and redeemed in the order's transaction. The discount reaches Stripe as a one off coupon `order_` with our exact amount, created after the order commits; if it fails the order is expired, which frees the use. The rule lives in `src/lib/discounts/`. - Every schema transform must be safe to run twice: `handleSubmit` sends the parsed output and the server parses it again. - The address travels as one `ShipTo` value, saved with `deliveryColumns` and sent to Stripe as `payment_intent_data.shipping`. Empty optional keys are left out, never sent as `""`. - `orders.email` can be null on an order purged under spec 0008. `checkoutPrefill` never reaches one (the purge clears `cart_id`) and returns null with `order.email_missing` if it ever sees a null email; `getCompletion` throws on a paid order without one. @@ -48,5 +50,6 @@ Everything from `/checkout` to the order confirmation. The customer types their - [0006 Card payment and paid orders](../../../docs/specs/0006-card-payment-paid-orders/index.md) - [0007 Shipping address and flat rate](../../../docs/specs/0007-shipping-address-flat-rate/index.md) - [0008 Purge PII from expired orders](../../../docs/specs/0008-purge-expired-order-pii/index.md) +- [0014 Discounts and promo codes](../../../docs/specs/0014-discounts-promo-codes/index.md) _Drafted by /audit from the repo, worth a quick human pass. Edit freely: once a line stops matching this draft, later runs treat it as curated and will flag rather than overwrite it._ diff --git a/src/features/checkout/actions/apply-discount.ts b/src/features/checkout/actions/apply-discount.ts new file mode 100644 index 0000000..1727a49 --- /dev/null +++ b/src/features/checkout/actions/apply-discount.ts @@ -0,0 +1,86 @@ +"use server"; + +import { z } from "zod"; + +import { readOwnedCartId } from "@/lib/cart/owned"; +import { getCustomer } from "@/lib/customer/session"; +import { db } from "@/lib/db"; +import { previewDiscount } from "@/lib/discounts/codes"; +import { type DiscountRefused, refusedDiscount } from "@/lib/discounts/rule"; +import type { ActionResult } from "@/lib/result"; + +import { logDiscountRefused } from "../log"; +import { emailSchema } from "../schemas"; + +export type AppliedDiscount = { + readonly code: string; + readonly discountCents: number; + readonly shippingCents: number; + readonly totalCents: number; +}; + +export type ApplyDiscountError = + | { readonly code: "below_minimum"; readonly minimumCents: number } + | ({ readonly code: "discount_refused" } & DiscountRefused) + // `merged`: a guest cart still waits to join the account cart, so Pay would reload the form. + | { readonly code: "cart_changed"; readonly merged?: true }; + +const inputSchema = z.object({ code: z.unknown(), email: z.unknown().optional() }); + +// spec 0014, AC-1, AC-3 and AC-17: read only. The cart comes from the signed cookie and every +// price, setting and code row from the database; the answer carries the totals or a refusal +// reason, never a code's limits, counts or note. Pay checks everything again under the lock. +export async function applyDiscountCode( + input: unknown, +): Promise> { + const parsed = inputSchema.safeParse(input); + const { code, email: typedEmail } = parsed.success ? parsed.data : { code: null, email: null }; + + const customer = await getCustomer(); + const cartId = await readOwnedCartId(); + if (cartId === null) return { ok: false, error: { code: "cart_changed" } }; + // The same cart /checkout renders. A signed in customer's guest cookie cart joins the account + // cart only on Pay; when the account cart holds lines too, Pay would send the form back. + if (customer !== null && (await waitsToMerge(cartId, customer.id))) { + return { ok: false, error: { code: "cart_changed", merged: true } }; + } + + // AC-3, check 5 runs only when the email is known: the account's, or a valid typed one. + const typed = emailSchema.safeParse(typedEmail); + const email = customer?.email ?? (typed.success ? typed.data : null); + + const preview = await previewDiscount({ cartId, codeText: code, email }); + switch (preview.kind) { + case "no_cart": + return { ok: false, error: { code: "cart_changed" } }; + case "below_minimum": + return { ok: false, error: { code: "below_minimum", minimumCents: preview.minimumCents } }; + case "refused": + logDiscountRefused({ + stage: "apply", + reason: preview.reason, + discountCodeId: preview.codeId, + }); + return { + ok: false, + error: { code: "discount_refused", ...refusedDiscount(preview) }, + }; + case "applied": + return { + ok: true, + data: { + code: preview.code, + discountCents: preview.discountCents, + shippingCents: preview.shippingCents, + totalCents: preview.totalCents, + }, + }; + } +} + +async function waitsToMerge(cartId: string, customerId: string): Promise { + const cart = await db.cart.findUnique({ where: { id: cartId }, select: { customerId: true } }); + if (cart === null || cart.customerId !== null) return false; + const lines = await db.cartItem.count({ where: { cart: { customerId } } }); + return lines > 0; +} diff --git a/src/features/checkout/actions/start-checkout.ts b/src/features/checkout/actions/start-checkout.ts index 8b14e85..63c0a32 100644 --- a/src/features/checkout/actions/start-checkout.ts +++ b/src/features/checkout/actions/start-checkout.ts @@ -9,6 +9,8 @@ import { getCustomer } from "@/lib/customer/session"; import { type CustomerIdentity, mergeCartForCustomer } from "@/lib/customer/setup"; import { db, type Tx } from "@/lib/db"; import { uniqueViolation } from "@/lib/db-errors"; +import { redeem } from "@/lib/discounts/codes"; +import type { DiscountRefused } from "@/lib/discounts/rule"; import { env } from "@/lib/env"; import { logOrderExpired } from "@/lib/orders/log"; import { minimumChargeCents } from "@/lib/orders/minimum-charge"; @@ -25,7 +27,14 @@ import { uuidv7 } from "@/lib/uuid"; import { variantLabel } from "@/lib/variant-label"; import { type CheckoutAccount, lockAccount, orderCustomerName, saveToAccount } from "../account"; -import { logCheckoutRefused, logCheckoutStarted, logStripeFailed, stripeErrorCode } from "../log"; +import { applyLockedCode, createOrderCoupon } from "../discount"; +import { + logCheckoutRefused, + logCheckoutStarted, + logDiscountBelowMinimum, + logStripeFailed, + stripeErrorCode, +} from "../log"; import { type CheckoutFieldErrors, checkoutFieldErrors, checkoutSchema } from "../schemas"; import { checkoutSessionParams } from "../stripe-session"; @@ -38,7 +47,9 @@ export type StartCheckoutError = | { readonly code: "payment_processing" } | { readonly code: "already_paid"; readonly confirmationUrl: string } | { readonly code: "checkout_in_progress" } - | { readonly code: "payment_unavailable" }; + | { readonly code: "payment_unavailable" } + // spec 0014, AC-4: the code stopped applying since Apply (or never did); no order was made. + | ({ readonly code: "discount_refused" } & DiscountRefused); type Result = ActionResult; @@ -48,6 +59,7 @@ const expiryReasons = { replaced: "Replaced by a new checkout", sessionNotCreated: "Stripe session could not be created", sessionNotSaved: "Session id could not be saved", + couponNotCreated: "Stripe coupon could not be created", } as const; // spec 0006, AC-1, AC-2 and AC-9: freezes the cart into a pending order and sends the browser to @@ -65,7 +77,12 @@ async function run(input: unknown): Promise> { if (!parsed.success) { return fail({ code: "validation", fields: checkoutFieldErrors(parsed.error) }); } - const { email: typedEmail, saveAddress: saveRequested, ...address } = parsed.data; + const { + email: typedEmail, + saveAddress: saveRequested, + discountCode: codeInput, + ...address + } = parsed.data; const shipTo: ShipTo = { ...address, countryCode: env.STORE_COUNTRY }; // spec 0012, AC-15: a signed in checkout uses the account email, whatever was typed, and a @@ -80,10 +97,13 @@ async function run(input: unknown): Promise> { const cleared = await clearPendingOrder(cartId); if (!cleared.ok) return cleared; - const created = await createPendingOrder(cartId, email, shipTo, account); + const created = await createPendingOrder(cartId, email, shipTo, account, codeInput); if (!created.ok) return created; const order = created.data; + const coupon = await createCoupon(order); + if (!coupon.ok) return coupon; + let session: Stripe.Checkout.Session; try { session = await stripe.checkout.sessions.create( @@ -99,6 +119,7 @@ async function run(input: unknown): Promise> { })), shippingCents: order.snapshot.shippingCents, shipping: shipTo, + couponId: coupon.data, }, { siteUrl: env.NEXT_PUBLIC_SITE_URL, nowMs: Date.now() }, ), @@ -109,9 +130,9 @@ async function run(input: unknown): Promise> { logStripeFailed(order.id, error); // No session exists, so nothing at Stripe can take money for this order. await expire(order.id, expiryReasons.sessionNotCreated); - return stripeErrorCode(error) === "amount_too_small" - ? fail({ code: "below_minimum", minimumCents: minimumChargeCents(env.STORE_CURRENCY) }) - : fail({ code: "payment_unavailable" }); + if (stripeErrorCode(error) !== "amount_too_small") return fail({ code: "payment_unavailable" }); + if (order.discount !== null) logDiscountBelowMinimum(order.id); + return fail({ code: "below_minimum", minimumCents: minimumChargeCents(env.STORE_CURRENCY) }); } const saved = await saveSessionId(order.id, session.id); @@ -132,10 +153,25 @@ async function run(input: unknown): Promise> { number: order.number, totalCents: order.totalCents, shippingCents: order.snapshot.shippingCents, + discountCents: order.snapshot.discountCents, + discountCodeId: order.discount?.codeId ?? null, }); return { ok: true, data: { url: session.url } }; } +// spec 0014, AC-7 and AC-8: no coupon, no session; the order is expired, which frees its use. +async function createCoupon(order: CreatedOrder): Promise> { + if (order.discount === null) return { ok: true, data: null }; + const id = await createOrderCoupon({ + id: order.id, + code: order.discount.code, + discountCents: order.snapshot.discountCents, + }); + if (id !== null) return { ok: true, data: id }; + await expire(order.id, expiryReasons.couponNotCreated); + return fail({ code: "payment_unavailable" }); +} + function fail(error: StartCheckoutError): Result { return { ok: false, error }; } @@ -231,6 +267,8 @@ type CreatedOrder = { readonly number: number; readonly totalCents: number; readonly snapshot: OrderSnapshot; + // The code this order redeemed, or null for none. + readonly discount: { readonly codeId: string; readonly code: string } | null; }; // AC-1, step 3: under the cart lock, reread the cart live and freeze it into an order. Prices @@ -240,6 +278,7 @@ async function createPendingOrder( email: string, shipTo: ShipTo, account: CheckoutAccount | null, + codeInput: unknown, ): Promise> { try { const created = await db.$transaction( @@ -273,13 +312,31 @@ async function createPendingOrder( // Read here, never from the cache: an admin change made while the customer sat on // /checkout applies to this Pay (AC-7), and Stripe's page shows that total. const settings = await readShippingSettings(tx); - const snapshot = snapshotOrder( - sources.map((source) => source.snapshot), - settings, - ); + const lines = sources.map((source) => source.snapshot); const minimumCents = minimumChargeCents(env.STORE_CURRENCY); - if (snapshot.totalCents < minimumCents) + // spec 0014, Key invariants: below the minimum without a code is answered before any + // code check. + const undiscounted = snapshotOrder(lines, settings, null); + if (undiscounted.totalCents < minimumCents) { return fail({ code: "below_minimum", minimumCents }); + } + + // spec 0014, AC-4: the code lock comes after the customer and cart locks, and before the + // order, line and redemption inserts that touch the code row's foreign keys. + const locked = await applyLockedCode(tx, { + codeInput, + email, + subtotalCents: undiscounted.subtotalCents, + settings, + minimumCents, + }); + if (!locked.ok) return fail({ code: "discount_refused", ...locked.error }); + const applied = locked.data; + const snapshot = snapshotOrder(lines, settings, applied); + // The AC-2 cap keeps a discounted total at the minimum; this is the backstop. + if (snapshot.totalCents < minimumCents) { + throw new Error("A discounted order fell below the minimum charge"); + } const customerName = orderCustomerName(profileName, shipTo.fullName); const order = await tx.order.create({ @@ -295,6 +352,15 @@ async function createPendingOrder( discountCents: snapshot.discountCents, shippingCents: snapshot.shippingCents, totalCents: snapshot.totalCents, + // spec 0014, AC-4: the code's snapshot, written once and never changed. + ...(applied === null + ? {} + : { + discountCodeId: applied.codeId, + discountCode: applied.code, + discountType: applied.type, + discountValue: applied.value, + }), lines: { create: snapshot.lines.map((line) => ({ variantId: line.variantId, @@ -315,9 +381,13 @@ async function createPendingOrder( }, select: { id: true, number: true, totalCents: true }, }); + if (applied !== null) { + await redeem(tx, { codeId: applied.codeId, orderId: order.id, email }); + } const addressSaved = account === null ? false : await saveToAccount(tx, account, shipTo, customerName); - return { ok: true, data: { ...order, snapshot, addressSaved } }; + const discount = applied === null ? null : { codeId: applied.codeId, code: applied.code }; + return { ok: true, data: { ...order, snapshot, discount, addressSaved } }; }, ); if (!created.ok) return created; diff --git a/src/features/checkout/components/checkout-complete.tsx b/src/features/checkout/components/checkout-complete.tsx index b6d7e22..e1dfdba 100644 --- a/src/features/checkout/components/checkout-complete.tsx +++ b/src/features/checkout/components/checkout-complete.tsx @@ -8,6 +8,7 @@ import { ProductImage } from "@/components/product-image"; import { buttonVariants } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import { discountRowLabel } from "@/lib/discounts/rule"; import { orderStatusLabels } from "@/lib/orders/status-labels"; import { type CompletedOrder, type Completion, getCompletion } from "../queries"; @@ -156,6 +157,14 @@ function PaidOrder({ order }: { readonly order: CompletedOrder }) { Subtotal
+ {order.discountCents > 0 ? ( +
+ {discountRowLabel(order.discountCode)} + + βˆ’ + +
+ ) : null}
diff --git a/src/features/checkout/components/checkout-form.tsx b/src/features/checkout/components/checkout-form.tsx index 2f7b907..6133032 100644 --- a/src/features/checkout/components/checkout-form.tsx +++ b/src/features/checkout/components/checkout-form.tsx @@ -3,7 +3,14 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { LockIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState, useSyncExternalStore, useTransition } from "react"; +import { + type RefObject, + useEffect, + useMemo, + useState, + useSyncExternalStore, + useTransition, +} from "react"; import { Controller, type FieldError as FormFieldError, @@ -49,11 +56,17 @@ const mergedMessage = const noSubscription = () => () => {}; +export function minimumMessage(minimum: string): string { + return `Your total is below the minimum card payment of ${minimum}. Add more to your cart to pay.`; +} + +type DiscountRefused = Extract; + // spec 0006, Result shapes and copy. function formError(error: StartCheckoutError, money: (cents: number) => string): string | null { switch (error.code) { case "below_minimum": - return `Your total is below the minimum card payment of ${money(error.minimumCents)}. Add more to your cart to pay.`; + return minimumMessage(money(error.minimumCents)); case "payment_processing": return "A payment for this cart is still processing. We will confirm your order once it completes."; case "checkout_in_progress": @@ -63,6 +76,7 @@ function formError(error: StartCheckoutError, money: (cents: number) => string): case "validation": case "cart_changed": case "already_paid": + case "discount_refused": return null; } } @@ -75,6 +89,9 @@ export function CheckoutForm({ countryName, prefill, account = null, + discountCode, + emailRef, + onDiscountRefused, }: { readonly totalCents: number; readonly country: StoreCountry; @@ -82,6 +99,12 @@ export function CheckoutForm({ readonly prefill: CheckoutPrefill; // The signed in customer: the email is theirs, and saved addresses can fill the form. readonly account?: CheckoutAccount | null; + // spec 0014, AC-4: the applied promo code Pay sends, or null. + readonly discountCode: string | null; + // Lets the promo code field read the email as typed, for its per customer check. + readonly emailRef: RefObject<() => string>; + // Pay refused the code: the field shows why and the summary drops it. + readonly onDiscountRefused: (error: DiscountRefused) => void; }) { const router = useRouter(); const format = useStoreFormat(); @@ -108,6 +131,10 @@ export function CheckoutForm({ }); const { errors } = form.formState; + useEffect(() => { + emailRef.current = () => form.getValues("email"); + }, [emailRef, form]); + // Back from Stripe can restore this page from the back/forward cache with the button still // saying it is redirecting; a restored page starts over. useEffect(() => { @@ -121,7 +148,10 @@ export function CheckoutForm({ const onSubmit = form.handleSubmit((values) => { setNotice(null); startTransition(async () => { - const result = await startCheckout(values); + const result = await startCheckout({ + ...values, + ...(discountCode === null ? {} : { discountCode }), + }); if (result.ok) { setRedirecting(true); window.location.assign(result.data.url); @@ -143,6 +173,8 @@ export function CheckoutForm({ router.refresh(); } else if (error.code === "cart_changed") { router.push("/cart"); + } else if (error.code === "discount_refused") { + onDiscountRefused(error); } else if (error.code === "already_paid") { setRedirecting(true); window.location.assign(error.confirmationUrl); diff --git a/src/features/checkout/components/checkout-payment.tsx b/src/features/checkout/components/checkout-payment.tsx new file mode 100644 index 0000000..a3a184e --- /dev/null +++ b/src/features/checkout/components/checkout-payment.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { type ReactNode, useRef, useState } from "react"; + +import { DeliveryRow } from "@/components/delivery-row"; +import { Price } from "@/components/price"; +import { useStoreFormat } from "@/components/store-format-provider"; +import { Separator } from "@/components/ui/separator"; +import { discountRowLabel, refusalMessage } from "@/lib/discounts/rule"; +import { formatMoney } from "@/lib/money"; +import type { StoreCountry } from "@/lib/shipping/address"; + +import { + type AppliedDiscount, + type ApplyDiscountError, + applyDiscountCode, +} from "../actions/apply-discount"; +import type { CheckoutPrefill } from "../schemas"; +import { type CheckoutAccount, CheckoutForm, minimumMessage } from "./checkout-form"; +import { DiscountField } from "./discount-field"; + +// What the page renders with (spec 0014, AC-9): a code coming back from an earlier Pay, either +// still applied or in the field with its refusal. +export type InitialDiscount = + | { readonly kind: "applied"; readonly applied: AppliedDiscount } + | { readonly kind: "refused"; readonly text: string; readonly message: string } + | null; + +// spec 0014, AC-1: the summary rows, the promo code field and the payment form. The rows start +// from the server's totals and follow Apply's answer; Pay checks the code again on its own. +export function CheckoutPayment({ + subtotalCents, + shippingCents, + totalCents, + initialDiscount, + signInHint, + ...form +}: { + readonly subtotalCents: number; + // Without a code. + readonly shippingCents: number; + readonly totalCents: number; + readonly initialDiscount: InitialDiscount; + readonly signInHint: ReactNode; + readonly country: StoreCountry; + readonly countryName: string; + readonly prefill: CheckoutPrefill; + readonly account: CheckoutAccount | null; +}) { + const router = useRouter(); + const format = useStoreFormat(); + const money = (cents: number) => formatMoney(cents, format); + const inputRef = useRef(null); + // The payment form's email, read when Apply runs (check 5 needs a known email). + const emailRef = useRef<() => string>(() => ""); + const [applied, setApplied] = useState( + initialDiscount?.kind === "applied" ? initialDiscount.applied : null, + ); + // An applied code starts in the text too, so a later Pay refusal (AC-4) leaves it in the field. + const [text, setText] = useState( + initialDiscount?.kind === "applied" + ? initialDiscount.applied.code + : initialDiscount?.kind === "refused" + ? initialDiscount.text + : "", + ); + const [error, setError] = useState( + initialDiscount?.kind === "refused" ? initialDiscount.message : null, + ); + const [announcement, setAnnouncement] = useState(""); + + function refuse(error: ApplyDiscountError) { + setApplied(null); + if (error.code === "cart_changed") { + if (error.merged) router.refresh(); + else router.push("/cart"); + return; + } + const message = + error.code === "below_minimum" + ? minimumMessage(money(error.minimumCents)) + : refusalMessage(error, format); + setError(message); + setAnnouncement(message); + inputRef.current?.focus(); + } + + async function apply() { + setError(null); + const result = await applyDiscountCode({ code: text, email: emailRef.current() }); + if (!result.ok) return refuse(result.error); + setApplied(result.data); + setText(result.data.code); + setAnnouncement( + `Code ${result.data.code} applied. Your total is now ${money(result.data.totalCents)}.`, + ); + } + + function remove() { + setApplied(null); + setText(""); + setError(null); + setAnnouncement(`Code removed. Your total is ${money(totalCents)}.`); + inputRef.current?.focus(); + } + + const shown = applied ?? { discountCents: 0, shippingCents, totalCents }; + + return ( + <> + { + setText(value); + setError(null); + }} + applied={applied} + error={error} + announcement={announcement} + inputRef={inputRef} + onApply={apply} + onRemove={remove} + /> +
+ Subtotal + + + +
+ {applied !== null ? ( +
+ {discountRowLabel(applied.code)} + + βˆ’ + +
+ ) : null} + + +
+ Total + + + +
+ {signInHint} + + + ); +} diff --git a/src/features/checkout/components/checkout-summary.tsx b/src/features/checkout/components/checkout-summary.tsx index b53a42d..acd4ac7 100644 --- a/src/features/checkout/components/checkout-summary.tsx +++ b/src/features/checkout/components/checkout-summary.tsx @@ -2,7 +2,6 @@ import { InfoIcon } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { DeliveryRow } from "@/components/delivery-row"; import { Price } from "@/components/price"; import { ProductImage } from "@/components/product-image"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -12,15 +11,17 @@ import { loadOwnedCart } from "@/lib/cart/owned"; import { addressNeedsCheck } from "@/lib/customer/address-rules"; import { listSavedAddresses, type SavedAddress } from "@/lib/customer/addresses"; import { getCustomer } from "@/lib/customer/session"; +import { previewDiscount } from "@/lib/discounts/codes"; +import { refusalMessage } from "@/lib/discounts/rule"; import { env } from "@/lib/env"; -import { NO_DISCOUNT_CENTS } from "@/lib/orders/snapshot"; import { countryDisplayName } from "@/lib/shipping/address"; import { orderCharges } from "@/lib/shipping/rule"; import { getShippingSettings } from "@/lib/shipping/settings"; -import { checkoutPrefill } from "../queries"; +import { checkoutPrefill, prefillDiscountCode } from "../queries"; import { type CheckoutPrefill, emptyPrefill } from "../schemas"; -import { type CheckoutAccount, CheckoutForm } from "./checkout-form"; +import type { CheckoutAccount } from "./checkout-form"; +import { CheckoutPayment, type InitialDiscount } from "./checkout-payment"; // spec 0012, AC-16: where a signed out visitor signs in and comes back. const signInToCheckout = "/account/sign-in?next=/checkout"; @@ -43,6 +44,8 @@ const cartPath = "/cart"; // email form and Pay button, and the notice after coming back from Stripe without paying. spec // 0007 adds the address fields, prefilled from the cart's last order, and the delivery row. The // fee shown here is for display; Pay computes it again from the live settings row (AC-5). +// spec 0014 adds the promo code field and, for a return visit, the code from the last Pay +// (AC-9); Pay checks the code again under its lock. export async function CheckoutSummary({ searchParams, }: { @@ -51,9 +54,10 @@ export async function CheckoutSummary({ const [cart, params] = await Promise.all([loadOwnedCart(), searchParams]); if (!cart || !cart.canCheckout) redirect(cartPath); const cancelled = params.cancelled === "1"; - const [settings, orderPrefill, customer] = await Promise.all([ + const [settings, orderPrefill, returningCode, customer] = await Promise.all([ getShippingSettings(), checkoutPrefill(cart.id), + prefillDiscountCode(cart.id), getCustomer(), ]); // spec 0012, AC-15: a signed in customer's form starts with the pending order's address for this @@ -81,9 +85,17 @@ export async function CheckoutSummary({ (defaultAddress ? { email: "", ...addressValues(defaultAddress) } : null) ?? emptyPrefill; const { shippingCents: shipping, totalCents } = orderCharges( - { subtotalCents: cart.subtotalCents, discountCents: NO_DISCOUNT_CENTS }, + { subtotalCents: cart.subtotalCents, discountCents: 0 }, settings, ); + const initialDiscount = + returningCode === null + ? null + : await returningDiscount( + cart.id, + returningCode.code, + customer?.email ?? returningCode.email, + ); return (
@@ -140,36 +152,27 @@ export async function CheckoutSummary({

Summary

-
- Subtotal - - - -
- - -
- Total - - - -
- {customer === null ? ( -

- Have an account?{" "} - - Sign in - -

- ) : null} {/* Keyed by who is paying: signing in and coming back (a client navigation that keeps the old form around) starts the form over with the account's email and address. */} - + Have an account?{" "} + + Sign in + +

+ ) : null + } country={env.STORE_COUNTRY} countryName={countryDisplayName(env.STORE_COUNTRY, env.STORE_LOCALE)} prefill={prefill} @@ -181,6 +184,39 @@ export async function CheckoutSummary({ ); } +// spec 0014, AC-9: a code from an earlier Pay shows applied when it still passes, else in the +// field with its refusal. The same previewDiscount as Apply, so the render and the button agree. +async function returningDiscount( + cartId: string, + code: string, + email: string | null, +): Promise { + const preview = await previewDiscount({ cartId, codeText: code, email }); + switch (preview.kind) { + case "applied": + return { + kind: "applied", + applied: { + code: preview.code, + discountCents: preview.discountCents, + shippingCents: preview.shippingCents, + totalCents: preview.totalCents, + }, + }; + case "refused": + return { + kind: "refused", + text: code, + message: refusalMessage(preview, { + currency: env.STORE_CURRENCY, + locale: env.STORE_LOCALE, + }), + }; + default: + return null; + } +} + export function CheckoutSummarySkeleton() { return (