Skip to content

    System Design

    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.

    By Published 24 min read

    Share

    This lesson is part of Backend System Design Project: Build QuickCart. Enroll for free, then sign in, to read it in full.

    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:

    1. Prevent repeats. Every money-moving operation carries an idempotency key, so doing it twice has the effect of doing it once.
    2. 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.
    3. 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):

    POST /v1/orders/ord_1001/payments HTTP/1.1
    Host: api.quickcart.example
    Content-Type: application/json
    Idempotency-Key: "b3f1c2de-5a6e-4c1f-9f0a-2d7e8a41c9b7"
    
    {"payment_method_id": "pm_9fK2", "amount_minor": 4500, "currency": "USD"}
    HTTP/1.1 201 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:

    1. Never seen. Process and store the result.
    2. Seen, same body, finished. Return the stored status and body unchanged.
    3. Seen, different body. A client bug; the draft suggests 422. Never process it.
    4. 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 NULL CHECK (status IN ('in_progress', 'completed')),
      response_code  integer,
      response_body  jsonb,
      created_at     timestamptz NOT NULL DEFAULT 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.

    CREATE UNIQUE 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:

    1. 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.
    import { createHmac, timingSafeEqual } from "node:crypto";
    
    export function isValidSignature(rawBody: Buffer, signatureHex: string, secret: string): boolean {
      const expected = createHmac("sha256", secret).update(rawBody).digest();
      const given = Buffer.from(signatureHex, "hex");
      return given.length === expected.length && timingSafeEqual(given, expected);
    }
    1. 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.
    2. 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.
    3. Answer fast. Record the event, write an outbox row, return 2xx. A slow handler makes the provider retry.
    4. 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 + $1
    WHERE id = $2
      AND 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.
    export function splitItems(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 AS IDENTITY PRIMARY KEY,
      code      text    NOT NULL UNIQUE,
      kind      text    NOT NULL CHECK (kind IN ('asset', 'liability', 'revenue', 'expense')),
      currency  char(3) NOT NULL,
      UNIQUE (id, currency)
    );
    
    CREATE TABLE ledger_transactions (
      id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY 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 NULL DEFAULT now(),
      UNIQUE (source_type, source_id)
    );
    
    CREATE TABLE ledger_entries (
      id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      transaction_id bigint  NOT NULL REFERENCES ledger_transactions (id),
      account_id     bigint  NOT NULL,
      direction      text    NOT NULL CHECK (direction IN ('debit', 'credit')),
      amount_minor   bigint  NOT NULL CHECK (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.

    CREATE FUNCTION check_transaction_balanced() RETURNS trigger LANGUAGE plpgsql AS $
    BEGIN
      IF EXISTS (
        SELECT 1 FROM ledger_entries
        WHERE transaction_id = NEW.transaction_id
        GROUP BY currency
        HAVING SUM(CASE direction WHEN 'debit' THEN amount_minor ELSE -amount_minor END) <> 0
      ) THEN
        RAISE EXCEPTION 'ledger transaction % is unbalanced', NEW.transaction_id;
      END IF;
      RETURN NULL;
    END $;
    
    CREATE CONSTRAINT TRIGGER entries_balanced
      AFTER INSERT ON ledger_entries
      DEFERRABLE INITIALLY DEFERRED
      FOR EACH ROW EXECUTE FUNCTION check_transaction_balanced();
    
    CREATE FUNCTION forbid_ledger_change() RETURNS trigger LANGUAGE plpgsql AS $
    BEGIN
      RAISE EXCEPTION 'ledger entries are immutable';
    END $;
    
    CREATE TRIGGER entries_immutable
      BEFORE UPDATE OR DELETE ON ledger_entries
      FOR EACH ROW EXECUTE FUNCTION 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.

    • Charged 4,500. Fee: 15 percent of 4,200 = 630. Store: 4,200 - 630 = 3,570. Courier: 300. Check: 3,570 + 630 + 300 = 4,500.

    T1, the capture (webhook confirmed):

    Account Direction Amount
    provider_clearing (asset) Debit 4,500
    store_payable:S42 (liability) Credit 3,570
    platform_fee_revenue (revenue) Credit 630
    courier_payable (liability) Credit 300

    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

    One captured order split across ledger accountsShopper pays to Provider clearing (4,500); Provider clearing to Store payable S42 (3,570); Provider clearing to Platform fee revenue (630); Provider clearing to Courier payable (300)Shopper pays: 4,500Shopper pays4,500Provider clearing: assetProviderclearingassetStore payable S42: liabilityStore payableS42liabilityPlatform fee revenue: revenuePlatform feerevenuerevenueCourier payable: liabilityCourierpayableliability4,5003,570630300

    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
    CROSS JOIN (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:

    SELECT COALESCE(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(CASE WHEN (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
      GROUP BY 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 IS NOT NULL
        AND t.posted_at >= TIMESTAMPTZ '2026-11-06 00:00+00'
        AND t.posted_at <  TIMESTAMPTZ '2026-11-07 00:00+00'
      GROUP BY t.provider_ref
    )
    SELECT COALESCE(o.provider_ref, r.provider_ref) AS provider_ref,
           o.amount_minor AS ours,
           r.amount_minor AS theirs
    FROM ours o
    FULL OUTER JOIN provider_report_lines r ON r.provider_ref = o.provider_ref
    WHERE o.provider_ref IS NULL
       OR r.provider_ref IS NULL
       OR o.amount_minor IS DISTINCT FROM r.amount_minor;

    Figure 6. Daily reconciliation

    Daily reconciliation of the ledger against the provider reportProvider report to Reconciliation job (lines); Ledger entries to Reconciliation job (sums); Reconciliation job to Matched (equal); Reconciliation job to Exception queue (differ)Provider report: file or APIProviderreportfile or APILedger entries: clearingLedger entriesclearingReconciliation job: dailyReconciliationjobdailyMatched: day closedMatchedday closedException queue: to reviewExceptionqueueto reviewlinessumsequaldiffer

    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:

    {
      "event_id": "aud_01JD3K8Z",
      "occurred_at": "2026-11-07T09:41:12Z",
      "actor": { "type": "staff", "id": "agent_204" },
      "action": "refund.issue",
      "resource": { "type": "payment", "id": "pay_7731" },
      "details": { "amount_minor": 1000, "currency": "USD", "reason": "item_missing" },
      "outcome": "success",
      "request_id": "req_5c1e",
      "prev_hash": "9ab4...",
      "hash": "e1d7..."
    }

    Identifiers are examples and the hashes are shortened. What makes such a log trustworthy:

    1. Append-only for the writer: insert permission, no update or delete.
    2. Server-generated time and identity: the actor comes from the authenticated session, never the request body.
    3. No secrets or card data, minimal personal data. Store ids, not names.
    4. 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.
    export function chainHash(prevHash: string, record: Record<string, unknown>): string {
      return createHash("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.

    1. 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.
    2. 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

    QuickCart payment path before Stage 6Shopper to Saga worker (pay); Saga worker to Payment provider (charge); Saga worker to Payment provider (retry, new charge?); Saga worker to Orders database (set status)Shopper: taps PayShoppertaps PaySaga worker: retriesSaga workerretriesPayment provider: externalPaymentproviderexternalOrders database: order statusOrdersdatabaseorder statuspaychargeretry, new charge?set status

    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

    1. 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.
    2. 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.
    3. Reconciliation in report-only mode, reviewing early exceptions by hand to learn the report's quirks.
    4. 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

    QuickCart architecture after Stage 6, with payments and the ledger addedOrder saga to Payments service (authorise, capture); Payments service to Idempotency keys (claim key); Payments service to Payment provider (charge); Payment provider to Webhook receiver (webhook); Webhook receiver to Payments service (event); Payments service to Event stream (outbox); Payments service to Audit log (who, what); Event stream to Ledger service (events); Ledger service to Ledger database (post); Payment provider to Reconciliation job (report); Reconciliation job to Ledger service (compare)Order saga: from Stage 5Order sagafrom Stage 5Webhook receiver: verify, dedupeWebhookreceiververify, dedupePayments service: state machinePaymentsservicestate machineIdempotency keys: 24 h expiryIdempotencykeys24 h expiryPayment provider: externalPaymentproviderexternalEvent stream: from Stage 5Event streamfrom Stage 5Audit log: hash chainedAudit loghash chainedReconciliation job: dailyReconciliationjobdailyLedger service: inbox, entriesLedger serviceinbox, entriesLedger database: append-onlyLedgerdatabaseappend-onlyauthorise, captureclaim keychargewebhookeventoutboxwho, whateventspostreportcompare

    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.

    Official documentation

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments