Files
fin/.claude/skills/fin-diagnostics-and-tooling/scripts/audit_linked_legs.py
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

145 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Audit linked transaction legs (transfers, settlements, splitwise pairs).
READ-ONLY. Usage: python3 audit_linked_legs.py /path/to/fin.db [--allow-legacy] [--json]
Ground truth: api/app/crud.py (create_transfer, create_settlement,
create_split_expense, create_splitwise_i_paid, create_funded_buy,
delete_transaction legacy fallback) and api/app/models.py.
Rules encoded:
- Every 'transfer'/'settlement' row must have a partner via linked_transaction_id,
EXCEPT the funded-buy cash leg: a single 'transfer' row whose linked_account_id
points to an investment/rsu account (create_funded_buy writes no partner row).
- Rows written before linked_transaction_id existed are "legacy": NULL link but
linked_account_id set. delete_transaction pairs them by reversed accounts +
same date + same type. --allow-legacy accepts legacy rows that have such a match.
- linked_transaction_id must resolve, must not be self, and for
transfer/settlement/splitwise_pay the partner must link back (reciprocal).
The split-expense 'expense' row links one-way into its transfer group by
design and is exempt from reciprocity.
- Same-currency transfer/settlement pairs must cancel: amount_a + amount_b == 0
(fees live in the fee column and are excluded). Cross-currency transfer legs
legitimately differ (to_amount conversion) and are skipped.
- splitwise_pay pairs never cancel by design (bank leg = -total, splitwise leg
= +partner_share) and are never amount-checked.
Exit 0 = clean, 1 = anomalies found.
"""
import argparse
import json
import sqlite3
import sys
EPS = 0.005
PARTNER_REQUIRED = ("transfer", "settlement")
RECIPROCAL = ("transfer", "settlement", "splitwise_pay")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("db")
ap.add_argument("--allow-legacy", action="store_true",
help="accept legacy NULL-link legs that have a crud-style fallback match")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
con = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
accounts = {r["id"]: dict(r) for r in con.execute(
"SELECT id, name, type FROM accounts")}
txns = {r["id"]: dict(r) for r in con.execute(
"SELECT id, date, account_id, amount, currency, type, fee, "
"linked_account_id, linked_transaction_id, description "
"FROM transactions")}
anomalies: list[dict] = []
info = {"transactions": len(txns), "checked_pairs": 0,
"cross_currency_pairs": 0, "funded_buy_legs": 0, "legacy_matched": 0}
def flag(check, txn_id, detail):
anomalies.append({"check": check, "txn_id": txn_id, "detail": detail})
for t in txns.values():
tid, ttype = t["id"], t["type"]
if t["account_id"] not in accounts:
flag("missing-account", tid,
f"account_id={t['account_id']} does not exist")
la = t["linked_account_id"]
if la is not None and la not in accounts:
flag("missing-linked-account", tid,
f"linked_account_id={la} does not exist")
if la is not None and la == t["account_id"]:
flag("self-account-link", tid, "linked_account_id == account_id")
lid = t["linked_transaction_id"]
if lid is not None:
if lid == tid:
flag("self-link", tid, "linked_transaction_id == id")
continue
partner = txns.get(lid)
if partner is None:
flag("orphan-link", tid,
f"linked_transaction_id={lid} does not exist")
continue
if ttype in RECIPROCAL and partner["linked_transaction_id"] != tid:
flag("non-reciprocal", tid,
f"partner {lid} links to {partner['linked_transaction_id']!r}, not back")
if la is not None and partner["account_id"] != la:
flag("partner-account-mismatch", tid,
f"partner {lid} lives on account {partner['account_id']}, "
f"but linked_account_id={la}")
# amount cancellation — once per pair, same type, same currency only
if (ttype in ("transfer", "settlement") and partner["type"] == ttype
and tid < lid):
if t["currency"] == partner["currency"]:
info["checked_pairs"] += 1
resid = t["amount"] + partner["amount"]
if abs(resid) > EPS:
flag("amounts-dont-cancel", tid,
f"pair ({tid},{lid}): {t['amount']} + {partner['amount']} "
f"= {resid:+.2f} {t['currency']}")
else:
info["cross_currency_pairs"] += 1
elif ttype in PARTNER_REQUIRED:
la_type = accounts.get(la, {}).get("type") if la is not None else None
if ttype == "transfer" and la_type in ("investment", "rsu"):
info["funded_buy_legs"] += 1 # single-leg by design
continue
legacy_ok = False
if args.allow_legacy and la is not None:
# replicate delete_transaction's legacy fallback match
for o in txns.values():
if (o["id"] != tid
and o["account_id"] == la
and o["linked_account_id"] == t["account_id"]
and o["linked_transaction_id"] is None
and o["date"] == t["date"]
and o["type"] == ttype):
legacy_ok = True
break
if legacy_ok:
info["legacy_matched"] += 1
else:
flag("unlinked-leg", tid,
f"{ttype} with NULL linked_transaction_id "
f"(linked_account_id={la}, date={t['date']}, amount={t['amount']})")
if args.json:
print(json.dumps({"info": info, "anomalies": anomalies}, indent=2))
else:
print(f"audit_linked_legs: {info['transactions']} transactions, "
f"{info['checked_pairs']} same-currency pairs amount-checked, "
f"{info['cross_currency_pairs']} cross-currency pairs skipped, "
f"{info['funded_buy_legs']} funded-buy single legs, "
f"{info['legacy_matched']} legacy pairs accepted")
for a in anomalies:
print(f"ANOMALY [{a['check']}] txn {a['txn_id']}: {a['detail']}")
print("CLEAN" if not anomalies else f"{len(anomalies)} anomalies")
return 1 if anomalies else 0
if __name__ == "__main__":
sys.exit(main())