Two long-standing correctness gaps, both flagged in fin-research-frontier. A1 — POST /api/transactions could write half a linked pair TransactionCreate inherited the full six-value type Literal and accepted a free-standing linked_account_id, so any client could create a lone `transfer` row with no partner. That is exactly the balance corruption invariant #1 exists to prevent: the orphan counts toward SUM(amount - fee) and paired-delete can never find a partner to remove it with. TransactionCreate now narrows type to the single-leg kinds and rejects a caller-supplied linked_account_id. TransactionBase stays wide on purpose — reads must keep describing historical rows of every type. splitwise_pay deliberately stays allowed. The two-leg creator uses that type, but a standalone row carrying splitwise_partner_share is also a legitimate record, and is what `fin tx add --type splitwise_pay --partner-share` writes. Narrowing to expense/income would have broken it. B3 — no idempotency, so any retry double-booked Migration i9d0e1f2a3b4 adds a nullable client_ref to transactions. POST /api/transactions, /batch, /transfer and /settlement accept it, and crud.find_by_client_ref is checked before writing, so retrying a timed-out request replays the original rows instead of booking them again. NULL means "no idempotency requested" and never collides. Deviation from the frontier's sketch, which called for unique-when-not-null: the index is NOT unique. A transfer writes two rows and a batch writes many, all under one key, so uniqueness cannot live on the column. Suppression is a check-then-write inside the creators instead — sound here because SQLite serialises writers and the scheduler never writes transactions. Exposed on the MCP create_transaction/create_transfer/create_settlement tools, since a retrying agent is the case B3 was written about. The server instructions no longer tell agents that writes are non-idempotent. Tests: 60 passing, up from 47. Thirteen new cover retries collapsing to one write across all four endpoints, differing keys staying separate, NULL keys not colliding, and the orphan-leg guard refusing transfer/settlement/manual linkage while the four legitimate single-leg types still work. Migration verified both directions on a scratch DB seeded at the previous head: existing rows survive with NULL client_ref, index is created, and downgrade drops cleanly with rows intact. Verified live over HTTP that a transfer submitted three times with one client_ref yields one pair of legs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
11 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.
cli/fin_mcp/ wraps FinClient as an MCP server (fin-mcp console script), exposing the full API as 39 tools. Install the extra with pip install -e 'cli/[mcp]'.
- Mode: read-only (15 tools) by default;
FIN_MCP_ALLOW_WRITES=1adds the 24 write tools. - Name resolution:
find_account/find_categorymap a name to the integer id every other tool takes (exact match wins, else substring). Accounts and categories are also published as the resourcesfin://accountsandfin://categories. There is noget_openapi_spectool — the tools already cover the API and the spec is too large to be worth a tool slot. - Transport:
FIN_MCP_TRANSPORT=stdio(default, for clients that spawn the process) orstreamable-http, which bindsFIN_MCP_HOST:FIN_MCP_PORTand serves MCP atFIN_MCP_PATH(default/mcp). HTTP mode setsstateless_httpso no session affinity is required. - Deployment: its own image (
cli/Dockerfile) and its own Deployment + ClusterIP Service in k8s — it is not mounted into the API app. Reference manifest:deploy/k8s/fin-mcp.yaml. In-cluster endpoint ishttp://fin-mcp.apps.svc.cluster.local:8000/mcp. - Tool params are typed: enum-valued arguments use
Literal, so the MCP client rejects bad values before the call. In particularcreate_transactionaccepts onlyexpense/income— the API itself accepts all six transaction types, and writing atransferrow through it would create an orphan leg with nolinked_account_id(invariant #1). Two-leg writes must go throughcreate_transfer/create_settlement/create_split_expense/create_splitwise_i_paid.
Security: fin-mcp has no auth of its own — it inherits whatever FinClient uses. With writes enabled it must stay unreachable from outside the cluster (no HTTPRoute) unless FIN_API_TOKEN is set on both the API and the MCP server.
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).
Idempotency: every write that moves money accepts an optional client_ref — POST /api/transactions, /batch, /transfer and /settlement. Every row of one write carries the same key, and the creators check crud.find_by_client_ref before writing, so retrying a timed-out request replays the original rows instead of double-booking. The index is deliberately not unique (a transfer writes two rows under one key); duplicate suppression is a check-then-write, which is sound because SQLite serialises writers and the scheduler never writes transactions. A NULL client_ref means "no idempotency requested" and never collides.
POST /api/transactions/batch writes several single-leg rows sharing one account, date and currency in a single commit — all rows land or none do. It is the one write endpoint whose items carry positive amounts: the sign is derived server-side from the batch type, which makes a mixed-sign batch unrepresentable. Its type is restricted to expense/income for the same reason the MCP create_transaction tool is — a batched transfer would emit orphan legs (invariant #1). The UI entry point is the Batch Expenses QuickAdd flow.
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. This is now enforced at the edge too:TransactionCreaterejectstypeoftransfer/settlementand rejects a caller-suppliedlinked_account_id, soPOST /api/transactionscan no longer write half a pair. KeepTransactionBasewide — reads must still describe historical rows of every type. - 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.