Files
money/statement_inventory.py
T
nmemmertandClaude Sonnet 5.5 4d076b2b73
build-container / build (push) Successful in 1m22s
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:41:35 -04:00

113 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)