#!/usr/bin/env python3 """Statement inventory: shows which accounts/months you have statements for, what is missing, and what to download next. Read-only: never edits Bills_Tracker.xlsx. Run: .venv/bin/python statement_inventory.py [folder] (default: this folder) Add a new account: add a line to ACCOUNTS below (match text found on its statement). """ import sys, re, csv, glob, hashlib, datetime as dt, calendar from pathlib import Path import pdfplumber # name, text that identifies it, statement day-of-month for "next due" (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 = {"1494": "Capital One Card"} # card number (last 4) -> account name def eom(y, m): return calendar.monthrange(y, m)[1] def d(s): for f in ("%m/%d/%Y", "%m/%d/%y", "%Y-%m-%d"): try: return dt.datetime.strptime(s, f).date() except ValueError: pass def read_pdf(p): with pdfplumber.open(p) as pdf: first = pdf.pages[0].extract_text() or "" full = "\n".join((pg.extract_text() or "") for pg in pdf.pages) for name, keys, _ in ACCOUNTS: if any(k.lower() in first.lower() for k in keys): # first page only: other pages mention other banks m = (re.search(r"Opening/Closing Date\s+\S+ - (\S+)", full) or re.search(r"Statement Ending (\S+)", first) or re.search(r"Statement Date.{0,120}?(\d\d/\d\d/\d{4})", first, re.S)) end = d(m.group(1)) if m else None return name, end return None, None def read_csv(p): try: rows = list(csv.DictReader(open(p, newline="", encoding="utf-8-sig"))) dates = [d(r["Transaction Date"]) for r in rows if r.get("Transaction Date")] card = {r.get("Card No.", "") for r in rows} if not dates: return None name = next((CSV_ACCOUNTS[c] for c in card if c in CSV_ACCOUNTS), None) return name or f"Unrecognized CSV (card {','.join(sorted(card))})", min(dates), max(dates), len(rows) except Exception: return None def main(folder): folder = Path(folder) seen, found, unknown = {}, {}, [] for p in sorted(folder.glob("*.pdf")): h = hashlib.md5(p.read_bytes()).hexdigest() name, end = read_pdf(p) if not name or not end: unknown.append(p.name); continue key = (name, end.year, end.month) if key in found: found[key].append(p.name) else: found[key] = [p.name] csvs = [] for p in sorted(folder.glob("*.csv")): r = read_csv(p) if r: csvs.append((p.name,) + r) accts = [a[0] for a in ACCOUNTS] + sorted({c[1] for c in csvs}) if not found and not csvs: print("No statements found."); return months = sorted({(y, m) for (_, y, m) in found}) first = months[0] if months else (csvs[0][2].year, csvs[0][2].month) today = dt.date.today(); last = (today.year, today.month) seq, (y, m) = [], first while (y, m) <= last: seq.append((y, m)); m += 1 if m > 12: y, m = y + 1, 1 out = [] out.append(f"STATEMENT COVERAGE as of {today:%b %d, %Y} (column = month the statement closes; X = have it, . = MISSING, - = not yet due)\n") out.append(f"{'':18}" + " ".join(f"{calendar.month_abbr[m]}{str(y)[2:]:>3}" for y, m in seq)) notes = [] for a in accts: cells = [] for y, m in seq: if a in {c[1] for c in csvs}: inside = any(c[1] == a and (c[2].year, c[2].month) <= (y, m) <= (c[3].year, c[3].month) for c in csvs) cells.append("X" if inside else ("." if (y, m) >= (min(c[2] for c in csvs if c[1]==a).year, min(c[2] for c in csvs if c[1]==a).month) else " ")) continue cfg = next(x for x in ACCOUNTS if x[0] == a) due = dt.date(y, m, cfg[2] or eom(y, m)) have = (a, y, m) in found mine = [k for k in found if k[0] == a] started = (y, m) >= min((k[1], k[2]) for k in mine) if mine else False cells.append("X" if have else (" " if not started else ("-" if due > today else "."))) out.append(f"{a:18}" + " ".join(f"{c:>6}" for c in cells)) out.append("") for a in accts: mine = sorted(k for k in found if k[0] == a) if mine: cfg = next(x for x in ACCOUNTS if x[0] == a) dup = [(k, v) for k, v in found.items() if k[0] == a and len(v) > 1] ly, lm = mine[-1][1], mine[-1][2] ny, nm = (ly, lm + 1) if lm < 12 else (ly + 1, 1) nxt = dt.date(ny, nm, cfg[2] or eom(ny, nm)) miss = [f"{calendar.month_abbr[m]} {y}" for y, m in seq if (y, m) >= (mine[0][1], mine[0][2]) and (a, y, m) not in found and dt.date(y, m, cfg[2] or eom(y, m)) <= today] out.append(f"{a}: {len(mine)} statements, {calendar.month_abbr[mine[0][2]]} {mine[0][1]} – {calendar.month_abbr[lm]} {ly}." f" Next statement closes ~{nxt:%b %d, %Y}" + (" (not out yet)" if nxt > today else " (DOWNLOAD NOW)") + (f" MISSING in range: {', '.join(miss)}" if miss else " No gaps.")) for k, v in dup: out.append(f" duplicate files for {calendar.month_abbr[k[2]]} {k[1]}: {', '.join(v)} (counted once)") for c in csvs: out.append(f"{c[1]}: CSV transaction download '{c[0]}', {c[2]:%b %d, %Y} – {c[3]:%b %d, %Y}, {c[4]} rows. Next download should start {c[3]+dt.timedelta(days=1):%b %d, %Y}.") if unknown: out.append("\nUnrecognized files (new account? add it to ACCOUNTS): " + ", ".join(unknown)) out.append("\nTo add a missed or new statement: drop the PDF/CSV in this folder, re-run this script, then ask Claude to load it into the workbook.") text = "\n".join(out); print(text) (folder / "Statement_Coverage.txt").write_text(text + "\n") main(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)