Auto-categorize with built-in merchant rules, payee category editor, income/net on dashboard
build-container / build (push) Successful in 35s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 13:17:42 -04:00
1 parent 6073279a1b
commit 837f89e563
7 files changed
+191 -17

No files matched your search

+21 -4
View File
@@ -74,7 +74,7 @@ _DATA = {}
def workbook_data():
"""Read-only snapshot of the Bill Log for the web dashboard (computed from raw cells, not cached formulas)."""
from openpyxl import load_workbook
if not L.WORKBOOK.exists(): return dict(tx=[], categories=[], excluded=0, updated="", setup=True, data_dir=str(DATA))
if not L.WORKBOOK.exists(): return dict(tx=[], income=[], categories=[], excluded=0, updated="", setup=True, data_dir=str(DATA))
key = L.WORKBOOK.stat().st_mtime_ns
if _DATA.get("key") == key: return _DATA["val"]
wb = load_workbook(L.WORKBOOK, read_only=True)
@@ -87,9 +87,16 @@ def workbook_data():
if isinstance(r[0], dt.datetime) and r[1] and r[5] is not None:
c, t = pay.get(r[1], ("Other / Unsorted", "Spending"))
tx.append([f"{r[0]:%Y-%m-%d}", r[1], c, t, r[4] or "", round(float(r[5]), 2), r[6] or "", r[8] or ""])
ex = sum(1 for r in wb["Excluded Transfers"].iter_rows(min_row=5, max_col=1, values_only=True) if isinstance(r[0], dt.datetime))
ex, income = 0, []
for r in wb["Excluded Transfers"].iter_rows(min_row=5, max_col=7, values_only=True):
if not isinstance(r[0], dt.datetime): continue
ex += 1
if r[5] == "Money in" and r[6] in ("Income (payroll deposit)", "Interest earned"):
m = re.search(r"EFT (.+?)\s*[;,]", r[2] or "")
src = (m.group(1).strip().title() if m else (r[2] or "Other").title()) if r[6].startswith("Income") else "Interest"
income.append([f"{r[0]:%Y-%m-%d}", src, "Interest" if r[6].startswith("Interest") else "Paycheck", round(float(r[4]), 2), r[1] or ""])
wb.close()
_DATA["key"], _DATA["val"] = key, dict(tx=tx, categories=cats, excluded=ex, updated=f"{dt.datetime.fromtimestamp(L.WORKBOOK.stat().st_mtime):%b %-d, %Y %-I:%M %p}")
_DATA["key"], _DATA["val"] = key, dict(tx=tx, income=income, categories=cats, excluded=ex, updated=f"{dt.datetime.fromtimestamp(L.WORKBOOK.stat().st_mtime):%b %-d, %Y %-I:%M %p}")
return _DATA["val"]
def safe_name(name):
@@ -124,6 +131,10 @@ class H(BaseHTTPRequestHandler):
with LOCK:
try: return self._send(200, workbook_data())
except Exception as e: return self._send(500, {"error": str(e)})
if u.path == "/api/payees":
with LOCK:
try: return self._send(200, L.uncategorized())
except Exception as e: return self._send(500, {"error": str(e)})
if u.path == "/api/state":
with LOCK:
try: return self._send(200, state())
@@ -140,7 +151,13 @@ class H(BaseHTTPRequestHandler):
if p.name == "loader_rules.json": json.loads(data) # must be valid JSON
DATA.mkdir(parents=True, exist_ok=True); p.write_bytes(data)
return self._send(200, {"saved": p.name})
self.rfile.read(n)
body = self.rfile.read(n)
if u.path == "/api/set_categories":
with LOCK: out = L.set_categories(json.loads(body or b"[]"))
return self._send(200, {"message": f"Updated {out['updated']} payee(s). Backup: {out['backup']}."})
if u.path == "/api/autocategorize":
with LOCK: out = L.autocategorize()
return self._send(200, {"message": f"Auto-categorized {out['updated']} payee(s); {out['remaining']} still need you. Backup: {out['backup']}."})
if u.path == "/api/import":
with LOCK:
res = L.run_preview(DATA)