Payments
This page shows you how Litestore takes payments through Stripe and how the database keeps the payment record correct.
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 types/payments.ts is still there, and a second processor would implement it. There is no registry behind the interface, though. Litestore picks the provider with a simple if branch.
This is a deliberate choice, not unfinished work. A system for ranking providers adds nothing when there is only one provider. Adding a second provider means adding a branch, which is no more work than adding an entry to a map.
Choosing the provider
export function getPaymentProvider(name?: string): PaymentProvider {
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. A shopper who asked for a processor the store doesn't have should never be charged through a different one without knowing.
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:
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRETLitestore turns Stripe on only when you set both.
Setting just one of them does not give you a partly working setup. With a secret key but no webhook secret, Litestore could create a payment session and take money. It could not verify the webhook that tells it the money arrived, though. So Litestore treats that setup as if Stripe were not configured at all.
When Stripe is not enabled, getPaymentProvider() throws. This is why a store without Stripe keys can still be browsed, but shoppers cannot check out.
The ledger
Litestore records every settled payment as a PaymentTransaction row.
Each row stores the order it belongs to, a type, an amount as Decimal(12, 2), a currency and the provider name. It also stores the processor's own id for that movement of money, which is a charge, refund, dispute or balance-transaction id.
The types are:
authorization
capture
refund
dispute
adjustmentYou can only add rows to the ledger, never change or remove them. The database enforces this, not just a coding convention. Two triggers in prisma/migrations/20260726143000_baseline_with_db_guarantees/migration.sql do the work.
The 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. This keeps all of an order's ledger rows in one currency.
To correct a mistake, you add a new row. You never edit an old one. Because of this, you can always add up the ledger again: the total at any moment is SUM(ledger), and nobody can rewrite history to hide a mismatch.
The 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 is what makes this safe. Without it, two refunds issued at the same moment could both read the total from before either refund, both decide there is room, and both save. The lock makes the second refund wait. When it runs, the ledger it adds up already includes the first refund.
Refunds are capped by the database, not the application
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.
Replayed webhooks
PaymentTransaction has a unique constraint, @@unique([provider, providerId, type]).
Stripe sends a webhook again when it thinks the first delivery failed. 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 rightly appear as more than one kind of movement. For example, a capture and a later dispute can refer to the same id. So each id must be unique for each type, not overall.
Reconciliation
Normally Litestore learns about a payment from the webhook. A webhook is a network delivery, though, and it may never arrive.
The case that matters is this one: Stripe charges the customer, but checkout.session.completed never reaches Litestore, so no order is created.
functions/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. For the ones that were, it runs the same code the webhook would have run.
It chooses which intents to check carefully:
- The intent must still be
open. A webhook that arrived would have consumed, settled or expired it. - Its
providermust bestripe, and it must have a session id. - It must not be flagged yet. Flagged intents wait for an operator and are never retried automatically.
- It must be 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. So if the reconciler and a late webhook run at the same time, they still create only one order.
The 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. OrderMoneyDrift lists 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 does the same check for Customer.totalSpent and Customer.orderCount against the customer's paid orders.
Litestore keeps these views because a cached total may be out of date, but it must always be possible to check it against the ledger.
Where a money change goes
If you are recording money that moved, add a new PaymentTransaction row. Choose the type that describes the movement, and let Litestore work out the totals from the ledger.
If you are fixing a number that turned out to be wrong, you still add a new row, with the type adjustment. Never edit an existing row. The database will refuse the edit anyway.
If you are adding a rule about how much can be refunded or in which currency, put it in the migration next to the existing triggers. Don't put it in a service, because other code could call around it.
Don't start by asking where to store a number. Ask this instead:
If this value were wrong, what would work out the correct value again, and can the database refuse the wrong write before it is saved?
Related
Checkout
See how a cart becomes a Stripe Checkout Session, how long Litestore holds stock, and why that hold must last longer than the payment window.
Guarantees
See which rules about money, refunds, stock, identity, visibility and caching Postgres enforces, which a harness check enforces, and which are not guaranteed.
Webhooks
See how Litestore signs and retries outgoing deliveries, when it turns an endpoint off, and how the incoming Stripe and Resend routes differ.