8 min read

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:

ScreenPurpose
Product listingBrowse, see price + stock
Product detailDescription, add-to-cart
CartLine items, quantities, subtotal
CheckoutStripe Payment Element (card entry)
Order confirmation"Thanks" + order summary
Order historyPast 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

MethodPathBodyAuth
GET/api/products—none
POST/api/cart/items{ productId, quantity }required
POST/api/checkout— (uses current cart)required
POST/api/webhooks/stripeStripe 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:

code
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 second payment_intent.succeeded event is a no-op, not a double-fulfillment.
  • Stock reservation on payment failure/timeout — handled by the payment_intent.payment_failed/canceled webhook 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_cents at checkout time (not the price when the item was added to the cart), and use that value for both the charge and the unit_price_cents snapshot.
  • Money as floats — never. 19.99 in floating point is not exactly 19.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

code
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.