Files
EislM0203andClaude Sonnet 4.6 082b6081b7 feat: fin CLI + agent skill + opt-in API bearer auth
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
2026-07-03 19:22:15 +00:00

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) 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.

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, 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

  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.
  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.

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.