Build Lunch Money

KINDAreplaces $10/mosaves $120/yrback to the verdict

0%0 of 20 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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.

estimated effort multi-daythe files for this build are in the project pack

RuntimeTypeScript with Fastify and PostgreSQLImportAn aggregator for bank connections, plus file import as a first-class pathAmountsInteger minor units, never floating pointRulesApplied at import, with retroactive application as an explicit reviewed actionCurrencyAmount, currency and the rate used, stored per transaction

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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

  3. free 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

  4. 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

  5. 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.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://localhost:5432/ledgerYour complete financial history. Encrypt backups and be careful where they live.
AGGREGATOR_CLIENT_IDsecretoptional61f...Phase 2 only. Without it the ledger runs on file import, which is a fine way to start.
AGGREGATOR_SECRETsecretoptionala provider-issued secretGrants access to your bank connections. Rotate it if it is ever exposed.
AGGREGATOR_ENVoptionalsandboxSandbox until the pending to posted path is proven correct against real data.
SYNC_STALE_HOURSrequired72When an account is considered stale and alerted on. Silence is the failure mode.
BASE_CURRENCYrequiredGBPFor cross-currency reporting. Individual transactions keep their own currency.

The build, in order

  1. A ledger that is correct about money

    Accounts, transactions and balances that never lose a penny.

    1. Schema, types and arithmetic. Display conversion only at the edge.

      Files src/money.tssrc/money.test.ts

    2. Quoted fields, several date formats, signed or separate debit and credit columns.

      Files src/import-csv.ts

    3. Ten thousand rows to a known total, in the test suite.

      Files tests/balance.test.ts

    done when · tick each as it passes
    watch 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.
  2. Bank imports that do not double-count

    The same spending never appears twice, however the bank delivers it.

    1. Never on amount and date. Two identical coffees in a day is a normal case.

      Files src/dedupe.ts

    2. The posted row is new and references the pending one. Store the link.

      Files src/reconcile.tssrc/reconcile.test.ts

    3. Re-running must change nothing. Test it by running twice.

      Files src/import.ts

    done when · tick each as it passes
    watch 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.
  3. Transfers, which are not spending

    Moving money between your own accounts does not look like expenditure.

    1. Amount, direction and a date window. Confirmation is required.

      Files src/transfers.ts

    2. In the query layer, so no report can forget to.

      Files src/reports.ts

    3. Reversibility is what makes automatic proposal safe.

      Files src/transfers.ts

    done when · tick each as it passes
    watch 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.
  4. Rules that cannot rewrite the past behind your back

    Categorisation is automatic going forward and deliberate backwards.

    1. Deterministic. Two matching rules must resolve the same way every time.

      Files src/rules.ts

    2. Showing the exact affected set before anything changes.

      Files src/retroactive.ts

    3. A human decision outranks a rule, always.

      Files src/rules.ts

    done when · tick each as it passes
    watch 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.
  5. Budgets and reports that agree with the ledger

    Every number in a report can be traced back to transactions.

    1. Or invalidate caches aggressively and assert agreement in tests.

      Files src/reports.ts

    2. The list behind a number must sum to it. Test that invariant.

      Files tests/report-sums.test.ts

    3. Late transactions dated in a closed month are normal, not exceptional.

      Files src/reports.ts

    done when · tick each as it passes
    watch 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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more personal finance.