Turns any bank statement into the picture the bank never shows you: what actually came in, what actually went out, and the rhythm underneath it.
Everything is parsed in the browser. No file is uploaded anywhere, there is no backend, and statements are never stored. Your names and corrections can be remembered on your device if you opt in — see Remembering.
npm install
npm run dev
npm test # vitest (tests/): classification, dedupe, parsing regressionsxlsx is installed from SheetJS's own CDN (cdn.sheetjs.com), not npm. The
npm package stopped at 0.18.5, which has a prototype-pollution and a ReDoS
advisory — both reachable here, since the app parses files users drop in. Don't
switch it back to the npm version; upgrade by changing the version in the URL.
| Format | Notes |
|---|---|
.pdf |
Any text-layer statement (not a scan) |
.xlsx .xls |
Any export; the transaction sheet is picked automatically |
.csv .tsv |
With or without a header row |
There are no bank-specific parsers. Layout is worked out from the file itself, so a bank nobody has ever tested works the same as a familiar one. Drop in several accounts to get one combined picture.
Handled automatically:
- Debit/credit column pairs or a single signed amount column
DD/MM/YYYY,MM/DD/YYYY,YYYY-MM-DD,10 May 2023,05-Jan-20261,234.56and1.234,56,(1,234.56)and1,234.56 DRfor negatives--,-,N/A,nilas empty cells- Currency from the statement (₦, $, £, €, ISO codes), correctable per file
Detection reports a confidence score. Below the floor, the app stops and shows the table with a dropdown over each column, a date-order picker, and a live preview of the transactions your choices produce. That review screen is what makes "any bank" true rather than aspirational — no layout is a dead end.
A raw statement massively overstates both income and spending, because the same money is counted several times as it moves around:
Bank ──▶ wallet ──▶ savings vault ──▶ back out ──▶ purchase
five statement entries, one actual purchase
So every transaction is tagged before anything is charted:
| Kind | Meaning | Counted? |
|---|---|---|
external |
Money crossing the boundary of all your accounts | ✅ |
self |
Moved between accounts you own | ❌ |
internal |
In-app housekeeping (auto-save, vaults, round-ups) | ❌ |
fee |
Bank charges, VAT, stamp duty, levies | ❌ |
reversal |
Reversed or refunded entries | ❌ |
A reversal also takes the entry it cancels out of the totals: the nearest earlier entry on the same account, in the opposite direction, for the same amount, within 14 days. Otherwise a transfer that bounced back would still read as money spent.
Drop in overlapping statements for one account (Jan–Jun and Mar–Dec) and
the shared months are counted once. Entries match on account number, date,
direction, amount, description and balance. Identical rows inside a single
file are kept, because two ₦500 top-ups on the same day are real.
Excluded totals are shown at the bottom of the dashboard, so nothing is silently swept away.
Self-transfer detection is the difference between a useful dashboard and a wrong one, and it goes wrong in both directions. Names and account numbers are detected from each statement and shown in an editable This is me panel.
- The same person is
MUSTAPHA IBRAHIMat one bank andMUSTAPHA ALHAJI IBRAHIMat another, so the matcher uses the union of every statement's name plus any aliases you add. - A name counts as you when all of its tokens belong to your name and it
starts with one of your first names —
Mustapha Liman MohammedandIbrahim Mustaphastay external. - An account number in a description only counts when it's a different account of yours. Banks print your own number on airtime top-ups and bill payments; treating that as conclusive marked ₦700k of ordinary spending as transfers.
NIP/GTB/YAHAYA ADELEKE/NIP Transfer to MUSTAPHA IBRAHIMcontains your name — as the beneficiary. AnFRMsegment always wins, and ato …clause is only read when no earlier segment names someone else.
Amounts in different currencies are never added together. Converting would need an exchange rate for every transaction date, which means fetching one, and the app never goes online with anything you give it. So when your statements span currencies, the dashboard gets a switcher and each currency has its own totals, charts and transaction list.
Currency is read from each file. If one is misread, correct it for that file in This is me and it moves into the right group. (The old single currency picker only changed the symbol, so ₦500,000 + $1,000 showed as "₦501,000".)
Off by default. Remember on this device in the This is me panel keeps
your name spellings, account numbers and transaction corrections in
localStorage, so the next visit starts where you left off.
- No statement content is saved. A per-row correction is keyed by a hash
of the row's account, date, direction, amount and description, so storage
holds opaque keys like
21tow8qr03e, never the row itself. Counterparty rules are stored by name, because that name is what the rule is. - The same hash makes a correction stick when you re-upload the file, or a different statement that overlaps it.
- Turning it off deletes what was saved. Start over clears the session but keeps what you chose to remember.
- Storage is treated as untrusted on load (unknown kinds and malformed entries are dropped), and a private window or blocked storage just means nothing is remembered.
- Annual inflow — external credits per calendar year, partial years labelled
- Spending rhythm — calendar heatmap of daily outflow, one tab per year
- Monthly rhythm — every month side by side with totals
- Where it goes — category split plus biggest recipients and senders
- Balance trend — end-of-day balance from the account that reports one
- Every transaction — searchable list of every row behind the charts. Change any row's kind or category and the totals update; Apply to all turns one correction into a rule for that counterparty. A row correction beats a counterparty rule, and a kind set by hand is never re-paired as a reversal.
Shade buckets are quantiles of your own data, not fixed thresholds, so a quiet year and a heavy year are both readable.
Every chart has a Save control offering:
- PNG — the card exactly as rendered, at 2× for legibility in docs and slides. The button and hover tooltip are excluded from the capture, and the card background is baked in so the image isn't transparent when pasted.
- CSV — the numbers behind that chart. The spending rhythm exports the year
currently on screen; "Where it goes" carries a
Sectioncolumn because it's three tables in one card.
CSV output is RFC 4180 escaped, which is not optional here: counterparty names
come straight from bank narrations and contain commas (HAIGHA & CO, LTD),
quotes and the occasional newline. Files are written with a UTF-8 BOM so Excel
renders ₦ correctly instead of mangling it.
src/
lib/
types.ts shared types
money.ts amount parsing, currency detection, formatting
dates.ts date parsing + column-wide format inference
extractPdf.ts PDF -> RawTable (geometric column recovery)
extractTable.ts XLSX/CSV -> RawTable
detectColumns.ts RawTable -> ColumnMap + confidence
buildStatement.ts ColumnMap -> Txn[] + account metadata
counterparty.ts description -> the other party's name
classify.ts self / internal / fee / reversal tagging
aggregate.ts derives every series the views render
labels.ts display names for kinds and categories
storage.ts opt-in remembered settings + stable row keys
ingest.ts one file in, one outcome out
ingest.worker.ts runs ingest() off the main thread
ingestClient.ts worker queue + progress, main-thread fallback
format.tsx currency formatting context
export.ts PNG capture + RFC 4180 CSV
components/
Upload.tsx MappingReview.tsx IdentityPanel.tsx
AnnualInflow.tsx DailyRhythm.tsx MonthlyRhythm.tsx
Breakdown.tsx BalanceTrend.tsx Transactions.tsx ui.tsx
Files are parsed in a Web Worker, one at a time, so a long PDF or a big workbook never freezes the page. SheetJS in particular is fully synchronous: a 30,000-row workbook blocked the page for about a second before this change, and now doesn't block it at all. The upload shows which file and, for PDFs, which page it's on.
- pdf.js and SheetJS are loaded on demand, only by the worker and only for the
file type in hand. Together with drawing every chart as plain SVG (Recharts
and its dependencies were over half the bundle, for one line chart) and
loading
html-to-imageon the first PNG save, this took the main bundle from 1.4 MB to 262 kB (83 kB gzipped). - The worker is an optimisation, not a requirement. If it can't start or dies mid-file, that file and the rest are parsed on the main thread instead.
- In
npm run devthe first upload is slow (~10 s for a 60-page PDF) while Vite transforms the worker's modules. A production build doesn't have this cost: the same file takes ~1.4 s cold.
These were all found by running real statements through the pipeline, and each one silently corrupted the output before it was fixed:
- PDFs have no table structure, only glyphs at coordinates. Columns are recovered by clustering the x-positions of amounts, which is what lets a blank debit cell stay blank instead of being mistaken for a credit.
- pdf.js merges adjacent cells into one text item —
"0.00 29/09/2024"is the credit and the value date. Items are split into tokens with interpolated positions, or every credit on those rows is lost. - Descriptions contain stray digits (
MNFY 26). Treated as amounts they invent phantom columns to the left of the real ones and swallow the description, so a money token must carry two decimal places. "10 May 2023"survives a naive digit-strip as102023. A value-date column read as an amount produced a total in the quadrillions.- Spreadsheet
!refranges lie. Real exports declare a range starting below the header, hiding it along with the account metadata; the range is forced back to the origin. --is not text. Counting placeholders as content makes a perfectly good debit column look like prose and get ignored.- A bare
Ris not rand. Detecting the rand symbol anywhere in the preamble matched the R inIBRAHIMand labelled naira statements ZAR. Letter symbols (R,KSh) only count directly in front of a number. - Plurals matter.
Lodgementsnot matching alodgementsynonym made every credit in a GTBank-style file disappear.