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

125 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""Independently recompute one month's spending per category from raw transactions.
READ-ONLY. Usage:
python3 spending_recompute.py /path/to/fin.db YYYY-MM [--account N] [--json]
Line-by-line replication of crud.get_monthly_spending (api/app/crud.py, as of
2026-07-05), so the output can be diffed against `fin spending month YYYY-MM --json`:
- Query 1: type IN ('expense','splitwise_receive'), amount < 0, account type
NOT IN ('receivable'), date in [month-start, next-month-start); SUM(amount)
grouped by category_id. NOTE: no is_active filter — inactive accounts count.
- Query 2: type = 'splitwise_pay', amount < 0, same account exclusion; each
row contributes amount + COALESCE(splitwise_partner_share, 0) — the user's
net share (e.g. -100 paid, partner owes 50 -> -50).
- Categories whose merged total >= 0 are dropped. total = sum of abs values.
- income_total: type = 'income', account type NOT IN ('payable','receivable'),
description <> 'Opening balance'. SQL NULL semantics apply: rows with NULL
description are ALSO excluded (matches SQLAlchemy's `!=`). Replicated as-is.
- percentage = round(total/overall*100, 1).
This is a recompute tool, not an audit: exit 0 on success, 2 on usage error.
Category order here is sorted by total DESC for readability; the API preserves
dict insertion order — diff by category, not by position.
"""
import argparse
import json
import sqlite3
import sys
from datetime import date
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("db")
ap.add_argument("month", help="YYYY-MM")
ap.add_argument("--account", type=int, default=None)
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
try:
year, mon = int(args.month[:4]), int(args.month[5:7])
start = date(year, mon, 1).isoformat()
end = (date(year + 1, 1, 1) if mon == 12 else date(year, mon + 1, 1)).isoformat()
except ValueError:
print("month must be YYYY-MM", file=sys.stderr)
return 2
con = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
acct_filter = "" if args.account is None else " AND t.account_id = :acct"
params = {"start": start, "end": end, "acct": args.account}
# Query 1: expense + splitwise_receive at raw amount
merged: dict = {}
for r in con.execute(
"SELECT t.category_id AS cid, SUM(t.amount) AS total "
"FROM transactions t JOIN accounts a ON t.account_id = a.id "
"WHERE t.date >= :start AND t.date < :end "
"AND t.type IN ('expense','splitwise_receive') AND t.amount < 0 "
"AND a.type NOT IN ('receivable')" + acct_filter +
" GROUP BY t.category_id", params):
merged[r["cid"]] = merged.get(r["cid"], 0.0) + r["total"]
# Query 2: splitwise_pay at user's net share
for r in con.execute(
"SELECT t.category_id AS cid, t.amount AS amount, "
"t.splitwise_partner_share AS share "
"FROM transactions t JOIN accounts a ON t.account_id = a.id "
"WHERE t.date >= :start AND t.date < :end "
"AND t.type = 'splitwise_pay' AND t.amount < 0 "
"AND a.type NOT IN ('receivable')" + acct_filter, params):
user_share = r["amount"] + (r["share"] or 0.0)
merged[r["cid"]] = merged.get(r["cid"], 0.0) + user_share
cats = {r["id"]: dict(r) for r in con.execute(
"SELECT id, name, color, icon FROM categories")}
categories = []
overall = 0.0
for cid, total in merged.items():
if total >= 0:
continue # no net expense for this category
positive = abs(total)
overall += positive
c = cats.get(cid) if cid is not None else None
categories.append({
"category_id": cid,
"category_name": c["name"] if c else None,
"color": c["color"] if c else None,
"icon": c["icon"] if c else None,
"total": positive,
"percentage": 0.0,
})
for item in categories:
item["percentage"] = round(item["total"] / overall * 100, 1) if overall > 0 else 0.0
categories.sort(key=lambda x: x["total"], reverse=True)
income = con.execute(
"SELECT COALESCE(SUM(t.amount), 0.0) "
"FROM transactions t JOIN accounts a ON t.account_id = a.id "
"WHERE t.date >= :start AND t.date < :end AND t.type = 'income' "
"AND a.type NOT IN ('payable','receivable') "
"AND t.description <> 'Opening balance'" + acct_filter, params).fetchone()[0]
result = {"month": args.month, "total": overall,
"income_total": income, "categories": categories}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"spending_recompute {args.month}"
+ (f" (account {args.account})" if args.account is not None else ""))
for c in categories:
print(f" {c['category_name'] or '(uncategorized)':<24} "
f"{c['total']:>10.2f} {c['percentage']:>5.1f}%")
print(f" {'TOTAL spending':<24} {overall:>10.2f}")
print(f" {'income_total':<24} {income:>10.2f}")
return 0
if __name__ == "__main__":
sys.exit(main())