12 KiB
fin — Personal Net Worth Tracker: Implementation Plan
Overview
A mobile-first personal finance webapp for tracking net worth, expenses, investments, and shared expenses (Splitwise). Hosted in homelab via Docker Compose. No auth (internal network only).
Architecture
| Layer | Tech |
|---|---|
| Backend | Python 3.12, FastAPI, SQLAlchemy (sync), Alembic |
| Database | SQLite (WAL mode, named Docker volume) |
| Scheduler | APScheduler + SQLite jobstore (daily price fetch + snapshot) |
| Frontend | React 18 + Vite + Tailwind CSS + shadcn/ui + Recharts |
| Serving | Nginx (serves React build, proxies /api/* → FastAPI on :8000) |
| Stock prices | yfinance — with retry/backoff + last-cached-price fallback |
| FX rates | frankfurter.app (EUR/USD) — cached in DB, served stale on outage |
| Deployment | Docker Compose (2 services: api, web) |
Repository Layout
fin/
├── docker-compose.yml
├── api/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── alembic/
│ ├── alembic.ini
│ └── app/
│ ├── main.py
│ ├── database.py
│ ├── models.py
│ ├── schemas.py
│ ├── routers/
│ │ ├── accounts.py
│ │ ├── transactions.py
│ │ ├── holdings.py
│ │ ├── categories.py
│ │ ├── snapshots.py
│ │ └── prices.py
│ ├── services/
│ │ ├── net_worth.py
│ │ ├── price_fetcher.py
│ │ └── scheduler.py
│ └── crud.py
└── web/
├── Dockerfile
├── nginx.conf
├── package.json
└── src/
├── main.tsx
├── App.tsx
├── api/ # typed API client
├── components/
│ ├── ui/ # shadcn/ui primitives
│ ├── QuickAdd/ # FAB + quick-entry sheets
│ ├── charts/
│ └── layout/
└── pages/
├── Dashboard.tsx
├── Transactions.tsx
├── Spending.tsx
├── Portfolio.tsx
├── History.tsx
└── Settings.tsx
Data Model (7 tables)
accounts
id, name, type, currency, notes,
manual_override_balance (nullable float — overrides calculated balance),
is_active, created_at
Types: bank | investment | rsu | splitwise | receivable
- One
splitwiseaccount represents the running net balance with girlfriend (positive = she owes user, negative = user owes her) - One or more
receivableaccounts track money owed to user by others manual_override_balancereplaces calculated balance in net worth if set
categories
id, name, color (hex), icon (emoji or lucide name)
transactions
id, date, account_id, amount (positive = in, negative = out),
currency, type, category_id (nullable), description,
fee (default 0), exchange_rate_used (nullable),
splitwise_partner_share (nullable — their share of a splitwise txn),
linked_account_id (nullable — transfer destination/source),
created_at
Types: expense | income | transfer | splitwise_pay | splitwise_receive | settlement
holdings
Current stock/ETF positions (maintained by stock_transactions).
id, account_id, ticker, shares, avg_cost_basis_per_share,
currency, updated_at
stock_transactions
id, account_id, ticker, type, shares, price_per_share,
fee, total_cost, date, currency, exchange_rate_used, notes
Types: buy | sell | rsu_vest
price_cache
Prices refreshed daily. On fetch failure, stale value is served.
id, symbol, price, currency, source (yfinance|frankfurter),
fetched_at
net_worth_snapshots
Daily snapshots for the history chart. Created by APScheduler at midnight.
id, snapshot_date (unique), total_eur,
breakdown_json (per-account values + exchange rate used)
Splitwise Accounting Flows
All flows preserve double-entry consistency. Net worth delta = your share only.
A — User pays (e.g. €100 dinner, 50/50 split)
| What | Account | Delta |
|---|---|---|
| Cash leaves bank | bank account | −€100 |
| Girlfriend owes you | splitwise account | +€50 |
| Expense recorded | dining category | −€50 (your share) |
| Net worth impact | −€50 |
Transaction type: splitwise_pay. amount = −100, splitwise_partner_share = 50.
B — Girlfriend pays (e.g. €80 groceries, 50/50 split)
| What | Account | Delta |
|---|---|---|
| No cash movement | — | — |
| You owe girlfriend | splitwise account | −€40 |
| Expense recorded | groceries category | −€40 (your share) |
| Net worth impact | −€40 |
Transaction type: splitwise_receive. amount = −40.
C — Settlement (girlfriend transfers you €10 net)
| What | Account | Delta |
|---|---|---|
| Cash arrives | bank account | +€10 |
| Splitwise clears | splitwise account | −€10 (toward 0) |
| No expense | — | — |
| Net worth impact | 0 (cash ↔ receivable swap) |
Transaction type: settlement. Linked accounts: bank + splitwise.
Net Worth Calculation
net_worth_EUR =
Σ bank accounts
[ manual_override ?? balance ] (USD converted at live rate)
+ Σ investment accounts
[ manual_override ?? Σ (holding.shares × live_price) ] (USD → EUR)
+ splitwise_account.balance (positive or negative)
+ Σ receivable accounts
[ manual_override ?? balance ]
(snapshot saved daily at midnight by APScheduler)
Exchange rate: EUR/USD fetched from frankfurter.app daily. Stale rate used on failure.
Price Fetching Strategy
- On startup: fetch all tracked tickers + EUR/USD rate if cache is stale (>23h old)
- Daily at midnight: APScheduler triggers
fetch_prices_and_snapshot()- Fetch EUR/USD rate from frankfurter.app
- Fetch all ticker prices via yfinance (with 3 retries, exponential backoff)
- Update
price_cache - Calculate net worth
- Write
net_worth_snapshotsrow
- On failure: serve last cached price; log warning; never crash the API
API Endpoints
GET /api/net-worth/current — live net worth + per-account breakdown
GET /api/net-worth/history — snapshots for chart (range param)
GET /api/accounts — list all accounts
POST /api/accounts — create account
PUT /api/accounts/{id} — update (incl. manual_override_balance)
DEL /api/accounts/{id} — soft-delete
GET /api/transactions — list (filter: account, category, month)
POST /api/transactions — create (handles all types)
DEL /api/transactions/{id} — delete
GET /api/categories — list
POST /api/categories — create
PUT /api/categories/{id} — update
DEL /api/categories/{id} — delete
GET /api/holdings/{account_id} — current positions
POST /api/stock-transactions — buy/sell/RSU vest (updates holdings)
GET /api/stock-transactions — history
GET /api/spending/monthly — expense totals by category for a month
GET /api/prices/refresh — manual trigger for price refresh
Mobile Screens (bottom nav, 5 tabs)
1. Dashboard
- Large net worth total in EUR
- Account cards (bank / investment / RSU / splitwise / receivables)
- 5 most recent transactions
- Floating Action Button (FAB) → Quick Add sheet
2. Transactions
- Infinite scroll list, filterable by account / category / month
- Swipe-left to delete
- Tap to expand details
3. Spending
- Month picker (← →)
- Donut chart: spending by category
- List below chart: category name, amount, % of total
- Splitwise share included in your monthly spend
4. Portfolio
- Per-account holdings: ticker, shares, current value, P&L (€ and %)
- Total portfolio value in EUR
- Add trade button → buy/sell/RSU vest form
5. History
- Line chart: net worth over time
- Range selector: 1M / 3M / 6M / 1Y / All
- Hover/tap shows date + value
Settings (gear icon, not in bottom nav)
- Manage accounts (add, edit, set override, deactivate)
- Manage categories (add, edit, reorder, delete)
- Manual price refresh trigger
- Clear manual overrides
Quick-Add Flows (FAB → bottom sheet, 3 taps max)
| Flow | Steps |
|---|---|
| Expense | category → amount → account → (optional description) → Save |
| Splitwise: I paid | total → category → my bank account → Save (auto-splits, records your share as expense) |
| Splitwise: She paid | total → category → Save (auto-debits your Splitwise balance, records your share) |
| Splitwise: Settle | direction → amount → my bank account → Save |
| Transfer | from account → to account → amount → (optional fee) → Save |
| Stock trade | account → ticker → buy/sell/RSU → shares → price → fee → Save |
All amount inputs use inputMode="decimal" for mobile keyboard.
Docker Compose
services:
api:
build: ./api
volumes:
- sqlite_data:/data
environment:
- DATABASE_URL=sqlite:////data/fin.db
restart: unless-stopped
web:
build: ./web
ports:
- "80:80"
depends_on:
- api
restart: unless-stopped
volumes:
sqlite_data:
Nginx in web serves the React build on / and proxies /api/* to api:8000.
Implementation Order
- Backend foundation — models, DB init, WAL mode, Alembic baseline migration
- Core CRUD API — accounts, categories, transactions
- Net worth calculation — live calculation endpoint
- Price fetching — yfinance + frankfurter.app + price_cache + APScheduler
- Snapshot job — daily net worth snapshots
- Stock/holdings API — buy/sell/RSU, holdings calculation
- Spending API — monthly aggregation by category
- Frontend scaffold — Vite + Tailwind + shadcn/ui + routing + API client
- Dashboard screen — net worth, accounts, recent transactions
- Quick-Add FAB — all 6 entry flows
- Transactions screen — list + filters
- Spending screen — chart + category breakdown
- Portfolio screen — holdings + trade form
- History screen — net worth chart
- Settings screen — accounts, categories, overrides
- Docker Compose — Dockerfiles + nginx config + compose file
- Polish — mobile tap targets, loading states, error handling
Key Decisions & Rationale
| Decision | Rationale |
|---|---|
| Sync SQLAlchemy (not async) | aiosqlite adds a threading layer anyway; FastAPI runs sync handlers in threadpool automatically |
| APScheduler over BackgroundTasks | BackgroundTasks is fire-and-forget within a request, not a true scheduler; APScheduler + SQLite jobstore survives restarts |
| WAL mode on SQLite | Multiple concurrent reads (API + scheduler) without locking; mandatory for this pattern |
| Alembic for migrations | Standard; --autogenerate handles schema evolution cleanly |
| shadcn/ui over daisyUI | Fully customizable primitives, tree-shakeable, excellent form components for quick-entry |
| Recharts over Chart.js | Smaller, responsive-first, ResponsiveContainer works well on mobile |
| Stale price fallback | yfinance has no SLA; app must stay usable when Yahoo is down |
| Splitwise as a balance account | Clean separation: Splitwise is a receivable/payable line, not a special case |