QuickCart Stage 6: Payments and the Ledger, Money Without Mistakes
Design QuickCart payments so a retry never charges twice, every cent is recorded in a double-entry ledger, and daily reconciliation catches what slips through.
What you'll learn: By the end of this lesson you will be able to make a payment request safe to retry with idempotency keys, design an authorise-then-capture flow with webhooks, record every money movement in an immutable double-entry ledger, reconcile it against a payment provider's reports, explain why QuickCart uses a saga instead of two-phase commit for money, and say when event sourcing and an audit log are worth their cost.
A shop till has two protections that software often forgets. The cashier will not ring the same sale twice just because the customer held out the card again, and at the end of the day somebody counts the drawer against the paper roll. The first prevents mistakes; the second finds the ones that got through.
Online, "held out the card again" is a retry: a double click, a dropped response, a worker that restarts mid-call. This lesson builds both protections into QuickCart, plus a notebook in which every cent is written down once, never erased, and always in two places. QuickCart is an educational reference design, and every number is an illustrative assumption.
Where QuickCart is now
After Stage 5, checkout is asynchronous: a saga reserves stock, charges the shopper and assigns a courier, with an outbox and a broker carrying events. Messages arrive reliably, but nothing checks that money moved correctly.
At the target of 500,000 orders per day (about 5.8 per second on average and, at an assumed 10 times peak, about 58 per second; the arithmetic is in Stage 1), rare bugs become daily events:
Symptom
Cause today
A shopper is charged twice
The payment call times out and the worker retries, with no way to say "same charge"
An order is "paid" but the provider has no charge
A crash between the provider call and the order status write
Store payouts disagree with the stores' numbers
Payout is a nightly query; refunds were manual UPDATE statements
Nobody can say where an amount went
Balances are overwritten in place
The provider's statement matches nothing
No process compares the two
All five share one gap: payment state lives in mutable columns, and the only truth is whatever the last writer left.
The core idea
Money software needs three protections:
Prevent repeats. Every money-moving operation carries an idempotency key, so doing it twice has the effect of doing it once.
Record every movement, immutably. A double-entry ledger stores each movement as entries that sum to zero. Entries are never updated or deleted; balances are computed from them.
Compare with the outside world.Reconciliation matches the ledger against the provider's reports, so differences become visible items.
Around them sit a payment flow that matches how card payments work (authorise, then capture, confirmed asynchronously), a saga instead of a distributed lock, and an audit log. Amounts are integers in the smallest currency unit, never floating point.
Making retries safe
Idempotency and deduplication
An operation is idempotent if doing it several times has the same effect as doing it once. RFC 9110 defines PUT, DELETE and the safe methods (GET, HEAD, OPTIONS, TRACE) as idempotent and does not define POST as idempotent; it says a client should not automatically retry a non-idempotent request unless it knows the request is safe to repeat. "Create a payment" is a POST, so it needs help.
The help is an idempotency key: a unique value the caller generates for one logical operation and sends with every attempt. The receiver stores the key with the result, and a repeat returns the stored result without redoing the work. Deduplication is the receiver-side half: recognising two messages as one operation. An IETF Internet-Draft for an Idempotency-Key header (version 08 at the time of writing, not yet an RFC) suggests the status codes used below; QuickCart follows its shape, which may change.
The key must survive four hops; if any hop loses it or invents a new one, the chain breaks.
Hop
Who creates the key
What dedupes
1. Client to API
The client, once per checkout attempt
The API's key table
2. API to Payments
Passed through
Payments' own table
3. Payments to provider
Payments, stored before the call
The provider's idempotency support
4. Event consumers
The event id
An inbox or unique constraint
The first hop on the wire (the draft defines the value as a quoted string and recommends UUIDs):
HTTP/1.1201 Created
Content-Type: application/json
{"payment_id":"pay_7731","status":"authorised","amount_minor":4500,"currency":"USD"}
The server stores the key with a hash of the request body, which separates four cases:
Never seen. Process and store the result.
Seen, same body, finished. Return the stored status and body unchanged.
Seen, different body. A client bug; the draft suggests 422. Never process it.
Seen, same body, still in progress. The draft suggests 409 Conflict.
CREATE TABLE idempotency_keys (
scope text NOT NULL, -- owner, e.g. 'shopper:8812'
key text NOT NULL,
request_hash bytea NOT NULL,
status text NOT NULLCHECK (status IN ('in_progress', 'completed')),
response_code integer,
response_body jsonb,
created_at timestamptz NOT NULLDEFAULT now(),
expires_at timestamptz NOT NULL,
PRIMARY KEY (scope, key)
);
Scoping by shopper stops a guessed key replaying someone else's response. Claiming a key is one statement: PostgreSQL's INSERT ... ON CONFLICT DO NOTHING skips a conflicting row and does not return it:
INSERT INTO idempotency_keys (scope, key, request_hash, status, expires_at)
VALUES ('shopper:8812', 'b3f1c2de-5a6e-4c1f-9f0a-2d7e8a41c9b7', $1, 'in_progress', now() +interval'24 hours')
ON CONFLICT (scope, key) DO NOTHING
RETURNING key;
A returned row means this request owns the key; no row means someone else does, so read the existing row and apply the four cases. The primary key makes this safe under concurrency: the database lets only one of two simultaneous inserts of the same key succeed.
Expiry. QuickCart uses 24 hours (illustrative): longer than any client retries one checkout, short enough to keep the table small. A key arriving after expiry counts as new. The provider has its own key retention; read its documentation.
Crashed owners. A process that claims a key and dies leaves the row in_progress. A recovery job checks the real payment state at the provider; "in progress for a long time" does not mean "failed".
What is stored. The response, never card data or secrets (see Stage 10).
Hop 4 applies the same idea to consumers. Apache Kafka delivers at least once by default, so a consumer can see an event twice. Its documentation describes an idempotent producer (no duplicates from the producer's own retries) and transactions (exactly-once processing inside Kafka), and states that exactly-once delivery to other systems needs those systems' cooperation. The ledger is such a system, so it makes its own writes idempotent (a unique constraint, shown later). See Message Queues and Event Streaming.
The double-submit race on the Pay button
The classic bug: the shopper taps Pay twice within 30 milliseconds. Disabling the button helps, but a network, proxy or second tab can duplicate a request too, so the server must be correct alone.
Figure 1. Two Pay requests with one key
sequenceDiagram
accTitle: Double submit of the Pay button with one idempotency key
accDescr: Two identical requests arrive 30 milliseconds apart. The first claims the key and calls the provider. The second finds the key in progress and receives 409. After the first finishes, a retry returns the stored response.
participant B as Browser
participant A as API
participant K as Key table
participant P as Provider
B->>A: POST payments, key K (click 1)
B->>A: POST payments, key K (click 2, 30 ms later)
A->>K: claim K for request 1
K-->>A: inserted, you own it
A->>K: claim K for request 2
K-->>A: conflict, row is in_progress
A-->>B: 409 Conflict, Retry-After 1 (request 2)
A->>P: authorise 4,500, attempt key
P-->>A: authorised
A->>K: mark K completed, store response
A-->>B: 201 Created (request 1)
B->>A: retry POST payments, key K
A->>K: claim K
K-->>A: conflict, row is completed
A-->>B: 201 Created, same stored body
How to read this: the second request never reaches the provider, so exactly one authorisation exists whichever tap wins.
Same-key duplicates are half the problem. If the client creates the key inside the click handler instead of when the checkout attempt begins, two taps carry two keys and look unrelated. Add a second guard at the business level: at most one live payment per order.
CREATEUNIQUE INDEX one_live_payment_per_order
ON payments (order_id)
WHERE status IN ('created', 'authorised', 'captured', 'partially_refunded');
This partial unique index enforces uniqueness only among rows matching the WHERE. A second attempt while one is live fails with a constraint error; failed and voided payments do not match, so a declined shopper can try again. Keys stop duplicates caused by retries; the index stops duplicates caused by logic. Keep both.
Designing the payment flow
Authorise, then capture
Card payments commonly run in two steps. Authorise: the provider asks the card issuer to hold an amount; no money moves, but the shopper's available balance drops. Capture: QuickCart tells the provider to take the held amount, which starts settlement.
A grocery basket is not final at the tap: an item may be out of stock or substituted. Authorising at checkout proves the shopper can pay; capturing after packing charges the real total. If it is lower, many providers allow a partial capture and release the rest of the hold; if higher, a new authorisation is usually needed. Authorisation lifetimes and release rules vary by provider and card network, so confirm them. A cancelled order is voided before capture; no money moved.
Figure 2. One checkout payment, from authorisation to ledger entry
sequenceDiagram
accTitle: QuickCart payment from authorise to ledger posting
accDescr: The order saga asks Payments to authorise. Payments calls the provider. After packing, the saga requests capture. The provider confirms by webhook. Payments records the event and publishes PaymentCaptured through an outbox, and the ledger posts the entries.
participant S as Order saga
participant Y as Payments
participant P as Provider
participant E as Event stream
participant L as Ledger
S->>Y: authorise order ord_1001, 4,500
Y->>P: authorise, attempt key A1
P-->>Y: authorised, reference
Y-->>S: authorised
Note over S: store packs, final total known
S->>Y: capture 4,500
Y->>P: capture, attempt key C1
P-->>Y: accepted, result comes by webhook
P--)Y: webhook payment.captured, event id
Y->>Y: store event id, set captured, write outbox row
Y--)E: PaymentCaptured (from outbox)
E--)L: PaymentCaptured
L->>L: post ledger entries once
How to read this: open-headed arrows are one-way messages. The answer to "capture" is only "accepted"; the webhook confirms, and nothing downstream acts until Payments has recorded it.
The payment is a small state machine; writing it down prevents impossible moves.
Figure 3. Payment states
stateDiagram-v2
accTitle: States of a QuickCart payment
accDescr: A payment starts as created, becomes authorised or failed, then captured or voided, and a captured payment can be partially or fully refunded.
[*] --> Created
Created --> Authorised: provider authorised
Created --> Failed: declined or error
Authorised --> Captured: capture confirmed
Authorised --> Voided: cancelled or hold expired
Captured --> PartiallyRefunded: refund below captured
Captured --> Refunded: refund equals captured
PartiallyRefunded --> Refunded: remainder refunded
Failed --> [*]
Voided --> [*]
Refunded --> [*]
How to read this: every arrow is a rule enforced in code and, where possible, in the database. A new attempt after Failed or Voided is a new payment row.
Webhooks and asynchronous confirmation
A webhook is an HTTP request the provider sends to QuickCart when something happens, such as a capture settling. Many payment steps finish after the original call returned, so the immediate response is a hint and the webhook is the confirmation. The endpoint takes untrusted input from the internet:
Verify the sender. Providers sign webhooks, commonly with an HMAC (keyed hash) over the raw body. Verify the raw bytes before parsing, with a constant-time comparison; header names and what is signed differ per provider.
Deduplicate by event id. Providers retry until they get a success response. Insert the event id into an inbox table with a unique constraint, in the same transaction as the state change.
Do not trust order. A refund event can arrive before its capture event. Let the state machine in Figure 3 decide: park an event that does not fit, or fetch the current state from the provider.
Answer fast. Record the event, write an outbox row, return 2xx. A slow handler makes the provider retry.
Poll as a backstop. A webhook can be lost, so a job checks authorised payments whose capture is unconfirmed; reconciliation is the final backstop.
Retries, refunds and partial captures
Retries. Retry a provider call only for ambiguous or transient failures (timeout, 5xx), with the same attempt key and backoff (see Resilience Patterns). A clear decline is final.
Refunds. A refund is a new money movement with its own idempotency key, provider reference and ledger transaction; it never edits the original, and it can be partial. Total refunded must never exceed total captured, even when two support agents click Refund together. Enforce it in one atomic statement, not a read followed by a write:
UPDATE payments
SET refunded_minor = refunded_minor + $1WHERE id = $2AND refunded_minor + $1<= captured_minor;
-- 1 row updated: refund allowed. 0 rows: it would exceed what was captured.
A CHECK (refunded_minor <= captured_minor) constraint is a second net, and captured_minor <= authorised_minor follows the same rule.
Money as integer minor units
Never store money as floating point. PostgreSQL's documentation calls real and double precision inexact, so "storing and retrieving a value might show slight discrepancies", and recommends numeric where exactness is required. JavaScript numbers behave the same way:
console.log(0.1 + 0.2); // 0.30000000000000004
QuickCart stores each amount as a bigint count of the minor unit, with the currency code beside it. ISO 4217 lists the minor-unit digits per currency: two for the US dollar (45.00 is stored as 4500), none for the Japanese yen (450 yen is 450). Never add amounts of different currencies.
Percentages bring fractions back: 15 percent of 4,205 is 630.75, which does not exist. Fix the rounding rule once and derive one side by subtraction, so the sides sum to the total:
// itemsMinor is an integer number of minor units.exportfunctionsplitItems(itemsMinor: number, feePercent: number): { feeMinor: number; storeMinor: number } {
const feeMinor = Math.floor((itemsMinor * feePercent) / 100);
return { feeMinor, storeMinor: itemsMinor - feeMinor };
}
// splitItems(4200, 15) returns { feeMinor: 630, storeMinor: 3570 }// splitItems(4205, 15) returns { feeMinor: 630, storeMinor: 3575 }
JavaScript represents integers exactly up to 9,007,199,254,740,991, ample for one order.
The ledger
Double-entry ledgers
A ledger is the permanent record of money movements. A double-entry ledger records each movement twice, as a debit in one account and a credit in another, so value only moves; it never appears or disappears.
An account is a named bucket with a balance: provider_clearing (money the provider holds for QuickCart, not yet paid out), store_payable:S42 (owed to store S42), platform_fee_revenue, courier_payable.
An entry is one line: account, direction (debit or credit) and a positive amount.
A ledger transaction is a set of entries that belong together, such as "capture of order ord_1001".
The invariant: in every transaction, debits equal credits, per currency.
An asset (something QuickCart has or is owed) grows with debits; a liability (something it owes) and revenue grow with credits. Software needs only this convention stored on the account.
The ledger is immutable: entries are never updated or deleted. A mistake is fixed by a reversing transaction, leaving both error and fix visible.
Figure 4. The ledger tables
erDiagram
accTitle: QuickCart ledger tables
accDescr: Ledger transactions have many entries. Each entry belongs to one account. Each account has one derived balance row.
ledger_accounts ||--o{ ledger_entries : "receives"
ledger_transactions ||--|{ ledger_entries : "contains"
ledger_accounts ||--|| account_balances : "has derived"
ledger_accounts {
bigint id PK
text code UK
text kind "asset, liability, revenue, expense"
char currency
}
ledger_transactions {
bigint id PK
text source_type
text source_id
text provider_ref
timestamptz posted_at
}
ledger_entries {
bigint id PK
bigint transaction_id FK
bigint account_id FK
text direction "debit or credit"
bigint amount_minor "always positive"
char currency
}
account_balances {
bigint account_id PK
bigint balance_minor
bigint last_entry_id
}
How to read this: facts live in ledger_entries; account_balances is a copy that can be rebuilt from them.
CREATE TABLE ledger_accounts (
id bigint GENERATED ALWAYS ASIDENTITYPRIMARY KEY,
code text NOT NULLUNIQUE,
kind text NOT NULLCHECK (kind IN ('asset', 'liability', 'revenue', 'expense')),
currency char(3) NOT NULL,
UNIQUE (id, currency)
);
CREATE TABLE ledger_transactions (
id bigint GENERATED ALWAYS ASIDENTITYPRIMARY KEY,
source_type text NOT NULL, -- 'capture', 'refund', 'payout'
source_id text NOT NULL, -- e.g. the payment id: makes posting idempotent
provider_ref text,
description text,
posted_at timestamptz NOT NULLDEFAULT now(),
UNIQUE (source_type, source_id)
);
CREATE TABLE ledger_entries (
id bigint GENERATED ALWAYS ASIDENTITYPRIMARY KEY,
transaction_id bigintNOT NULLREFERENCES ledger_transactions (id),
account_id bigintNOT NULL,
direction text NOT NULLCHECK (direction IN ('debit', 'credit')),
amount_minor bigintNOT NULLCHECK (amount_minor >0),
currency char(3) NOT NULL,
FOREIGN KEY (account_id, currency) REFERENCES ledger_accounts (id, currency)
);
The composite foreign key forces an entry's currency to match its account's. The positive-amount check means direction, not sign, carries meaning. UNIQUE (source_type, source_id) is the idempotency guard for hop 4: "capture of pay_7731" can exist only once.
The database should enforce the invariant. PostgreSQL constraint triggers are AFTER ... FOR EACH ROW triggers that can be DEFERRABLE INITIALLY DEFERRED, firing at the end of the transaction, which matters because a transaction is briefly unbalanced while entries go in one at a time.
CREATEFUNCTION check_transaction_balanced() RETURNStriggerLANGUAGE plpgsql AS $
BEGIN
IF EXISTS (
SELECT1FROM ledger_entries
WHERE transaction_id = NEW.transaction_id
GROUPBY currency
HAVINGSUM(CASE direction WHEN'debit'THEN amount_minor ELSE-amount_minor END) <>0
) THEN
RAISE EXCEPTION 'ledger transaction % is unbalanced', NEW.transaction_id;
END IF;
RETURNNULL;
END $;
CREATECONSTRAINTTRIGGER entries_balanced
AFTER INSERTON ledger_entries
DEFERRABLE INITIALLY DEFERRED
FOREACHROWEXECUTEFUNCTION check_transaction_balanced();
CREATEFUNCTION forbid_ledger_change() RETURNStriggerLANGUAGE plpgsql AS $
BEGIN
RAISE EXCEPTION 'ledger entries are immutable';
END $;
CREATETRIGGER entries_immutable
BEFORE UPDATEORDELETEON ledger_entries
FOREACHROWEXECUTEFUNCTION forbid_ledger_change();
A transaction that commits with unequal debits and credits now fails. Also revoke UPDATE, DELETE and TRUNCATE from the application's role, since a row-level trigger does not fire for TRUNCATE.
Size: at the assumed peak of 58 checkouts per second and four entries per capture, about 232 rows per second are inserted; 500,000 orders per day is about 2,000,000 rows per day, or 730,000,000 per year, before refunds and payouts (illustrative). That is plausible for one PostgreSQL primary with time-based partitioning; measure it. The ledger is its own database, not sharded by shopper like the orders database from Stage 4, because accounts such as platform fee revenue are shared by every shopper.
A worked example: one order, one store, one fee
Shopper 8812 buys 42.00 of groceries from store S42 plus a 3.00 delivery fee: 45.00 in all. QuickCart's fee is 15 percent of the item subtotal (illustrative policy); the delivery fee is owed to the courier. Amounts are minor units of an example currency, written USD.
Debits 4,500, credits 4,500: the provider holds 4,500 for QuickCart, which owes the store 3,570 and the courier 300 and earned 630.
Figure 5. Where the 4,500 goes
How to read this: the arrows show allocation, not four payments. One debit is matched by three credits that add up to it.
Later one item is reported missing. Illustrative policy: a 1,000 refund returns the platform's 15 percent (150) and the store's share (850).
T2, the partial refund of 1,000 (its own provider reference and idempotency key): debit store_payable:S42 850, debit platform_fee_revenue 150, credit provider_clearing 1,000.
When the store is paid, QuickCart pays what it owes: 3,570 - 850 = 2,720.
T3, the payout of 2,720: debit store_payable:S42 2,720, credit provider_clearing 2,720.
Check the balances. store_payable:S42 (credit minus debit): 3,570 - 850 - 2,720 = 0, so the store is paid. platform_fee_revenue: 630 - 150 = 480. courier_payable: 300 still owed. provider_clearing (debit minus credit): 4,500 - 1,000 - 2,720 = 780, exactly the 480 QuickCart keeps plus the 300 it owes the courier. The accounts explain each other; that is what double entry buys.
To post T1 idempotently, one statement inserts the transaction and its entries. On a duplicate source_id, DO NOTHING returns no row, the tx set is empty and no entries are inserted:
WITH tx AS (
INSERT INTO ledger_transactions (source_type, source_id, provider_ref, description)
VALUES ('capture', 'pay_7731', 'cap_8841', 'Order ord_1001 captured')
ON CONFLICT (source_type, source_id) DO NOTHING
RETURNING id
)
INSERT INTO ledger_entries (transaction_id, account_id, direction, amount_minor, currency)
SELECT tx.id, a.id, v.direction, v.amount_minor, 'USD'FROM tx
CROSSJOIN (VALUES
('provider_clearing', 'debit', 4500),
('store_payable:S42', 'credit', 3570),
('platform_fee_revenue', 'credit', 630),
('courier_payable', 'credit', 300)
) AS v (code, direction, amount_minor)
JOIN ledger_accounts a ON a.code = v.code;
The ids are made up.
Balances are derived data
A balance is a sum of facts, not a fact. For a liability account:
SELECTCOALESCE(SUM(CASE direction WHEN'credit'THEN amount_minor ELSE-amount_minor END), 0) AS balance_minor
FROM ledger_entries
WHERE account_id = $1;
Summing millions of entries per read is slow, so keep a balance read model, account_balances, maintained one of two ways:
Approach
Gain
Cost
Update in the posting transaction
Exactly current; safe for payout decisions
Hot rows: every order touches provider_clearing and platform_fee_revenue, so at about 58 checkouts per second those updates run in series
Update asynchronously from the stream
No contention on the write path
Balance lags; unsafe for payouts without a catch-up check
QuickCart updates per-store payables synchronously and high-traffic aggregates asynchronously (seconds of dashboard lag are harmless); a hot row can be split into sub-accounts. The entries stay the truth, so this nightly query must return nothing; a row means drift, and the table is rebuilt from entries:
SELECT b.account_id, b.balance_minor AS stored, d.derived
FROM account_balances b
JOIN (
SELECT e.account_id,
SUM(CASEWHEN (e.direction ='credit') = (a.kind IN ('liability', 'revenue'))
THEN e.amount_minor ELSE-e.amount_minor END) AS derived
FROM ledger_entries e
JOIN ledger_accounts a ON a.id = e.account_id
GROUPBY e.account_id
) d ON d.account_id = b.account_id
WHERE b.balance_minor <> d.derived;
Reconciliation
Reconciliation
A perfect ledger records only what QuickCart believes happened. The provider has its own records. Reconciliation is the routine comparison of the two, so disagreements surface within a day instead of a quarter.
The inputs: internal, ledger entries on provider_clearing grouped by provider reference; external, the provider's settlement report (one line per capture, refund, fee and payout; its format and cut-off time zone are the provider's). A full outer join on the reference exposes unmatched rows on either side. Assume each report line has a signed amount from QuickCart's side:
WITH ours AS (
SELECT t.provider_ref,
SUM(CASE e.direction WHEN'debit'THEN e.amount_minor ELSE-e.amount_minor END) AS amount_minor
FROM ledger_entries e
JOIN ledger_transactions t ON t.id = e.transaction_id
JOIN ledger_accounts a ON a.id = e.account_id
WHERE a.code ='provider_clearing'AND t.provider_ref ISNOT NULLAND t.posted_at >= TIMESTAMPTZ '2026-11-06 00:00+00'AND t.posted_at < TIMESTAMPTZ '2026-11-07 00:00+00'GROUPBY t.provider_ref
)
SELECTCOALESCE(o.provider_ref, r.provider_ref) AS provider_ref,
o.amount_minor AS ours,
r.amount_minor AS theirs
FROM ours o
FULLOUTERJOIN provider_report_lines r ON r.provider_ref = o.provider_ref
WHERE o.provider_ref ISNULLOR r.provider_ref ISNULLOR o.amount_minor ISDISTINCTFROM r.amount_minor;
Figure 6. Daily reconciliation
How to read this: every line ends in one of two places, matched or on a human's list. Nothing is dropped silently.
Mismatch
Likely cause
Fix
In the report, not the ledger
A lost webhook
Fetch the payment, post through the normal path
In the ledger, not the report
A capture that never completed, or a cut-off difference
Check the provider API; post a reversing transaction if needed
Amounts differ
Partial capture, a fee netted in
Compare line by line; fees get their own transaction
Duplicate in the report
A retry that charged twice
Refund the duplicate; fix key handling
Never fix a mismatch by editing history. Post a correction, or an entry to a suspense account for unexplained amounts, with an owner and a deadline; a growing suspense balance is itself a signal. Alert on the exception count and the age of the oldest, and treat an empty report as a failure, not a clean day. Reconciliation does not prevent a double charge; it bounds how long one can stay hidden.
Consistency across services
2PC versus saga for money
A payment spans the orders database, inventory, the ledger database and an external provider. The classic tool for atomicity across resources is two-phase commit (2PC): a coordinator asks every participant to prepare, and when all agree, tells them to commit. A prepared participant holds its locks until the coordinator decides, so a coordinator failure at the wrong moment leaves participants waiting (Distributed Transactions covers the protocol).
For QuickCart's money flow it is a poor fit. The provider is external and has no prepare-and-wait interface a QuickCart coordinator could drive. Locks held across services cost availability: at 58 checkouts per second, one slow participant stalls checkout. And every participant must speak the protocol, which ends independent deployment.
So QuickCart uses the saga from Stage 5: local transactions, each with a compensating action if a later step fails.
Step
Local transaction
Compensation if a later step fails
Reserve stock
Items reserved
Release the reservation
Authorise payment
Funds held
Void the authorisation
Assign courier
Courier assigned
Release the courier
Capture payment, after packing
Captured; ledger posts T1
Refund: a new reversing ledger transaction
A saga gives up isolation: readers can see intermediate states (stock reserved, payment authorised, not yet captured). The design copes by making each state explicit (Figure 3), each step idempotent and each compensation a recorded forward action, never a delete. Where everything lives in one database a local transaction still wins: posting a ledger transaction is one, and so is "set captured plus write the outbox row" (the transactional outbox from Stage 5).
Figure 7. Which mechanism for which money change
flowchart TD
accTitle: Choosing a consistency mechanism for a money change
accDescr: If all writes are in one database use a local transaction. If an external provider is involved use a saga with idempotent steps. Otherwise two-phase commit is possible only if every participant supports it and blocking is acceptable.
Q1{"All writes in one database?"} -->|yes| A1["One local transaction ledger entries commit together"]
Q1 -->|no| Q2{"External provider involved?"}
Q2 -->|yes| A2["Saga with idempotent steps compensate with refunds and voids"]
Q2 -->|no| Q3{"Every participant supports prepare and commit, and blocking is acceptable?"}
Q3 -->|yes| A3["2PC is possible rarely worth it across services"]
Q3 -->|no| A2
How to read this: ask top to bottom. The ledger work answers the first question (one database); the payment call answers the second (a provider, so a saga).
The authorise-then-capture flow is the Try-Confirm/Cancel pattern from the same lesson: authorise is "try", capture is "confirm", void is "cancel".
Event sourcing and audit
Event sourcing and CQRS for the ledger
Event sourcing stores changes as a sequence of immutable events and computes current state from them. CQRS (Command Query Responsibility Segregation) separates the model that accepts changes from the models that answer questions, so each can have its own shape. CQRS and Event Sourcing covers both.
A double-entry ledger already has this shape: ledger_entries is an append-only log of facts and balances are computed from it. The write side is "post a balanced transaction". The read side is a set of read models: account_balances, a statement for the store's dashboard, and daily revenue for analytics fed by LedgerTransactionPosted events through the outbox.
Benefits: a read model can be dropped and rebuilt, a new question can be answered by building a new model over old history, and every number traces to entries. Costs: read models lag, replay takes time (snapshots help), event meaning must stay stable as schemas evolve, and the team must think in events. QuickCart therefore applies event sourcing to the ledger only. Orders stay an ordinary table with a status column and a history table. Use the pattern where the log is the product.
A strict event-sourced system derives all state from events. QuickCart keeps one mutable convenience, account_balances, verified nightly: entries are authoritative and the balance table is a provable cache.
Audit logging
The ledger records what happened to the money. An audit log records who did what, including actions that move no money: a support agent issuing a refund, an admin changing a fee rate, someone viewing a shopper's payment history. They are separate stores with different access and retention rules. A record answers who, what, when and with what result:
Identifiers are examples and the hashes are shortened. What makes such a log trustworthy:
Append-only for the writer: insert permission, no update or delete.
Server-generated time and identity: the actor comes from the authenticated session, never the request body.
No secrets or card data, minimal personal data. Store ids, not names.
Tamper-evident. In a hash chain, each record includes the previous record's hash, so changing or removing one breaks every later hash.
import { createHash } from"node:crypto";
// Serialise the record the same way every time (fixed key order), or the hash will differ.exportfunctionchainHash(prevHash: string, record: Record<string, unknown>): string {
returncreateHash("sha256").update(prevHash).update(JSON.stringify(record)).digest("hex");
}
A chain detects tampering only against a trusted copy of the latest hash, because someone with write access to the whole store could recompute it all. So periodically copy the latest hash somewhere the log's operators cannot change (write-once storage or a separate account) and re-verify the chain in a job.
Retention is a policy decision. How long audit and financial records must be kept differs by country and record type, and privacy law may require deleting personal data. Ask compliance, keep the periods as configuration, and store ids rather than personal details. See Security.
Index by what you will ask: resource id, actor id and the request id shared with logs and traces (Observability and SLOs).
Decisions and trade-offs
Option
Gain
Cost
When it is the wrong choice
Mutable balance column
Simple, one read
No history; lost updates
Anywhere a number must be explained
Double-entry append-only ledger (chosen)
Invariant, history, rebuildable balances
More tables and rows
A tool that never moves others' money
Event sourcing for all orders
Replay and audit everywhere
Schema evolution, rebuild time, lag
A status column plus history suffices
Keys in the database (chosen)
Atomic with the payment
An extra write per request
Very high volume, low-value requests
Authorise then capture (chosen)
Charge the real total
Two calls; holds expire
Instant digital goods at a fixed price
Saga for money (chosen)
Works with a provider; no cross-service locks
Visible intermediate states
All writes in one database
2PC across services
Atomic commit
Blocks on coordinator failure
Almost always for payments
In practice: QuickCart at this stage
NOTE
This is a realistic scenario, not a case history. It describes an educational reference design, and every number is an illustrative assumption.
The situation
QuickCart is at Stage 5, at an assumed 5,000,000 monthly active users and 500,000 orders per day. The saga's payment step calls the provider from a worker, sets the order PAYMENT_AUTHORISED and publishes PaymentAuthorised. A finance analyst compares the monthly provider statement with internal totals in a spreadsheet, and support has a refund button that runs an UPDATE and calls the provider.
Starting architecture
Figure 8. Payments before this stage
How to read this: the worker has no memory of its charge. If the response is lost, the retry (red dashed) looks like a new payment, and a crash before the status write leaves a charged shopper with an order that never advances.
Options considered
Option
What it fixes
What it costs
Risk
Idempotency keys only
Duplicate charges
No history; payouts still wrong
Fixes one symptom of five
Payments service, ledger, reconciliation
All five symptoms
A service, a database, a migration
A ledger bug is a money bug
The decision
Field
Decision record
Context
Retries can double charge; payout and refund numbers cannot be explained; the provider report is matched to nothing.
Decision
A Payments service owning the state machine, a key table (24-hour expiry), authorise-then-capture and signed, deduplicated webhooks. Every movement posts to an append-only double-entry ledger in its own database; the database enforces the invariant and immutability. Reconcile daily.
Consequences, good
A retry cannot charge twice; every cent traces to entries; mismatches surface in a day.
Consequences, bad
Two more stores to run; about 2,000,000 new rows per day (illustrative); hot-row risk.
Revisit when
Hot rows show lock waits, or a second provider is added.
Rollout and verification
Shadow ledger for two weeks (illustrative): the old path stays the record, ledger transactions are posted from the same events, and daily totals are compared.
Key table behind a flag for a small share of shoppers, with a test that sends one key twice, concurrently, to a provider sandbox and asserts one authorisation.
Reconciliation in report-only mode, reviewing early exceptions by hand to learn the report's quirks.
Cut over payouts to the ledger, running the old query in parallel for one cycle.
Automate: the balance invariant and drift queries return no rows; captured_minor <= authorised_minor and refunded_minor <= captured_minor hold; reconciliation exception count and age alert; webhook signature failures are watched.
The 2 a.m. failure
A deploy moves key generation inside the capture retry loop, so each retry gets a fresh attempt key. At 02:00 the provider slows, many capture calls time out, and the job retries them. Some originals had succeeded, so those shoppers are charged twice.
Detection: the webhook consumer sees two payment.captured events for one payment, the captured_minor <= authorised_minor check fails, and an early reconciliation run shows references twice in the provider report but once in the ledger. The page reads "duplicate captures detected". First three checks: payments with captured above authorised; the attempt-key column for those payments (several keys on one payment shows the bug); capture calls per payment since the deploy.
Mitigation: switch off the retry job with its flag, refund the duplicates through the normal path, and tell support who is affected. Follow-ups: derive the attempt key from payment and operation (pay_7731:capture:1) and store it before the call; add a unique constraint on (payment id, operation, sequence) so the database rejects a second key; test that a retried timed-out capture creates one provider-side charge.
This is a realistic scenario, not a case history.
Architecture after this stage
Figure 9. QuickCart after Stage 6
How to read this: dashed grey boxes are unchanged or external; solid rust boxes are new. Confirmations return as webhooks, the stream feeds the ledger, and reconciliation closes the loop.
What QuickCart looks like now
Component
Why it exists
What it costs
Payments service
State machine and provider calls
A service to run and keep on call
Idempotency key table
Safe retries
One write per request; an expiry job
Webhook receiver
Safe asynchronous confirmation
Secrets to rotate; an exposed endpoint
Ledger service and database
Immutable record of every cent
About 2,000,000 rows per day (illustrative)
Balance read model
Fast balances
Hot rows; a nightly drift check
Reconciliation job
Provider mismatches found in a day
A report integration; an exception owner
Audit log
Who did what, tamper-evident
A second append-only store; retention policy
Architect's notes
Fix the unit of money on day one. Integer minor units plus a currency, everywhere. Changing later touches everything.
Write the attempt before the call. Commit "about to call the provider with key X" first, or a crash leaves a charge nobody recorded.
Keep the ledger boring. Few account types, balanced transactions, no updates.
Measure exceptions, not only success: exception count and age, suspense balance, signature failures, drift results.
Common mistakes
Floats for money. Rounding errors accumulate.
A new key on every retry. Each request looks new, so nothing is deduplicated.
Keys only in a cache. An eviction reopens the duplicate window.
Unsigned or duplicated webhooks. Anyone can mark orders paid; duplicates post money twice.
Fixing the ledger with UPDATE or DELETE. History is destroyed; post a reversing transaction.
Skipping reconciliation because the ledger balances. It can balance and still disagree with the provider.
2PC across services and a provider. The provider cannot join; use a saga.
Key takeaways
Every money-moving operation carries an idempotency key that survives client, API, provider call and consumer, stored with a request hash.
A concurrent duplicate gets a clear answer: 409 while in progress, the stored response when done, 422 for a different body (per an IETF draft, not yet a standard).
Back keys with a business constraint such as one live payment per order.
Authorise at checkout, capture at the real total, and treat webhooks as confirmation: verify, deduplicate, tolerate disorder.
Store money as integer minor units with a currency.
A double-entry ledger is append-only and balanced; corrections are new transactions and balances are derived.
Reconcile daily against the provider's report; a mismatch is an item with an owner.
Use a saga, not 2PC, for money across services.
Event sourcing earns its cost for the ledger, not every table.
Practice exercises
Exercise 1: Starter (30 min). Write the ledger transactions for an order of 60.00 of items and 4.00 delivery with a 12 percent platform fee on items (rounded down), then a full refund before any payout. Check each transaction balances and state each account's final balance.
Exercise 2: Intermediate (1.5 hours). Design the recovery job for keys stuck in_progress more than two minutes. Write the decision table (provider says authorised, nothing found, unreachable) and explain why "retry the call" is not always right.
Exercise 3: Advanced (half day). Build the key table and a payment endpoint against local PostgreSQL. Fire 20 concurrent requests with one key at a fake provider that counts charges and assert one charge. Repeat with a fresh key per request and show the partial unique index limiting the damage.
Exercise 4: Advanced (1 hour). If one in 10,000 of 500,000 daily orders mismatches (illustrative), how many reconciliation exceptions is that per day? Design who sees them, how long one may stay open and when a suspense entry is posted.
Lesson 6 of 11 · Backend System Design Project: Build QuickCart
Design QuickCart payments so a retry never charges twice, every cent is recorded in a double-entry ledger, and daily reconciliation catches what slips through.
In the rest of this lesson
The ledger
Reconciliation
Consistency across services
Event sourcing and audit
Decisions and trade-offs
In practice: QuickCart at this stage
Enrolling is free. It unlocks every lesson in Backend System Design Project: Build QuickCart, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.