Moving money the calm way
Shipping Stripe payments to production without losing sleep.
Minh Quang Tran · July 24, 2026 · 13 min read
Payments are the one part of a product where a small bug doesn't just annoy someone — it moves the wrong amount of real money, to the wrong person, at the worst possible time. Here's how I handle payment transactions on a multi-vendor ordering platform: the architecture, the workflow, and the small habits that keep me calm when I flip the switch in production. I'll show the shape of the code too — cleaned up, but close to what actually runs.
Why payments scare me (in a good way)
The app I work on lets people order lunch from a bunch of small food vendors. Money comes in from a customer and has to end up with the right vendor. If I get that wrong, someone is out real cash and the trust is gone. So I treat the payment path with a little more respect than the rest of the code: fewer clever tricks, more boring guarantees.
Almost everything below is really one idea in different clothes — make every step safe to repeat, and make every outcome easy to see and undo. Networks fail, browsers close, servers restart mid-request. The design just assumes all of that will happen and refuses to lose or double a payment when it does.
Two ways to move money
With Stripe, there are broadly two ways to route the money:
- Platform charge— the money lands in my platform's account first, and later I pay each vendor. Easy to start, but now I'm holding other people's money and I owe them a payout.
- Direct charge— the payment is created directly on the vendor's own connected Stripe account, so the money settles with them from the start. The platform just keeps a small fee.
We went with direct charges. It keeps the money flow clean: each order's money goes straight to the vendor who actually makes the food. One rule keeps this sane — one order, one vendor. If a cart mixes two vendors, we split it, so a single charge never has to be shared. In Stripe terms, a direct charge is just a normal payment made on behalf of a connected account, which you select with the stripeAccount option.
Setting up, once and carefully
The Stripe client is created once and shared. The two settings that matter most here aren't the keys — they're the retry and timeout behaviour. The network will blip, and a bounded automatic retry is much better than a mystery failure halfway through a charge.
import Stripe from 'stripe'
// One client, created once and reused.
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
maxNetworkRetries: 2, // safe, bounded retries on transient errors
timeout: 20_000, // fail loudly instead of hanging forever
})Keys live in the environment, never in the repo. Everything else about how payments behave lives in code we can review — more on that near the end.
Creating the charge
Here's the heart of it. Two options do the important work: stripeAccountpicks the vendor's connected account (that's what makes it a direct charge), and idempotencyKey makes the call safe to send twice. I key it off our own order id, so if the request times out and we retry, Stripe creates one payment, not two.
// One order = one vendor, so every charge has exactly one destination.
const intent = await stripe.paymentIntents.create(
{
amount: order.totalMinor, // smallest currency unit (e.g. yen)
currency: 'jpy',
application_fee_amount: order.platformFeeMinor,
metadata: { orderId: order.id }, // our id rides along with the payment
},
{
stripeAccount: order.connectedAccountId, // the "direct" in direct charge
idempotencyKey: `pi_create:${order.id}`, // retry-safe: same order, same call
},
)Notice metadata.orderId. That one line is what lets a webhook — which arrives with a Stripe object, not our order — find its way back to our order later. Small detail, saves a lot of pain.
The data model behind it
None of this works without the right tables. The schema is small and deliberately boring — six tables carry the whole payment story, and the shape of each one is doing a specific reliability job.
-- The order: one row per order, one vendor each.
create table orders (
id text primary key,
status text not null, -- reserved → paid → fulfilled → refunded
payment_intent_id text, -- points at the current intent (below)
connected_account_id text not null, -- FROZEN here at charge time
total_minor bigint not null, -- smallest currency unit
platform_fee_minor bigint not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Every PaymentIntent we create, each with its own lifecycle. A retried
-- order can produce more than one, so this is a table, not a column.
create table payment_intents (
id text primary key, -- Stripe PaymentIntent id
order_id text not null references orders(id),
connected_account_id text not null,
amount_minor bigint not null,
currency text not null,
status text not null, -- requires_payment_method → processing → succeeded | canceled
last_error text, -- human-readable, for support
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Every money movement, append-only. We never UPDATE or DELETE a row.
create table payment_ledger (
id bigserial primary key,
order_id text not null references orders(id),
kind text not null, -- 'charge' (+) or 'refund' (−)
amount_minor bigint not null, -- signed
created_at timestamptz not null default now()
);
-- Refunds still to send. A worker drains this; nothing calls Stripe inline.
create table refund_outbox (
id bigserial primary key,
order_id text not null references orders(id),
amount_minor bigint not null,
connected_account_id text not null,
idempotency_key text not null unique, -- can't enqueue the same refund twice
status text not null default 'pending', -- pending → done | failed
attempts int not null default 0,
created_at timestamptz not null default now()
);
-- Our own idempotency store: a repeated request replays the saved result
-- instead of doing the work (and the charge) twice.
create table idempotency_keys (
key text primary key, -- the Idempotency-Key we accepted / sent
scope text not null, -- 'create_charge', 'refund', ...
order_id text references orders(id),
request_hash text not null, -- same key + different body = reject
response jsonb, -- the stored result, replayed on retry
status text not null default 'in_progress', -- in_progress → completed
created_at timestamptz not null default now(),
expires_at timestamptz not null
);
-- Webhook events we've already handled, so a re-delivery is a no-op.
create table processed_events (
event_id text primary key,
processed_at timestamptz not null default now()
);Each table earns its place:
- orders — the
statuscolumn is the durable marker we write at reserve time, and it only moves forward.connected_account_idis written here when we charge and never changed; that frozen value is exactly what a refund reads months later, so it can't be fooled by a flag flip. - payment_intents — one row per PaymentIntent, each with its own
status. If a customer's first attempt fails and they try again, that's a new intent, not an overwrite — so we keep the whole history, andorders.payment_intent_idjust points at the current one. - payment_ledger— the real source of truth for money. It's append-only: a charge is a positive row, a refund a negative one. “How much has this order been refunded?” is a
sum(), not a boolean someone can set wrong by hand. Thestatuson the order is just a fast cache of what the ledger already knows. - refund_outbox — refunds are written here first, then sent by a worker. The
uniqueconstraint onidempotency_keymeans the same refund physically cannot be queued twice, andattemptslets the worker retry safely without piling up duplicates. - idempotency_keys — our own version of the key we send to Stripe. When a request arrives with an
Idempotency-Key, we look it up first: if we've completed it before, we replay the savedresponseinstead of charging again.request_hashstops the same key being reused for a different request. - processed_events — Stripe can deliver the same webhook more than once. We record each
event_idand skip anything we've already seen, so a re-delivery does nothing.
That idempotency table is small but it does a lot. Any money-moving endpoint gets wrapped in one guard: check the key, do the work once, store the result.
// Wrap any charge/refund endpoint so a retried request is a no-op.
export async function withIdempotency<T>(
key: string,
scope: string,
run: () => Promise<T>,
): Promise<T> {
const existing = await db.idempotencyKeys.get(key)
if (existing?.status === 'completed') {
return existing.response as T // replay the same result, don't re-charge
}
await db.idempotencyKeys.upsert({ key, scope, status: 'in_progress' })
const result = await run()
await db.idempotencyKeys.complete(key, result) // store result + mark done
return result
}The relationships stay simple: one order has one current payment_intent (plus a short history of past attempts), many payment_ledger rows, and — rarely — a few refund_outboxrows. There's no clever normalization here on purpose; the goal is that the truth about any order's money is a boring query, not a guess.
The flow, start to finish
Here's the whole trip a payment takes, minus the drama:
- Reserve.Before we touch Stripe, we set the order aside and write a row that says “a payment is about to happen here.” If the server crashes mid-way, that marker is how we find our way back.
- Charge.We create the payment on the vendor's connected account. The customer pays — by card, or a wallet like PayPay.
- Confirm.This is the part people get wrong: we don't wait only for a webhook. When the customer comes back to the app, we check the payment right then and there. The webhook and a background job are backups, not the only source of truth. So if the customer's browser dies on the “thank you” page, the order still completes.
Three different signals can finish the same order — the customer returning, Stripe's webhook, and a job that sweeps for stragglers every few minutes. They all funnel through the same function, so it doesn't matter which one gets there first. Whoever arrives first wins; the rest quietly notice it's already done.
One finalizer, three triggers
The trick that makes those three signals safe is that they don't each “finish” the order in their own way. They all call one function. It takes a row lock, checks whether the order is already paid, and only then records the money and flips the status — inside a single transaction.
// The customer returning, the webhook, and the reconciler ALL call this.
// Whoever arrives first wins; the rest find it already done.
export async function finalizePayment(orderId: string) {
return db.tx(async (t) => {
const order = await t.orders.selectForUpdate(orderId) // row lock
if (order.status === 'paid') return order // already finished
const intent = await stripe.paymentIntents.retrieve(order.paymentIntentId, {
stripeAccount: order.connectedAccountId,
})
if (intent.status !== 'succeeded') return order
await t.ledger.append({ orderId, kind: 'charge', amountMinor: intent.amount })
await t.orders.markPaid(orderId)
return order
})
}The selectForUpdate lock plus the early returnis the whole game: two signals arriving at the same instant can't both write. One wins the lock, marks it paid; the other waits, sees paid, and does nothing.
The webhook is a backup, not the truth
The webhook handler does two jobs and nothing else: verify the signature (so we know it's really Stripe), then hand off to the exact same finalizer the UI uses. It answers 2xx fast; anything slow happens out of band.
export async function POST(req: Request) {
const body = await req.text()
const signature = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body, signature, process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response('invalid signature', { status: 400 })
}
if (event.type === 'payment_intent.succeeded') {
const intent = event.data.object as Stripe.PaymentIntent
await finalizePayment(intent.metadata.orderId) // same finalizer as the UI
}
return new Response('ok') // answer fast; heavy work happens elsewhere
}Do it exactly once
The internet is unreliable on purpose. Requests time out, get retried, and sometimes arrive twice. For money, “twice” is a disaster. A few habits keep every action to exactly once:
- Write it down first. Before calling Stripe, save a durable record of what we're about to do, so a crash is recoverable.
- Idempotency keys. Every charge and refund carries a key derived from our own ids, so a request that lands twice is still treated as one.
- An outbox for refunds. A refund isn't fired inline — we write it to a table and a worker drains it. If it fails, the worker tries again later. The refund can't get lost.
- An append-only ledger. Every money movement is one row we never edit (a charge is +, a refund is −). “Refunded” is a total of that ledger, not a flag someone can set by hand.
- Reconcile before retrying. Before re-sending anything, we ask “did this already happen?” Blindly retrying is exactly how you pay someone twice.
Refunds must ignore feature flags
Here's a subtle one. Whether a charge goes to the platform or straight to the vendor is controlled by a feature flag. But a refund might happen weeks after the charge — long after someone may have flipped that flag or re-linked a vendor's account. So we don't ask the flag at refund time. We read the connected account that we saved on the order when we made the charge.
// The connected account is read from the ORDER (frozen at charge time),
// never from today's feature flag.
export async function requestRefund(orderId: string, amountMinor: number) {
const order = await db.orders.get(orderId)
await db.refundOutbox.insert({
orderId,
amountMinor,
connectedAccountId: order.connectedAccountId, // where the money came from
idempotencyKey: `refund:${orderId}:${amountMinor}`,
})
// A worker drains the outbox and calls Stripe, retrying on failure — so a
// refund can be slow, but it can't be lost, and the key stops it doubling.
}The money goes back exactly where it came from, no matter what the flag says today. And a few independent checks — the idempotency key, the ledger total, a status guard — make sure a fully-refunded order can never be refunded again.
Before re-sending a payment, ask “did this already happen?” Reconcile first, retry second.
Turning it on without fireworks
The scary moment isn't writing the code — it's the day it goes live. I keep that boring on purpose, and it starts with where the switch lives. The flag is a value in version-controlled config, reviewed in a pull request — not a setting typed into a server that gets wiped on the next deploy.
// Lives in git, reviewed in a PR — not typed on a server by hand.
type Env = 'staging' | 'production'
export const directChargeEnabled: Record<Env, boolean> = {
staging: true,
production: false, // turning this on is a code change, with a human on it
}Around that one flag, the rollout is deliberately dull:
- Off by default. Shipping the code changes nothing until the flag is on. Deploying is safe; enabling is the decision.
- Staging first. Turn it on in staging, place one real, tiny order, and watch it land in the right account.
- Flip prod in a quiet window. Do it when few people are ordering, so if something's off, few people feel it.
- A human says “go.” Everything up to the last real-money step can be automated; the final flip is a person clicking yes.
- Rollback is one move. If it misbehaves, flip the flag back. That's the whole plan.
The mindset
- Config can look perfect and still be wrong. Everything can say “enabled” while a real charge still fails. So I always run one real transaction before I trust it.
- Every failure gets a log and an alarm. If a payment breaks, I want to know before the customer tells me.
- Watch the in-between state. “Paid but not fulfilled” is the one that quietly hurts people. Alert on it.
- Turn ugly errors into plain messages. A shopper should never see a raw gateway error.
- Delete the old path once the new one wins. Dead branches are where the next bug hides.
In short
Handling payments well isn't about clever code. It's about small reversible steps, doing each action exactly once, and being able to see and undo anything. The Stripe calls are a few lines; the reliability lives in the boring scaffolding around them. Calm beats clever — especially when real money is moving.