Statement loader web app with Docker image and Gitea Actions build
build-container / build (push) Successful in 31s
build-container / build (push) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3dba054dca
12 files changed
+1081
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
.venv
|
||||||
|
.git
|
||||||
|
backups
|
||||||
|
data
|
||||||
|
*.pdf
|
||||||
|
*.csv
|
||||||
|
*.xlsx
|
||||||
|
loader_rules.json
|
||||||
|
*.json
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: build-container
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.necloud.us
|
||||||
|
IMAGE: git.necloud.us/nate/money
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest # runner needs Docker access (act_runner with the docker socket)
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Gitea container registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--label org.opencontainers.image.source="${{ gitea.server_url }}/${{ gitea.repository }}" \
|
||||||
|
--label org.opencontainers.image.revision="${{ gitea.sha }}" \
|
||||||
|
-t "$IMAGE:latest" -t "$IMAGE:${{ gitea.sha }}" .
|
||||||
|
|
||||||
|
- name: Smoke test (starts the app and loads the dashboard page)
|
||||||
|
run: |
|
||||||
|
docker run -d --name smoke "$IMAGE:latest"
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
docker exec smoke python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8765/dashboard')" && ok=1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker logs smoke
|
||||||
|
docker rm -f smoke
|
||||||
|
test "$ok" = 1
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
run: |
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
docker push "$IMAGE:${{ gitea.sha }}"
|
||||||
|
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||||
|
docker tag "$IMAGE:latest" "$IMAGE:${{ gitea.ref_name }}"
|
||||||
|
docker push "$IMAGE:${{ gitea.ref_name }}"
|
||||||
|
fi
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# private financial data - never commit
|
||||||
|
*.pdf
|
||||||
|
*.csv
|
||||||
|
*.xlsx
|
||||||
|
backups/
|
||||||
|
data/
|
||||||
|
Statement_Coverage.txt
|
||||||
|
# local tooling
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.DS_Store
|
||||||
|
loader_rules.json
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data BIND=0.0.0.0 PORT=8765
|
||||||
|
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 ./
|
||||||
|
|
||||||
|
# statements, Bills_Tracker.xlsx and backups live in this volume, never in the image
|
||||||
|
RUN useradd -m -u 1000 app && mkdir /data && chown app /data
|
||||||
|
USER app
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 8765
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8765/dashboard')" || exit 1
|
||||||
|
CMD ["python", "app.py"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# money
|
||||||
|
|
||||||
|
Local tool that loads bank/card statements (Chase Visa, Ally, CVFCU PDFs; Capital One CSV) into `Bills_Tracker.xlsx` and shows a web dashboard.
|
||||||
|
Everything runs locally; statements and the workbook are **never** committed or baked into the image.
|
||||||
|
|
||||||
|
## Run on ZimaOS / CasaOS
|
||||||
|
Add the app: App Store -> Custom Install -> import `compose.yaml` (it follows the ZimaOS `x-casaos` format).
|
||||||
|
Copy `Bills_Tracker.xlsx` into `/DATA/AppData/money/data/` (the app's data folder), then open the app's Web UI (port 8765).
|
||||||
|
The image is built by Gitea Actions (`.gitea/workflows/build.yml`) and pushed to `git.necloud.us/nate/money`.
|
||||||
|
Add a repository secret `REGISTRY_TOKEN` (a Gitea access token with `write:package`), and on the ZimaOS box run
|
||||||
|
`docker login git.necloud.us` once so it can pull the private image.
|
||||||
|
|
||||||
|
## Run with plain Docker
|
||||||
|
```bash
|
||||||
|
mkdir -p data && cp /path/to/Bills_Tracker.xlsx data/
|
||||||
|
AppID=money PUID=1000 PGID=1000 TZ=America/New_York docker compose up -d # then edit the bind path in compose.yaml or symlink
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
{"own_names": ["JANE DOE"], "own_bank_hints": ["MY CREDIT UNION"], "csv_accounts": {"1234": "Capital One Card"}}
|
||||||
|
```
|
||||||
|
`own_names` lets the loader recognise transfers between your own accounts. Copy the file into the data folder (`/DATA/AppData/money/data/` on ZimaOS).
|
||||||
|
|
||||||
|
## Run without Docker
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt && .venv/bin/python app.py
|
||||||
|
```
|
||||||
|
`DATA_DIR` (default: this folder) is where statements, the workbook and `backups/` live.
|
||||||
|
|
||||||
|
The app has no login. Publish it on localhost only, or put it behind your own authentication.
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
.venv/bin/python app.py
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/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.")
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Bills Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f6f7f9;--card:#fff;--ink:#1c2230;--mute:#667085;--line:#e4e7ec;--accent:#1f4fd8;--up:#b42318;--down:#1a7f4b;--none:#eef0f3}
|
||||||
|
@media (prefers-color-scheme:dark){:root{--bg:#10131a;--card:#181c25;--ink:#e8ebf2;--mute:#9aa3b5;--line:#2a3040;--accent:#6c8cff;--up:#ff8a80;--down:#5ad08f;--none:#222838}}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}
|
||||||
|
main{max-width:1180px;margin:0 auto;padding:24px 16px 64px}
|
||||||
|
h1{font-size:22px;margin:0}h2{font-size:15px;margin:0 0 10px}.mute{color:var(--mute)}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.spacer{flex:1}
|
||||||
|
a{color:var(--accent);font-weight:600;text-decoration:none}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px}
|
||||||
|
select,input{font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:6px 10px}
|
||||||
|
button{font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer}
|
||||||
|
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:16px 0}
|
||||||
|
.kpi .l{font-size:12px;color:var(--mute)}.kpi .v{font-size:22px;font-weight:700;margin-top:2px}.kpi .s{font-size:12px;color:var(--mute)}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(480px,1fr));gap:16px;margin-bottom:16px}
|
||||||
|
@media (max-width:560px){.grid{grid-template-columns:1fr}}
|
||||||
|
svg{width:100%;height:auto;display:block;overflow:visible}svg text{fill:var(--mute);font-size:11px}
|
||||||
|
.legend{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:8px;font-size:12px;color:var(--mute)}.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px}
|
||||||
|
table{border-collapse:collapse;width:100%}th,td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--line)}th{font-size:12px;color:var(--mute);font-weight:600;position:sticky;top:0;background:var(--card)}
|
||||||
|
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height:460px;overflow:auto}
|
||||||
|
.up{color:var(--up)}.down{color:var(--down)}
|
||||||
|
#tip{position:fixed;pointer-events:none;background:var(--ink);color:var(--bg);padding:6px 9px;border-radius:6px;font-size:12px;display:none;z-index:9;max-width:260px}
|
||||||
|
.bar{fill:var(--accent)}.bar:hover{opacity:.8}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="row"><h1>Bills Dashboard</h1><span class="mute" id="updated"></span><div class="spacer"></div>
|
||||||
|
<label class="mute">Year <select id="year"></select></label><a href="/">← Statement Loader</a></div>
|
||||||
|
<div class="kpis" id="kpis"></div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><h2 id="t1">Monthly total</h2><div id="c1"></div></div>
|
||||||
|
<div class="card"><h2>Monthly spending by category</h2><div id="c2"></div><div class="legend" id="l2"></div></div>
|
||||||
|
<div class="card"><h2>Year over year by month</h2><div id="c3"></div><div class="legend" id="l3"></div></div>
|
||||||
|
<div class="card"><h2>Year over year trend</h2><div id="c4"></div><div class="legend" id="l4"></div></div>
|
||||||
|
<div class="card"><h2>Category totals by year</h2><div id="c5"></div><div class="legend" id="l5"></div></div>
|
||||||
|
<div class="card"><h2 id="t6">Share by category</h2><div id="c6"></div><div class="legend" id="l6"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><h2 id="t7">Top payees</h2><div class="scroll" id="payees"></div></div>
|
||||||
|
<div class="card"><h2>Category detail · year over year</h2><div class="scroll" id="catyoy"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="margin-bottom:10px"><h2 style="margin:0">Transactions</h2><div class="spacer"></div>
|
||||||
|
<input id="q" placeholder="Search payee or description"><select id="fm"><option value="">All months</option></select>
|
||||||
|
<select id="fc"><option value="">All categories</option></select><select id="fa"><option value="">All accounts</option></select></div>
|
||||||
|
<div class="mute" id="count" style="margin-bottom:6px"></div><div class="scroll" id="tx"></div>
|
||||||
|
</div>
|
||||||
|
<p class="mute">Read-only view of Bills_Tracker.xlsx. Card payments and transfers are not included (see Excluded Transfers in the workbook). Refunds are negative amounts.</p>
|
||||||
|
</main>
|
||||||
|
<div id="tip"></div>
|
||||||
|
<script>
|
||||||
|
const $=s=>document.querySelector(s), MON=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||||
|
const PAL=["#4E79A7","#F28E2B","#E15759","#76B7B2","#59A14F","#EDC948","#B07AA1","#9C9C9C"];
|
||||||
|
const money=(n,d=0)=>(n<0?"-":"")+"$"+Math.abs(n).toLocaleString(undefined,{minimumFractionDigits:d,maximumFractionDigits:d});
|
||||||
|
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||||||
|
const tip=$("#tip");
|
||||||
|
function showTip(e,t){tip.innerHTML=t;tip.style.display="block";tip.style.left=Math.min(e.clientX+12,innerWidth-270)+"px";tip.style.top=(e.clientY+12)+"px"}
|
||||||
|
function hideTip(){tip.style.display="none"}
|
||||||
|
document.addEventListener("mouseover",e=>{const t=e.target.closest("[data-tip]");t?showTip(e,t.dataset.tip):hideTip()});
|
||||||
|
document.addEventListener("mousemove",e=>{if(tip.style.display=="block"){tip.style.left=Math.min(e.clientX+12,innerWidth-270)+"px";tip.style.top=(e.clientY+12)+"px"}});
|
||||||
|
let D=null;
|
||||||
|
function niceMax(v){if(v<=0)return 1;const p=Math.pow(10,Math.floor(Math.log10(v)));const m=v/p;return (m<=1?1:m<=2?2:m<=2.5?2.5:m<=5?5:10)*p}
|
||||||
|
// series: [{name,color,values[]}]; mode: "group"|"stack"|"line"
|
||||||
|
function chart(el,labels,series,mode,fmtv=v=>money(v),rot=false){
|
||||||
|
const W=560,B=rot?74:24,H=226+B,L=48,R=8,T=10,iw=W-L-R,ih=H-T-B;
|
||||||
|
let max=0;
|
||||||
|
labels.forEach((_,i)=>{max=Math.max(max,mode=="stack"?series.reduce((a,s)=>a+Math.max(0,s.values[i]||0),0):Math.max(...series.map(s=>s.values[i]||0)))});
|
||||||
|
max=niceMax(max);const y=v=>T+ih-(v/max)*ih;
|
||||||
|
let g="";for(let k=0;k<=4;k++){const v=max*k/4,yy=y(v);g+=`<line x1="${L}" x2="${W-R}" y1="${yy}" y2="${yy}" stroke="var(--line)"/><text x="${L-6}" y="${yy+4}" text-anchor="end">${v>=1000?"$"+(v/1000)+"k":"$"+v}</text>`}
|
||||||
|
const step=iw/labels.length;
|
||||||
|
labels.forEach((l,i)=>{const x=L+step*i+step/2;if(rot)g+=`<text x="${x}" y="${H-B+14}" text-anchor="end" transform="rotate(-35 ${x} ${H-B+14})">${esc(l)}</text>`;else if(labels.length<=12||i%2==0)g+=`<text x="${x}" y="${H-6}" text-anchor="middle">${esc(l)}</text>`});
|
||||||
|
if(mode=="line"){
|
||||||
|
series.forEach(s=>{const pts=s.values.map((v,i)=>v==null?null:[L+step*i+step/2,y(v)]);
|
||||||
|
let d="",pen=false;pts.forEach(p=>{if(!p){pen=false;return}d+=(pen?"L":"M")+p[0].toFixed(1)+" "+p[1].toFixed(1);pen=true});
|
||||||
|
g+=`<path d="${d}" fill="none" stroke="${s.color}" stroke-width="2"/>`;
|
||||||
|
pts.forEach((p,i)=>{if(p)g+=`<circle cx="${p[0]}" cy="${p[1]}" r="4" fill="${s.color}" data-tip="${esc(s.name)} · ${esc(labels[i])}<br><b>${fmtv(s.values[i])}</b>"/>`})});
|
||||||
|
}else if(mode=="stack"){
|
||||||
|
labels.forEach((l,i)=>{let acc=0;const x=L+step*i+step*.15,w=step*.7;
|
||||||
|
series.forEach(s=>{const v=Math.max(0,s.values[i]||0);if(!v)return;const y0=y(acc+v),h=y(acc)-y0;acc+=v;
|
||||||
|
g+=`<rect x="${x}" y="${y0}" width="${w}" height="${Math.max(h,0)}" fill="${s.color}" data-tip="${esc(s.name)} · ${esc(l)}<br><b>${fmtv(v)}</b>"/>`})});
|
||||||
|
}else{
|
||||||
|
const n=series.length,w=step*.8/n;
|
||||||
|
labels.forEach((l,i)=>series.forEach((s,k)=>{const v=s.values[i]||0;if(v<=0)return;
|
||||||
|
g+=`<rect x="${L+step*i+step*.1+w*k}" y="${y(v)}" width="${w*.92}" height="${y(0)-y(v)}" fill="${s.color}" rx="2" data-tip="${esc(s.name)} · ${esc(l)}<br><b>${fmtv(v)}</b>"/>`}));
|
||||||
|
}
|
||||||
|
$(el).innerHTML=`<svg viewBox="0 0 ${W} ${H}" role="img">${g}</svg>`;
|
||||||
|
}
|
||||||
|
function legend(el,items){$(el).innerHTML=items.map(i=>`<span><i style="background:${i.color}"></i>${esc(i.name)}</span>`).join("")}
|
||||||
|
function donut(el,items){
|
||||||
|
const tot=items.reduce((a,i)=>a+i.value,0);if(!tot){$(el).innerHTML='<div class="mute">No data</div>';return}
|
||||||
|
const cx=120,cy=110,r=90,ri=55;let a0=-Math.PI/2,g="";
|
||||||
|
items.forEach(i=>{const a1=a0+(i.value/tot)*2*Math.PI,lg=a1-a0>Math.PI?1:0;
|
||||||
|
const p=(a,rr)=>[cx+rr*Math.cos(a),cy+rr*Math.sin(a)];const [x0,y0]=p(a0,r),[x1,y1]=p(a1-.0001,r),[x2,y2]=p(a1-.0001,ri),[x3,y3]=p(a0,ri);
|
||||||
|
g+=`<path d="M${x0} ${y0}A${r} ${r} 0 ${lg} 1 ${x1} ${y1}L${x2} ${y2}A${ri} ${ri} 0 ${lg} 0 ${x3} ${y3}Z" fill="${i.color}" data-tip="${esc(i.name)}<br><b>${money(i.value)}</b> · ${(i.value/tot*100).toFixed(1)}%"/>`;a0=a1});
|
||||||
|
g+=`<text x="${cx}" y="${cy-2}" text-anchor="middle" style="font-size:18px;font-weight:700;fill:var(--ink)">${money(tot)}</text><text x="${cx}" y="${cy+14}" text-anchor="middle">total</text>`;
|
||||||
|
$(el).innerHTML=`<svg viewBox="0 0 240 220" style="max-width:300px;margin:0 auto">${g}</svg>`;
|
||||||
|
}
|
||||||
|
const sum=(a,f)=>a.reduce((s,x)=>s+f(x),0);
|
||||||
|
function render(){
|
||||||
|
const Y=+$("#year").value, all=D.tx.map(t=>({d:t[0],y:+t[0].slice(0,4),m:+t[0].slice(5,7),payee:t[1],cat:t[2],type:t[3],what:t[4],amt:t[5],acct:t[6],note:t[7]}));
|
||||||
|
const yrs=[...new Set(all.map(t=>t.y))].sort(), cur=all.filter(t=>t.y==Y);
|
||||||
|
$("#t1").textContent=`Monthly total · ${Y}`;$("#t6").textContent=`Share by category · ${Y}`;$("#t7").textContent=`Top payees · ${Y}`;
|
||||||
|
const byM=Array(12).fill(0);cur.forEach(t=>byM[t.m-1]+=t.amt);
|
||||||
|
const total=sum(byM,x=>x),active=byM.filter(v=>v!=0).length,hi=Math.max(...byM);
|
||||||
|
const cats={};cur.forEach(t=>cats[t.cat]=(cats[t.cat]||0)+t.amt);
|
||||||
|
const topc=Object.entries(cats).sort((a,b)=>b[1]-a[1]);
|
||||||
|
const lastM=cur.length?Math.max(...cur.map(t=>t.m)):0, prev=all.filter(t=>t.y==Y-1&&t.m<=lastM), curYtd=total;
|
||||||
|
const prevT=sum(prev,t=>t.amt), chg=prevT?(curYtd/prevT-1):null;
|
||||||
|
$("#kpis").innerHTML=[
|
||||||
|
["Total paid",money(total),`${cur.length} payments`],
|
||||||
|
["Average per month",money(active?total/active:0),`${active} month(s) with data`],
|
||||||
|
["Highest month",hi>0?MON[byM.indexOf(hi)]:"-",hi>0?money(hi):""],
|
||||||
|
["Largest category",topc[0]?topc[0][0]:"-",topc[0]?money(topc[0][1]):""],
|
||||||
|
[`${Y} vs ${Y-1}, same months`,chg==null?"n/a":`<span class="${chg>0?"up":"down"}">${chg>0?"+":""}${(chg*100).toFixed(1)}%</span>`,prevT?`${money(curYtd)} vs ${money(prevT)} (Jan–${MON[lastM-1]})`:`no ${Y-1} data`]
|
||||||
|
].map(k=>`<div class="card kpi"><div class="l">${k[0]}</div><div class="v">${k[1]}</div><div class="s">${k[2]}</div></div>`).join("");
|
||||||
|
chart("#c1",MON,[{name:"Total",color:PAL[0],values:byM}],"group");
|
||||||
|
// categories: top 7 + Other
|
||||||
|
const top=topc.slice(0,7).map(c=>c[0]);const col=c=>PAL[top.indexOf(c)>=0?top.indexOf(c):7];
|
||||||
|
const sers=[...top,"All other"].map((c,i)=>({name:c,color:PAL[i],values:Array(12).fill(0)}));
|
||||||
|
cur.forEach(t=>{const i=top.indexOf(t.cat);sers[i>=0?i:7].values[t.m-1]+=t.amt});
|
||||||
|
chart("#c2",MON,sers.filter(s=>s.values.some(v=>v)),"stack");legend("#l2",sers.filter(s=>s.values.some(v=>v)));
|
||||||
|
// YoY (latest 4 years ending at the selected year)
|
||||||
|
const ys=yrs.filter(y=>y<=Y).slice(-4);const ysers=ys.map((y,i)=>({name:String(y),color:PAL[i%7],values:Array(12).fill(0),has:Array(12).fill(false)}));
|
||||||
|
all.forEach(t=>{const i=ys.indexOf(t.y);if(i>=0){ysers[i].values[t.m-1]+=t.amt;ysers[i].has[t.m-1]=true}});
|
||||||
|
chart("#c3",MON,ysers,"group");legend("#l3",ysers);
|
||||||
|
chart("#c4",MON,ysers.map(s=>({...s,values:s.values.map((v,i)=>s.has[i]?v:null)})),"line");legend("#l4",ysers);
|
||||||
|
// category by year
|
||||||
|
const allCats=[...new Set(all.filter(t=>ys.includes(t.y)).map(t=>t.cat))];
|
||||||
|
const catTot=c=>sum(all.filter(t=>t.cat==c&&ys.includes(t.y)),t=>t.amt);const cl=allCats.sort((a,b)=>catTot(b)-catTot(a)).slice(0,10);
|
||||||
|
const cser=ys.map((y,i)=>({name:String(y),color:PAL[i%7],values:cl.map(c=>sum(all.filter(t=>t.y==y&&t.cat==c),t=>t.amt))}));
|
||||||
|
chart("#c5",cl,cser,"group",undefined,true);legend("#l5",cser);
|
||||||
|
const dn=topc.slice(0,7).map((c,i)=>({name:c[0],color:PAL[i],value:Math.max(0,c[1])}));const rest=topc.slice(7).reduce((a,c)=>a+Math.max(0,c[1]),0);if(rest)dn.push({name:"All other",color:PAL[7],value:rest});
|
||||||
|
donut("#c6",dn);legend("#l6",dn);
|
||||||
|
// top payees
|
||||||
|
const pm={};cur.forEach(t=>{(pm[t.payee]=pm[t.payee]||{amt:0,n:0,cat:t.cat});pm[t.payee].amt+=t.amt;pm[t.payee].n++});
|
||||||
|
$("#payees").innerHTML="<table><tr><th>Payee</th><th>Category</th><th class='n'>Payments</th><th class='n'>Total</th></tr>"+Object.entries(pm).sort((a,b)=>b[1].amt-a[1].amt).slice(0,25).map(([p,v])=>`<tr><td>${esc(p)}</td><td class="mute">${esc(v.cat)}</td><td class="n">${v.n}</td><td class="n">${money(v.amt)}</td></tr>`).join("")+"</table>";
|
||||||
|
// category yoy table
|
||||||
|
$("#catyoy").innerHTML="<table><tr><th>Category</th>"+ys.map(y=>`<th class='n'>${y}</th>`).join("")+"<th class='n'>Change</th></tr>"+cl.map(c=>{
|
||||||
|
const v=ys.map(y=>sum(all.filter(t=>t.y==y&&t.cat==c),t=>t.amt));const a=v[v.length-2],b=v[v.length-1];
|
||||||
|
const ch=v.length>1&&a>0&&b>0?(b/a-1):null;return `<tr><td>${esc(c)}</td>`+v.map(x=>`<td class="n">${money(x)}</td>`).join("")+`<td class="n ${ch>0?"up":"down"}">${ch==null?"":(ch>0?"+":"")+(ch*100).toFixed(0)+"%"}</td></tr>`}).join("")+"</table>";
|
||||||
|
renderTx(all);
|
||||||
|
}
|
||||||
|
let LIM=200;
|
||||||
|
function renderTx(all){
|
||||||
|
const q=$("#q").value.toLowerCase(),fm=$("#fm").value,fc=$("#fc").value,fa=$("#fa").value,Y=+$("#year").value;
|
||||||
|
let r=all.filter(t=>t.y==Y&&(!fm||t.m==+fm)&&(!fc||t.cat==fc)&&(!fa||t.acct==fa)&&(!q||(t.payee+" "+t.what+" "+t.note).toLowerCase().includes(q)));
|
||||||
|
r.sort((a,b)=>b.d.localeCompare(a.d));
|
||||||
|
$("#count").textContent=`${r.length} transactions · ${money(sum(r,t=>t.amt),2)}`;
|
||||||
|
$("#tx").innerHTML="<table><tr><th>Date</th><th>Payee</th><th>Category</th><th>What it was for</th><th>Account</th><th class='n'>Amount</th></tr>"+r.slice(0,LIM).map(t=>`<tr><td>${t.d}</td><td>${esc(t.payee)}</td><td class="mute">${esc(t.cat)}</td><td class="mute">${esc(t.what)}${t.note?` <i>(${esc(t.note)})</i>`:""}</td><td class="mute">${esc(t.acct)}</td><td class="n">${money(t.amt,2)}</td></tr>`).join("")+"</table>"+(r.length>LIM?`<div style="padding:8px"><button onclick="LIM+=300;render()">Show more</button></div>`:"");
|
||||||
|
}
|
||||||
|
async function init(){
|
||||||
|
const j=await (await fetch("/api/data")).json();if(j.error){document.body.innerHTML="<main>"+esc(j.error)+"</main>";return}
|
||||||
|
D=j;$("#updated").textContent="Workbook saved "+j.updated;
|
||||||
|
const yrs=[...new Set(j.tx.map(t=>+t[0].slice(0,4)))].sort().reverse();
|
||||||
|
$("#year").innerHTML=yrs.map(y=>`<option>${y}</option>`).join("");
|
||||||
|
$("#fm").innerHTML+=MON.map((m,i)=>`<option value="${i+1}">${m}</option>`).join("");
|
||||||
|
$("#fc").innerHTML+=[...new Set(j.tx.map(t=>t[2]))].sort().map(c=>`<option>${esc(c)}</option>`).join("");
|
||||||
|
$("#fa").innerHTML+=[...new Set(j.tx.map(t=>t[6]))].sort().map(c=>`<option>${esc(c)}</option>`).join("");
|
||||||
|
["year","fm","fc","fa"].forEach(i=>$("#"+i).onchange=()=>{LIM=200;render()});$("#q").oninput=()=>{LIM=200;render()};
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Statement Loader</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f6f7f9;--card:#fff;--ink:#1c2230;--mute:#667085;--line:#e4e7ec;--accent:#1f4fd8;--ok:#1a7f4b;--okbg:#e6f4ec;--miss:#b42318;--missbg:#fdeceb;--warn:#9a6700;--warnbg:#fff4d6;--none:#eef0f3}
|
||||||
|
@media (prefers-color-scheme:dark){:root{--bg:#10131a;--card:#181c25;--ink:#e8ebf2;--mute:#9aa3b5;--line:#2a3040;--accent:#6c8cff;--ok:#5ad08f;--okbg:#14301f;--miss:#ff8a80;--missbg:#3a1a18;--warn:#f0c14b;--warnbg:#3a2f10;--none:#222838}}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:24px 16px 64px}
|
||||||
|
h1{font-size:22px;margin:0 0 2px}h2{font-size:15px;margin:0 0 10px}.sub{color:var(--mute);margin-bottom:20px}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:16px}
|
||||||
|
#drop{border:2px dashed var(--line);border-radius:10px;padding:26px;text-align:center;color:var(--mute);cursor:pointer}
|
||||||
|
#drop.over{border-color:var(--accent);background:var(--none);color:var(--ink)}
|
||||||
|
button{font:inherit;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 14px;cursor:pointer}
|
||||||
|
button.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}button:disabled{opacity:.45;cursor:default}
|
||||||
|
.grid{overflow-x:auto}table{border-collapse:collapse;width:100%}th,td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||||
|
th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
.cell{display:inline-block;width:26px;height:22px;border-radius:5px;text-align:center;line-height:22px;font-size:12px;font-weight:700}
|
||||||
|
.have{background:var(--okbg);color:var(--ok)}.missing{background:var(--missbg);color:var(--miss)}.future,.none{background:var(--none);color:var(--mute)}
|
||||||
|
.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px}.pill.new{background:var(--warnbg);color:var(--warn)}.pill.ex{background:var(--none);color:var(--mute)}
|
||||||
|
.alert{padding:10px 12px;border-radius:8px;margin-bottom:8px}.alert.bad{background:var(--missbg);color:var(--miss)}.alert.warn{background:var(--warnbg);color:var(--warn)}.alert.good{background:var(--okbg);color:var(--ok)}
|
||||||
|
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.spacer{flex:1}.mute{color:var(--mute)}code{background:var(--none);padding:1px 5px;border-radius:4px}
|
||||||
|
.legend span{margin-right:14px;font-size:12px;color:var(--mute)}
|
||||||
|
#help{display:none;margin-top:10px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="row"><h1 style="margin:0">Statement Loader</h1><div class="spacer"></div><a href="/dashboard" style="color:var(--accent);font-weight:600;text-decoration:none">Dashboard →</a></div>
|
||||||
|
<div class="sub">Runs only on this Mac. Drop in statements, check what’s covered, then add the new transactions to <b>Bills_Tracker.xlsx</b>. A backup is saved first, every time.</div>
|
||||||
|
|
||||||
|
<div id="alerts"></div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>1 · Add statements</h2>
|
||||||
|
<div id="drop">Drop PDF or CSV statements here, or click to choose files<input id="file" type="file" multiple accept=".pdf,.csv" hidden></div>
|
||||||
|
<div class="mute" style="margin-top:8px" id="uploadmsg">Supported: Chase Visa PDF · Ally Bank PDF · CVFCU PDF · Capital One CSV download.
|
||||||
|
<a href="#" id="helplink">Adding a different account?</a></div>
|
||||||
|
<div id="help" class="mute">Open <code>statement_loader.py</code>, add one line to <code>ACCOUNTS</code> (a name, a word printed on page 1 of its statement, and the day it closes), then ask Claude to write the parser for that bank’s layout. Any file the app can’t read is listed under “Needs attention”.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>2 · Coverage</h2>
|
||||||
|
<div class="grid" id="coverage"></div>
|
||||||
|
<div class="legend" style="margin-top:8px"><span><b style="color:var(--ok)">✓</b> have it</span><span><b style="color:var(--miss)">!</b> missing</span><span>· not yet due / before first statement</span><span>Columns = month the statement closes.</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="row"><h2 style="margin:0">3 · Review & import</h2><div class="spacer"></div>
|
||||||
|
<button id="open">Open workbook</button><button id="undo">Undo last import</button><button class="primary" id="import" disabled>Add to workbook</button></div>
|
||||||
|
<div id="summary" class="mute" style="margin:10px 0"></div>
|
||||||
|
<div class="grid"><table id="preview"></table></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const TOKEN="__TOKEN__";
|
||||||
|
const $=s=>document.querySelector(s);
|
||||||
|
const fmt=n=>(n<0?"-":"")+"$"+Math.abs(n).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||||
|
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||||||
|
const mon=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||||
|
async function post(path,body,extra){const r=await fetch(path,{method:"POST",headers:{"X-Token":TOKEN,...(extra||{})},body});const j=await r.json();if(!r.ok)throw new Error(j.error||"Request failed");return j}
|
||||||
|
function alertBox(kind,html){$("#alerts").insertAdjacentHTML("beforeend",`<div class="alert ${kind}">${html}</div>`)}
|
||||||
|
async function refresh(msg){
|
||||||
|
$("#alerts").innerHTML=msg?`<div class="alert good">${msg}</div>`:"";
|
||||||
|
$("#coverage").innerHTML="Reading statements…";
|
||||||
|
const r=await fetch("/api/state");const s=await r.json();
|
||||||
|
if(s.error){alertBox("bad",esc(s.error));return}
|
||||||
|
s.bad.forEach(b=>alertBox("bad",`<b>${esc(b.file)}</b>: the transactions I read add up to ${fmt(b.parsed)} but the statement says ${fmt(b.stated)}. It will <b>not</b> be imported until that matches.`));
|
||||||
|
s.skipped.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b>: ${esc(b.why)}`));
|
||||||
|
s.duplicates.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b> is a duplicate of ${esc(b.same_as)}; counted once.`));
|
||||||
|
if(s.excel_open)alertBox("warn","Bills_Tracker.xlsx looks open in Excel. Close it before importing so changes aren’t overwritten.");
|
||||||
|
const c=s.coverage; let h="<table><tr><th>Account</th>"+c.months.map(m=>`<th>${mon[+m.slice(5)-1]}<br>${m.slice(2,4)}</th>`).join("")+"<th>Status</th></tr>";
|
||||||
|
c.accounts.forEach(a=>{h+=`<tr><td><b>${esc(a.account)}</b><div class="mute" style="font-size:12px">${a.count} ${a.kind=="csv"?"download(s)":"statements"}</div></td>`+
|
||||||
|
c.months.map(m=>{const v=a.cells[m];return `<td><span class="cell ${v}">${v=="have"?"✓":v=="missing"?"!":"·"}</span></td>`}).join("")+`<td class="mute">through ${esc(a.through)}<br>${esc(a.next)}</td></tr>`});
|
||||||
|
$("#coverage").innerHTML=h+"</table>";
|
||||||
|
const n=s.new; $("#import").disabled=!n.length;
|
||||||
|
$("#summary").innerHTML=n.length?`<b>${n.length}</b> new transactions found (${s.summary.log} go to the Bill Log, ${s.summary.excluded} are card payments/transfers kept off the spending totals). ${s.already} were already in the workbook.`+(s.summary.new_payees.length?` <span class="pill new">${s.summary.new_payees.length} new payee(s) to categorize</span>`:""):`Nothing new — all ${s.already} transactions in ${s.statements} files are already in the workbook.`;
|
||||||
|
$("#preview").innerHTML=n.length?"<tr><th>Date</th><th>Account</th><th>Statement description</th><th>Goes to</th><th>Payee / reason</th><th class='num'>Amount</th></tr>"+n.map(r=>`<tr><td>${r.date}</td><td>${esc(r.account)}</td><td>${esc(r.desc)}</td><td><span class="pill ${r.kind=="log"?"new":"ex"}">${r.kind=="log"?"Bill Log":"Excluded"}</span></td><td>${esc(r.kind=="log"?r.payee:r.reason)}${r.new_payee?' <span class="pill new">new</span>':""}</td><td class="num">${fmt(r.amount)}</td></tr>`).join(""):"";
|
||||||
|
}
|
||||||
|
async function upload(files){
|
||||||
|
let ok=0,errs=[];$("#uploadmsg").textContent="Uploading…";
|
||||||
|
for(const f of files){try{await post("/api/upload?name="+encodeURIComponent(f.name),f);ok++}catch(e){errs.push(f.name+": "+e.message)}}
|
||||||
|
await refresh(`${ok} file(s) added.`+(errs.length?" Problems: "+esc(errs.join("; ")):""));
|
||||||
|
$("#uploadmsg").innerHTML="Supported: Chase Visa PDF · Ally Bank PDF · CVFCU PDF · Capital One CSV download.";
|
||||||
|
}
|
||||||
|
const drop=$("#drop"),inp=$("#file");
|
||||||
|
drop.onclick=()=>inp.click();inp.onchange=()=>upload([...inp.files]);
|
||||||
|
["dragenter","dragover"].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add("over")}));
|
||||||
|
["dragleave","drop"].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove("over")}));
|
||||||
|
drop.addEventListener("drop",ev=>upload([...ev.dataTransfer.files]));
|
||||||
|
$("#helplink").onclick=e=>{e.preventDefault();$("#help").style.display="block"};
|
||||||
|
$("#import").onclick=async()=>{
|
||||||
|
if(!confirm("Back up the workbook and add these transactions?"))return;
|
||||||
|
$("#import").disabled=true;
|
||||||
|
try{const j=await post("/api/import","");await refresh(esc(j.message)+" Backup: "+esc(j.backup||"")+(j.new_payees&&j.new_payees.length?". New payees were added to the Lists tab as “Other / Unsorted” — set their categories there.":""))}catch(e){alertBox("bad",esc(e.message))}
|
||||||
|
};
|
||||||
|
$("#undo").onclick=async()=>{if(!confirm("Restore the workbook from the most recent backup? This discards the last import."))return;try{const j=await post("/api/undo","");await refresh(esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};
|
||||||
|
$("#open").onclick=async()=>{try{await post("/api/open","")}catch(e){alertBox("bad",esc(e.message))}};
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: money
|
||||||
|
services:
|
||||||
|
money:
|
||||||
|
image: git.necloud.us/nate/money:latest
|
||||||
|
container_name: money
|
||||||
|
restart: unless-stopped
|
||||||
|
user: $PUID:$PGID
|
||||||
|
ports:
|
||||||
|
- target: 8765
|
||||||
|
published: "8765"
|
||||||
|
protocol: tcp
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /DATA/AppData/$AppID/data
|
||||||
|
target: /data
|
||||||
|
environment:
|
||||||
|
- DATA_DIR=/data
|
||||||
|
- ALLOWED_HOSTS=*
|
||||||
|
- TZ=$TZ
|
||||||
|
x-casaos:
|
||||||
|
envs:
|
||||||
|
- container: ALLOWED_HOSTS
|
||||||
|
description:
|
||||||
|
en_us: "Hostnames the web page accepts. * = any (the app has no login, so only use it on a trusted network)."
|
||||||
|
- container: TZ
|
||||||
|
description:
|
||||||
|
en_us: Time zone
|
||||||
|
ports:
|
||||||
|
- container: "8765"
|
||||||
|
description:
|
||||||
|
en_us: Web UI port
|
||||||
|
volumes:
|
||||||
|
- container: /data
|
||||||
|
description:
|
||||||
|
en_us: "Bills_Tracker.xlsx, your statements (PDF/CSV) and backups. Put Bills_Tracker.xlsx here before first use."
|
||||||
|
x-casaos:
|
||||||
|
architectures:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
main: money
|
||||||
|
author: nate
|
||||||
|
developer: nate
|
||||||
|
category: Finance
|
||||||
|
icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/actual-budget.png
|
||||||
|
tagline:
|
||||||
|
en_us: Load bank statements into your bills workbook
|
||||||
|
title:
|
||||||
|
en_us: Money
|
||||||
|
description:
|
||||||
|
en_us: "Drop in Chase, Ally, CVFCU and Capital One statements, see which months are missing, add new transactions to Bills_Tracker.xlsx (with a backup first), and view a bills dashboard. Runs entirely on your server."
|
||||||
|
port_map: "8765"
|
||||||
|
index: /
|
||||||
|
scheme: http
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
openpyxl==3.1.5
|
||||||
|
pdfplumber==0.11.10
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Statement loader: parses bank/card statements and adds new transactions to Bills_Tracker.xlsx.
|
||||||
|
|
||||||
|
Sources understood: Chase Visa PDF, Ally Bank PDF, CVFCU PDF, Capital One CSV download.
|
||||||
|
Everything runs locally. The workbook is backed up before every write.
|
||||||
|
|
||||||
|
CLI: .venv/bin/python statement_loader.py preview # what would be added (writes nothing)
|
||||||
|
.venv/bin/python statement_loader.py import # back up, then add new rows
|
||||||
|
"""
|
||||||
|
import re, csv, sys, shutil, zipfile, datetime as dt, calendar, collections
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
import pdfplumber
|
||||||
|
|
||||||
|
import os
|
||||||
|
HERE = Path(__file__).parent # code lives here
|
||||||
|
DATA = Path(os.environ.get("DATA_DIR", HERE)) # statements, workbook and backups live here (a mounted volume in Docker)
|
||||||
|
WORKBOOK = DATA / "Bills_Tracker.xlsx"
|
||||||
|
BACKUPS = DATA / "backups"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- accounts
|
||||||
|
# name, text on page 1 that identifies it, day-of-month the statement closes (None = month end)
|
||||||
|
ACCOUNTS = [
|
||||||
|
("Chase Visa", ["chase.com", "Prime Visa"], 15),
|
||||||
|
("Ally Checking", ["COMBINED CUSTOMER STATEMENT", "ally.com"], 10),
|
||||||
|
("CVFCU Checking", ["cvfcu.com"], None),
|
||||||
|
]
|
||||||
|
CSV_ACCOUNTS = {} # optional: last 4 of card no. in the CSV -> account name (set in loader_rules.json)
|
||||||
|
CSV_COLUMNS = ("Transaction Date", "Debit", "Credit", "Description")
|
||||||
|
|
||||||
|
# Personal matching rules live in DATA/loader_rules.json (git-ignored, never baked into the image), e.g.
|
||||||
|
# {"own_names": ["JANE DOE"], "own_bank_hints": ["MY CREDIT UNION"], "csv_accounts": {"1234": "Capital One Card"}}
|
||||||
|
# own_names: your name as it appears in transfer descriptions, so transfers between your own accounts are not counted as spending.
|
||||||
|
import json
|
||||||
|
def _rules():
|
||||||
|
try: return json.loads((DATA / "loader_rules.json").read_text())
|
||||||
|
except (OSError, ValueError): return {}
|
||||||
|
def own(d): return any(n.upper() in d for n in _rules().get("own_names", []))
|
||||||
|
def bank_hint(d): return any(n.upper() in d for n in _rules().get("own_bank_hints", []))
|
||||||
|
|
||||||
|
def money(s):
|
||||||
|
s = s.replace(",", "").replace("$", "")
|
||||||
|
return float(s)
|
||||||
|
def pdate(s):
|
||||||
|
for f in ("%m/%d/%Y", "%m/%d/%y", "%Y-%m-%d"):
|
||||||
|
try: return dt.datetime.strptime(s, f).date()
|
||||||
|
except ValueError: pass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Txn:
|
||||||
|
account: str
|
||||||
|
date: dt.date
|
||||||
|
desc: str # statement description as shown
|
||||||
|
detail: str # continuation lines (merchant detail on Ally etc.)
|
||||||
|
amount: float # positive = money out / charge, negative = money in / refund
|
||||||
|
source: str = "" # file name
|
||||||
|
extra: str = "" # e.g. mortgage principal/interest note
|
||||||
|
category_hint: str = ""
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Statement:
|
||||||
|
file: str
|
||||||
|
account: str
|
||||||
|
start: dt.date | None
|
||||||
|
end: dt.date
|
||||||
|
txns: list = field(default_factory=list)
|
||||||
|
stated_out: float | None = None # total "money out" the statement itself reports (for checking)
|
||||||
|
kind: str = "pdf"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- PDF parsers
|
||||||
|
def _pages(path):
|
||||||
|
with pdfplumber.open(path) as pdf:
|
||||||
|
return [(pg.extract_text() or "") for pg in pdf.pages]
|
||||||
|
|
||||||
|
def identify_pdf(pages):
|
||||||
|
first = pages[0].lower()
|
||||||
|
for name, keys, _ in ACCOUNTS:
|
||||||
|
if any(k.lower() in first for k in keys): return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_chase(path, pages):
|
||||||
|
full = "\n".join(pages)
|
||||||
|
m = re.search(r"Opening/Closing Date\s+(\S+) - (\S+)", full)
|
||||||
|
start, end = pdate(m.group(1)), pdate(m.group(2))
|
||||||
|
out = {}
|
||||||
|
for k in ("Purchases", "Fees Charged", "Interest Charged"):
|
||||||
|
mm = re.search(rf"^{k}\s+\+?\$([\d,]+\.\d\d)", full, re.M)
|
||||||
|
out[k] = money(mm.group(1)) if mm else 0.0
|
||||||
|
st = Statement(Path(path).name, "Chase Visa", start, end, stated_out=sum(out.values()))
|
||||||
|
in_rewards = False
|
||||||
|
for line in full.splitlines():
|
||||||
|
if re.search(r"\$ Amount Rewards", line): in_rewards = True; continue
|
||||||
|
if re.match(r"ACCOUNT ACTIVITY|PAYMENTS AND OTHER|PURCHASE$|FEES CHARGED|INTEREST CHARGED", line): in_rewards = False
|
||||||
|
if in_rewards: continue
|
||||||
|
mm = re.match(r"^(\d\d)/(\d\d) (.+?) (-?(?:[\d,]+)?\.\d\d)$", line)
|
||||||
|
if not mm: continue
|
||||||
|
mo, dy = int(mm.group(1)), int(mm.group(2))
|
||||||
|
yr = end.year if mo <= end.month or (mo, dy) <= (end.month, end.day) else end.year - 1
|
||||||
|
if dt.date(yr, mo, dy) > end + dt.timedelta(days=31): yr -= 1
|
||||||
|
st.txns.append(Txn("Chase Visa", dt.date(yr, mo, dy), mm.group(3).strip(), "", money(mm.group(4)), st.file))
|
||||||
|
return st
|
||||||
|
|
||||||
|
ALLY_TX = re.compile(r"^(\d\d/\d\d/\d{4}) (.+?) \$([\d,]+\.\d\d) -\$([\d,]+\.\d\d) \$([\d,]+\.\d\d)$")
|
||||||
|
def parse_ally(path, pages):
|
||||||
|
full = "\n".join(pages)
|
||||||
|
m = re.search(r"Statement Date.{0,120}?(\d\d/\d\d/\d{4})", pages[0], re.S)
|
||||||
|
end = pdate(m.group(1))
|
||||||
|
mb = re.search(r"Beginning Balance, as of (\d\d/\d\d/\d{4})", full)
|
||||||
|
start = pdate(mb.group(1)) + dt.timedelta(days=1) if mb else None
|
||||||
|
md = re.search(r"Withdrawals and Other Debits\s+-?\$([\d,]+\.\d\d)", full)
|
||||||
|
st = Statement(Path(path).name, "Ally Checking", start, end, stated_out=money(md.group(1)) if md else None)
|
||||||
|
acct, cur = "Ally Checking", None
|
||||||
|
for line in full.splitlines():
|
||||||
|
if re.match(r"^(Spending|Savings) Account\b", line) and "xxxx" not in line.lower() and "www" in line:
|
||||||
|
acct = "Ally Checking" if line.startswith("Spending") else "Ally Savings"
|
||||||
|
mm = ALLY_TX.match(line)
|
||||||
|
if mm:
|
||||||
|
if mm.group(2) in ("Beginning Balance", "Ending Balance"): cur = None; continue
|
||||||
|
cr, db = money(mm.group(3)), money(mm.group(4))
|
||||||
|
cur = Txn(acct, pdate(mm.group(1)), re.sub(r"^(\w) (\w)", r"\1\2", mm.group(2)).strip(), "", round(db - cr, 2), st.file)
|
||||||
|
st.txns.append(cur); continue
|
||||||
|
if re.match(r"^\d\d/\d\d/\d{4} (Beginning|Ending) Balance", line): cur = None; continue
|
||||||
|
if cur and not re.match(r"^(\d{6}/|Ally Bank Member|Page \d|Statement Date|COMBINED|\d{6}-\d\d)", line):
|
||||||
|
cur.detail = (cur.detail + " | " + line.strip()).strip(" |")
|
||||||
|
if re.match(r"^\d{6}/\d+//", line): cur = None
|
||||||
|
st.txns = [t for t in st.txns if t.account == "Ally Checking"] # savings has no spending
|
||||||
|
return st
|
||||||
|
|
||||||
|
CV_TX = re.compile(r"^(\d\d/\d\d/\d{4}) (.+?) \$([\d,]+\.\d\d) \$([\d,]+\.\d\d)$")
|
||||||
|
def parse_cvfcu(path, pages):
|
||||||
|
full = "\n".join(pages)
|
||||||
|
end = pdate(re.search(r"Statement Ending (\d\d/\d\d/\d{4})", pages[0]).group(1))
|
||||||
|
st = Statement(Path(path).name, "CVFCU Checking", dt.date(end.year, end.month, 1), end)
|
||||||
|
block, prev, cur = None, None, None
|
||||||
|
pr = fin = None
|
||||||
|
for line in full.splitlines():
|
||||||
|
h = re.match(r"^([A-Z0-9][A-Z0-9 &/\.\-]+?) - XXXXXX\d{4}$", line)
|
||||||
|
if h:
|
||||||
|
if h.group(1) != block: prev = None
|
||||||
|
block, cur = h.group(1), None; continue
|
||||||
|
if re.match(r"^30 YR MORTGAGE|^Statement Year to Date", line): block = None
|
||||||
|
if block is None:
|
||||||
|
mp = re.match(r"^Principal Amount -?\$([\d,.]+)", line); mf = re.match(r"^Finance Charge \$([\d,.]+)", line)
|
||||||
|
if mp: pr = money(mp.group(1))
|
||||||
|
if mf: fin = money(mf.group(1))
|
||||||
|
continue
|
||||||
|
if block != "PREFERRED CHECKING": continue
|
||||||
|
md = re.match(r"^([\d,]+) Debit\(s\) This Period -\$([\d,]+\.\d\d)", line)
|
||||||
|
if md: st.stated_out = money(md.group(2)); continue
|
||||||
|
mb = re.match(r"^\d\d/\d\d/\d{4} Beginning Balance \$([\d,]+\.\d\d)$", line)
|
||||||
|
if mb: prev = money(mb.group(1)); continue
|
||||||
|
mm = CV_TX.match(line)
|
||||||
|
if mm and prev is not None:
|
||||||
|
amt, bal = money(mm.group(3)), money(mm.group(4))
|
||||||
|
sign = 1 if abs(prev - amt - bal) < 0.005 else -1 if abs(prev + amt - bal) < 0.005 else 1
|
||||||
|
cur = Txn("CVFCU Checking", pdate(mm.group(1)), mm.group(2).strip(), "", round(sign * amt, 2), st.file)
|
||||||
|
st.txns.append(cur); prev = bal; continue
|
||||||
|
if re.match(r"^\d\d/\d\d/\d{4} Ending Balance", line): cur = None
|
||||||
|
for t in st.txns:
|
||||||
|
if "SCHEDULED TRANSFER TO LOAN" in t.desc.upper() and pr and fin:
|
||||||
|
t.extra = f"Principal ${pr:,.2f} / Interest ${fin:,.2f}"
|
||||||
|
return st
|
||||||
|
|
||||||
|
def parse_capone_csv(path):
|
||||||
|
rows = list(csv.DictReader(open(path, newline="", encoding="utf-8-sig")))
|
||||||
|
rows = [r for r in rows if r.get("Transaction Date") and pdate(r["Transaction Date"])]
|
||||||
|
cards = {r.get("Card No.", "") for r in rows}
|
||||||
|
mapped = {**CSV_ACCOUNTS, **_rules().get("csv_accounts", {})}
|
||||||
|
unknown = sorted(c for c in cards if c not in mapped)
|
||||||
|
acct = next((mapped[c] for c in cards if c in mapped), None) or ("Capital One Card" if len(cards) == 1 else f"Capital One Card ****{unknown[0]}")
|
||||||
|
ds = [pdate(r["Transaction Date"]) for r in rows]
|
||||||
|
st = Statement(Path(path).name, acct, min(ds), max(ds), kind="csv")
|
||||||
|
st.stated_out = round(sum(money(r["Debit"]) for r in rows if r.get("Debit")), 2)
|
||||||
|
for r in rows:
|
||||||
|
amt = money(r["Debit"]) if r.get("Debit") else -money(r["Credit"])
|
||||||
|
st.txns.append(Txn(acct, pdate(r["Transaction Date"]), r["Description"].strip(), "", round(amt, 2), st.file, category_hint=r.get("Category", "")))
|
||||||
|
return st
|
||||||
|
|
||||||
|
_CACHE = {}
|
||||||
|
def load_statements(folder=DATA):
|
||||||
|
"""Parse every statement file in folder. Returns (statements, skipped, duplicates)."""
|
||||||
|
sts, skipped, dups, seen = [], [], [], {}
|
||||||
|
for p in sorted(Path(folder).glob("*")):
|
||||||
|
if p.suffix.lower() not in (".pdf", ".csv"): continue
|
||||||
|
ck = (str(p), p.stat().st_mtime_ns, p.stat().st_size)
|
||||||
|
if ck in _CACHE:
|
||||||
|
st, err = _CACHE[ck]
|
||||||
|
if err: skipped.append((p.name, err))
|
||||||
|
elif st:
|
||||||
|
key = (st.account, st.end, st.kind)
|
||||||
|
if key in seen and st.kind == "pdf": dups.append((st.file, seen[key]))
|
||||||
|
else: seen[key] = st.file; sts.append(st)
|
||||||
|
continue
|
||||||
|
st, err = None, None
|
||||||
|
try:
|
||||||
|
if p.suffix.lower() == ".pdf":
|
||||||
|
pages = _pages(p); name = identify_pdf(pages)
|
||||||
|
if not name: err = "not recognized (new account? see ACCOUNTS in statement_loader.py)"
|
||||||
|
else: st = {"Chase Visa": parse_chase, "Ally Checking": parse_ally, "CVFCU Checking": parse_cvfcu}[name](p, pages)
|
||||||
|
elif p.suffix.lower() == ".csv":
|
||||||
|
head = open(p, encoding="utf-8-sig").readline()
|
||||||
|
if not all(c in head for c in CSV_COLUMNS): _CACHE[ck] = (None, None); continue
|
||||||
|
st = parse_capone_csv(p)
|
||||||
|
except Exception as e:
|
||||||
|
err = f"could not read: {e}"
|
||||||
|
_CACHE[ck] = (st, err)
|
||||||
|
if err: skipped.append((p.name, err)); continue
|
||||||
|
if st is None: continue
|
||||||
|
key = (st.account, st.end, st.kind)
|
||||||
|
if key in seen and st.kind == "pdf": dups.append((st.file, seen[key])); continue
|
||||||
|
seen[key] = st.file; sts.append(st)
|
||||||
|
return sts, skipped, dups
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- classification
|
||||||
|
def norm(desc):
|
||||||
|
toks = []
|
||||||
|
for t in re.sub(r"[*,;#\-]", " ", desc.upper()).split():
|
||||||
|
if re.search(r"\d|#", t): break
|
||||||
|
toks.append(t)
|
||||||
|
if len(toks) == 3: break
|
||||||
|
return " ".join(toks)
|
||||||
|
|
||||||
|
def _is_chase_payment(t): return t.account == "Chase Visa" and re.match(r"(AUTOMATIC )?PAYMENT", t.desc.upper()) and t.amount < 0
|
||||||
|
|
||||||
|
def exclusion(t):
|
||||||
|
"""Return (reason, direction) if this is NOT spending (card payment, transfer, income, interest), else None."""
|
||||||
|
d = (t.desc + " " + t.detail).upper(); out = "Money out" if t.amount > 0 else "Money in"
|
||||||
|
CARD = "Card payment (spending counted when purchased)"
|
||||||
|
if _is_chase_payment(t): return CARD, out
|
||||||
|
if t.account == "Capital One Card" and t.category_hint.startswith("Payment") and t.amount < 0: return CARD, out
|
||||||
|
if t.account == "Ally Checking":
|
||||||
|
if t.desc.upper().startswith("INTEREST PAID"): return "Interest earned", out
|
||||||
|
if "CASH APP*" in d and own(d): return "Cash App transfer to your own Cash App (confirm destination)", out
|
||||||
|
if "CASH APP" in d and t.amount < 0: return "Deposit (Cash App / Amazon) – not spending", out
|
||||||
|
if "AMAZON.COM SERVI" in d and t.amount < 0: return "Deposit (Cash App / Amazon) – not spending", out
|
||||||
|
if "SAVINGS" in d and "TRANSFER" in d: return "Transfer to savings account", out
|
||||||
|
if ("TRANSFER TO" in d and own(d)) or bank_hint(d): return "Transfer to CVFCU checking", out
|
||||||
|
if ("TRANSFER FROM" in d and own(d)) or ("ALLY BANK TRANSFER" in d and t.amount < 0): return "Transfer from CVFCU checking", out
|
||||||
|
if t.account == "CVFCU Checking":
|
||||||
|
if "PAYROLL" in d: return "Income (payroll deposit)", out
|
||||||
|
if "CHASE CREDIT CRD" in d or "DISCOVER" in d: return CARD, out
|
||||||
|
if "ALLY BANK" in d: return "Transfer to Ally account", out
|
||||||
|
if "P2P" in d and own(d): return "Transfer from Ally account", out
|
||||||
|
if d.startswith("DIVIDEND EARNED"): return "Interest earned", out
|
||||||
|
return None
|
||||||
|
|
||||||
|
SEED_RULES = [ # (text to find in desc+detail, payee, what for) for sources whose log description is generic
|
||||||
|
("T-MOBILE", "T-Mobile", "Cell phone service"),
|
||||||
|
("SCHEDULED TRANSFER TO LOAN", "CVFCU Mortgage", "Monthly mortgage payment (principal + interest)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Row:
|
||||||
|
txn: Txn
|
||||||
|
kind: str # "log" or "excluded"
|
||||||
|
payee: str = ""
|
||||||
|
what: str = ""
|
||||||
|
reason: str = ""
|
||||||
|
direction: str = ""
|
||||||
|
note: str = ""
|
||||||
|
is_new_payee: bool = False
|
||||||
|
needs_review: bool = False
|
||||||
|
|
||||||
|
def learn_rules(wb):
|
||||||
|
"""payee/what-for per normalized description, learned from rows already in the Bill Log."""
|
||||||
|
c = collections.defaultdict(collections.Counter)
|
||||||
|
for r in wb["Bill Log"].iter_rows(min_row=6, values_only=True):
|
||||||
|
if r[0] and r[7] and r[1]:
|
||||||
|
c[norm(r[7])][(r[1], r[4])] += 1
|
||||||
|
return {k: v.most_common(1)[0][0] for k, v in c.items() if k}
|
||||||
|
|
||||||
|
def classify(t, rules, known_payees):
|
||||||
|
ex = exclusion(t)
|
||||||
|
if ex: return Row(t, "excluded", reason=ex[0], direction=ex[1])
|
||||||
|
d = (t.desc + " " + t.detail).upper()
|
||||||
|
payee = what = None
|
||||||
|
for needle, p, w in SEED_RULES:
|
||||||
|
if needle in d: payee, what = p, w; break
|
||||||
|
if not payee and t.account == "Ally Checking" and "ATM" in d:
|
||||||
|
payee, what = "ATM Cash", "Cash withdrawal (net of ATM-fee reimbursement)"
|
||||||
|
if not payee:
|
||||||
|
k = norm(t.desc)
|
||||||
|
hit = rules.get(k)
|
||||||
|
if not hit and k: # fall back to the first two words
|
||||||
|
two = " ".join(k.split()[:2])
|
||||||
|
cands = {v for kk, v in rules.items() if " ".join(kk.split()[:2]) == two}
|
||||||
|
if len(cands) == 1: hit = next(iter(cands))
|
||||||
|
if hit: payee, what = hit
|
||||||
|
row = Row(t, "log")
|
||||||
|
if payee: row.payee, row.what = payee, what
|
||||||
|
else:
|
||||||
|
row.payee = " ".join(w.capitalize() for w in (norm(t.desc) or t.desc.upper()).split())[:40] or "Unknown"
|
||||||
|
row.what = "NEW – please categorize"; row.is_new_payee = True; row.needs_review = True
|
||||||
|
row.note = "NEW – review payee/category"
|
||||||
|
if payee and payee not in known_payees: row.is_new_payee = True
|
||||||
|
if t.amount < 0: row.note = (row.note + "; " if row.note else "") + "Refund / credit"
|
||||||
|
if t.extra: row.note = (row.note + "; " if row.note else "") + t.extra
|
||||||
|
return row
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- workbook access
|
||||||
|
def existing_keys(wb):
|
||||||
|
"""Multiset of (account, date, |amount|) already in Bill Log + Excluded Transfers."""
|
||||||
|
keys = collections.Counter()
|
||||||
|
for r in wb["Bill Log"].iter_rows(min_row=6, values_only=True):
|
||||||
|
if r[0]: keys[(r[6], r[0].date(), round(abs(r[5]), 2))] += 1
|
||||||
|
for r in wb["Excluded Transfers"].iter_rows(min_row=5, values_only=True):
|
||||||
|
if r[0] and isinstance(r[0], dt.datetime): keys[(r[1], r[0].date(), round(abs(r[4]), 2))] += 1
|
||||||
|
return keys
|
||||||
|
|
||||||
|
def plan(wb, statements):
|
||||||
|
"""Work out which transactions are new. ATM withdrawal + same-day fee line are merged like the existing log does."""
|
||||||
|
rules = learn_rules(wb)
|
||||||
|
lists = wb["Lists"]
|
||||||
|
known = {r[0] for r in lists.iter_rows(min_row=4, max_row=153, max_col=1, values_only=True) if r[0]}
|
||||||
|
have = existing_keys(wb)
|
||||||
|
rows = []
|
||||||
|
for st in statements:
|
||||||
|
for t in st.txns:
|
||||||
|
rows.append(classify(t, rules, known))
|
||||||
|
new, already = [], 0
|
||||||
|
for r in rows:
|
||||||
|
t = r.txn; k = (t.account, t.date, round(abs(t.amount), 2))
|
||||||
|
if have[k] > 0: have[k] -= 1; already += 1; continue
|
||||||
|
new.append(r)
|
||||||
|
return new, already
|
||||||
|
|
||||||
|
def check_statements(statements):
|
||||||
|
"""Compare what we parsed to the totals each statement prints. Returns [(file, parsed, stated, ok)]."""
|
||||||
|
res = []
|
||||||
|
for st in statements:
|
||||||
|
if st.stated_out is None: continue
|
||||||
|
parsed = round(sum(t.amount for t in st.txns if t.amount > 0), 2)
|
||||||
|
res.append((st.file, parsed, st.stated_out, abs(parsed - st.stated_out) < 0.005))
|
||||||
|
return res
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- writing
|
||||||
|
def _chart_sig(xml):
|
||||||
|
refs = re.findall(rb"<(?:c:)?f>(.*?)</(?:c:)?f>", xml)
|
||||||
|
kinds = tuple(re.findall(rb"<(?:c:)?(bar|line|pie|doughnut|area)Chart>", xml))
|
||||||
|
return kinds + tuple(sorted(re.sub(rb"'|'|\$| ", b"", r) for r in refs))
|
||||||
|
|
||||||
|
def _restore_charts(original, saved):
|
||||||
|
"""openpyxl rewrites charts and drops some styling; put the original chart XML back (matched by plotted ranges)."""
|
||||||
|
with zipfile.ZipFile(original) as zo, zipfile.ZipFile(saved) as zs:
|
||||||
|
orig = {_chart_sig(zo.read(n)): zo.read(n) for n in zo.namelist() if re.match(r"xl/charts/chart\d+\.xml$", n)}
|
||||||
|
out = {}
|
||||||
|
for n in zs.namelist():
|
||||||
|
data = zs.read(n)
|
||||||
|
if re.match(r"xl/charts/chart\d+\.xml$", n):
|
||||||
|
data = orig.get(_chart_sig(data), data)
|
||||||
|
out[n] = data
|
||||||
|
infos = {i.filename: i for i in zs.infolist()}
|
||||||
|
tmp = Path(str(saved) + ".tmp")
|
||||||
|
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for n, data in out.items(): z.writestr(n, data)
|
||||||
|
tmp.replace(saved)
|
||||||
|
|
||||||
|
def _copy_style(src, dst):
|
||||||
|
from copy import copy
|
||||||
|
dst.font, dst.fill, dst.border, dst.alignment, dst.number_format = copy(src.font), copy(src.fill), copy(src.border), copy(src.alignment), src.number_format
|
||||||
|
|
||||||
|
def apply(new_rows, workbook=WORKBOOK):
|
||||||
|
"""Back up, then append new rows. Returns a summary dict."""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
BACKUPS.mkdir(exist_ok=True)
|
||||||
|
backup = BACKUPS / f"Bills_Tracker_{dt.datetime.now():%Y%m%d_%H%M%S}.xlsx"
|
||||||
|
shutil.copy2(workbook, backup)
|
||||||
|
wb = load_workbook(workbook)
|
||||||
|
bl, ex, ls, rc, rv = wb["Bill Log"], wb["Excluded Transfers"], wb["Lists"], wb["Reconciliation"], wb["Review Items"]
|
||||||
|
log_rows = sorted([r for r in new_rows if r.kind == "log"], key=lambda r: (r.txn.date, r.txn.account))
|
||||||
|
ex_rows = sorted([r for r in new_rows if r.kind == "excluded"], key=lambda r: (r.txn.date, r.txn.account))
|
||||||
|
# Bill Log: fill the next empty rows (formulas in C/D are already there)
|
||||||
|
r = 6
|
||||||
|
while bl.cell(r, 1).value is not None: r += 1
|
||||||
|
if r + len(log_rows) - 1 > 2505: raise RuntimeError("Bill Log is full (2,500 rows). Archive a year first.")
|
||||||
|
for row in log_rows:
|
||||||
|
t = row.txn
|
||||||
|
for c, v in ((1, dt.datetime.combine(t.date, dt.time())), (2, row.payee), (5, row.what), (6, t.amount), (7, t.account), (8, t.desc), (9, row.note or None)):
|
||||||
|
bl.cell(r, c).value = v
|
||||||
|
r += 1
|
||||||
|
# Excluded Transfers
|
||||||
|
er = 5
|
||||||
|
while ex.cell(er, 1).value is not None: er += 1
|
||||||
|
for row in ex_rows:
|
||||||
|
t = row.txn
|
||||||
|
for c, v in enumerate((dt.datetime.combine(t.date, dt.time()), t.account, t.desc, t.detail or None, abs(t.amount), row.direction, row.reason), 1):
|
||||||
|
ex.cell(er, c).value = v
|
||||||
|
_copy_style(ex.cell(5, c), ex.cell(er, c))
|
||||||
|
er += 1
|
||||||
|
# reconciliation formulas read 'Excluded Transfers'!E5:E111; widen them
|
||||||
|
for rr in rc.iter_rows():
|
||||||
|
for c in rr:
|
||||||
|
if isinstance(c.value, str) and "$111" in c.value: c.value = c.value.replace("$111", "$2000")
|
||||||
|
ex.auto_filter.ref = f"A4:G{max(er - 1, 111)}"
|
||||||
|
# Lists: new payees
|
||||||
|
lr = 4
|
||||||
|
while ls.cell(lr, 1).value is not None and lr <= 153: lr += 1
|
||||||
|
added_payees = []
|
||||||
|
for row in log_rows:
|
||||||
|
if row.is_new_payee and row.payee not in added_payees and row.payee not in {ls.cell(i, 1).value for i in range(4, lr)}:
|
||||||
|
if lr > 153: break
|
||||||
|
ls.cell(lr, 1).value, ls.cell(lr, 2).value, ls.cell(lr, 3).value = row.payee, "Other / Unsorted", "Spending"
|
||||||
|
for c in (1, 2, 3): _copy_style(ls.cell(lr - 1, c), ls.cell(lr, c))
|
||||||
|
added_payees.append(row.payee); lr += 1
|
||||||
|
# Reconciliation: statement money-out added per account (statements are verified against their printed totals before import)
|
||||||
|
by_acct = collections.Counter()
|
||||||
|
for row in new_rows:
|
||||||
|
if row.txn.amount > 0: by_acct[row.txn.account] += row.txn.amount
|
||||||
|
for rr in range(5, 9):
|
||||||
|
a = rc.cell(rr, 1).value
|
||||||
|
if by_acct.get(a): rc.cell(rr, 2).value = round(rc.cell(rr, 2).value + by_acct[a], 2)
|
||||||
|
# coverage lines
|
||||||
|
lo, hi = {}, {}
|
||||||
|
for sheet, a_col, d_col, first in ((bl, 7, 1, 6), (ex, 2, 1, 5)):
|
||||||
|
for rr in sheet.iter_rows(min_row=first, values_only=True):
|
||||||
|
d, a = rr[d_col - 1], rr[a_col - 1]
|
||||||
|
if isinstance(d, dt.datetime) and a:
|
||||||
|
lo[a] = min(lo.get(a, d), d); hi[a] = max(hi.get(a, d), d)
|
||||||
|
for rr in range(1, rc.max_row + 1):
|
||||||
|
a = rc.cell(rr, 1).value
|
||||||
|
if a in lo and rc.cell(rr, 2).value and "–" in str(rc.cell(rr, 2).value):
|
||||||
|
rc.cell(rr, 2).value = f"{lo[a]:%-m/%-d/%Y} – {hi[a]:%-m/%-d/%Y}"
|
||||||
|
# Review Items
|
||||||
|
if added_payees or any(r.needs_review for r in log_rows):
|
||||||
|
n = max([x[0] for x in rv.iter_rows(min_row=5, max_col=1, values_only=True) if isinstance(x[0], int)] + [0]) + 1
|
||||||
|
rr = rv.max_row + 1
|
||||||
|
for c, v in enumerate((n, f"{len(added_payees)} new payee(s) from imported statements: " + ", ".join(added_payees[:12]) + ("…" if len(added_payees) > 12 else ""),
|
||||||
|
"Added to the Lists tab as 'Other / Unsorted' (Spending) so they appear in every summary.",
|
||||||
|
"Set the right Category/Type on the Lists tab (and rename the payee if you like)."), 1):
|
||||||
|
rv.cell(rr, c).value = v; _copy_style(rv.cell(rr - 1, c), rv.cell(rr, c))
|
||||||
|
wb.calculation.fullCalcOnLoad = True
|
||||||
|
tmp = Path(str(workbook) + ".new")
|
||||||
|
wb.save(tmp)
|
||||||
|
_restore_charts(workbook, tmp)
|
||||||
|
tmp.replace(workbook)
|
||||||
|
return dict(backup=str(backup), log_rows=len(log_rows), excluded_rows=len(ex_rows), new_payees=added_payees)
|
||||||
|
|
||||||
|
def summarize(rows):
|
||||||
|
return dict(log=sum(r.kind == "log" for r in rows), excluded=sum(r.kind == "excluded" for r in rows),
|
||||||
|
new_payees=sorted({r.payee for r in rows if r.is_new_payee}))
|
||||||
|
|
||||||
|
def run_preview(folder=DATA):
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
sts, skipped, dups = load_statements(folder)
|
||||||
|
checks = check_statements(sts)
|
||||||
|
bad = {f for f, p, s, ok in checks if not ok}
|
||||||
|
good = [s for s in sts if s.file not in bad]
|
||||||
|
wb = load_workbook(WORKBOOK)
|
||||||
|
new, already = plan(wb, good)
|
||||||
|
return dict(statements=sts, skipped=skipped, dups=dups, checks=checks, bad=bad, new=new, already=already)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cmd = sys.argv[1] if len(sys.argv) > 1 else "preview"
|
||||||
|
res = run_preview()
|
||||||
|
for f, p, st, ok in res["checks"]:
|
||||||
|
if not ok: print(f"!! {f}: parsed {p:,.2f} but statement says {st:,.2f} - NOT imported")
|
||||||
|
for f, why in res["skipped"]: print(f"skipped {f}: {why}")
|
||||||
|
new = res["new"]; sm = summarize(new)
|
||||||
|
print(f"{len(res['statements'])} statements read, {res['already']} transactions already in workbook, {len(new)} new "
|
||||||
|
f"({sm['log']} to Bill Log, {sm['excluded']} to Excluded Transfers).")
|
||||||
|
for r in new[:200]:
|
||||||
|
print(f" {r.txn.date} {r.txn.account:16} {r.txn.amount:>10,.2f} {r.kind:8} {r.payee or r.reason} <- {r.txn.desc[:40]}")
|
||||||
|
if sm["new_payees"]: print("New payees to categorize:", ", ".join(sm["new_payees"]))
|
||||||
|
if cmd == "import" and new:
|
||||||
|
print(apply(new))
|
||||||
|
elif cmd == "import": print("Nothing new to import.")
|
||||||
Reference in new issue
Block a user