Files
fin/.claude/skills/fin-accounting-reference/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

23 KiB
Raw Blame History

name, description
name description
fin-accounting-reference The complete semantic model of money in the fin app. Load whenever reasoning about amounts, balances, spending or income math, currencies and FX conversion, transaction types, transfers/settlements, Splitwise shares, receivables/payables, stock holdings, RSU grants and vesting, or net worth — and BEFORE writing or reviewing any accounting logic in api/app/crud.py, api/app/services/net_worth.py, routers, the CLI, or QuickAdd flows.

fin Accounting Reference (domain theory)

Every formula below is derived from the code as of 2026-07-05, with file:function citations. Code is the truth; if this document and the code disagree, trust the code and update this file (see "Provenance and maintenance").

1. Units, signs, and columns

  • amount: positive = money into the account, negative = money out. Always in the transaction's currency (model comment in api/app/models.py:Transaction, PLAN.md data model).
  • fee: a positive number representing an extra cost, stored separately from amount. Default 0.
  • Balance formula (bank / splitwise / receivable / payable accounts): balance = SUM(amount − fee) over all of the account's transactions. Source: api/app/services/net_worth.py:calculate_net_worth (the balance_rows query: func.sum(Transaction.amount - Transaction.fee)). Never sum amount alone (CLAUDE.md invariant 5).
    • Worked example: expense of €50 with a €2 fee → amount = -50, fee = 2 → balance impact = -50 − 2 = -52.
    • On transfers, the fee sits on the source leg only; the destination leg is always written with fee = 0 (crud.py:create_transfer). So a €500 transfer with €1 fee debits -501 net and credits +500.
  • Currency: each Transaction carries its own currency (default "EUR"), and each Account has a declared currency. Balances are raw sums with no per-transaction FX conversion; net worth converts the summed native balance using the account's currency (net_worth.py). Consequence: a transaction's currency must match its account's currency or the balance is silently wrong — cross-currency transfers handle this via to_amount/to_currency (§3).
  • exchange_rate_used (nullable, on transactions and stock_transactions): an audit column recording the EUR/USD rate in effect. It is only used computationally in crud.py:create_funded_buy, which converts the cash leg with bank_amount = cash_native / used_rate when the funding account's currency differs from the stock's currency (used_rate = req.exchange_rate_used or eur_usd_rate). Everywhere else it is informational.
  • Opening balance: crud.py:create_account with initial_balance > 0 writes an income transaction with description = "Opening balance". That exact description string is what excludes it from income totals (§4) — do not rename it.

2. Transaction type semantics

All six types from models.py:Transaction / schemas.py:TransactionBase. "Two-leg" means two rows linked via linked_account_id + linked_transaction_id.

Type Meaning Sign pattern In spending? In income? Two-leg? Created by
expense Consumption (incl. debt to a person via a payable account) negative Yes (raw amount) No No — except as part of a split-expense triple QuickAdd ExpenseFlow, IOweFlow; fin tx add --type expense; split-expense endpoint (its expense row)
income Earnings; also opening balances positive No Yes (except description = 'Opening balance') No QuickAdd IncomeFlow; fin tx add --type income; crud.py:create_account
transfer Asset movement between own accounts (incl. lending money = bank→receivable, and funded-buy cash legs) source −, destination + Never Never Yes QuickAdd TransferFlow, IAmOwedFlow; fin transfer; POST /api/transactions/transfer; crud.py:create_funded_buy cash leg; split-expense partner legs
splitwise_pay I paid a shared bill; partner owes their share bank leg − (full bill), splitwise leg + (partner share) Yes — bank leg only, at net share amount + splitwise_partner_share No Yes QuickAdd SplitwiseIPaidFlow; fin splitwise-paid; POST /api/transactions/splitwise-i-paid
splitwise_receive Partner paid; I owe my share single negative row on the splitwise account Yes (raw amount) No No QuickAdd SplitwiseShePayedFlow; fin tx add --type splitwise_receive
settlement Clearing a splitwise/receivable/payable balance against a bank account one leg +, one leg − (opposite accounts) Never Never Yes QuickAdd SplitwiseSettleFlow; fin settle; fin reconcile; POST /api/transactions/settlement and /reconcile

History note: funded-buy cash legs used to be typed expense and reconcile legs income/expense, which inflated the metrics. Migration api/alembic/versions/h8c9d0e1f2a3_retype_internal_cash_flows.py retyped legacy rows to transfer/settlement; new rows are written correctly (crud.py:create_funded_buy docstring, commit e36d719). Never reintroduce income/expense typing for internal cash movements.

fin tx add deliberately only accepts expense, income, splitwise_pay, splitwise_receive (cli/fin_cli/tx.py:TX_TYPES) — single-row transfers/settlements corrupt balances; use the paired commands.

3. Two-leg (double-entry) mechanics

All constructs below are written atomically: db.add_all([...]), db.flush() to get ids, cross-set linked_transaction_id, single db.commit() (CLAUDE.md invariant 1).

Construct Function Legs (account: amount, type) Invariant preserved
Transfer crud.py:create_transfer from: −amount (fee=fee); to: +to_amount ?? +amount (fee=0) — both transfer Same-currency: net worth unchanged minus fee. Cross-currency: caller supplies to_amount/to_currency (see schemas.py:TransferRequest); the two legs need not sum to zero in nominal terms
Splitwise settlement crud.py:create_settlement direction splitwise_to_bank: bank +amount, splitwise −amount; bank_to_splitwise: reversed — both settlement Cash ↔ receivable swap: net worth delta = 0
Split expense (3 rows) crud.py:create_split_expense bank: −(total − partner_share) expense; bank: −partner_share transfer; receivable: +partner_share transfer. Expense is linked into the group via expense.linked_transaction_id = from_leg.id Bank debited the full total; only your share is spending; partner share becomes a receivable asset. Net worth delta = −your share
Splitwise "I paid" crud.py:create_splitwise_i_paid bank: −total splitwise_pay with splitwise_partner_share = partner_share; splitwise: +partner_share splitwise_pay Net worth delta = −your share (PLAN.md Flow A)
Reconcile routers/transactions.py:reconcile_accounts receivable: bank +amount, receivable −amount; payable: bank −amount, payable +amount — both settlement Net worth delta = 0; the consumption was already booked when the payable/receivable was created
Funded buy/sell crud.py:create_funded_buy StockTransaction + one bank row typed transfer (buy: −total_cost; sell: +shares×price − fee), FX-converted via used_rate if currencies differ Cash ↔ securities swap never touches spending/income

Group delete: crud.py:delete_transaction walks linked_transaction_id in both directions to collect the whole group (both legs, plus a split-expense's expense row) and deletes it atomically; a legacy fallback matches on linked_account_id + date + type + closest id for pre-linked_transaction_id rows (migration e5f6a7b8c9d0). Never delete a single leg.

Edit lock: PUT /api/transactions/{id} returns 422 for any row with linked_account_id set — delete both legs and re-enter (routers/transactions.py:update_transaction).

4. Spending & income math

Source: crud.py:get_monthly_spending (per-month) and crud.py:get_spending_summary (yearly/custom = per-month loop, then aggregate).

Spending for month YYYY-MM = sum of two query sets, grouped by category, reported as positive numbers:

  1. Rows with type IN ('expense', 'splitwise_receive'), amount < 0, joined to accounts with Account.type NOT IN ('receivable') — counted at raw amount.
  2. Rows with type = 'splitwise_pay', amount < 0, same account filter — counted at the user's net share: amount + splitwise_partner_share.

Worked €100-dinner example (50/50, I paid, via splitwise-i-paid):

  • Rows written: bank amount = −100, splitwise_partner_share = 50, type splitwise_pay; splitwise account amount = +50, type splitwise_pay.
  • Spending contribution: −100 + 50 = −50 → €50 (the splitwise +50 leg fails the amount < 0 filter, so it never double-counts).
  • Net worth: bank −100, splitwise +50 → −50. Consistent.

If instead the partner paid an €80 bill split 50/50: one splitwise_receive row, amount = −40, on the splitwise account → spending €40, no cash movement.

Income for the month = SUM(amount) where type = 'income', account type NOT IN ('payable', 'receivable'), and description != 'Opening balance'.

Summary (get_spending_summary): grand_total = Σ monthly spending, income_total = Σ monthly income, net_total = income_total − grand_total. Categories with a net non-negative month total are skipped; percentages are share of the month's overall total.

Two intentional surprises — do not "fix" them:

  1. I-Owe counts as spending immediately. IOweFlow records an expense on a payable account; commit e61b254 changed the account filter from NOT IN ('payable','receivable') to NOT IN ('receivable') precisely so payable expenses count when the debt arises. Cash leaves the bank later via reconcile, which is neutral. (Receivable accounts stay excluded — a9f045f — because lending is a transfer, not spending; see docs/superpowers/specs/2026-07-01-category-types-receivable-redesign.md §3.)
  2. Reconcile/settlement is never income or spending, even though cash arrives in/leaves the bank. Both legs are settlement, which appears in neither query (h8c9d0e1f2a3 migration rationale).

Also: transfer never appears in either aggregate, and category type (expense/income, models.py:Category) is a UI-only picker filter — the spending queries never join on it (spec doc §1).

5. Net worth

Source: api/app/services/net_worth.py:calculate_net_worth. Only is_active accounts are included. Result: {total_eur, eur_usd_rate, accounts: [{id, name, type, currency, balance_native, balance_eur, is_override}]}.

Per account type:

  • bank / splitwise / receivable / payable: balance_native = manual_override_balance ?? SUM(amount − fee); balance_eur = _to_eur(balance_native, account.currency, price_map).
  • investment / rsu: if manual_override_balance is set, it is treated as being in the account's declared currency and converted with _to_eur (fix in commit 37bc101). Otherwise Σ over holdings: shares × price_cache[ticker], each holding converted using the price row's currency (ccy_map), not the account's.
  • Unknown account types are skipped with a warning.

_to_eur(amount, ccy, price_map): EUR passes through; otherwise divide by price_map["EUR/<CCY>"] (rates are quoted as units of CCY per 1 EUR, so EUR value = native / rate). If the rate is missing, the value contributes 0.0 and a warning is logged — never a wrong-currency conversion (see §8). Two residual 1.0 fallbacks exist when EUR/USD is uncached: the top-level eur_usd_rate response field, and net_worth.py:_get_eur_usd_rate, which routers/stock_transactions.py feeds into funded-buy conversion — a documented gap (U8 in fin-ledger-analysis-toolkit), not a pattern to copy.

Unvested RSUs are NOT in net worth. calculate_net_worth only reads holdings, which only change on vest. The dashboard shows unvested value greyed-out, client-side only (commit babea72, web/src/pages/Dashboard.tsx): Σ gross_shares × (1 − withholding_pct) × current_price, converted to EUR via the EUR/USD rate (any non-EUR price currency uses EUR/USD there — a dashboard-only approximation).

Snapshot: services/scheduler.py:fetch_prices_and_snapshot runs at midnight — fetches prices, computes net worth, upserts one net_worth_snapshots row per calendar day with total_eur and breakdown_json = the JSON-serialized accounts list. A separate 5-minute job refreshes prices only (no snapshot).

6. Stock & holdings math

Source: crud.py:recompute_holding_from_stock_transactions — the single source of truth. Holdings are never edited directly; every create/delete of a StockTransaction flushes then recomputes from the full per-(account_id, ticker) history in date-then-id order (CLAUDE.md invariant 2). Note: as of 2026-07-05, insert also does a full recompute (crud.py:create_stock_transaction), not an incremental update.

Running-total algorithm:

  • buy / rsu_vest: shares += tx.shares; total_cost += tx.shares × tx.price_per_share + tx.fee — fees are capitalized into cost basis.
  • sell: reject if tx.shares > shares at that point in history (ValueError → HTTP 422). Otherwise reduce total_cost by sell_shares × (total_cost / shares) (average-cost method), then shares −= sell_shares; total_cost floored at 0.
  • Final avg_cost_basis_per_share = total_cost / shares (0 if no shares). Zero remaining transactions → the holding row is deleted.

Example: buy 10 @ $100 fee $5 → shares 10, cost 1005, avg 100.50. Buy 5 @ $120 → shares 15, cost 1605, avg 107. Sell 6 → cost 1605 − 6×107 = 963, shares 9, avg still 107.

Fractional shares / total-first buy: shares are floats. In QuickAdd StockTradeFlow, a buy into an investment account is entered as total invested; shares = totalInvested / price (fractional), total_cost = totalInvested + fee (web/src/components/QuickAdd/flows/StockTradeFlow.tsx, isTotalFirstBuy).

Funded buy cash leg (crud.py:create_funded_buy, commit e36d719): the bank row is typed transfer — asset conversion, never spending/income. Buy: −total_cost; sell: +(shares × price − fee); converted cash_native / used_rate when the funding account's currency differs from the stock currency. CLI: fin stock funded-buy; plain fin stock add writes no cash leg.

P&L (routers/holdings.py:list_holdings, feeds Portfolio page and fin holdings): per holding, current_value_eur = _to_eur(shares × live_price, price_ccy), cost_basis_eur = _to_eur(shares × avg_cost_basis_per_share, price_ccy), pnl_eur = current − cost, pnl_pct = pnl_eur / cost_basis_eur × 100 (0 when basis is 0). Caveat: cost basis is converted at today's FX rate, not the historical one.

7. RSU model

models.py:RSUGrant: account_id, ticker, vest_date, gross_shares, withholding_pct (default 0.5), notes, vested_at, vested_stock_transaction_id. One row = one tranche. "One pot per account": grants attach to an rsu (or investment) account (crud.py:create_rsu_grant enforces this); all tranches of a ticker share that account's single holding. The UI "pot form" (web/src/pages/Portfolio.tsx) defaults withholding to 51% (0.51, commit c835118); the schema/CLI default is 0.5 (schemas.py:RSUGrantBase, cli/fin_cli/rsu.py:add). Withholding is stored as a 0..1 fraction.

Vest (crud.py:vest_rsu_grant, POST /api/rsu-grants/{id}/vest, fin rsu vest):

  • net_shares = net_shares_override ?? floor(gross_shares × (1 − withholding_pct)) — floored to whole shares, must be ≥ 1 (422 otherwise). E.g. gross 30, withholding 0.51 → floor(14.7) = 14 net shares. (UI preview floors the same way — commit 225e69e.)
  • Price and currency come from price_cache[ticker] (422 if no cached price > 0 — refresh prices first). Writes an rsu_vest StockTransaction: shares = net_shares, price_per_share = cached price, fee = 0, total_cost = net_shares × price, date = grant.vest_date (not today), then recomputes the holding and stamps vested_at + vested_stock_transaction_id.
  • Cost basis consequence: vests enter the holding at market price, so the "cost basis" of RSU shares is their vest-day value.
  • Deleting the linked stock transaction reverts the grant to pending (crud.py:delete_stock_transaction clears vested_at/vested_stock_transaction_id first). A vested grant itself cannot be deleted (crud.py:delete_rsu_grant).

Schedule generation (crud.py:_generate_rsu_schedule; endpoints /api/rsu-grants/preview-schedule and /generate-schedule; CLI fin rsu schedule preview|generate): one cliff tranche at grant_date + cliff_months, then a tranche every frequency_months until grant_date + total_months. Defaults: cliff 12, total 36, frequency 3 (schemas.py:RSUScheduleRequest). Each tranche gets floor(total / n_tranches) whole shares; the remainder is added to the last tranche so the sum equals the (floored) total.

Pending grants keep their ticker in the price-fetch set (price_fetcher.py:get_tracked_tickers unions holdings with unvested grants), which is what makes the dashboard's unvested estimate (§5) and vesting possible.

8. Multi-currency

  • Base currency is EUR. Everything reported EUR-first; native balances are shown alongside.
  • FX pairs in price_cache (as of 2026-07-05): EUR/USD and EUR/GBP, both from Frankfurter (https://api.frankfurter.dev/v1/latest?from=EUR&to=...), fetched in price_fetcher.py:fetch_eur_usd / fetch_eur_gbp. Quotation: CCY per 1 EUR → convert to EUR by dividing.
  • Ticker prices are cached in whatever currency yfinance reports — not always USD. European suffixes (.DE, .L, .AS, …) are auto-tried; LSE prices in pence (GBp/GBX) are divided by 100 and stored as GBP (price_fetcher.py:_try_fetch_candidate). The price row's currency drives conversion, never an assumption of USD.
  • Staleness policy: prices refresh every 5 minutes and at midnight (scheduler jobs); on fetch failure the last cached value is served and a warning logged — the API never crashes on Yahoo/Frankfurter outages.
  • No wrong-currency fallbacks. Commit 81091fc removed a 1.0 FX fallback: if a rate can't be fetched and no cache exists, the fetcher returns None and skips the upsert, and net_worth._to_eur contributes 0.0 with a warning for missing rates. Rationale: a silently-wrong rate (e.g. valuing GBP at par with EUR) corrupts net worth history via snapshots; an obviously-missing value plus a log line is recoverable. Never reintroduce a numeric FX fallback. Known residual exception (as of 2026-07-05): _get_eur_usd_rate still falls back to 1.0 and feeds funded-buy conversion — tracked as gap U8 (fin-ledger-analysis-toolkit) / candidate fix (fin-research-frontier), not license to add more.

9. Glossary

Term Meaning in fin
Receivable Account type: money others owe you (an asset). Created by lending (bank→receivable transfer) or a split expense's partner share. Excluded from spending queries.
Payable Account type: money you owe others (a liability, negative balance). An expense on it counts as spending immediately; paying it off is a neutral settlement.
Settlement Two-leg settlement-typed pair clearing a splitwise/receivable/payable balance against a bank account. Never income or spending.
Reconcile The receivable/payable variant of settlement — POST /api/transactions/reconcile, fin reconcile.
Splitwise account Single account holding the running net balance with the partner: positive = they owe you, negative = you owe them.
Partner share splitwise_partner_share column: the partner's portion of a bill you paid; subtracted (via amount + partner_share) so spending shows only your net share.
Pot An rsu account treated as one pool: all grants/tranches of a ticker vest into the same holding; the UI pot form seeds grants with a 51% withholding default.
Vest Converting a pending RSU grant into an rsu_vest StockTransaction for floor(gross × (1 − withholding)) net shares at the cached market price.
Withholding Fraction (0..1) of gross shares withheld at vest (tax). Schema default 0.5; UI default 0.51.
Opening balance Auto-generated income transaction (description = 'Opening balance') from initial_balance at account creation; excluded from income totals.
Manual override accounts.manual_override_balance: replaces the calculated balance in net worth (interpreted in the account's currency); calculation resumes when cleared.
Snapshot One net_worth_snapshots row per day (midnight job): total_eur + per-account breakdown_json. Feeds the History chart.
Holding Derived position row (unique per account+ticker): shares and avg_cost_basis_per_share, always recomputed from the stock-transaction log.
Funded buy Stock trade plus an atomic bank cash leg typed transfer.

When NOT to use this skill

  • Running CLI commands against the live API → fin skill (.claude/skills/fin/SKILL.md).
  • Architecture/layering rules, what code goes where → fin-architecture-contract.
  • Making changes safely (migrations, review gates) → fin-change-control; building/running → fin-build-and-env, fin-run-and-operate.
  • Debugging a wrong number in the running system → fin-debugging-playbook, fin-diagnostics-and-tooling; past incidents → fin-failure-archaeology.
  • Querying/analyzing the ledger data itself → fin-ledger-analysis-toolkit; tests → fin-validation-and-qa, fin-test-coverage-campaign; open questions → fin-research-frontier.

Provenance and maintenance

All facts verified 2026-07-05 against the working tree at commit 082b608. Re-verify before trusting:

  • Balance formula: grep -n "amount - models.Transaction.fee" api/app/services/net_worth.py
  • Transaction types: grep -n "type = Column" api/app/models.py and grep -n "Literal\[" api/app/schemas.py
  • Two-leg writers: grep -n "def create_\(transfer\|settlement\|split_expense\|splitwise_i_paid\|funded_buy\)" api/app/crud.py; reconcile: grep -n "def reconcile_accounts" api/app/routers/transactions.py
  • Spending filters: grep -n "notin_\|splitwise_partner_share\|Opening balance" api/app/crud.py (expect notin_(["receivable"]) for spending, notin_(["payable", "receivable"]) for income)
  • Net worth & FX-missing→0: grep -n "_to_eur\|return 0.0" api/app/services/net_worth.py
  • Holding recompute (avg cost, sell guard): sed -n '453,520p' api/app/crud.py
  • Vest floor + price-cache currency: grep -n "math.floor\|price_row" api/app/crud.py (in vest_rsu_grant)
  • Schedule tranche math: grep -n "_generate_rsu_schedule" api/app/crud.py api/app/routers/rsu_grants.py
  • FX pairs & pence handling: grep -n "frankfurter.dev\|GBp\|GBX" api/app/services/price_fetcher.py
  • Retype migration: cat api/alembic/versions/h8c9d0e1f2a3_retype_internal_cash_flows.py
  • UI withholding default: grep -n "withholding_pct: '51'" web/src/pages/Portfolio.tsx; unvested dashboard calc: grep -n "unvestedEurByAccount" web/src/pages/Dashboard.tsx
  • CLI command surface: grep -n "add_typer\|app.command" cli/fin_cli/main.py
  • Key commits: git log --oneline — e36d719 (funded-buy transfer typing), h8c9d0e1f2a3 in bc256f6-era (retype migration), e61b254 (payable in spending), a9f045f (receivable/payable exclusion + summary), babea72 (unvested display), 81091fc (no FX fallback), 225e69e (floor net shares), c835118 (51% UI default)