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
23 KiB
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'scurrency(model comment inapi/app/models.py:Transaction, PLAN.md data model).fee: a positive number representing an extra cost, stored separately fromamount. 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(thebalance_rowsquery:func.sum(Transaction.amount - Transaction.fee)). Never sumamountalone (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-501net and credits+500.
- Worked example: expense of €50 with a €2 fee →
- Currency: each
Transactioncarries its owncurrency(default"EUR"), and eachAccounthas a declaredcurrency. 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'scurrencymust match its account's currency or the balance is silently wrong — cross-currency transfers handle this viato_amount/to_currency(§3). exchange_rate_used(nullable, ontransactionsandstock_transactions): an audit column recording the EUR/USD rate in effect. It is only used computationally incrud.py:create_funded_buy, which converts the cash leg withbank_amount = cash_native / used_ratewhen 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_accountwithinitial_balance > 0writes anincometransaction withdescription = "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:
- Rows with
type IN ('expense', 'splitwise_receive'),amount < 0, joined to accounts withAccount.type NOT IN ('receivable')— counted at raw amount. - 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, typesplitwise_pay; splitwise accountamount = +50, typesplitwise_pay. - Spending contribution:
−100 + 50 = −50→ €50 (the splitwise+50leg fails theamount < 0filter, 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:
- I-Owe counts as spending immediately. IOweFlow records an
expenseon a payable account; commite61b254changed the account filter fromNOT IN ('payable','receivable')toNOT 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; seedocs/superpowers/specs/2026-07-01-category-types-receivable-redesign.md§3.) - 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_balanceis set, it is treated as being in the account's declared currency and converted with_to_eur(fix in commit37bc101). 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 iftx.shares > sharesat that point in history (ValueError → HTTP 422). Otherwise reducetotal_costbysell_shares × (total_cost / shares)(average-cost method), thenshares −= sell_shares;total_costfloored 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 anrsu_vestStockTransaction: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 stampsvested_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_transactionclearsvested_at/vested_stock_transaction_idfirst). 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/USDandEUR/GBP, both from Frankfurter (https://api.frankfurter.dev/v1/latest?from=EUR&to=...), fetched inprice_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'scurrencydrives 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
81091fcremoved a1.0FX fallback: if a rate can't be fetched and no cache exists, the fetcher returnsNoneand skips the upsert, andnet_worth._to_eurcontributes0.0with 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_ratestill falls back to1.0and 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.pyandgrep -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(expectnotin_(["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(invest_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)