Building a Self-Hosted Personal Finance Manager

7 min read
Project Full Stack TypeScript

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.