My accountant sees my books once a year. This sees them every morning at 03:30.
16,094 transactions, 97 accounts, 2,284 payees in one Postgres schema: my spending obeys Benford's Law, 20 payees hold 84% of the money, and 37% of the lines are worth 0.1% of the francs.
My spending obeys a law used to catch accounting fraud
Benford's Law says that in naturally occurring numbers, the leading digit is a 1 about 30% of the time, and each digit after that is rarer. Auditors use the deviation from it to flag fabricated books. I never aimed at this. Eleven years of groceries, taxes and Ubers land on the curve anyway.
13,407 outflows ≥ CHF 1 · largest gap: digit 7, 1.2 points · verified 2026-08-26
Twenty names out of 2,284 hold 84% of the money
Pareto is a cliché until you run it on your own life. Twenty payees — under one percent of the list — absorb five sixths of everything that ever left. And the other end is stranger: 58% of every business I have ever paid, I paid exactly once.
2,184 payees with outflows · verified 2026-08-26
Four of every ten lines in my books are worth one thousandth of the money
5,055 transactions are under CHF 20. That is 37% of everything my system has to import, deduplicate, convert, categorise and store. Together they account for 0.10% of the francs. This is the strongest argument for automation I have ever run: the work does not scale with the value, so the work has to cost nothing.
n = 5,055 outflows under CHF 20 · verified 2026-08-26
One supermarket has been in my life longer than any job, city or bike
Coop shows up in twelve different calendar years, from 31 December 2015 to last month: 1,158 separate visits. It is the single most persistent relationship in the dataset. Beekeeper, the company I founded, is second at 216 lines. Uber and Migros follow at eleven years each. A ledger is a biography written by someone who was not paying attention.
Transactions per payee, 2015–2026 · verified 2026-08-26
The system found 88 subscriptions I never wrote down
Same payee, identical amount, month after month: that pattern is a standing order whether or not you remember signing it. There are 88 of them across 38 payees, and six have been running for more than two years. The longest has charged the same figure for 38 consecutive months. Nobody has a list like this in their head. That is the point of writing it down in a machine.
GROUP BY payee, amount HAVING months ≥ 6 · verified 2026-08-26
A transaction crosses five systems before it rests
Nothing here is typed twice. The bank exports, the importer normalises three currencies into francs, the categories come from sixteen years of accounting in Xero and, for anything new, my own rules, the lake stores it, and a nightly job proves the whole thing still exists tomorrow. A failure pushes to my phone before I wake up.
Twice, the ledger paid for itself in a single line
Everything above is structure. This part is money that came back. Neither of these was found by staring at a bank statement — both surfaced because every transaction has to land in a schema where a duplicate looks wrong and a missing pattern leaves a hole.
CHF 6,200 recovered from two mistakes that nobody made on purpose and nobody would have caught by feel. That is the honest return on eleven years of typing things into the right columns.
Both caught by the reconciliation pass, not by memory
The schema, so you can steal it
There is no product here to sell you. Eleven years fit in six tables and one foreign key discipline: every transaction points at an account, a category and a payee, and nothing else is allowed to be a string typed by hand.
| table | rows | what it holds |
|---|---|---|
transactions | 16,094 | date · account · category · payee · amount_chf · fx_rate · source |
payees | 2,284 | the long tail nobody plans for |
categories | 101 | the taxonomy that makes reports possible |
accounts | 97 | banks · cards · brokers · entities |
valuations · properties | 81 | what a ledger cannot see by itself |
fx_rates · budgets | 1,891 | three currencies, one truth |
-- the query behind finding #3: the work does not scale with the value select count(*) filter (where abs(amount_chf) < 20) lines_under_20, round(100.0 * sum(abs(amount_chf)) filter (where abs(amount_chf) < 20) / sum(abs(amount_chf)), 2) pct_of_francs from finance.transactions where amount_chf < 0; -- → 5,055 lines · 0.10% of the money