← cs
$ cat projects/Mizan.md

Mizān: Local-First Personal Finance Platform

Self-hosted finance app syncing bank/crypto accounts with AES-256-GCM credential encryption and zero telemetry.

2026-06-01
TypeScriptExpressReactSQLite

Mizān: Local-First Personal Finance Platform

A personal finance app for one person, running on that person's own machine. No accounts, no multi-tenancy, no auth layer, no cloud database. Roughly what I wanted Monarch Money to be if it never phoned home.

The constraint that made it interesting is that the AI is structural rather than bolted on. Once an Anthropic key is configured it categorizes transactions and writes merchant rules on its own, with no confirmation step, and every one of those actions is listed and reversible in Settings. Deciding where that line goes was the hardest design problem in the project.

Where the data comes from

SimpleFIN Bridge gives read-only access to thousands of institutions without anyone ever handling bank credentials directly, which is why I chose it over screen-scraping. Coinbase's Advanced Trade API covers crypto balances, per-coin holdings, and filled trade history. Anything else goes in by CSV or by hand.

Everything lives in a project-local .mizan/ directory:

.mizan/
  mizan.db            SQLite database
  credentials.json    AES-256-GCM encrypted API credentials
  logs/               Structured server logs

Credentials are AES-256-GCM encrypted at rest. The key lives in the OS keychain through @napi-rs/keyring, falling back to a 0600 key file if the keychain is unavailable, so there is no master password to remember and no plaintext secret on disk either way.

Data leaves the machine for exactly three destinations: SimpleFIN for transactions, Coinbase (plus its public unauthenticated spot-price endpoint) for balances, and Anthropic for the advisor.

Three AI surfaces, not three modes

These are separate code paths with different cost and latency profiles, and conflating them is how a privacy claim turns into marketing.

Local heuristics are regex and SQL driven, sub-millisecond, and make no network call. They power the Cmd+K palette and produce the structured citations and one-click drafts shown in the advisor and in Review. This layer works with no API key at all, so the app is fully usable by someone who never wants to talk to a model.

The cloud chat streams from Claude Sonnet 5 over SSE with adaptive extended thinking, with a financial context snapshot injected behind a prompt cache. Every message calls the LLM and the local heuristic in parallel: the model writes the answer, the heuristic supplies the citations. With no key it degrades to heuristic-only rather than erroring.

The background worker runs Claude Haiku 4.5 after each sync and proposes drafts from the sync delta. No key, no worker, no error.

Splitting those at the route level is what makes the privacy boundary enforceable in code instead of in a policy document.

What the AI does unattended

The boundary is drawn by domain, not by a confidence threshold. Categorizing a transaction and writing a merchant rule apply on arrival, because both are observations about data that already exists. Everything else, changing a budget, a goal target, a cost basis, waits for an explicit confirm, because those change a target the owner set rather than describing what is already there.

Undo has to cover the real blast radius for that to be safe. Every touched row records the action that touched it and the category it displaced, so undoing a merchant rule reverts every row the rule swept in, not just the one that triggered it. Rows edited by hand since are skipped: a manual edit clears the action link precisely so an undo cannot reach back through a human decision.

Two bugs worth keeping around

The context builder used to abbreviate anything over $1,000, so $2,749.39 became $2.7k, while the system prompt told the model never to fabricate numbers. The model faithfully reported the abbreviation. It now renders every figure to the cent.

The advisor's aggregate tools used to run their own SQL instead of calling the services the UI renders from, and drifted. On identical data the advisor reported $1,695.00 of spending where the Reports page reported $75.00, because it was counting transfers, resolved duplicates, pending rows, and crypto purchases that Reports excludes. Any new aggregate now belongs in the shared service.

Stack

Express and better-sqlite3 on the server, React with Vite, TanStack Query, and Zustand on the client, with shared Zod schemas across both. In development a single process serves the API and the Vite middleware on one port, so there is no separate client dev server to run. Migrations are numbered SQL files applied on startup.

There is no authentication layer, by design: one owner, one machine. It binds to loopback and rejects unrecognized Host headers and cross-origin state-changing requests. Those guards are about integrity rather than secrecy. If you expose the port, anything that can reach it can read and write your ledger.

Coinbase OAuth for wallet-level transactions (sends, receives, Earn rewards) is currently closed to new app registrations, so those are the one gap. They go in when it reopens.