Litestore · Developer guide

Checkout

Learn how a cart becomes a Stripe Checkout Session, how long stock is reserved, and why the reservation window must outlive the payment window.

Checkout freezes a quote, reserves stock, creates a Stripe Checkout Session and redirects the shopper to Stripe's hosted page. The shopper returns to /cart/success.

Payment is not collected on your own domain. The card details never touch the application.

The consequence of that design is that the moment of payment happens somewhere the application cannot observe directly. It learns about the payment afterwards, from a webhook. Almost everything difficult about checkout follows from that gap.

The lifecycle

  1. The cart is priced in a single pass and frozen into an OrderIntent with its OrderIntentItem rows.
  2. reserveStockForCart decrements stock immediately — at checkout start, not at payment.
  3. A Stripe Checkout Session is created and its url is returned for redirect.
  4. The paying webhook commits the reservation and creates the order.

Step 2 is the one worth pausing on. Stock is taken out of circulation before any money has moved, on the theory that a shopper who has reached the payment page has a stronger claim to the unit than a shopper who has not.

That choice is what creates a reservation, and a reservation is a promise with a deadline.

The frozen quote

An OrderIntent is an ephemeral frozen quote, purged after 30 minutes.

It is not an abandoned-cart record. Recovery works from the Cart, which outlives the intent — see Scheduled Jobs.

The intent exists so the webhook has something to promote rather than something to recompute. A cart is mutable; a catalog is mutable. If the webhook priced the order from the live cart at the moment payment landed, a price edit made during those thirty minutes would change what the customer is recorded as having bought.

The intent's own rows carry the money:

amountSubtotal
discountTotal
amountTax
shippingCost
amountTotal
currency

The identity amountSubtotal − discountTotal + amountTax + shippingCost === amountTotal holds by construction, and the same identity is a CHECK constraint on the table. The quote and the charge are the same number, which is what lets the webhook promote instead of discover.

The timing policy

Every duration that gates checkout lives in one file so the windows cannot drift apart:

lib/checkout/policy.ts
/** How long a started checkout (the OrderIntent) stays payable before it expires. */
export const CHECKOUT_WINDOW_MS = 30 * 60 * 1000
export const CHECKOUT_WINDOW_SECONDS = CHECKOUT_WINDOW_MS / 1000

export const RESERVATION_EXPIRY_MS = CHECKOUT_WINDOW_MS + 5 * 60 * 1000

/** Stock-commit idempotency key lives 7 days (must outlive Stripe's retry window). */
export const STOCK_COMMIT_TTL_SECONDS = 60 * 60 * 24 * 7

/** Checkout abuse prevention: max initiations per cart per window. */
export const MAX_CHECKOUT_ATTEMPTS_PER_HOUR = 5
export const CHECKOUT_ATTEMPT_WINDOW_SECONDS = 60 * 60

/** Cooldown after a reservation expires before the same cart can re-reserve. */
export const COOLDOWN_AFTER_EXPIRY_MS = 2 * 60 * 1000

Note that RESERVATION_EXPIRY_MS is not written as 35 * 60 * 1000. It is derived from CHECKOUT_WINDOW_MS, so the two cannot be edited apart.

That derivation is the fix for a real production failure.

The oversell

The reservation used to be a flat fifteen minutes, set in one file. The payable Stripe session was thirty minutes, set in another. Nothing in the code connected the two numbers, and nothing checked that one was larger than the other.

For most shoppers this never mattered. The gap only opens for someone who reaches the payment page and then takes their time — a shopper hunting for a card, switching devices, or waiting on a bank confirmation.

Here is what happened to that shopper when the item they were buying was the last one in stock:

t=0     checkout starts
        → stock decremented, 15-minute hold begins
        → Stripe session created, payable for 30 minutes

t=15m   the hold expires
        → the unit is released back into sellable stock
        → the Stripe session is still open and still payable

t=16m   a second shopper buys the released unit
        → stock is now legitimately zero

t=25m   the first shopper pays
        → Stripe accepts, because the session has not expired
        → the webhook arrives with a captured payment
        → it looks for the reservation and finds nothing
        → it must decrement again, for an order that is already paid

        stock: 0 − 1 = −1

The webhook cannot refuse. The money has already been captured, so the order must be created; aborting on insufficient stock would leave a charged customer with no order at all. The commit path therefore decrements into the negative deliberately and flags the order for review rather than throwing away the charge.

So the symptom was negative stock. The cause was two numbers that were never required to agree.

And the reason it clustered on the scarcest SKUs is arithmetic, not coincidence. Overselling requires the released unit to actually be resold inside the ten-minute window between the hold expiring and the session closing. On a product with fifty units in stock, nobody notices a single unit briefly returning to the pool. On a product with one unit left, that unit is exactly what the queue of waiting shoppers is watching.

The reservation must outlive the payment window

A 15-minute hold once expired while the 30-minute Stripe session was still payable, so a slow payer on the last unit lost their reservation and the unit was resold — a structural oversell that surfaced as negative stock on the scarcest SKUs.

The five-minute grace also covers the gap between payment and the paying webhook, so the commit always finds its reservation.

The invariant is tested, not just documented

Deriving RESERVATION_EXPIRY_MS from CHECKOUT_WINDOW_MS makes the relationship hard to break by accident. It does not make it impossible — someone could still rewrite the derivation.

lib/__tests__/checkout-policy.test.ts pins the relationships rather than the absolute values. Changing the checkout window from thirty minutes to an hour is allowed. Making the hold shorter than the window is not.

It asserts:

  • RESERVATION_EXPIRY_MS is greater than CHECKOUT_WINDOW_MS
  • the grace between them is at least a minute, so it cannot be a rounding artifact
  • STOCK_COMMIT_TTL_SECONDS is at least three days, covering Stripe's webhook retry window
  • CHECKOUT_WINDOW_SECONDS * 1000 equals CHECKOUT_WINDOW_MS
  • MAX_CHECKOUT_ATTEMPTS_PER_HOUR is above zero and no more than twenty
  • COOLDOWN_AFTER_EXPIRY_MS is positive and shorter than the checkout window

The last one has a shopper-facing reason. If the cooldown after an expired reservation were longer than the checkout window itself, a legitimate shopper whose reservation lapsed could never retry inside a single session.

The seven-day stock-commit key has the same shape of reasoning. Stripe retries a webhook it believes failed for up to about three days. If the idempotency key that records "stock was already committed for this payment" expired first, a late retry would decrement the same units a second time.

Reserving the stock

reserveStockForCart in lib/checkout/stock-reservation.ts does the work. It runs several gates before it touches inventory:

  1. Rate-limit the cart's checkout attempts in Redis.
  2. Reject a pickup rate with no chosen pickup point.
  3. Enforce the cooldown if a previous reservation expired recently.
  4. Filter the cart to items whose variant is active and inventory-tracked.
  5. Choose the fulfillment location.
  6. Decrement stock and flag the cart, in one transaction.
  7. Emit checkout.started after the transaction commits.

Step 1 fails closed. If Redis is unavailable the checkout is rejected, not allowed through. Rate limiting that cannot be enforced during an outage is an invitation to deplete stock with rapid repeated reservations, so the outage blocks checkout rather than removing the limit.

Step 5 has two paths. When the shopper chose a pickup point at one of your own locations, fulfillment is pinned to that location — the order is collected there, so it must be reserved there and never silently sourced from another warehouse. Otherwise findFulfillmentLocation prefers the warehouse hinted by the shipping rate and falls back to any active location that can fulfil the whole order.

Step 6 is the part that has to be atomic:

If the process dies halfway through reserving five items, what does the database look like?

The guarded decrements and the cart's reservation flags are written in a single transaction. adjustInventoryInTransaction throws on insufficient stock, which rolls back every prior decrement and the flag update together. A crash mid-reservation therefore cannot leave decremented-but-unflagged stock — units that are gone from the sellable pool with nothing recording that they are held.

Step 7 is deliberately after the commit. An event emitted inside the transaction would announce a checkout that a rollback then erased.

The same instant is stamped on the cart row and on the event's occurredAt, so the recorded occurrence time is one value rather than two clocks read a few milliseconds apart.

Releasing a reservation

A reservation ends one of three ways: the payment succeeds, the shopper abandons, or the expiry cron reaps it.

The abandon and expiry paths both go through releaseStockForCart, which claims the reservation and restores the stock in one transaction:

UPDATE cart SET stockReserved = false WHERE id = ? AND stockReserved = true

count === 0 → someone else already consumed it → restore nothing
count === 1 → we own the release → restore the units

The conditional update is the claim. It contends with the payment webhook's commitStock, which runs the same update on the same row for the opposite reason. Whichever transaction flips stockReserved first wins, and the loser does nothing.

That matters in both directions:

  • If the payment already consumed the reservation, the stock is paid for. Restoring it would oversell.
  • If the release already happened, the webhook must decrement now rather than assume the reservation is still there.

The restores must also commit with the claim. An earlier version flipped the flags first and restored stock in separate per-item transactions, so a crash mid-loop cleared the reservation flags while the units stayed decremented — a phantom stockout, with no retry path that would ever restore them.

releaseExpiredReservations sweeps carts whose checkoutStartedAt is older than RESERVATION_EXPIRY_MS. A failure to release one cart is logged rather than swallowed, because a silent failure leaves stock reserved indefinitely.

Abuse limits

Two limits sit in front of the reservation path, both in policy.ts.

A cart may start at most five checkouts per hour. Each initiation decrements real stock, so an unbounded loop of started-and-abandoned checkouts is a stock-depletion attack that costs the attacker nothing.

After a reservation expires, the same cart waits two minutes before it can reserve again. That blocks the rapid reserve-abandon-reserve cycle while staying short enough that a shopper who genuinely timed out can simply try again.

Without Stripe

getPaymentProvider() throws when the Stripe keys are absent.

Browsing and adding to a cart work on a store with no payment configuration; starting a checkout does not.

That is the practical meaning of Stripe being "optional" — optional up to the point of taking money. The catalog, the cart, the pricing engine and the admin all run without a payment processor. Only the act of charging requires one.

Tax and coupons

Tax is charged as its own line item with Stripe's automatic_tax disabled, because the amount is resolved by the store's own provider before the session is created — see Tax.

The reason is the frozen quote again. If Stripe computed tax during the hosted session, the total the customer paid would be a number the OrderIntent never contained, and the webhook would have to discover the breakdown from the processor instead of promoting the quote it already holds.

A coupon takes an atomic slot with a per-user limit, so two simultaneous checkouts cannot both consume the last redemption.

The slot is claimed in Redis, and both the global limit and the per-user limit are checked and incremented in a single atomic operation. Checking them one after the other would let a parallel checkout slip between the two checks. The database re-check under FOR UPDATE at capture is the hard guard; the Redis slot exists to shrink the window in which a shopper is told a coupon works and then told at payment that it does not.

The coupon reservation TTL is derived from CHECKOUT_WINDOW_SECONDS for the same reason the stock hold is: an advisory count that expires mid-checkout would let a concurrent cart reserve the same last slot.

Where a new duration goes

If you are adding a timing value that gates any part of checkout, put it in lib/checkout/policy.ts and derive it from CHECKOUT_WINDOW_MS if it has to outlive the payable window.

If it has to agree with an external system's window — a processor's retry schedule, a session lifetime — add an assertion to lib/__tests__/checkout-policy.test.ts that pins the relationship rather than the number.

The question worth asking is not "how long should this be?" It is:

Which other window does this one have to survive, and what happens in the gap if it does not?

On this page