Vibecode Lunch Money
track this build5 phases, 15 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Lunch Money, import transactions and maintain a transparent personal ledger with rules and reports. The hard boundary is bank sync, multi-currency support, api, community, and hosted convenience, plus financial integrations, compliance, and trust.
You are building a lean indie version of Lunch Money. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Lunch Money · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | money data wants transactions and constraints, not a document store | | Import | An aggregator for bank connections, plus file import as a first-class path | connections break constantly and a ledger that only works when they work is not a ledger | | Amounts | Integer minor units, never floating point | floating point money produces off-by-a-penny errors that take days to find | | Rules | Applied at import, with retroactive application as an explicit reviewed action | a rule that silently rewrites reviewed history destroys trust in the ledger | | Currency | Amount, currency and the rate used, stored per transaction | converting on display loses the rate that was actually in effect | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the import workers. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Money data wants real transactions, constraints and exact integer arithmetic. - Get it: Docker or your package manager. - Verify: psql connects and creates a table - [ ] **A bank aggregator account in sandbox** (optional) · 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. - Verify: A sandbox account syncs and a pending transaction is observed becoming posted - [ ] **Twelve months of your own statements** · free - 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 - [ ] **Where the ledger's source of truth sits** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Lunch Money, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Lunch Money The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal ledger that imports from your banks and never quietly changes your history to replace Lunch Money. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · money data wants transactions and constraints, not a document store - Import: An aggregator for bank connections, plus file import as a first-class path · connections break constantly and a ledger that only works when they work is not a ledger - Amounts: Integer minor units, never floating point · floating point money produces off-by-a-penny errors that take days to find - Rules: Applied at import, with retroactive application as an explicit reviewed action · a rule that silently rewrites reviewed history destroys trust in the ledger - Currency: Amount, currency and the rate used, stored per transaction · converting on display loses the rate that was actually in effect ### Data model (create this before Phase 1) - 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. ### Phase 1 · A ledger that is correct about money Build: Accounts and transactions with integer minor units, a currency per transaction, and computed balances. Manual entry and CSV import. Enforce that a balance is derived rather than stored, or reconciled if stored. Trap: 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. Done when: Ten thousand transactions sum to an exact expected balance, asserted in a test. No floating point type appears in any money path, verified by inspecting the schema and types. CSV import handles quoted fields, varying date formats and a signed amount column. A multi-currency account keeps its own currency rather than converting on import. Do not build yet: No bank connections, rules or budgets. ### Phase 2 · Bank imports that do not double-count Build: Connect an aggregator, import transactions, and handle the pending to posted transition correctly. Deduplicate on the provider's identifiers rather than on amount and date. Handle a transaction disappearing from the feed. Trap: 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. Done when: A pending transaction that posts results in one transaction in the ledger, joined by a link, tested against real provider data or a faithful fixture. Two genuinely identical purchases on one day both survive. A removed transaction is marked rather than deleted. Re-running an import changes nothing. Do not build yet: No rules or budgets yet. ### Phase 3 · Transfers, which are not spending Build: Detect matching pairs across accounts by amount, direction and a date window, propose them as transfers, and require confirmation. Excluded transfers must not appear in spending totals or budgets. Trap: 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. Done when: A matching pair across two accounts is proposed and, once confirmed, excluded from spending. An unconfirmed proposal has no effect on any total. Unmatching restores both transactions to spending. A same-amount coincidence between unrelated accounts is proposed rather than applied, tested with a crafted pair. Do not build yet: No rules or budgets yet. ### Phase 4 · Rules that cannot rewrite the past behind your back Build: Rules matching on payee, amount and account, applied at import in priority order. Retroactive application is a separate action showing exactly which transactions would change, requiring confirmation, and reversible. Trap: 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. Done when: A new rule applies to incoming transactions automatically. Retroactive application previews the affected set and does nothing until confirmed, tested with a rule matching hundreds of rows. The application is recorded and reversible. A manually set category is never overwritten by a rule. Do not build yet: No budgets or reports yet. ### Phase 5 · Budgets and reports that agree with the ledger Build: Monthly budgets per category, spending reports, and a rule that every displayed figure can be opened to the transactions behind it. Handle mid-month category changes and transactions dated in a closed month. Trap: 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. Done when: Every report figure opens to its transactions and the list sums to the figure, asserted in a test. A transaction dated in a past month appears in that month's report after import. Recategorising updates every affected report immediately. Budgets handle a category with no spending without dividing by zero. Do not build yet: Export and connection health come last. ### Phase 6 · Broken connections, export and deletion Build: Surface connection health and the age of the last successful sync per account. Alert when an account has not synced. Full export and complete deletion including the aggregator connection. Trap: 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. Done when: Each account shows its last successful sync and stale accounts are surfaced. An expired connection produces an alert rather than silence, tested by revoking one. Export contains accounts, transactions, rules and links and re-imports identically. Deleting an account removes its transactions and revokes the connection at the provider. Do not build yet: Nothing after this. ### Out of scope (and why) - Investment tracking with cost basis and tax lots, which is a substantially harder domain than cash accounting. - Broad institution coverage, which is what an aggregator's fees actually pay for and cannot be replicated. - Any tax filing or advice, which is jurisdiction-specific and carries liability. - Shared household finances with per-person visibility rules, which is a permissions model on top of everything here. ### README must contain - That money is stored as integer minor units and never as floating point - That a pending transaction and its posted counterpart are separate rows joined by a link - That transfer matches are proposed and confirmed, never applied automatically - That retroactive rule application previews, requires confirmation and is reversible - That a stale bank connection is alerted on, because missing data produces no error ===== AGENTS.md ===== # Agent instructions · Lunch Money indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, An aggregator for bank connections, plus file import as a first-class path, Integer minor units, never floating point, Applied at import, with retroactive application as an explicit reviewed action, Amount, currency and the rate used, stored per transaction. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Lunch Money 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A ledger that is correct about money Accounts, transactions and balances that never lose a penny. ### Steps 1. Integer minor units end to end Schema, types and arithmetic. Display conversion only at the edge. Files: `src/money.ts`, `src/money.test.ts` 2. CSV import that handles real files Quoted fields, several date formats, signed or separate debit and credit columns. Files: `src/import-csv.ts` 3. Derive balances and assert exactness Ten thousand rows to a known total, in the test suite. Files: `tests/balance.test.ts` ### Done when - [ ] Ten thousand transactions sum exactly - [ ] No floating point appears in a money path - [ ] CSV import handles quoting and date variation - [ ] Multi-currency accounts keep their own currency ### 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. ## Phase 2 · Bank imports that do not double-count The same spending never appears twice, however the bank delivers it. ### Steps 1. Deduplicate on provider identifiers Never on amount and date. Two identical coffees in a day is a normal case. Files: `src/dedupe.ts` 2. Link pending to posted explicitly The posted row is new and references the pending one. Store the link. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 3. Make import idempotent Re-running must change nothing. Test it by running twice. Files: `src/import.ts` ### Done when - [ ] A pending transaction posting yields one ledger entry - [ ] Two identical same-day purchases both survive - [ ] A removed transaction is marked, not deleted - [ ] Re-running an import changes nothing ### 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. ## Phase 3 · Transfers, which are not spending Moving money between your own accounts does not look like expenditure. ### Steps 1. Propose matches, never apply them Amount, direction and a date window. Confirmation is required. Files: `src/transfers.ts` 2. Exclude confirmed transfers from totals In the query layer, so no report can forget to. Files: `src/reports.ts` 3. Make unmatching restore both sides Reversibility is what makes automatic proposal safe. Files: `src/transfers.ts` ### Done when - [ ] A matching pair is proposed and excludes once confirmed - [ ] An unconfirmed proposal affects no total - [ ] Unmatching restores both transactions - [ ] A coincidental match is proposed, not applied ### 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. ## Phase 4 · Rules that cannot rewrite the past behind your back Categorisation is automatic going forward and deliberate backwards. ### Steps 1. Apply rules at import in priority order Deterministic. Two matching rules must resolve the same way every time. Files: `src/rules.ts` 2. Preview, confirm and record retroactive runs Showing the exact affected set before anything changes. Files: `src/retroactive.ts` 3. Never overwrite a manual category A human decision outranks a rule, always. Files: `src/rules.ts` ### Done when - [ ] Rules apply automatically to new transactions - [ ] A retroactive run previews and requires confirmation - [ ] Applications are recorded and reversible - [ ] A manual category is never overwritten ### 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. ## Phase 5 · Budgets and reports that agree with the ledger Every number in a report can be traced back to transactions. ### Steps 1. Compute reports from transactions Or invalidate caches aggressively and assert agreement in tests. Files: `src/reports.ts` 2. Make every figure openable The list behind a number must sum to it. Test that invariant. Files: `tests/report-sums.test.ts` 3. Handle backdated arrivals Late transactions dated in a closed month are normal, not exceptional. Files: `src/reports.ts` ### Done when - [ ] Every figure opens to transactions that sum to it - [ ] A backdated transaction lands in the right month - [ ] Recategorising updates reports immediately - [ ] An empty category does not divide by zero ### 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. ## Not in this build - Investment tracking with cost basis and tax lots. - Broad institution coverage. - Any tax filing or advice. - Shared household finances with per-person visibility. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your complete financial history. Encrypt backups and be careful where they live. DATABASE_URL=postgres://localhost:5432/ledger # Optional · secret. Phase 2 only. Without it the ledger runs on file import, which is a fine way to start. AGGREGATOR_CLIENT_ID=61f... # Optional · secret. Grants access to your bank connections. Rotate it if it is ever exposed. AGGREGATOR_SECRET=a provider-issued secret # Optional. Sandbox until the pending to posted path is proven correct against real data. AGGREGATOR_ENV=sandbox # Required. When an account is considered stale and alerted on. Silence is the failure mode. SYNC_STALE_HOURS=72 # Required. For cross-currency reporting. Individual transactions keep their own currency. BASE_CURRENCY=GBP
You are building a lean indie version of Lunch Money. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Lunch Money · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | money data wants transactions and constraints, not a document store | | Import | An aggregator for bank connections, plus file import as a first-class path | connections break constantly and a ledger that only works when they work is not a ledger | | Amounts | Integer minor units, never floating point | floating point money produces off-by-a-penny errors that take days to find | | Rules | Applied at import, with retroactive application as an explicit reviewed action | a rule that silently rewrites reviewed history destroys trust in the ledger | | Currency | Amount, currency and the rate used, stored per transaction | converting on display loses the rate that was actually in effect | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the import workers. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Money data wants real transactions, constraints and exact integer arithmetic. - Get it: Docker or your package manager. - Verify: psql connects and creates a table - [ ] **A bank aggregator account in sandbox** (optional) · 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. - Verify: A sandbox account syncs and a pending transaction is observed becoming posted - [ ] **Twelve months of your own statements** · free - 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 - [ ] **Where the ledger's source of truth sits** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Lunch Money, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Lunch Money The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal ledger that imports from your banks and never quietly changes your history to replace Lunch Money. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · money data wants transactions and constraints, not a document store - Import: An aggregator for bank connections, plus file import as a first-class path · connections break constantly and a ledger that only works when they work is not a ledger - Amounts: Integer minor units, never floating point · floating point money produces off-by-a-penny errors that take days to find - Rules: Applied at import, with retroactive application as an explicit reviewed action · a rule that silently rewrites reviewed history destroys trust in the ledger - Currency: Amount, currency and the rate used, stored per transaction · converting on display loses the rate that was actually in effect ### Data model (create this before Phase 1) - 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. ### Phase 1 · A ledger that is correct about money Build: Accounts and transactions with integer minor units, a currency per transaction, and computed balances. Manual entry and CSV import. Enforce that a balance is derived rather than stored, or reconciled if stored. Trap: 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. Done when: Ten thousand transactions sum to an exact expected balance, asserted in a test. No floating point type appears in any money path, verified by inspecting the schema and types. CSV import handles quoted fields, varying date formats and a signed amount column. A multi-currency account keeps its own currency rather than converting on import. Do not build yet: No bank connections, rules or budgets. ### Phase 2 · Bank imports that do not double-count Build: Connect an aggregator, import transactions, and handle the pending to posted transition correctly. Deduplicate on the provider's identifiers rather than on amount and date. Handle a transaction disappearing from the feed. Trap: 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. Done when: A pending transaction that posts results in one transaction in the ledger, joined by a link, tested against real provider data or a faithful fixture. Two genuinely identical purchases on one day both survive. A removed transaction is marked rather than deleted. Re-running an import changes nothing. Do not build yet: No rules or budgets yet. ### Phase 3 · Transfers, which are not spending Build: Detect matching pairs across accounts by amount, direction and a date window, propose them as transfers, and require confirmation. Excluded transfers must not appear in spending totals or budgets. Trap: 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. Done when: A matching pair across two accounts is proposed and, once confirmed, excluded from spending. An unconfirmed proposal has no effect on any total. Unmatching restores both transactions to spending. A same-amount coincidence between unrelated accounts is proposed rather than applied, tested with a crafted pair. Do not build yet: No rules or budgets yet. ### Phase 4 · Rules that cannot rewrite the past behind your back Build: Rules matching on payee, amount and account, applied at import in priority order. Retroactive application is a separate action showing exactly which transactions would change, requiring confirmation, and reversible. Trap: 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. Done when: A new rule applies to incoming transactions automatically. Retroactive application previews the affected set and does nothing until confirmed, tested with a rule matching hundreds of rows. The application is recorded and reversible. A manually set category is never overwritten by a rule. Do not build yet: No budgets or reports yet. ### Phase 5 · Budgets and reports that agree with the ledger Build: Monthly budgets per category, spending reports, and a rule that every displayed figure can be opened to the transactions behind it. Handle mid-month category changes and transactions dated in a closed month. Trap: 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. Done when: Every report figure opens to its transactions and the list sums to the figure, asserted in a test. A transaction dated in a past month appears in that month's report after import. Recategorising updates every affected report immediately. Budgets handle a category with no spending without dividing by zero. Do not build yet: Export and connection health come last. ### Phase 6 · Broken connections, export and deletion Build: Surface connection health and the age of the last successful sync per account. Alert when an account has not synced. Full export and complete deletion including the aggregator connection. Trap: 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. Done when: Each account shows its last successful sync and stale accounts are surfaced. An expired connection produces an alert rather than silence, tested by revoking one. Export contains accounts, transactions, rules and links and re-imports identically. Deleting an account removes its transactions and revokes the connection at the provider. Do not build yet: Nothing after this. ### Out of scope (and why) - Investment tracking with cost basis and tax lots, which is a substantially harder domain than cash accounting. - Broad institution coverage, which is what an aggregator's fees actually pay for and cannot be replicated. - Any tax filing or advice, which is jurisdiction-specific and carries liability. - Shared household finances with per-person visibility rules, which is a permissions model on top of everything here. ### README must contain - That money is stored as integer minor units and never as floating point - That a pending transaction and its posted counterpart are separate rows joined by a link - That transfer matches are proposed and confirmed, never applied automatically - That retroactive rule application previews, requires confirmation and is reversible - That a stale bank connection is alerted on, because missing data produces no error ===== AGENTS.md ===== # Agent instructions · Lunch Money indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, An aggregator for bank connections, plus file import as a first-class path, Integer minor units, never floating point, Applied at import, with retroactive application as an explicit reviewed action, Amount, currency and the rate used, stored per transaction. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Lunch Money 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A ledger that is correct about money Accounts, transactions and balances that never lose a penny. ### Steps 1. Integer minor units end to end Schema, types and arithmetic. Display conversion only at the edge. Files: `src/money.ts`, `src/money.test.ts` 2. CSV import that handles real files Quoted fields, several date formats, signed or separate debit and credit columns. Files: `src/import-csv.ts` 3. Derive balances and assert exactness Ten thousand rows to a known total, in the test suite. Files: `tests/balance.test.ts` ### Done when - [ ] Ten thousand transactions sum exactly - [ ] No floating point appears in a money path - [ ] CSV import handles quoting and date variation - [ ] Multi-currency accounts keep their own currency ### 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. ## Phase 2 · Bank imports that do not double-count The same spending never appears twice, however the bank delivers it. ### Steps 1. Deduplicate on provider identifiers Never on amount and date. Two identical coffees in a day is a normal case. Files: `src/dedupe.ts` 2. Link pending to posted explicitly The posted row is new and references the pending one. Store the link. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 3. Make import idempotent Re-running must change nothing. Test it by running twice. Files: `src/import.ts` ### Done when - [ ] A pending transaction posting yields one ledger entry - [ ] Two identical same-day purchases both survive - [ ] A removed transaction is marked, not deleted - [ ] Re-running an import changes nothing ### 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. ## Phase 3 · Transfers, which are not spending Moving money between your own accounts does not look like expenditure. ### Steps 1. Propose matches, never apply them Amount, direction and a date window. Confirmation is required. Files: `src/transfers.ts` 2. Exclude confirmed transfers from totals In the query layer, so no report can forget to. Files: `src/reports.ts` 3. Make unmatching restore both sides Reversibility is what makes automatic proposal safe. Files: `src/transfers.ts` ### Done when - [ ] A matching pair is proposed and excludes once confirmed - [ ] An unconfirmed proposal affects no total - [ ] Unmatching restores both transactions - [ ] A coincidental match is proposed, not applied ### 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. ## Phase 4 · Rules that cannot rewrite the past behind your back Categorisation is automatic going forward and deliberate backwards. ### Steps 1. Apply rules at import in priority order Deterministic. Two matching rules must resolve the same way every time. Files: `src/rules.ts` 2. Preview, confirm and record retroactive runs Showing the exact affected set before anything changes. Files: `src/retroactive.ts` 3. Never overwrite a manual category A human decision outranks a rule, always. Files: `src/rules.ts` ### Done when - [ ] Rules apply automatically to new transactions - [ ] A retroactive run previews and requires confirmation - [ ] Applications are recorded and reversible - [ ] A manual category is never overwritten ### 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. ## Phase 5 · Budgets and reports that agree with the ledger Every number in a report can be traced back to transactions. ### Steps 1. Compute reports from transactions Or invalidate caches aggressively and assert agreement in tests. Files: `src/reports.ts` 2. Make every figure openable The list behind a number must sum to it. Test that invariant. Files: `tests/report-sums.test.ts` 3. Handle backdated arrivals Late transactions dated in a closed month are normal, not exceptional. Files: `src/reports.ts` ### Done when - [ ] Every figure opens to transactions that sum to it - [ ] A backdated transaction lands in the right month - [ ] Recategorising updates reports immediately - [ ] An empty category does not divide by zero ### 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. ## Not in this build - Investment tracking with cost basis and tax lots. - Broad institution coverage. - Any tax filing or advice. - Shared household finances with per-person visibility. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your complete financial history. Encrypt backups and be careful where they live. DATABASE_URL=postgres://localhost:5432/ledger # Optional · secret. Phase 2 only. Without it the ledger runs on file import, which is a fine way to start. AGGREGATOR_CLIENT_ID=61f... # Optional · secret. Grants access to your bank connections. Rotate it if it is ever exposed. AGGREGATOR_SECRET=a provider-issued secret # Optional. Sandbox until the pending to posted path is proven correct against real data. AGGREGATOR_ENV=sandbox # Required. When an account is considered stale and alerted on. Silence is the failure mode. SYNC_STALE_HOURS=72 # Required. For cross-currency reporting. Individual transactions keep their own currency. BASE_CURRENCY=GBP
You are building a production product version of Lunch Money. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Lunch Money · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Lunch Money, import transactions and maintain a transparent personal ledger with rules and reports. The hard boundary is bank sync, multi-currency support, api, community, and hosted convenience, plus financial integrations, compliance, and trust. ## Product outcome A personal ledger where every figure traces to transactions, the same spending is never counted twice however the bank delivers it, and nothing rewrites a month you already reviewed without asking. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - CSV or OFX exports from financial institutions - local encrypted storage - optional exchange-rate API key ## Explicit non-goals for v1 - 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 ## Success criteria - A pending transaction posting is confirmed to yield one ledger entry - Two identical same-day purchases are confirmed both to survive - A retroactive rule run is confirmed to preview and require confirmation - Every report figure is confirmed to sum to its transaction list - A revoked connection is confirmed to raise an alert ===== BRIEF.md ===== # Build brief · Lunch Money The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal ledger that imports from your banks and never quietly changes your history to replace Lunch Money. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · money data wants transactions and constraints, not a document store - Import: An aggregator for bank connections, plus file import as a first-class path · connections break constantly and a ledger that only works when they work is not a ledger - Amounts: Integer minor units, never floating point · floating point money produces off-by-a-penny errors that take days to find - Rules: Applied at import, with retroactive application as an explicit reviewed action · a rule that silently rewrites reviewed history destroys trust in the ledger - Currency: Amount, currency and the rate used, stored per transaction · converting on display loses the rate that was actually in effect ### Data model (create this before Phase 1) - 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. ### Phase 1 · A ledger that is correct about money Build: Accounts and transactions with integer minor units, a currency per transaction, and computed balances. Manual entry and CSV import. Enforce that a balance is derived rather than stored, or reconciled if stored. Trap: 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. Done when: Ten thousand transactions sum to an exact expected balance, asserted in a test. No floating point type appears in any money path, verified by inspecting the schema and types. CSV import handles quoted fields, varying date formats and a signed amount column. A multi-currency account keeps its own currency rather than converting on import. Do not build yet: No bank connections, rules or budgets. ### Phase 2 · Bank imports that do not double-count Build: Connect an aggregator, import transactions, and handle the pending to posted transition correctly. Deduplicate on the provider's identifiers rather than on amount and date. Handle a transaction disappearing from the feed. Trap: 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. Done when: A pending transaction that posts results in one transaction in the ledger, joined by a link, tested against real provider data or a faithful fixture. Two genuinely identical purchases on one day both survive. A removed transaction is marked rather than deleted. Re-running an import changes nothing. Do not build yet: No rules or budgets yet. ### Phase 3 · Transfers, which are not spending Build: Detect matching pairs across accounts by amount, direction and a date window, propose them as transfers, and require confirmation. Excluded transfers must not appear in spending totals or budgets. Trap: 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. Done when: A matching pair across two accounts is proposed and, once confirmed, excluded from spending. An unconfirmed proposal has no effect on any total. Unmatching restores both transactions to spending. A same-amount coincidence between unrelated accounts is proposed rather than applied, tested with a crafted pair. Do not build yet: No rules or budgets yet. ### Phase 4 · Rules that cannot rewrite the past behind your back Build: Rules matching on payee, amount and account, applied at import in priority order. Retroactive application is a separate action showing exactly which transactions would change, requiring confirmation, and reversible. Trap: 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. Done when: A new rule applies to incoming transactions automatically. Retroactive application previews the affected set and does nothing until confirmed, tested with a rule matching hundreds of rows. The application is recorded and reversible. A manually set category is never overwritten by a rule. Do not build yet: No budgets or reports yet. ### Phase 5 · Budgets and reports that agree with the ledger Build: Monthly budgets per category, spending reports, and a rule that every displayed figure can be opened to the transactions behind it. Handle mid-month category changes and transactions dated in a closed month. Trap: 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. Done when: Every report figure opens to its transactions and the list sums to the figure, asserted in a test. A transaction dated in a past month appears in that month's report after import. Recategorising updates every affected report immediately. Budgets handle a category with no spending without dividing by zero. Do not build yet: Export and connection health come last. ### Phase 6 · Broken connections, export and deletion Build: Surface connection health and the age of the last successful sync per account. Alert when an account has not synced. Full export and complete deletion including the aggregator connection. Trap: 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. Done when: Each account shows its last successful sync and stale accounts are surfaced. An expired connection produces an alert rather than silence, tested by revoking one. Export contains accounts, transactions, rules and links and re-imports identically. Deleting an account removes its transactions and revokes the connection at the provider. Do not build yet: Nothing after this. ### Out of scope (and why) - Investment tracking with cost basis and tax lots, which is a substantially harder domain than cash accounting. - Broad institution coverage, which is what an aggregator's fees actually pay for and cannot be replicated. - Any tax filing or advice, which is jurisdiction-specific and carries liability. - Shared household finances with per-person visibility rules, which is a permissions model on top of everything here. ### README must contain - That money is stored as integer minor units and never as floating point - That a pending transaction and its posted counterpart are separate rows joined by a link - That transfer matches are proposed and confirmed, never applied automatically - That retroactive rule application previews, requires confirmation and is reversible - That a stale bank connection is alerted on, because missing data produces no error ===== ARCHITECTURE.md ===== # Architecture · Lunch Money ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | money data wants transactions and constraints, not a document store | | Import | An aggregator for bank connections, plus file import as a first-class path | connections break constantly and a ledger that only works when they work is not a ledger | | Amounts | Integer minor units, never floating point | floating point money produces off-by-a-penny errors that take days to find | | Rules | Applied at import, with retroactive application as an explicit reviewed action | a rule that silently rewrites reviewed history destroys trust in the ledger | | Currency | Amount, currency and the rate used, stored per transaction | converting on display loses the rate that was actually in effect | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Money | Integer minor units and exact arithmetic. | Not swappable for a float type at any point, including display intermediates. | | Import | Provider sync, deduplication and pending links. | Any aggregator, provided the pending to posted model is handled rather than assumed. | | Transfers | Proposing and confirming matches. | Any matcher, provided it proposes rather than applies. | | Rules | Categorisation forward and, deliberately, backward. | Any engine, provided retroactive runs preview and are reversible. | | Health | Connection staleness alerting. | Any alerting. The point is that absence of data raises no error on its own. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Your complete financial history. Encrypt backups and be careful where they live. - `AGGREGATOR_CLIENT_ID` · optional, secret · Phase 2 only. Without it the ledger runs on file import, which is a fine way to start. - `AGGREGATOR_SECRET` · optional, secret · Grants access to your bank connections. Rotate it if it is ever exposed. - `AGGREGATOR_ENV` · optional · Sandbox until the pending to posted path is proven correct against real data. - `SYNC_STALE_HOURS` · required · When an account is considered stale and alerted on. Silence is the failure mode. - `BASE_CURRENCY` · required · For cross-currency reporting. Individual transactions keep their own currency. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Lunch Money product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, An aggregator for bank connections, plus file import as a first-class path, Integer minor units, never floating point, Applied at import, with retroactive application as an explicit reviewed action, Amount, currency and the rate used, stored per transaction. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · Lunch Money Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A ledger that is correct about money Accounts, transactions and balances that never lose a penny. ### Steps 1. Integer minor units end to end Schema, types and arithmetic. Display conversion only at the edge. Files: `src/money.ts`, `src/money.test.ts` 2. CSV import that handles real files Quoted fields, several date formats, signed or separate debit and credit columns. Files: `src/import-csv.ts` 3. Derive balances and assert exactness Ten thousand rows to a known total, in the test suite. Files: `tests/balance.test.ts` ### Done when - [ ] Ten thousand transactions sum exactly - [ ] No floating point appears in a money path - [ ] CSV import handles quoting and date variation - [ ] Multi-currency accounts keep their own currency ### 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. ## M2 · Bank imports that do not double-count The same spending never appears twice, however the bank delivers it. ### Steps 1. Deduplicate on provider identifiers Never on amount and date. Two identical coffees in a day is a normal case. Files: `src/dedupe.ts` 2. Link pending to posted explicitly The posted row is new and references the pending one. Store the link. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 3. Make import idempotent Re-running must change nothing. Test it by running twice. Files: `src/import.ts` ### Done when - [ ] A pending transaction posting yields one ledger entry - [ ] Two identical same-day purchases both survive - [ ] A removed transaction is marked, not deleted - [ ] Re-running an import changes nothing ### 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. ## M3 · Transfers, which are not spending Moving money between your own accounts does not look like expenditure. ### Steps 1. Propose matches, never apply them Amount, direction and a date window. Confirmation is required. Files: `src/transfers.ts` 2. Exclude confirmed transfers from totals In the query layer, so no report can forget to. Files: `src/reports.ts` 3. Make unmatching restore both sides Reversibility is what makes automatic proposal safe. Files: `src/transfers.ts` ### Done when - [ ] A matching pair is proposed and excludes once confirmed - [ ] An unconfirmed proposal affects no total - [ ] Unmatching restores both transactions - [ ] A coincidental match is proposed, not applied ### 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. ## M4 · Rules that cannot rewrite the past behind your back Categorisation is automatic going forward and deliberate backwards. ### Steps 1. Apply rules at import in priority order Deterministic. Two matching rules must resolve the same way every time. Files: `src/rules.ts` 2. Preview, confirm and record retroactive runs Showing the exact affected set before anything changes. Files: `src/retroactive.ts` 3. Never overwrite a manual category A human decision outranks a rule, always. Files: `src/rules.ts` ### Done when - [ ] Rules apply automatically to new transactions - [ ] A retroactive run previews and requires confirmation - [ ] Applications are recorded and reversible - [ ] A manual category is never overwritten ### 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. ## M5 · Budgets and reports that agree with the ledger Every number in a report can be traced back to transactions. ### Steps 1. Compute reports from transactions Or invalidate caches aggressively and assert agreement in tests. Files: `src/reports.ts` 2. Make every figure openable The list behind a number must sum to it. Test that invariant. Files: `tests/report-sums.test.ts` 3. Handle backdated arrivals Late transactions dated in a closed month are normal, not exceptional. Files: `src/reports.ts` ### Done when - [ ] Every figure opens to transactions that sum to it - [ ] A backdated transaction lands in the right month - [ ] Recategorising updates reports immediately - [ ] An empty category does not divide by zero ### 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. ## M6 · Broken connections, export and deletion (production only) You notice when a bank feed dies, and your data can always leave. ### Steps 1. Show last successful sync per account Missing data raises no error on its own. This is the only way you find out. Files: `src/health.ts` 2. Alert on staleness Per account, with a threshold. Silence is the failure mode. Files: `src/alerts.ts` 3. Export fully and delete completely Including revoking the connection at the provider rather than only locally. Files: `src/export.ts`, `src/deletion.ts` ### Done when - [ ] Each account shows its last successful sync - [ ] A revoked connection produces an alert - [ ] Export round trips accounts, transactions and links - [ ] Deletion revokes the provider connection ### Watch 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. ===== OPERATIONS.md ===== # Operations · Lunch Money ## Backup A financial history is irreplaceable and highly sensitive. Encrypted backups with a tested restore, and think carefully about where they are stored, since this is a complete record of someone's life. ## Restore Restore and confirm balances match the bank's own figures rather than merely being internally consistent, since a restore that loses recent imports is internally consistent and wrong. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync staleness per account, deduplication collisions, and report-to-ledger agreement. The first is the one that silently invalidates everything else. ## Incident checklist Spending that suddenly appears doubled means the pending link is not being made. A report disagreeing with its own transaction list means an aggregate is stale. Both should be caught by invariants in the suite rather than by noticing. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A pending transaction posting is confirmed to yield one ledger entry - [ ] Two identical same-day purchases are confirmed both to survive - [ ] A retroactive rule run is confirmed to preview and require confirmation - [ ] Every report figure is confirmed to sum to its transaction list - [ ] A revoked connection is confirmed to raise an alert ## Launch constraint Do not market omitted Lunch Money capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your complete financial history. Encrypt backups and be careful where they live. DATABASE_URL=postgres://localhost:5432/ledger # Optional · secret. Phase 2 only. Without it the ledger runs on file import, which is a fine way to start. AGGREGATOR_CLIENT_ID=61f... # Optional · secret. Grants access to your bank connections. Rotate it if it is ever exposed. AGGREGATOR_SECRET=a provider-issued secret # Optional. Sandbox until the pending to posted path is proven correct against real data. AGGREGATOR_ENV=sandbox # Required. When an account is considered stale and alerted on. Silence is the failure mode. SYNC_STALE_HOURS=72 # Required. For cross-currency reporting. Individual transactions keep their own currency. BASE_CURRENCY=GBP
# Lunch Money · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | money data wants transactions and constraints, not a document store | | Import | An aggregator for bank connections, plus file import as a first-class path | connections break constantly and a ledger that only works when they work is not a ledger | | Amounts | Integer minor units, never floating point | floating point money produces off-by-a-penny errors that take days to find | | Rules | Applied at import, with retroactive application as an explicit reviewed action | a rule that silently rewrites reviewed history destroys trust in the ledger | | Currency | Amount, currency and the rate used, stored per transaction | converting on display loses the rate that was actually in effect | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the import workers. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Money data wants real transactions, constraints and exact integer arithmetic. - Get it: Docker or your package manager. - Verify: psql connects and creates a table - [ ] **A bank aggregator account in sandbox** (optional) · 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. - Verify: A sandbox account syncs and a pending transaction is observed becoming posted - [ ] **Twelve months of your own statements** · free - 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 - [ ] **Where the ledger's source of truth sits** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Lunch Money, and the README should say so rather than pretend.
# Build brief · Lunch Money The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal ledger that imports from your banks and never quietly changes your history to replace Lunch Money. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · money data wants transactions and constraints, not a document store - Import: An aggregator for bank connections, plus file import as a first-class path · connections break constantly and a ledger that only works when they work is not a ledger - Amounts: Integer minor units, never floating point · floating point money produces off-by-a-penny errors that take days to find - Rules: Applied at import, with retroactive application as an explicit reviewed action · a rule that silently rewrites reviewed history destroys trust in the ledger - Currency: Amount, currency and the rate used, stored per transaction · converting on display loses the rate that was actually in effect ### Data model (create this before Phase 1) - 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. ### Phase 1 · A ledger that is correct about money Build: Accounts and transactions with integer minor units, a currency per transaction, and computed balances. Manual entry and CSV import. Enforce that a balance is derived rather than stored, or reconciled if stored. Trap: 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. Done when: Ten thousand transactions sum to an exact expected balance, asserted in a test. No floating point type appears in any money path, verified by inspecting the schema and types. CSV import handles quoted fields, varying date formats and a signed amount column. A multi-currency account keeps its own currency rather than converting on import. Do not build yet: No bank connections, rules or budgets. ### Phase 2 · Bank imports that do not double-count Build: Connect an aggregator, import transactions, and handle the pending to posted transition correctly. Deduplicate on the provider's identifiers rather than on amount and date. Handle a transaction disappearing from the feed. Trap: 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. Done when: A pending transaction that posts results in one transaction in the ledger, joined by a link, tested against real provider data or a faithful fixture. Two genuinely identical purchases on one day both survive. A removed transaction is marked rather than deleted. Re-running an import changes nothing. Do not build yet: No rules or budgets yet. ### Phase 3 · Transfers, which are not spending Build: Detect matching pairs across accounts by amount, direction and a date window, propose them as transfers, and require confirmation. Excluded transfers must not appear in spending totals or budgets. Trap: 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. Done when: A matching pair across two accounts is proposed and, once confirmed, excluded from spending. An unconfirmed proposal has no effect on any total. Unmatching restores both transactions to spending. A same-amount coincidence between unrelated accounts is proposed rather than applied, tested with a crafted pair. Do not build yet: No rules or budgets yet. ### Phase 4 · Rules that cannot rewrite the past behind your back Build: Rules matching on payee, amount and account, applied at import in priority order. Retroactive application is a separate action showing exactly which transactions would change, requiring confirmation, and reversible. Trap: 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. Done when: A new rule applies to incoming transactions automatically. Retroactive application previews the affected set and does nothing until confirmed, tested with a rule matching hundreds of rows. The application is recorded and reversible. A manually set category is never overwritten by a rule. Do not build yet: No budgets or reports yet. ### Phase 5 · Budgets and reports that agree with the ledger Build: Monthly budgets per category, spending reports, and a rule that every displayed figure can be opened to the transactions behind it. Handle mid-month category changes and transactions dated in a closed month. Trap: 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. Done when: Every report figure opens to its transactions and the list sums to the figure, asserted in a test. A transaction dated in a past month appears in that month's report after import. Recategorising updates every affected report immediately. Budgets handle a category with no spending without dividing by zero. Do not build yet: Export and connection health come last. ### Phase 6 · Broken connections, export and deletion Build: Surface connection health and the age of the last successful sync per account. Alert when an account has not synced. Full export and complete deletion including the aggregator connection. Trap: 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. Done when: Each account shows its last successful sync and stale accounts are surfaced. An expired connection produces an alert rather than silence, tested by revoking one. Export contains accounts, transactions, rules and links and re-imports identically. Deleting an account removes its transactions and revokes the connection at the provider. Do not build yet: Nothing after this. ### Out of scope (and why) - Investment tracking with cost basis and tax lots, which is a substantially harder domain than cash accounting. - Broad institution coverage, which is what an aggregator's fees actually pay for and cannot be replicated. - Any tax filing or advice, which is jurisdiction-specific and carries liability. - Shared household finances with per-person visibility rules, which is a permissions model on top of everything here. ### README must contain - That money is stored as integer minor units and never as floating point - That a pending transaction and its posted counterpart are separate rows joined by a link - That transfer matches are proposed and confirmed, never applied automatically - That retroactive rule application previews, requires confirmation and is reversible - That a stale bank connection is alerted on, because missing data produces no error
# Agent instructions · Lunch Money indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, An aggregator for bank connections, plus file import as a first-class path, Integer minor units, never floating point, Applied at import, with retroactive application as an explicit reviewed action, Amount, currency and the rate used, stored per transaction. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. - 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.
# Build plan · Lunch Money 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A ledger that is correct about money Accounts, transactions and balances that never lose a penny. ### Steps 1. Integer minor units end to end Schema, types and arithmetic. Display conversion only at the edge. Files: `src/money.ts`, `src/money.test.ts` 2. CSV import that handles real files Quoted fields, several date formats, signed or separate debit and credit columns. Files: `src/import-csv.ts` 3. Derive balances and assert exactness Ten thousand rows to a known total, in the test suite. Files: `tests/balance.test.ts` ### Done when - [ ] Ten thousand transactions sum exactly - [ ] No floating point appears in a money path - [ ] CSV import handles quoting and date variation - [ ] Multi-currency accounts keep their own currency ### 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. ## Phase 2 · Bank imports that do not double-count The same spending never appears twice, however the bank delivers it. ### Steps 1. Deduplicate on provider identifiers Never on amount and date. Two identical coffees in a day is a normal case. Files: `src/dedupe.ts` 2. Link pending to posted explicitly The posted row is new and references the pending one. Store the link. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 3. Make import idempotent Re-running must change nothing. Test it by running twice. Files: `src/import.ts` ### Done when - [ ] A pending transaction posting yields one ledger entry - [ ] Two identical same-day purchases both survive - [ ] A removed transaction is marked, not deleted - [ ] Re-running an import changes nothing ### 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. ## Phase 3 · Transfers, which are not spending Moving money between your own accounts does not look like expenditure. ### Steps 1. Propose matches, never apply them Amount, direction and a date window. Confirmation is required. Files: `src/transfers.ts` 2. Exclude confirmed transfers from totals In the query layer, so no report can forget to. Files: `src/reports.ts` 3. Make unmatching restore both sides Reversibility is what makes automatic proposal safe. Files: `src/transfers.ts` ### Done when - [ ] A matching pair is proposed and excludes once confirmed - [ ] An unconfirmed proposal affects no total - [ ] Unmatching restores both transactions - [ ] A coincidental match is proposed, not applied ### 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. ## Phase 4 · Rules that cannot rewrite the past behind your back Categorisation is automatic going forward and deliberate backwards. ### Steps 1. Apply rules at import in priority order Deterministic. Two matching rules must resolve the same way every time. Files: `src/rules.ts` 2. Preview, confirm and record retroactive runs Showing the exact affected set before anything changes. Files: `src/retroactive.ts` 3. Never overwrite a manual category A human decision outranks a rule, always. Files: `src/rules.ts` ### Done when - [ ] Rules apply automatically to new transactions - [ ] A retroactive run previews and requires confirmation - [ ] Applications are recorded and reversible - [ ] A manual category is never overwritten ### 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. ## Phase 5 · Budgets and reports that agree with the ledger Every number in a report can be traced back to transactions. ### Steps 1. Compute reports from transactions Or invalidate caches aggressively and assert agreement in tests. Files: `src/reports.ts` 2. Make every figure openable The list behind a number must sum to it. Test that invariant. Files: `tests/report-sums.test.ts` 3. Handle backdated arrivals Late transactions dated in a closed month are normal, not exceptional. Files: `src/reports.ts` ### Done when - [ ] Every figure opens to transactions that sum to it - [ ] A backdated transaction lands in the right month - [ ] Recategorising updates reports immediately - [ ] An empty category does not divide by zero ### 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. ## Not in this build - Investment tracking with cost basis and tax lots. - Broad institution coverage. - Any tax filing or advice. - Shared household finances with per-person visibility. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your complete financial history. Encrypt backups and be careful where they live. DATABASE_URL=postgres://localhost:5432/ledger # Optional · secret. Phase 2 only. Without it the ledger runs on file import, which is a fine way to start. AGGREGATOR_CLIENT_ID=61f... # Optional · secret. Grants access to your bank connections. Rotate it if it is ever exposed. AGGREGATOR_SECRET=a provider-issued secret # Optional. Sandbox until the pending to posted path is proven correct against real data. AGGREGATOR_ENV=sandbox # Required. When an account is considered stale and alerted on. Silence is the failure mode. SYNC_STALE_HOURS=72 # Required. For cross-currency reporting. Individual transactions keep their own currency. BASE_CURRENCY=GBP
# Lunch Money · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Lunch Money, import transactions and maintain a transparent personal ledger with rules and reports. The hard boundary is bank sync, multi-currency support, api, community, and hosted convenience, plus financial integrations, compliance, and trust. ## Product outcome A personal ledger where every figure traces to transactions, the same spending is never counted twice however the bank delivers it, and nothing rewrites a month you already reviewed without asking. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - CSV or OFX exports from financial institutions - local encrypted storage - optional exchange-rate API key ## Explicit non-goals for v1 - 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 ## Success criteria - A pending transaction posting is confirmed to yield one ledger entry - Two identical same-day purchases are confirmed both to survive - A retroactive rule run is confirmed to preview and require confirmation - Every report figure is confirmed to sum to its transaction list - A revoked connection is confirmed to raise an alert
# Build brief · Lunch Money The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal ledger that imports from your banks and never quietly changes your history to replace Lunch Money. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · money data wants transactions and constraints, not a document store - Import: An aggregator for bank connections, plus file import as a first-class path · connections break constantly and a ledger that only works when they work is not a ledger - Amounts: Integer minor units, never floating point · floating point money produces off-by-a-penny errors that take days to find - Rules: Applied at import, with retroactive application as an explicit reviewed action · a rule that silently rewrites reviewed history destroys trust in the ledger - Currency: Amount, currency and the rate used, stored per transaction · converting on display loses the rate that was actually in effect ### Data model (create this before Phase 1) - 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. ### Phase 1 · A ledger that is correct about money Build: Accounts and transactions with integer minor units, a currency per transaction, and computed balances. Manual entry and CSV import. Enforce that a balance is derived rather than stored, or reconciled if stored. Trap: 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. Done when: Ten thousand transactions sum to an exact expected balance, asserted in a test. No floating point type appears in any money path, verified by inspecting the schema and types. CSV import handles quoted fields, varying date formats and a signed amount column. A multi-currency account keeps its own currency rather than converting on import. Do not build yet: No bank connections, rules or budgets. ### Phase 2 · Bank imports that do not double-count Build: Connect an aggregator, import transactions, and handle the pending to posted transition correctly. Deduplicate on the provider's identifiers rather than on amount and date. Handle a transaction disappearing from the feed. Trap: 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. Done when: A pending transaction that posts results in one transaction in the ledger, joined by a link, tested against real provider data or a faithful fixture. Two genuinely identical purchases on one day both survive. A removed transaction is marked rather than deleted. Re-running an import changes nothing. Do not build yet: No rules or budgets yet. ### Phase 3 · Transfers, which are not spending Build: Detect matching pairs across accounts by amount, direction and a date window, propose them as transfers, and require confirmation. Excluded transfers must not appear in spending totals or budgets. Trap: 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. Done when: A matching pair across two accounts is proposed and, once confirmed, excluded from spending. An unconfirmed proposal has no effect on any total. Unmatching restores both transactions to spending. A same-amount coincidence between unrelated accounts is proposed rather than applied, tested with a crafted pair. Do not build yet: No rules or budgets yet. ### Phase 4 · Rules that cannot rewrite the past behind your back Build: Rules matching on payee, amount and account, applied at import in priority order. Retroactive application is a separate action showing exactly which transactions would change, requiring confirmation, and reversible. Trap: 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. Done when: A new rule applies to incoming transactions automatically. Retroactive application previews the affected set and does nothing until confirmed, tested with a rule matching hundreds of rows. The application is recorded and reversible. A manually set category is never overwritten by a rule. Do not build yet: No budgets or reports yet. ### Phase 5 · Budgets and reports that agree with the ledger Build: Monthly budgets per category, spending reports, and a rule that every displayed figure can be opened to the transactions behind it. Handle mid-month category changes and transactions dated in a closed month. Trap: 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. Done when: Every report figure opens to its transactions and the list sums to the figure, asserted in a test. A transaction dated in a past month appears in that month's report after import. Recategorising updates every affected report immediately. Budgets handle a category with no spending without dividing by zero. Do not build yet: Export and connection health come last. ### Phase 6 · Broken connections, export and deletion Build: Surface connection health and the age of the last successful sync per account. Alert when an account has not synced. Full export and complete deletion including the aggregator connection. Trap: 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. Done when: Each account shows its last successful sync and stale accounts are surfaced. An expired connection produces an alert rather than silence, tested by revoking one. Export contains accounts, transactions, rules and links and re-imports identically. Deleting an account removes its transactions and revokes the connection at the provider. Do not build yet: Nothing after this. ### Out of scope (and why) - Investment tracking with cost basis and tax lots, which is a substantially harder domain than cash accounting. - Broad institution coverage, which is what an aggregator's fees actually pay for and cannot be replicated. - Any tax filing or advice, which is jurisdiction-specific and carries liability. - Shared household finances with per-person visibility rules, which is a permissions model on top of everything here. ### README must contain - That money is stored as integer minor units and never as floating point - That a pending transaction and its posted counterpart are separate rows joined by a link - That transfer matches are proposed and confirmed, never applied automatically - That retroactive rule application previews, requires confirmation and is reversible - That a stale bank connection is alerted on, because missing data produces no error
# Architecture · Lunch Money ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | money data wants transactions and constraints, not a document store | | Import | An aggregator for bank connections, plus file import as a first-class path | connections break constantly and a ledger that only works when they work is not a ledger | | Amounts | Integer minor units, never floating point | floating point money produces off-by-a-penny errors that take days to find | | Rules | Applied at import, with retroactive application as an explicit reviewed action | a rule that silently rewrites reviewed history destroys trust in the ledger | | Currency | Amount, currency and the rate used, stored per transaction | converting on display loses the rate that was actually in effect | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Money | Integer minor units and exact arithmetic. | Not swappable for a float type at any point, including display intermediates. | | Import | Provider sync, deduplication and pending links. | Any aggregator, provided the pending to posted model is handled rather than assumed. | | Transfers | Proposing and confirming matches. | Any matcher, provided it proposes rather than applies. | | Rules | Categorisation forward and, deliberately, backward. | Any engine, provided retroactive runs preview and are reversible. | | Health | Connection staleness alerting. | Any alerting. The point is that absence of data raises no error on its own. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Your complete financial history. Encrypt backups and be careful where they live. - `AGGREGATOR_CLIENT_ID` · optional, secret · Phase 2 only. Without it the ledger runs on file import, which is a fine way to start. - `AGGREGATOR_SECRET` · optional, secret · Grants access to your bank connections. Rotate it if it is ever exposed. - `AGGREGATOR_ENV` · optional · Sandbox until the pending to posted path is proven correct against real data. - `SYNC_STALE_HOURS` · required · When an account is considered stale and alerted on. Silence is the failure mode. - `BASE_CURRENCY` · required · For cross-currency reporting. Individual transactions keep their own currency. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Lunch Money product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, An aggregator for bank connections, plus file import as a first-class path, Integer minor units, never floating point, Applied at import, with retroactive application as an explicit reviewed action, Amount, currency and the rate used, stored per transaction. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · Lunch Money Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A ledger that is correct about money Accounts, transactions and balances that never lose a penny. ### Steps 1. Integer minor units end to end Schema, types and arithmetic. Display conversion only at the edge. Files: `src/money.ts`, `src/money.test.ts` 2. CSV import that handles real files Quoted fields, several date formats, signed or separate debit and credit columns. Files: `src/import-csv.ts` 3. Derive balances and assert exactness Ten thousand rows to a known total, in the test suite. Files: `tests/balance.test.ts` ### Done when - [ ] Ten thousand transactions sum exactly - [ ] No floating point appears in a money path - [ ] CSV import handles quoting and date variation - [ ] Multi-currency accounts keep their own currency ### 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. ## M2 · Bank imports that do not double-count The same spending never appears twice, however the bank delivers it. ### Steps 1. Deduplicate on provider identifiers Never on amount and date. Two identical coffees in a day is a normal case. Files: `src/dedupe.ts` 2. Link pending to posted explicitly The posted row is new and references the pending one. Store the link. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 3. Make import idempotent Re-running must change nothing. Test it by running twice. Files: `src/import.ts` ### Done when - [ ] A pending transaction posting yields one ledger entry - [ ] Two identical same-day purchases both survive - [ ] A removed transaction is marked, not deleted - [ ] Re-running an import changes nothing ### 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. ## M3 · Transfers, which are not spending Moving money between your own accounts does not look like expenditure. ### Steps 1. Propose matches, never apply them Amount, direction and a date window. Confirmation is required. Files: `src/transfers.ts` 2. Exclude confirmed transfers from totals In the query layer, so no report can forget to. Files: `src/reports.ts` 3. Make unmatching restore both sides Reversibility is what makes automatic proposal safe. Files: `src/transfers.ts` ### Done when - [ ] A matching pair is proposed and excludes once confirmed - [ ] An unconfirmed proposal affects no total - [ ] Unmatching restores both transactions - [ ] A coincidental match is proposed, not applied ### 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. ## M4 · Rules that cannot rewrite the past behind your back Categorisation is automatic going forward and deliberate backwards. ### Steps 1. Apply rules at import in priority order Deterministic. Two matching rules must resolve the same way every time. Files: `src/rules.ts` 2. Preview, confirm and record retroactive runs Showing the exact affected set before anything changes. Files: `src/retroactive.ts` 3. Never overwrite a manual category A human decision outranks a rule, always. Files: `src/rules.ts` ### Done when - [ ] Rules apply automatically to new transactions - [ ] A retroactive run previews and requires confirmation - [ ] Applications are recorded and reversible - [ ] A manual category is never overwritten ### 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. ## M5 · Budgets and reports that agree with the ledger Every number in a report can be traced back to transactions. ### Steps 1. Compute reports from transactions Or invalidate caches aggressively and assert agreement in tests. Files: `src/reports.ts` 2. Make every figure openable The list behind a number must sum to it. Test that invariant. Files: `tests/report-sums.test.ts` 3. Handle backdated arrivals Late transactions dated in a closed month are normal, not exceptional. Files: `src/reports.ts` ### Done when - [ ] Every figure opens to transactions that sum to it - [ ] A backdated transaction lands in the right month - [ ] Recategorising updates reports immediately - [ ] An empty category does not divide by zero ### 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. ## M6 · Broken connections, export and deletion (production only) You notice when a bank feed dies, and your data can always leave. ### Steps 1. Show last successful sync per account Missing data raises no error on its own. This is the only way you find out. Files: `src/health.ts` 2. Alert on staleness Per account, with a threshold. Silence is the failure mode. Files: `src/alerts.ts` 3. Export fully and delete completely Including revoking the connection at the provider rather than only locally. Files: `src/export.ts`, `src/deletion.ts` ### Done when - [ ] Each account shows its last successful sync - [ ] A revoked connection produces an alert - [ ] Export round trips accounts, transactions and links - [ ] Deletion revokes the provider connection ### Watch 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.
# Operations · Lunch Money ## Backup A financial history is irreplaceable and highly sensitive. Encrypted backups with a tested restore, and think carefully about where they are stored, since this is a complete record of someone's life. ## Restore Restore and confirm balances match the bank's own figures rather than merely being internally consistent, since a restore that loses recent imports is internally consistent and wrong. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync staleness per account, deduplication collisions, and report-to-ledger agreement. The first is the one that silently invalidates everything else. ## Incident checklist Spending that suddenly appears doubled means the pending link is not being made. A report disagreeing with its own transaction list means an aggregate is stale. Both should be caught by invariants in the suite rather than by noticing. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A pending transaction posting is confirmed to yield one ledger entry - [ ] Two identical same-day purchases are confirmed both to survive - [ ] A retroactive rule run is confirmed to preview and require confirmation - [ ] Every report figure is confirmed to sum to its transaction list - [ ] A revoked connection is confirmed to raise an alert ## Launch constraint Do not market omitted Lunch Money capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your complete financial history. Encrypt backups and be careful where they live. DATABASE_URL=postgres://localhost:5432/ledger # Optional · secret. Phase 2 only. Without it the ledger runs on file import, which is a fine way to start. AGGREGATOR_CLIENT_ID=61f... # Optional · secret. Grants access to your bank connections. Rotate it if it is ever exposed. AGGREGATOR_SECRET=a provider-issued secret # Optional. Sandbox until the pending to posted path is proven correct against real data. AGGREGATOR_ENV=sandbox # Required. When an account is considered stale and alerted on. Silence is the failure mode. SYNC_STALE_HOURS=72 # Required. For cross-currency reporting. Individual transactions keep their own currency. BASE_CURRENCY=GBP
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Lunch Money because people pay because accurate money records and dependable institution connections are worth more than the visible charts. The recurring cost buys import normalization, reconciliation, rule drift, exchange rates, backups, audit trails, tax changes, bank APIs, and support, not just the visible interface.
xbank sync, multi-currency support, API, community, and hosted convenience
xdirect bank feeds
xtax filing guarantees
xpayroll and payments
xaccountant ecosystem and support
Don't feel like building it? These folks already made it free.
all 5 free alternatives to Lunch Money →· no votes, no pay-to-list · just what's real
Lunch Money pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| monthly subscription | $10 | — | All product features; 30-day trial; price locked for life |
| annual subscription | — | $8.33 | All product features; user can set annual price, displayed default $100/year; 30-day trial |
free tierno free tier; 30-day trial with no credit card
billingmonthly + annual (annual price is user-set; displayed default $100/year)
hidden costsAnnual customers can choose their own price; organization pricing is separate. Pausing converts unused time into account credit.
verified 2026-08-13 · source ↗
Vibecode Lunch Money
Kinda. The core of Lunch Money is buildable in a weekend with the prompt on this page, but there are real gaps: bank sync, multi-currency support, API, community, and hosted convenience, direct bank feeds. Read the honest list above before committing.
How much does Lunch Money cost?
Lunch Money costs about $10/month (Lunch Money, checked 2026-07-31), which is $120 per year.
What do I lose by replacing Lunch Money?
Honestly: bank sync, multi-currency support, API, community, and hosted convenience; direct bank feeds; tax filing guarantees; payroll and payments; accountant ecosystem and support. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Lunch Money?
Yes: Actual Budget (A transparent ledger with imports, rules and reports on your own disk; automatic bank sync is optional.) Firefly III (A meticulous imported ledger with rules and reports in Docker; powerful, private and willing to teach accounting words.) Wealthfolio (Transactions, rules, spending reports and net worth on your device; automatic account sync is paid.) All 5 curated free alternatives are at vibecodeit.com/lunch-money/alternatives. The prompt is for when you want it exactly your way.