Litestore · Developer guide
Commerce

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

lib/payments/payment-providers.ts
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_SECRET

Litestore 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
adjustment

You 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 instead

On 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 %
Order #1042Paid $120.00
Refunded $80.00Requested $60.00
Write rejectedTotal refunded can't exceed the order total. Nothing was saved.
The database rejects a refund that would push the order's refunds above what the customer paid.

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:

  1. The intent must still be open. A webhook that arrived would have consumed, settled or expired it.
  2. Its provider must be stripe, and it must have a session id.
  3. It must not be flagged yet. Flagged intents wait for an operator and are never retried automatically.
  4. 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?

On this page