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:

  1. Charge the card via PSP.
  2. Record in your ledger atomically.
  3. Confirm via webhook (asynchronous source of truth).
  4. Reconcile your ledger against the PSP daily.
  5. 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

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 .