Files
fin/CLAUDE.md
T
EislM0203andClaude Opus 5.5 ec21078bf8 fix: balance adjustments counted as spending and income — add 'adjustment' type
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>
2026-09-30 09:10:45 +00:00

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) behind web (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=1 adds the 24 write tools.
  • Name resolution: find_account / find_category map a name to the integer id every other tool takes (exact match wins, else substring). Accounts and categories are also published as the resources fin://accounts and fin://categories. There is no get_openapi_spec tool — 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) or streamable-http, which binds FIN_MCP_HOST:FIN_MCP_PORT and serves MCP at FIN_MCP_PATH (default /mcp). HTTP mode sets stateless_http so 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 is http://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 particular create_transaction accepts only expense/income — POST /api/transactions also accepts splitwise_pay/splitwise_receive, and rejects transfer/settlement, which would create an orphan leg with no linked_account_id (invariant #1). Two-leg writes must go through create_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 introduce async def endpoints or async with Session. FastAPI handles the thread pool.
  • Layer separation: Routers do input validation and HTTP error mapping. crud.py owns 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 fields Optional and use model_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 call Base.metadata.create_all() as a migration strategy (it is used only for first-boot convenience in lifespan).
  • Prices: All prices stored in price_cache (one row per symbol, upserted). EUR/USD is the FX rate. Ticker prices are USD. The scheduler refreshes at midnight; on-demand refresh via POST /api/prices/refresh.

Frontend conventions

  • Single API layer: All backend calls go through web/src/api/client.ts. Do not call fetch() directly in pages or components — add a function to api in client.ts instead.
  • Event bus: When a QuickAdd flow saves successfully, it calls onSuccess(), which the QuickAdd parent dispatches as window.dispatchEvent(new CustomEvent('fin:transaction-added')). Pages that need to refresh listen for this event in a useEffect. 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 in components.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

  1. Atomic two-leg writes: create_transfer and create_settlement in crud.py use db.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: TransactionCreate rejects type of transfer/settlement and rejects a caller-supplied linked_account_id, so POST /api/transactions can no longer write half a pair. Keep TransactionBase wide — reads must still describe historical rows of every type.
  2. Holding recompute on delete: delete_stock_transaction calls recompute_holding_from_stock_transactions after the delete, before db.commit(). Skipping this leaves holdings inconsistent with the transaction log.
  3. WAL mode: PRAGMA journal_mode=WAL is 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.
  4. Non-root Docker user: The API Dockerfile creates user app (uid 1000) and runs as that user. Do not revert to root.
  5. Balance formula: For bank/splitwise/receivable accounts, balance = SUM(amount - fee) over all transactions. Do not sum amount alone.
  6. Spending logic: splitwise_pay expenses are counted at amount + splitwise_partner_share (the user's net share), not the full amount. This is intentional — see get_monthly_spending in crud.py. Spending and income select rows by type, 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 async to SQLAlchemy sessions or use AsyncSession. 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.ts for 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_prices synchronously from a request handler on the hot path — it does blocking HTTP calls and sleeps for retries.