A ledger balance is never a stored field — it's the derived sum of an append-only stream of double-entry postings, where every transaction touches at least two accounts and debits always equal credits. Store the history, not the number, and refunds and audits become ordinary queries instead of special cases.
A ledger balance is never written directly — it's the running total of every debit and credit posted against an account. Store the postings, derive the balance, and you get an audit trail, reversibility, and correctness for free.
Most fintech PMs inherit this concept the hard way: a support ticket about a refund that "disappeared," or an engineer asking why the wallet balance doesn't match what the payment processor reports. The fix isn't a patch — it's a different data model. This is the ledger data model every payments feature eventually needs, whether you call it that or not.
Why "Balance Is a Number" Breaks the First Time You Need a Refund
Most first-draft payment schemas store a balance column on the user or wallet record and increment or decrement it with every transaction. This works fine in a demo and fails the moment two things happen at once, a refund needs to unwind a specific charge, or someone asks you to prove the number is correct. A single mutable field can't carry that proof.
The failure modes are predictable, and if you've shipped a wallet, credits system, or payout feature you've likely hit at least one:
- Race conditions. Two concurrent updates to the same balance row can silently overwrite each other under load, especially with naive read-modify-write logic.
- No audit trail. A balance of $42 tells you nothing about how it got there — not the sequence of charges, refunds, or adjustments behind it.
- Ambiguous refunds. Reversing a charge by just subtracting the amount from the balance loses the link between the refund and the original transaction it corrects.
- No point-in-time balance. Support and finance regularly need "what was this account's balance last Tuesday," which a single current-state field cannot answer.
- Reconciliation breaks. Matching your internal number against a processor's statement requires transaction-level detail, not a summary field — a problem covered in depth in our guide to payment reconciliation systems.
None of these are edge cases in fintech; they're Tuesday. The double-entry ledger exists specifically because "balance as mutable state" cannot survive contact with real money movement.
Debits and Credits Without the Accounting Jargon
A debit and a credit are just two directions on a single entry — one account's balance moves up, its paired account's balance moves down, and the two amounts always net to zero. Forget the bank-statement folklore that debits are "bad" and credits are "good"; in ledger design they're a sign convention, not a moral judgment, and their entire purpose is to make the system self-checking.
This isn't a new idea dressed up in software. Italian mathematician Luca Pacioli documented double-entry bookkeeping for merchants in his 1494 treatise Summa de Arithmetica, and the core rule hasn't changed since: every transaction must have equal debits and credits, full stop. Martin Fowler's Patterns of Enterprise Application Architecture later formalized this as the "Accounting" analysis pattern for software systems, treating money movement as a graph of entries rather than a set of counters.
Which side increases which account
The direction that "increases" a balance depends on the account's type, which is exactly why a single generic balance += amount operation is dangerous — it has to know what kind of account it's touching.
| Account type | Normal balance | Debit effect | Credit effect | Example account |
|---|---|---|---|---|
| Asset | Debit | Increases | Decreases | cash, processor_clearing |
| Liability | Credit | Decreases | Increases | customer_payable, refunds_owed |
| Equity | Credit | Decreases | Increases | retained_earnings |
| Revenue | Credit | Decreases | Increases | revenue:subscriptions |
| Expense | Debit | Increases | Decreases | expense:processor_fees |
Read the table as a constraint, not trivia: a payment feature that credits revenue and debits processor_clearing for the same amount is internally consistent by construction. If your engineering team can't tell you which side of an entry increases which account, that's a sign the ledger data model hasn't been designed yet — it's been assumed.
The Append-Only Journal: Why You Never Mutate a Balance
The journal is the single source of truth: a strictly append-only log where every posting is written once and never edited or deleted. A wrong entry gets corrected with a new, reversing entry — never an UPDATE statement. This is the same principle Martin Kleppmann describes in Designing Data-Intensive Applications: state is a fold over an event log, not a place you poke values into directly.
The ledger doesn't record what the balance is. It records everything that happened, and the balance falls out as a consequence.
Practically, this pattern buys you four things engineering teams stop having to build from scratch:
- Concurrency safety. Inserts don't need row-level locks the way read-modify-write balance updates do, which matters at payment volume.
- Point-in-time balances.
SUM()all postings up to any timestamp and you have the historically accurate balance for that moment — no snapshot tables required. - Free audit trail. Every posting is timestamped, attributed, and permanent, which is precisely the tamper-evident record a security PM role will ask for during a compliance review.
- Safe replay and recovery. Because the journal is the source of truth, you can rebuild every derived balance from scratch if a downstream cache or read-model gets corrupted.
The trade-off is storage and query design: an append-only journal grows forever and needs indexing strategy, partitioning, and archival thinking — the kind of scale decision an infra PM role should weigh in on before volume makes it painful to change.
The Core Entities: Account, Transaction, Entry, Posting
Four entities do almost all the work in a double-entry ledger data model, and confusing them is the most common modeling mistake fintech PMs make when briefing engineering. Get the definitions straight and the rest of the schema — foreign keys, constraints, indexes — mostly falls out on its own.
| Entity | What it represents | Key attributes | Example |
|---|---|---|---|
| Account | A node whose balance you track — not necessarily a bank account | id, name, type (asset/liability/equity/revenue/expense), currency | processor_clearing, wallet:user_4521 |
| Transaction | The business event; wraps two or more entries that must net to zero together | id, type, external_ref, status, created_at | "Refund for order #4521" |
| Entry | One line within a transaction — an account, an amount, and a direction | id, transaction_id, account_id, amount, direction | Debit $100 to processor_clearing |
| Posting | The immutable, timestamped journal row created once a transaction commits | id, entry_id, posted_at, balance_after | The permanent audit-grade record of that entry |
The distinction between entry and posting matters more than it looks. An entry can exist in an intermediate state — authorized but not yet captured, pending settlement — while a posting is the irrevocable record written once the transaction actually clears. Modeling both separately gives you a place to represent "this charge is authorized" without ever writing an unreliable row to the permanent journal.
How the entities relate
- One
transactionhas manyentries(minimum two, for the debit and the credit side). - Each
entryreferences exactly oneaccountand carries onedirection. - Each committed
entryproduces exactly oneposting, which is never updated after creation. - An
account's balance is alwaysSUM(postings.amount)filtered by account and direction — never a stored value.
Worked Example: A Refund as Reversing Entries
A refund is not a delete or an update to the original charge — it's a brand-new transaction whose entries mirror the original in the opposite direction, so the ledger permanently shows both the sale and its reversal. Walking through the numbers makes the "never mutate" rule concrete instead of abstract.
Step 1 — The original sale. A customer is charged $100 for a subscription. Transaction T1 creates two balanced entries:
| Account | Debit | Credit |
|---|---|---|
processor_clearing (asset) | $100 | |
revenue:subscriptions | $100 |
Step 2 — Three days later, support issues a full refund. Instead of touching T1 or decrementing a balance field, the system creates a new transaction, T2, whose entries are the exact mirror image:
| Account | Debit | Credit |
|---|---|---|
revenue:subscriptions | $100 | |
processor_clearing (asset) | $100 |
Step 3 — Compute the balances. Summing all postings for revenue:subscriptions gives you -$100 + $100 = $0, and processor_clearing nets to $0 as well — the correct end state. Critically, T1 and T2 both remain permanently visible, linked by external_ref, so any auditor or reconciliation job can see exactly what happened and when, rather than inferring it from a net-zero balance.
Partial refunds follow the identical pattern at a smaller amount; multi-party splits — say, a marketplace refund that has to unwind a buyer charge, a seller payout, and a platform fee simultaneously — just mean more entries in the same transaction, each still balancing to zero. That's precisely the multi-party payout structure that marketplace PMs have to reason about, since a refund there rarely touches just one account. On the trigger side, most refunds don't start with a person clicking a button — they start with a processor's refund.succeeded webhook, and mapping that event to a brand-new ledger transaction rather than a database mutation is exactly the kind of contract an integrations PM role has to get right with every payment partner.
Modeling the Ledger Before Engineering Builds It
The best time to catch a broken ledger data model is before the first migration, and that means sketching accounts, transactions, entries, and postings as entities with real relationships — not describing them in a doc that engineering interprets differently than you meant. A whiteboard diagram of "accounts connect to entries" doesn't tell you whether the foreign keys, required fields, and cardinalities actually hold together.
Key Takeaways
- A ledger balance is a derived sum, never a stored, mutable field — compute it from postings, don't write to it directly.
- Double-entry means every transaction has at least two entries whose debits and credits net to zero, a rule unchanged since Pacioli's 1494 formalization.
- The journal is append-only: corrections happen through new reversing entries, never through
UPDATEorDELETEstatements on existing postings. - The four core entities — account, transaction, entry, posting — map cleanly to "what," "why," "which side," and "the permanent record," respectively.
- A refund is a new transaction, not a change to the original charge; both remain visible forever, which is what makes audits and reconciliation tractable.
- Modeling the ledger as entities and relationships before engineering builds it — with a tool like Prodinja's Data Modelling — surfaces gaps in the schema while they're still cheap to fix.
Frequently Asked Questions
What is a double-entry ledger in software terms?
A double-entry ledger is a data model where every financial event is recorded as two or more balanced entries across different accounts, rather than as a single balance update. It guarantees that money is never created or destroyed by an incomplete write, because every transaction's debits must equal its credits by construction.
Why shouldn't I just store balance as a column on the user table?
A stored balance column can't prove how it got to its current value, can't support point-in-time queries, and is vulnerable to lost updates under concurrent writes. Deriving the balance from an immutable stream of postings solves all three problems at once, at the cost of a slightly more complex read query (a SUM() instead of a field lookup).
How do refunds work in a double-entry ledger?
A refund is modeled as an entirely new transaction with entries that mirror the original charge in the opposite direction, rather than as an edit to the original charge. Both the original sale and the refund remain permanently in the journal, linked by reference, so the net balance is correct while the full history stays auditable.
What's the difference between an entry and a posting?
An entry is the logical line item within a transaction — an account, an amount, and a direction — and it can exist in a pending or authorized state before it's final. A posting is the immutable, timestamped row written to the journal once that entry actually commits, and it is never edited after the fact.
Do I need a full double-entry model for a simple app with in-app credits?
If real money, refunds, or any external reconciliation is involved — even lightweight in-app credits redeemable for value — a minimal double-entry structure (accounts, transactions, entries) is worth the upfront modeling cost. For purely cosmetic points with no monetary or refund implications, a simpler counter may be an acceptable, deliberate trade-off, as long as the team makes that call explicitly rather than by default.