- Add four portfolio charts: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar, return % bar - Fix asset search to use yf.Search() full-text instead of ticker-only lookup — name searches like "vanguard ftse all world" now work - Fix holding creation double-quantity bug: holdings now created with quantity=0 so buy transaction is sole source of quantity/cost basis - Add per-share / total price toggle in Add Holding modal with live calculated equivalent shown as you type - Add ErrorBoundary in AppShell so render errors show a message instead of a blank page - Fix donut charts using || instead of ?? when falling back from current_value to cost_basis_total (0 was not falling through ??) - Allow HoldingCreate.quantity >= 0 (was gt=0) to support zero-init - Fix error display for Pydantic v2 array-of-objects validation errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.5 KiB
7.5 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
MyMidas — a self-hosted personal finance tracker. Full-stack: FastAPI backend + React/TypeScript frontend, running in Docker Compose with PostgreSQL and Redis.
Commands
Backend (Python 3.12 + FastAPI)
# Dev server (from backend/)
python -m uvicorn app.main:app --reload --port 8080
# Apply migrations
python -m alembic upgrade head
# Create a new migration
python -m alembic revision --autogenerate -m "description"
# Run tests
pytest tests/
pytest tests/test_foo.py::test_bar # single test
Frontend (React 18 + Vite)
cd frontend
npm run dev # Vite dev server on :5173, proxies /api → localhost:8080
npm run build # TypeScript check + production build
npm run lint # ESLint
Docker (production)
docker compose up -d --build # Full rebuild and start
docker compose up -d --build backend # Rebuild backend only
docker compose logs -f backend # Follow backend logs
Architecture
Request flow
Browser → Frontend nginx (:4000) or Vite dev (:5173)
→ /api/* proxied to FastAPI (:8000 in Docker, :8080 in dev)
→ Middleware: SecurityHeaders → CSRF → CORSMiddleware
→ Rate limiter (Redis sliding window)
→ Route handler → get_current_user dependency
→ Service layer (business logic + encryption)
→ AsyncSession → PostgreSQL (RLS enforced)
Auth architecture
- JWT: RS256, 15-min access tokens + 7-day HttpOnly refresh cookie
get_current_userdependency validates the JWT, checks session exists and isn't revoked in DB, then callsSET LOCAL app.current_user_idto activate PostgreSQL RLS- TOTP: optional TOTP on login; before TOTP is verified, a short-lived
challenge_tokenis issued - CSRF: double-submit cookie — backend sets
csrf_tokencookie on first GET; frontend reads it and sendsX-CSRF-Tokenheader on all mutating requests
Backend layout
backend/app/
main.py — FastAPI app factory (middleware stack, router include)
config.py — Pydantic Settings from env vars
dependencies.py — get_db, get_redis, get_current_user
api/
router.py — Central router; investments/reports/budgets have no prefix (paths self-contained)
v1/ — One file per domain (auth, accounts, transactions, budgets, reports, investments, predictions, settings)
db/models/ — SQLAlchemy 2.0 Mapped models
schemas/ — Pydantic request/response models (separate Create/Update/Response per domain)
services/ — Business logic; each service owns one domain
core/
security.py — Crypto primitives: Argon2id, RS256 JWT, AES-256-GCM, TOTP
middleware.py — CSRFMiddleware, SecurityHeadersMiddleware
workers/
scheduler.py — APScheduler in-process jobs
ml/ — Prophet/SARIMA forecasts, Monte Carlo simulations
Service layer conventions
- All DB ops use
AsyncSession+await - PII fields (account name, transaction description/merchant/notes, TOTP secret) are AES-256-GCM encrypted; stored as
byteanamed with_encsuffix (e.g.description_enc). Useencrypt_field/decrypt_fieldfromcore/security.py - Import deduplication via SHA-256
import_hashon transactions - Every mutation writes to
AuditLog(append-only; app role has no UPDATE/DELETE on that table) - Soft deletes:
deleted_attimestamp; all queries must filterWHERE deleted_at IS NULL _to_response()intransaction_service.pymust include all fields returned to the frontend — omitting a field here makes it invisible to the UI even if it's in the DB
Investment data model
- Asset search uses
yf.Search(query)(full-text, name or ticker) inprice_feed_service.search_yahoo()— not ticker-only lookup HoldingCreate.quantityallowsge=0(zero) — holdings are created with quantity=0, andadd_investment_transaction()drives the quantity via cumulative buy/sell updates. Never create a holding with the target quantity and also add a matching buy transaction, or the quantity doublesPortfolioCharts.tsxuses||(not??) when falling back fromcurrent_valuetocost_basis_total—??fails whencurrent_valueis0rather than null
AI / receipt parsing (api/v1/settings.py, api/v1/transactions.py)
- User AI config (provider, encrypted API key, base URL, model, debug flag) lives on the
userstable; managed viaGET/PUT/DELETE /settings/ai ai_api_key_encis AES-256-GCM encrypted withencrypt_field/decrypt_field- Receipt parsing pipeline in
_call_ai_parse(): OCR text extraction (_extract_ocr_text) → AI text prompt → rule-based fallback (_rule_based_parse)- Images: pytesseract; PDFs: pdfplumber (text layer) → pdf2image + tesseract (scanned fallback)
- AI receives OCR text, not the image — works with any text model, not just vision models
_RECEIPT_TEXT_PROMPTuses.format(ocr_text=...)— escape literal braces in the JSON example with{{and}}
POST /transactions/parse-receipt— scan without an existing transaction (used by "Scan Receipt" toolbar button)POST /transactions/{id}/attachments/{att_id}/parse— parse an already-uploaded attachmentai_debugboolean on user controls whether the OCR/AI debug panel shows in the transaction form; checkaiSettings?.debugon the frontend via the["ai-settings"]query key
CSRF cookie
- Set with
secure=Falseandmax_age=604800(7 days) intentionally — the CSRF token is a public value readable by JS;Securewould break HTTP deployments. Session/auth cookies remain properly secured.
Frontend layout
frontend/src/
api/ — Typed axios clients per domain; all use the shared client in api/client.ts
pages/ — Route-level components (one directory per domain)
components/ — Shared UI (layout, charts, modals)
store/ — Zustand: authStore (token/userId/displayName), uiStore (theme/sidebar/currency)
utils/ — Currency formatting, cn() class helper
Frontend data fetching
TanStack Query (React Query v5) for all server state. queryKey conventions determine cache invalidation — always invalidate the correct key after mutations. The axios client in api/client.ts handles token injection, CSRF header, and 401 auto-refresh transparently.
Background jobs (APScheduler, in-process)
- Every 15 min: sync asset prices (yfinance + CoinGecko)
- Every 60 min: sync FX rates
- 2 AM daily: net worth snapshot
- 3 AM daily: encrypted GPG backup
- Sunday weekly: retrain ML prediction models
Database patterns
- UUID PKs everywhere
- PostgreSQL RLS policies on every table keyed to
app.current_user_id(set per-request byget_current_user) postgres/init/contains init SQL; alembic manages schema evolution- Migrations run automatically on container start (
alembic upgrade headin Dockerfile CMD)
Themes
7 CSS variable themes in frontend/src/index.css: obsidian (default dark), arctic (light), midnight, vault, terminal, synthwave, ledger. Applied as a class on <html>.
Environment variables
See .env.example for the full list. Key ones:
ENCRYPTION_KEY— 32-byte base64 for AES-256-GCM field encryptionDATABASE_URL— asyncpg connection stringENVIRONMENT—developmentenables/docs,/redoc,/openapi.jsonand relaxes CORSALLOW_REGISTRATION— gates the register endpoint (defaultfalsein prod)BASE_CURRENCY— default display currency (defaultGBP)