build-container / build (push) Successful in 1m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
113 lines
5.9 KiB
Python
113 lines
5.9 KiB
Python
#!/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)
|