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
currencyLitestore 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:
/** 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| Constant | What it controls | Value |
|---|---|---|
CHECKOUT_WINDOW_MS | How long a started checkout stays payable. | 30 minutes |
RESERVATION_EXPIRY_MS | How long Litestore holds the stock. | The checkout window plus 5 minutes |
STOCK_COMMIT_TTL_SECONDS | How long the stock-commit idempotency key lives. | 7 days |
MAX_CHECKOUT_ATTEMPTS_PER_HOUR | How many checkouts one cart may start per window. | 5 |
CHECKOUT_ATTEMPT_WINDOW_SECONDS | The length of that window. | 1 hour |
COOLDOWN_AFTER_EXPIRY_MS | How 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 = −1The 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_MSis greater thanCHECKOUT_WINDOW_MS- the gap between them is at least a minute, so it can't be a rounding effect
STOCK_COMMIT_TTL_SECONDSis at least three days, which covers Stripe's webhook retry windowCHECKOUT_WINDOW_SECONDS * 1000equalsCHECKOUT_WINDOW_MSMAX_CHECKOUT_ATTEMPTS_PER_HOURis above zero and no more than twentyCOOLDOWN_AFTER_EXPIRY_MSis 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 unitsThe 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 wins | What 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.
Related
Payments
Litestore takes payments through Stripe, and the database keeps the payment record correct.
Inventory
Litestore adds up stock across locations, the database limits stock changes, and concurrent checkouts can't sell the same unit.
Pricing
Litestore finds a variant's price for each channel and currency, applies sale prices when they're active and stores amounts at a fixed precision.