Files
fin/.claude/skills/fin-debugging-playbook/SKILL.md
T
EislM0203andClaude Opus 5 cb97d97d81 docs: check in the fin skill library, ignore local Claude settings
Twelve skills that had been sitting uncommitted in the working tree: the
architecture contract, accounting reference, change-control rulebook, failure
archaeology, debugging playbook, diagnostics scripts, ledger-analysis toolkit,
research frontier, run/operate runbook, build-and-env, test-coverage campaign,
and validation/QA guide.

Updates three frontier statuses to match what actually shipped today rather
than checking in a roadmap that describes closed work as open:

- A1 double-entry hardening -> partly-done. The create half is enforced; what
  remains is running the existing audit_linked_legs.py against a prod backup.
- B3 safety rails -> partly-done. Idempotency shipped, including the note that
  the index is not unique-when-not-null and why. dry_run is still open.
- D3 CSV import -> unblocked but still gated. Its stated blocker was B3's
  idempotency keys, which now exist.

Also gitignores .claude/settings.local.json, which is per-machine and was
showing up as untracked on every status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-22 22:09:22 +00:00

20 KiB
Raw Blame History

name, description
name description
fin-debugging-playbook Symptom-to-cause triage playbook for the fin app. Load when anything looks wrong and the cause is unknown — balances look wrong, spending or income totals too high/inflated, net worth off or in the wrong currency, prices stale or missing, holdings don't match trades, dates off by one day, 401s or frontend can't reach the API, container won't start or is unhealthy — or for any bug triage/debugging session in fin.

fin Debugging Playbook

Symptom → likely cause → discriminating check, for this codebase's actual historical failure modes. Every claim below is grounded in code or a fix commit (SHAs cited). Facts date-stamped 2026-07-05; re-verify per "Provenance" at the bottom.

Jargon (defined once):

  • Leg: one of the two (or three) transactions rows written together for a transfer/settlement/splitwise-pay/split-expense. Linked via linked_transaction_id (both directions) and linked_account_id.
  • Orphan leg: a leg whose partner row was deleted — corrupts both account balances.
  • price_cache: one row per symbol (tickers + EUR/USD, EUR/GBP), upserted; fetched_at is UTC.
  • Recompute: recompute_holding_from_stock_transactions in api/app/crud.py — holdings are derived state, rebuilt from the full stock_transactions history for that account+ticker.

When NOT to use this skill

  • Recording/querying finances via CLI → sibling fin skill (.claude/skills/fin/SKILL.md).
  • Making a code change safely → fin-change-control; architecture questions → fin-architecture-contract.
  • Accounting semantics (what should the numbers be) → fin-accounting-reference; ad-hoc ledger SQL → fin-ledger-analysis-toolkit.
  • Build/deploy/env setup → fin-build-and-env; day-2 ops (not active bug hunt) → fin-run-and-operate.
  • Deep history of past incidents → fin-failure-archaeology; tooling setup → fin-diagnostics-and-tooling.
  • Writing regression tests after the fix → fin-test-coverage-campaign, fin-validation-and-qa.

Rule zero: reproduce on a copy — never debug against prod SQLite

The production DB (Docker volume sqlite_data, file /data/fin.db inside the api container — see docker-compose.yml) is sacred. Copy it out first; all SQL below runs against the copy. The DB runs in WAL mode, so a naive file copy can miss un-checkpointed writes in fin.db-wal — use VACUUM INTO, which produces a consistent single-file snapshot. Verified statically against docker-compose.yml and api/Dockerfile (python:3.12-slim has stdlib sqlite3; /data is writable by user app); run on the homelab host:

# 1. Consistent snapshot inside the container (read-only w.r.t. live data)
docker compose exec api python -c "import sqlite3; sqlite3.connect('/data/fin.db').execute(\"VACUUM INTO '/data/fin-debug-copy.db'\")"
# 2. Copy it out and clean up
docker compose cp api:/data/fin-debug-copy.db ./fin-debug-copy.db
docker compose exec api rm /data/fin-debug-copy.db
# 3. Query the copy locally
sqlite3 ./fin-debug-copy.db "SELECT count(*) FROM transactions;"
# 4. Optionally run a whole local API against the copy (from api/):
DATABASE_URL=sqlite:///$PWD/../fin-debug-copy.db uvicorn app.main:app --port 8001

Caveat for step 4: the app starts APScheduler and a 5-min price job that writes — fine against the copy, never point it at the real file. Destructive experiments (deletes, migrations) go against the copy only.

First 15 minutes: "something is wrong and I don't know what"

Run in order; stop when a step discriminates.

  1. Is the API up? curl -fsS http://<host>/api/health — always unauthenticated (api/app/main.py). If this fails, jump to the Container family.
  2. Container state + logs (on homelab host): docker compose ps then docker compose logs --tail 100 api. Look for: alembic errors at start, No EUR/... rate in price cache warnings, yfinance retry storms, database is locked.
  3. Price freshness (via API, no DB needed): fin prices fx --json and check fetched_at in the copy: sqlite3 fin-debug-copy.db "SELECT symbol, price, currency, fetched_at FROM price_cache ORDER BY fetched_at;" — anything older than ~10 min means the 5-min scheduler job is not running (times are UTC).
  4. Orphan legs (corrupt balances):
    SELECT t.id, t.date, t.type, t.amount, t.account_id FROM transactions t
    WHERE t.linked_transaction_id IS NOT NULL
      AND NOT EXISTS (SELECT 1 FROM transactions p WHERE p.id = t.linked_transaction_id);
    
    Also count legacy unpaired legs: SELECT count(*) FROM transactions WHERE type IN ('transfer','settlement') AND linked_transaction_id IS NULL;
  5. What changed recently? git log --oneline -10 and git diff HEAD~3 -- api/app/crud.py api/app/services/ — most fin bugs shipped in the last few commits.
  6. Cross-check a number two ways: fin networth --json | jq '.total_eur' vs summing the copy yourself (see Balance family) — tells you whether data or calculation is wrong.

Master symptom table

Symptom Likely cause (most→least) Discriminating check
Spending/income totals inflated Internal cash flow typed expense/income (stock buy leg, receivable settle) — e36d719; opening-balance income; missing splitwise_partner_share SQL S1/S2 below
Spending "off by partner's share" Not a bug: splitwise_pay counts amount + splitwise_partner_share; I-Owe counts as spending by design (e61b254) Read invariant 6 in CLAUDE.md before "fixing"
One account balance wrong Orphan leg; fee ignored (balance = SUM(amount−fee)); manual_override_balance set; forgotten "Opening balance" row SQL B1/B2/B3 below
Net worth total wrong / a currency ignored Missing EUR/<CCY> row in price_cache → asset contributes 0 (6b7d19b); EUR/USD missing → rate falls back 1.0; override in wrong currency (37bc101) grep 'No EUR/' logs; SQL N1
Prices stale/missing Frankfurter URL rot (582e39d); yfinance down (stale cache served); Euro ETF needs exchange suffix (bc8c8d5); scheduler dead fetched_at query; P1 below
Transactions dated yesterday UTC vs local date: toISOString() before ~1–2 AM CET (bc256f6, todayLocalISO in web/src/lib/utils.ts) Grep check D1
Holdings ≠ trade history Recompute skipped/failed; vested-grant FK deadlock (bc256f6) SQL H1
401s / frontend broken but API "works" FIN_API_TOKEN set on API env — locks out React frontend (sends no Authorization header) curl /api/accounts with vs without Bearer
Container won't start / unhealthy Alembic migration failure in CMD; healthcheck failing; web blocked on api health; WAL lock from external reader docker compose logs api first 30 lines

Family runbooks

1. Spending/income totals look inflated or wrong

Ground truth (get_monthly_spending, api/app/crud.py ~line 624):

  • Spending = rows with amount < 0 and type expense/splitwise_receive (raw amount) + splitwise_pay (counted as amount + splitwise_partner_share, the user's net share). transfer and settlement are excluded.
  • Accounts of type receivable are excluded; payable is included on purpose (I-Owe is real consumption — e61b254 deliberately reverted part of a9f045f).
  • Income = type income, excluding payable/receivable accounts and rows with description Opening balance.

Discriminating checks (against the copy):

-- S1: internal flows mistyped as expense/income (the e36d719 bug class).
-- Any hit = data predating migration h8c9d0e1f2a3 or a new regression.
SELECT id, date, type, amount, description FROM transactions
WHERE type IN ('expense','income') AND linked_account_id IS NOT NULL;

-- S2: splitwise_pay rows missing partner share (counted at full amount)
SELECT id, date, amount, splitwise_partner_share FROM transactions
WHERE type='splitwise_pay' AND amount < 0
  AND (splitwise_partner_share IS NULL OR splitwise_partner_share = 0);

API cross-check: fin spending month 2026-06 --json | jq '.total, .income_total' and eyeball big categories with fin tx list --month 2026-06 --json | jq '[.[] | select(.type=="expense")] | sort_by(.amount) | .[0:5]'.

2. Balance looks wrong

Ground truth: for bank/splitwise/receivable/payable accounts, balance = SUM(amount - fee) over all its transactions (calculate_net_worth in api/app/services/net_worth.py). manual_override_balance on the account silently replaces the computed balance.

-- B1: recompute the balance yourself and compare with the UI
SELECT a.id, a.name, a.type, a.manual_override_balance,
       ROUND(COALESCE(SUM(t.amount - t.fee),0), 2) AS computed
FROM accounts a LEFT JOIN transactions t ON t.account_id = a.id
WHERE a.is_active = 1 GROUP BY a.id;

-- B2: orphan legs touching a specific account (see First-15-min step 4 for global)
SELECT * FROM transactions t
WHERE t.account_id = :acct AND t.linked_transaction_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM transactions p WHERE p.id = t.linked_transaction_id);

-- B3: overrides and opening balances that explain "mystery" amounts
SELECT id, name, manual_override_balance FROM accounts WHERE manual_override_balance IS NOT NULL;
SELECT id, account_id, date, amount FROM transactions WHERE description = 'Opening balance';

Decision: B1 computed ≠ UI → override or FX (family 3). B1 computed ≠ your expectation → B2 orphans, then fee column (SELECT id, amount, fee FROM transactions WHERE account_id=:acct AND fee != 0;).

3. Net worth wrong / currency wrong

Ground truth (api/app/services/net_worth.py):

  • _to_eur converts via price_cache row EUR/<CCY>. If the rate row is missing, the asset contributes 0.0 to net worth with a log warning No EUR/<CCY> rate in price cache (6b7d19b — deliberately no wrong-currency fallback).
  • If EUR/USD itself is missing, the reported rate falls back to 1.0 with a warning — and the same 1.0 fallback (net_worth._get_eur_usd_rate) is fed into funded-buy conversions at write time (gap U8, fin-ledger-analysis-toolkit), so a cross-currency funded buy booked while the cache was cold may carry a wrong amount permanently.
  • Investment/RSU manual_override_balance is converted assuming the account's declared currency (37bc101).
  • LSE pence: fetcher converts GBp/GBX → price/100, currency GBP (price_fetcher.py).
-- N1: which FX rates exist, and how old
SELECT symbol, price, currency, fetched_at FROM price_cache WHERE symbol LIKE 'EUR/%';
-- N2: currencies in use that need a rate
SELECT DISTINCT currency FROM accounts WHERE is_active=1
UNION SELECT DISTINCT currency FROM price_cache WHERE symbol NOT LIKE 'EUR/%';

If an account/holding currency appears in N2 but has no EUR/<CCY> row in N1, that asset is being valued at €0. Check docker compose logs api | grep 'No EUR/'. Per-account drill-down: fin networth --json | jq '.accounts[] | {name, currency, balance_native, balance_eur, is_override}' — balance_native nonzero with balance_eur 0 is the smoking gun.

4. Prices stale or missing

Ground truth (api/app/services/price_fetcher.py, scheduler.py, as of 2026-07-05):

  • FX from https://api.frankfurter.dev/v1/latest?from=EUR&to=USD (and to=GBP). The old api.frankfurter.app host was retired — 582e39d. If FX dies again, suspect URL rot first.
  • Tickers via yfinance: tries bare ticker, then suffixes .DE .L .AS .PA .MI .SW .F .VI (bc8c8d5); price cached under the original symbol. 3 retries/candidate with 1/2/4 s sleeps.
  • On total failure: last cached price is served; 0.0 only when no cache row exists at all.
  • Scheduler (0b5ba7c): price_refresh interval job every 5 min + daily_snapshot cron at midnight, persisted in table apscheduler_jobs, running on a private engine.
  • Tracked = holdings with shares > 0 plus tickers of pending RSU grants.

Checks:

# P1: is anything updating? (fetched_at is UTC; should be < ~10 min old)
sqlite3 fin-debug-copy.db "SELECT symbol, fetched_at FROM price_cache ORDER BY fetched_at DESC;"
docker compose logs --tail 200 api | grep -E "Price refresh|Failed to fetch|All candidates|retrying"
# P2: force one ticker end-to-end (writes to price_cache — prod-safe by design)
fin prices fetch VWCE --json
# P3: full refresh runs as a 202 background task (POST /api/prices/refresh)
fin prices refresh

Never call fetch_all_prices synchronously in a request handler — it blocks and sleeps (CLAUDE.md).

5. Dates off by one day

Story: new Date().toISOString().split('T')[0] returns the UTC date; in UTC+1/+2 anything entered before ~1–2 AM was booked to yesterday. Fixed by todayLocalISO() in web/src/lib/utils.ts (bc256f6). Backend stores plain dates; only frontend default-date derivation was affected.

# D1: any regressions? Should return only the explanatory comment in utils.ts.
grep -rn "toISOString" /home/markuseisl/Documents/git/fin/web/src

If a user reports one specific wrong-dated row, fix the data with fin tx update <id> --date YYYY-MM-DD (allowed — it's not a linked leg... verify first: updates on legs with linked_transaction_id are rejected by the API, a1289f6).

6. Holdings don't match trades

Ground truth (recompute_holding_from_stock_transactions, api/app/crud.py): replay all stock_transactions for account+ticker in date,id order; buy/rsu_vest add shares and cost (incl. fee); sell removes shares at average cost; oversell raises → 422 and rollback. Delete triggers full recompute before commit; deleting a vest transaction also reverts the linked RSU grant to pending (bc256f6 — previously a FK deadlock made vested grants undeletable).

-- H1: expected shares from the log vs the holdings row
SELECT st.account_id, st.ticker,
  ROUND(SUM(CASE WHEN lower(st.type) IN ('buy','rsu_vest') THEN st.shares ELSE -st.shares END), 6) AS expected,
  h.shares AS stored
FROM stock_transactions st
LEFT JOIN holdings h ON h.account_id = st.account_id AND h.ticker = st.ticker
GROUP BY st.account_id, st.ticker
HAVING ABS(COALESCE(stored,0) - expected) > 1e-6;

Mismatch → trigger a recompute by the supported path: delete + re-add any one stock transaction for that ticker via fin stock rm / fin stock add (on a copy first). Never edit holdings rows directly.

7. 401s / frontend can't reach API

Ground truth: api/app/auth.py + main.py. Auth is opt-in: enforced only when env FIN_API_TOKEN is set on the API container, checked per-request (rotatable without restart... of code; the env var itself needs a container recreate). /api/health is always open; everything else, including /api/openapi.json, requires Authorization: Bearer <token>. The React frontend sends no Authorization header (web/src/api/client.ts) — setting the token locks the UI out until nginx injects the header. Nginx (web/nginx.conf) proxies /api/ to ${API_UPSTREAM} (default api:8000, set in web/Dockerfile and docker-compose.yml).

curl -fsS http://<host>/api/health                                  # up at all?
curl -s -o /dev/null -w '%{http_code}\n' http://<host>/api/accounts  # 401 → token enforced
curl -s -H "Authorization: Bearer $FIN_API_TOKEN" http://<host>/api/accounts | head -c 200
docker compose exec api printenv FIN_API_TOKEN                      # is it set on the container?

502 from nginx instead of 401 → API_UPSTREAM wrong or api container down.

8. Container won't start / unhealthy

Ground truth: api/Dockerfile CMD is python -m app.preflight && alembic upgrade head && uvicorn ... — a failing migration crash-loops the container before the app exists. Healthcheck (docker-compose.yml) curls /api/health every 30 s, 3 retries; web has depends_on: api: condition: service_healthy, so a sick api also keeps web down. Runs as non-root user app (uid 1000). Migration chain head as of 2026-07-05: h8c9d0e1f2a3 (api/alembic/versions/).

docker compose ps
docker compose logs --tail 50 api            # alembic tracebacks appear here first
docker compose exec api alembic current      # applied revision vs files in api/alembic/versions/
docker compose exec api ls -la /data         # fin.db + -wal + -shm owned by app (uid 1000)?

database is locked in logs → something outside the two engines is holding the file (e.g. a host sqlite3 shell on the live DB — that's exactly why you debug on a copy). Both the request engine and the scheduler's private engine set PRAGMA journal_mode=WAL and foreign_keys=ON per connection (database.py, scheduler.py).

Traps that cost real time

  • "Spending is too high" that wasn't a spending bug (e36d719): stock buys' bank legs were typed expense and receivable/payable reconciles typed income/expense, inflating both metrics for weeks. The fix retyped them transfer/settlement and shipped migration h8c9d0e1f2a3 to rewrite history. Lesson: when a total looks wrong, first ask what types feed this aggregate (run S1), not is the arithmetic wrong.
  • The payable flip-flop (a9f045f → e61b254): payable accounts were first excluded from spending together with receivables, then deliberately re-included one commit later — money you owe and spent via I-Owe is consumption. Sonnet-class agents keep wanting to "fix" this. Don't; it's invariant 6 territory.
  • Orphan legs from single-leg deletes (a06f921, a1f109b, 9d06e1b): deleting one transfer leg silently skewed two balances. Fixes added paired delete, then linked_transaction_id for precise pairing (the old account-pair heuristic could grab a leg of a different transfer between the same accounts), then frontend refetch-after-delete. Legacy rows without linked_transaction_id still exist — the fallback matcher in delete_transaction handles them.
  • Silent wrong-currency conversion (6b7d19b): _to_eur used to fall back to the EUR/USD rate for any currency, quietly mis-valuing GBP assets. Now a missing rate excludes the asset (0 €) with a warning — so "asset disappeared from net worth" is usually a missing FX row, not lost data.
  • FX provider URL rot (582e39d): frankfurter moved to api.frankfurter.dev/v1; until fixed, the app served an ever-staler cached rate with no user-visible error. Stale-graceful design hides upstream breakage — check fetched_at, not just whether numbers exist.
  • European ETFs invisible to yfinance (bc8c8d5): bare ISIN-ish tickers (e.g. VWCE) return no data on Yahoo without an exchange suffix; the fetcher now walks .DE .L .AS .... A ticker priced at 0 with "All candidates exhausted" in logs may simply trade under a suffix not in the list.
  • Midnight-minus-two hours dates (bc256f6): every QuickAdd flow defaulted its date via toISOString(), so late-evening entries landed on the previous day for UTC+ users. Grep D1 whenever a new date input is added.
  • Undeletable vested grants (bc256f6): deleting a vest's stock transaction hit the vested_stock_transaction_id FK (500), while deleting the grant was blocked (422) — a deadlock. delete_stock_transaction now reverts the grant to pending first.
  • Auth that "worked in curl" (082b608): enabling FIN_API_TOKEN broke only the browser UI, because the frontend sends no Authorization header. Test both paths after touching auth.

Provenance and maintenance

Verified against the working tree at commit 082b608 (2026-07-05). Docker/CLI commands verified statically (no docker/pip in authoring sandbox). Re-verify one-liners:

  • Spending rules: grep -n "notin_\|splitwise_partner_share\|Opening balance" api/app/crud.py
  • Balance formula & _to_eur: grep -n "amount - \|return 0.0" api/app/services/net_worth.py
  • Frankfurter URL & suffixes: grep -n "frankfurter\|_EXCHANGE_SUFFIXES" api/app/services/price_fetcher.py
  • Scheduler cadence: grep -n "minutes=5\|hour=0" api/app/services/scheduler.py
  • Auth wiring: grep -n "FIN_API_TOKEN" api/app/auth.py; grep -n "require_api_token" api/app/main.py
  • Container CMD/healthcheck/volume: grep -n "CMD" api/Dockerfile; grep -n "healthcheck\|sqlite_data\|API_UPSTREAM" -A2 docker-compose.yml
  • Migration head: ls api/alembic/versions/
  • Date helper: grep -rn "todayLocalISO" web/src/lib/utils.ts
  • CLI commands: grep -n "add_typer\|@app.command" cli/fin_cli/main.py