Skip to content
projecthanifPublic

About

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

TrueSpend

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 regressions

xlsx 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.

What it accepts

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-2026
  • 1,234.56 and 1.234,56, (1,234.56) and 1,234.56 DR for negatives
  • --, -, N/A, nil as empty cells
  • Currency from the statement (₦, $, £, €, ISO codes), correctable per file

When it isn't sure

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.

The idea

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.

Who counts as "you"

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 IBRAHIM at one bank and MUSTAPHA ALHAJI IBRAHIM at 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 Mohammed and Ibrahim Mustapha stay 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 IBRAHIM contains your name — as the beneficiary. An FRM segment always wins, and a to … clause is only read when no earlier segment names someone else.

More than one currency

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".)

Remembering

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.

Views

  • 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.

Downloads

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 Section column 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.

Layout

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

Parsing off the main thread

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-image on 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 dev the 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.

Extraction gotchas worth knowing

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 as 102023. A value-date column read as an amount produced a total in the quadrillions.
  • Spreadsheet !ref ranges 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 R is not rand. Detecting the rand symbol anywhere in the preamble matched the R in IBRAHIM and labelled naira statements ZAR. Letter symbols (R, KSh) only count directly in front of a number.
  • Plurals matter. Lodgements not matching a lodgement synonym made every credit in a GTBank-style file disappear.

About

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages