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
125 lines
5.2 KiB
Python
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())
|