Personal project · Local-first finance tool

Every aggregator wanted my banking credentials. I'd rather parse the PDF myself.

Salary in one bank, spending in another, emergency fund in a third. Three credit cards. Mutual funds, stocks, gold and FDs across four brokers. Every tool that offered to join it up wanted read access to all of it. So the statements stay statements — downloaded, parsed on my own machine, and never sent anywhere.

Role
Designed and built solo — product, interface, code
Scope
Statement ingestion, categorisation, cash flow, portfolio and net worth
Stack
Vanilla JS · IndexedDB · Claude API for unstructured parsing only · no server, no account
0Credentials handed to an aggregator
4Statement sources joined each month
28Categories, one keystroke each
0Records that commit without review

The product

Eleven months, fully joined.

Below is the tool itself, running in your browser on entirely invented records. Nothing is a screenshot.

Ledger — live, on invented data every figure below is fictional
Open Categorise for the queue · Cash Flow for the months · Net Worth for the composition No real income, balance, holding or merchant is present

The demo replaces browser storage with an in-memory store. It cannot read or write anything on your machine, and it cannot collide with a working copy of the tool.

A few things about it

The interesting part isn't the charts. It's what the model is allowed to do.

Anyone can point a language model at a bank statement. The design work was deciding where it belongs, what it is never permitted to decide, and what has to stand between its output and a number I act on.

Two lanes in, on purpose

Bank and card statements arrive as unpredictable PDFs, so those go to a model. Groww and Zerodha ship structured workbooks, so those are parsed deterministically and never leave the page. Choosing not to use AI where the structure is already known is the cheaper, more accurate answer.

Nothing commits unreviewed

Extraction lands in a staging batch, not the ledger. Every row is visible before it is saved, batches are reversible, and a transaction that can't be categorised stays marked Other rather than being guessed into a total.

A statement can't be imported twice

Each row is fingerprinted on date, amount and a normalised merchant key. Re-uploading last month's file — which happens, because the download is one click and the memory is imperfect — adds nothing.

It needs the model less every month

Obvious merchants are matched by rule before anything is sent anywhere. Every manual correction is learned as a merchant rule and applied from then on. Month one is the work; by month three the queue is a handful of genuinely ambiguous UPI strings.

It says what it's guessing

Until a holdings file has been uploaded, the portfolio reports contributions at cost rather than pretending to know market value, and labels it as such. A finance tool that quietly fills a gap with a plausible number is worse than one that leaves it visibly empty.

Local, with the obvious caveat

Records live in IndexedDB. There is no server and no account, so a cleared browser is the one real failure mode — which is why backup and restore exist and the tool nags about it. Statement text sent for parsing is the single outbound call, and it goes with a key I control.