POST /api/accounts/{id}/adjust-balance wrote its delta as 'expense' (down)
or 'income' (up), so every downward correction surfaced as uncategorized
spending in get_monthly_spending, and every upward one as income. Opening
balances were written as 'income' too, kept out of the income total only by
a description != 'Opening balance' filter.
Backend:
- adjust_balance (routers/accounts.py) and create_account (crud.py) now
write type='adjustment' with the signed amount. Balances are unaffected:
SUM(amount - fee) ignores type.
- get_monthly_spending drops the description filter; spending and income
select purely by type.
- TransactionBase gains 'adjustment' for reads; TransactionCreate does not,
so only the server can write one.
- Migration j0e1f2a3b4c5 retypes historical rows: unlinked income/expense
rows with the exact generated descriptions. Downgrade restores the
typing from the sign of the amount.
Tests: cli/tests/test_adjustment.py covers spending/income exclusion, the
balance landing on target, and the migration + downgrade on a scratch DB.
test_cli's opening-balance assertion updated to the new type. 65 passed.
Docs: CLAUDE.md type list and invariant 6; spec in
docs/superpowers/specs/2026-09-30-adjustment-transaction-type.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 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—POST /api/transactionsalso acceptssplitwise_pay/splitwise_receive, and rejectstransfer/settlement, which 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, settlement, or adjustment. adjustment is a single-leg correction written only by the server — opening balances on account creation and POST /api/accounts/{id}/adjust-balance. It moves the balance but is excluded from spending and income, because it is neither consumption nor earnings. 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. Spending and income select rows bytype, never by description: anything that is not real consumption or earnings (transfers, settlements, adjustments) gets its own type so those queries skip it.
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.