Files

12 KiB
Raw Permalink Blame History

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