Initial: add implementation plan

This commit is contained in:
Markus Eisl
2026-06-30 08:37:45 +02:00
commit a9de3c81a5
+349
View File
@@ -0,0 +1,349 @@
# 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 `splitwise` account represents the running net balance with girlfriend (positive = she owes user, negative = user owes her)
- One or more `receivable` accounts track money owed to user by others
- `manual_override_balance` replaces 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()`
1. Fetch EUR/USD rate from frankfurter.app
2. Fetch all ticker prices via yfinance (with 3 retries, exponential backoff)
3. Update `price_cache`
4. Calculate net worth
5. Write `net_worth_snapshots` row
- **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
1. **Backend foundation** — models, DB init, WAL mode, Alembic baseline migration
2. **Core CRUD API** — accounts, categories, transactions
3. **Net worth calculation** — live calculation endpoint
4. **Price fetching** — yfinance + frankfurter.app + price_cache + APScheduler
5. **Snapshot job** — daily net worth snapshots
6. **Stock/holdings API** — buy/sell/RSU, holdings calculation
7. **Spending API** — monthly aggregation by category
8. **Frontend scaffold** — Vite + Tailwind + shadcn/ui + routing + API client
9. **Dashboard screen** — net worth, accounts, recent transactions
10. **Quick-Add FAB** — all 6 entry flows
11. **Transactions screen** — list + filters
12. **Spending screen** — chart + category breakdown
13. **Portfolio screen** — holdings + trade form
14. **History screen** — net worth chart
15. **Settings screen** — accounts, categories, overrides
16. **Docker Compose** — Dockerfiles + nginx config + compose file
17. **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 |