Vibecode Auritrack
track this build4 phases, 12 steps, beginner friendly0%The core loop, logging expenses by typing a sentence into a chat box and having an LLM extract amount, payee, and category, is very buildable with an Anthropic API key. Budgets, categories, and spending reports are standard CRUD. The real gap is statement import: getting one clean CSV parsed by an LLM is a demo, but reliably parsing messy multi-page bank PDFs across many banks needs batching, fallbacks, and cross-checks that take far longer than a weekend.
You are building a lean indie version of Auritrack. 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 ===== # Auritrack · indie build A personal ledger whose hard problems are representing money so totals never drift, turning a loose sentence or a messy statement into rows without letting a model commit anything on its own, and keeping a correction trail that explains where every figure came from. Estimated effort: **weekend**. 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 | Node 22 with Express, server-rendered pages, bound to the loopback address | your spending history should not be listening on a network interface | | Storage | SQLite with amounts stored as integer minor units and a currency column beside each | the representation decision is the one that cannot be changed later without a migration and a reconciliation | | Extraction | One model call per input with a strict schema, producing a proposal and never a row | a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other | | Import | A staging table with per-row confidence, reviewed and committed as one batch | half-committed imports are the worst state a ledger can be in | | Reporting | Arithmetic in minor units throughout, formatted only at the last moment | every rounding bug in bookkeeping comes from converting early | ## 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 local application. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **The published currency list with minor unit exponents** · free - Why: The exponent per currency is the fact the whole money representation rests on, and guessing it is how a schema ends up wrong for the dinar and the yen at the same time. - Get it: Download the current published list and seed the currencies table from it, recording the publication date. - Verify: The seeded table gives the yen an exponent of zero and the Kuwaiti dinar an exponent of three - [ ] **An API key for a model that returns structured output** · pay per call; a statement import is the expensive operation and the README should say roughly how much - Why: Extraction from a sentence and from statement rows both need validated JSON, and this is the only part that costs money. - Get it: Create a key and set a provider-side spend limit. - Verify: One sentence produces a proposal that validates against the schema - [ ] **Two real statements from different banks, with a text layer** · free - Why: Every import assumption you make will be wrong for the second bank, and finding that out on day one is much cheaper than finding out in April. - Get it: Download PDF or CSV statements from two accounts you hold. - Verify: Both stage rows with page and line indices recorded - [ ] **Your base currency and how foreign amounts are handled** · free - Why: Whether foreign transactions are converted at all, and with which stored rate, changes the schema and every report, and deciding it late means recomputing history. - Get it: Write down the base currency and the rate source, and put both in configuration. - Verify: A foreign transaction without a stored rate is refused rather than estimated ## 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: - Bank connections and automatic feeds. - Scanned statements with no text layer. - Investment, tax and multi-entity accounting. - Automatic categorisation that overwrites your corrections. - battle-tested statement parsing across many banks and layouts - mobile apps and push notifications - Telegram bot logging - predictive spending forecasts - polished AI-written financial reports If one of those is essential to you, that is the reason to keep paying for Auritrack, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Auritrack 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 local expense ledger that accepts a typed sentence or a statement file and never records a number a person has not confirmed to replace Auritrack. 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. ### Rule zero Two rules about money. Both are cheap now and expensive in a year. Money is an integer count of minor units plus a currency code. Never a floating point number, never a bare decimal string. Binary floating point cannot represent a tenth exactly, so a ledger built on it drifts, and the drift shows up as a total that is a penny out and an afternoon nobody gets back. The number of minor units is also a property of the currency rather than a constant: the published ISO 4217 currency list, in the edition dated 1 January 2026, gives the yen, the Icelandic krona and the Vietnamese dong an exponent of zero, the Kuwaiti and Bahraini dinars an exponent of three, and the Chilean Unidad de Fomento an exponent of four. A schema that assumes two decimal places is wrong for real currencies the first time somebody travels. Second, the model never writes a row. It proposes, a person confirms, and both the proposal and the correction are kept. This is a bookkeeping tool, and the distinction between a suggestion and a record is the entire product. An import that quietly commits sixty transactions because the confidence looked high is how a year of accounts becomes fiction, and you will not notice until you are reconciling against a bank statement in April. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages, bound to the loopback address · your spending history should not be listening on a network interface - Storage: SQLite with amounts stored as integer minor units and a currency column beside each · the representation decision is the one that cannot be changed later without a migration and a reconciliation - Extraction: One model call per input with a strict schema, producing a proposal and never a row · a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other - Import: A staging table with per-row confidence, reviewed and committed as one batch · half-committed imports are the worst state a ledger can be in - Reporting: Arithmetic in minor units throughout, formatted only at the last moment · every rounding bug in bookkeeping comes from converting early ### Data model (create this before Phase 1) - currencies(code, exponent, symbol, source_note) · seeded from the published list, with the exponent stored rather than assumed - transactions(id, occurred_on, amount_minor, currency, payee, category_id, note, source_kind, source_ref, created_at) · amount_minor is an integer and there is no decimal column anywhere - proposals(id, source_kind, raw_input_excerpt, model_id, prompt_version, extracted_json, confidence, created_at, resolved_at, resolution) · what the model said, kept whether or not it was accepted - corrections(id, proposal_id, field, model_value, user_value, at) · the only record of how wrong the extraction usually is - import_batches(id, filename, file_checksum, page_count, rows_staged, rows_committed, state, created_at) · a batch is committed whole or not at all - staged_rows(id, batch_id, page, line_index, parsed_json, confidence, fingerprint, duplicate_of, decision) - budgets(id, category_id, month, limit_minor, currency) · in minor units, like everything else - rates(from_currency, to_currency, rate_ppm, as_of_date, source) · stored per conversion, so last month's report does not change when the market does ### Phase 1 · A ledger that cannot drift Build: Seed the currencies table from the published list including each currency's exponent. Parse user input into minor units at the boundary and reject anything with more fractional digits than the currency allows. Do all arithmetic in integers. Format for display from the exponent, never from a hard-coded two. Add manual entry forms that work with no model configured at all. Trap: Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. Done when: A sum of ten thousand random amounts matches an independently computed integer total exactly, tested. An amount entered with three decimal places is accepted for a currency whose exponent is three and rejected for one whose exponent is two, tested with both. Display formatting reads the exponent from the currencies table, tested with a zero-exponent and a three-exponent currency. Manual entry works end to end with no model key configured at all, tested. Do not build yet: No extraction, no import, no budgets or reports yet. ### Phase 2 · A sentence becomes a proposal, not a row Build: Send the sentence to the model with a strict schema asking for amount, currency, payee, category and date, plus a confidence and a list of fields it was unsure about. Resolve relative dates against the user's configured zone and today's date in that zone, both sent explicitly. Render the proposal for confirmation with every uncertain field highlighted. Record corrections when the user changes something. Trap: Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. Done when: Relative dates resolve against the configured zone and a supplied current date, tested by running the same sentence with the process clock set to a different zone and getting the same answer. A proposal with an unparseable amount produces a question rather than a row, tested. Every field the user changes on the confirmation screen is written as a correction with both values, tested. No path exists from a model response to a transactions row that does not pass through a confirmation, verified by review and by a test. Do not build yet: No statement import, no budgets, no question box yet. ### Phase 3 · Statement import that commits whole or not at all Build: Checksum the file, stage every parsed row with its page and line index, and give each a fingerprint derived from the file, the page and the line rather than from its contents alone. Flag likely duplicates against existing transactions without deleting anything. Commit the whole batch in one transaction after review, and refuse a batch containing any unresolved low-confidence row. Trap: Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. Done when: Two identical purchases on the same day both survive an import, tested with a crafted statement. A suspected duplicate is flagged for a decision and is never dropped automatically, tested. Importing the same file twice detects it by checksum and offers to review rather than reimporting blindly, tested. A batch containing an unresolved low-confidence row cannot be committed, and a failure partway through commits nothing, tested by injecting an error mid-transaction. Do not build yet: No budgets, no reports, no question box yet. ### Phase 4 · Budgets and reports that do not move Build: Compute budgets and category totals in minor units per currency, and never sum across currencies without a stored rate. When a conversion is needed, store the rate, its date and its source on the transaction so the historical figure is fixed. Charts read the same aggregation functions the tables do, and every figure carries its currency. Trap: Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. Done when: A report for a past month produces identical figures when regenerated after rates change, tested by moving the rate table. Mixed-currency totals are refused without a stored rate and the interface says which transactions are missing one, tested. Budget progress is computed in minor units and the boundary case of exactly meeting a limit is tested. Every displayed figure carries its currency and no figure is shown as a bare number. Do not build yet: No question box, no backups, no export yet. ### Phase 5 · Asking questions safely, and getting everything out Build: Let the model write a query, but run it on a read-only connection with a statement timeout and a row limit, show the query before the answer, and refuse anything that is not a single select. Copy the database nightly with a retention count, and restore into a scratch file on a schedule. Export every table as CSV with a manifest recording the currency exponents in force. Trap: A question box that runs model-written SQL on the same connection the application writes with is one confidently generated delete away from an empty ledger, and the backup you never restored will not save you. Open a separate read-only connection, cap the statement time and the row count, reject anything that is not a single select statement, and show the query to the user before the answer so a wrong answer is visible as a wrong query. Done when: A generated statement that is not a single select is refused before execution, tested with a delete, an attach and a multi-statement string. The read-only connection cannot write, verified by attempting an insert in a test. A restore from last night's backup into a scratch file reproduces the transaction count and the sum of every category, exercised on a schedule. Export produces every table as CSV with the currency exponents recorded, and reimports into an empty database with identical totals. Do not build yet: Nothing after this. ### Out of scope (and why) - Bank connections and automatic feeds, which need a licensed aggregator or screen scraping of a login, and neither belongs in a personal weekend build. - Scanned statements with no text layer, because that is an optical recognition pipeline with its own error rates and it would need the same correction machinery all over again. - Investment, tax and multi-entity accounting, which are separate domains with their own rules and are not a larger version of this. - Automatic categorisation that overwrites a category you set, because the one thing a ledger owes you is that your corrections stay corrected. ### README must contain - That amounts are integer minor units with the currency's exponent read from a table, and why two decimal places is not a safe assumption - That the model produces proposals and a person creates rows, and where the correction trail lives - That imports commit as one batch and that suspected duplicates are flagged rather than dropped - That historical conversions use a stored rate and date, so past reports do not move - Roughly what an import costs in model calls, and how to run the whole ledger with no model configured ===== AGENTS.md ===== # Agent instructions · Auritrack indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express, server-rendered pages, bound to the loopback address, SQLite with amounts stored as integer minor units and a currency column beside each, One model call per input with a strict schema, producing a proposal and never a row, A staging table with per-row confidence, reviewed and committed as one batch, Arithmetic in minor units throughout, formatted only at the last moment. 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 amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. ===== BUILD_PLAN.md ===== # Build plan · Auritrack A personal ledger whose hard problems are representing money so totals never drift, turning a loose sentence or a messy statement into rows without letting a model commit anything on its own, and keeping a correction trail that explains where every figure came from. 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 cannot drift Every amount is an integer count of minor units in a named currency, and the arithmetic is tested at the awkward cases. ### Steps 1. Seed currencies with their exponents Two decimal places is an assumption, not a fact. Files: `migrations/001_currencies.sql`, `src/money/currencies.ts` 2. Parse to integers at the boundary, format at the edge Nothing between those two points sees a decimal. Files: `src/money/parse.ts`, `tests/money.test.ts` 3. Make manual entry work without any model The ledger has to be usable when the key expires. Files: `src/app/transactions/new.tsx` ### Done when - [ ] A large integer sum matches an independent total - [ ] Fractional digits are validated against the exponent - [ ] Formatting reads the exponent from the table - [ ] Manual entry works with no model configured ### Watch out - Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. ## Phase 2 · A sentence becomes a proposal, not a row Typed input produces something you confirm, with ambiguity surfaced as a question rather than resolved by guessing. ### Steps 1. Send today's date and the zone explicitly Never let a model infer what day it is. Files: `src/extract/prompt.ts`, `tests/relative-dates.test.ts` 2. Surface uncertainty instead of resolving it The schema asks which fields it was unsure about. Files: `src/extract/schema.ts`, `src/app/confirm.tsx` 3. Record every correction with both values It is the only measurement of how wrong extraction is for you. Files: `src/extract/corrections.ts` ### Done when - [ ] Relative dates are zone-stable across process clocks - [ ] An unparseable amount produces a question - [ ] Corrections store model and user values - [ ] No row is created without confirmation ### Watch out - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. ## Phase 3 · Statement import that commits whole or not at all A statement becomes a reviewed batch with per-row confidence, and a partial commit is impossible. ### Steps 1. Fingerprint on file, page and line Contents alone cannot tell two real coffees apart. Files: `src/import/fingerprint.ts`, `tests/duplicate-coffees.test.ts` 2. Flag duplicates, never drop them A silently discarded row is undiscoverable later. Files: `src/import/duplicates.ts` 3. Commit the batch in one transaction Half an import is the worst state this ledger can reach. Files: `src/import/commit.ts`, `tests/atomic-import.test.ts` ### Done when - [ ] Two identical same-day purchases both survive - [ ] Suspected duplicates are flagged, not dropped - [ ] A repeated file is detected by checksum - [ ] A failed commit leaves nothing behind ### Watch out - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. ## Phase 4 · Budgets and reports that do not move A report of last month shows the same numbers next year, including across currencies. ### Steps 1. Store the rate and its date on the transaction A historical figure should never move. Files: `src/money/convert.ts`, `tests/stable-history.test.ts` 2. Refuse a mixed total without a rate Declining to show a number beats inventing one. Files: `src/reports/aggregate.ts` 3. Make charts and tables share one aggregation Two implementations means two answers and one argument. Files: `src/reports/aggregate.ts`, `src/app/reports/page.tsx` ### Done when - [ ] A past month's report is stable across rate changes - [ ] Mixed totals without a rate are refused - [ ] Budget boundary cases are tested - [ ] Every figure carries its currency ### Watch out - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. ## Not in this build - Bank connections and automatic feeds. - Scanned statements with no text layer. - Investment, tax and multi-entity accounting. - Automatic categorisation that overwrites your corrections. ## After v1, if you want it - A monthly accuracy report comparing model proposals against your corrections, which is the only honest measure of whether extraction is working - Per-bank import profiles learned from the staging table, which removes most of the model cost from routine imports - A reconciliation view against a statement balance, which is the check that catches everything else ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The whole ledger. This is the backup target and the only copy. DATABASE_PATH=./data/ledger.db # Required. Reports total in this. Foreign amounts need a stored rate. BASE_CURRENCY=GBP # Required. An IANA zone name. Relative dates resolve against this and today's date in it. USER_TIMEZONE=Europe/London # Optional · secret. Extraction only. The ledger works fully without it through manual entry. MODEL_API_KEY=sk-...redacted # Optional. Extraction stops here; manual entry and reports continue. MODEL_MONTHLY_CEILING=5.00 # Required. Staged rows below this must be resolved by hand before a batch can commit. IMPORT_CONFIDENCE_FLOOR=0.7 # Required. Nightly copies with a retention count. Restored on a schedule, not on faith. BACKUP_DIR=./backups
You are building a lean indie version of Auritrack. 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 ===== # Auritrack · indie build A personal ledger whose hard problems are representing money so totals never drift, turning a loose sentence or a messy statement into rows without letting a model commit anything on its own, and keeping a correction trail that explains where every figure came from. Estimated effort: **weekend**. 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 | Node 22 with Express, server-rendered pages, bound to the loopback address | your spending history should not be listening on a network interface | | Storage | SQLite with amounts stored as integer minor units and a currency column beside each | the representation decision is the one that cannot be changed later without a migration and a reconciliation | | Extraction | One model call per input with a strict schema, producing a proposal and never a row | a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other | | Import | A staging table with per-row confidence, reviewed and committed as one batch | half-committed imports are the worst state a ledger can be in | | Reporting | Arithmetic in minor units throughout, formatted only at the last moment | every rounding bug in bookkeeping comes from converting early | ## 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 local application. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **The published currency list with minor unit exponents** · free - Why: The exponent per currency is the fact the whole money representation rests on, and guessing it is how a schema ends up wrong for the dinar and the yen at the same time. - Get it: Download the current published list and seed the currencies table from it, recording the publication date. - Verify: The seeded table gives the yen an exponent of zero and the Kuwaiti dinar an exponent of three - [ ] **An API key for a model that returns structured output** · pay per call; a statement import is the expensive operation and the README should say roughly how much - Why: Extraction from a sentence and from statement rows both need validated JSON, and this is the only part that costs money. - Get it: Create a key and set a provider-side spend limit. - Verify: One sentence produces a proposal that validates against the schema - [ ] **Two real statements from different banks, with a text layer** · free - Why: Every import assumption you make will be wrong for the second bank, and finding that out on day one is much cheaper than finding out in April. - Get it: Download PDF or CSV statements from two accounts you hold. - Verify: Both stage rows with page and line indices recorded - [ ] **Your base currency and how foreign amounts are handled** · free - Why: Whether foreign transactions are converted at all, and with which stored rate, changes the schema and every report, and deciding it late means recomputing history. - Get it: Write down the base currency and the rate source, and put both in configuration. - Verify: A foreign transaction without a stored rate is refused rather than estimated ## 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: - Bank connections and automatic feeds. - Scanned statements with no text layer. - Investment, tax and multi-entity accounting. - Automatic categorisation that overwrites your corrections. - battle-tested statement parsing across many banks and layouts - mobile apps and push notifications - Telegram bot logging - predictive spending forecasts - polished AI-written financial reports If one of those is essential to you, that is the reason to keep paying for Auritrack, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Auritrack 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 local expense ledger that accepts a typed sentence or a statement file and never records a number a person has not confirmed to replace Auritrack. 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. ### Rule zero Two rules about money. Both are cheap now and expensive in a year. Money is an integer count of minor units plus a currency code. Never a floating point number, never a bare decimal string. Binary floating point cannot represent a tenth exactly, so a ledger built on it drifts, and the drift shows up as a total that is a penny out and an afternoon nobody gets back. The number of minor units is also a property of the currency rather than a constant: the published ISO 4217 currency list, in the edition dated 1 January 2026, gives the yen, the Icelandic krona and the Vietnamese dong an exponent of zero, the Kuwaiti and Bahraini dinars an exponent of three, and the Chilean Unidad de Fomento an exponent of four. A schema that assumes two decimal places is wrong for real currencies the first time somebody travels. Second, the model never writes a row. It proposes, a person confirms, and both the proposal and the correction are kept. This is a bookkeeping tool, and the distinction between a suggestion and a record is the entire product. An import that quietly commits sixty transactions because the confidence looked high is how a year of accounts becomes fiction, and you will not notice until you are reconciling against a bank statement in April. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages, bound to the loopback address · your spending history should not be listening on a network interface - Storage: SQLite with amounts stored as integer minor units and a currency column beside each · the representation decision is the one that cannot be changed later without a migration and a reconciliation - Extraction: One model call per input with a strict schema, producing a proposal and never a row · a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other - Import: A staging table with per-row confidence, reviewed and committed as one batch · half-committed imports are the worst state a ledger can be in - Reporting: Arithmetic in minor units throughout, formatted only at the last moment · every rounding bug in bookkeeping comes from converting early ### Data model (create this before Phase 1) - currencies(code, exponent, symbol, source_note) · seeded from the published list, with the exponent stored rather than assumed - transactions(id, occurred_on, amount_minor, currency, payee, category_id, note, source_kind, source_ref, created_at) · amount_minor is an integer and there is no decimal column anywhere - proposals(id, source_kind, raw_input_excerpt, model_id, prompt_version, extracted_json, confidence, created_at, resolved_at, resolution) · what the model said, kept whether or not it was accepted - corrections(id, proposal_id, field, model_value, user_value, at) · the only record of how wrong the extraction usually is - import_batches(id, filename, file_checksum, page_count, rows_staged, rows_committed, state, created_at) · a batch is committed whole or not at all - staged_rows(id, batch_id, page, line_index, parsed_json, confidence, fingerprint, duplicate_of, decision) - budgets(id, category_id, month, limit_minor, currency) · in minor units, like everything else - rates(from_currency, to_currency, rate_ppm, as_of_date, source) · stored per conversion, so last month's report does not change when the market does ### Phase 1 · A ledger that cannot drift Build: Seed the currencies table from the published list including each currency's exponent. Parse user input into minor units at the boundary and reject anything with more fractional digits than the currency allows. Do all arithmetic in integers. Format for display from the exponent, never from a hard-coded two. Add manual entry forms that work with no model configured at all. Trap: Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. Done when: A sum of ten thousand random amounts matches an independently computed integer total exactly, tested. An amount entered with three decimal places is accepted for a currency whose exponent is three and rejected for one whose exponent is two, tested with both. Display formatting reads the exponent from the currencies table, tested with a zero-exponent and a three-exponent currency. Manual entry works end to end with no model key configured at all, tested. Do not build yet: No extraction, no import, no budgets or reports yet. ### Phase 2 · A sentence becomes a proposal, not a row Build: Send the sentence to the model with a strict schema asking for amount, currency, payee, category and date, plus a confidence and a list of fields it was unsure about. Resolve relative dates against the user's configured zone and today's date in that zone, both sent explicitly. Render the proposal for confirmation with every uncertain field highlighted. Record corrections when the user changes something. Trap: Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. Done when: Relative dates resolve against the configured zone and a supplied current date, tested by running the same sentence with the process clock set to a different zone and getting the same answer. A proposal with an unparseable amount produces a question rather than a row, tested. Every field the user changes on the confirmation screen is written as a correction with both values, tested. No path exists from a model response to a transactions row that does not pass through a confirmation, verified by review and by a test. Do not build yet: No statement import, no budgets, no question box yet. ### Phase 3 · Statement import that commits whole or not at all Build: Checksum the file, stage every parsed row with its page and line index, and give each a fingerprint derived from the file, the page and the line rather than from its contents alone. Flag likely duplicates against existing transactions without deleting anything. Commit the whole batch in one transaction after review, and refuse a batch containing any unresolved low-confidence row. Trap: Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. Done when: Two identical purchases on the same day both survive an import, tested with a crafted statement. A suspected duplicate is flagged for a decision and is never dropped automatically, tested. Importing the same file twice detects it by checksum and offers to review rather than reimporting blindly, tested. A batch containing an unresolved low-confidence row cannot be committed, and a failure partway through commits nothing, tested by injecting an error mid-transaction. Do not build yet: No budgets, no reports, no question box yet. ### Phase 4 · Budgets and reports that do not move Build: Compute budgets and category totals in minor units per currency, and never sum across currencies without a stored rate. When a conversion is needed, store the rate, its date and its source on the transaction so the historical figure is fixed. Charts read the same aggregation functions the tables do, and every figure carries its currency. Trap: Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. Done when: A report for a past month produces identical figures when regenerated after rates change, tested by moving the rate table. Mixed-currency totals are refused without a stored rate and the interface says which transactions are missing one, tested. Budget progress is computed in minor units and the boundary case of exactly meeting a limit is tested. Every displayed figure carries its currency and no figure is shown as a bare number. Do not build yet: No question box, no backups, no export yet. ### Phase 5 · Asking questions safely, and getting everything out Build: Let the model write a query, but run it on a read-only connection with a statement timeout and a row limit, show the query before the answer, and refuse anything that is not a single select. Copy the database nightly with a retention count, and restore into a scratch file on a schedule. Export every table as CSV with a manifest recording the currency exponents in force. Trap: A question box that runs model-written SQL on the same connection the application writes with is one confidently generated delete away from an empty ledger, and the backup you never restored will not save you. Open a separate read-only connection, cap the statement time and the row count, reject anything that is not a single select statement, and show the query to the user before the answer so a wrong answer is visible as a wrong query. Done when: A generated statement that is not a single select is refused before execution, tested with a delete, an attach and a multi-statement string. The read-only connection cannot write, verified by attempting an insert in a test. A restore from last night's backup into a scratch file reproduces the transaction count and the sum of every category, exercised on a schedule. Export produces every table as CSV with the currency exponents recorded, and reimports into an empty database with identical totals. Do not build yet: Nothing after this. ### Out of scope (and why) - Bank connections and automatic feeds, which need a licensed aggregator or screen scraping of a login, and neither belongs in a personal weekend build. - Scanned statements with no text layer, because that is an optical recognition pipeline with its own error rates and it would need the same correction machinery all over again. - Investment, tax and multi-entity accounting, which are separate domains with their own rules and are not a larger version of this. - Automatic categorisation that overwrites a category you set, because the one thing a ledger owes you is that your corrections stay corrected. ### README must contain - That amounts are integer minor units with the currency's exponent read from a table, and why two decimal places is not a safe assumption - That the model produces proposals and a person creates rows, and where the correction trail lives - That imports commit as one batch and that suspected duplicates are flagged rather than dropped - That historical conversions use a stored rate and date, so past reports do not move - Roughly what an import costs in model calls, and how to run the whole ledger with no model configured ===== AGENTS.md ===== # Agent instructions · Auritrack indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express, server-rendered pages, bound to the loopback address, SQLite with amounts stored as integer minor units and a currency column beside each, One model call per input with a strict schema, producing a proposal and never a row, A staging table with per-row confidence, reviewed and committed as one batch, Arithmetic in minor units throughout, formatted only at the last moment. 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 amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. ===== BUILD_PLAN.md ===== # Build plan · Auritrack A personal ledger whose hard problems are representing money so totals never drift, turning a loose sentence or a messy statement into rows without letting a model commit anything on its own, and keeping a correction trail that explains where every figure came from. 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 cannot drift Every amount is an integer count of minor units in a named currency, and the arithmetic is tested at the awkward cases. ### Steps 1. Seed currencies with their exponents Two decimal places is an assumption, not a fact. Files: `migrations/001_currencies.sql`, `src/money/currencies.ts` 2. Parse to integers at the boundary, format at the edge Nothing between those two points sees a decimal. Files: `src/money/parse.ts`, `tests/money.test.ts` 3. Make manual entry work without any model The ledger has to be usable when the key expires. Files: `src/app/transactions/new.tsx` ### Done when - [ ] A large integer sum matches an independent total - [ ] Fractional digits are validated against the exponent - [ ] Formatting reads the exponent from the table - [ ] Manual entry works with no model configured ### Watch out - Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. ## Phase 2 · A sentence becomes a proposal, not a row Typed input produces something you confirm, with ambiguity surfaced as a question rather than resolved by guessing. ### Steps 1. Send today's date and the zone explicitly Never let a model infer what day it is. Files: `src/extract/prompt.ts`, `tests/relative-dates.test.ts` 2. Surface uncertainty instead of resolving it The schema asks which fields it was unsure about. Files: `src/extract/schema.ts`, `src/app/confirm.tsx` 3. Record every correction with both values It is the only measurement of how wrong extraction is for you. Files: `src/extract/corrections.ts` ### Done when - [ ] Relative dates are zone-stable across process clocks - [ ] An unparseable amount produces a question - [ ] Corrections store model and user values - [ ] No row is created without confirmation ### Watch out - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. ## Phase 3 · Statement import that commits whole or not at all A statement becomes a reviewed batch with per-row confidence, and a partial commit is impossible. ### Steps 1. Fingerprint on file, page and line Contents alone cannot tell two real coffees apart. Files: `src/import/fingerprint.ts`, `tests/duplicate-coffees.test.ts` 2. Flag duplicates, never drop them A silently discarded row is undiscoverable later. Files: `src/import/duplicates.ts` 3. Commit the batch in one transaction Half an import is the worst state this ledger can reach. Files: `src/import/commit.ts`, `tests/atomic-import.test.ts` ### Done when - [ ] Two identical same-day purchases both survive - [ ] Suspected duplicates are flagged, not dropped - [ ] A repeated file is detected by checksum - [ ] A failed commit leaves nothing behind ### Watch out - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. ## Phase 4 · Budgets and reports that do not move A report of last month shows the same numbers next year, including across currencies. ### Steps 1. Store the rate and its date on the transaction A historical figure should never move. Files: `src/money/convert.ts`, `tests/stable-history.test.ts` 2. Refuse a mixed total without a rate Declining to show a number beats inventing one. Files: `src/reports/aggregate.ts` 3. Make charts and tables share one aggregation Two implementations means two answers and one argument. Files: `src/reports/aggregate.ts`, `src/app/reports/page.tsx` ### Done when - [ ] A past month's report is stable across rate changes - [ ] Mixed totals without a rate are refused - [ ] Budget boundary cases are tested - [ ] Every figure carries its currency ### Watch out - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. ## Not in this build - Bank connections and automatic feeds. - Scanned statements with no text layer. - Investment, tax and multi-entity accounting. - Automatic categorisation that overwrites your corrections. ## After v1, if you want it - A monthly accuracy report comparing model proposals against your corrections, which is the only honest measure of whether extraction is working - Per-bank import profiles learned from the staging table, which removes most of the model cost from routine imports - A reconciliation view against a statement balance, which is the check that catches everything else ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The whole ledger. This is the backup target and the only copy. DATABASE_PATH=./data/ledger.db # Required. Reports total in this. Foreign amounts need a stored rate. BASE_CURRENCY=GBP # Required. An IANA zone name. Relative dates resolve against this and today's date in it. USER_TIMEZONE=Europe/London # Optional · secret. Extraction only. The ledger works fully without it through manual entry. MODEL_API_KEY=sk-...redacted # Optional. Extraction stops here; manual entry and reports continue. MODEL_MONTHLY_CEILING=5.00 # Required. Staged rows below this must be resolved by hand before a batch can commit. IMPORT_CONFIDENCE_FLOOR=0.7 # Required. Nightly copies with a retention count. Restored on a schedule, not on faith. BACKUP_DIR=./backups
You are building a production product version of Auritrack. 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 ===== # Auritrack · product brief ## Problem The core loop, logging expenses by typing a sentence into a chat box and having an LLM extract amount, payee, and category, is very buildable with an Anthropic API key. Budgets, categories, and spending reports are standard CRUD. The real gap is statement import: getting one clean CSV parsed by an LLM is a demo, but reliably parsing messy multi-page bank PDFs across many banks needs batching, fallbacks, and cross-checks that take far longer than a weekend. ## Product outcome A ledger whose totals do not drift because every amount is an integer in a currency whose exponent is stored, whose rows are only ever created by a person confirming a proposal, whose imports commit whole, and whose past reports stay the same when exchange rates move. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Anthropic API key - local or hosted database - CSV import - PDF text extraction - charting library ## Explicit non-goals for v1 - Bank connections and automatic feeds. - Scanned statements with no text layer. - Investment, tax and multi-entity accounting. - Automatic categorisation that overwrites your corrections. - battle-tested statement parsing across many banks and layouts - mobile apps and push notifications - Telegram bot logging - predictive spending forecasts - polished AI-written financial reports ## Success criteria - A large integer sum is confirmed to match an independent total - No path is confirmed to create a row without confirmation - Two identical same-day purchases are confirmed to survive an import - A past month's report is confirmed stable across rate changes - Anything but a single select is confirmed refused in the question box ===== BRIEF.md ===== # Build brief · Auritrack 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 local expense ledger that accepts a typed sentence or a statement file and never records a number a person has not confirmed to replace Auritrack. 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. ### Rule zero Two rules about money. Both are cheap now and expensive in a year. Money is an integer count of minor units plus a currency code. Never a floating point number, never a bare decimal string. Binary floating point cannot represent a tenth exactly, so a ledger built on it drifts, and the drift shows up as a total that is a penny out and an afternoon nobody gets back. The number of minor units is also a property of the currency rather than a constant: the published ISO 4217 currency list, in the edition dated 1 January 2026, gives the yen, the Icelandic krona and the Vietnamese dong an exponent of zero, the Kuwaiti and Bahraini dinars an exponent of three, and the Chilean Unidad de Fomento an exponent of four. A schema that assumes two decimal places is wrong for real currencies the first time somebody travels. Second, the model never writes a row. It proposes, a person confirms, and both the proposal and the correction are kept. This is a bookkeeping tool, and the distinction between a suggestion and a record is the entire product. An import that quietly commits sixty transactions because the confidence looked high is how a year of accounts becomes fiction, and you will not notice until you are reconciling against a bank statement in April. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages, bound to the loopback address · your spending history should not be listening on a network interface - Storage: SQLite with amounts stored as integer minor units and a currency column beside each · the representation decision is the one that cannot be changed later without a migration and a reconciliation - Extraction: One model call per input with a strict schema, producing a proposal and never a row · a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other - Import: A staging table with per-row confidence, reviewed and committed as one batch · half-committed imports are the worst state a ledger can be in - Reporting: Arithmetic in minor units throughout, formatted only at the last moment · every rounding bug in bookkeeping comes from converting early ### Data model (create this before Phase 1) - currencies(code, exponent, symbol, source_note) · seeded from the published list, with the exponent stored rather than assumed - transactions(id, occurred_on, amount_minor, currency, payee, category_id, note, source_kind, source_ref, created_at) · amount_minor is an integer and there is no decimal column anywhere - proposals(id, source_kind, raw_input_excerpt, model_id, prompt_version, extracted_json, confidence, created_at, resolved_at, resolution) · what the model said, kept whether or not it was accepted - corrections(id, proposal_id, field, model_value, user_value, at) · the only record of how wrong the extraction usually is - import_batches(id, filename, file_checksum, page_count, rows_staged, rows_committed, state, created_at) · a batch is committed whole or not at all - staged_rows(id, batch_id, page, line_index, parsed_json, confidence, fingerprint, duplicate_of, decision) - budgets(id, category_id, month, limit_minor, currency) · in minor units, like everything else - rates(from_currency, to_currency, rate_ppm, as_of_date, source) · stored per conversion, so last month's report does not change when the market does ### Phase 1 · A ledger that cannot drift Build: Seed the currencies table from the published list including each currency's exponent. Parse user input into minor units at the boundary and reject anything with more fractional digits than the currency allows. Do all arithmetic in integers. Format for display from the exponent, never from a hard-coded two. Add manual entry forms that work with no model configured at all. Trap: Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. Done when: A sum of ten thousand random amounts matches an independently computed integer total exactly, tested. An amount entered with three decimal places is accepted for a currency whose exponent is three and rejected for one whose exponent is two, tested with both. Display formatting reads the exponent from the currencies table, tested with a zero-exponent and a three-exponent currency. Manual entry works end to end with no model key configured at all, tested. Do not build yet: No extraction, no import, no budgets or reports yet. ### Phase 2 · A sentence becomes a proposal, not a row Build: Send the sentence to the model with a strict schema asking for amount, currency, payee, category and date, plus a confidence and a list of fields it was unsure about. Resolve relative dates against the user's configured zone and today's date in that zone, both sent explicitly. Render the proposal for confirmation with every uncertain field highlighted. Record corrections when the user changes something. Trap: Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. Done when: Relative dates resolve against the configured zone and a supplied current date, tested by running the same sentence with the process clock set to a different zone and getting the same answer. A proposal with an unparseable amount produces a question rather than a row, tested. Every field the user changes on the confirmation screen is written as a correction with both values, tested. No path exists from a model response to a transactions row that does not pass through a confirmation, verified by review and by a test. Do not build yet: No statement import, no budgets, no question box yet. ### Phase 3 · Statement import that commits whole or not at all Build: Checksum the file, stage every parsed row with its page and line index, and give each a fingerprint derived from the file, the page and the line rather than from its contents alone. Flag likely duplicates against existing transactions without deleting anything. Commit the whole batch in one transaction after review, and refuse a batch containing any unresolved low-confidence row. Trap: Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. Done when: Two identical purchases on the same day both survive an import, tested with a crafted statement. A suspected duplicate is flagged for a decision and is never dropped automatically, tested. Importing the same file twice detects it by checksum and offers to review rather than reimporting blindly, tested. A batch containing an unresolved low-confidence row cannot be committed, and a failure partway through commits nothing, tested by injecting an error mid-transaction. Do not build yet: No budgets, no reports, no question box yet. ### Phase 4 · Budgets and reports that do not move Build: Compute budgets and category totals in minor units per currency, and never sum across currencies without a stored rate. When a conversion is needed, store the rate, its date and its source on the transaction so the historical figure is fixed. Charts read the same aggregation functions the tables do, and every figure carries its currency. Trap: Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. Done when: A report for a past month produces identical figures when regenerated after rates change, tested by moving the rate table. Mixed-currency totals are refused without a stored rate and the interface says which transactions are missing one, tested. Budget progress is computed in minor units and the boundary case of exactly meeting a limit is tested. Every displayed figure carries its currency and no figure is shown as a bare number. Do not build yet: No question box, no backups, no export yet. ### Phase 5 · Asking questions safely, and getting everything out Build: Let the model write a query, but run it on a read-only connection with a statement timeout and a row limit, show the query before the answer, and refuse anything that is not a single select. Copy the database nightly with a retention count, and restore into a scratch file on a schedule. Export every table as CSV with a manifest recording the currency exponents in force. Trap: A question box that runs model-written SQL on the same connection the application writes with is one confidently generated delete away from an empty ledger, and the backup you never restored will not save you. Open a separate read-only connection, cap the statement time and the row count, reject anything that is not a single select statement, and show the query to the user before the answer so a wrong answer is visible as a wrong query. Done when: A generated statement that is not a single select is refused before execution, tested with a delete, an attach and a multi-statement string. The read-only connection cannot write, verified by attempting an insert in a test. A restore from last night's backup into a scratch file reproduces the transaction count and the sum of every category, exercised on a schedule. Export produces every table as CSV with the currency exponents recorded, and reimports into an empty database with identical totals. Do not build yet: Nothing after this. ### Out of scope (and why) - Bank connections and automatic feeds, which need a licensed aggregator or screen scraping of a login, and neither belongs in a personal weekend build. - Scanned statements with no text layer, because that is an optical recognition pipeline with its own error rates and it would need the same correction machinery all over again. - Investment, tax and multi-entity accounting, which are separate domains with their own rules and are not a larger version of this. - Automatic categorisation that overwrites a category you set, because the one thing a ledger owes you is that your corrections stay corrected. ### README must contain - That amounts are integer minor units with the currency's exponent read from a table, and why two decimal places is not a safe assumption - That the model produces proposals and a person creates rows, and where the correction trail lives - That imports commit as one batch and that suspected duplicates are flagged rather than dropped - That historical conversions use a stored rate and date, so past reports do not move - Roughly what an import costs in model calls, and how to run the whole ledger with no model configured ===== ARCHITECTURE.md ===== # Architecture · Auritrack ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express, server-rendered pages, bound to the loopback address | your spending history should not be listening on a network interface | | Storage | SQLite with amounts stored as integer minor units and a currency column beside each | the representation decision is the one that cannot be changed later without a migration and a reconciliation | | Extraction | One model call per input with a strict schema, producing a proposal and never a row | a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other | | Import | A staging table with per-row confidence, reviewed and committed as one batch | half-committed imports are the worst state a ledger can be in | | Reporting | Arithmetic in minor units throughout, formatted only at the last moment | every rounding bug in bookkeeping comes from converting early | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Money | Minor units, exponents, parsing and formatting. | Not swappable for a decimal type in the application layer, which is the bug this module exists to prevent. | | Extraction | Proposals, schemas, confidence and corrections. | Any model, provided a proposal can never become a row without confirmation. | | Import | Staging, fingerprints, duplicate flags, atomic commit. | Any parser, provided fingerprints include file, page and line. | | Reports | Aggregation in minor units with stored rates. | Any presentation, provided charts and tables share one aggregation. | | Ask | Read-only generated queries with limits. | Any implementation, provided it is a separate read-only connection. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · The whole ledger. This is the backup target and the only copy. - `BASE_CURRENCY` · required · Reports total in this. Foreign amounts need a stored rate. - `USER_TIMEZONE` · required · An IANA zone name. Relative dates resolve against this and today's date in it. - `MODEL_API_KEY` · optional, secret · Extraction only. The ledger works fully without it through manual entry. - `MODEL_MONTHLY_CEILING` · optional · Extraction stops here; manual entry and reports continue. - `IMPORT_CONFIDENCE_FLOOR` · required · Staged rows below this must be resolved by hand before a batch can commit. - `BACKUP_DIR` · required · Nightly copies with a retention count. Restored on a schedule, not on faith. ## 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 · Auritrack product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express, server-rendered pages, bound to the loopback address, SQLite with amounts stored as integer minor units and a currency column beside each, One model call per input with a strict schema, producing a proposal and never a row, A staging table with per-row confidence, reviewed and committed as one batch, Arithmetic in minor units throughout, formatted only at the last moment. - 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 amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. - A question box that runs model-written SQL on the same connection the application writes with is one confidently generated delete away from an empty ledger, and the backup you never restored will not save you. Open a separate read-only connection, cap the statement time and the row count, reject anything that is not a single select statement, and show the query to the user before the answer so a wrong answer is visible as a wrong query. ===== MILESTONES.md ===== # Delivery milestones · Auritrack Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A ledger that cannot drift Every amount is an integer count of minor units in a named currency, and the arithmetic is tested at the awkward cases. ### Steps 1. Seed currencies with their exponents Two decimal places is an assumption, not a fact. Files: `migrations/001_currencies.sql`, `src/money/currencies.ts` 2. Parse to integers at the boundary, format at the edge Nothing between those two points sees a decimal. Files: `src/money/parse.ts`, `tests/money.test.ts` 3. Make manual entry work without any model The ledger has to be usable when the key expires. Files: `src/app/transactions/new.tsx` ### Done when - [ ] A large integer sum matches an independent total - [ ] Fractional digits are validated against the exponent - [ ] Formatting reads the exponent from the table - [ ] Manual entry works with no model configured ### Watch out - Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. ## M2 · A sentence becomes a proposal, not a row Typed input produces something you confirm, with ambiguity surfaced as a question rather than resolved by guessing. ### Steps 1. Send today's date and the zone explicitly Never let a model infer what day it is. Files: `src/extract/prompt.ts`, `tests/relative-dates.test.ts` 2. Surface uncertainty instead of resolving it The schema asks which fields it was unsure about. Files: `src/extract/schema.ts`, `src/app/confirm.tsx` 3. Record every correction with both values It is the only measurement of how wrong extraction is for you. Files: `src/extract/corrections.ts` ### Done when - [ ] Relative dates are zone-stable across process clocks - [ ] An unparseable amount produces a question - [ ] Corrections store model and user values - [ ] No row is created without confirmation ### Watch out - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. ## M3 · Statement import that commits whole or not at all A statement becomes a reviewed batch with per-row confidence, and a partial commit is impossible. ### Steps 1. Fingerprint on file, page and line Contents alone cannot tell two real coffees apart. Files: `src/import/fingerprint.ts`, `tests/duplicate-coffees.test.ts` 2. Flag duplicates, never drop them A silently discarded row is undiscoverable later. Files: `src/import/duplicates.ts` 3. Commit the batch in one transaction Half an import is the worst state this ledger can reach. Files: `src/import/commit.ts`, `tests/atomic-import.test.ts` ### Done when - [ ] Two identical same-day purchases both survive - [ ] Suspected duplicates are flagged, not dropped - [ ] A repeated file is detected by checksum - [ ] A failed commit leaves nothing behind ### Watch out - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. ## M4 · Budgets and reports that do not move A report of last month shows the same numbers next year, including across currencies. ### Steps 1. Store the rate and its date on the transaction A historical figure should never move. Files: `src/money/convert.ts`, `tests/stable-history.test.ts` 2. Refuse a mixed total without a rate Declining to show a number beats inventing one. Files: `src/reports/aggregate.ts` 3. Make charts and tables share one aggregation Two implementations means two answers and one argument. Files: `src/reports/aggregate.ts`, `src/app/reports/page.tsx` ### Done when - [ ] A past month's report is stable across rate changes - [ ] Mixed totals without a rate are refused - [ ] Budget boundary cases are tested - [ ] Every figure carries its currency ### Watch out - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. ## M5 · Asking questions safely, and getting everything out (production only) The question box cannot damage or exfiltrate the ledger, backups are tested, and the whole thing exports. ### Steps 1. Run generated queries read-only with limits A separate connection, a timeout and a row cap. Files: `src/ask/run.ts`, `tests/readonly.test.ts` 2. Show the query before the answer A wrong answer is easier to spot as a wrong query. Files: `src/app/ask/page.tsx` 3. Restore into a scratch file on a schedule A backup nobody restores is a story about a backup. Files: `scripts/restore-check.sh`, `docs/operations.md` ### Done when - [ ] Anything but a single select is refused - [ ] The read-only connection cannot write - [ ] A scheduled restore reproduces counts and totals - [ ] Export and reimport preserve every total ### Watch out - A question box that runs model-written SQL on the same connection the application writes with is one confidently generated delete away from an empty ledger, and the backup you never restored will not save you. Open a separate read-only connection, cap the statement time and the row count, reject anything that is not a single select statement, and show the query to the user before the answer so a wrong answer is visible as a wrong query. ===== OPERATIONS.md ===== # Operations · Auritrack ## Backup The database file nightly with a retention count, plus the exported CSV manifest weekly. The corrections and proposals tables are as valuable as the transactions, because together they are the only explanation of where a figure came from. ## Restore Restore into a scratch file, compare the transaction count and every category total against the last export, then swap. A restore that changes a category total means a rate or an exponent moved and should be investigated before the file is used. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Model spend against the ceiling, the proportion of proposals corrected, the number of staged rows below the confidence floor per import, and whether last night's restore check passed. A rising correction rate on one bank usually means that bank changed its statement layout. ## Incident checklist The incident is a number somebody trusted. proposals, corrections and staged_rows together reconstruct what was suggested, what was accepted and what was changed, which is why they survive the transactions they produced. 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 large integer sum is confirmed to match an independent total - [ ] No path is confirmed to create a row without confirmation - [ ] Two identical same-day purchases are confirmed to survive an import - [ ] A past month's report is confirmed stable across rate changes - [ ] Anything but a single select is confirmed refused in the question box ## Launch constraint Do not market omitted Auritrack 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. The whole ledger. This is the backup target and the only copy. DATABASE_PATH=./data/ledger.db # Required. Reports total in this. Foreign amounts need a stored rate. BASE_CURRENCY=GBP # Required. An IANA zone name. Relative dates resolve against this and today's date in it. USER_TIMEZONE=Europe/London # Optional · secret. Extraction only. The ledger works fully without it through manual entry. MODEL_API_KEY=sk-...redacted # Optional. Extraction stops here; manual entry and reports continue. MODEL_MONTHLY_CEILING=5.00 # Required. Staged rows below this must be resolved by hand before a batch can commit. IMPORT_CONFIDENCE_FLOOR=0.7 # Required. Nightly copies with a retention count. Restored on a schedule, not on faith. BACKUP_DIR=./backups
# Auritrack · indie build A personal ledger whose hard problems are representing money so totals never drift, turning a loose sentence or a messy statement into rows without letting a model commit anything on its own, and keeping a correction trail that explains where every figure came from. Estimated effort: **weekend**. 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 | Node 22 with Express, server-rendered pages, bound to the loopback address | your spending history should not be listening on a network interface | | Storage | SQLite with amounts stored as integer minor units and a currency column beside each | the representation decision is the one that cannot be changed later without a migration and a reconciliation | | Extraction | One model call per input with a strict schema, producing a proposal and never a row | a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other | | Import | A staging table with per-row confidence, reviewed and committed as one batch | half-committed imports are the worst state a ledger can be in | | Reporting | Arithmetic in minor units throughout, formatted only at the last moment | every rounding bug in bookkeeping comes from converting early | ## 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 local application. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **The published currency list with minor unit exponents** · free - Why: The exponent per currency is the fact the whole money representation rests on, and guessing it is how a schema ends up wrong for the dinar and the yen at the same time. - Get it: Download the current published list and seed the currencies table from it, recording the publication date. - Verify: The seeded table gives the yen an exponent of zero and the Kuwaiti dinar an exponent of three - [ ] **An API key for a model that returns structured output** · pay per call; a statement import is the expensive operation and the README should say roughly how much - Why: Extraction from a sentence and from statement rows both need validated JSON, and this is the only part that costs money. - Get it: Create a key and set a provider-side spend limit. - Verify: One sentence produces a proposal that validates against the schema - [ ] **Two real statements from different banks, with a text layer** · free - Why: Every import assumption you make will be wrong for the second bank, and finding that out on day one is much cheaper than finding out in April. - Get it: Download PDF or CSV statements from two accounts you hold. - Verify: Both stage rows with page and line indices recorded - [ ] **Your base currency and how foreign amounts are handled** · free - Why: Whether foreign transactions are converted at all, and with which stored rate, changes the schema and every report, and deciding it late means recomputing history. - Get it: Write down the base currency and the rate source, and put both in configuration. - Verify: A foreign transaction without a stored rate is refused rather than estimated ## 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: - Bank connections and automatic feeds. - Scanned statements with no text layer. - Investment, tax and multi-entity accounting. - Automatic categorisation that overwrites your corrections. - battle-tested statement parsing across many banks and layouts - mobile apps and push notifications - Telegram bot logging - predictive spending forecasts - polished AI-written financial reports If one of those is essential to you, that is the reason to keep paying for Auritrack, and the README should say so rather than pretend.
# Build brief · Auritrack 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 local expense ledger that accepts a typed sentence or a statement file and never records a number a person has not confirmed to replace Auritrack. 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. ### Rule zero Two rules about money. Both are cheap now and expensive in a year. Money is an integer count of minor units plus a currency code. Never a floating point number, never a bare decimal string. Binary floating point cannot represent a tenth exactly, so a ledger built on it drifts, and the drift shows up as a total that is a penny out and an afternoon nobody gets back. The number of minor units is also a property of the currency rather than a constant: the published ISO 4217 currency list, in the edition dated 1 January 2026, gives the yen, the Icelandic krona and the Vietnamese dong an exponent of zero, the Kuwaiti and Bahraini dinars an exponent of three, and the Chilean Unidad de Fomento an exponent of four. A schema that assumes two decimal places is wrong for real currencies the first time somebody travels. Second, the model never writes a row. It proposes, a person confirms, and both the proposal and the correction are kept. This is a bookkeeping tool, and the distinction between a suggestion and a record is the entire product. An import that quietly commits sixty transactions because the confidence looked high is how a year of accounts becomes fiction, and you will not notice until you are reconciling against a bank statement in April. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages, bound to the loopback address · your spending history should not be listening on a network interface - Storage: SQLite with amounts stored as integer minor units and a currency column beside each · the representation decision is the one that cannot be changed later without a migration and a reconciliation - Extraction: One model call per input with a strict schema, producing a proposal and never a row · a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other - Import: A staging table with per-row confidence, reviewed and committed as one batch · half-committed imports are the worst state a ledger can be in - Reporting: Arithmetic in minor units throughout, formatted only at the last moment · every rounding bug in bookkeeping comes from converting early ### Data model (create this before Phase 1) - currencies(code, exponent, symbol, source_note) · seeded from the published list, with the exponent stored rather than assumed - transactions(id, occurred_on, amount_minor, currency, payee, category_id, note, source_kind, source_ref, created_at) · amount_minor is an integer and there is no decimal column anywhere - proposals(id, source_kind, raw_input_excerpt, model_id, prompt_version, extracted_json, confidence, created_at, resolved_at, resolution) · what the model said, kept whether or not it was accepted - corrections(id, proposal_id, field, model_value, user_value, at) · the only record of how wrong the extraction usually is - import_batches(id, filename, file_checksum, page_count, rows_staged, rows_committed, state, created_at) · a batch is committed whole or not at all - staged_rows(id, batch_id, page, line_index, parsed_json, confidence, fingerprint, duplicate_of, decision) - budgets(id, category_id, month, limit_minor, currency) · in minor units, like everything else - rates(from_currency, to_currency, rate_ppm, as_of_date, source) · stored per conversion, so last month's report does not change when the market does ### Phase 1 · A ledger that cannot drift Build: Seed the currencies table from the published list including each currency's exponent. Parse user input into minor units at the boundary and reject anything with more fractional digits than the currency allows. Do all arithmetic in integers. Format for display from the exponent, never from a hard-coded two. Add manual entry forms that work with no model configured at all. Trap: Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. Done when: A sum of ten thousand random amounts matches an independently computed integer total exactly, tested. An amount entered with three decimal places is accepted for a currency whose exponent is three and rejected for one whose exponent is two, tested with both. Display formatting reads the exponent from the currencies table, tested with a zero-exponent and a three-exponent currency. Manual entry works end to end with no model key configured at all, tested. Do not build yet: No extraction, no import, no budgets or reports yet. ### Phase 2 · A sentence becomes a proposal, not a row Build: Send the sentence to the model with a strict schema asking for amount, currency, payee, category and date, plus a confidence and a list of fields it was unsure about. Resolve relative dates against the user's configured zone and today's date in that zone, both sent explicitly. Render the proposal for confirmation with every uncertain field highlighted. Record corrections when the user changes something. Trap: Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. Done when: Relative dates resolve against the configured zone and a supplied current date, tested by running the same sentence with the process clock set to a different zone and getting the same answer. A proposal with an unparseable amount produces a question rather than a row, tested. Every field the user changes on the confirmation screen is written as a correction with both values, tested. No path exists from a model response to a transactions row that does not pass through a confirmation, verified by review and by a test. Do not build yet: No statement import, no budgets, no question box yet. ### Phase 3 · Statement import that commits whole or not at all Build: Checksum the file, stage every parsed row with its page and line index, and give each a fingerprint derived from the file, the page and the line rather than from its contents alone. Flag likely duplicates against existing transactions without deleting anything. Commit the whole batch in one transaction after review, and refuse a batch containing any unresolved low-confidence row. Trap: Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. Done when: Two identical purchases on the same day both survive an import, tested with a crafted statement. A suspected duplicate is flagged for a decision and is never dropped automatically, tested. Importing the same file twice detects it by checksum and offers to review rather than reimporting blindly, tested. A batch containing an unresolved low-confidence row cannot be committed, and a failure partway through commits nothing, tested by injecting an error mid-transaction. Do not build yet: No budgets, no reports, no question box yet. ### Phase 4 · Budgets and reports that do not move Build: Compute budgets and category totals in minor units per currency, and never sum across currencies without a stored rate. When a conversion is needed, store the rate, its date and its source on the transaction so the historical figure is fixed. Charts read the same aggregation functions the tables do, and every figure carries its currency. Trap: Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. Done when: A report for a past month produces identical figures when regenerated after rates change, tested by moving the rate table. Mixed-currency totals are refused without a stored rate and the interface says which transactions are missing one, tested. Budget progress is computed in minor units and the boundary case of exactly meeting a limit is tested. Every displayed figure carries its currency and no figure is shown as a bare number. Do not build yet: No question box, no backups, no export yet. ### Phase 5 · Asking questions safely, and getting everything out Build: Let the model write a query, but run it on a read-only connection with a statement timeout and a row limit, show the query before the answer, and refuse anything that is not a single select. Copy the database nightly with a retention count, and restore into a scratch file on a schedule. Export every table as CSV with a manifest recording the currency exponents in force. Trap: A question box that runs model-written SQL on the same connection the application writes with is one confidently generated delete away from an empty ledger, and the backup you never restored will not save you. Open a separate read-only connection, cap the statement time and the row count, reject anything that is not a single select statement, and show the query to the user before the answer so a wrong answer is visible as a wrong query. Done when: A generated statement that is not a single select is refused before execution, tested with a delete, an attach and a multi-statement string. The read-only connection cannot write, verified by attempting an insert in a test. A restore from last night's backup into a scratch file reproduces the transaction count and the sum of every category, exercised on a schedule. Export produces every table as CSV with the currency exponents recorded, and reimports into an empty database with identical totals. Do not build yet: Nothing after this. ### Out of scope (and why) - Bank connections and automatic feeds, which need a licensed aggregator or screen scraping of a login, and neither belongs in a personal weekend build. - Scanned statements with no text layer, because that is an optical recognition pipeline with its own error rates and it would need the same correction machinery all over again. - Investment, tax and multi-entity accounting, which are separate domains with their own rules and are not a larger version of this. - Automatic categorisation that overwrites a category you set, because the one thing a ledger owes you is that your corrections stay corrected. ### README must contain - That amounts are integer minor units with the currency's exponent read from a table, and why two decimal places is not a safe assumption - That the model produces proposals and a person creates rows, and where the correction trail lives - That imports commit as one batch and that suspected duplicates are flagged rather than dropped - That historical conversions use a stored rate and date, so past reports do not move - Roughly what an import costs in model calls, and how to run the whole ledger with no model configured
# Agent instructions · Auritrack indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express, server-rendered pages, bound to the loopback address, SQLite with amounts stored as integer minor units and a currency column beside each, One model call per input with a strict schema, producing a proposal and never a row, A staging table with per-row confidence, reviewed and committed as one batch, Arithmetic in minor units throughout, formatted only at the last moment. 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 amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate.
# Build plan · Auritrack A personal ledger whose hard problems are representing money so totals never drift, turning a loose sentence or a messy statement into rows without letting a model commit anything on its own, and keeping a correction trail that explains where every figure came from. 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 cannot drift Every amount is an integer count of minor units in a named currency, and the arithmetic is tested at the awkward cases. ### Steps 1. Seed currencies with their exponents Two decimal places is an assumption, not a fact. Files: `migrations/001_currencies.sql`, `src/money/currencies.ts` 2. Parse to integers at the boundary, format at the edge Nothing between those two points sees a decimal. Files: `src/money/parse.ts`, `tests/money.test.ts` 3. Make manual entry work without any model The ledger has to be usable when the key expires. Files: `src/app/transactions/new.tsx` ### Done when - [ ] A large integer sum matches an independent total - [ ] Fractional digits are validated against the exponent - [ ] Formatting reads the exponent from the table - [ ] Manual entry works with no model configured ### Watch out - Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. ## Phase 2 · A sentence becomes a proposal, not a row Typed input produces something you confirm, with ambiguity surfaced as a question rather than resolved by guessing. ### Steps 1. Send today's date and the zone explicitly Never let a model infer what day it is. Files: `src/extract/prompt.ts`, `tests/relative-dates.test.ts` 2. Surface uncertainty instead of resolving it The schema asks which fields it was unsure about. Files: `src/extract/schema.ts`, `src/app/confirm.tsx` 3. Record every correction with both values It is the only measurement of how wrong extraction is for you. Files: `src/extract/corrections.ts` ### Done when - [ ] Relative dates are zone-stable across process clocks - [ ] An unparseable amount produces a question - [ ] Corrections store model and user values - [ ] No row is created without confirmation ### Watch out - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. ## Phase 3 · Statement import that commits whole or not at all A statement becomes a reviewed batch with per-row confidence, and a partial commit is impossible. ### Steps 1. Fingerprint on file, page and line Contents alone cannot tell two real coffees apart. Files: `src/import/fingerprint.ts`, `tests/duplicate-coffees.test.ts` 2. Flag duplicates, never drop them A silently discarded row is undiscoverable later. Files: `src/import/duplicates.ts` 3. Commit the batch in one transaction Half an import is the worst state this ledger can reach. Files: `src/import/commit.ts`, `tests/atomic-import.test.ts` ### Done when - [ ] Two identical same-day purchases both survive - [ ] Suspected duplicates are flagged, not dropped - [ ] A repeated file is detected by checksum - [ ] A failed commit leaves nothing behind ### Watch out - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. ## Phase 4 · Budgets and reports that do not move A report of last month shows the same numbers next year, including across currencies. ### Steps 1. Store the rate and its date on the transaction A historical figure should never move. Files: `src/money/convert.ts`, `tests/stable-history.test.ts` 2. Refuse a mixed total without a rate Declining to show a number beats inventing one. Files: `src/reports/aggregate.ts` 3. Make charts and tables share one aggregation Two implementations means two answers and one argument. Files: `src/reports/aggregate.ts`, `src/app/reports/page.tsx` ### Done when - [ ] A past month's report is stable across rate changes - [ ] Mixed totals without a rate are refused - [ ] Budget boundary cases are tested - [ ] Every figure carries its currency ### Watch out - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. ## Not in this build - Bank connections and automatic feeds. - Scanned statements with no text layer. - Investment, tax and multi-entity accounting. - Automatic categorisation that overwrites your corrections. ## After v1, if you want it - A monthly accuracy report comparing model proposals against your corrections, which is the only honest measure of whether extraction is working - Per-bank import profiles learned from the staging table, which removes most of the model cost from routine imports - A reconciliation view against a statement balance, which is the check that catches everything else
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The whole ledger. This is the backup target and the only copy. DATABASE_PATH=./data/ledger.db # Required. Reports total in this. Foreign amounts need a stored rate. BASE_CURRENCY=GBP # Required. An IANA zone name. Relative dates resolve against this and today's date in it. USER_TIMEZONE=Europe/London # Optional · secret. Extraction only. The ledger works fully without it through manual entry. MODEL_API_KEY=sk-...redacted # Optional. Extraction stops here; manual entry and reports continue. MODEL_MONTHLY_CEILING=5.00 # Required. Staged rows below this must be resolved by hand before a batch can commit. IMPORT_CONFIDENCE_FLOOR=0.7 # Required. Nightly copies with a retention count. Restored on a schedule, not on faith. BACKUP_DIR=./backups
# Auritrack · product brief ## Problem The core loop, logging expenses by typing a sentence into a chat box and having an LLM extract amount, payee, and category, is very buildable with an Anthropic API key. Budgets, categories, and spending reports are standard CRUD. The real gap is statement import: getting one clean CSV parsed by an LLM is a demo, but reliably parsing messy multi-page bank PDFs across many banks needs batching, fallbacks, and cross-checks that take far longer than a weekend. ## Product outcome A ledger whose totals do not drift because every amount is an integer in a currency whose exponent is stored, whose rows are only ever created by a person confirming a proposal, whose imports commit whole, and whose past reports stay the same when exchange rates move. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Anthropic API key - local or hosted database - CSV import - PDF text extraction - charting library ## Explicit non-goals for v1 - Bank connections and automatic feeds. - Scanned statements with no text layer. - Investment, tax and multi-entity accounting. - Automatic categorisation that overwrites your corrections. - battle-tested statement parsing across many banks and layouts - mobile apps and push notifications - Telegram bot logging - predictive spending forecasts - polished AI-written financial reports ## Success criteria - A large integer sum is confirmed to match an independent total - No path is confirmed to create a row without confirmation - Two identical same-day purchases are confirmed to survive an import - A past month's report is confirmed stable across rate changes - Anything but a single select is confirmed refused in the question box
# Build brief · Auritrack 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 local expense ledger that accepts a typed sentence or a statement file and never records a number a person has not confirmed to replace Auritrack. 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. ### Rule zero Two rules about money. Both are cheap now and expensive in a year. Money is an integer count of minor units plus a currency code. Never a floating point number, never a bare decimal string. Binary floating point cannot represent a tenth exactly, so a ledger built on it drifts, and the drift shows up as a total that is a penny out and an afternoon nobody gets back. The number of minor units is also a property of the currency rather than a constant: the published ISO 4217 currency list, in the edition dated 1 January 2026, gives the yen, the Icelandic krona and the Vietnamese dong an exponent of zero, the Kuwaiti and Bahraini dinars an exponent of three, and the Chilean Unidad de Fomento an exponent of four. A schema that assumes two decimal places is wrong for real currencies the first time somebody travels. Second, the model never writes a row. It proposes, a person confirms, and both the proposal and the correction are kept. This is a bookkeeping tool, and the distinction between a suggestion and a record is the entire product. An import that quietly commits sixty transactions because the confidence looked high is how a year of accounts becomes fiction, and you will not notice until you are reconciling against a bank statement in April. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages, bound to the loopback address · your spending history should not be listening on a network interface - Storage: SQLite with amounts stored as integer minor units and a currency column beside each · the representation decision is the one that cannot be changed later without a migration and a reconciliation - Extraction: One model call per input with a strict schema, producing a proposal and never a row · a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other - Import: A staging table with per-row confidence, reviewed and committed as one batch · half-committed imports are the worst state a ledger can be in - Reporting: Arithmetic in minor units throughout, formatted only at the last moment · every rounding bug in bookkeeping comes from converting early ### Data model (create this before Phase 1) - currencies(code, exponent, symbol, source_note) · seeded from the published list, with the exponent stored rather than assumed - transactions(id, occurred_on, amount_minor, currency, payee, category_id, note, source_kind, source_ref, created_at) · amount_minor is an integer and there is no decimal column anywhere - proposals(id, source_kind, raw_input_excerpt, model_id, prompt_version, extracted_json, confidence, created_at, resolved_at, resolution) · what the model said, kept whether or not it was accepted - corrections(id, proposal_id, field, model_value, user_value, at) · the only record of how wrong the extraction usually is - import_batches(id, filename, file_checksum, page_count, rows_staged, rows_committed, state, created_at) · a batch is committed whole or not at all - staged_rows(id, batch_id, page, line_index, parsed_json, confidence, fingerprint, duplicate_of, decision) - budgets(id, category_id, month, limit_minor, currency) · in minor units, like everything else - rates(from_currency, to_currency, rate_ppm, as_of_date, source) · stored per conversion, so last month's report does not change when the market does ### Phase 1 · A ledger that cannot drift Build: Seed the currencies table from the published list including each currency's exponent. Parse user input into minor units at the boundary and reject anything with more fractional digits than the currency allows. Do all arithmetic in integers. Format for display from the exponent, never from a hard-coded two. Add manual entry forms that work with no model configured at all. Trap: Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. Done when: A sum of ten thousand random amounts matches an independently computed integer total exactly, tested. An amount entered with three decimal places is accepted for a currency whose exponent is three and rejected for one whose exponent is two, tested with both. Display formatting reads the exponent from the currencies table, tested with a zero-exponent and a three-exponent currency. Manual entry works end to end with no model key configured at all, tested. Do not build yet: No extraction, no import, no budgets or reports yet. ### Phase 2 · A sentence becomes a proposal, not a row Build: Send the sentence to the model with a strict schema asking for amount, currency, payee, category and date, plus a confidence and a list of fields it was unsure about. Resolve relative dates against the user's configured zone and today's date in that zone, both sent explicitly. Render the proposal for confirmation with every uncertain field highlighted. Record corrections when the user changes something. Trap: Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. Done when: Relative dates resolve against the configured zone and a supplied current date, tested by running the same sentence with the process clock set to a different zone and getting the same answer. A proposal with an unparseable amount produces a question rather than a row, tested. Every field the user changes on the confirmation screen is written as a correction with both values, tested. No path exists from a model response to a transactions row that does not pass through a confirmation, verified by review and by a test. Do not build yet: No statement import, no budgets, no question box yet. ### Phase 3 · Statement import that commits whole or not at all Build: Checksum the file, stage every parsed row with its page and line index, and give each a fingerprint derived from the file, the page and the line rather than from its contents alone. Flag likely duplicates against existing transactions without deleting anything. Commit the whole batch in one transaction after review, and refuse a batch containing any unresolved low-confidence row. Trap: Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. Done when: Two identical purchases on the same day both survive an import, tested with a crafted statement. A suspected duplicate is flagged for a decision and is never dropped automatically, tested. Importing the same file twice detects it by checksum and offers to review rather than reimporting blindly, tested. A batch containing an unresolved low-confidence row cannot be committed, and a failure partway through commits nothing, tested by injecting an error mid-transaction. Do not build yet: No budgets, no reports, no question box yet. ### Phase 4 · Budgets and reports that do not move Build: Compute budgets and category totals in minor units per currency, and never sum across currencies without a stored rate. When a conversion is needed, store the rate, its date and its source on the transaction so the historical figure is fixed. Charts read the same aggregation functions the tables do, and every figure carries its currency. Trap: Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. Done when: A report for a past month produces identical figures when regenerated after rates change, tested by moving the rate table. Mixed-currency totals are refused without a stored rate and the interface says which transactions are missing one, tested. Budget progress is computed in minor units and the boundary case of exactly meeting a limit is tested. Every displayed figure carries its currency and no figure is shown as a bare number. Do not build yet: No question box, no backups, no export yet. ### Phase 5 · Asking questions safely, and getting everything out Build: Let the model write a query, but run it on a read-only connection with a statement timeout and a row limit, show the query before the answer, and refuse anything that is not a single select. Copy the database nightly with a retention count, and restore into a scratch file on a schedule. Export every table as CSV with a manifest recording the currency exponents in force. Trap: A question box that runs model-written SQL on the same connection the application writes with is one confidently generated delete away from an empty ledger, and the backup you never restored will not save you. Open a separate read-only connection, cap the statement time and the row count, reject anything that is not a single select statement, and show the query to the user before the answer so a wrong answer is visible as a wrong query. Done when: A generated statement that is not a single select is refused before execution, tested with a delete, an attach and a multi-statement string. The read-only connection cannot write, verified by attempting an insert in a test. A restore from last night's backup into a scratch file reproduces the transaction count and the sum of every category, exercised on a schedule. Export produces every table as CSV with the currency exponents recorded, and reimports into an empty database with identical totals. Do not build yet: Nothing after this. ### Out of scope (and why) - Bank connections and automatic feeds, which need a licensed aggregator or screen scraping of a login, and neither belongs in a personal weekend build. - Scanned statements with no text layer, because that is an optical recognition pipeline with its own error rates and it would need the same correction machinery all over again. - Investment, tax and multi-entity accounting, which are separate domains with their own rules and are not a larger version of this. - Automatic categorisation that overwrites a category you set, because the one thing a ledger owes you is that your corrections stay corrected. ### README must contain - That amounts are integer minor units with the currency's exponent read from a table, and why two decimal places is not a safe assumption - That the model produces proposals and a person creates rows, and where the correction trail lives - That imports commit as one batch and that suspected duplicates are flagged rather than dropped - That historical conversions use a stored rate and date, so past reports do not move - Roughly what an import costs in model calls, and how to run the whole ledger with no model configured
# Architecture · Auritrack ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express, server-rendered pages, bound to the loopback address | your spending history should not be listening on a network interface | | Storage | SQLite with amounts stored as integer minor units and a currency column beside each | the representation decision is the one that cannot be changed later without a migration and a reconciliation | | Extraction | One model call per input with a strict schema, producing a proposal and never a row | a proposal is reviewable and a row is a fact, and only a person gets to turn one into the other | | Import | A staging table with per-row confidence, reviewed and committed as one batch | half-committed imports are the worst state a ledger can be in | | Reporting | Arithmetic in minor units throughout, formatted only at the last moment | every rounding bug in bookkeeping comes from converting early | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Money | Minor units, exponents, parsing and formatting. | Not swappable for a decimal type in the application layer, which is the bug this module exists to prevent. | | Extraction | Proposals, schemas, confidence and corrections. | Any model, provided a proposal can never become a row without confirmation. | | Import | Staging, fingerprints, duplicate flags, atomic commit. | Any parser, provided fingerprints include file, page and line. | | Reports | Aggregation in minor units with stored rates. | Any presentation, provided charts and tables share one aggregation. | | Ask | Read-only generated queries with limits. | Any implementation, provided it is a separate read-only connection. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · The whole ledger. This is the backup target and the only copy. - `BASE_CURRENCY` · required · Reports total in this. Foreign amounts need a stored rate. - `USER_TIMEZONE` · required · An IANA zone name. Relative dates resolve against this and today's date in it. - `MODEL_API_KEY` · optional, secret · Extraction only. The ledger works fully without it through manual entry. - `MODEL_MONTHLY_CEILING` · optional · Extraction stops here; manual entry and reports continue. - `IMPORT_CONFIDENCE_FLOOR` · required · Staged rows below this must be resolved by hand before a batch can commit. - `BACKUP_DIR` · required · Nightly copies with a retention count. Restored on a schedule, not on faith. ## 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 · Auritrack product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express, server-rendered pages, bound to the loopback address, SQLite with amounts stored as integer minor units and a currency column beside each, One model call per input with a strict schema, producing a proposal and never a row, A staging table with per-row confidence, reviewed and committed as one batch, Arithmetic in minor units throughout, formatted only at the last moment. - 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 amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. - A question box that runs model-written SQL on the same connection the application writes with is one confidently generated delete away from an empty ledger, and the backup you never restored will not save you. Open a separate read-only connection, cap the statement time and the row count, reject anything that is not a single select statement, and show the query to the user before the answer so a wrong answer is visible as a wrong query.
# Delivery milestones · Auritrack Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A ledger that cannot drift Every amount is an integer count of minor units in a named currency, and the arithmetic is tested at the awkward cases. ### Steps 1. Seed currencies with their exponents Two decimal places is an assumption, not a fact. Files: `migrations/001_currencies.sql`, `src/money/currencies.ts` 2. Parse to integers at the boundary, format at the edge Nothing between those two points sees a decimal. Files: `src/money/parse.ts`, `tests/money.test.ts` 3. Make manual entry work without any model The ledger has to be usable when the key expires. Files: `src/app/transactions/new.tsx` ### Done when - [ ] A large integer sum matches an independent total - [ ] Fractional digits are validated against the exponent - [ ] Formatting reads the exponent from the table - [ ] Manual entry works with no model configured ### Watch out - Storing amounts as a decimal number because the database offers one, then summing them in the application layer, reintroduces binary floating point at exactly the step where it matters, and the symptom is a category total that disagrees with the sum of its rows by a penny in a way nobody can reproduce. Keep integers end to end, and write a test that sums ten thousand random amounts and compares against an independently computed integer total. ## M2 · A sentence becomes a proposal, not a row Typed input produces something you confirm, with ambiguity surfaced as a question rather than resolved by guessing. ### Steps 1. Send today's date and the zone explicitly Never let a model infer what day it is. Files: `src/extract/prompt.ts`, `tests/relative-dates.test.ts` 2. Surface uncertainty instead of resolving it The schema asks which fields it was unsure about. Files: `src/extract/schema.ts`, `src/app/confirm.tsx` 3. Record every correction with both values It is the only measurement of how wrong extraction is for you. Files: `src/extract/corrections.ts` ### Done when - [ ] Relative dates are zone-stable across process clocks - [ ] An unparseable amount produces a question - [ ] Corrections store model and user values - [ ] No row is created without confirmation ### Watch out - Letting the model resolve 'yesterday' without being told today's date and the user's zone produces an entry a day out whenever the server clock is in a different zone or the request crosses midnight, and a ledger that is quietly a day out is worse than one that is obviously broken. Send today's date in the user's zone as part of the input, and have the schema return a date rather than a relative phrase. ## M3 · Statement import that commits whole or not at all A statement becomes a reviewed batch with per-row confidence, and a partial commit is impossible. ### Steps 1. Fingerprint on file, page and line Contents alone cannot tell two real coffees apart. Files: `src/import/fingerprint.ts`, `tests/duplicate-coffees.test.ts` 2. Flag duplicates, never drop them A silently discarded row is undiscoverable later. Files: `src/import/duplicates.ts` 3. Commit the batch in one transaction Half an import is the worst state this ledger can reach. Files: `src/import/commit.ts`, `tests/atomic-import.test.ts` ### Done when - [ ] Two identical same-day purchases both survive - [ ] Suspected duplicates are flagged, not dropped - [ ] A repeated file is detected by checksum - [ ] A failed commit leaves nothing behind ### Watch out - Deduplicating on date, amount and payee collapses two genuinely separate identical purchases on the same day into one, and because the discarded row never existed in your database you will never find out, which turns reconciliation against the bank into an unwinnable argument. Fingerprint on the source file, page and line so every statement row is distinct, flag matches as suspected duplicates for a human, and never drop a row silently. ## M4 · Budgets and reports that do not move A report of last month shows the same numbers next year, including across currencies. ### Steps 1. Store the rate and its date on the transaction A historical figure should never move. Files: `src/money/convert.ts`, `tests/stable-history.test.ts` 2. Refuse a mixed total without a rate Declining to show a number beats inventing one. Files: `src/reports/aggregate.ts` 3. Make charts and tables share one aggregation Two implementations means two answers and one argument. Files: `src/reports/aggregate.ts`, `src/app/reports/page.tsx` ### Done when - [ ] A past month's report is stable across rate changes - [ ] Mixed totals without a rate are refused - [ ] Budget boundary cases are tested - [ ] Every figure carries its currency ### Watch out - Converting foreign transactions with today's rate at report time means last March's total changes every time you open the page, and a budget that was met in March quietly becomes a budget that was missed, which destroys the only thing a report is for. Store the rate and the date used on the transaction at the moment it is recorded, and treat a missing rate as a figure you decline to show rather than one you estimate. ## M5 · Asking questions safely, and getting everything out (production only) The question box cannot damage or exfiltrate the ledger, backups are tested, and the whole thing exports. ### Steps 1. Run generated queries read-only with limits A separate connection, a timeout and a row cap. Files: `src/ask/run.ts`, `tests/readonly.test.ts` 2. Show the query before the answer A wrong answer is easier to spot as a wrong query. Files: `src/app/ask/page.tsx` 3. Restore into a scratch file on a schedule A backup nobody restores is a story about a backup. Files: `scripts/restore-check.sh`, `docs/operations.md` ### Done when - [ ] Anything but a single select is refused - [ ] The read-only connection cannot write - [ ] A scheduled restore reproduces counts and totals - [ ] Export and reimport preserve every total ### Watch out - A question box that runs model-written SQL on the same connection the application writes with is one confidently generated delete away from an empty ledger, and the backup you never restored will not save you. Open a separate read-only connection, cap the statement time and the row count, reject anything that is not a single select statement, and show the query to the user before the answer so a wrong answer is visible as a wrong query.
# Operations · Auritrack ## Backup The database file nightly with a retention count, plus the exported CSV manifest weekly. The corrections and proposals tables are as valuable as the transactions, because together they are the only explanation of where a figure came from. ## Restore Restore into a scratch file, compare the transaction count and every category total against the last export, then swap. A restore that changes a category total means a rate or an exponent moved and should be investigated before the file is used. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Model spend against the ceiling, the proportion of proposals corrected, the number of staged rows below the confidence floor per import, and whether last night's restore check passed. A rising correction rate on one bank usually means that bank changed its statement layout. ## Incident checklist The incident is a number somebody trusted. proposals, corrections and staged_rows together reconstruct what was suggested, what was accepted and what was changed, which is why they survive the transactions they produced. 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 large integer sum is confirmed to match an independent total - [ ] No path is confirmed to create a row without confirmation - [ ] Two identical same-day purchases are confirmed to survive an import - [ ] A past month's report is confirmed stable across rate changes - [ ] Anything but a single select is confirmed refused in the question box ## Launch constraint Do not market omitted Auritrack 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. The whole ledger. This is the backup target and the only copy. DATABASE_PATH=./data/ledger.db # Required. Reports total in this. Foreign amounts need a stored rate. BASE_CURRENCY=GBP # Required. An IANA zone name. Relative dates resolve against this and today's date in it. USER_TIMEZONE=Europe/London # Optional · secret. Extraction only. The ledger works fully without it through manual entry. MODEL_API_KEY=sk-...redacted # Optional. Extraction stops here; manual entry and reports continue. MODEL_MONTHLY_CEILING=5.00 # Required. Staged rows below this must be resolved by hand before a batch can commit. IMPORT_CONFIDENCE_FLOOR=0.7 # Required. Nightly copies with a retention count. Restored on a schedule, not on faith. BACKUP_DIR=./backups
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay because a hardened parsing pipeline, mobile apps, and chat-anywhere logging remove all friction, and $3/mo is cheaper than maintaining your own LLM plumbing.
xbattle-tested statement parsing across many banks and layouts
xmobile apps and push notifications
xTelegram bot logging
xpredictive spending forecasts
xpolished AI-written financial reports
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Auritrack pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | Manual app; 1 AI report/month; 1 custom report/month; 1 period report/month; 1 export/month; 0 alerts |
| plus | $3/workspace | $2.50/workspace | 50 coins/month or 600/year; 3 AI reports, 3 custom reports, 3 period reports, and 5 exports/month; 0 alerts |
| pro | $8/workspace | $6.67/workspace | 150 coins/month or 1,800/year; unlimited chat, deep dives, and voice; 15 AI/custom/period reports and 30 exports/month; alerts included |
| premium | $20/workspace | $16.67/workspace | 450 coins/month or 5,400/year; all report types and exports included; transaction imports remain pay per use |
free tier1 AI report, 1 custom report, 1 period report, and 1 export per month; 0 alerts; transaction imports and additional AI actions are pay per use
billingfree + monthly/annual paid plans (annual is 2 months free)
hidden costsSubscription coins refill each billing cycle and do not roll forward; purchased top-up coins do not expire, but public top-up prices were not disclosed. Transaction imports remain pay per use even on Premium.
verified 2026-08-10 · source ↗
Vibecode Auritrack
Kinda. The core of Auritrack is buildable in a weekend with the prompt on this page, but there are real gaps: battle-tested statement parsing across many banks and layouts, mobile apps and push notifications. Read the honest list above before committing.
How much does Auritrack cost?
Auritrack costs about $3/month (Plus, checked 2026-08-01), which is $36 per year.
What do I lose by replacing Auritrack?
Honestly: battle-tested statement parsing across many banks and layouts; mobile apps and push notifications; Telegram bot logging; predictive spending forecasts; polished AI-written financial reports. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Auritrack?
Yes: TaxHacker (Feed it receipts, invoices or bank statements and let an LLM fill the ledger; it is useful, young and very much your server.) The prompt is for when you want it exactly your way.