TehRiehlBudget
A self-hosted personal finance application for tracking spending, balances, and net worth across all of your accounts — checking, savings, credit, loans, and investments — with manual transaction entry, receipt uploads, and a conversational AI advisor running entirely on your own hardware.
Deployed at budget.tehriehldeal.com.
Features
- Accounts — track checking, savings, cash, credit cards, loans, stocks, brokerage, and retirement accounts. Drag-to-reorder, per-account history, and a balance integrity check that reverses counter-party transfers when an account is deleted.
- Transactions — record income, expenses, and transfers between accounts. Filter by account, category, type, and date. Attach receipt images. Edit, delete, and bulk-export to CSV.
- Activity history — every create / update / delete is recorded with a snapshot, so deleted transactions and accounts remain auditable for as long as you need them. Filter by entity, action, account, or date range.
- CSV export — pull a date-ranged transaction set out as CSV from the global list or scoped to a single account, with quick-range presets (last 30/60/90 days, this year, all time) plus custom start/end pickers.
- Confirmation guards — every destructive action prompts before running, with light/dark mode parity.
- AI advisor — a "financial buddy" that summarizes your month against last month and answers follow-ups. Runs against a local Ollama model — no transaction data leaves your network.
- Dashboards — net worth and total debt at a glance, spending-by-category pie, monthly cash flow bar chart, and per-account balance history with overlaid valuations.
- Receipts — store receipt images on your own filesystem, never on a third-party blob store.
- Field-level encryption — sensitive columns (account numbers, transaction notes) are AES-256-GCM encrypted at rest.
Tech Stack
- Frontend — React 19 + TypeScript, Vite, TailwindCSS + ShadCN UI, Zustand, Recharts, React Router
- Backend — NestJS (TypeScript), REST, Prisma ORM
- Database — PostgreSQL (Dockerized for local dev)
- Auth — self-hosted email/password: bcrypt password hashes and server-side sessions in Postgres, carried in an httpOnly cookie
- AI — Ollama (self-hosted; defaults to
llama3) - File storage — local filesystem on the self-hosted server
- Package manager — pnpm workspaces
Project Structure
tehriehlbudget/
├── tehriehlbudget-frontend/ # React + Vite app
├── tehriehlbudget-backend/ # NestJS API + Prisma schema
├── docker-compose.yml # Postgres for local dev
├── pnpm-workspace.yaml
└── package.json # Workspace-level scripts
Prerequisites
- Node.js 24 LTS (CI and the container images run Node 24)
- pnpm 9+
- Docker (for local Postgres)
- An Ollama server reachable from the backend (local or LAN) for the AI advisor
Quick Start
# Clone and install
git clone <your-fork-url>
cd tehriehlbudget
pnpm install
# Start local Postgres
docker-compose up -d
# Configure the backend
cp tehriehlbudget-backend/.env.example tehriehlbudget-backend/.env
# Edit .env to set DATABASE_URL, ENCRYPTION_KEY, OLLAMA_URL, OLLAMA_MODEL
# (set ALLOW_SIGNUP=true while you create your first account)
# Generate the Prisma client (Prisma 7 never generates on install; rerun after
# every schema change) and apply database migrations
pnpm --filter tehriehlbudget-backend prisma generate
pnpm --filter tehriehlbudget-backend prisma migrate deploy
# Configure the frontend
cp tehriehlbudget-frontend/.env.example tehriehlbudget-frontend/.env
# Edit .env to set VITE_API_URL
# Run both servers (in separate terminals)
pnpm dev:backend
pnpm dev:frontend
The frontend is served at http://localhost:5173 and the API at http://localhost:3000.
Required environment variables
Backend (tehriehlbudget-backend/.env)
| Variable | Description |
|---|---|
DATABASE_URL |
Postgres connection string |
ENCRYPTION_KEY |
32-byte hex key for AES-256-GCM field encryption |
OLLAMA_URL |
URL of your Ollama server (e.g. http://localhost:11434) |
OLLAMA_MODEL |
Ollama model id (e.g. llama3.2:latest) |
CORS_ORIGIN |
Frontend origin allowed to call the API with credentials (default http://localhost:5173) |
ALLOW_SIGNUP |
true enables the Signup page and POST /api/auth/signup; anything else disables it (the default) |
TRUST_PROXY_HOPS |
Number of reverse proxies in front of the API, so login rate limiting sees real client IPs (default 1) |
Frontend (tehriehlbudget-frontend/.env)
| Variable | Description |
|---|---|
VITE_API_URL |
Backend URL including the /api prefix (e.g. http://localhost:3000/api) |
The frontend and API must be on the same site: the same origin, or subdomains of one domain such as budget.example.com and budgetapi.example.com. The session cookie is SameSite=Lax, so browsers will not send it to an API on a different domain.
Common Commands
# Run dev servers
pnpm dev:frontend
pnpm dev:backend
# Build
pnpm build:frontend
pnpm build:backend
# Tests
pnpm test # both packages
pnpm test:backend # Vitest
pnpm test:frontend # Vitest
pnpm test:coverage # both, with coverage (fails below 90%)
# Lint & format
pnpm lint
pnpm format
# Prisma
pnpm --filter tehriehlbudget-backend prisma migrate dev
pnpm --filter tehriehlbudget-backend prisma studio
# Reset a user's password (prompts for the new one; needs a build first).
# Inside the backend container: node dist/cli/set-password.js <email>
pnpm --filter tehriehlbudget-backend auth:set-password you@example.com
The backend container runs prisma migrate deploy on every start, so deploying a new image applies any pending migrations.
Coverage must stay at or above 90% (lines, branches, functions, statements) in both packages. One file has a recorded per-file branch floor of 85%:\n\n- tehriehlbudget-frontend/src/components/ReceiptViewer.tsx — the remaining branches are defensive fallbacks the UI can't reach (explained in vitest.config.ts).
The backend auth e2e suite (test/auth.e2e-spec.ts) needs a real Postgres: CI runs it against a service container; locally run docker-compose up -d, prisma migrate deploy, then pnpm --filter tehriehlbudget-backend test:e2e auth.
The backend is native ESM ("type": "module"): relative imports in src, test and prisma/seed.ts carry an explicit .js extension. Vitest compiles with SWC (which does not type-check), so pnpm --filter tehriehlbudget-backend typecheck runs tsc --noEmit and CI runs it in the lint job. The seed script runs with tsx (pnpm --filter tehriehlbudget-backend prisma:seed, which is prisma db seed; Prisma 7 no longer seeds automatically after migrate).
The backend uses Prisma 7 with the pg driver adapter. prisma.config.ts holds the schema path, migrations path, seed command and DATABASE_URL, and loads .env itself through dotenv. DATABASE_URL is read without requiring it, so prisma generate works with no database configured, while commands that connect (such as migrate deploy) fail without it. The generated client lives in tehriehlbudget-backend/src/generated/prisma (gitignored): run prisma generate after installing and after editing schema.prisma, before the typecheck, tests or build. It is compiled into dist/ with the rest of the app, so the Docker image has no generate step. The root package.json has pnpm.overrides entries that lift two transitive dependencies of the Prisma CLI past known CVEs: deepmerge-ts (used by @prisma/config to merge plain config objects) to 8.x, and mysql2 (only bundled for Prisma's local dev tooling, this app uses Postgres) to a patched 3.x. Drop them once Prisma ships fixed versions.
Architecture Notes
- Field-level encryption. A NestJS interceptor encrypts sensitive columns on write and decrypts on read using AES-256-GCM. The plaintext only ever exists in memory inside the request handler.
- Receipt storage. Images are stored on the server's local filesystem. The backend issues access-controlled URLs for upload and retrieval — there is no S3 / external blob store.
- Balance integrity on delete. When an account is deleted, related transfers' counter-party balances are reversed inside the same Prisma
$transactionbefore the cascade fires, so a surviving account never reflects a transfer that no longer exists. - Audit log.
ActivityLogrows capture create / update / delete actions for transactions, accounts, and account valuations. For account deletions the per-transaction snapshots are written before the cascade so the trail outlives the data. - AI advisor. The advisor builds a per-request snapshot of standing balances, monthly flow numbers, and top spending categories, strips PII, and sends it to Ollama. The prompt explicitly separates point-in-time balances from monthly flow and instructs the model to never invent equations or derive new figures — your transaction data does not leave your network.
- Auth. Passwords are stored as bcrypt hashes (cost 12); older, cheaper hashes (such as ones imported from Supabase) are re-hashed at cost 12 the next time that user logs in. Passwords are limited to 72 bytes, bcrypt's input limit, and longer ones are rejected rather than silently truncated. Logging in creates a row in
sessionsholding the SHA-256 of a random 32-byte token, and the raw token goes to the browser as an httpOnly,Securecookie named__Host-tb_session(the__Host-prefix stops a sibling subdomain from planting its own session cookie). SetNODE_ENV=developmentfor local plain-HTTP work; the cookie is then non-Secureand namedtb_session. Every guarded request looks up that hash, so logging out (or deleting the row) revokes the session immediately. "Stay logged in" issues a 30-day persistent cookie; otherwise the cookie ends with the browser session and the server expires it after 12 hours. State-changing requests must carry anX-Requested-Withheader, which pages on other origins can't send without passing CORS. Login and signup are rate limited to 5 attempts per minute per IP; the counter is in memory, so it resets when the backend restarts. There is no email-based password reset — an admin resets passwords with theauth:set-passwordcommand, which also signs that user out everywhere.
Migrating from Supabase Auth (one-time)
Versions before 0.5.0 used Supabase for login. User data was always keyed by the app's own user id, so nothing moves — only the password hashes are copied over, and everyone keeps their current password.
- Back up the database:
docker exec tehriehlbudget-postgres pg_dump -U <user> <db> > budget-backup.sql. - In the Supabase SQL editor, run the query below and use Download CSV. The file contains password hashes: keep it out of the repo and delete it when you're done.
SELECT id, email, encrypted_password FROM auth.users WHERE deleted_at IS NULL AND encrypted_password IS NOT NULL AND encrypted_password <> ''; - Remove the
SUPABASE_*/VITE_SUPABASE_*variables, addALLOW_SIGNUPandTRUST_PROXY_HOPS, and deploy the new images. The backend applies the migration on start. - Copy the CSV into the backend container and import it:
Users are matched by Supabase id, then by email. The command prints how many users were updated and lists unmatched emails and rows without a valid bcrypt hash. It only fills empty hashes unless you pass
docker cp supabase-users.csv tehriehlbudget-backend:/tmp/ docker exec tehriehlbudget-backend node dist/cli/import-supabase-passwords.js /tmp/supabase-users.csv docker exec tehriehlbudget-backend rm /tmp/supabase-users.csv--overwrite, so it is safe to re-run. - For anyone it missed:
docker exec -it tehriehlbudget-backend node dist/cli/set-password.js <email>. - Everyone signs in once; old Supabase sessions are ignored. To roll back, restore the
SUPABASE_*/VITE_SUPABASE_*variables and redeploy the previous image tags. The migration only adds a column and a table and makessupabase_idnullable, so older images start against it, but they expect every user to have asupabase_id: any user created after the switch (a new signup, or one set up only withset-password) will break the old image until you delete that row or restore the backup from step 1.
The supabase_id column is kept for one release so the import can match on it; a later migration drops it.