Initial: add implementation plan
This commit is contained in:
@@ -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 |
|
||||
Reference in New Issue
Block a user