Adds `fin` — a typer/httpx CLI for the deployed API — so Claude Code and other agents can consume fin over HTTP without an MCP server. Backend: - api/app/auth.py: require_api_token dependency (no-op unless FIN_API_TOKEN env is set; RFC 7235 case-insensitive Bearer, constant-time compare). All nine routers wired; /api/health exempt for k8s probes. - GET /api/openapi.json added as an explicit token-gated route (FastAPI's built-in openapi_url bypasses dependency injection). - GET /api/transactions gains an optional `limit` query param (ge=1). CLI (cli/): - fin_cli/client.py: FinClient — sync httpx wrapper, zero typer/rich imports (MCP-ready core for a future MCP server). - Subcommands: networth, accounts, categories, tx, transfer, settle, split-expense, splitwise-paid, reconcile, holdings, stock, rsu, spending, prices. Every command supports --json for agent use. - fin tx add refuses transfer/settlement types to prevent single-leg writes. - Config via FIN_API_URL (required) and FIN_API_TOKEN (optional). Agent skill: .claude/skills/fin/SKILL.md — command map, jq patterns, and domain invariants (two-leg atomicity, net-share splitwise math, derived holdings, funded-buy, soft-deactivate, FIN_API_TOKEN web-UI lockout warning). Tests: 25 e2e tests (FinClient → real routes → temp SQLite) covering auth on/off, all key invariants (two-leg linkage, fee-in-balance, net-share math), transfer whole-group delete, and linked-leg edit rejection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPBt3s7Jyr2nozQTrVVGA4
8.1 KiB
fin — CLAUDE.md
Personal finance webapp for tracking net worth, transactions, and a stock portfolio. Single-user, self-hosted, no auth.
Tech stack
- Backend: Python 3.12, FastAPI, SQLAlchemy (sync), SQLite (WAL mode), Alembic, APScheduler, yfinance, httpx
- Frontend: React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui (Radix UI primitives), Recharts, react-router-dom v6
- Infra: Two Docker containers —
api(uvicorn, port 8000) behindweb(nginx, port 80). Nginx proxies/api/*to the API container. SQLite DB lives in a named volume at/data/fin.db.
How to run
docker compose up --build # full stack
No separate dev server setup exists. The Dockerfile CMD runs alembic upgrade head before starting uvicorn, so migrations apply automatically on container start.
Project structure
api/
app/
main.py # FastAPI app, lifespan (APScheduler start/stop)
models.py # SQLAlchemy ORM models
schemas.py # Pydantic v2 request/response schemas
crud.py # All DB read/write logic (no business logic in routers)
database.py # Engine, SessionLocal, WAL pragma, get_db()
routers/ # One file per resource; thin — validates, calls crud/services
services/
net_worth.py # calculate_net_worth() — reads price_cache + holdings
price_fetcher.py # yfinance + frankfurter.app, upserts price_cache
scheduler.py # APScheduler: daily midnight job (fetch prices + snapshot)
alembic/
versions/ # Migration chain: a1b2c3d4e5f6 → b2c3d4e5f6a7
web/
src/
api/client.ts # Single API layer — all fetch calls go through api.* here
pages/ # Dashboard, Transactions, Spending, Portfolio, History, Settings
components/
QuickAdd/flows/ # One component per transaction type (Expense, Transfer, etc.)
layout/ # Shell layout with nav
ui/ # shadcn/ui components (Button, Card, Dialog, Input, Select…)
cli/
fin_cli/
client.py # FinClient — plain httpx wrapper, no CLI deps (MCP-ready core)
main.py # typer entry point (`fin` command), subcommand modules alongside
tests/ # pytest e2e: FinClient → real app via TestClient → SQLite
CLI (cli/)
fin is a typer CLI that talks HTTP to the deployed API. Configure with FIN_API_URL (required, no default) and FIN_API_TOKEN (only if the API enforces auth — the API checks it only when its own FIN_API_TOKEN env var is set; /api/health is always open). Warning: enabling FIN_API_TOKEN on the API also locks out the React frontend — web/src/api/client.ts sends no Authorization header — until nginx injects the header or the frontend learns the token. Install with pip install -e cli/; every command supports --json. fin_cli/client.py is kept free of typer/rich imports so a future MCP server can reuse it as-is. Agent-facing usage docs live in .claude/skills/fin/SKILL.md.
Backend conventions
- Sync only: SQLAlchemy is configured with a synchronous engine (
create_engine,SessionLocal). Never introduceasync defendpoints orasync with Session. FastAPI handles the thread pool. - Layer separation: Routers do input validation and HTTP error mapping.
crud.pyowns all DB mutations.services/contains logic that spans multiple tables or calls external APIs. No cross-layer imports in the wrong direction. - Schemas: Pydantic v2. Read schemas use
model_config = ConfigDict(from_attributes=True). Update schemas have all fieldsOptionaland usemodel_dump(exclude_unset=True). - Migrations: Schema changes go through Alembic. Generate with
alembic revision --autogenerate -m "description", review output, then commit. Never ALTER tables by hand or callBase.metadata.create_all()as a migration strategy (it is used only for first-boot convenience inlifespan). - Prices: All prices stored in
price_cache(one row per symbol, upserted).EUR/USDis the FX rate. Ticker prices are USD. The scheduler refreshes at midnight; on-demand refresh viaPOST /api/prices/refresh.
Frontend conventions
- Single API layer: All backend calls go through
web/src/api/client.ts. Do not callfetch()directly in pages or components — add a function toapiin client.ts instead. - Event bus: When a QuickAdd flow saves successfully, it calls
onSuccess(), which the QuickAdd parent dispatches aswindow.dispatchEvent(new CustomEvent('fin:transaction-added')). Pages that need to refresh listen for this event in auseEffect. Do not break this pattern — it is the only cross-component refresh mechanism. - UI components: Use existing shadcn/ui components from
web/src/components/ui/. Do not add new UI libraries. New Radix primitives can be scaffolded following the existing pattern incomponents.json. - No global state manager: Data fetching is local to each page via
useState+useEffect. Shared state (accounts, categories) is re-fetched per page.
Data model
Account is the top-level entity with a type field: bank, investment, rsu, splitwise, or receivable. Bank accounts track cash via Transaction rows. Investment and RSU accounts track equity via Holding rows (one per ticker, unique on account_id + ticker). A Splitwise account records shared-expense balances.
Transaction belongs to one account and has a type: expense, income, transfer, splitwise_pay, splitwise_receive, or settlement. Transfers and settlements always produce two linked rows — a debit on one account and a credit on another — connected via linked_account_id. The fee column is included in balance calculations (amount - fee). For Splitwise pay transactions, splitwise_partner_share records the partner's portion (used in spending calculations to show only the user's net share).
StockTransaction records buy/sell/rsu_vest events and drives Holding state. Holdings are never edited directly — they are recomputed from the full stock transaction history on any delete, and incrementally updated on insert.
PriceCache holds the latest fetched price per symbol (one row, upserted). NetWorthSnapshot holds one row per calendar day with the total EUR value and a JSON breakdown by account.
Key invariants — never break these
- Atomic two-leg writes:
create_transferandcreate_settlementin crud.py usedb.add_all([leg1, leg2]); db.commit(). Both legs must be written in a single transaction. Deleting one leg of a transfer without the other corrupts balances. - Holding recompute on delete:
delete_stock_transactioncallsrecompute_holding_from_stock_transactionsafter the delete, beforedb.commit(). Skipping this leaves holdings inconsistent with the transaction log. - WAL mode:
PRAGMA journal_mode=WALis applied on every new connection (both the main engine and the scheduler's private engine). The scheduler runs in a background thread and must not share the request-scoped session. - Non-root Docker user: The API Dockerfile creates user
app(uid 1000) and runs as that user. Do not revert to root. - Balance formula: For bank/splitwise/receivable accounts, balance =
SUM(amount - fee)over all transactions. Do not sumamountalone. - Spending logic:
splitwise_payexpenses are counted atamount + splitwise_partner_share(the user's net share), not the fullamount. This is intentional — seeget_monthly_spendingin crud.py.
What NOT to do
- Do not add
asyncto SQLAlchemy sessions or useAsyncSession. The engine is synchronous. - Do not edit the SQLite file directly or run raw DDL. All schema changes go through Alembic.
- Do not add new columns to models without a matching Alembic migration.
- Do not bypass
api/client.tsfor API calls from the frontend. - Do not delete a single leg of a transfer or settlement — delete both or implement a paired-delete helper.
- Do not add authentication/multi-user features without understanding the single-user assumptions baked into net worth calculation and account filtering.
- Do not call
fetch_all_pricessynchronously from a request handler on the hot path — it does blocking HTTP calls and sleeps for retries.