Litestore · Developer docs
Commerce

Checkout

Change checkout safely, keeping the stock-hold and payment-window time limits in step with each other.

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 the store's own domain, and their card details never reach the application.

Because of this, Litestore isn't involved when the payment happens. It only receives the result when Stripe sends a webhook afterwards, and most of the complexity in checkout comes from that delay.

Checkout flow

Cart pricing

Litestore prices the cart in one pass and saves the result as an OrderIntent with its OrderIntentItem rows.

Stock reservation

It takes the stock right away, when checkout starts, not when the shopper pays.

Stripe Checkout Session

Litestore returns the session's url so the shopper can be redirected.

Payment webhook

The webhook confirms the held stock and creates the order.

Litestore removes the stock from sale before the shopper pays, because a shopper who has reached the payment page should have priority over one who hasn't. Since the stock is held for that shopper, the hold also needs to expire at some point.

Checkout quote

An OrderIntent is a short-lived, fixed quote that stays payable for 30 minutes. The weekly data cleanup deletes an unpaid one a day after it expired, and a consumed one after ORDER_INTENT_RETENTION_DAYS (90). An intent the payment reconciler flagged as paid without an order is never deleted. It is not a record of an abandoned cart. Cart recovery works from the Cart, which lasts longer than the intent, and Scheduled Jobs explains 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, and 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 recalculating anything.

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:

modules/checkout/checkout-timing.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
ConstantWhat it controlsValue
CHECKOUT_WINDOW_MSHow long a started checkout stays payable.30 minutes
RESERVATION_EXPIRY_MSHow long Litestore holds the stock.The checkout window plus 5 minutes
STOCK_COMMIT_TTL_SECONDSHow long the stock-commit idempotency key lives.7 days
MAX_CHECKOUT_ATTEMPTS_PER_HOURHow many checkouts one cart may start per window.5
CHECKOUT_ATTEMPT_WINDOW_SECONDSThe length of that window.1 hour
COOLDOWN_AFTER_EXPIRY_MSHow long a cart waits after a hold expires before it can hold stock again.2 minutes

RESERVATION_EXPIRY_MS isn't written as 35 * 60 * 1000. It's calculated from CHECKOUT_WINDOW_MS, so changing the checkout window also changes the stock hold.

The stock hold must last longer than the payment window

If the hold expired while the Stripe session could still be paid, a slow payer could lose the last unit to another shopper and then pay for it anyway, which leaves the product with negative stock.

Overselling

If the stock hold were shorter than the payment window, a shopper buying the last unit could lose it while their Stripe session was still open:

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

hold    the hold expires before the session closes
expires → the unit is released back into sellable stock

later   a second shopper buys the released unit
        → stock is now zero

later   the first shopper pays
        → Stripe accepts, because the session hasn't expired
        → the webhook finds no reservation and decrements again

        stock: 0 − 1 = −1

The webhook can't reject a captured payment, so Litestore always creates the order. When there isn't enough stock, the code lowers stock below zero and flags the order for review. Products with the least stock are the most exposed, because another shopper is likely to buy a single released unit straight away.

The hold lasts five minutes longer than the payment window. Those five minutes also cover the delay between the payment and the webhook, so the webhook always finds the stock still held.

Policy test

Calculating RESERVATION_EXPIRY_MS from CHECKOUT_WINDOW_MS makes it hard to break the rule by accident, but someone could still rewrite the calculation. test/unit/checkout-timing.test.ts therefore checks how the values relate to each other instead of their exact numbers. Changing the checkout window from thirty minutes to an hour passes the test, but making the hold shorter than the window fails it.

The test checks each of these relationships:

  • 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 that didn't get a successful response for up to about three days. The key records that Litestore already confirmed stock for a payment, and if it expired before Stripe stopped retrying, a late retry would take the same items out of stock a second time.

Stock holds

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

Checkout rate limit

Litestore uses Redis for this limit, and it 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.

Pickup validation

Litestore rejects a pickup rate when the shopper chose no pickup point.

Expiry cooldown

If a previous hold expired recently, the cart has to wait.

Tracked items

Litestore keeps only the cart items whose variant is active and tracks inventory.

Fulfilment location

If the shopper chose a pickup point at one of the store's 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.

Reservation transaction

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.

The single transaction matters if the process crashes partway through holding several items. A crash can never leave stock taken without the cart recording that it holds it. Without the transaction, items could disappear from sale with no record of which cart holds them.

checkout.started event

This step 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. Both values come from the same clock reading, so they match exactly.

Stock release

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 release claims the hold with this conditional update. The payment webhook's commitStock runs the same update on the same row when it confirms the stock. Whichever transaction changes stockReserved first wins, and the other one does nothing.

Who winsWhat the other side does
The payment used the hold first.The release puts nothing back, because the stock has been paid for and putting it back would oversell.
The release happened first.The webhook takes the stock now instead of assuming it is still held.

Litestore must also put the stock back in the same transaction as the claim. If the flags were cleared first and the stock returned item by item in separate transactions, a crash in the middle of the loop would leave the items out of stock with no hold flag left to retry from.

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.

Abuse limits

Two limits in checkout-timing.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 the store's 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, and two minutes is still short enough that a shopper who simply ran out of time can try again.

Stripe not configured

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, but they can't start a checkout.

You only need Stripe once you're ready to take payments. The catalog, the cart, the pricing engine and the admin all run without a payment processor, and 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, and Tax has the details.

This keeps the saved quote accurate. 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, because a parallel checkout could slip in between two separate checks. The authoritative check happens a second time in the database, under FOR UPDATE, when the payment is captured. The Redis slot reduces the chance that a shopper is told a coupon works and only finds out at payment that it doesn't.

Litestore calculates the coupon hold's TTL 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 time limits

Policy file

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

Policy assertions

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 test/unit/checkout-timing.test.ts. The assertion should check how the values relate, not the exact number.

When you choose a value, first work out which other time limits this one has to outlast, and what would go wrong if it expired before them.

On this page