Building a Self-Hosted Personal Finance Manager
I wanted to know where my money actually goes without handing my bank history to a third-party app. So I built one: a self-hosted finance manager that imports bank CSVs and turns them into reports and charts.
Every budgeting app asks for the same thing — link your bank, trust our cloud, pay a subscription. I did not want any of that. My bank already lets me export transactions as CSV, and that is all the raw data I really need. The rest is just parsing, categorizing, and drawing charts. So I turned it into a weekend-sized project that grew into something I actually use.
The shape of the thing
It is a small monorepo with three workspaces. A Fastify + Prisma +
PostgreSQL API exposes a REST interface with OpenAPI docs. A Vite +
React + TypeScript web client talks to it using TanStack Query and
draws charts with Recharts. And in the middle sits a shared package,
@fm/shared, that holds Zod schemas.
That shared package is the part I am most happy with. The Zod schemas are the single source of truth for every shape that crosses the wire. The API validates requests against them, and the web client infers its TypeScript types from the very same definitions. There is no hand-written type that can quietly drift out of sync with the server. When I change a field, both ends stop compiling until I fix them.
The CSV import problem
The hard part of a finance app is not the charts. It is that every bank exports a different CSV. Some put debits and credits in one signed column, others split them into two. Date formats vary. Column headers are never quite what you expect. A rigid parser would only ever work with one bank.
So the import is a small wizard instead. You upload a file, and the API responds with the detected columns and a few sample rows. You map those columns — which one is the date, the amount, the description, the merchant — and tell it how amounts are signed and how dates are formatted. Then you can save that mapping as a reusable template, so next month the same bank's export just works.
Auto-guessing the mapping
Nobody wants to hand-map columns every single time, so the wizard
tries hard to fill the form in for you. When a file comes in, it
looks at the header names and the sample values and takes its best
guess at what each column is: a header like Date,
Posted, or Transaction Date is almost
certainly the date; Amount, Debit, and
Credit point at the money columns; anything that looks
like free text becomes the description or merchant.
It also guesses the shape of the data, not just the labels. It sniffs whether amounts live in one signed column or split debit/credit columns, and it reads the sample dates to work out the format rather than forcing you to pick from a dropdown. And if you have imported from this bank before, a saved template short-circuits all of it — the guessing only has to be good enough for the first time. In practice most imports are just upload, glance at the pre-filled wizard, and confirm.
Before anything is written, a preview shows the normalized rows: what each transaction will look like, which rows are duplicates of things you already imported, and which category the rules would assign. Only when you commit does it insert the new rows.
Importing the same file twice
Re-imports are where naive finance apps fall apart. Export August, import it, then export August plus a few September rows and import that too — a naive app now has August twice. I wanted imports to be idempotent, so each row gets a dedup hash derived from its meaningful fields. On commit, only rows whose hash it has never seen are inserted. Import the same file ten times and nothing changes after the first.
Every import is also recorded as a batch, so a bad import can be undone as a unit instead of hunting down individual rows.
Categorization without the busywork
Manually tagging hundreds of transactions is exactly the kind of work
that kills a budgeting habit. So categorization is driven by rules —
keyword or regex matches that assign a category automatically as
transactions are imported. A rule that matches UBER maps
to Transport; one that matches your grocery store maps to Groceries.
Anything a rule gets wrong, you override by hand on the individual
transaction. Over a couple of months the rules cover almost
everything and the manual work drops to near zero.
Seeing where it went
Once the data is clean, the reports are the payoff. Spending by category as a donut. Income versus expense as a bar chart over time. Balance as a line. And budgets per category, with alerts when you go over. There is also support for accounts, tags, and recurring transactions, so the picture is closer to reality than a single flat list of rows.
Built to grow, but not over-built
It is single-user today, and I deliberately did not build an auth
system I do not yet need. But every row already carries a
userId, and the whole app reads that value through one
function. The day I want real accounts, I change that one function to
read a verified session instead of a constant — no schema migration,
no rewrite. The same REST API and shared types could back a React
Native client later, too.
That is the balance I keep coming back to in side projects: solve the
problem in front of you cleanly, but leave the seams in the right
places. The whole thing runs with a single
docker compose up, on my own machine, with my data never
leaving it. Which was the entire point.