lifeos handbook
Concepts

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):

QuestionRule
Postingswhich accounts moved, by how muchmust sum to zero
Lineswhat the money was formust 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 for SELECT … FOR UPDATE semantics (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).

On this page