From 76fba6a4585e7b7ebbdeaebdcee304d164b399a2 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sat, 17 Oct 2026 17:24:08 +0200 Subject: [PATCH 01/17] docs(discounts): spec 0014 for discounts and promo codes Co-Authored-By: Claude Opus 5.5 --- docs/scope/scope.md | 17 +- .../specs/0014-discounts-promo-codes/index.md | 263 ++++++++++++++++++ .../0014-discounts-promo-codes/rationale.md | 68 +++++ 3 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 docs/specs/0014-discounts-promo-codes/index.md create mode 100644 docs/specs/0014-discounts-promo-codes/rationale.md diff --git a/docs/scope/scope.md b/docs/scope/scope.md index b38f490..017b13e 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 | in-progress | | 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 · GA · in-progress 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) +- [x] Design it (spec): `/architect discounts & promo codes` +- [ ] Build it: `/develop discounts & promo codes` + - [ ] 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) + - [ ] 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) + - [ ] 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) + - [ ] 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) +- [ ] Verify it: `/check verify discounts & promo codes` +- [ ] Test it: `/test discounts & promo codes` +- [ ] Review it (fresh model): `/check review discounts & promo codes` +- [ ] 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..5fad7d3 --- /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**: In Progress + +## 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. Migration for the new columns, CHECKs and index; update the Prisma models; DB test for the format and note CHECKs, satisfies **AC-19** +2. `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. `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. `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. 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. 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. 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. 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. Admin orders `discount` filter and chip, the link from the code pages, satisfies **AC-10**, **AC-14** +10. Customer facing rows: `/checkout/complete`, `/account/orders/[number]`, the confirmation email template and its preview fixture, satisfies **AC-15** +11. 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. 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. From 39ca5b48090ecdb82ab0aef15d2568bd3402d4c7 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sat, 17 Oct 2026 23:48:27 +0200 Subject: [PATCH 02/17] feat(discounts): promo code rules, Apply at checkout and Pay with a one off Stripe coupon Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 26 + prisma/schema.prisma | 20 +- .../cart/components/cart-contents.tsx | 4 +- .../checkout/actions/apply-discount.ts | 96 +++ .../checkout/actions/start-checkout.ts | 195 ++++- .../checkout/components/checkout-form.tsx | 38 +- .../checkout/components/checkout-payment.tsx | 150 ++++ .../checkout/components/checkout-summary.tsx | 100 ++- .../checkout/components/discount-field.tsx | 92 +++ src/features/checkout/log.ts | 22 + src/features/checkout/schemas.ts | 9 +- src/features/checkout/stripe-session.test.ts | 16 +- src/features/checkout/stripe-session.ts | 4 + src/lib/dates.test.ts | 44 +- src/lib/dates.ts | 26 + src/lib/discounts/codes.ts | 220 ++++++ src/lib/discounts/rule.test.ts | 291 ++++++++ src/lib/discounts/rule.ts | 229 ++++++ src/lib/discounts/schema.test.ts | 31 + src/lib/discounts/schema.ts | 30 + src/lib/orders/snapshot.test.ts | 53 +- src/lib/orders/snapshot.ts | 30 +- src/lib/orders/transitions.ts | 2 +- tests/db/discounts-checkout.db.test.ts | 678 ++++++++++++++++++ tests/db/fixtures.ts | 7 + tests/db/orders.db.test.ts | 95 ++- tests/db/start-checkout.db.test.ts | 2 + 27 files changed, 2425 insertions(+), 85 deletions(-) create mode 100644 prisma/migrations/20261017152429_discount_code_admin/migration.sql create mode 100644 src/features/checkout/actions/apply-discount.ts create mode 100644 src/features/checkout/components/checkout-payment.tsx create mode 100644 src/features/checkout/components/discount-field.tsx create mode 100644 src/lib/discounts/codes.ts create mode 100644 src/lib/discounts/rule.test.ts create mode 100644 src/lib/discounts/rule.ts create mode 100644 src/lib/discounts/schema.test.ts create mode 100644 src/lib/discounts/schema.ts create mode 100644 tests/db/discounts-checkout.db.test.ts 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/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/actions/apply-discount.ts b/src/features/checkout/actions/apply-discount.ts new file mode 100644 index 0000000..d20d70f --- /dev/null +++ b/src/features/checkout/actions/apply-discount.ts @@ -0,0 +1,96 @@ +"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 { DiscountRefusal } 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"; + readonly reason: DiscountRefusal; + readonly minimumGapCents?: number; + } + // `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", + reason: preview.reason, + ...(preview.minimumGapCents === undefined + ? {} + : { minimumGapCents: preview.minimumGapCents }), + }, + }; + 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..7c34479 100644 --- a/src/features/checkout/actions/start-checkout.ts +++ b/src/features/checkout/actions/start-checkout.ts @@ -9,6 +9,9 @@ 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 { countRedemptions, lockCode, redeem } from "@/lib/discounts/codes"; +import { type DiscountRefusal, evaluateDiscount } from "@/lib/discounts/rule"; +import { parseCodeText } from "@/lib/discounts/schema"; import { env } from "@/lib/env"; import { logOrderExpired } from "@/lib/orders/log"; import { minimumChargeCents } from "@/lib/orders/minimum-charge"; @@ -20,12 +23,20 @@ import { productImageUrl } from "@/lib/product-image"; import type { ActionResult } from "@/lib/result"; import { deliveryColumns, type ShipTo } from "@/lib/shipping/address"; import { readShippingSettings } from "@/lib/shipping/settings"; +import { logger } from "@/lib/logger"; import { stripe } from "@/lib/stripe"; 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 { + logCheckoutRefused, + logCheckoutStarted, + logCouponFailed, + logDiscountRefused, + logStripeFailed, + stripeErrorCode, +} from "../log"; import { type CheckoutFieldErrors, checkoutFieldErrors, checkoutSchema } from "../schemas"; import { checkoutSessionParams } from "../stripe-session"; @@ -38,7 +49,13 @@ 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"; + readonly reason: DiscountRefusal; + readonly minimumGapCents?: number; + }; type Result = ActionResult; @@ -48,6 +65,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 +83,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 +103,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 +125,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 +136,15 @@ 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" }); + // spec 0014, AC-7: the AC-2 cap keeps a discounted total at the minimum, so this is a bug. + if (order.discount !== null) { + logger.error( + { event: "checkout.discount_below_minimum", orderId: order.id }, + "checkout.discount_below_minimum", + ); + } + return fail({ code: "below_minimum", minimumCents: minimumChargeCents(env.STORE_CURRENCY) }); } const saved = await saveSessionId(order.id, session.id); @@ -132,10 +165,42 @@ 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: after the order commits and before the session, a single use coupon with +// the order's exact discount, so Stripe's amount_total equals our total. Its fixed id and +// idempotency key make a retry land on the same coupon. No coupon, no session: the order is +// expired, which frees its use of the code (AC-8). +async function createCoupon(order: CreatedOrder): Promise> { + if (order.discount === null) return { ok: true, data: null }; + const id = `order_${order.id}`; + try { + await stripe.coupons.create( + { + id, + amount_off: order.snapshot.discountCents, + currency: env.STORE_CURRENCY.toLowerCase(), + duration: "once", + max_redemptions: 1, + name: order.discount.code, + metadata: { order_id: order.id }, + }, + { idempotencyKey: `coupon:${order.id}` }, + ); + } catch (error) { + // A create that already happened (a retried request) left the same coupon behind. + if (stripeErrorCode(error) === "resource_already_exists") return { ok: true, data: id }; + logCouponFailed(order.id, error); + await expire(order.id, expiryReasons.couponNotCreated); + return fail({ code: "payment_unavailable" }); + } + return { ok: true, data: id }; +} + function fail(error: StartCheckoutError): Result { return { ok: false, error }; } @@ -231,8 +296,78 @@ 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; }; +type AppliedCode = NonNullable & { + readonly type: "percent" | "fixed_amount"; + readonly value: number; + readonly discountCents: number; +}; + +// spec 0014, AC-3 to AC-5: inside the order transaction, after the customer and cart locks. The +// code row lock serializes every checkout using this code, so two racing for its last use (or +// the last use for one email) end with one redemption. Pay counts every use: the cart's own +// earlier pending order was already expired by clearPendingOrder. +async function applyLockedCode( + tx: Tx, + input: { + readonly codeInput: unknown; + readonly email: string; + readonly subtotalCents: number; + readonly settings: Awaited>; + readonly minimumCents: number; + }, +): Promise> { + const text = parseCodeText(input.codeInput); + const code = text === null ? null : await lockCode(tx, { code: text }); + const counts = + code === null + ? { uses: 0, customerUses: 0 } + : await countRedemptions(tx, code.id, { email: input.email }); + const evaluation = evaluateDiscount({ + code, + subtotalCents: input.subtotalCents, + settings: input.settings, + minimumCents: input.minimumCents, + now: code?.now ?? new Date(), + uses: counts.uses, + customerUses: counts.customerUses ?? 0, + }); + if (!evaluation.ok) { + logDiscountRefused({ + stage: "pay", + reason: evaluation.reason, + discountCodeId: code?.id ?? null, + }); + return fail({ + code: "discount_refused", + reason: evaluation.reason, + ...(evaluation.minimumGapCents === undefined + ? {} + : { minimumGapCents: evaluation.minimumGapCents }), + }); + } + // A refusal above covers a missing code; reaching here means the row exists. + if (code === null) throw new Error("A discount applied without a code row"); + return { + ok: true, + data: { + codeId: code.id, + code: code.code, + type: code.type, + value: code.value, + discountCents: evaluation.discountCents, + }, + }; +} + +// An absent or empty field means no code; anything else is checked, and refused if it is not one. +function wantsCode(codeInput: unknown): boolean { + return !(codeInput === undefined || codeInput === null || codeInput === ""); +} + // AC-1, step 3: under the cart lock, reread the cart live and freeze it into an order. Prices // and the delivery fee come from the database here and nowhere else (spec 0007, AC-5). async function createPendingOrder( @@ -240,6 +375,7 @@ async function createPendingOrder( email: string, shipTo: ShipTo, account: CheckoutAccount | null, + codeInput: unknown, ): Promise> { try { const created = await db.$transaction( @@ -273,13 +409,33 @@ 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. + if (snapshotOrder(lines, settings, null).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. + let applied: AppliedCode | null = null; + if (wantsCode(codeInput)) { + const locked = await applyLockedCode(tx, { + codeInput, + email, + subtotalCents: snapshotOrder(lines, settings, null).subtotalCents, + settings, + minimumCents, + }); + if (!locked.ok) return locked; + 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 +451,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 +480,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-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..23aabc9 --- /dev/null +++ b/src/features/checkout/components/checkout-payment.tsx @@ -0,0 +1,150 @@ +"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 { 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, + ); + const [text, setText] = useState(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 ? ( +
+ Discount ({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 (