Back to Payments

Payments · Lesson 6

The Ledger Pattern (vs Balance Columns)

Once money is involved, "store the balance in a column and update it" stops being good enough. The pattern that serious money-handling apps use is the same one accountants used before computers: an append-only ledger.

Why the balance column breaks

The naive shape is a balance_cents column on User or Account. Every transaction adds or subtracts. The current balance is one query: read the column.

What breaks:

  • You can't audit it. "Why does this user have $42.13 instead of $42.50?" The column has the answer "right now" and no history. You can't trace how it got there.
  • Concurrent writes race. Two payments arriving at the same time both read 0, both write 100. You end up with 100 instead of 200. The Concurrency lesson covered this; for money, the bug is unacceptable.
  • Reversals require knowing the original transaction. A refund needs to subtract exactly what was added. If you didn't store the per-transaction history, you can't reverse correctly when amounts diverged from rounding or fees.

The ledger

A ledger is an append-only table where each row is one financial event. No row is ever updated or deleted. To know the current balance, you sum the rows.

# Schema (simplified):
create_table :ledger_entries do |t|
  t.references :account, null: false
  t.integer    :amount_cents, null: false   # positive credit, negative debit
  t.string     :kind, null: false           # "payment", "refund", "fee", "adjustment"
  t.references :source, polymorphic: true   # the Payment, Refund, etc. that caused this
  t.string     :memo
  t.datetime   :created_at, null: false
end

add_index :ledger_entries, [:account_id, :created_at]

# Balance:
account.ledger_entries.sum(:amount_cents)

The polymorphic source reference lets you trace every entry back to the event that caused it. The kind column lets you slice the ledger by purpose: just payments, just fees, just adjustments.

Concurrency is solved by the design

Two concurrent payments both INSERT. The database handles concurrent INSERTs cleanly without serialization. Whatever order they land, the balance is the sum, and the sum doesn't depend on order. The race-condition bug from the balance-column shape can't happen, because no two operations are racing to update the same row.

Reversals are insertions, not updates

A refund doesn't update the original payment entry. It adds a new entry with the opposite sign, referencing the original. The original entry stays. The history is intact. You can ask "what happened on this account?" and read the answer row by row.

# Original payment:
# +5000 cents, kind: "payment", source: Payment#42

# Customer is refunded $25:
# -2500 cents, kind: "refund",  source: Refund#7  (which references Payment#42)

# Current balance: +5000 + -2500 = +2500
# Audit query: "show me everything that touched this account" — straightforward.

When the SUM gets slow

A real account might accumulate millions of entries over years. Summing them on every balance read becomes a hot query. Standard fixes:

  • Periodic snapshots. Once a day, write a "balance as of midnight" row. Balance = snapshot + sum(entries after snapshot). Fast read, full history retained.
  • Caching layer. Memcached / Redis stores the current balance; the ledger entries are still the source of truth. Cache invalidation on every write. The cache stampede lesson applies — use a single-flight refresh.
  • Materialized view. Postgres can maintain a materialized view of per-account balances, refreshed on writes. Native to the database, no app-layer cache.

You only need these if you actually measure the SUM as slow. Modern Postgres indexes a hundred million rows comfortably. Premature optimization here is a real cost; the ledger's value is in correctness, not in micro-optimizations against a cost that may never appear.

When you can skip the ledger

The ledger is the right pattern for credit-style features: store credits, wallets, account balances, anywhere users hold money. For a simple "did the order get paid? yes/no" model, the Payment record is enough. Use the ledger when you need a history of credits and debits on a balance that accumulates. Use a simple balance flag when the question is just "is this order paid."