Files
rune/scripts/finance_tracker.py
T
Nick 853468783d
local-review.yml (push) Invalid workflow file
Auto-version and Release / release (push) Successful in 12s
CI Pipeline / lint (push) Failing after 31s
CI Pipeline / go-surface (push) Failing after 19s
CI Pipeline / canary (push) Failing after 1m32s
CI Pipeline / best-practices (push) Successful in 7s
CI Pipeline / documentation (push) Successful in 7s
CI Pipeline / type-check (push) Failing after 36s
CI Pipeline / gitea-offline (push) Successful in 19s
CI Pipeline / rust-surface (push) Successful in 4m36s
CI Pipeline / unit-tests (push) Failing after 5m50s
deploy / deploy (push) Failing after 15m12s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Successful in 9s
feat(finance): stdlib-only finance tracker — #3 (contract BUILDER-2026-10-08T13:22:02Z-af7a8b)
- scripts/finance_tracker.py: CSV ledger (~/.rune/finance/, human-readable,
  portable), add/budget/report commands, month + week filters, per-category
  totals vs budgets with over-by flags, income/net, and recurring-payment
  detection (3+ same-note ~monthly payments) that surfaces subscription
  cancellation candidates — the exact 'flags subscriptions worth cancelling'
  acceptance.
- tests/test_finance_tracker.py 6/6: sign→kind, budget over-flag, net,
  week filter, monthly-subscription detection, one-off not flagged.
2026-10-08 14:24:29 +01:00

182 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""finance_tracker.py — #3: track Nick's finances, surface waste and opportunity.
Nick has no disposable income; every penny matters. This is a zero-dependency
(stdlib-only) tracker:
add — record an expense or income entry into the CSV ledger
budget — set a monthly category limit
report — per-category totals vs budgets, income vs spend, for a month
or the last 7 days; recurring payments (same note, ~monthly,
3+ occurrences) are flagged as SUBSCRIPTION candidates
Storage: ~/.rune/finance/ledger.csv + budgets.json (human-readable, portable,
yours — no database, no cloud, no lock-in).
finance_tracker.py add -30.00 groceries "weekly shop"
finance_tracker.py add 1450.00 salary "October pay"
finance_tracker.py budget groceries 260
finance_tracker.py report --month 2026-10
finance_tracker.py report --week
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
from collections import defaultdict
from datetime import date, datetime, timedelta
from pathlib import Path
FINANCE_DIR = Path(os.environ.get("RUNE_FINANCE_DIR", Path.home() / ".rune" / "finance"))
LEDGER = FINANCE_DIR / "ledger.csv"
BUDGETS = FINANCE_DIR / "budgets.json"
FIELDS = ["date", "amount", "category", "note", "kind"] # kind: in | out
def _ensure() -> None:
FINANCE_DIR.mkdir(parents=True, exist_ok=True)
if not LEDGER.exists():
with open(LEDGER, "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerow(FIELDS)
def add(amount: float, category: str, note: str = "", kind: str | None = None, when: str | None = None) -> dict:
"""Record one entry. Negative amount = expense, positive = income (unless
kind forces it). Returns the stored row."""
_ensure()
if kind is None:
kind = "out" if amount < 0 else "in"
amount = abs(amount)
row = {
"date": when or date.today().isoformat(),
"amount": f"{amount:.2f}",
"category": category.strip().lower(),
"note": note.strip(),
"kind": kind,
}
with open(LEDGER, "a", newline="", encoding="utf-8") as f:
csv.DictWriter(f, fieldnames=FIELDS).writerow(row)
return row
def set_budget(category: str, monthly_limit: float) -> dict:
_ensure()
budgets = {}
if BUDGETS.exists():
budgets = json.loads(BUDGETS.read_text())
budgets[category.strip().lower()] = round(monthly_limit, 2)
BUDGETS.write_text(json.dumps(budgets, indent=2, sort_keys=True))
return budgets
def _rows() -> list[dict]:
_ensure()
with open(LEDGER, newline="", encoding="utf-8") as f:
return [r for r in csv.DictReader(f) if r.get("date")]
def find_subscriptions(rows: list[dict] | None = None) -> list[dict]:
"""Recurring payments: same normalized note (or category+note), same-ish
amount, 3+ occurrences with ~monthly spacing. These are the
cancellation-review candidates."""
rows = rows if rows is not None else _rows()
groups: dict[str, list[dict]] = defaultdict(list)
for r in rows:
if r["kind"] != "out":
continue
key = (r["note"] or r["category"]).strip().lower()
groups[key].append(r)
subs = []
for key, items in groups.items():
if len(items) < 3:
continue
items.sort(key=lambda r: r["date"])
gaps = [
(datetime.fromisoformat(items[i + 1]["date"]) - datetime.fromisoformat(items[i]["date"])).days
for i in range(len(items) - 1)
]
if gaps and all(20 <= g <= 45 for g in gaps):
amounts = {float(i["amount"]) for i in items}
subs.append(
{
"note": key,
"occurrences": len(items),
"monthly": sorted(amounts)[-1],
"first": items[0]["date"],
"last": items[-1]["date"],
}
)
subs.sort(key=lambda s: -s["monthly"])
return subs
def report(month: str | None = None, week: bool = False) -> dict:
"""Per-category totals vs budgets + income vs spend + subscription flags."""
rows = _rows()
if week:
start = (date.today() - timedelta(days=7)).isoformat()
rows = [r for r in rows if r["date"] >= start]
elif month:
rows = [r for r in rows if r["date"].startswith(month)]
income = sum(float(r["amount"]) for r in rows if r["kind"] == "in")
spend = sum(float(r["amount"]) for r in rows if r["kind"] == "out")
by_cat: dict[str, float] = defaultdict(float)
for r in rows:
if r["kind"] == "out":
by_cat[r["category"]] += float(r["amount"])
budgets = json.loads(BUDGETS.read_text()) if BUDGETS.exists() else {}
over = [
{"category": c, "spent": round(s, 2), "budget": budgets[c], "over_by": round(s - budgets[c], 2)}
for c, s in sorted(by_cat.items())
if c in budgets and s > budgets[c]
]
return {
"income": round(income, 2),
"spend": round(spend, 2),
"net": round(income - spend, 2),
"by_category": {c: round(v, 2) for c, v in sorted(by_cat.items(), key=lambda kv: -kv[1])},
"over_budget": over,
"subscription_candidates": find_subscriptions(rows),
"entries": len(rows),
}
def main() -> int:
ap = argparse.ArgumentParser(description="#3: finances — every penny matters")
sub = ap.add_subparsers(dest="cmd", required=True)
p_add = sub.add_parser("add")
p_add.add_argument("amount", type=float)
p_add.add_argument("category")
p_add.add_argument("note", nargs="?", default="")
p_add.add_argument("--kind", choices=["in", "out"])
p_add.add_argument("--date", dest="when")
p_b = sub.add_parser("budget")
p_b.add_argument("category")
p_b.add_argument("limit", type=float)
p_r = sub.add_parser("report")
p_r.add_argument("--month", help="YYYY-MM")
p_r.add_argument("--week", action="store_true")
args = ap.parse_args()
if args.cmd == "add":
row = add(args.amount, args.category, args.note, args.kind, args.when)
print(f"recorded: {row['kind']} {row['amount']} on {row['category']} ({row['date']})")
elif args.cmd == "budget":
b = set_budget(args.category, args.limit)
print(f"budgets: {json.dumps(b, sort_keys=True)}")
elif args.cmd == "report":
r = report(args.month, args.week)
print(json.dumps(r, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())