Skip to content

The ledger

Purser’s core promise is that the books cannot be silently wrong. Three mechanisms deliver it: a double-entry core behind a flat API, immutable posted rows with linked corrections, and an append-only raw feed the ledger can always be re-derived from.

Clients — human or agent — submit the flat form a person thinks in:

{ "account": "citicard", "date": "2026-07-01", "amount": "-14.00",
"payee": "Chipotle", "category": "expenses.dining" }

The server expands every write into a balanced posting pair (citicard -14.00, expenses.dining +14.00). Postings per transaction sum to zero per currency — enforced in the application layer with a database trigger as backstop. Reads return the flat view by default; full postings are available on request.

Categories are themselves accounts — hierarchical virtual accounts with ltree paths like expenses.dining.coffee — which is what makes categorization a first-class ledger operation instead of a tag. A transfer is one transaction with two real-account postings; in the flat view its “category” appears as the other account in Quicken-style brackets, like [assets.savings]. A split loan payment keeps both bank legs exact and adds one interest/fee posting for the difference, so it still sums to zero — see Confirm a split loan payment.

One bank amount spread over several categories is a split transaction: one leg on the real account plus one leg per category line, still summing to zero. It is never a transfer and never has a second real leg; money a split sends to another account is a transfer of its own. The flat view shows a split with no single category_path and a split_lines count, and the transaction’s postings carry the lines. Split rows come from QIF imports today and are read-only.

Every transaction moves through expected -> pending -> posted -> void:

  • expected — future entries: manual bills, materialized schedule instances, and the rows a QIF or CSV file marks uncleared. Estimates, consumed by the matcher and forecaster.
  • pending — rows the bank feed reports as not yet cleared, and manual or agent entries the bank has not posted yet. Freely editable.
  • posted — immutable. Any change, including recategorization, executes as an atomic reversal + replacement pair linked via reverses_id. The flat view collapses the chain; the ledger keeps the forensic history. The database allows exactly one mutation of a posted row: voiding it with a lineage link in the same statement.
  • void — dead rows kept for lineage: superseded expecteds, merged transfer halves, vanished pendings, reversal legs.

Nothing is ever silently edited or deleted; every correction is itself a visible ledger event with an audit trail.

Every payload a bank connection returns is archived verbatim, append-only, before anything touches the ledger. Feed syncs and manual imports are content-hash idempotent — re-running any import produces zero new rows. A row’s identity is what the bank sent (account, date, amount, description) plus, for identical rows in one payload, which occurrence it is — so two same-day coffees at the same price stay two rows, and re-fetching or re-importing them adds none. Every ledger row traces to the source bytes that produced it, and the ledger is a projection that can be re-derived from the archive.

These rules are enforced structurally — app-layer checks with database triggers as backstop, and property-based tests over the invariants (zero-sum postings, immutability, idempotent import). The authoritative specification is DESIGN.md in the repository — these pages explain the behavior; that document defines it.