Skip to main content

Payment Ledger

Rule: payment status says whether a transaction succeeded; only a ledger says where the money went. Every movement is two entries that sum to zero, and no entry is ever updated or deleted.

Part of the payment set: state & idempotency · provider & webhooks · order consistency · ledger (this file) · refunds · reconciliation · settlement · fraud detection

Contents​

The anti-pattern​

-- The entire financial record of a 100,000đ payment.
UPDATE orders
SET status = 'paid'
WHERE id = :order_id;
UPDATE merchants
SET balance = balance + 100000
WHERE id = :merchant_id;

At month end, accounting asks questions this schema cannot answer:

  • Where is that 100,000đ right now?
  • How much did the provider take in fees?
  • What exactly does the merchant get paid after platform fees?
  • Why is the merchant's balance 3,000đ off from the sum of their orders?

A mutable balance column is a running total with no derivation. When it is wrong — and it will be, after one failed job or one race — there is nothing to recompute it from and no way to find out when it drifted.

Why development hides it​

balance = balance + amount is correct for a single successful payment, and that is the only case a dev environment produces. Fees, partial refunds, chargebacks, multi-party splits, and failed mid-sequence writes never appear.

Ledger defects are also silent by nature: nothing throws, no request fails. The system reports success while the numbers quietly stop adding up, and it surfaces weeks later as a finance question rather than an engineering alert.

Status is a snapshot; a ledger is the movement​

Payment status answers "what state is this transaction in?" A ledger answers the more important question: "how did the money move?"

Status is a photograph at one instant. A ledger is the full recording: whose account the money left, which fees it passed through, and where it came to rest. You need both, and they answer different questions — you cannot derive the second from the first.

Double-entry​

Every financial movement is recorded as at least two entries — a debit and a credit — written together. For a 100,000đ payment:

AccountDirectionAmount
User cashdebit100,000
Merchant receivablecredit100,000

The invariant that makes this self-checking:

sum(debits) = sum(credits) -- equivalently, the entries sum to zero

If that does not balance, reject the transaction. This is the mechanism that makes it impossible for software to create or destroy money — a logic bug or a race can produce a wrong entry, but it cannot produce an unbalanced transaction. Enforce it as a constraint or a checked assertion at write time, not as a report you run later.

An entry needs, at minimum: the ledger transaction it belongs to, the account, direction, amount, currency, and a reference to the business event that caused it.

Immutability: no UPDATE, no DELETE​

Once an entry is written, it is permanent. Never UPDATE an amount, never DELETE a row.

To correct a mistake, write an adjustment entry that offsets it. The error and its correction both remain visible, which is precisely what an audit requires — a ledger you can edit is not evidence of anything.

This is the same principle refunds run on: a refund is not the erasure of a payment but a new, opposite movement. See refunds.

Practical enforcement: revoke UPDATE and DELETE on the ledger tables at the database role level. An application-level convention will not survive a hotfix at 2am.

One ledger transaction, many entries​

A real payment is rarely two entries. Money leaves the user, part goes to the merchant, part to platform fees:

AccountDirectionAmount
User cashdebit100,000
Merchant receivablecredit97,000
Platform fee revenuecredit3,000

All of these belong to one ledger transaction, written atomically. Either every entry lands or none does.

A partial write is the worst state the ledger can hold: the user is debited, the merchant is credited, and the fee entry is missing — so the books do not balance, and no single record shows which step failed. Group the entries and commit them together.

Account types, and why the equation holds​

Double-entry only self-checks if accounts are typed. The minimum vocabulary:

  • Asset — value you control (cash at the acquirer, bank account, receivable from a provider)
  • Liability — value you owe (customer wallet balances, merchant payables, unsettled funds)
  • Equity / revenue — what the business has earned (platform fees)

A customer's wallet balance is a liability, not an asset. That single classification prevents a whole family of errors: it makes explicit that holding customer funds is an obligation, and that paying it out reduces both an asset and a liability rather than "spending" your own money. See payment-settlement for why unsettled funds are a receivable rather than cash.

Corrections: reversing entries​

The operational form of immutability. To correct an entry, write its exact mirror — same amount, opposite direction — then write the correct entry. Three rows now exist: the error, its reversal, and the truth.

This is strictly better than a single "adjustment" that nets the difference, because the audit trail shows what was wrong and not merely that something changed. It is also the same mechanism refunds use — see refunds — which is why a refund needs no special case in the ledger.

Balances are a cache​

Summing every entry for an account on each read does not survive scale. So keep a balance snapshot per account for fast lookup.

But keep the direction of authority straight: ledger entries are the source of truth; the snapshot is a cache. It must always be recomputable from the entries, and it must be periodically reconciled against them — see reconciliation.

The failure mode to watch for in review is code that treats the snapshot as authoritative: updating a balance without writing entries, or trusting a balance that has drifted. If the snapshot and the entries disagree, the entries are right.

Review checklist​

  • Money movement is recorded as ledger entries, not as mutations of a balance column
  • Every ledger transaction balances: sum(debits) = sum(credits), enforced at write time
  • Unbalanced transactions are rejected, not logged and accepted
  • Ledger entries are never updated or deleted; corrections are adjustment entries
  • UPDATE/DELETE are revoked on ledger tables at the database level
  • All entries for one event — including fees — are written atomically in one transaction
  • Each entry references the business event that caused it
  • Balance snapshots are treated as a cache, recomputable from entries
  • Snapshots are reconciled against entry sums on a schedule
  • Currency is explicit on every entry; no cross-currency arithmetic without conversion records
  • Accounts are typed (asset / liability / equity); customer balances are modelled as liabilities
  • Corrections use reversing entries, not net adjustments

Synthesized from TechCraft's Payment System series part P7 (collection), and Data Modeling Patterns parts P14–P15 (collection). The status-vs-flow framing, the double-entry invariant, immutability, balance-as-cache, asset/liability classification, and reversing entries are TechCraft's; the review structure and checklist are this repository's.