Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

336 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

BrokerShark

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.

CI

Leia em português

Dashboard

All figures in the screenshots come from the built-in synthetic ledger (npm run demo), not from real accounts.


Why it exists

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.

The interesting part: the invariants

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.

Screenshots

Investment drill-down — yield computed across 16 dated measurements
Investment detail
What is already committed — the list that adds up to the number, opened from the month summary
Committed
Since the beginning — the only view that ignores the month selector on purpose
Lifetime

Banks are adapters; your accounts are configuration

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.

{
  "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" }
  ]
}

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.

Running it

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:8000

The 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 default

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

Testing

npm test      # node:test, backend + frontend
npm run audit # invariant checks against the live database, read-only

Pure domain logic (domain/, frontend/js/domain/) has no database or IO, so the part that decides what money means is tested without infrastructure.

Architecture

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.

Non-goals

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

A note on the data

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.

License

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.

About

Dashboard de finanças pessoais, 100% local, em centavos inteiros

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages