Litestore · Developer guide
Commerce

Checkout

This page shows you how Litestore turns a cart into a Stripe payment and holds stock for the shopper while they pay.

When a shopper checks out, Litestore saves a fixed price quote, holds the stock they are buying, and sends them to Stripe's hosted payment page. Read this page before you change any part of checkout, especially anything that involves time limits.

After paying, the shopper comes back to /cart/success.

Shoppers don't pay on your own domain, and their card details never reach the application.

Because of this, Litestore cannot see the moment a shopper pays. It finds out afterwards, when Stripe sends a webhook. Most of what makes checkout difficult comes from that gap.

The steps of a checkout

  1. Litestore prices the cart in one pass and saves the result as an OrderIntent with its OrderIntentItem rows.
  2. reserveStockForCart takes the stock right away. It does this when checkout starts, not when the shopper pays.
  3. Litestore creates a Stripe Checkout Session and returns its url so the shopper can be redirected.
  4. The payment webhook confirms the held stock and creates the order.

Step 2 deserves a closer look. Litestore removes the stock from sale before any money has moved. The idea is that a shopper who has reached the payment page has a better claim to the item than one who has not.

Taking stock early means Litestore is holding it for someone, and that hold has to end at some point.

The saved quote

An OrderIntent is a short-lived, fixed quote. Litestore deletes it after 30 minutes.

It is not a record of an abandoned cart. Cart recovery works from the Cart, which lasts longer than the intent. See Scheduled Jobs for how that works.

The intent gives the webhook a finished quote to turn into an order, so it doesn't have to work out the price again. The cart and the catalog can both change. If the webhook priced the order from the live cart when the payment arrived, a price edit made during those thirty minutes would change what the customer is recorded as having bought.

The intent stores the money amounts itself:

amountSubtotal
discountTotal
amountTax
shippingCost
amountTotal
currency

Litestore always builds these so that amountSubtotal − discountTotal + amountTax + shippingCost === amountTotal. A CHECK constraint on the table enforces the same rule. The quote and the amount charged are the same number, so the webhook can turn the quote into an order without working anything out.

The time limits

Every time limit that affects checkout is defined in one file, so the limits can't fall out of step with each other:

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

RESERVATION_EXPIRY_MS is not written as 35 * 60 * 1000. It is calculated from CHECKOUT_WINDOW_MS, so nobody can change one without the other.

This was done to fix a real failure in production.

How Litestore once sold the same item twice

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

For most shoppers this never mattered. It only affected someone who reached the payment page and then took their time, for example while looking for a card, switching devices or waiting for their bank to confirm.

Here is what happened to that shopper when they were buying the last item 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 at this point. The money has already been taken, so Litestore must create the order. If it stopped because there was not enough stock, the customer would be charged with no order to show for it. So the code that confirms stock lowers it below zero on purpose and flags the order for review, instead of throwing the payment away.

What people saw was negative stock. The real cause was two numbers that nothing required to agree.

It mostly happened on the products with the least stock, and that follows from the numbers. For an oversell, someone had to buy the released item during the ten minutes between the hold expiring and the session closing. On a product with fifty in stock, nobody notices one unit briefly coming back. On a product with one left, that unit is exactly what waiting shoppers are watching for.

The stock hold must last longer than the payment window

A 15-minute hold once expired while the 30-minute Stripe session could still be paid. A slow payer buying the last unit lost their hold, the unit was sold again, and the scarcest products ended up with negative stock.

The extra five minutes also cover the time between the payment and the webhook arriving. This way, the webhook always finds the stock still held.

A test checks the rule

Calculating RESERVATION_EXPIRY_MS from CHECKOUT_WINDOW_MS makes it hard to break the rule by accident. It doesn't make it impossible, because someone could still rewrite the calculation.

So lib/__tests__/checkout-policy.test.ts checks how the values relate to each other instead of their exact numbers. You can change the checkout window from thirty minutes to an hour. You can't make the hold shorter than the window.

The test checks that:

  • RESERVATION_EXPIRY_MS is greater than CHECKOUT_WINDOW_MS
  • the gap between them is at least a minute, so it can't be a rounding effect
  • STOCK_COMMIT_TTL_SECONDS is at least three days, which covers 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 check protects shoppers. If the wait after an expired hold were longer than the checkout window, a real shopper whose hold ran out could never try again within the same session.

The seven-day stock key follows the same reasoning. Stripe keeps retrying a webhook it thinks failed for up to about three days. The key records that stock was already confirmed for a payment. If it expired before Stripe stopped retrying, a late retry would take the same items out of stock a second time.

Holding the stock

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

  1. It limits how often the cart can start checkout, using Redis.
  2. It rejects a pickup rate when no pickup point was chosen.
  3. It makes the cart wait if a previous hold expired recently.
  4. It keeps only the cart items whose variant is active and tracks inventory.
  5. It chooses the location that will fulfil the order.
  6. It takes the stock and marks the cart as holding it, in one transaction.
  7. It sends checkout.started after the transaction is saved.

Step 1 blocks checkout when it can't check the limit. If Redis is down, Litestore rejects the checkout instead of letting it through. Otherwise an outage would let someone drain stock by starting checkout over and over.

Step 5 can go two ways. If the shopper chose a pickup point at one of your own locations, Litestore holds the stock at that location. The shopper collects the order there, so the stock must come from there and never from another warehouse. Otherwise findFulfillmentLocation tries the warehouse suggested by the shipping rate first. If that doesn't work, it picks any active location that can fulfil the whole order.

Step 6 must happen all at once. Ask yourself:

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

Litestore takes the stock and sets the cart's hold flags in a single transaction. If any item is short, adjustInventoryInTransaction throws, and the database undoes every earlier stock change and the flag update together. So a crash in the middle can never leave stock taken without the cart recording that it holds it. Without this, items would vanish from sale with nothing to say who has them.

Step 7 comes after the transaction on purpose. If Litestore sent the event inside the transaction, it might announce a checkout that a rollback then undid.

Litestore writes the same timestamp to the cart row and to the event's occurredAt. The two values come from one clock reading, not two readings a few milliseconds apart.

Releasing the stock

A hold ends in one of three ways. The payment succeeds, the shopper abandons checkout, or the expiry cron clears it.

When the shopper abandons or the hold expires, releaseStockForCart runs. In one transaction, it claims the hold and puts the stock back:

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 how the release claims the hold. The payment webhook's commitStock runs the same update on the same row, for the opposite reason. Whichever transaction changes stockReserved first wins, and the other one does nothing.

This protects both sides:

  • If the payment already used the hold, the stock has been paid for. Putting it back would oversell.
  • If the release already happened, the webhook must take the stock now instead of assuming it is still held.

The stock must also go back in the same transaction as the claim. An earlier version cleared the flags first and then put stock back item by item in separate transactions. If it crashed in the middle of the loop, the hold flags were gone but the items were still taken out of stock. The store showed them as out of stock, and nothing would ever retry and put them back.

releaseExpiredReservations finds carts whose checkoutStartedAt is older than RESERVATION_EXPIRY_MS and releases them. If it fails to release a cart, it logs the error instead of ignoring it, because a silent failure would leave that stock held forever.

Limits against abuse

Two limits in policy.ts protect the code that holds stock.

A cart can start at most five checkouts per hour. Each start takes real stock, so without a limit, someone could start and abandon checkouts in a loop and empty your stock at no cost to them.

After a hold expires, the same cart must wait two minutes before it can hold stock again. This stops a fast cycle of holding, abandoning and holding again. Two minutes is still short enough that a shopper who simply ran out of time can try again.

Without Stripe

getPaymentProvider() throws when the Stripe keys are missing.

On a store with no payment setup, shoppers can still browse and add items to a cart. They can't start a checkout.

That is what it means when we say Stripe is optional: it's optional until you need to take money. The catalog, the cart, the pricing engine and the admin all run without a payment processor. Only charging a customer needs one.

Tax and coupons

Litestore sends tax to Stripe as its own line item and turns off Stripe's automatic_tax. The store's own tax provider works out the amount before Litestore creates the session. See Tax for details.

The reason is the saved quote again. If Stripe calculated tax during the hosted session, the customer would pay a total that the OrderIntent never contained. The webhook would then have to get the breakdown from Stripe instead of using the quote it already has.

Each coupon use claims a slot, and there is a limit per user. The claim happens atomically, so two checkouts at the same moment can't both use the last redemption.

Litestore claims the slot in Redis. It checks and increases both the overall limit and the per-user limit in a single atomic operation. If it checked them one after the other, a parallel checkout could slip in between the two checks. The real protection is a second check in the database, under FOR UPDATE, when the payment is captured. The Redis slot only shortens the time in which a shopper could be told a coupon works and then learn at payment that it doesn't.

The coupon hold's TTL is calculated from CHECKOUT_WINDOW_SECONDS, for the same reason as the stock hold. If the coupon hold expired in the middle of checkout, another cart could claim the same last slot.

Adding a new time limit

If you add a time limit that affects any part of checkout, put it in lib/checkout/policy.ts. If it must last longer than the payment window, calculate it from CHECKOUT_WINDOW_MS.

If it must line up with an outside system's timing, such as a processor's retry schedule or a session lifetime, add an assertion to lib/__tests__/checkout-policy.test.ts. The assertion should check how the values relate, not the exact number.

Don't start by asking how long the limit should be. Ask this instead:

Which other time limit must this one outlast, and what goes wrong in the gap if it doesn't?

On this page