Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
76fba6a
docs(discounts): spec 0014 for discounts and promo codes
kris1027 Oct 17, 2026
39ca5b4
feat(discounts): promo code rules, Apply at checkout and Pay with a o…
kris1027 Oct 17, 2026
ad7035c
feat(discounts): admin code pages, list with uses, and the orders fil…
kris1027 Oct 17, 2026
1def378
feat(discounts): name the code on the confirmation page, account orde…
kris1027 Oct 17, 2026
3775331
test(discounts): e2e for promo codes at checkout and in the admin, bu…
kris1027 Oct 17, 2026
148c776
docs(discounts): verify steps for spec 0014
kris1027 Oct 17, 2026
a75ae1d
test(discounts): value sourcing checks, verify and review ticked
kris1027 Oct 17, 2026
d54bf89
fix(checkout): keep an applied code's text when Pay refuses it
kris1027 Oct 17, 2026
7665e3b
refactor(checkout): log discount_below_minimum through a log.ts helper
kris1027 Oct 17, 2026
6d55937
refactor(checkout): name the summary's discount row with discountRowL…
kris1027 Oct 17, 2026
2891260
refactor(discounts): one DiscountRefused shape and refusedDiscount he…
kris1027 Oct 17, 2026
1c656fd
refactor(discounts): share the code row's column list across its thre…
kris1027 Oct 17, 2026
cf01298
refactor(dates): one wallToUtc for the two zone conversions
kris1027 Oct 17, 2026
b390b0d
refactor(checkout): move the promo code steps of Pay into checkout/di…
kris1027 Oct 17, 2026
146a25c
refactor(discounts): take the admin list's status from its SQL, not a…
kris1027 Oct 17, 2026
87a0bca
refactor(discounts): parse the code value and minimum subtotal with h…
kris1027 Oct 17, 2026
e2630d0
docs(discounts): AGENTS.md for discounts, spec 0014 accepted, scope done
kris1027 Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions app/admin/(panel)/discounts/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <DiscountNotFound />;
return (
<DiscountEditor
code={code}
money={{ locale: env.STORE_LOCALE, currency: env.STORE_CURRENCY }}
timeZone={env.STORE_TIMEZONE}
/>
);
}
18 changes: 18 additions & 0 deletions app/admin/(panel)/discounts/new/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
return adminMetadata({ title: "New code" });
}

export default async function AdminNewDiscountPage() {
await requireAdmin();
return <NewDiscountPage currency={env.STORE_CURRENCY} timeZone={env.STORE_TIMEZONE} />;
}
28 changes: 28 additions & 0 deletions app/admin/(panel)/discounts/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
return adminMetadata({ title: "Discounts" });
}

export default async function AdminDiscountsPage({ searchParams }: PageProps<"/admin/discounts">) {
await requireAdmin();
const params = parseDiscountListParams(await searchParams);
return (
<DiscountsList
list={await getDiscountCodes(params)}
params={params}
dateFormat={{ locale: env.STORE_LOCALE, timeZone: env.STORE_TIMEZONE }}
money={{ locale: env.STORE_LOCALE, currency: env.STORE_CURRENCY }}
/>
);
}
11 changes: 9 additions & 2 deletions app/admin/(panel)/orders/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -15,12 +19,15 @@ export function generateMetadata(): Promise<Metadata> {

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 (
<AdminOrdersList
page={page}
params={params}
discountCode={discountCode}
dateFormat={{ locale: env.STORE_LOCALE, timeZone: env.STORE_TIMEZONE }}
/>
);
Expand Down
40 changes: 40 additions & 0 deletions docs/reviews/2026-10-18-feat-discounts-promo-codes.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 14 additions & 3 deletions docs/scope/scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading