Files
money/app.py
T
nmemmertandClaude Sonnet 5.5 3dba054dca
build-container / build (push) Successful in 31s
Statement loader web app with Docker image and Gitea Actions build
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 12:58:29 -04:00

166 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""Local web front end for the statement loader. Run: .venv/bin/python app.py
Opens http://127.0.0.1:8765 - listens on this Mac only; nothing is sent anywhere.
"""
import os, json, secrets, shutil, subprocess, sys, re, calendar, datetime as dt, threading, webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs
import statement_loader as L
PORT = int(os.environ.get("PORT", "8765"))
BIND = os.environ.get("BIND", "127.0.0.1") # in Docker: 0.0.0.0 (publish the port to 127.0.0.1 only)
EXTRA_HOSTS = {h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()}
TOKEN = secrets.token_urlsafe(24)
LOCK = threading.Lock()
HERE = L.HERE # code and pages
DATA = L.DATA # statements and workbook
def eom(y, m): return calendar.monthrange(y, m)[1]
def coverage(sts):
today = dt.date.today()
cfg = {a[0]: a[2] for a in L.ACCOUNTS}
by = {}
for st in sts: by.setdefault(st.account, []).append(st)
if not by: return dict(months=[], accounts=[])
lo = min(s.start or s.end for s in sts); months = []
y, m = lo.year, lo.month
while (y, m) <= (today.year, today.month):
months.append(f"{y}-{m:02d}"); m += 1
if m > 12: y, m = y + 1, 1
out = []
for acct, lst in by.items():
csv_only = all(s.kind == "csv" for s in lst)
cells, first = {}, min((s.start or s.end) for s in lst)
for mo in months:
y, m = int(mo[:4]), int(mo[5:])
if csv_only:
have = any((s.start.year, s.start.month) <= (y, m) <= (s.end.year, s.end.month) for s in lst)
cells[mo] = "have" if have else ("future" if (y, m) > (max(s.end for s in lst).year, max(s.end for s in lst).month) else ("none" if (y, m) < (first.year, first.month) else "missing"))
continue
have = any((s.end.year, s.end.month) == (y, m) for s in lst)
due = dt.date(y, m, cfg.get(acct) or eom(y, m))
started = (y, m) >= (min(s.end for s in lst).year, min(s.end for s in lst).month)
cells[mo] = "have" if have else ("none" if not started else ("future" if due > today else "missing"))
last = max(s.end for s in lst)
if csv_only:
nxt = f"Next download: from {last + dt.timedelta(days=1):%b %-d, %Y}"
else:
ny, nm = (last.year, last.month + 1) if last.month < 12 else (last.year + 1, 1)
if cfg.get(acct) is None and last.day != eom(last.year, last.month): ny, nm = last.year, last.month
nd = dt.date(ny, nm, cfg.get(acct) or eom(ny, nm))
nxt = f"Next statement closes ~{nd:%b %-d, %Y}" + (" (ready to download)" if nd <= today else "")
out.append(dict(account=acct, cells=cells, count=len(lst), through=f"{last:%b %-d, %Y}", next=nxt, kind="csv" if csv_only else "pdf"))
return dict(months=months, accounts=out)
def state():
res = L.run_preview(DATA)
new = res["new"]
rows = [dict(date=f"{r.txn.date:%Y-%m-%d}", account=r.txn.account, desc=r.txn.desc[:60], amount=r.txn.amount, kind=r.kind,
payee=r.payee, what=r.what, reason=r.reason, new_payee=r.is_new_payee) for r in new]
sm = L.summarize(new)
return dict(coverage=coverage(res["statements"]), skipped=[dict(file=f, why=w) for f, w in res["skipped"]],
duplicates=[dict(file=a, same_as=b) for a, b in res["dups"]],
bad=[dict(file=f, parsed=p, stated=s) for f, p, s, ok in res["checks"] if not ok],
already=res["already"], new=rows, summary=sm, statements=len(res["statements"]),
excel_open=(DATA / "~$Bills_Tracker.xlsx").exists() or (DATA / ".~lock.Bills_Tracker.xlsx#").exists(),
backups=sorted(p.name for p in L.BACKUPS.glob("*.xlsx"))[-5:] if L.BACKUPS.exists() else [])
_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
key = L.WORKBOOK.stat().st_mtime_ns
if _DATA.get("key") == key: return _DATA["val"]
wb = load_workbook(L.WORKBOOK, read_only=True)
pay = {}
for r in wb["Lists"].iter_rows(min_row=4, max_row=153, max_col=3, values_only=True):
if r[0]: pay[r[0]] = (r[1] or "Other / Unsorted", r[2] or "Spending")
cats = [r[0] for r in wb["Lists"].iter_rows(min_row=4, max_row=40, min_col=5, max_col=5, values_only=True) if r[0] and not str(r[0]).startswith("Slots")]
tx = []
for r in wb["Bill Log"].iter_rows(min_row=6, max_col=9, values_only=True):
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))
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}")
return _DATA["val"]
def safe_name(name):
name = re.sub(r"[^\w.\- ]", "_", Path(name).name)
if Path(name).suffix.lower() not in (".pdf", ".csv"): raise ValueError("Only PDF or CSV files can be added.")
p = DATA / name; i = 1
while p.exists():
p = DATA / f"{Path(name).stem}_{i}{Path(name).suffix}"; i += 1
return p
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def _send(self, code, body, ctype="application/json"):
b = body if isinstance(body, bytes) else json.dumps(body).encode()
self.send_response(code); self.send_header("Content-Type", ctype); self.send_header("Content-Length", str(len(b)))
self.send_header("Cache-Control", "no-store"); self.end_headers(); self.wfile.write(b)
def _ok_host(self):
if "*" in EXTRA_HOSTS: return True # LAN/NAS use: any hostname or IP (the app has no login, so keep it on a trusted network)
h = self.headers.get("Host", ""); return h.rsplit(":", 1)[0] in {"127.0.0.1", "localhost"} | EXTRA_HOSTS or h in EXTRA_HOSTS
def do_GET(self):
if not self._ok_host(): return self._send(403, {"error": "forbidden"})
u = urlparse(self.path)
if u.path == "/": return self._send(200, PAGE.replace("__TOKEN__", TOKEN).encode(), "text/html; charset=utf-8")
if u.path == "/dashboard":
f = HERE / "app_dashboard.html"
return self._send(200, f.read_bytes() if f.exists() else b"app_dashboard.html missing", "text/html; charset=utf-8")
if u.path == "/api/data":
with LOCK:
try: return self._send(200, workbook_data())
except Exception as e: return self._send(500, {"error": str(e)})
if u.path == "/api/state":
with LOCK:
try: return self._send(200, state())
except Exception as e: return self._send(500, {"error": str(e)})
self._send(404, {"error": "not found"})
def do_POST(self):
if not self._ok_host() or self.headers.get("X-Token") != TOKEN: return self._send(403, {"error": "forbidden"})
u = urlparse(self.path); n = int(self.headers.get("Content-Length") or 0)
try:
if u.path == "/api/upload":
name = parse_qs(u.query).get("name", [""])[0]; data = self.rfile.read(n)
with LOCK:
p = safe_name(name); p.write_bytes(data)
return self._send(200, {"saved": p.name})
self.rfile.read(n)
if u.path == "/api/import":
with LOCK:
res = L.run_preview(DATA)
if not res["new"]: return self._send(200, {"message": "Nothing new to import."})
out = L.apply(res["new"])
return self._send(200, {"message": f"Added {out['log_rows']} rows to the Bill Log and {out['excluded_rows']} to Excluded Transfers.",
"backup": Path(out["backup"]).name, "new_payees": out["new_payees"]})
if u.path == "/api/undo":
with LOCK:
b = sorted(L.BACKUPS.glob("*.xlsx")) if L.BACKUPS.exists() else []
if not b: return self._send(200, {"message": "No backups to restore."})
keep = L.BACKUPS / f"before_undo_{dt.datetime.now():%Y%m%d_%H%M%S}.xlsx"
shutil.copy2(L.WORKBOOK, keep); shutil.copy2(b[-1], L.WORKBOOK)
return self._send(200, {"message": f"Restored the workbook from {b[-1].name}."})
if u.path == "/api/open":
if sys.platform != "darwin": return self._send(200, {"message": "Open Bills_Tracker.xlsx from the data folder on the host."})
subprocess.run(["open", str(L.WORKBOOK)], check=False)
return self._send(200, {"message": "Opening the workbook…"})
except Exception as e:
return self._send(400, {"error": str(e)})
self._send(404, {"error": "not found"})
PAGE = (HERE / "app_page.html").read_text() if (HERE / "app_page.html").exists() else "<h1>app_page.html missing</h1>"
if __name__ == "__main__":
if not L.WORKBOOK.exists(): print(f"WARNING: {L.WORKBOOK} not found - put Bills_Tracker.xlsx in the data folder.")
srv = ThreadingHTTPServer((BIND, PORT), H)
url = f"http://127.0.0.1:{PORT}/"
print(f"Statement loader running at {url} (Ctrl+C to stop)")
if "--no-browser" not in sys.argv and not os.environ.get("DATA_DIR"): threading.Timer(0.8, lambda: webbrowser.open(url)).start()
try: srv.serve_forever()
except KeyboardInterrupt: print("\nStopped.")