Payments
Learn how Stripe is wired as the single provider, how the append-only transaction ledger is enforced by Postgres, and why replayed webhooks are idempotent.
Stripe is the only wired payment provider.
The PaymentProvider interface in types/payments.ts remains as the seam a second processor would implement, but there is no registry behind it — resolution is a branch.
That is a deliberate choice rather than an unfinished one. Priority machinery over a one-element set buys nothing, and adding a provider means adding a branch, which is no more work than adding a map entry was.
Resolving a 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
}name is validated rather than ignored.
It arrives from the storefront checkout action as an optional string — provider: z.string().optional() — so an unknown value must fail loudly instead of silently resolving to Stripe. A checkout that asked for a processor the store does not have should not quietly be charged through a different one.
The constructed provider is memoized across requests, because constructing the Stripe SDK client is not free.
Configuration
getPaymentConfig() reads two environment variables:
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRETStripe is marked enabled only when both are present.
One key without the other is not a partially working integration. A secret key with no webhook secret can create a session and take money, but cannot verify the delivery that tells the application the money arrived — so that configuration is treated as no configuration at all.
getPaymentProvider() throws when the provider is not enabled, which is what makes a store without Stripe keys browsable but not checkout-able.
The ledger
Every settlement is a PaymentTransaction row.
A row records the order it belongs to, a type, an amount at Decimal(12, 2), a currency, the provider name, and the processor's own identifier for the movement — a charge, refund, dispute or balance-transaction id.
The types are:
authorization
capture
refund
dispute
adjustmentThe ledger is append-only, and that is enforced by the database rather than by convention. Two triggers in prisma/migrations/20260726143000_baseline_with_db_guarantees/migration.sql do the enforcing.
The write guard
litestore_guard_payment_transaction_write runs before every insert, update and delete on the table.
An UPDATE or a DELETE raises immediately:
PaymentTransaction is append-only; write a correction row insteadOn insert it also loads the parent order's currency and raises when the row's currency does not match it, so a ledger denominated in one currency cannot accumulate rows denominated in another.
A correction is a new row, never an edit. That is what makes the ledger re-summable: the total is SUM(ledger) at any point in time, and history cannot be rewritten to make a disagreement disappear.
The refund cap
litestore_guard_payment_refund_cap runs after an insert, and only when the new row's type is refund.
It reads Order.amountTotal FOR UPDATE, re-sums every refund row for that order, and raises when the sum exceeds the total by more than a cent:
refund ledger would exceed order total for order %: refunds %, total %The FOR UPDATE is the load-bearing part. Two refunds issued at the same moment would otherwise each read the pre-refund total, each conclude there is room, and both commit. The row lock serializes them, so the second one re-sums against a ledger that already contains the first.
Refunds are capped by the database, not the application
The refund cap is a trigger that recomputes the total under a row lock on every insert. Application code cannot exceed it even with a bug, and neither can a direct SQL write.
Webhook idempotency
PaymentTransaction carries @@unique([provider, providerId, type]).
Stripe retries a webhook it believes failed. Without that constraint, a retry would insert a second row for the same charge, and the order's captured total would double.
With it, the replay violates the unique index and becomes a no-op instead of a second ledger row.
Note that the key includes type. The same processor id can legitimately appear as more than one kind of movement — a capture and the dispute that later references it — so uniqueness is per kind rather than per id.
Reconciliation
The webhook is the normal path, but it is a delivery over the network, and a delivery can fail to arrive at all.
The failure that matters is specific: a customer is charged, and because checkout.session.completed never lands, no order is ever created.
functions/cron.payment-reconcile.ts runs every thirty minutes as the safety net.
Every open OrderIntent carries the Stripe session id it was created with, so the job can ask Stripe directly whether that session was paid, and replay the exact webhook path for the ones that were.
It selects its suspects narrowly:
- Intents still in
openstatus — a delivered webhook consumes, settles, or expires them. - With
providerset tostripeand a session id recorded. - Not already flagged, since flagged intents are the operator's queue and are never auto-retried.
- Older than an hour, because a younger intent may still be mid-checkout or inside Stripe's normal retry cadence.
The batch is capped at 25, oldest first. Stripe reads are serial, so the cap keeps a run inside the route's maxDuration; ordering by age means the longest-charged customers recover first and the backlog drains across runs.
Replaying is safe because order creation stays single-sourced. The handler's own idempotency — a Redis mark plus the unique Order.externalPaymentId — makes a race between the reconciler and a late webhook delivery produce one order, not two.
The drift views
OrderMoneyDrift and CustomerMoneyDrift expose totals that disagree with their ledger.
They are views in the baseline migration, not Prisma models. OrderMoneyDrift selects orders whose cached money projections diverge from SUM(PaymentTransaction) by more than a cent, or whose refunded amount exceeds the order total. CustomerMoneyDrift does the same for Customer.totalSpent and Customer.orderCount against the paid orders behind them.
Both exist because a cached projection is allowed to be a cache, but never allowed to be unverifiable.
Where a money change goes
If you are recording something that moved money, it is a new PaymentTransaction row. Pick the type that describes the movement, and let the totals be re-derived from the ledger.
If you are correcting a number that turned out to be wrong, it is still a new row — an adjustment — and never an edit to an existing one. The database will refuse the edit anyway.
If you are adding a rule about how much may be refunded or in what currency, it belongs in the migration alongside the existing triggers, not in a service that a second caller can bypass.
The question to ask is not "where do I store this number?" It is:
If this value were wrong, what would re-derive the truth — and can the database refuse the wrong write before it lands?
Related
Checkout
Learn how a cart becomes a Stripe Checkout Session, how long stock is reserved, and why the reservation window must outlive the payment window.
Guarantees
Learn which money, refund, stock, identity, visibility and cache promises Postgres enforces, which ones a harness check enforces, and which ones are explicitly not guaranteed.
Webhooks
Learn how outbound deliveries are signed and retried, when an endpoint is auto-disabled, and how the inbound Stripe and Resend routes differ.
Checkout
Learn how a cart becomes a Stripe Checkout Session, how long stock is reserved, and why the reservation window must outlive the payment window.
Pricing
Learn how a variant's price is resolved per channel and currency, how sale windows are evaluated at read time, and what decimal precision the schema uses.