Architecture One-Pager
Solution Architecture v1.0 · open source, self-hosted on Kubernetes across two in-country data centres · Payments Platform Architecture · 2026-10 · 24 views · 18 architecture decision records
Payment Checkout & Ledger Service · Solution Architecture v1.0 · open source, self-hosted on Kubernetes across two in-country data centres · Payments Platform Architecture · 2026-10 · 24 views · 18 architecture decision records
The authorization plane is allowed to fail; the book of record is not allowed to be wrong. A durable ordered event log is the only thing between them.
Everybody has pressed Pay now. What happens next is the part almost nobody sees, and it is harder than it looks for one reason: the money moves on networks the service does not own and cannot interrogate reliably. A card authorization can time out eight seconds after the issuer has already approved it, leaving the service genuinely unable to say whether the buyer has been charged. An account-to-account callback can arrive twice, out of order, or three hours late. A settlement file can turn up a day later and disagree with everything the service believed happened. Meanwhile the buyer is looking at a spinner and their instinct is to press the button again, the merchant is deciding whether to ship, and a regulator may ask in 2036 what the platform believed about this payment and when it believed it. A design that produces the right answer when the rails behave is not an architecture. It is a demo.
Split the system at the only boundary that matters. The authorization plane is stateless, latency-bound and allowed to fail: it authenticates the merchant, holds the payment intent's state machine, picks a provider from a versioned policy, calls it under a hard deadline, and answers the buyer. It holds no truth. The ledger plane is append-only double-entry, strongly consistent, synchronously replicated, slower by design, and allowed to be unavailable for writes for minutes without a buyer noticing — because nothing in it is on the buyer's path. Between them sits a durable ordered event log, which is the only coupling, and the reason a ledger partition does not become a checkout outage. Then accept the cost of that split honestly: there is a window in which a payment has succeeded in the world and is not yet in the books. Build the machinery that always closes it — an outcome poller for intents stuck in processing, a callback ingest that deduplicates and refuses illegal transitions, a daily settlement matcher whose file is the evidence, and a named break account for every amount that still cannot be explained. Treat unknown as an outcome rather than an error, never retry after one, declare idempotency separately at each boundary, and make every correction a new opposite-signed entry so the question "what did you believe, and when" always has an answer.
What it is, and what it is not
- The double-entry ledger as the only truth about money — not A status column on a payments table, with balances cached beside it
- Append-only, with corrections as new opposite entries — not An admin screen that can adjust a balance
unknownas a first-class provider outcome — not A timeout treated as a failure and retried- The settlement file as the evidence — not The provider's synchronous HTTP response as the truth
- Idempotency declared separately at each boundary — not One idempotency key assumed to cover every duplicate
- At-least-once webhooks with a per-object sequence — not Exactly-once delivery promised to merchants
- Unmatched value posted to a named, aged break account — not Unmatched value netted away at month end
- Fail closed on money movement — not Accept the payment and reconcile it later, optimistically
The decisions that are the architecture
- Authorization plane ≠ ledger plane, coupled only by a durable event log (ADR-01) — The buyer-facing path is stateless, deadline-bound and permitted to decline, time out and fail closed; the book of record is append-only, strongly consistent and permitted to lag. A ledger outage becomes a balance that is behind, not a checkout that is down — and the price is a window the rest of the architecture exists to close.
- The ledger is append-only double entry, balanced at write time (ADR-02) — Entries carry signed integer minor units, a transaction is rejected unless its entries sum to zero, and nothing is ever updated or deleted. Every balance, statement and report is a projection, so a number that cannot be derived from the ledger is a number the platform does not publish.
- Idempotency is declared separately at every boundary (ADR-03) — The merchant's
Idempotency-Keyat the edge, the provider's own event identifier at the callback, the event identifier as a unique natural key on the ledger transaction, and a unique instruction key on the payout. Duplicate provider calls and duplicate ledger entries are different failures and need different keys. unknownis an outcome, and there is never a retry after one (ADR-04) — A provider that does not answer within eight seconds leaves the intent inprocessing, which only an inbound signal or a reconciliation job may leave. A retry is the one action that can produce a double charge, and the budget is one per million attempts.- The provider's answer is a hint; the settlement file is the evidence (ADR-05) — Raw callbacks are persisted before they are parsed, deduplicated on the provider's event identifier, and checked against the state machine's legal transitions. What the daily file says is what the platform ultimately books.
- Unmatched value is named, not netted (ADR-07) — Every settlement line is matched, broken or disputed. A break is posted to a named account, aged, owned and alarmed past 48 hours. An unexplained rupee on the books is a finding; an unexplained rupee off the books is a loss nobody will ever find.
- Payment state is sharded by merchant; the ledger gives up a global sequence (ADR-08) — No synchronous cross-shard transaction exists anywhere in the payment path, and one large merchant's sale cannot consume another's capacity. The ledger is partitioned by period and account class, providing per-account ordering and a deterministic close rather than one total order.
- At-least-once webhooks with a per-object sequence number (ADR-09) — The platform promises what it can provide and says so, gives a monotonic per-object sequence so a gap is detectable, and queues per merchant so a stalled endpoint delays nobody else. Ordered delivery would be kinder to naive handlers and would turn one slow merchant into everyone's backlog.
- Keep the cardholder data environment small (ADR-11) — Card details travel from the buyer's browser into a separately clustered CDE and never enter the application zone. Everything downstream carries a token, keys never leave the HSM, and a full compromise of the application zone yields tokens rather than the ability to take money anywhere.
- The platform holds merchant balances (ADR-13) — Refunds, disputes, fees and hold periods become natural, and the platform becomes custodian of other people's money — segregated accounts, float accounting, a licence and a much larger account model. The alternative is a thin pass-through that pushes refund funding onto merchants.
- Dual control is a service in the path, not a policy in a document (ADR-14) — No sequence exists in which one authenticated human changes the books. The proposal and the approval are separate authenticated acts by different people, and the resulting instruction is signed in the HSM so the approval is evidence rather than a row somebody could write.
- Self-managed open source in two in-country data centres (ADR-18) — Data localisation, PCI-DSS scope containment and a money path with no dependency a regulator has not approved outweigh the operational cost of running Kubernetes, Kafka, PostgreSQL and HSMs. Forty kilometres between sites is what makes synchronous replication and RPO 0 affordable.
Why this should still be right in ten years
The container platform, the broker, the relational store, the HSM vendor and every provider integration in this set will be replaced inside the platform's life. These are the properties that should outlast them.
- The boundary is not a technology. "The buyer-facing path may fail; the book of record may not be wrong" is a statement about where truth lives, not about Kafka or PostgreSQL. Every component named in this set can be replaced without touching that sentence.
- Double-entry is older than computing and will outlive this stack. The ledger's shape — balanced transactions, signed entries, named accounts, corrections as new entries — predates every technology here by centuries and is the one part of the design that will not need revisiting. Choosing it is choosing not to invent a money model.
unknownwill still be an outcome. New rails arrive constantly and every one of them will occasionally fail to say what happened. A design that treats ambiguity as a first-class state absorbs a new rail as an adapter; a design that treats it as an error rewrites itself for each one.- Reconciliation outlives trust in any provider. The loop of believe, post, receive evidence, match, name the gap, correct and prove is independent of who the providers are, how many there are, and what protocol they speak. It is the control that makes every other optimism in the system safe.
- Evidence beats state. Storing the raw payload before parsing it, the policy version that made each routing decision and the posting rule version that booked each transaction means a question asked in 2036 is answerable from data rather than from code archaeology. Code changes; stored evidence does not.
- The blast radius is drawn, not asserted. A leaked merchant credential can take money only into that merchant's own settlement account, and an application-zone compromise yields tokens. Those are structural properties of where data and keys live, so they survive every refactor that respects the zones.
Non-functional targets
Every figure below is a stated assumption for this design, chosen to be defensible and arguable rather than measured. A reviewer who changes one can follow it to the decision that depends on it.
| Quality | Target | How it is met | View |
|---|---|---|---|
| Authorization availability | ≥ 99.99% monthly, maintenance included | Stateless pods across two in-country DCs both serving traffic; circuit-broken providers; fail closed rather than lossy acceptance | 18 |
| Ledger write availability | ≥ 99.99% monthly | Synchronous replication at 1.4 ms RTT; posting buffers in the event log when the store is unwritable and drains on recovery | 18 |
| Intent create latency | p99 ≤ 120 ms | Single-shard write, idempotency key in an in-memory store, no provider call on this path | 13 |
| Confirm latency, platform time | p99 ≤ 250 ms excluding provider | Routing evaluated from cached policy; tokenisation on a separate hop inside the CDE | 13 |
| Buyer-visible confirm | p95 ≤ 2.5 s, hard ceiling 8 s | Hard per-provider deadline; unknown on expiry rather than an extended wait |
14 |
| Ledger posting lag | p99 ≤ 2 s after a terminal event | Dedicated poster consuming the event log, natural-key idempotent, one version at a time | 13 |
| Throughput | 420 attempts/s steady, 4,000/s for 10 min | Scale on concurrent in-flight provider calls; per-merchant concurrency limits and fair queuing | 08 |
| Double-charge rate | ≤ 1 per 1,000,000 attempts | Edge idempotency key, no retry after unknown, legality-checked callbacks, natural-key posting |
14 |
| Lost ledger entries | zero tolerance | Append-only store, balance check at write, synchronous replication, replay from the event log | 12 |
| Settlement match | 100% matched or broken within 24 h; unmatched ≤ 0.01% of daily value | Idempotent re-runnable ingest, match on provider reference and amount, named break account with 48 h ageing | 16 |
| Webhook delivery | 99.9% dispatched within 5 s; 72 h retry | Per-merchant queue, signed payload, per-object sequence number, pull-based catch-up API | 17 |
| Payout timeliness | T+1 initiated by 06:00 local for 99.5% of eligible balances | Nightly close gates the run; proven cash only; unique instruction key; returns restore the balance | 16 |
| Durability | Ledger RPO 0; payment state RPO ≤ 5 s | Synchronous ledger replication across two in-country DCs; asynchronous state replication | 18 |
| Recovery | Authorization RTO ≤ 120 s; ledger write RTO ≤ 15 min; reporting RTO ≤ 4 h | DC-2 already carrying ~30% of traffic; declared ledger failover; projections rebuilt rather than recovered | 18 |
| Retention | Ledger 10 y; state and raw payloads 7 y; audit 7 y; logs 400 d | Partition-level archival of closed periods with query support preserved; no PAN at rest outside the CDE | 11 |
Scope
In scope
- Server-created payment intents with an explicit state machine, edge idempotency, separate authorize and capture, partial and multiple captures, and expiry with a provider void
- Card (network tokenised), account-to-account real-time transfer, wallet redirect and mandate debit behind one provider interface, with normalised outcomes including
unknown - Versioned declarative provider routing with health and cost inputs, circuit breaking per provider and per method, and a capped second attempt on retriable declines only
- Signed, deduplicated, legality-checked provider callbacks; an outcome poller for intents in
processing; and a settlement matcher with authority to resolve payment state under constraint - Full and partial refunds, voids of uncaptured authorizations, dispute and chargeback lifecycle with merchant debit and reversal, and receivables where a merchant balance goes negative
- A double-entry ledger as sole system of record: balanced at write, append-only, versioned posting rules, natural-key idempotency, daily trial balance and cash-position proof
- Daily settlement-file ingest with restatement handling, matching, a named and aged break account, and dual-control correction
- Merchant balances with per-risk-tier hold periods, T+1 payouts with unique instruction keys, sanctions holds, return handling and per-period merchant statements
- Signed at-least-once merchant webhooks with per-object sequence numbers, 72-hour retry, replay and a pull-based catch-up API
- A PCI-DSS-scoped cardholder data environment with a token vault and HSM key custody, and a browser-to-CDE path that bypasses the application zone
- Dual control on every privileged money operation, immutable operator audit with case references, masked-by-default operator views and logged elevation
Explicitly out of scope
- Card issuing, lending and credit, and in-person terminals with their EMV kernels, key injection and offline capture
- Acting as a payment network: the platform never clears or settles on its own rails
- Merchant onboarding, KYC, risk scoring and pricing configuration — a separate platform whose merchant records this service reads
- Fraud detection and decisioning as a product: the platform carries the hooks and the routing inputs, not the models
- Multi-currency settlement, FX rate custody and revaluation, marketplace splits and inter-merchant transfer — all deferred, and all of them reshape the ledger's account model
- The merchant's own storefront, order management and shipping decisions, including whether to ship on
processing - Tax calculation and invoice generation; the ledger records tax withheld, it does not compute liability
What a four-week prototype should prove
The prototype's job is to falsify the two central claims — that an asynchronously posted ledger can be made provably correct, and that the unknown outcome can be resolved without ever double-charging — on one rail and one merchant, not to build a platform.
- One provider adapter against a simulator that can be told to approve, decline, time out silently, callback twice, callback out of order and omit a line from the settlement file.
- The full intent state machine with edge idempotency, and a real append-only double-entry store that rejects an unbalanced transaction at write time.
- The ledger poster consuming an event log with the event identifier as a unique natural key. Replay the whole log three times and prove the ledger is byte-identical.
- A settlement matcher, a break account, and a daily trial balance. Inject a missing line, a duplicate line and a restated file, and show the books still balance with the gap named.
- A property test that generates random sequences of approve / decline / timeout / duplicate-callback / late-callback and asserts, over ten thousand runs, that no intent ever produces two captures and no ledger transaction is ever posted twice.
- Measure intent create and confirm latency at p50 and p99 under 500 attempts a second against the simulator, and measure the posting lag separately.
- Deliberately excluded: disputes, payouts, multi-provider routing, the CDE, dashboards and anything with a user interface. If the above does not hold, none of them matter.
- Tell the provider simulator to accept the authorization and then never answer: does the intent end in
processing, does the buyer's second tap create a second attempt, and is the payment resolved from the settlement file within the window? - Deliver the same provider callback five times, two of them out of order and one for a state that is already terminal: does the ledger hold exactly one transaction, and is the ignored transition recorded rather than dropped?
- Omit one line from the settlement file, then deliver a restated file containing it the next day: does the break appear, age, and clear by a correcting transaction rather than an edit?
- Kill the ledger store mid-run for ten minutes: do authorizations keep succeeding, does posting lag alarm, and does the ledger drain to exactly the right state on recovery with nothing double-posted?
- Replay the entire event log three times into an empty ledger: is the result byte-identical each time, and does the trial balance come out at zero?
- Issue two concurrent refunds that together exceed the captured amount: is exactly one accepted, and does the rejection name the reason?
- Submit the same merchant
Idempotency-Keywith a different amount: is it a conflict rather than a second payment? - Attempt a manual ledger correction with one operator account: does the ledger refuse it for want of a second signature?
Open risks, carried rather than hidden
| Risk | If it lands | Response |
|---|---|---|
| The window between a successful payment and a posted ledger entry is permanent, by design | A merchant can be told 'paid' seconds before the balance moves, and in the unknown case up to 24 hours before it moves |
The poller, the callback resolver, the matcher and the break account exist only to guarantee the window always closes and that its contents are visible while open; posting lag and processing age are first-class business metrics (ADR-01, ADR-07, ADR-12) |
| The reconciler has authority to change payment state, which makes a batch job a writer to the payment path | A race between the resolver and a live callback could produce a double transition; the alternative is an unbounded manual backlog | Resolution goes through the same state machine and legality check as any other transition, is idempotent on the event identifier, and is audited as a distinct source; the constraint is the whole content of ADR-12 |
| Two data centres forty kilometres apart share a metro | A regional event takes both sites; there is no third site in this design | Accepted explicitly: the response is a declared, announced outage with RPO 0 rather than a third site, because synchronous replication is what buys RPO 0 and distance is what breaks it (ADR-18) |
| Self-managing Kubernetes, Kafka, PostgreSQL and HSMs is a standing operational commitment | The localisation and PCI-scope benefits are paid for in headcount, and an understaffed platform team is the most likely cause of an outage in this design | Named as the cost of ADR-18 rather than hidden; the batch tier is deliberately placed in DC-2 so reporting load never competes with payments, and the component count is kept deliberately small |
| Dual control is only as strong as the separation between the two approvers | Two operators on the same shift under the same pressure are one control in practice | The mechanism is cryptographic and auditable (ADR-14), and the organisational separation is named as outside the architecture's power to fix — which is itself the honest finding |
| Deferred Phase 3 items each reshape the ledger's account model | Marketplace splits force cross-partition transactions; multi-currency forces FX custody and revaluation; both are expensive after 35 billion entries exist | Both are named in MVP scope as deliberately deferred rather than quietly omitted, and the partitioning record (ADR-08) states the conditions under which it must be re-opened |
The reasoning behind every component and technology choice is in the Architecture Decision Record: 18 records across 6 areas, each with the alternatives that lost and what the choice costs.