Auto-categorize with built-in merchant rules, payee category editor, income/net on dashboard
build-container / build (push) Successful in 35s
build-container / build (push) Successful in 35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6073279a1b
commit
837f89e563
7 files changed
+191
-17
No files matched your search
+1
-1
@@ -6,7 +6,7 @@ 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 ./
|
||||
COPY statement_loader.py seed_rules.py app.py app_page.html app_dashboard.html ./
|
||||
COPY template/ ./template/
|
||||
|
||||
# statements, Bills_Tracker.xlsx and backups live in this volume, never in the image
|
||||
|
||||
@@ -21,6 +21,10 @@ AppID=money PUID=1000 PGID=1000 TZ=America/New_York docker compose up -d # the
|
||||
"Create a blank workbook" button that copies it into the data folder. Then drop statements in; new payees land on the Lists tab as
|
||||
"Other / Unsorted" for you to categorize. `loader_rules.example.json` shows the private rules file format.
|
||||
|
||||
## Categories
|
||||
New payees are categorized by built-in generic merchant rules (`seed_rules.py`); anything unknown lands in "Other / Unsorted".
|
||||
Use the **Categorize payees** card in the app (or the Lists tab) to set the rest. Your choices are saved in the workbook.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -74,7 +74,7 @@ _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
|
||||
if not L.WORKBOOK.exists(): return dict(tx=[], categories=[], excluded=0, updated="", setup=True, data_dir=str(DATA))
|
||||
if not L.WORKBOOK.exists(): return dict(tx=[], income=[], categories=[], excluded=0, updated="", setup=True, data_dir=str(DATA))
|
||||
key = L.WORKBOOK.stat().st_mtime_ns
|
||||
if _DATA.get("key") == key: return _DATA["val"]
|
||||
wb = load_workbook(L.WORKBOOK, read_only=True)
|
||||
@@ -87,9 +87,16 @@ def workbook_data():
|
||||
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))
|
||||
ex, income = 0, []
|
||||
for r in wb["Excluded Transfers"].iter_rows(min_row=5, max_col=7, values_only=True):
|
||||
if not isinstance(r[0], dt.datetime): continue
|
||||
ex += 1
|
||||
if r[5] == "Money in" and r[6] in ("Income (payroll deposit)", "Interest earned"):
|
||||
m = re.search(r"EFT (.+?)\s*[;,]", r[2] or "")
|
||||
src = (m.group(1).strip().title() if m else (r[2] or "Other").title()) if r[6].startswith("Income") else "Interest"
|
||||
income.append([f"{r[0]:%Y-%m-%d}", src, "Interest" if r[6].startswith("Interest") else "Paycheck", round(float(r[4]), 2), r[1] or ""])
|
||||
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}")
|
||||
_DATA["key"], _DATA["val"] = key, dict(tx=tx, income=income, 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):
|
||||
@@ -124,6 +131,10 @@ class H(BaseHTTPRequestHandler):
|
||||
with LOCK:
|
||||
try: return self._send(200, workbook_data())
|
||||
except Exception as e: return self._send(500, {"error": str(e)})
|
||||
if u.path == "/api/payees":
|
||||
with LOCK:
|
||||
try: return self._send(200, L.uncategorized())
|
||||
except Exception as e: return self._send(500, {"error": str(e)})
|
||||
if u.path == "/api/state":
|
||||
with LOCK:
|
||||
try: return self._send(200, state())
|
||||
@@ -140,7 +151,13 @@ class H(BaseHTTPRequestHandler):
|
||||
if p.name == "loader_rules.json": json.loads(data) # must be valid JSON
|
||||
DATA.mkdir(parents=True, exist_ok=True); p.write_bytes(data)
|
||||
return self._send(200, {"saved": p.name})
|
||||
self.rfile.read(n)
|
||||
body = self.rfile.read(n)
|
||||
if u.path == "/api/set_categories":
|
||||
with LOCK: out = L.set_categories(json.loads(body or b"[]"))
|
||||
return self._send(200, {"message": f"Updated {out['updated']} payee(s). Backup: {out['backup']}."})
|
||||
if u.path == "/api/autocategorize":
|
||||
with LOCK: out = L.autocategorize()
|
||||
return self._send(200, {"message": f"Auto-categorized {out['updated']} payee(s); {out['remaining']} still need you. Backup: {out['backup']}."})
|
||||
if u.path == "/api/import":
|
||||
with LOCK:
|
||||
res = L.run_preview(DATA)
|
||||
|
||||
+13
-2
@@ -33,6 +33,8 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height:
|
||||
<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="t0">Income vs spending</h2><div id="c0"></div><div class="legend" id="l0"></div></div>
|
||||
<div class="card"><h2 id="tsrc">Income sources</h2><div class="scroll" id="inc"></div></div>
|
||||
<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>
|
||||
@@ -50,7 +52,7 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height:
|
||||
<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>
|
||||
<p class="mute">Read-only view of Bills_Tracker.xlsx. Income = paychecks and interest. Card payments and transfers between your accounts are in neither income nor spending (see Excluded Transfers in the workbook). Refunds are negative amounts.</p>
|
||||
</main>
|
||||
<div id="tip"></div>
|
||||
<script>
|
||||
@@ -111,13 +113,22 @@ function render(){
|
||||
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;
|
||||
const inc=(D.income||[]).map(i=>({d:i[0],y:+i[0].slice(0,4),m:+i[0].slice(5,7),src:i[1],kind:i[2],amt:i[3]})).filter(i=>i.y==Y);
|
||||
const incM=Array(12).fill(0);inc.forEach(i=>incM[i.m-1]+=i.amt);const incT=sum(incM,x=>x),net=incT-total,rate=incT>0?net/incT:null;
|
||||
$("#t0").textContent=`Income vs spending · ${Y}`;$("#tsrc").textContent=`Income sources · ${Y}`;
|
||||
$("#kpis").innerHTML=[
|
||||
["Total paid",money(total),`${cur.length} payments`],
|
||||
["Income",money(incT),`${inc.length} deposits`],
|
||||
["Total paid (spending)",money(total),`${cur.length} payments`],
|
||||
["Net (income − spending)",`<span class="${net<0?"up":"down"}">${net<0?"-":"+"}${money(Math.abs(net))}</span>`,rate==null?"":`${(rate*100).toFixed(0)}% of income ${net<0?"overspent":"saved"}`],
|
||||
["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("");
|
||||
const iser=[{name:"Income",color:PAL[4],values:incM},{name:"Spending",color:PAL[2],values:byM.map(v=>Math.max(0,v))}];
|
||||
chart("#c0",MON,iser,"group");legend("#l0",iser);
|
||||
const sm={};inc.forEach(i=>{(sm[i.src]=sm[i.src]||{amt:0,n:0,kind:i.kind});sm[i.src].amt+=i.amt;sm[i.src].n++});
|
||||
$("#inc").innerHTML=inc.length?"<table><tr><th>Source</th><th>Type</th><th class='n'>Deposits</th><th class='n'>Total</th></tr>"+Object.entries(sm).sort((a,b)=>b[1].amt-a[1].amt).map(([k,v])=>`<tr><td>${esc(k)}</td><td class="mute">${v.kind}</td><td class="n">${v.n}</td><td class="n">${money(v.amt,2)}</td></tr>`).join("")+`<tr><td><b>Total</b></td><td></td><td class="n">${inc.length}</td><td class="n"><b>${money(incT,2)}</b></td></tr></table>`:'<div class="mute">No income recorded for this year. Paychecks and interest are read from the Excluded Transfers tab.</div>';
|
||||
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];
|
||||
|
||||
+21
-2
@@ -53,6 +53,12 @@ th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font
|
||||
<div id="summary" class="mute" style="margin:10px 0"></div>
|
||||
<div class="grid"><table id="preview"></table></div>
|
||||
</div>
|
||||
<div class="card" id="catcard">
|
||||
<div class="row"><h2 style="margin:0">4 · Categorize payees</h2><div class="spacer"></div>
|
||||
<button id="auto">Auto-categorize</button><button class="primary" id="savecats" disabled>Save changes</button></div>
|
||||
<div id="catsum" class="mute" style="margin:10px 0"></div>
|
||||
<div class="grid" style="max-height:420px;overflow:auto"><table id="cattbl"></table></div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const TOKEN="__TOKEN__";
|
||||
@@ -63,11 +69,12 @@ const mon=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","De
|
||||
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){
|
||||
if($("#catcard"))$("#catcard").style.display="";
|
||||
$("#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}
|
||||
if(s.setup){$("#coverage").innerHTML="";$("#preview").innerHTML="";$("#import").disabled=true;
|
||||
if(s.setup){$("#catcard").style.display="none";$("#coverage").innerHTML="";$("#preview").innerHTML="";$("#import").disabled=true;
|
||||
$("#summary").innerHTML="";alertBox("warn",`<b>First-time setup:</b> <code>Bills_Tracker.xlsx</code> isn’t in the data folder (<code>${esc(s.data_dir)}</code>). Drop your existing one into the box above, or <button id="blank" style="margin-left:6px">Create a blank workbook</button>`+(s.rules?"":" Also add <code>loader_rules.json</code> (see loader_rules.example.json: your name, so transfers between your own accounts aren’t counted as spending)."));
|
||||
const b=$("#blank");if(b)b.onclick=async()=>{try{const j=await post("/api/create_blank","");await refresh(esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};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.`));
|
||||
@@ -101,7 +108,19 @@ $("#import").onclick=async()=>{
|
||||
};
|
||||
$("#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();
|
||||
|
||||
let CATS=null,EDITS={};
|
||||
async function loadPayees(){
|
||||
const r=await fetch("/api/payees");const j=await r.json();if(j.error){$("#catsum").textContent=j.error;return}
|
||||
CATS=j;EDITS={};$("#savecats").disabled=true;
|
||||
$("#catsum").textContent=j.payees.length?`${j.payees.length} payee(s) are still “Other / Unsorted”. Pick a category (and Bill or Spending) for each, or try Auto-categorize first.`:"Every payee has a category.";
|
||||
const opt=(list,sel)=>list.map(c=>`<option${c==sel?" selected":""}>${esc(c)}</option>`).join("");
|
||||
$("#cattbl").innerHTML=j.payees.length?"<tr><th>Payee</th><th class='num'>Payments</th><th class='num'>Total</th><th>Category</th><th>Type</th></tr>"+j.payees.map((p,i)=>`<tr><td>${esc(p.payee)}</td><td class="num">${p.n}</td><td class="num">${fmt(p.total)}</td><td><select data-i="${i}" data-k="category">${opt(j.categories,p.category)}</select></td><td><select data-i="${i}" data-k="type">${opt(j.types,p.type)}</select></td></tr>`).join(""):"";
|
||||
document.querySelectorAll("#cattbl select").forEach(sel=>sel.onchange=()=>{const p=j.payees[+sel.dataset.i];(EDITS[p.payee]=EDITS[p.payee]||{payee:p.payee,category:p.category,type:p.type})[sel.dataset.k]=sel.value;$("#savecats").disabled=false});
|
||||
}
|
||||
$("#auto").onclick=async()=>{try{const j=await post("/api/autocategorize","");await loadPayees();alertBox("good",esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};
|
||||
$("#savecats").onclick=async()=>{try{const j=await post("/api/set_categories",JSON.stringify(Object.values(EDITS)));await loadPayees();alertBox("good",esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};
|
||||
refresh();loadPayees();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Generic merchant rules: statement text -> (payee, category, type, what it was for).
|
||||
Keywords are matched as whole words (case-insensitive) in the statement description, in order: first match wins,
|
||||
so put specific entries before generic ones. payee=None keeps the cleaned-up statement name.
|
||||
Categories must exist on the workbook's Lists tab (otherwise the payee falls back to "Other / Unsorted").
|
||||
No personal data belongs in this file; private rules go in loader_rules.json.
|
||||
"""
|
||||
import re
|
||||
|
||||
B, S = "Bill", "Spending"
|
||||
# (keywords, payee, category, type, what)
|
||||
SEEDS = [
|
||||
# --- housing / insurance / taxes
|
||||
(["SCHEDULED TRANSFER TO LOAN", "MORTGAGE"], "Mortgage", "Housing", B, "Monthly mortgage payment"),
|
||||
(["RENT", "HOA", "PROPERTY MGMT"], None, "Housing", B, "Housing payment"),
|
||||
(["GEICO", "PROGRESSIVE", "STATE FARM", "ALLSTATE", "LIBERTY MUTUAL", "FARMERS INS", "NATIONWIDE", "HOMESITE", "TRUSTAGE", "INSUR", "INS PREM"], None, "Insurance", B, "Insurance premium"),
|
||||
(["DMV", "DEPARTMENT OF MOTOR", "DEPARTMENT MOTOR", "DEPT OF MOTOR", "TREASURER", "COUNTY TAX", "TAXFINESFEES", "IRS", "USPS", "POST OFFICE", "POSTAL"], None, "Taxes & Gov Fees", B, "Tax / government fee / postage"),
|
||||
# --- phone, internet, utilities
|
||||
(["T-MOBILE", "TMOBILE", "VERIZON", "AT&T", "ATT", "SPRINT", "XFINITY", "COMCAST", "SPECTRUM", "VONAGE", "SHENTEL", "COX", "FRONTIER", "MINT MOBILE", "GOOGLE FI", "STARLINK", "SIMPLEFIN"], None, "Phone & Internet", B, "Phone / internet service"),
|
||||
(["ELECTRIC", "POWER", "ENERGY", "DOMINION", "DUKE", "WATER", "SEWER", "PROPANE", "NATURAL GAS", "UTILIT", "TRASH", "WASTE"], None, "Utilities", B, "Utility bill"),
|
||||
# --- subscriptions & software
|
||||
(["NETFLIX", "SPOTIFY", "HULU", "DISNEY", "HBO", "PEACOCK", "PARAMOUNT", "PRIME VIDEO", "YOUTUBE", "PURE FLIX", "EMBY", "AUDIBLE", "KINDLE", "AMZN DIGITAL", "AMAZON DIGITAL", "AMAZON PRIME", "APPLE.COM/BILL", "ITUNES", "GOOGLE", "MICROSOFT", "MSFT", "ADOBE", "DROPBOX", "ICLOUD", "ANTHROPIC", "CLAUDE.AI", "OPENAI", "CLOUDFLARE", "GITHUB", "ELEVENLABS", "1PASSWORD", "DISCORD", "NINTENDO", "PLAYSTATION", "XBOX", "STEAM", "LOGOS"], None, "Subscriptions & Software", B, "Subscription / software"),
|
||||
# --- groceries & household
|
||||
(["ALDI", "FOOD LION", "KROGER", "PUBLIX", "SAFEWAY", "HARRIS TEETER", "WEGMANS", "GIANT", "TRADER JOE", "WHOLE FOODS", "SAMS CLUB", "SAMSCLUB", "COSTCO", "WM SUPERCENTER", "WAL MART", "WALMART", "WAL-MART", "DOLLAR TREE", "DOLLARTREE", "DOLLAR GENERAL", "FAMILY DOLLAR", "OLLIES", "GROCERY"], None, "Groceries & Household", S, "Groceries / household"),
|
||||
# --- dining
|
||||
(["MCDONALD", "WENDY", "BURGER KING", "TACO BELL", "CHICK FIL", "SUBWAY", "DOMINO", "PIZZA", "LITTLE CAESARS", "PANDA EXPRESS", "STARBUCKS", "DUNKIN", "HARDEE", "CHIPOTLE", "SONIC", "ARBY", "KFC", "WAFFLE HOUSE", "APPLEBEE", "LONGHORN", "OLIVE GARDEN", "PANERA", "CICIS", "CAFE", "RESTAURANT", "GRILL", "DINER", "DOORDASH", "UBER EATS", "GRUBHUB", "CANTEEN", "VENDING", "MEXICAN", "WASABI", "SUSHI", "BBQ"], None, "Dining Out", S, "Restaurant / food"),
|
||||
# --- fuel & auto
|
||||
(["SHEETZ", "WAWA", "EXXON", "SHELL", "BP", "CHEVRON", "MARATHON", "SPEEDWAY", "CIRCLE K", "MURPHY", "QUIKTRIP", "RACETRAC", "7-ELEVEN", "CITGO", "GAS STATION", "FUEL", "AUTOZONE", "OREILLY", "O'REILLY", "ADVANCE AUTO", "NAPA", "JIFFY LUBE", "TIRE", "PEP BOYS", "AUTO"], None, "Fuel & Auto", S, "Fuel / auto"),
|
||||
# --- health
|
||||
(["CVS", "WALGREENS", "RITE AID", "PHARMACY", "DENTAL", "DENTIST", "ORTHODONT", "MYEYEDR", "OPTICAL", "EYE", "HOSPITAL", "MEDICAL", "CLINIC", "URGENT CARE", "PHYSICIAN", "HEALTH", "PLANET FITNESS", "GYM"], None, "Health & Medical", S, "Health / medical"),
|
||||
# --- pets
|
||||
(["PETCO", "PETSMART", "VETERINAR", "ANIMAL HOSPITAL", "PET &", "PET SUPPLIES", "CHEWY"], None, "Pets", S, "Pet care / supplies"),
|
||||
# --- home & garden
|
||||
(["LOWES", "HOME DEPOT", "TRACTOR SUPPLY", "ACE HARDWARE", "MENARDS", "HARBOR FREIGHT", "HARDWARE", "NURSERY", "GARDEN"], None, "Home & Garden", S, "Home / garden / hardware"),
|
||||
# --- travel & outings
|
||||
(["EXPEDIA", "AIRBNB", "HOTEL", "MARRIOTT", "HILTON", "DELTA", "SOUTHWEST", "UNITED AIR", "AMTRAK", "UBER", "LYFT", "CINEMA", "REGAL", "AMC", "THEATER", "THEATRE", "MUSEUM", "RESORT", "SETTLEMENT", "PARK", "BOWLING"], None, "Travel & Outings", S, "Travel / outing"),
|
||||
# --- education, giving
|
||||
(["SCHOOL", "TUITION", "ACADEMY", "UDEMY", "COURSERA", "UNIVERSITY", "COLLEGE", "ABEKA"], None, "Education", S, "Education"),
|
||||
(["DONATION", "CHURCH", "CHARITY", "RED CROSS", "GOFUNDME", "BAPTIST", "MINISTR"], None, "Giving & Charity", S, "Giving / donation"),
|
||||
# --- shopping (broad, keep last)
|
||||
(["AMAZON", "AMZN", "EBAY", "ETSY", "BEST BUY", "TJMAXX", "TJ MAXX", "ROSS", "KOHL", "MACY", "STAPLES", "HOBBY LOBBY", "HOBBYLOBBY", "MICHAELS", "GOODWILL", "THRIFT", "ZENNI", "TARGET", "BOOKSHOP", "MARDEL", "CHRISTIANBOOK"], None, "Shopping", S, "Shopping"),
|
||||
]
|
||||
|
||||
def _kw(k):
|
||||
# match at the start of a word; short keywords (BP, ATT, IRS...) must also end a word
|
||||
return re.escape(k) + (r"(?![A-Z0-9])" if len(k) <= 4 else "")
|
||||
|
||||
_COMPILED = [(re.compile(r"(?<![A-Z0-9])(?:" + "|".join(_kw(k) for k in kws) + r")"), p, c, t, w) for kws, p, c, t, w in SEEDS]
|
||||
|
||||
def seed_match(text):
|
||||
"""Return (payee_or_None, category, type, what) for the first matching rule, else None."""
|
||||
t = text.upper()
|
||||
for rx, p, c, ty, w in _COMPILED:
|
||||
if rx.search(t): return p, c, ty, w
|
||||
return None
|
||||
+77
-8
@@ -11,6 +11,7 @@ import re, csv, sys, shutil, zipfile, datetime as dt, calendar, collections
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import pdfplumber
|
||||
from seed_rules import seed_match
|
||||
|
||||
import os
|
||||
HERE = Path(__file__).parent # code lives here
|
||||
@@ -260,6 +261,8 @@ class Row:
|
||||
note: str = ""
|
||||
is_new_payee: bool = False
|
||||
needs_review: bool = False
|
||||
category: str = ""
|
||||
type: str = ""
|
||||
|
||||
def learn_rules(wb):
|
||||
"""payee/what-for per normalized description, learned from rows already in the Bill Log."""
|
||||
@@ -269,7 +272,7 @@ def learn_rules(wb):
|
||||
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):
|
||||
def classify(t, rules, known_payees, cats=None):
|
||||
ex = exclusion(t)
|
||||
if ex: return Row(t, "excluded", reason=ex[0], direction=ex[1])
|
||||
d = (t.desc + " " + t.detail).upper()
|
||||
@@ -286,18 +289,28 @@ def classify(t, rules, known_payees):
|
||||
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
|
||||
sm = seed_match(d) # built-in generic merchant rules
|
||||
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"
|
||||
row.payee = (sm[0] if sm and sm[0] else " ".join(w.capitalize() for w in (norm(t.desc) or t.desc.upper()).split())[:40]) or "Unknown"
|
||||
row.what = sm[3] if sm else "NEW – please categorize"
|
||||
row.is_new_payee = True
|
||||
if not sm: row.needs_review = True; row.note = "NEW – review payee/category"
|
||||
if payee and payee not in known_payees: row.is_new_payee = True
|
||||
if row.is_new_payee and row.payee in known_payees: row.is_new_payee = False
|
||||
if row.is_new_payee:
|
||||
row.category, row.type = ("Other / Unsorted", "Spending")
|
||||
if sm and (cats is None or sm[1] in cats): row.category, row.type = sm[1], sm[2]
|
||||
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 _categories(wb):
|
||||
return [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")]
|
||||
|
||||
def existing_keys(wb):
|
||||
"""Multiset of (account, date, |amount|) already in Bill Log + Excluded Transfers."""
|
||||
keys = collections.Counter()
|
||||
@@ -312,11 +325,12 @@ def plan(wb, statements):
|
||||
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]}
|
||||
cats = _categories(wb)
|
||||
have = existing_keys(wb)
|
||||
rows = []
|
||||
for st in statements:
|
||||
for t in st.txns:
|
||||
rows.append(classify(t, rules, known))
|
||||
rows.append(classify(t, rules, known, cats))
|
||||
new, already = [], 0
|
||||
for r in rows:
|
||||
t = r.txn; k = (t.account, t.date, round(abs(t.amount), 2))
|
||||
@@ -363,7 +377,7 @@ 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"
|
||||
backup = BACKUPS / f"Bills_Tracker_{dt.datetime.now():%Y%m%d_%H%M%S_%f}.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"]
|
||||
@@ -399,7 +413,7 @@ def apply(new_rows, workbook=WORKBOOK):
|
||||
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"
|
||||
ls.cell(lr, 1).value, ls.cell(lr, 2).value, ls.cell(lr, 3).value = row.payee, row.category or "Other / Unsorted", row.type or "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)
|
||||
@@ -425,7 +439,7 @@ def apply(new_rows, workbook=WORKBOOK):
|
||||
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.",
|
||||
"Added to the Lists tab (category from the built-in merchant rules, or 'Other / Unsorted' when unknown) 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
|
||||
@@ -435,6 +449,61 @@ def apply(new_rows, workbook=WORKBOOK):
|
||||
tmp.replace(workbook)
|
||||
return dict(backup=str(backup), log_rows=len(log_rows), excluded_rows=len(ex_rows), new_payees=added_payees)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- categorizing existing payees
|
||||
def _backup(workbook):
|
||||
BACKUPS.mkdir(exist_ok=True)
|
||||
b = BACKUPS / f"Bills_Tracker_{dt.datetime.now():%Y%m%d_%H%M%S_%f}.xlsx"
|
||||
shutil.copy2(workbook, b); return b
|
||||
|
||||
def _save_workbook(wb, workbook):
|
||||
wb.calculation.fullCalcOnLoad = True
|
||||
tmp = Path(str(workbook) + ".new"); wb.save(tmp); _restore_charts(workbook, tmp); tmp.replace(workbook)
|
||||
|
||||
def uncategorized(workbook=WORKBOOK):
|
||||
"""Payees on the Lists tab still set to Other / Unsorted, with how much they account for."""
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(workbook, read_only=True)
|
||||
tot, cnt = collections.Counter(), collections.Counter()
|
||||
for r in wb["Bill Log"].iter_rows(min_row=6, max_col=6, values_only=True):
|
||||
if r[0] and r[1]: tot[r[1]] += r[5] or 0; cnt[r[1]] += 1
|
||||
cats = _categories(wb)
|
||||
out = [dict(payee=r[0], category=r[1] or "Other / Unsorted", type=r[2] or "Spending", n=cnt[r[0]], total=round(tot[r[0]], 2))
|
||||
for r in wb["Lists"].iter_rows(min_row=4, max_row=153, max_col=3, values_only=True)
|
||||
if r[0] and (not r[1] or r[1] == "Other / Unsorted")]
|
||||
wb.close()
|
||||
return dict(payees=sorted(out, key=lambda x: -x["total"]), categories=cats, types=["Bill", "Spending"])
|
||||
|
||||
def set_categories(changes, workbook=WORKBOOK):
|
||||
"""changes: [{payee, category, type}] -> written to the Lists tab (backup first)."""
|
||||
from openpyxl import load_workbook
|
||||
b = _backup(workbook); wb = load_workbook(workbook); ls = wb["Lists"]
|
||||
cats = set(_categories(wb)); rows = {ls.cell(r, 1).value: r for r in range(4, 154) if ls.cell(r, 1).value}
|
||||
n = 0
|
||||
for ch in changes:
|
||||
r = rows.get(ch.get("payee"))
|
||||
if r and ch.get("category") in cats and ch.get("type") in ("Bill", "Spending"):
|
||||
ls.cell(r, 2).value, ls.cell(r, 3).value = ch["category"], ch["type"]; n += 1
|
||||
_save_workbook(wb, workbook)
|
||||
return dict(updated=n, backup=b.name)
|
||||
|
||||
def autocategorize(workbook=WORKBOOK):
|
||||
"""Apply the built-in merchant rules to payees still set to Other / Unsorted."""
|
||||
from openpyxl import load_workbook
|
||||
b = _backup(workbook); wb = load_workbook(workbook); ls = wb["Lists"]; cats = set(_categories(wb))
|
||||
descs = collections.defaultdict(list)
|
||||
for r in wb["Bill Log"].iter_rows(min_row=6, max_col=8, values_only=True):
|
||||
if r[0] and r[1] and r[7] and len(descs[r[1]]) < 3: descs[r[1]].append(str(r[7]))
|
||||
n = left = 0
|
||||
for r in range(4, 154):
|
||||
p = ls.cell(r, 1).value
|
||||
if not p or (ls.cell(r, 2).value and ls.cell(r, 2).value != "Other / Unsorted"): continue
|
||||
sm = seed_match(" ".join([p] + descs[p]))
|
||||
if sm and sm[1] in cats: ls.cell(r, 2).value, ls.cell(r, 3).value = sm[1], sm[2]; n += 1
|
||||
else: left += 1
|
||||
_save_workbook(wb, workbook)
|
||||
return dict(updated=n, remaining=left, backup=b.name)
|
||||
|
||||
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}))
|
||||
|
||||
Reference in new issue
Block a user