Designing a payment system: the timeout that charges twice, and the ledger that proves it Advanced
Estimated reading time: 10 min read
The problem
Section titled “The problem”A generic online shop. Order 101 costs 450,000 VND. The payment service asks an external payment gateway to charge the card. Today the gateway is slow: after 10 seconds the call times out, and checkout shows “Payment failed, try again”.
The customer clicks Pay again, and it succeeds. A week later, finance finds the card charged twice: the first charge had succeeded, only the reply was lost.
How should a payment service record, retry and account for a charge whose outcome it does not know?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| Situation | Choose | Condition |
|---|---|---|
| Call to the gateway times out or returns 5xx | Record unknown, never failed |
failed only on a definite decline from the gateway |
| Retrying a charge | Same idempotency key, or query status first | Key stored before the first call; within the gateway’s key retention window |
| Customer clicks Pay twice | One active payment per order (partial UNIQUE) | Return the existing payment while it is pending/unknown |
| Recording money | Double-entry ledger, append-only | Integer minor units + currency; corrections are new journals |
| Webhook from the gateway | Verify signature, dedupe by event id, monotonic transitions | Treat it as a hint; fetch status if the payload is thin |
| Collecting card numbers | Hosted fields / iframe or redirect to the gateway | Your servers only see a token |
Load estimation (stated assumptions)
Section titled “Load estimation (stated assumptions)”These numbers are illustrative assumptions, not data from a real shop or gateway.
| Assumption | Value |
|---|---|
| Paid orders per day | 2 million |
| Payment attempts per order | 1.2 (declines, retries) |
| Peak-to-average factor (sale days) | 10x |
| Gateway calls that time out | 0.1% |
| Ledger entries per successful payment | 5 (capture 2, settlement 3) |
| One entry row with indexes | ~150 bytes |
| Quantity | Calculation | Result |
|---|---|---|
| Payment attempts | 2.4M / 86,400 | ~28/s, peak ~280/s |
| Unknown outcomes | 2.4M × 0.1% | ~2,400 per day |
| Ledger entries | 2M × 5 | 10M rows/day |
| Ledger storage | 10M × 150 B | ~1.5 GB/day, ~550 GB/year |
Throughput is easy: ~280 writes/s fits one PostgreSQL primary. The decisive number is 2,400 unknown outcomes a day. Guessing on each one means double charges or lost revenue every day.
Core concepts
Section titled “Core concepts”- Gateway: the external service that talks to card networks. It owns the truth about whether money moved.
- Authorize / capture: authorize holds the amount; capture takes it. Many shops do both in one call.
- Idempotency key: a unique value that makes repeats of a request produce one effect (see idempotency, Idempotency-Key).
- Ledger: append-only money movements, separate from the
paymentstatus row. - Settlement file: the gateway’s daily report of captures, refunds, payouts and fees.
Payment state machine
Section titled “Payment state machine”Loading diagram…
failedmeans the gateway said no. A timeout says nothing about its decision.- Transitions only move forward:
capturednever returns topending, even if a late event says so.
How it works
Section titled “How it works”Two idempotency keys
Section titled “Two idempotency keys”| Boundary | Key | Protects against |
|---|---|---|
| Client → payment service | Idempotency-Key header from the app, plus one active payment per order |
Double click, mobile retry after a dropped response |
| Payment service → gateway | payment.id (pay_101), stored before the first call |
Our own retries after a timeout |
The second key prevents the opening incident. It is committed with status pending, then sent, and every retry reuses it.
Timeout → status query → reconcile
Section titled “Timeout → status query → reconcile”Before calling the gateway, the service stores pay_101 as pending; the key exists before any money moves.
State after each step:
pay_101ispendingwith us; the gateway has not seen it.- The gateway has captured 450,000 VND. We do not know.
- We record
unknown; checkout says “being confirmed”, not “failed”. - The second click finds the active
pay_101and returns it. No second charge. - The status query returns
captured. Status, ledger journal and outbox event commit together. - The late webhook’s event id is stored;
captured → capturedis a no-op. - Reconciliation finds
pay_101once in the settlement file and once in the ledger.
If the gateway never saw pay_101, retrying with the same key is safe. If the query also times out, the payment stays unknown and a worker retries with backoff.
Never resolve unknown by sending a new charge with a new key. That is exactly the double charge.
Webhooks
Section titled “Webhooks”Signed events such as charge.captured follow webhook signatures and dedupe: verify the signature over the raw body, insert the event id under a UNIQUE constraint in the same transaction as the state change, apply forward transitions only, and return 2xx.
A missing webhook is normal; queries and reconciliation cover it.
Double-entry ledger
Section titled “Double-entry ledger”Each money event is a journal of two or more entries; each entry debits or credits one account. Per journal, debits equal credits. Balances are sums of entries, never a stored column.
Money is a bigint in minor units plus a currency. ISO 4217 lists minor units: VND has 0 (450,000 VND = 450000), USD has 2 ($49.99 = 4999). Never float8; see data type pitfalls.
entry has CHECK (amount_minor > 0), CHECK (direction IN ('D','C')) and a composite foreign key (account_id, currency). A CHECK sees one row only, so “debits = credits” is a deferred constraint trigger that runs at COMMIT:
CREATE CONSTRAINT TRIGGER entry_balanced AFTER INSERT ON entry DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION assert_journal_balanced();A second trigger rejects UPDATE and DELETE. Mistakes are fixed by a correcting journal that reverses them, so history stays intact.
Outbox for downstream events
Section titled “Outbox for downstream events”Orders, email and fulfilment learn about the capture from a PaymentCaptured row written to an outbox in the same transaction as the status and journal (see transactional outbox). Consumers dedupe by event id.
Operational details
Section titled “Operational details”- Deadline: the gateway call’s deadline is shorter than checkout’s, so the service can answer “being confirmed” (see timeout budgets).
- Key retention: gateways keep idempotency keys for a provider-specific window. Resolve every
unknownwell inside it. - Unknown sweeper: query every
unknownolder than 1 minute, with backoff. Alert on the count and the oldest age. - Reconciliation: daily, match settlement rows ↔
payment↔ ledger by gateway reference and amount. Each break (missing on either side, amount or fee mismatch) becomes a ticket, then a correcting journal. - PCI DSS scope: with a redirect or the gateway’s iframe (hosted fields), card numbers go from browser to gateway; you keep a token. The PCI SSC says such merchants may be eligible for SAQ A. Since v4.0.1, iframe pages also need script-attack protection. The acquirer confirms the SAQ.
Real-world example
Section titled “Real-world example”The ledger demo replays one such duplicate, plus rejected writes, on PostgreSQL 17.11 (examples/docker-compose.yml, port 5433):
docker compose -f examples/docker-compose.yml exec -T postgres \ psql -U hub -d hub -v ON_ERROR_STOP=0 < examples/src/payment-system/ledger.sqlReal output, abridged:
--- 2. unbalanced journal: rejected at COMMITERROR: journal 2 unbalanced in VND: debit 450000 <> credit 405000--- 5. editing an entry: rejectedERROR: ledger entries are immutable; post a correcting journal instead--- 8. balances are sums of entries, never a stored column account | kind | balance_minor---------------------+-----------+--------------- bank_cash | asset | 441000 customer_refundable | liability | 0 gateway_fees | expense | 9000 gateway_receivable | asset | 0 sales | revenue | 450000--- 9. trial balance: total debits = total credits debits | credits---------+--------- 1800000 | 1800000What the demo does not cover: the trigger cannot catch a journal with no entries, concurrency control on balances (for example a spending limit), or permissions; production also REVOKEs UPDATE/DELETE from the application role.
Trade-offs
Section titled “Trade-offs”Pros
-
An explicit
unknownturns guesses into a queue to resolve. -
One key per payment makes retries safe without trusting the network.
-
An append-only ledger explains every balance; reconciliation catches the rest.
<
slot="cons">
>
-
Customers sometimes see “being confirmed” instead of an instant answer.
-
More moving parts: sweeper, webhook handler, reconciliation, outbox relay.
-
Balances are queries over growing tables; large accounts need snapshots.
Cons
Production checklist
Section titled “Production checklist”- Commit
paymentaspendingwith its gateway key before the first gateway call. - Map every timeout, connection reset and 5xx to
unknown; only explicit declines becomefailed. - Enforce one active payment per order with a partial UNIQUE index.
- Run an unknown sweeper; alert when the oldest
unknownpasses 15 minutes. - Store money as
bigintminor units with a currency; enforce balanced journals at commit. - Make ledger entries append-only at the database level, not just in code.
- Verify and dedupe webhooks; apply forward-only transitions.
- Reconcile daily against the settlement file and track breaks to zero.
Common mistakes
Section titled “Common mistakes”The first mistake is the one from the problem section, written as code.
Retrying a timed-out charge
Common
try { await gw.charge({ amount, key: randomUUID() }); }catch (e) { if (isTimeout(e)) await gw.charge({ amount, key: randomUUID() }); }Prefer
await markUnknown(pay.id); // never "failed"const s = await gw.getCharge(pay.id); // same key as the first callif (s === 'not_found') await gw.charge({ amount, key: pay.id });Why
A new key tells the gateway this is a new charge. The same key lets the gateway return the first result.
The second mistake hides in the ledger, where a fix looks harmless.
| Misconception | Consequence | Do instead |
|---|---|---|
| Timeout means the charge failed | Customer pays again; double charge | unknown + status query |
| The webhook is the source of truth | Missed or late event leaves the payment stuck | Webhook, status query and reconciliation together |
| Balance column updated in place | Drift nobody can explain | Balance = sum of entries |
Amounts as float8 or decimal strings |
Rounding errors, mixed currencies | bigint minor units + currency |
Self-check
Section titled “Self-check”- The status query for an
unknownpayment returns “not found”. Why is retrying with the same key safe, and a new key not? - Why is “debits = credits” a deferred trigger and not a
CHECKconstraint? - A refund call times out. Which state and which ledger entries do you record?
Summary
Section titled “Summary”References
Section titled “References”- The Idempotency-Key HTTP Header Field — IETF draft: key semantics for retrying non-idempotent requests.
- CREATE TRIGGER — PostgreSQL 17: constraint triggers must be
AFTER ROWand can be deferred to the end of the transaction. - Constraints — PostgreSQL 17: why a
CHECKmust not reference other rows. - ISO 4217 data standards — SIX: the currency list with minor units per currency.
- FAQ clarifies new SAQ A eligibility criteria — PCI SSC: iframe vs redirect under PCI DSS v4.0.1.
- Document Library — PCI SSC: PCI DSS and the self-assessment questionnaires.