New problems: money (which has to be exact), a real third-party integration whose responses arrive asynchronously (Stripe webhooks, not a direct API response), and inventory correctness under concurrency where the cost of getting it wrong is a customer paying for a product you don't have.
1. Problem framing
A small shop wants to sell a catalog of products online: browse, add to cart, pay by card, get a confirmation. The business cannot oversell a product — if stock is 1, exactly one paying customer gets it, even if ten people click "buy" in the same second.
2. PRD
Goal: Let a customer browse products, check out with a card via Stripe, and receive a confirmed order — without ever overselling inventory.
Non-goals:
- Multiple payment methods (card via Stripe only)
- Coupon/discount codes
- Multi-seller marketplace
- Shipping rate calculation (flat-rate shipping only)
- Refunds/returns workflow
User stories:
- As a shopper, I want to browse products and see current stock, so I know what's available.
- As a shopper, I want to add items to a cart and adjust quantities.
- As a shopper, I want to pay by card and get a confirmation, so I know my order went through.
- As a shopper, I want an email receipt.
- As the shop, I want it to be impossible to sell more units than are in stock, even under concurrent checkouts.
Success metrics:
- Zero oversold orders, ever, under concurrent checkout load
- Payment confirmation reaches the customer within seconds of Stripe confirming
MVP cut line: catalog + cart + Stripe checkout + inventory correctness + email receipt. Discounts, refunds, and multi-seller are deferred.
3. Wireframes & UX flow
Screen inventory:
| Screen | Purpose |
|---|---|
| Product listing | Browse, see price + stock |
| Product detail | Description, add-to-cart |
| Cart | Line items, quantities, subtotal |
| Checkout | Stripe Payment Element (card entry) |
| Order confirmation | "Thanks" + order summary |
| Order history | Past orders, status |
4. HLD
The critical shape here: card data flows client → Stripe directly,
never through our server. Our server only ever sees a PaymentIntent id and
a client_secret. That one design choice removes almost all PCI-DSS scope
from this app — worth an ADR on its own.
5. Data model / ERD
unit_price_cents is copied onto ORDER_ITEM at checkout time, not
read live from PRODUCT.price_cents — a price change next month must never
rewrite what a customer was actually charged last month. All money fields
are integer cents; nothing here is a float.
6. API design + flow mapping
| Method | Path | Body | Auth |
|---|---|---|---|
| GET | /api/products | — | none |
| POST | /api/cart/items | { productId, quantity } | required |
| POST | /api/checkout | — (uses current cart) | required |
| POST | /api/webhooks/stripe | Stripe event (raw body) | Stripe signature |
| GET | /api/orders | — | required, own orders |
Flow: checkout — reserve stock, hand off to Stripe
Flow: Stripe webhook — the actual source of truth for payment
The order's real status is decided by the webhook, never by the checkout response — the checkout endpoint only ever hands the user off to Stripe. This is the core asynchronous-integration lesson of this app: don't assume "the API call returned" means "the transaction happened."
7. LLD
Module breakdown:
app/api/checkout/route.ts — reserve stock, create PaymentIntent
app/api/webhooks/stripe/route.ts — signature verification + state transition
lib/inventory.ts — the FOR UPDATE reservation transaction
lib/money.ts — integer-cents helpers (never floats)
Edge cases worth naming explicitly:
- Webhook signature verification needs the raw request body — Next.js parses JSON by default; the raw bytes must be read explicitly before parsing, or Stripe's signature check fails on every single webhook. This is the single most common Stripe-integration bug in the wild.
- Duplicate webhook delivery — Stripe explicitly documents that events
can be delivered more than once. The handler checks the payment's current
status before transitioning it; if it's already
succeeded, a secondpayment_intent.succeededevent is a no-op, not a double-fulfillment. - Stock reservation on payment failure/timeout — handled by the
payment_intent.payment_failed/canceledwebhook restoring stock; no cron job needed, because Stripe already tracks PaymentIntent expiry and tells us about it. - Price drift between add-to-cart and checkout — re-validate current
PRODUCT.price_centsat checkout time (not the price when the item was added to the cart), and use that value for both the charge and theunit_price_centssnapshot. - Money as floats — never.
19.99in floating point is not exactly19.99; every amount is an integer count of cents from the database up through the Stripe API call.
8. NFRs
- Performance: checkout latency includes a live Stripe API round trip (typically 200–500ms) — this is not the hot path the way app 3's redirect was, and forcing it faster isn't worth the complexity.
- Security: card data never reaches our server (Stripe Elements handles
it client-side) — this is the single biggest security decision in this
app, since it removes almost all PCI-DSS scope. Webhook requests are
rejected unless their signature verifies against
STRIPE_WEBHOOK_SECRET. Orders are only visible to their owner. - Observability: log every webhook event received and its outcome (processed / no-op / rejected) — with real money involved, "did we actually process this payment" has to be answerable from logs alone.
9. ADRs
ADR-1: Reserve stock synchronously at checkout, release via Stripe webhooks
Context: need to prevent overselling without running our own cron job to
sweep abandoned checkouts.
Decision: decrement stock inside the same transaction that creates the
pending order (row-locked); restore it from the
payment_intent.payment_failed/canceled webhook if payment doesn't
complete.
Consequence: correctness without extra scheduler infrastructure — Stripe's
own PaymentIntent expiry does the timeout job for us.
ADR-2: Stripe Elements / PaymentIntents — card data never touches our server
Context: handling raw card numbers ourselves means full PCI-DSS compliance
scope.
Decision: client talks to Stripe directly for card entry and confirmation;
our server only ever handles PaymentIntent ids.
Consequence: dramatically smaller compliance and security burden, at the
cost of the checkout UI needing Stripe's client library.
ADR-3: unit_price_cents is snapshotted onto OrderItem, not joined live
Context: product prices change over time.
Decision: copy the price at checkout time into the order line item.
Consequence: past orders remain historically accurate regardless of future
price changes; requires one extra column instead of a join.
ADR-4: Webhook idempotency via status-check, not just signature verification
Context: Stripe delivers webhooks at-least-once — the same event can arrive
more than once.
Decision: every state transition checks the current status first
(e.g., only move pending → paid, never paid → paid again).
Consequence: safe to receive the same webhook multiple times, which will
happen in production regardless of anything we do.
ADR-5: All money as integer cents, never floating point
Context: floating-point arithmetic on currency produces rounding errors.
Decision: every price/amount column and calculation uses integer cents.
Consequence: slightly less human-readable in raw form, but eliminates an
entire class of off-by-a-cent bugs.
10. Deploy plan
- Environments: local (Stripe test mode + Stripe CLI forwarding
webhooks to localhost), Preview (Stripe test mode), Production (Stripe
live mode) — each with its own
STRIPE_WEBHOOK_SECRET. - CI: typecheck, lint, plus a test asserting a webhook with an invalid signature is rejected with 400, and a test simulating duplicate webhook delivery asserts no double-fulfillment.
- Definition of done: a full purchase completes end to end in Stripe test mode, stock decrements exactly once, a confirmation is visible in order history, deployed with live webhooks verified via Stripe's dashboard event log.
- Rollback: Vercel instant rollback; in-flight PaymentIntents are Stripe's state, not ours, so a rollback doesn't lose payment state — only our own order-status bookkeeping needs to catch back up via webhook replay.
11. Retro — "at 10x"
The scenario that actually breaks this design isn't 10x steady traffic —
it's a flash sale: hundreds of concurrent checkouts against one row-
locked product with limited stock. The FOR UPDATE lock that guarantees
correctness also serializes every checkout attempt on that product, one at
a time, which becomes a visible latency spike right when it matters most.
At that scale I'd move hot-product stock into a fast in-memory counter
(Redis, using DECR with a floor at zero) synced back to Postgres, rather
than locking a database row directly on every attempt — the same caching
instinct from app 3, applied to inventory instead of redirects. I'd also
move webhook processing off the inline handler and onto a queue, so a slow
downstream step (like the email send) can never cause Stripe to see a
timeout and retry — which is exactly the queue pattern app 7 introduces
properly.