Double-entry for developers: the accounting primitives every fintech coder should know
Model money as a single number column and you will regret it. Double-entry accounting (journal, ledger, debits, immutability) is the correct data model for fintech.
Here is the bug report that should scare you: "the balance is right on the dashboard but wrong on the export." I have read some version of that sentence more times than I want to admit. It almost always means the same thing. Somebody modelled money as a single number, updated it from three different code paths, and now nobody can say what the number is supposed to mean.
When I built the ledger core of Vantnod, I had a choice. Store an account balance as a column and add or subtract from it as transactions arrive: fast to write, easy to read, obvious. Or do what accountants have done for five hundred years and use double-entry. I picked double-entry, and I want to walk you through why, because the accounting concepts map cleanly onto data structures and invariants, and once you see the mapping you stop fighting it.
The single-column trap
The naive model looks innocent:
-- the version you will regret
accounts (id, name, balance)
You credit a sale, you debit a refund, you nudge balance up and down. It works in the demo. Then reality arrives.
A customer disputes a charge from two months ago. What was the balance the day before that charge? You cannot reconstruct it, because you only stored the current number, not how it got there. So you bolt on a transactions table for the record. Now you have two sources of truth: the running balance column and the sum of the transactions. The day they disagree (and they will, the first time two requests update the same row at once, or a job retries and double-applies) you cannot tell which one is lying.
This is the real failure mode, and it is not theoretical. The single-column model has no built-in check that says "money does not appear or vanish." Every write is a naked mutation, nothing forces the books to balance, so eventually they don't, and you find out from a customer rather than from your own system.
Double-entry is a data model, not a finance thing
The mental shift that helped me most: double-entry is not an accounting tradition you tolerate. It is a data model with a built-in invariant, and the invariant is the whole point.
The rule is simple. Every transaction touches at least two accounts, and the total of what goes in equals the total of what goes out. Money is never created or destroyed inside your system, it only moves between accounts. That is conservation of money, the same shape as a conservation law in physics: a quantity that must hold no matter what path the system takes. If you have ever written a CHECK constraint or a test that asserts a sum is zero, you already understand the engine. You are just applying it to money.
Let me translate the vocabulary, because the accounting words scare developers off and they shouldn't.
Accounts
An account is a bucket money can sit in. Cash, accounts receivable, sales revenue, VAT payable. In data terms it is a row with an id, a name, and a type. The type matters because it decides which direction is "increase," which I will get to.
accounts (id, name, type, currency)
-- type ∈ asset | liability | equity | income | expense
Debits and credits
Here is the concept that trips everyone up, so let me kill the confusion directly. Debit and credit are not "minus" and "plus." They are just the two sides of an entry, left and right, centuries-old jargon for "column A and column B."
What they do to a balance depends on the account type:
- For assets and expenses: a debit increases, a credit decreases.
- For liabilities, equity, and income: a credit increases, a debit decreases.
You do not have to memorise this as folklore. Store it as a property of the account type and let the code apply it. The signed effect on a balance is a pure function of (account type, debit or credit). Write that function once, test it, never think about it again.
The journal and the ledger
Two words for two structures you already know.
The journal is the append-only log of what happened, in order. Every economic event becomes a journal entry, and a journal entry is a set of lines (the debits and credits) that must sum to zero. This is your event log. You never edit it.
The ledger is the same data sliced by account: every line that touched a given account, in order, which gives you that account's history and its balance. The ledger is a view over the journal, not a separate source of truth. If you have done event sourcing: the journal is your event stream, the ledger is a projection. Same idea, older name.
The schema I actually use
Here is the shape, stripped to essentials:
-- the event: a balanced set of postings
journal_entries (
id, occurred_at, description,
created_at -- when we recorded it, not when it happened
)
-- the lines: each entry has two or more
postings (
id,
entry_id references journal_entries(id),
account_id references accounts(id),
direction, -- 'debit' | 'credit'
amount, -- always positive, integer minor units
currency
)
Three invariants make this trustworthy, and they are the actual product:
- Every entry balances. Within one
entry_id, the sum of debits equals the sum of credits. Enforce it at write time, inside one transaction: the whole entry commits or none of it does. - Amounts are positive integers in minor units. Store 1050, not 10.50. Never use floats for money. The direction column carries the sign, the amount carries the magnitude, and floating-point rounding never eats a cent.
- The journal is immutable. No
UPDATE, noDELETEon a posted entry. Ever.
That third one deserves its own section, because it is where most homegrown ledgers quietly go wrong.
You do not edit history, you reverse it
The instinct from CRUD apps is: a record is wrong, so fix the record. In a ledger that instinct is poison. The moment you let an entry be edited, every report you ever generated becomes a lie, because the number you showed someone last month no longer matches what the database says today.
Accountants solved this before computers existed. You do not erase a mistake, you write a reversing entry: a new entry that is the mirror image of the wrong one, which cancels it out, and then you post the correct entry. The wrong entry stays in the journal forever, visible, with its reversal next to it. The history is honest that a mistake was made and corrected.
In Vantnod this is a hard rule. Posted entries are read-only at the database level, and a correction is always new journal entries, never a mutation. The payoff is large: any balance, on any date, is reproducible by replaying the journal up to that date. When a customer asks "what did my books say on 31 March," I do not guess, I replay. That single property is the difference between a ledger you can defend in an audit and a spreadsheet with extra steps.
If you cannot reconstruct yesterday's balance from your data alone, you do not have a ledger. You have a cache of the present moment, and caches lie.
A worked example: a sale with VAT
Concrete beats abstract. A Finnish company sells something for 100 euros plus 25.5 percent VAT. The customer owes 125.50. Of that, 100 is revenue you keep and 25.50 is VAT you are holding to hand to the tax authority. That is three accounts moving in one event:
Journal entry: invoice #1042, occurred 2026-05-20
debit Accounts receivable 12550 (asset increases, money owed to us)
credit Sales revenue 10000 (income increases)
credit VAT payable 2550 (liability increases, we owe the state)
Debits: 12550. Credits: 10000 + 2550 = 12550. It balances, so it is a legal entry. Notice the VAT is not jammed into the revenue number, it sits in its own liability account from the first moment. When the customer pays, that is a second entry: debit Cash 12550, credit Accounts receivable 12550. The receivable goes to zero, the cash goes up, revenue is untouched because it was already earned at invoice time.
This is why double-entry is worth the extra table. The single-column model lumps 12550 into one cash bucket, and then you are doing arithmetic forever to answer "how much VAT do I owe" or "how much have customers not paid yet." Here those are just account balances, always correct because the invariant guarantees it.
The tradeoffs, honestly
Double-entry is not free, and I would be lying by omission if I pretended otherwise.
- More writes per event. One sale is three or more rows, not one. Storage is cheap, so this is a non-issue at any sane scale, but it is real.
- Balances are derived, so reads cost more. Summing a million postings on every dashboard load is slow. The fix is a balance snapshot per account per period: a checkpoint you start from so you only replay recent postings. You still can replay from zero to verify, you just don't on the hot path. The snapshot is a cache you rebuild from the journal, not a second source of truth you mutate.
And the failure modes to watch, the ones I have actually hit or nearly hit:
- Forgetting the balance check on write. If nothing enforces debits equalling credits inside the transaction, an unbalanced entry slips in and corrupts every report downstream of it. Make it a constraint, not a convention.
- Mixing currencies in one entry without saying so. A debit in euros and a credit in dollars do not "balance" just because the numbers match. Multi-currency needs an explicit exchange-rate posting, or you keep one entry to one currency. Decide that early.
- Treating the snapshot as truth. The day someone updates a snapshot directly instead of rebuilding it from postings, you are back in single-column hell with extra ceremony.
Why I would never go back
The reason I will keep using double-entry for anything that touches money is not tradition and not compliance, though it buys you both. It is that the data model refuses to lie. The invariant does work a thousand careful code reviews can only hope to. Money cannot vanish, because the books would not balance and the write would fail. History cannot be quietly rewritten, because there is no UPDATE. Any past balance is reproducible, because the journal is the whole truth and the ledger is just a way of looking at it.
You can build a money system without it. Plenty of people have, and plenty of them have spent a weekend reconciling a balance that should never have drifted. Merchants with quills figured out the structure that makes that drift impossible. The least we can do, with transactions and constraints at our disposal, is use it.
Want to discuss this? Write directly.
jami@impactnode.fi