Module P-24·40 min read

Idempotency end to end, a double-entry ledger, explicit state machines, reconciliation as a first-class service, and UPI-scale throughput.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-24 — Capstone: Design a Payment System

What this module covers: A complete worked design for taking money, from the API contract down to the reconciliation job — the system where every idea in Practitioner is load-bearing at once. Requirements and the non-functional constraint that reorders everything (correctness above availability). Money as integers, never floats, and always paired with a currency. A double-entry, append-only ledger, and why a mutable balance column is the wrong data structure for reasons that are half correctness and half concurrency. Payment state as an explicit machine advanced only by compare-and-set. The end-to-end flow annotated with the ambiguous failure at every hop, including the webhook that arrives before your own API call returns. Then the failure scenarios worked one by one, three-way reconciliation as the required backstop rather than a nice-to-have, the hot-account problem that appears at scale, and the compliance constraint where statutory retention beats the right to erasure.


What We Are Building, and What We Are Not

Scope. A payment service inside your product that charges customers using an external payment service provider — Stripe, Adyen, Razorpay — records every movement of money in a ledger you own, and can prove at any time that its numbers match the PSP's and the bank's.

Explicitly out of scope, and each exclusion is a decision:

  • We never touch a card number. The client collects card details directly with the PSP's hosted fields or SDK and hands us a token. This keeps us in the smallest PCI DSS scope available (SAQ-A) instead of the most expensive one, and it is the single highest-leverage architectural decision in the whole design. Any design that proxies a PAN through your servers has changed the project from "build a payment service" to "build a PCI-audited environment."
  • We are not a card network or an acquirer. Authorisation, clearing and settlement between banks are the PSP's problem.
  • No fraud-scoring model, no multi-PSP routing, no marketplace payouts, no subscription billing. Each is a real system; the last section says where they attach.

Functional requirements

  1. Charge a customer a specified amount in a specified currency, for an order.
  2. Be safe to retry: a repeated request must never charge twice.
  3. Support authorise-now/capture-later, because physical goods ship after the order.
  4. Support full and partial refunds, never exceeding what was captured.
  5. Handle asynchronous outcomes: 3-D Secure challenges, bank transfers that settle days later, and PSP webhooks as the authoritative notification.
  6. Handle disputes and chargebacks as money movements, not as support tickets.
  7. Produce a complete, immutable audit trail: for any penny, who moved it, when, and why.

Non-functional requirements, and the one that reorders everything

RequirementTarget
Correctnessabsolute. No double charge, no lost charge, no unaccounted penny.
Availability99.95% for payment initiation
Latencyp99 < 800 ms for our own work, excluding PSP round trip
Throughput200 payments/sec sustained, 5,000/sec peak (a flash sale, or UPI-scale festival traffic)
Auditabilityevery state change attributable and immutable for 7 years
Freshness of reportingreconciliation daily; internal dashboards hourly

The first row changes the design's priorities in a way worth stating explicitly, because it inverts the default instinct of this course. Everywhere else, an ambiguous failure is resolved in favour of availability — retry, accept the duplicate, deduplicate later. Here, when correctness and availability conflict, we fail the payment. A customer seeing "we couldn't process that, please try again" has had a bad minute. A customer charged twice has had a bad week, and we have a financial liability, a support cost, and in aggregate a regulatory problem.

That single sentence is what justifies the expensive parts below: the ledger, the compare-and-set transitions, and the reconciliation job.

Analogy: double-entry bookkeeping, which is a 700-year-old solution to precisely this problem. A Venetian merchant could not ask the ship's captain what had happened, could not undo an entry once the ink dried, and could not trust that any single ledger was complete. So every transaction was written twice, in two places, and the books had to balance — not because balancing was the goal, but because failing to balance is how you find the error you did not know you had made. Our system has the same three constraints. The PSP is the ship's captain, the append-only ledger is the ink, and reconciliation is the balancing.


Estimation: What the Numbers Demand

Rough sizing, in the style of Module F-3, because it decides which problems are real.

Write throughput. 5,000 payments/sec peak. Each payment produces, over its life: 1 intent row, 1–3 attempt rows, 4–8 ledger entries, 3–6 webhook inbox rows, and a handful of outbox events. Call it ~20 row writes per payment, so 100,000 row writes/sec at peak.

That number immediately tells us something: a single Postgres primary handles low tens of thousands of small writes per second with fsync-on-commit, so peak traffic requires either sharding (Module P-6) or the observation that peak is short. Both are true in practice: shard by tenant or payment ID, and note that the 5,000/sec peak lasts minutes, so a queue in front of non-critical work absorbs much of it.

Storage. At 200/sec sustained: ~6.3 billion payments over a year is wrong by an order of magnitude — 200 × 86,400 × 365 ≈ 6.3 billion? No: 200 × 86,400 = 17.3M/day, ≈ 6.3 billion/year. That is genuinely the arithmetic, and it tells you 200/sec sustained is an enormous business (Stripe-scale), not a mid-sized one. For a more typical product, sustained is 5–20/sec — 160M–630M payments/year — and 5,000/sec is the flash-sale peak. Design for the peak, size storage for the sustained rate, which is a distinction worth making explicitly because conflating them is how teams over-build.

At 20/sec sustained: 630M payments/year × ~20 rows × ~250 bytes ≈ 3 TB/year of ledger and payment data, before indexes. That is fine on one machine for a few years if it is partitioned by month so old partitions can be moved to cheaper storage and never scanned (Module P-9, Module O-8).

The number that matters most is not throughput. It is that 7 years of immutable retention is a hard requirement, so nothing is ever deleted, the ledger only grows, and every design decision must assume the table is large. That is why partitioning appears in the schema below rather than as a later optimisation.


Money: Integers in Minor Units, Always With a Currency

Two rules, both absolute, both violated constantly.

Never use a floating-point type for money. 0.1 + 0.2 !== 0.3 in binary floating point, and a payment system that accumulates rounding errors will fail reconciliation in ways that take days to trace. Store integer minor units — pence, cents, paise — as bigint.

sql

An amount without a currency is not an amount. They travel together, always, in the same row and the same function signature. And currencies have different scales: USD and GBP have 2 decimal places, JPY has 0, KWD and BHD have 3. So amount_minor = 1000 is £10.00, ¥1000, or 1.000 KWD depending entirely on the currency column — which is why a helper that formats or compares money must take both, and why a CHECK that amounts are positive plus a separate direction column is safer than allowing signed amounts (a sign flip is a silent reversal of meaning).

Never mix currencies in one account or one ledger transaction. A single sum(amount_minor) across mixed currencies is meaningless, and no constraint will catch it if the currency lives only in the payment row. Every ledger account is denominated in exactly one currency, so cross-currency movement is modelled explicitly as two transactions plus an FX position — which forces the conversion rate to be recorded rather than implied.

For FX rates themselves, NUMERIC with explicit scale, never a float, and store the rate used at the time on the transaction. A rate looked up later is a different number and will not reproduce the historical figure — the same "record what was true then" principle as Module P-22's Type 2 dimensions.


The Ledger: Append-Only, Double-Entry, and Balances Are Derived

Here is the design most teams get wrong on the first attempt:

sql

It is a relative operation, so it is not idempotent (Module P-16). A retried message adds £42 twice, and there is no record that would let you notice.

It destroys history. The balance is 8,300. Why? Nobody can say. There is no audit trail, no way to answer "what did this look like on 3 March," and no way to find which of ten thousand movements was wrong.

It serialises every payment for that account on one row lock. The platform's clearing account is touched by every payment, so at 5,000/sec every transaction queues behind the same row — and each update produces a dead tuple, making it simultaneously a lock bottleneck and the worst vacuum target in the database (Module F-13).

The correct structure is an append-only, double-entry ledger. Every movement of money is a transaction containing two or more entries whose signed amounts sum to zero. Balances are derived.

sql

Four properties do the work:

UNIQUE (source_event_id) is the whole idempotency mechanism. A duplicated webhook, a replayed queue message, an operator re-running a job — each attempts to insert a transaction with a source_event_id it has already used, the constraint rejects it, and the ledger is unchanged. This is Module P-16's "restructure the effect so it has a natural unique key" strategy, and it is why a ledger is the easy case for idempotency while a balance column is the hard one.

Entries must sum to zero per transaction, checked at write time — the invariant that catches programming errors before they become accounting errors:

sql

Nothing is ever updated or deleted. A mistake is corrected by a reversing transaction — equal and opposite entries, referencing the original. This means the ledger is a complete history, an auditor can be given read access without risk, and "who changed this and when" is answerable by construction. It is also what makes the 7-year retention requirement free rather than a chore.

Balances are SUM(amount_minor), which is exact and never contended — the hot-row problem disappears because there is no row to contend on. The cost is that summing a large partition per read is expensive, which the scale section addresses with snapshots.

A worked transaction

A customer pays £42.00; the PSP keeps £1.05 in fees.

AccountAmount (minor)Meaning
psp_clearing (asset)+4200money the PSP owes us, awaiting payout
customer_receivable (asset)−4200the customer no longer owes us
sums to 0

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.