Payments are unforgiving. A bug in your search ranking is annoying; a bug in payments is a refund + an angry customer + maybe a legal letter. The discipline is boring and irreducible. This post is the working design.
The pieces
[Customer] ──▶ [Your API] ──▶ [PSP (Stripe)]
│
▼
[Ledger DB]
│
▼
[Reconciliation job]
│
▼
[Reports / accounting]
Five concerns:
- Charge the card via PSP.
- Record in your ledger atomically.
- Confirm via webhook (asynchronous source of truth).
- Reconcile your ledger against the PSP daily.
- Refund / dispute flows.
Idempotency
Network retries WILL happen. Without idempotency, you double-charge.
async def charge(user_id, amount, idempotency_key):
# Local check
if existing := await db.fetchrow(
"SELECT id, status FROM payments WHERE idempotency_key = $1",
idempotency_key
):
return existing
# PSP also dedupes via key
pi = await stripe.PaymentIntent.create(
amount=amount,
currency="usd",
customer=user_id,
idempotency_key=idempotency_key,
)
await db.execute(
"INSERT INTO payments (id, idempotency_key, user_id, amount, status, psp_id) "
"VALUES (gen_random_uuid(), $1, $2, $3, 'pending', $4)",
idempotency_key, user_id, amount, pi.id
)
return pi
Both your DB and Stripe use the same idempotency key. Belt and suspenders. See Idempotency Patterns .
Ledger (double-entry)
CREATE TABLE journal_entries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id uuid NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
description text NOT NULL
);
CREATE TABLE journal_lines (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entry_id uuid NOT NULL REFERENCES journal_entries(id),
account text NOT NULL, -- e.g., 'cash', 'revenue', 'fees'
direction char(1) NOT NULL, -- 'D' or 'C'
amount_cents bigint NOT NULL CHECK (amount_cents > 0)
);
-- Constraint: every entry's debits = credits
CREATE FUNCTION check_balanced() RETURNS trigger AS $$
DECLARE d bigint; c bigint;
BEGIN
SELECT COALESCE(SUM(amount_cents), 0) INTO d FROM journal_lines
WHERE entry_id = NEW.entry_id AND direction = 'D';
SELECT COALESCE(SUM(amount_cents), 0) INTO c FROM journal_lines
WHERE entry_id = NEW.entry_id AND direction = 'C';
IF d <> c THEN RAISE EXCEPTION 'unbalanced'; END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
A successful charge of $100 with $3 fee:
DR cash 97
DR fees 3
CR revenue 100
Sums equal. Every entry is balanced. Errors get caught early.
Source of truth: webhooks
The HTTP response from stripe.PaymentIntent.create says “pending.” The truth comes from webhooks:
@app.post("/webhooks/stripe")
async def stripe_webhook(req: Request):
event = stripe.Webhook.construct_event(
await req.body(), req.headers["stripe-signature"], WEBHOOK_SECRET
)
# Idempotent — store event id
if await db.exists("SELECT 1 FROM webhook_events WHERE id = $1", event.id):
return {"status": "duplicate"}
await db.execute("INSERT INTO webhook_events (id, type) VALUES ($1, $2)",
event.id, event.type)
if event.type == "payment_intent.succeeded":
await mark_payment_succeeded(event.data.object)
elif event.type == "charge.refunded":
await record_refund(event.data.object)
# ... handle others
return {"status": "ok"}
Idempotent webhook handler. Verify signature. Process exactly once. See Webhook Design .
Reconciliation
Daily, compare your ledger to the PSP:
async def reconcile(date):
# Fetch from Stripe
psp_charges = await stripe.Charge.list(created={"gte": date, "lt": date+1})
# Fetch from local
local = await db.fetch(
"SELECT psp_id, amount FROM payments WHERE created_at::date = $1",
date
)
psp_set = {c.id: c.amount for c in psp_charges}
local_set = {p["psp_id"]: p["amount"] for p in local}
only_psp = set(psp_set) - set(local_set)
only_local = set(local_set) - set(psp_set)
mismatched = {k for k in psp_set & local_set if psp_set[k] != local_set[k]}
if only_psp or only_local or mismatched:
await alert_finance(only_psp, only_local, mismatched)
Drift surfaces fast. Investigate before it’s a month-end mess.
Refunds
async def refund(payment_id, amount, idempotency_key):
payment = await get_payment(payment_id)
if not payment.refundable: raise InvalidRefund
refund = await stripe.Refund.create(
payment_intent=payment.psp_id,
amount=amount,
idempotency_key=idempotency_key,
)
# Ledger entry: reverse the original
await ledger.entry(
payment_id=payment_id,
description="refund",
lines=[
("revenue", "D", amount),
("cash", "C", amount),
],
)
Idempotent. Recorded as a separate journal entry. Original entry isn’t modified.
Disputes / chargebacks
@webhook("charge.dispute.created")
async def on_dispute(event):
await db.execute(
"INSERT INTO disputes (id, payment_id, amount, reason) VALUES ($1, $2, $3, $4)",
event.id, payment_id, amount, reason
)
# Hold revenue until resolved
await ledger.entry(...) # accrue dispute liability
await notify_team()
Track every dispute. Provide evidence within deadline. Chargebacks > 1% threaten your account.
Currency / multi-currency
Always store amount as integer cents (or smaller for crypto/JPY). Never floats.
For multi-currency: store amount and currency; convert to base currency for reports using rate-of-record.
Audit log
Every payment-related action: who did it, when, what changed. Append-only.
CREATE TABLE payment_audit (
id bigserial PRIMARY KEY,
payment_id uuid,
actor text,
action text,
before jsonb,
after jsonb,
occurred_at timestamptz DEFAULT now()
);
Compliance and debugging both love this.
Common mistakes
1. No idempotency
Network retry → double charge → angry customer → lost trust.
2. Trusting the create-response
Stripe’s response is “I’m trying.” Webhook is “I succeeded/failed.” Wait for webhook before fulfillment.
3. Single-entry ledger
UPDATE wallet SET balance = balance + $1. One bug → silent corruption. Use double-entry; sums must balance.
4. No reconciliation
Drift accumulates; nobody notices for months. Daily recon is non-negotiable.
5. Floats for money
0.1 + 0.2 != 0.3. Always integers.
What I’d ship today
For a payments feature:
- Stripe as PSP (or equivalent).
- Idempotency keys on every API call.
- Double-entry ledger in Postgres.
- Webhook handler as source of truth.
- Daily reconciliation job with alerts on drift.
- Audit log of all changes.
- Refund + dispute flows built early.
- Tests with Stripe’s test mode CI integration.
Read this next
- Idempotency, Retries, and Exactly-Once Illusions
- Webhook Design 2026
- Event Sourcing 2026
- Distributed Transactions and Sagas
If you want my Stripe + ledger reference architecture, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .