Statement loader web app with Docker image and Gitea Actions build
build-container / build (push) Successful in 1m22s
build-container / build (push) Successful in 1m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
4d076b2b73
13 files changed
+1125
No files matched your search
@@ -0,0 +1,112 @@
|
||||
#!/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)
|
||||
Reference in new issue
Block a user