Build Lunch Money
KINDAreplaces $10/mosaves $120/yrback to the verdict
A personal finance ledger where the interesting work is reconciliation: bank feeds deliver the same spending several times in different forms, transactions change after they arrive, and rules that recategorise the past must never silently rewrite a month you already reviewed.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the server and the import workers.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - installfree locally
Why Money data wants real transactions, constraints and exact integer arithmetic.
Get it Docker or your package manager. open ↗
Verify
psql connects and creates a table - accountfree in sandbox; per-connection in production
Why Phase 2 depends on how a real provider models pending and posted transactions, and no fixture you invent will match it.
Get it Sign up for a provider covering your region, read its transaction states documentation before writing code, and use sandbox institutions first. open ↗
Verify
A sandbox account syncs and a pending transaction is observed becoming posted - have readyfree
Why Deduplication, transfer matching and date handling only break on real data, and CSV exports from different banks disagree about nearly everything.
Get it Download CSV exports from every account you hold, including one from a bank whose format is awkward.
Verify
A year of real statements imports and balances match the bank's own figures - decidefree
Why If the bank feed is authoritative you cannot edit history, and if your ledger is authoritative you must reconcile against the bank. Deciding late means rebuilding the reconciliation.
Get it Decide whether an imported transaction may be edited, and what happens when the bank later changes it. Write it down before phase 2.
Verify
A written note states the rule and what happens on a conflict
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- accounts(id, name, kind, currency, source, external_id, last_synced_at) · source is bank or manual - transactions(id, account_id, external_id, posted_on, amount_minor, currency, payee_raw, payee, category_id, status, review_state, created_at) - status · pending or posted. review_state · unreviewed, reviewed or excluded - links(transaction_id, replaces_transaction_id, kind) · kind is pending_to_posted or transfer_match - rules(id, priority, match_json, set_category_id, set_payee, created_at) - rule_applications(rule_id, transaction_id, at, retroactive) · so a retroactive change is attributable and reversible - budgets(month, category_id, amount_minor) - A pending transaction and its posted counterpart are two rows joined by a link, because that is how the data actually arrives.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://localhost:5432/ledger | Your complete financial history. Encrypt backups and be careful where they live. |
AGGREGATOR_CLIENT_IDsecret | optional | 61f... | Phase 2 only. Without it the ledger runs on file import, which is a fine way to start. |
AGGREGATOR_SECRETsecret | optional | a provider-issued secret | Grants access to your bank connections. Rotate it if it is ever exposed. |
AGGREGATOR_ENV | optional | sandbox | Sandbox until the pending to posted path is proven correct against real data. |
SYNC_STALE_HOURS | required | 72 | When an account is considered stale and alerted on. Silence is the failure mode. |
BASE_CURRENCY | required | GBP | For cross-currency reporting. Individual transactions keep their own currency. |
The build, in order
A ledger that is correct about money
Accounts, transactions and balances that never lose a penny.
Schema, types and arithmetic. Display conversion only at the edge.
Files
src/money.tssrc/money.test.tsQuoted fields, several date formats, signed or separate debit and credit columns.
Files
src/import-csv.tsTen thousand rows to a known total, in the test suite.
Files
tests/balance.test.ts
done when · tick each as it passeswatch out- Storing money as a floating point number produces balances that are off by a penny after a few thousand transactions, and the error is unreproducible and impossible to explain to yourself six months later. Integer minor units everywhere, with the conversion happening only at display.
Bank imports that do not double-count
The same spending never appears twice, however the bank delivers it.
Never on amount and date. Two identical coffees in a day is a normal case.
Files
src/dedupe.tsThe posted row is new and references the pending one. Store the link.
Files
src/reconcile.tssrc/reconcile.test.tsRe-running must change nothing. Test it by running twice.
Files
src/import.ts
done when · tick each as it passeswatch out- A pending transaction that posts does not change state: the provider removes it and delivers a new transaction with a new identifier, carrying a reference back to the pending one. Treating them as unrelated double-counts the spending, and deduplicating on amount and date instead breaks the moment somebody buys the same coffee twice in a day.
Transfers, which are not spending
Moving money between your own accounts does not look like expenditure.
Amount, direction and a date window. Confirmation is required.
Files
src/transfers.tsIn the query layer, so no report can forget to.
Files
src/reports.tsReversibility is what makes automatic proposal safe.
Files
src/transfers.ts
done when · tick each as it passeswatch out- Automatically matching transfers on amount and a date window will eventually pair two unrelated transactions of the same value, and once a real expense has been silently reclassified as a transfer it vanishes from every report without any indication that it happened. Propose, never auto-apply, and keep the match reversible.
Rules that cannot rewrite the past behind your back
Categorisation is automatic going forward and deliberate backwards.
Deterministic. Two matching rules must resolve the same way every time.
Files
src/rules.tsShowing the exact affected set before anything changes.
Files
src/retroactive.tsA human decision outranks a rule, always.
Files
src/rules.ts
done when · tick each as it passeswatch out- A rule edited today that silently recategorises last year means every report you have already looked at now says something different, and there is no record that it changed. Preview, confirm, record the application, and make it undoable, so the ledger's history is stable unless you chose otherwise.
Budgets and reports that agree with the ledger
Every number in a report can be traced back to transactions.
Or invalidate caches aggressively and assert agreement in tests.
Files
src/reports.tsThe list behind a number must sum to it. Test that invariant.
Files
tests/report-sums.test.tsLate transactions dated in a closed month are normal, not exceptional.
Files
src/reports.ts
done when · tick each as it passeswatch out- A report computed from a cached aggregate drifts from the ledger the moment a transaction is recategorised or a late one arrives dated last month, and the discrepancy is discovered when the numbers stop adding up. Compute from transactions, or invalidate aggressively and reconcile in the test suite.
Broken connections, export and deletionproduct builder
You notice when a bank feed dies, and your data can always leave.
Missing data raises no error on its own. This is the only way you find out.
Files
src/health.tsPer account, with a threshold. Silence is the failure mode.
Files
src/alerts.tsIncluding revoking the connection at the provider rather than only locally.
Files
src/export.tssrc/deletion.ts
done when · tick each as it passeswatch out- A silently expired bank connection means the ledger looks fine while quietly missing three weeks of spending, and every budget and report is wrong without any error anywhere. Show the last successful sync per account prominently and alert on staleness, since absence of data produces no error by itself.
That is the whole plan for Lunch Money. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Investment tracking with cost basis and tax lots.
- Broad institution coverage.
- Any tax filing or advice.
- Shared household finances with per-person visibility.
- bank sync, multi-currency support, API, community, and hosted convenience
- direct bank feeds
- tax filing guarantees
- payroll and payments
- accountant ecosystem and support
- Recurring transaction detection, which makes forecasting possible
- A cash flow forecast from known recurring items, which is the question budgets do not answer
- Receipt attachment, once the ledger itself is trustworthy
Need the files? The project pack on the verdict page hands your agent the whole brief · more personal finance.