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
- 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.
182 lines
6.4 KiB
Python
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())
|