Payments
Change checkout, refunds or order totals without breaking the payment record that the database protects.
Litestore takes payments through Stripe and records every movement of money in a ledger that the database protects. Read this page when you work on checkout, refunds or anything else that changes how much an order has paid.
Stripe is the only payment provider Litestore supports today. The PaymentProvider interface in platform/types/payments.ts is still there, and a second processor would implement it. There is no registry behind the interface, though, and Litestore picks the provider with a simple if branch.
This is intentional. A registry for choosing between providers isn't useful while there's only one, and adding a second provider means adding another branch, which is about as much work as adding an entry to a map.
Payment provider
export function getPaymentProvider(name?: string): PaymentProvider<Stripe.Event> {
if (name && name !== "stripe") {
throw new Error(`Payment provider "${name}" not found`)
}
// …constructs and memoizes StripeProvider, or throws when unconfigured
}getPaymentProvider checks name instead of ignoring it. The storefront checkout action passes name as an optional string (provider: z.string().optional()). If the value is unknown, the function must throw an error instead of quietly falling back to Stripe, because a shopper who asked for a processor the store doesn't have should never be charged through a different one without being told.
Creating the Stripe SDK client takes some work, so Litestore creates the provider once and reuses it across requests.
Configuration
getPaymentConfig() reads two environment variables, and Litestore turns Stripe on only when you set both.
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRETIf you set only one of them, Stripe stays off. With a secret key but no webhook secret, Litestore could create a payment session and take money, but it could not verify the webhook that tells it the money arrived. Litestore therefore treats that setup as if Stripe were not configured at all.
When Stripe is not enabled, getPaymentProvider() throws. That is why shoppers can still browse a store without Stripe keys but cannot check out.
Payment ledger
Litestore records every settled payment as a PaymentTransaction row.
| Field | What it stores |
|---|---|
| Order | The order the row belongs to. |
type | One of authorization, capture, refund, dispute or adjustment. |
amount | The amount as Decimal(12, 2). |
| Currency | The row's currency, which must match the order's. |
| Provider | The provider name. |
| Processor id | The processor's own id for that movement of money, which is a charge, refund, dispute or balance-transaction id. |
The ledger only accepts new rows. The database enforces this with two triggers in prisma/migrations/20260726143000_baseline_with_db_guarantees/migration.sql, so it doesn't depend on application code following a convention.
Write guard
litestore_guard_payment_transaction_write runs before every insert, update and delete on the table. It rejects every UPDATE and DELETE straight away with this error:
PaymentTransaction is append-only; write a correction row insteadOn an insert, it also loads the currency of the parent order. If the new row uses a different currency, the trigger raises an error, which keeps all of an order's ledger rows in one currency.
To correct a mistake, you add a new row instead of editing an existing one. As a result, the total at any point is always SUM(ledger), and nobody can change earlier rows to hide a mismatch.
Refund cap
litestore_guard_payment_refund_cap runs after an insert, but only when the new row's type is refund. It reads Order.amountTotal with FOR UPDATE and adds up every refund row for that order again. If the refunds exceed the order total by more than a cent, it raises this error:
refund ledger would exceed order total for order %: refunds %, total %The FOR UPDATE lock prevents a race condition. Without it, two refunds issued at the same moment could both read the total from before either refund, both find room for the refund, and both save. The lock makes the second refund wait, so when it runs, the ledger it adds up already includes the first refund.
The database enforces the refund cap
The refund cap is a trigger that adds up the refunds again, under a row lock, on every insert. Application code cannot get past it even with a bug, and neither can a direct SQL write.
Webhook replays
PaymentTransaction has a unique constraint, @@unique([provider, providerId, type]).
Stripe sends a webhook again when it doesn't receive a successful response to the first delivery. Without the constraint, the retry would insert a second row for the same charge, and the order's captured total would double. With the constraint, the retry breaks the unique index and adds nothing to the ledger.
The key includes type because the same processor id can legitimately appear as more than one kind of movement. For example, a capture and a later dispute can refer to the same id, so the id only has to be unique per type.
Reconciliation
Normally Litestore learns about a payment from the webhook, but a webhook can fail to arrive. The important case is when Stripe charges the customer but checkout.session.completed never reaches Litestore, so Litestore creates no order.
jobs/cron.payment-reconcile.ts runs every thirty minutes to catch this. Each open OrderIntent stores the Stripe session id it was created with. The job asks Stripe directly whether each session was paid, and for the ones that were, it runs the same code the webhook would have run.
It checks an intent only when all four conditions hold.
| Condition | Why |
|---|---|
The intent is still open. | A webhook that arrived would have consumed, settled or expired it. |
Its provider is stripe, and it has a session id. | The job can only ask Stripe about Stripe sessions. |
| It is not flagged yet. | Flagged intents wait for an operator and are never retried automatically. |
| It is older than an hour. | A newer intent may still be in checkout, or Stripe may still be retrying it on its normal schedule. |
Each run checks at most 25 intents, oldest first. The job reads from Stripe one session at a time, so the cap keeps each run within the route's maxDuration. Starting with the oldest means the customers who have waited longest get their orders first, and later runs work through the rest.
Running the webhook code again is safe because only one code path creates orders. That handler sets a mark in Redis, and Order.externalPaymentId is unique. If the reconciler and a late webhook run at the same time, they still create only one order.
Drift views
OrderMoneyDrift and CustomerMoneyDrift show totals that don't match the ledger. They are database views defined in the baseline migration, not Prisma models.
| View | What it lists |
|---|---|
OrderMoneyDrift | Orders whose cached money totals differ from SUM(PaymentTransaction) by more than a cent, and orders whose refunded amount is higher than the order total. |
CustomerMoneyDrift | Customers whose Customer.totalSpent and Customer.orderCount don't match their paid orders. |
A cached total can go out of date, and these views check it against the ledger at any time.
Adding money changes
Related
Checkout
A cart becomes a Stripe Checkout Session, Litestore holds stock for a fixed time, and that hold must last longer than the payment window.
Guarantees
Postgres enforces some rules about money, refunds, stock, identity, visibility and caching, application code enforces others, and Litestore doesn't guarantee the rest.
Webhooks
Litestore signs and retries outgoing deliveries, turns off an endpoint that keeps failing, and handles incoming Stripe and Resend callbacks on separate routes.