Lesson 9State and lifecycleIntermediate

A purchase is three ledger events

Auth, capture, and settle each touch your books differently.

By Solomon Ajayi · Free to read, no signup

A customer pays you ₦5,000 via card. What your code calls 'a transaction' is actually three separate ledger events spanning two days. The provider authorizes the card now, captures the funds when you fulfill the order, then settles cash to your bank tomorrow. Every fintech engineer eventually realizes their refund logic is broken because they treated this as atomic. Watch all three.

Your code calls it 'a payment,' but a card purchase is really three separate events spread across two days. First the card is authorized: funds are held on the customer's card but nothing has moved. Then you capture: the sale becomes real, revenue and fees are recognized, and the provider now owes you. Finally it settles: cash actually arrives in your bank. Three moments, three different entries.

Each stage touches a different combination of accounts. Auth is just a balanced placeholder, a promise that resolves to nothing if you never capture. Capture is where the economics happen: real revenue, the provider's fee, and a real receivable all fire at once. Settle is a plain asset-to-asset move, cash replacing the receivable, with no revenue at all, because you already recognized it at capture.

Treating this as one atomic event is the bug factory, and refund logic is the usual casualty. Refunding an authorized-but-not-captured payment is a void. Refunding a captured one reverses revenue and fees. Refunding a settled one moves real cash back. Code that cannot tell which stage a transaction is in will reverse the wrong accounts every time.

Worked example, step by step

Auth: card authorized ₦5,000

User clicks Pay. The card issuer reserves ₦5,000 on their card, funds are HELD, not moved. You record a pending receivable that will only become real if you capture it.

Auth: ₦5,000 hold on customer card
AccountDebitCredit
Auth Pending (1110)₦5,000.00
Auth Counterparty (2110)₦5,000.00

Auth creates a balanced placeholder pair. Auth Pending (asset, ₦5,000 debit) says 'we WILL receive this if we capture.' Auth Counterparty (liability, ₦5,000 credit) is its mirror, 'we'd owe it back if we don't.' No revenue yet. No fee yet. Nothing has actually moved.

Capture: commit the auth

You confirm the order is shipped. You tell your provider 'capture that auth.' The placeholder dissolves. A real receivable, real revenue, and real fee all fire at once. Provider takes 1.5% (₦75); you get ₦4,925 net.

Capture: commit ₦5,000 auth
AccountDebitCredit
Auth Counterparty (2110)₦5,000.00
Provider Receivable (1100)₦4,925.00
Card Fee Expense (5000)₦75.00
Auth Pending (1110)₦5,000.00
Sales Revenue (4000)₦5,000.00

Five lines. The Auth Pending / Auth Counterparty placeholder pair both clear. Provider Receivable becomes real for ₦4,925. Card Fee Expense ₦75 (the fee is on YOU). Sales Revenue ₦5,000 (you earned what the customer paid, even though you only RECEIVE ₦4,925). Debits 5000+4925+75 = 10000. Credits 5000+5000 = 10000.

Card transactions live in their own state table next to the ledger.

CREATE TYPE card_txn_status AS ENUM (
  'authorized', 'captured', 'settled', 'voided', 'refunded'
);

CREATE TABLE card_transaction (
  id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id       uuid NOT NULL,
  gross_amount      bigint NOT NULL,
  fee_amount        bigint,
  net_amount        bigint,
  status            card_txn_status NOT NULL,
  auth_entry_id     uuid REFERENCES journal_entry (id),
  capture_entry_id  uuid REFERENCES journal_entry (id),
  settle_entry_id   uuid REFERENCES journal_entry (id),
  provider_txn_id   text UNIQUE,                  -- idempotency boundary
  updated_at        timestamptz NOT NULL DEFAULT now()
);

Capture: clear the auth placeholder AND book real receivable + revenue + fee.

import { Client } from "pg";

export async function captureCardTransaction(
  client: Client,
  args: { txnId: string; gross: bigint; feeRateBp: number },
): Promise<void> {
  const fee = (args.gross * BigInt(args.feeRateBp)) / 10000n;
  const net = args.gross - fee;

  try {
    await client.query("BEGIN");

    const { rows } = await client.query<{ id: string }>(
      `INSERT INTO journal_entry (description) VALUES ($1) RETURNING id`,
      [`Capture: commit NGN${args.gross} auth`],
    );
    const entryId = rows[0].id;

    // Five lines: clear the auth pair, book receivable, fee, and revenue.
    await client.query(
      `INSERT INTO journal_line (entry_id, account_code, debit, credit) VALUES
         ($1, '2110', $2, 0),
         ($1, '1100', $3, 0),
         ($1, '5000', $4, 0),
         ($1, '1110', 0, $2),
         ($1, '4000', 0, $2)`,
      [entryId, args.gross, net, fee],
    );

    await client.query(
      `UPDATE card_transaction
          SET status = 'captured',
              fee_amount = $1,
              net_amount = $2,
              capture_entry_id = $3,
              updated_at = now()
        WHERE id = $4`,
      [fee, net, entryId, args.txnId],
    );

    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  }
}

Settle: provider wires ₦4,925 to your bank

Tomorrow morning, your bank notification fires. ₦4,925 from your provider. The receivable converts into actual cash. NO revenue here, that was recognized at capture.

Settle: provider → bank ₦4,925
AccountDebitCredit
Bank Account (1200)₦4,925.00
Provider Receivable (1100)₦4,925.00

Dr Bank Account, Cr Provider Receivable. Asset-to-asset transfer. Same pattern as Lesson 4. Notice how the THREE events touch DIFFERENT account combinations, that's why your refund code needs to know which stage a transaction is at.

Takeaway

A card purchase is three ledger events: auth (placeholder), capture (real receivable + revenue + fee), settle (cash arrives). Refund logic that doesn't distinguish 'capture-stage' from 'settle-stage' will inevitably break. Build your ledger to track the lifecycle, not just the snapshot.

Practice this on a real ledger

Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.

More in this section

Search lessons

Type to find any of the 85 lessons. Press Enter to open.