diff --git a/Dockerfile b/Dockerfile index d761826..c6db74d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY statement_loader.py app.py app_page.html app_dashboard.html ./ +COPY statement_loader.py seed_rules.py app.py app_page.html app_dashboard.html ./ COPY template/ ./template/ # statements, Bills_Tracker.xlsx and backups live in this volume, never in the image diff --git a/README.md b/README.md index 598d3f8..77dfafd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ AppID=money PUID=1000 PGID=1000 TZ=America/New_York docker compose up -d # the "Create a blank workbook" button that copies it into the data folder. Then drop statements in; new payees land on the Lists tab as "Other / Unsorted" for you to categorize. `loader_rules.example.json` shows the private rules file format. +## Categories +New payees are categorized by built-in generic merchant rules (`seed_rules.py`); anything unknown lands in "Other / Unsorted". +Use the **Categorize payees** card in the app (or the Lists tab) to set the rest. Your choices are saved in the workbook. + ## Personal rules (not in git) Your name, card last-4 and similar live in `loader_rules.json` next to the workbook (git-ignored, not in the image): ```json diff --git a/app.py b/app.py index 33aded9..1e57516 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/app_dashboard.html b/app_dashboard.html index ad704b2..848e4c9 100644 --- a/app_dashboard.html +++ b/app_dashboard.html @@ -33,6 +33,8 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height: ← Statement Loader
Read-only view of Bills_Tracker.xlsx. Card payments and transfers are not included (see Excluded Transfers in the workbook). Refunds are negative amounts.
+Read-only view of Bills_Tracker.xlsx. Income = paychecks and interest. Card payments and transfers between your accounts are in neither income nor spending (see Excluded Transfers in the workbook). Refunds are negative amounts.