The ledger
Postings, lines, and why both have to balance.
Two answers to two different questions
A single amount cannot answer both which account moved and what was this for. Buying 30,000 of groceries with 20,000 from M-Pesa and 10,000 cash is two postings and one line. Paying a 50,000 bill that is 40,000 rent and 10,000 water is one posting and two lines.
So a transaction carries both (ADR-0021):
| Question | Rule | |
|---|---|---|
| Postings | which accounts moved, by how much | must sum to zero |
| Lines | what the money was for | must sum to the transaction |
The invariant checked on every write is sum(postings) == sum(lines).
One writer
LedgerService.post() is the only code path that changes an account balance. Every feature — bills,
envelopes, projects, reconciliation, the allocation wizard — goes through it. A balance writable
from two places is a balance that will eventually disagree with its own history, and the disagreement
will be discovered months later by someone trying to work out where 4,000 shillings went.
Cached balances, proved rather than trusted
account.cached_balance exists because a balance is read on every screen. It is a cache, and caches
drift, so reconciliation recomputes it from the postings and posts the difference to an
Unaccounted category rather than silently correcting it (ADR-0011,
ADR-0020).
That is why RECONCILIATION transactions stay in the statement's category totals. A rising
Unaccounted total is the visible symptom of something being wrong; filtering it out would hide the
only evidence.
Anything read less often is not cached at all. A project's profit is derived from its postings on every read, precisely so it cannot drift.
Signs, and why direction is not a second opinion
A category has a direction — income or expense — and CategoryDirection.permits() enforces the sign
at write time. So a line's sign and its category's direction can never disagree: the write would
have been refused.
Money, time and place
- Amounts are
NUMERIC, never floating point. Postgres was chosen over MySQL largely for this and forSELECT … FOR UPDATEsemantics (ADR-0003). - Timestamps are stored UTC, displayed EAT. The month boundary on the statement is the EAT calendar month (ADR-0016).
- Corrections are edits in place, with a change log. One user who mistyped an amount wants it fixed, not reversed (ADR-0017).