Home / Blog / Engineering
EngineeringThe append-only ledger that keeps split-expense balances exact
A balance should be a projection you can throw away and rebuild. This is the machinery that makes that safe: zero-sum journals, edits that post corrections, and an outbox that dispatches in commit order.
A balance you increment is a balance that drifts. Dimesum never increments one. Every expense posts a double-entry journal whose postings sum to exactly zero, and the number on your group screen is a projection over those postings that we can delete and rebuild with one command.
You can test that claim. When the only way money moves is an append-only journal, a wrong balance stops being a mystery and becomes a query we can run.
Balances are a projection, not a number you increment
Dimesum's ledger owns three tables: journals, postings, and a balances projection keyed by group, member and currency. Postings carry the truth. A posting is positive when a member put money into the group and negative when they consumed value, so a balance is a plain SUM(amount_minor). The projection exists for speed, not authority: it is written inside the journal's own transaction, and postings being complete keeps it disposable.
Two layers assert the same zero, and neither is enough alone
In Go, buildPostings refuses to open a transaction unless the posting set sums to zero. In Postgres, a deferred constraint trigger re-checks SUM(amount_minor) = 0 per journal at commit, because postings insert one row at a time and a per-row check would reject every journal's first leg. The assert catches a bug in the calculator. The trigger catches a writer that never called it.
A third layer is a grant. UPDATE, DELETE and TRUNCATE are revoked from the ledger_app role on both tables, so buggy code cannot rewrite history even when it tries.
| Bug class | Incremented balances table | Append-only ledger |
|---|---|---|
| Debtor charged, payer never credited | Silently wrong forever | Zero-sum check fails, write rejected |
| A share changes, the total does not | Balances drift quietly | The journal cannot commit unbalanced |
| "Why is my balance $412?" | Unanswerable | Every cent traces to a journal |
| A hotfix in production | Untracked mutation | The only path is a new, audited journal |
An edit posts a reversal, never an UPDATE
Editing an expense writes nothing over the old version. The ledger consumes one expense.amended event and posts two journals in a single transaction: an EXPENSE_REVERSAL whose postings are the exact leg-for-leg negation of the version being replaced, then a fresh EXPENSE for the new one. A delete stops after the reversal.
One transaction matters as much as the two journals. If the reversal committed alone, a group would briefly owe nothing for an expense it still owes.
Both idempotency keys are derived from the version rather than minted: expense:<id>:v<n> for the version, and the previous version's key plus :reversal for the negation. journals.idempotency_key is UNIQUE, so a redelivered event finds its journals already there and does nothing. Derivation is what makes at-least-once delivery safe: a retry computes the key the first attempt used.
A commit-order watermark keeps an amendment behind its expense
Every event Dimesum publishes is written to an outbox table in the same transaction as the business write. If the expense commits, the announcement exists. If it rolls back, so does the announcement. The ledger never hears about an expense that does not exist.
Dispatch order is the harder half. Ids are UUIDv7 and sort by time, but they encode when the id was minted, not when its transaction committed. A relay reading in id order can skip past a transaction that took an earlier id and committed later, so an amendment overtakes the expense it amends.
The fix is one column and one predicate. Each outbox row carries inserted_xid xid8 DEFAULT pg_current_xact_id(), and the relay reads only rows WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), ordered by that xid (see the PostgreSQL transaction id functions). A causally later event always carries a later xid, because it had to read the earlier row to exist.
The amendment consumer does not take that ordering on trust. An amendment whose predecessor has no journal is redelivered while it is young, and parked for a human once past a two-minute grace.
Money is int64 minor units, and the exponent is not always two
Every amount is an int64 count of minor units plus an ISO 4217 code, so 1,234.56 rupees is {Minor: 123456, Currency: "INR"}. Integers are exact by construction, not by discipline: no representable value is half a cent, so no operation can quietly produce one. The Python sidecar reads its own AST and fails a test if the word float appears in its amount module.
Assuming the minor unit is one hundredth is the trap underneath. JPY has none at all and KWD has three decimals, so a rate quoted between major units and applied to minor units is wrong by a power of ten. Take 2,000 yen at 0.58: the naive product is 1160, which reads as 11.60 rupees, when the answer is 1,160.
WRITE_OFF is a fifth journal type because its postings match a settlement's
A write-off posts the same two legs a settlement does: the debtor rises, the creditor falls, by the same amount. Folding it into SETTLEMENT was rejected for exactly the reason the shapes match. "Asha paid you 500 rupees" and "you forgave Asha 500 rupees" are different facts, and a feed that conflated them would say somebody paid when nobody did.
So WRITE_OFF joined the journal-type CHECK on 2026-08-21, with legs of its own. Naming them was half the point, because a write-off reusing SETTLE_PAY would make every "how much has actually been paid" query silently wrong.
| Journal type | Posted when | Legs |
|---|---|---|
EXPENSE | Created, or a new version replaces one | PAID, SHARE |
EXPENSE_REVERSAL | Edited or deleted | The previous journal's legs, negated |
SETTLEMENT | A repayment is asserted | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | The counterparty disputes it | Both settle legs, negated |
WRITE_OFF | A creditor gives up a claim | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
Two subcommands turn the invariants into a cron job
evenly verify-ledger re-proves the invariants over the whole schema and exits non-zero on any hit. Its report has four fields, and every one is expected to be empty: journals that do not sum to zero, groups that do not sum to zero, projection rows diverging from a recomputed SUM(postings), and parked events waiting on a human. All scans share one repeatable-read snapshot, so a journal landing mid-verify cannot fabricate a mismatch.
Each scan groups by currency as well as by id, and the failure that avoids is a false negative. A posting of 500 rupees and one of minus 500 yen sum to zero when a query ignores the currency column, so a doubly corrupt ledger would read clean.
evenly rebuild-balances [group-id] is the repair and the drill. It clears the projection and recomputes every row from postings, stamping each with the last journal that moved that member, as the live write does. Row-for-row equality is the property: when a rebuild and the live projection disagree, the ledger is right.
Verify, then rebuild. The report names the stored balance and the recomputed one for every drifting row, and a rebuild overwrites the stored value, so rebuilding first destroys the evidence.
Make the balance derivable and drift becomes a query
An append-only ledger earns its second journal only if you can prove it, so build the verifier and the rebuild before the feature that needs them. A rebuild nobody has run is a hope, not an escape hatch. Pick your riskiest money projection this week, write the query that recomputes it from source, and page yourself when the two disagree.
Common questions
What is an append-only ledger in a money share tracking app?
An append-only ledger records every money event as a journal of postings that sum to zero, and never updates or deletes one. In Dimesum, an expense, an edit, a settlement, a dispute and a write-off each append a new journal. Balances are then derived by summing postings, so every cent traces back to the event that moved it.
How does an append-only ledger handle an edited expense?
An edit posts two journals in one transaction: an EXPENSE_REVERSAL that negates the original leg for leg, then a new EXPENSE carrying the replacement version. Nothing is rewritten, and a delete posts only the reversal. Both journals take idempotency keys derived from the expense version, so a redelivered event finds them already there and changes nothing.
Why store money as integers instead of floats?
Integer minor units are exact by construction, while binary floating point cannot represent 0.1 exactly and drifts across a group's history. Dimesum stores every amount as an int64 count of minor units plus an ISO 4217 code. The exponent comes from the currency: JPY has no minor unit at all, so assuming one hundredth is a 100x error.
How do you know split-expense balances have not drifted?
Run evenly verify-ledger, which re-proves the invariants over the whole schema and exits non-zero on any hit. It reports journals that do not sum to zero, groups that do not sum to zero, projection rows disagreeing with a recomputed SUM(postings), and parked events. Cron it nightly, and repair a bad projection with evenly rebuild-balances.
Why does a write-off need its own journal type?
A write-off posts legs identical to a settlement's, which is exactly why the journal type had to differ. Only that word separates 'Asha paid you 500 rupees' from 'you forgave Asha 500 rupees', and a feed conflating them would say somebody paid when nobody did. Its legs are named WRITE_OFF_FORGIVEN and WRITE_OFF_GRANTED so payment queries stay correct.
Popular posts
- Why editing a shared expense must restate the split5 min read
- Six money bugs in multi-currency expense splitting6 min read
- Settle up: how to clear group expenses in fewer transfers6 min read
- How to split an itemized bill when one dish wasn't shared6 min read
- The rent split guide: divide rent with roommates fairly6 min read