A personal finance dashboard that answers one question first: how much can I spend right now?
100% local, single user, no bank APIs, no cloud, no telemetry. The input is the CSV and XLSX files Brazilian banks already export. Money is stored in integer cents — there is no float anywhere in the ledger.
All figures in the screenshots come from the built-in synthetic ledger (npm run demo),
not from real accounts.
Budgeting apps answer "where did the money go?". That question is retrospective and, for me, useless at the moment of decision. The question that actually governs a purchase is what is safe to spend today, and answering it means subtracting what is already committed — an open credit card invoice, installments still running — from what is actually liquid.
So the hero number on screen is available − committed, and everything else is
arranged around defending it.
Most of the engineering here is not the UI. It is the set of rules that keep totals from lying, each one learned from a number that was wrong on screen.
A credit card invoice is counted once. The itemized invoice lines are the real
expenses. The payment that shows up on the bank statement is a settlement
(is_settlement=1) and is excluded from spending totals. Count both and every month with
a card is inflated twice.
Transfers between your own accounts are not expenses. They are detected, never
declared: an outgoing leg and an incoming leg of the same amount, in different accounts,
within ±3 days, get paired (self_pair_tx_id on both sides). The pairing rewrites the
outgoing leg to method='transfer', which is what keeps it out of consumption spending.
...and a paired transfer is not an investment either. This one cost me a real bug. An
investment contribution is also flow='expense' AND method='transfer' — the exact
signature the SELF pairing writes. Any query summing investments has to exclude
self_pair_tx_id, or moving money from account A to account B reads as "invested" and
the month's free balance silently shrinks. The frontend had it right and the backend did
not, and the disagreement between the two is what exposed it.
Yield is computed, never stored as a claim. Investment positions carry dated snapshots; the return is the difference between them. A position that disappears from a newer broker report is soft-closed, never deleted.
Closing an account affects the present, never the past. A closed account counts zero in "what I have now" — not its last known balance — while every historical total ignores the closure entirely, because the money really did move back then. And, like a real bank, an account only closes when it is settled: a card with an open invoice or a checking account in the red is refused.
A new ledger starts with no categories at all. Spending taxonomy is a personal decision, not domain structure — the six categories this project ran on for months say more about its author's life than about money, so nothing seeds them. Imported transactions start uncategorized, which is a state the UI already knows how to show and resolve in bulk, and the categories you create teach rules that suggest themselves next time.
An installment belongs to the month it leaves, not the month you bought. The invoice repeats the original purchase date on every installment, so "2 of 3" from a January purchase arrives dated January. Filtering by that date filed the charge under January and the month it actually leaves never saw it.
What the contract still owes becomes a forecast row. "1 of 2" on a May invoice is the
bank stating that June has the second one. Those live in their own table, never in
transactions: inside it, every query that sums money would have to learn to exclude
them, and the first one forgotten makes a total lie with no test going red. When June's
invoice arrives the chain advances, June stops being projected, and the forecast
disappears. The reconciliation is the recomputation, so nothing has to match a forecast
against a real charge and no matching rule can be wrong.
A month with no transactions did not score zero — it was not imported. Zero is a measurement. Showing "R$ 0.00" for the current month with a stale ledger claims nothing came in or went out. The screen shows an em dash instead, and the position tells you which date it is from: a balance carried forward from six weeks ago is not "available now".
Ending a recurrence keeps the months it already hit. ended_at is a soft close, the
same gesture accounts and positions use. Unmarking says "this was never recurring" and
removes it everywhere; ending says "it ran until here". The plan (until_month) and the
fact (ended_at) are separate columns, because overwriting one with the other would erase
that the thing had been declared open-ended.
These rules live in one place in SQL (backend/src/db/ledgerSql.ts) rather than being
re-typed per query, because a copy that drifts makes two widgets disagree without failing
a single test.
Every rule above is enforced twice: as a unit test, and as an audit query that runs
against the live database (npm run audit, 19 checks). If a total on screen is lying,
the audit says so and exits non-zero.
| Investment drill-down — yield computed across 16 dated measurements |
|---|
![]() |
| What is already committed — the list that adds up to the number, opened from the month summary |
|---|
![]() |
| Since the beginning — the only view that ignores the month selector on purpose |
|---|
![]() |
The line here is public versus yours, not real versus generic.
An adapter (backend/src/banks/) describes what an institution publishes: its name,
which file format each account type exports, and how its export filenames look. That is
public fact, like the name of a dependency, so it lives in the code. Inter and Nubank ship
with one; adding another is about fifteen lines, because adapters carry no parser. Parsers
are keyed by format, and banks that export the same CSV share one.
The registry is a shortcut, never a gatekeeper. A bank without an adapter still works: the account keeps the label you typed and the importer sniffs the file. Rejecting the unknown would turn "no adapter yet" into "unusable".
Which accounts are yours stays out of the repo. Account ids, nicknames, opening
balances, archive filename patterns, investment keywords and the derived savings position
live in config/default.json — a sample install, not a catalogue. Copy it to
config/local.json (git-ignored) and it wins. Format resolution goes config first, then
the adapter, then sniffing: whoever declared a format by hand did so because the guess was
wrong.
No transaction, merchant, account number or document ever enters a versioned file, tests
and fixtures included. One exception is deliberate: migration 0004 renames an old
derived-savings key that predates the config split. It has to match the literal already
written in existing databases, or the rename silently creates a second savings position
and doubles it in net worth. A migration is history, and history does not get to be
generic.
Parsers are named after file formats, not institutions. statementWithIds reads a
CSV that carries a unique id per row — which is what makes deduplication exact.
statementWithBalance reads a CSV with a running balance column and verifies it line by
line, refusing to import numbers that do not add up. A bank you have never heard of that
exports either shape needs no code, only an entry in accounts.
This split is also why the invariants above are testable: the rules are about money, the config is about you.
Requires Node ≥ 26 (native type-stripping — the project has no build step).
Try it with no data of your own — a synthetic 24-month ledger ships with the project:
cd backend
npm install # one dependency: xlsx
npm run demo # builds the synthetic ledger at data/demo.db
npm start -- data/demo.db # http://127.0.0.1:8000The demo generator is not a fixture dump: it feeds transactions through the same production modules the real importer uses (SELF pairing, savings derivation, itemized invoice, payment reconciliation) and then runs the invariant audit against what it produced, failing if anything broke. That is also why it is a CI step.
For your own ledger, declare your accounts in config/local.json and run:
npm start # serves data/brokershark-v2.db, the defaultThe dashboard is the way in. Statements, card invoices and broker reports are all imported through the UI, with preview and dedup before anything is written. Rebuilding from a directory of exports is optional — it recovers a whole history, it is not how you feed the thing day to day:
npm run backfill "<archive dir>"The server binds 127.0.0.1:8000 (PORT in the env, or --port N).
BROKERSHARK_IDLE_EXIT=<seconds> makes the process exit on its own after that
long with no dashboard open — the server knows because every open dashboard holds
an SSE connection on /api/events. It is what lets the thing run as an on-demand
service that gives its memory back when you close the tab. Opt-in: without the
variable the server stays up, which is what you want while debugging.
npm test # node:test, backend + frontend
npm run audit # invariant checks against the live database, read-onlyPure domain logic (domain/, frontend/js/domain/) has no database or IO, so the part
that decides what money means is tested without infrastructure.
bank exports (CSV / XLSX)
↓
parsers + backfill → SQLite (WAL, foreign_keys=ON, chmod 0600)
↓
node:http + SSE → React frontend (no build step)
| Layer | Choice |
|---|---|
| Language | TypeScript on Node ≥ 26, native type-stripping, no bundler |
| Database | SQLite via the builtin node:sqlite |
| Server | node:http plus a URLPattern-based router and SSE — zero dependencies |
| Frontend | React 18, vendored, plain hyperscript (never JSX), no CDN |
| Dependencies | One: xlsx, to read broker reports |
Bind is 127.0.0.1 only, with a Host allowlist (anti DNS-rebinding) and an Origin
allowlist on every non-GET method (anti-CSRF). The database file is chmod 0600: with no
auth layer, file permissions are the at-rest boundary. The frontend is fully vendored, so
a request to an external host is by definition illegitimate and the CSP blocks it.
- Multi-user, accounts, sync. One person, one machine.
- Mobile. The screen is a dense desktop panel and does not pretend otherwise.
- Bank APIs. Open Finance would remove the file import, and add a credential surface this project deliberately does not have.
- A desktop wrapper. There was one; it was removed. A browser already solves it, and a second way to run the app is a second process lifecycle to keep alive.
No ledger, statement, or export is versioned here, and none ever was — data/ has been
in .gitignore since the first commit. The history was rewritten before this repository
went public to remove personal details that had leaked into test fixtures. Screenshots
and demo figures are synthetic.
Published for evaluation and study — read it, run it, learn from it. It is not open source: use in a product, or redistribution, needs written permission. See LICENSE.
Built by Felipe Artur with Claude Code. The rules above are written down and enforced precisely because an AI agent works on this code every day: an invariant that only lives in someone's head survives exactly one refactor. What is in this repository is what is built — there are no plans or roadmaps here, only the code and the reasoning behind it.




{ "accounts": [ { "id": "conta-a", "bank": "Banco A", "type": "checking", "name": "Banco A Conta", "statementFormat": "ids" }, { "id": "cartao-b", "bank": "Banco B", "type": "credit_card", "name": "Banco B Cartão", "invoiceFormat": "itemized", "paidFrom": "conta-b" } ] }