ON THIS PAGE (10 SECTIONS)
- ✓Model money as immutable double-entry rows — a transaction header plus balanced debit and credit entries — never as a single mutable balance column.
- ✓Enforce the zero-sum rule with a database constraint trigger, not application code, so no code path can ever write an unbalanced transaction.
- ✓Use SERIALIZABLE isolation with an explicit retry loop for concurrent postings; ORMs often hide exactly the failure you need to see and retry on.
Any system that tracks money — ad spend budgets, marketplace payouts, internal fund pools — eventually needs a ledger, and the standard ORM workflow of load an object, mutate a balance field, save it back is exactly the pattern that produces silent financial corruption under concurrency. The fix is to model money as immutable double-entry rows, enforce the balance rule inside the database itself, and keep the write path in raw SQL where every lock and every isolation guarantee is visible directly in the code that uses it.
Double-entry bookkeeping's core rule is simple: every transaction is recorded as a set of debits and credits whose amounts sum to zero. A single mutable "balance" column can't enforce that rule on its own, and it's exactly the kind of column two concurrent transactions can both read, both increment based on a now-stale value, and both write back — a classic lost update, and in a ledger, a lost update means money that doesn't reconcile.
Why ORMs Are the Wrong Tool for This One Job
This isn't an anti-ORM argument in general — plenty of read paths and simpler write paths are fine behind one. It's specific to the ledger write path, where three ORM behaviors become genuinely dangerous: implicit autocommit on individual statements when you assumed you were inside one transaction, a connection pool that silently hands a different underlying connection to a later call in the same "unit of work," and lazy-loaded relations that issue an extra query mid-transaction that you never explicitly wrote. Any one of these can quietly break the atomicity you were relying on, and none of them show up as an obvious error.
Schema: Transactions, Entries, and the Zero-Sum Constraint
The schema splits into a transaction header and a set of entry rows, one per debit or credit leg, linked by a foreign key. Amounts are stored as integer cents, never as floating point, to avoid rounding drift.
| 1 | CREATE TABLE accounts ( |
| 2 | id TEXT PRIMARY KEY, |
| 3 | name TEXT NOT NULL, |
| 4 | type TEXT NOT NULL CHECK (type IN ('asset', 'liability', 'equity')) |
| 5 | ); |
| 6 | |
| 7 | CREATE TABLE transactions ( |
| 8 | id TEXT PRIMARY KEY, |
| 9 | idempotency_key TEXT UNIQUE NOT NULL, |
| 10 | description TEXT NOT NULL, |
| 11 | posted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() |
| 12 | ); |
| 13 | |
| 14 | CREATE TABLE entries ( |
| 15 | id BIGSERIAL PRIMARY KEY, |
| 16 | transaction_id TEXT NOT NULL REFERENCES transactions(id), |
| 17 | account_id TEXT NOT NULL REFERENCES accounts(id), |
| 18 | amount_cents BIGINT NOT NULL |
| 19 | ); |
A plain `CHECK` constraint can't enforce the zero-sum rule because it can only see one row at a time, and the balance check needs to sum across every entry for a transaction. A deferred constraint trigger solves this: it runs after all the entries for a transaction have been inserted, right before commit, and rolls back the whole transaction if the sum isn't zero.
| 1 | CREATE FUNCTION check_transaction_balance() RETURNS TRIGGER AS $ |
| 2 | DECLARE |
| 3 | total BIGINT; |
| 4 | BEGIN |
| 5 | SELECT SUM(amount_cents) INTO total |
| 6 | FROM entries |
| 7 | WHERE transaction_id = NEW.transaction_id; |
| 8 | |
| 9 | IF total <> 0 THEN |
| 10 | RAISE EXCEPTION 'Transaction % is unbalanced: sum = %', NEW.transaction_id, total; |
| 11 | END IF; |
| 12 | |
| 13 | RETURN NULL; |
| 14 | END; |
| 15 | $ LANGUAGE plpgsql; |
| 16 | |
| 17 | CREATE CONSTRAINT TRIGGER entries_balance_check |
| 18 | AFTER INSERT ON entries |
| 19 | DEFERRABLE INITIALLY DEFERRED |
| 20 | FOR EACH ROW |
| 21 | EXECUTE FUNCTION check_transaction_balance(); |
Posting a Transaction With SERIALIZABLE Isolation
Two transactions posting against the same account at nearly the same time can each read a consistent snapshot, compute a result that looks valid in isolation, and still produce an outcome that's wrong once both commit — a write skew anomaly that weaker isolation levels don't prevent. `SERIALIZABLE` isolation does prevent it, at the cost of occasionally aborting one of the two transactions with a serialization failure that the caller is expected to retry.
| 1 | async function postTransaction(pool, { id, idempotencyKey, description, entries }) { |
| 2 | const client = await pool.connect(); |
| 3 | const maxRetries = 5; |
| 4 | |
| 5 | for (let attempt = 0; attempt < maxRetries; attempt++) { |
| 6 | try { |
| 7 | await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE'); |
| 8 | |
| 9 | await client.query( |
| 10 | 'INSERT INTO transactions (id, idempotency_key, description) VALUES ($1, $2, $3) ON CONFLICT (idempotency_key) DO NOTHING', |
| 11 | [id, idempotencyKey, description] |
| 12 | ); |
| 13 | |
| 14 | for (const entry of entries) { |
| 15 | await client.query( |
| 16 | 'INSERT INTO entries (transaction_id, account_id, amount_cents) VALUES ($1, $2, $3)', |
| 17 | [id, entry.accountId, entry.amountCents] |
| 18 | ); |
| 19 | } |
| 20 | |
| 21 | await client.query('COMMIT'); |
| 22 | return; |
| 23 | } catch (err) { |
| 24 | await client.query('ROLLBACK'); |
| 25 | if (err.code === '40001' && attempt < maxRetries - 1) { |
| 26 | await sleep(50 * 2 ** attempt + Math.random() * 50); |
| 27 | continue; |
| 28 | } |
| 29 | throw err; |
| 30 | } |
| 31 | } |
| 32 | } |
Preventing Overdrafts Without Trusting the Caller
Application-level balance checks are only as good as every code path that's supposed to call them. A second trigger that recomputes an account's running balance from its entries and rejects the insert if an asset account would go negative closes that gap at the only layer that can't be bypassed by a bug somewhere else in the codebase.
| 1 | CREATE FUNCTION check_asset_balance() RETURNS TRIGGER AS $ |
| 2 | DECLARE |
| 3 | running_balance BIGINT; |
| 4 | BEGIN |
| 5 | SELECT COALESCE(SUM(e.amount_cents), 0) INTO running_balance |
| 6 | FROM entries e |
| 7 | JOIN accounts a ON a.id = e.account_id |
| 8 | WHERE e.account_id = NEW.account_id AND a.type = 'asset'; |
| 9 | |
| 10 | IF running_balance < 0 THEN |
| 11 | RAISE EXCEPTION 'Account % would go negative: %', NEW.account_id, running_balance; |
| 12 | END IF; |
| 13 | |
| 14 | RETURN NULL; |
| 15 | END; |
| 16 | $ LANGUAGE plpgsql; |
Idempotent Postings and Reading Balances Efficiently
The `idempotency_key` column on `transactions`, combined with `ON CONFLICT DO NOTHING`, makes the whole "post this transaction" call safe to retry from the caller's side after a network timeout — if the first attempt actually succeeded, the retry is a harmless no-op instead of a duplicate posting. On the read side, recomputing an account's balance with `SUM()` over every entry works fine at moderate volume; once that sum gets expensive, a running-balance table updated transactionally alongside each entry insert avoids the full recompute without giving up the entry-level audit trail underneath it.
Wrong vs Right: Editing a Mistake vs Reversing It
Once a transaction has posted, the instinct when you spot an error is to fix it in place — update the amount, delete the bad entries, correct the record. That instinct is exactly what a ledger schema is designed to refuse. Editing or deleting posted entries destroys the audit trail that makes a ledger trustworthy in the first place: there's no longer a record of what actually happened, only what you've decided should have happened. The correct fix is to post a second, reversing transaction that cancels out the first one's effect, leaving both the mistake and the correction permanently visible.
| 1 | -- Wrong: mutating a posted entry destroys the audit trail |
| 2 | -- UPDATE entries SET amount_cents = 120000 WHERE transaction_id = 'tx-88329'; |
| 3 | |
| 4 | -- Right: post a reversing transaction referencing the original |
| 5 | INSERT INTO transactions (id, idempotency_key, description, reverses_transaction_id) |
| 6 | VALUES ('tx-88330', 'reverse-tx-88329', 'Correction for tx-88329', 'tx-88329'); |
| 7 | |
| 8 | INSERT INTO entries (transaction_id, account_id, amount_cents) VALUES |
| 9 | ('tx-88330', 'acc_cash_assets', -150000), |
| 10 | ('tx-88330', 'acc_accounts_rec', 150000); |
Multi-Currency Transactions Need Their Own Legs, Not Silent Conversion
An account holds one currency. When a transaction genuinely crosses currencies — paying out in dollars against a rupee-denominated fund pool, for example — model the conversion as an explicit pair of entries against an FX clearing account, with the exchange rate used recorded on the transaction itself. Converting the amount silently inside the posting function and writing a single cross-currency entry throws away the information you'll need later to explain why the books don't match today's spot rate.
Operational Caveats: Growth, Backups, and Migrations
A ledger table is append-only by design, which means it never shrinks and eventually becomes the largest table in the system. Plan for that early: partition `entries` by month or quarter so old data can be archived or queried without scanning everything, and make sure your backup and point-in-time-recovery strategy is tested against a table this shape, not just assumed to work because it works for smaller tables. The other operational rule worth stating plainly: never run a destructive schema migration against `transactions` or `entries` without a rollback plan that itself respects the ledger's own rules — if a migration needs to "undo" something, it should do so with a reversing transaction, not a `DELETE`.
What This Looks Like at Vasiva
Dima's fund pools and budget tracking are built on this same shape — ledger-based tracking rather than a single mutable balance column — because the deterministic budget projections the product surfaces are only trustworthy if the numbers underneath them are calculated from an immutable, balanced set of entries, not guessed at by anything, including a model.
If you're building anything where the numbers have to reconcile — internal ledgers, fund tracking, budget systems — that's Product Engineering and Internal Tools territory for us. Get in touch if you want your schema reviewed before it's carrying real transactions.
References & Prior Art
Accounting Patterns: Accounting Transaction & Double-Entry Ledger
Martin Fowler • martinfowler.com (2015)
PostgreSQL Documentation: Chapter 13. Concurrency Control & Serializable Snapshot Isolation (SSI)
PostgreSQL Core Team • PostgreSQL Global Development Group (2025)
A Critique of ANSI SQL Isolation Levels
Hal Berenson, Phil Bernstein, Jim Gray, Jim Melton, Elizabeth O’Neil, Patrick O’Neil • ACM SIGMOD Record 24(2) (1995)
Barath
Product EngineerBarath works across Vasiva's finance-adjacent engineering — double-entry ledger schemas, reconciliation logic, and explicit transaction boundaries.
