April 8, 2026 10 min read

Designing a Double-Entry Ledger for Payment Platforms — Why Your Balance Table Is a Liability

I've watched three different payment platforms try to scale with a single balance column in a users table. All three hit the same wall. Here's how double-entry bookkeeping — a technique from 15th-century Italy — solves problems that modern engineers keep recreating.

Why Single Balance Tables Break

The pattern is always the same. You start with a users table that has a balance column. A payment comes in, you run UPDATE users SET balance = balance + 100 WHERE id = ?. It works great for your first thousand users.

Then things get interesting. Two concurrent requests hit the same user. One is a deposit, one is a withdrawal. Without careful locking, you get a race condition — the classic lost update problem. I've personally debugged a production incident where a user's balance was off by $4,200 because two goroutines read the same balance, computed different results, and the last write won.

But race conditions aren't even the worst part. The real problem is that you have no idea why the balance is what it is. When a customer disputes a charge and you need to reconstruct what happened, all you have is a number. No trail. No history. Just a balance that may or may not be correct.

The Double-Entry Principle Applied to Payments

Double-entry bookkeeping has one rule: every transaction must have equal debits and credits. Money doesn't appear or disappear — it moves between accounts. In a payment platform, this means every operation creates at least two ledger entries that sum to zero.

DEBIT
Source Account
-$50.00
Journal
Entry
CREDIT
Dest Account
+$50.00

When a customer deposits $50, you don't just increment their balance. You create a journal entry with two lines: a debit from the payment processor's liability account and a credit to the customer's wallet account. The sum is always zero. If it's not, something is broken — and you'll know immediately.

This isn't just accounting theory. It's a runtime invariant you can check on every single transaction. I run a background job that sums all ledger entries every hour. If the total isn't zero, we get paged. It's caught bugs in staging that would have been invisible with a balance table.

Journal Entry Design in Go

Here's the core data model I've used across two production ledger systems. The key insight is that a JournalEntry is the atomic unit — it groups multiple LedgerLine records that must all succeed or all fail.

type JournalEntry struct {
    ID            string    `db:"id"`             // UUID, immutable
    IdempotencyKey string   `db:"idempotency_key"` // prevents duplicate posts
    EntryType     string    `db:"entry_type"`     // "deposit", "withdrawal", "transfer", "correction"
    Description   string    `db:"description"`
    Metadata      JSONB     `db:"metadata"`       // payment processor ref, user context
    CreatedAt     time.Time `db:"created_at"`
}

type LedgerLine struct {
    ID             string    `db:"id"`
    JournalEntryID string    `db:"journal_entry_id"`
    AccountID      string    `db:"account_id"`
    Amount         int64     `db:"amount"`         // in cents, positive = credit, negative = debit
    Currency       string    `db:"currency"`       // ISO 4217
    CreatedAt      time.Time `db:"created_at"`
}

type Account struct {
    ID          string `db:"id"`
    AccountType string `db:"account_type"` // "user_wallet", "revenue", "processor_liability"
    OwnerID     string `db:"owner_id"`     // user ID or system identifier
    Currency    string `db:"currency"`
}

A few things worth noting. The Amount field is an int64 in cents — never use floats for money. The IdempotencyKey on the journal entry is critical: if a network retry causes the same deposit to be posted twice, the unique constraint on this field catches it. And Metadata as a JSONB column gives you a place to stash the Stripe payment intent ID, the internal request ID, whatever you need for debugging later without polluting the schema.

Real-world gotcha: We once had a bug where a refund handler created ledger lines but crashed before committing the journal entry. The lines were orphaned — they existed in the database but weren't linked to any entry. Our balance derivation query included them, so the user's balance was wrong for 6 hours. The fix: wrap journal entry + all ledger lines in a single database transaction, and add a foreign key constraint with ON DELETE CASCADE. Orphaned lines become structurally impossible.

Deriving Balances vs. Storing Them

This is where most teams get nervous. "You want me to SUM the entire ledger every time someone checks their balance?" Yes — with caveats.

The query is simple: SELECT SUM(amount) FROM ledger_lines WHERE account_id = ?. That's the source of truth. But at scale, you'll want a materialized balance that's updated transactionally alongside new ledger lines. The trick is that the materialized balance is a cache, not the source of truth. You can always rebuild it.

Aspect Balance Table Ledger-Derived
Read speed O(1) lookup O(n) without caching
Audit trail None — only current value Complete history
Race conditions Prone without locking Append-only, no conflicts
Reconciliation Manual, error-prone Sum and compare
Corrections Overwrite (destructive) New entry (preserves history)
Storage cost Minimal Higher, grows with transactions

In practice, I use both. The ledger is the source of truth. A balance_snapshots table caches the computed balance, updated inside the same transaction that inserts new ledger lines. A nightly job recomputes all balances from scratch and flags any that don't match the snapshot. In two years, it's caught exactly one discrepancy — and that was from a manual database edit someone made during an incident.

Immutable Entries and Corrections

This is non-negotiable: ledger entries are immutable. You never update or delete a row in the ledger. Ever. If something is wrong, you post a correcting entry — a new journal entry that reverses the original.

Say a customer was charged $100 but should have been charged $80. You don't go back and edit the original entry. You create a new journal entry: credit the customer $100 (reversing the original), then debit $80 (the correct charge). The ledger now tells the full story — what happened, what was wrong, and how it was fixed.

Enforce this at the database level. Remove UPDATE and DELETE grants on the ledger tables for your application's database user. Use PostgreSQL's row-level security or a trigger that rejects mutations. I've seen well-intentioned engineers "fix" ledger data with direct SQL updates during incidents, and it always makes things worse.

Lifecycle of a Payment Through the Ledger

Here's what happens when a customer pays $50 to a merchant on your platform, end to end:

1
Payment Initiated
Customer triggers $50 payment. Idempotency key generated from request context.
2
Acquire Advisory Lock
PostgreSQL advisory lock on the customer's account ID prevents concurrent mutations.
3
Validate Sufficient Balance
Derive current balance from ledger (or read cached snapshot). Reject if insufficient.
4
Create Journal Entry + Ledger Lines
In one transaction: insert journal entry, debit customer (-$50), credit merchant (+$50), update both balance snapshots.
5
Release Lock & Emit Event
Transaction commits, advisory lock releases. Async event published for downstream consumers (notifications, analytics).

The advisory lock in step 2 is worth calling out. We use pg_advisory_xact_lock keyed on the account ID. It's scoped to the transaction, so it auto-releases on commit or rollback. This is lighter than SELECT ... FOR UPDATE on the account row and doesn't cause deadlocks when multiple accounts are involved in a transfer — as long as you always lock in a consistent order (lowest account ID first).

Practical Tips from Production

  1. Use int64 cents, not floats. I shouldn't have to say this in 2026, but I still see float64 for money in code reviews. Use the smallest currency unit. For USD, that's cents. For JPY, it's yen (no subunit). Store the currency code alongside every amount.
  2. Partition your ledger table by month. After a year of moderate traffic, our ledger_lines table had 80 million rows. Queries against recent data were fine, but the nightly full reconciliation was taking 45 minutes. PostgreSQL declarative partitioning by created_at month brought it down to 3 minutes.
  3. Idempotency keys are your safety net. Every external event (webhook from Stripe, callback from a bank) gets an idempotency key derived from the external reference. Duplicate deliveries are silently ignored, not double-posted.
  4. Build a reconciliation report from day one. Don't wait until you have a discrepancy. Run a daily job that compares your ledger totals against your payment processor's settlement reports. The first time it catches a mismatch, you'll be glad you built it early.
  5. Log the full context in metadata. The metadata JSONB column on journal entries is your best friend during incidents. Store the request ID, the user agent, the IP, the processor's transaction ID. When you're debugging at 2 AM, you'll thank yourself.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.