Build Auritrack
KINDAreplaces $3/mosaves $36/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the local application.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - have readyfree
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 - API keypay 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 - have readyfree
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 - decidefree
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
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- 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
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/ledger.db | The whole ledger. This is the backup target and the only copy. |
BASE_CURRENCY | required | GBP | Reports total in this. Foreign amounts need a stored rate. |
USER_TIMEZONE | required | Europe/London | An IANA zone name. Relative dates resolve against this and today's date in it. |
MODEL_API_KEYsecret | optional | sk-...redacted | Extraction only. The ledger works fully without it through manual entry. |
MODEL_MONTHLY_CEILING | optional | 5.00 | Extraction stops here; manual entry and reports continue. |
IMPORT_CONFIDENCE_FLOOR | required | 0.7 | Staged rows below this must be resolved by hand before a batch can commit. |
BACKUP_DIR | required | ./backups | Nightly copies with a retention count. Restored on a schedule, not on faith. |
The build, in order
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.
Two decimal places is an assumption, not a fact.
Files
migrations/001_currencies.sqlsrc/money/currencies.tsNothing between those two points sees a decimal.
Files
src/money/parse.tstests/money.test.tsThe ledger has to be usable when the key expires.
Files
src/app/transactions/new.tsx
done when · tick each as it passeswatch 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.
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.
Never let a model infer what day it is.
Files
src/extract/prompt.tstests/relative-dates.test.tsThe schema asks which fields it was unsure about.
Files
src/extract/schema.tssrc/app/confirm.tsxIt is the only measurement of how wrong extraction is for you.
Files
src/extract/corrections.ts
done when · tick each as it passeswatch 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.
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.
Contents alone cannot tell two real coffees apart.
Files
src/import/fingerprint.tstests/duplicate-coffees.test.tsA silently discarded row is undiscoverable later.
Files
src/import/duplicates.tsHalf an import is the worst state this ledger can reach.
Files
src/import/commit.tstests/atomic-import.test.ts
done when · tick each as it passeswatch 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.
Budgets and reports that do not move
A report of last month shows the same numbers next year, including across currencies.
A historical figure should never move.
Files
src/money/convert.tstests/stable-history.test.tsDeclining to show a number beats inventing one.
Files
src/reports/aggregate.tsTwo implementations means two answers and one argument.
Files
src/reports/aggregate.tssrc/app/reports/page.tsx
done when · tick each as it passeswatch 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.
Asking questions safely, and getting everything outproduct builder
The question box cannot damage or exfiltrate the ledger, backups are tested, and the whole thing exports.
A separate connection, a timeout and a row cap.
Files
src/ask/run.tstests/readonly.test.tsA wrong answer is easier to spot as a wrong query.
Files
src/app/ask/page.tsxA backup nobody restores is a story about a backup.
Files
scripts/restore-check.shdocs/operations.md
done when · tick each as it passeswatch 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.
That is the whole plan for Auritrack. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more personal finance.