A self-hosted personal finance tracker built for two users (owner + partner), running on a baremetal Kubernetes homelab. Tracks income, expenses, and transfers across Philippine bank accounts, credit cards, GCash, Maya, and cash.
Currency: Philippine Peso (₱) · Users: 2 (private, invite-only) · Platform: Self-hosted
| Area | Capability |
|---|---|
| Institutions | Banks, digital wallets, government agencies, in-house developers — normalized anchor for all financial entities |
| Accounts | Savings, checking, wallet, credit card, loan, cash — linked to institutions, balance computed from transactions |
| Credit Lines | Shared credit limit across multiple cards; institution-linked; standalone cards also supported |
| Credit Cards | Statement periods auto-calculated from billing/due day; statement due tracking; credit line grouping |
| Loans | Auto, housing, personal, education — schema ready (UI in future sprint) |
| Transactions | Manual entry with 22+ sub-types (salary, 13th month, bills, ATM withdrawal, transfers, etc.) |
| Budgets | Per-category and per-account monthly limits with 80%/100% alerts |
| Smart Input | Upload receipt or PDF → copy AI prompt → paste response → review and import |
| Notifications | In-app bell with unread badge; budget warnings and statement due reminders; Discord webhook |
| Analytics | Spending-by-category pie chart; per-card statement history bar chart; net worth snapshot |
| Dashboard | Monthly income/expense summary, net worth breakdown, 10 most recent transactions |
| Layer | Technology |
|---|---|
| Frontend | Next.js 16 · Tailwind CSS 4 · shadcn/ui · Recharts |
| Backend | FastAPI 0.129 · Python 3.14 |
| ORM | SQLAlchemy 2.0 async |
| Job Queue | Celery 5.6 · Redis 8 |
| Database | PostgreSQL 18 (uuidv7 for all PKs) |
| Package (Python) | uv |
| Package (Node) | bun |
| Infrastructure | Docker Compose (dev) · Kustomize/Kubernetes (prod) |
fintrack/
├── api/ # FastAPI backend + Celery worker
│ ├── app/
│ │ ├── core/ # config, database, security, JWT
│ │ ├── models/ # SQLAlchemy ORM models
│ │ ├── schemas/ # Pydantic v2 request/response schemas
│ │ ├── routers/ # Route handlers (one file per domain)
│ │ ├── services/ # Business logic (balance, budget alerts, Discord)
│ │ └── tasks/ # Celery tasks + beat schedule
│ ├── migrations/ # Alembic versions + seed data
│ └── tests/ # 181 async API tests
├── frontend/ # Next.js 16 app (App Router)
│ └── src/
│ ├── app/
│ │ ├── (auth)/ # /login, /register
│ │ └── (dashboard)/ # all authenticated pages
│ ├── components/ # shared UI components
│ ├── hooks/ # useAuth
│ ├── lib/ # api client, utils
│ └── types/ # TypeScript interfaces
├── k8s/ # Kustomize manifests (base + dev/prod overlays)
├── scripts/ # Utilities (Notion import, seed data)
├── docs/
│ ├── plans/ # Design docs and implementation plans
│ ├── USER_GUIDE.md # How to use the app
│ └── KNOWN_ISSUES.md # Active bugs and UX debt
├── docker-compose.yml # Local development
└── .env.example
# 1. Clone and configure environment
git clone <repo-url>
cd fintrack
cp .env.example .env
# Edit .env — set JWT_SECRET_KEY (see below)
# 2. Generate a secret key
openssl rand -hex 32
# Paste the output into JWT_SECRET_KEY in .env
# 3. Start all services
docker compose up -d --build
# 4. Run database migrations (first time only)
docker compose run --rm api alembic upgrade head
# 5. (Optional) Seed sample data — Philippine banks, accounts, credit cards
uv run --with httpx python scripts/seed.pyAll services run via docker compose. No local Python/Node install needed for development.
| Service | Container | Port | Notes |
|---|---|---|---|
| Frontend | frontend |
3000 | Next.js 16 dev server (bun) |
| API | api |
8000 | FastAPI + Uvicorn (hot reload) |
| Worker | worker |
— | Celery worker (same image as API) |
| Beat | beat |
— | Celery beat scheduler |
| PostgreSQL | postgres |
5435 → 5432 | Data in postgres_data volume |
| Redis | redis |
6379 | Celery broker + cache |
All containers have restart: unless-stopped — they survive Docker Desktop restarts.
WSL2 note: Use
127.0.0.1instead oflocalhostwhen connecting to Postgres from the host (e.g. alembic) — asyncpg has intermittent DNS failures withlocalhoston WSL2.
- Register your account at http://localhost:3000/register
- Go to Institutions → add your banks and financial services (BPI, GCash, etc.)
- Go to Accounts → create accounts linked to your institutions
- Go to Cards → add credit cards (standalone or grouped under a credit line)
- Go to Budgets → set monthly spending limits per category
- Start recording transactions manually or via the AI import flow
Or run the seed script (scripts/seed.py) to populate all of the above with sample Philippine financial data.
See docs/USER_GUIDE.md for the full walkthrough.
# Recommended — run inside Docker (no local env vars needed)
docker compose run --rm api pytest
# Or run locally (requires Postgres on 127.0.0.1:5435)
cd api
DATABASE_URL="postgresql+asyncpg://finance:changeme@127.0.0.1:5435/finance_db" \
TEST_DATABASE_URL="postgresql+asyncpg://finance:changeme@127.0.0.1:5435/finance_test" \
uv run pytest -v181 tests, all passing. Tests use an isolated finance_test database that is created and torn down per session.
Copy .env.example to .env and configure:
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL asyncpg connection string |
TEST_DATABASE_URL |
No | Test DB connection (derived from DATABASE_URL if empty) |
JWT_SECRET_KEY |
Yes | Generate: openssl rand -hex 32 |
JWT_ALGORITHM |
No | Default: HS256 |
JWT_ACCESS_TOKEN_EXPIRE_MINUTES |
No | Default: 30 |
JWT_REFRESH_TOKEN_EXPIRE_DAYS |
No | Default: 30 |
REDIS_URL |
Yes | Celery broker — default: redis://redis:6379/0 |
CORS_ORIGINS |
No | Comma-separated allowed origins |
COOKIE_DOMAIN |
No | Cookie domain (leave blank for localhost) |
DISCORD_WEBHOOK_URL |
No | Budget/bill reminders sent here |
VAPID_PRIVATE_KEY |
No | Web Push notifications (generate with npx web-push generate-vapid-keys) |
GEMINI_API_KEY |
No | Reserved for Phase 5 auto-OCR |
CLAUDE_API_KEY |
No | Reserved for Phase 5 auto-OCR fallback |
| Phase | Status | What was built |
|---|---|---|
| Phase 1 — Core | ✅ Done | Auth, institutions, accounts, credit cards/lines, loans (schema), transactions, categories, budgets, dashboard |
| Phase 2 — Smart Input | ✅ Done | Receipt/PDF upload, AI prompt generation, manual paste + review workflow, document history |
| Phase 3 — Notifications | ✅ Done | In-app bell, SSE badge, budget 80%/100% alerts, statement due reminders, Discord webhook |
| Phase 4 — Analytics | ✅ Done | Spending-by-category pie chart, per-card statement history bar chart, net worth card |
| Phase 5 — Auto OCR | ⏳ Planned | Gemini Flash + Claude API automatic extraction (no manual paste) |
| Phase 6 — Ollama | ⏳ Future | Fully local LLM processing, no external API dependency |
See docs/KNOWN_ISSUES.md for current bugs and UX improvements planned before Phase 5.
Institutions are the top-level financial entity. Every account, credit line, and loan links to an institution (bank, digital wallet, government agency, or in-house developer). This avoids duplicating bank names on individual cards and enables grouping across account types (savings + credit card + loan at the same bank).
Credit card payment is a Transfer, not an Expense.
The expense was recorded when you swiped. Logging the payment as an expense would double-count it. Transfer → own_account moves money from your bank account to your credit card account without affecting your expense total.
Manual paste is the primary smart input (Phase 2). Uses your existing Gemini Pro or Claude Max subscription at zero marginal cost. You take a photo, copy a prompt, paste the AI's JSON response. Phase 5 will automate this with the Gemini/Claude API.
Everything runs in Docker Compose.
Six services (postgres, redis, api, worker, beat, frontend) — all containerized. The api, worker, and beat share the same Docker image with different command overrides. No local Python or Node install required for development.
uuidv7() for all primary keys.
PostgreSQL 18 native. Time-ordered — better B-tree index locality than UUIDv4, no hotspot on insert.
PDF password never stored. Passed in-memory to PyMuPDF for decryption, discarded immediately. Never written to disk or database.
No soft delete (by design).
This is a private two-user app. Hard deletes are acceptable. Complexity of soft-delete (filtering deleted_at IS NULL everywhere, ghost records in analytics) is not worth it at this scale.
Kustomize manifests in k8s/. Namespace: expense-tracker.
# Deploy to dev
kubectl apply -k k8s/overlays/dev
# Deploy to prod
kubectl apply -k k8s/overlays/prodPostgreSQL runs as a StatefulSet with a PersistentVolumeClaim. The API and worker share a single Deployment image with different command arguments.
This is a work-in-progress personal project, not production software. Current known issues:
- Session expiry redirects are not implemented — expired tokens cause silent empty states
- The Settings page profile save is non-functional (missing
PATCH /auth/meendpoint) - SSE notifications are one-shot (not a live push stream)
- No transaction text search
- Loans have schema only — no API routes or frontend UI yet
- Mobile layout is incomplete
Full list with priorities: docs/KNOWN_ISSUES.md
Private. Not open source.
This project uses Claude Code with a shared global config — see rommelporras/claude-config for setup instructions before working on a new machine.