dimesum

Home / Blog / Engineering

Engineering

The append-only ledger that keeps split-expense balances exact

· 6 min read ·

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.

What an append-only ledger makes impossible, against an incremented balances table
Bug classIncremented balances tableAppend-only ledger
Debtor charged, payer never creditedSilently wrong foreverZero-sum check fails, write rejected
A share changes, the total does notBalances drift quietlyThe journal cannot commit unbalanced
"Why is my balance $412?"UnanswerableEvery cent traces to a journal
A hotfix in productionUntracked mutationThe 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.

An expense edit posts an EXPENSE_REVERSAL that negates version one, plus a new EXPENSE for version two, in one transaction; the balances projection is a sum over all the postings. one transaction EXPENSE expense:7c1:v1 4 postings sum = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal every leg negated sum = 0 EXPENSE expense:7c1:v2 5 postings sum = 0 balances projection = SUM(postings) per member, per currency disposable: evenly rebuild-balances clears it and replays every posting
An edit appends: a reversal names the version it negates, and the replacement is posted beside it.

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 business write and its outbox row commit in one transaction; the relay then dispatches only outbox rows whose inserted transaction id is below the pg_snapshot_xmin watermark, in transaction id order. one transaction INSERT expense row INSERT outbox row topic id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 writer in flight event bus ledger consumer pg_snapshot_xmin(pg_current_snapshot()). Rows above it were written by transactions that have committed with none older still running. The row below waits for the next pass. Ordering by id would dispatch 019a-1a8 first, so an amendment could land before the expense it amends. Ordering by inserted_xid cannot: a later event has a later xid.
The outbox row commits with the business write, and the relay dispatches below the watermark in transaction-id order.

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.

The five journal types in the Dimesum ledger and the legs each one posts
Journal typePosted whenLegs
EXPENSECreated, or a new version replaces onePAID, SHARE
EXPENSE_REVERSALEdited or deletedThe previous journal's legs, negated
SETTLEMENTA repayment is assertedSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALThe counterparty disputes itBoth settle legs, negated
WRITE_OFFA creditor gives up a claimWRITE_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.

Run them in this order

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.