from flask import Flask, render_template, request, redirect, url_for, jsonify, session import json import os import pdfplumber import re from datetime import datetime, date import calendar as cal_module from storage import load_json, save_json from settings import load_settings, save_settings, SECRET_KEYS import txns_db from version import __version__ as APP_VERSION, CHANGELOG from categories import CATEGORIES, CATEGORY_COLORS from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num from sinking import (load_funds, save_funds, FUND_COLORS, monthly_needed, months_until, DEFAULT_FUNDS) from mortgage import (load_mortgage, save_mortgage, monthly_payment, amortization_schedule, biweekly_schedule, current_balance_estimate) from assets import (load_assets, save_assets, car_display_name, total_spent, flip_profit, flip_total_invested, ASSET_CATEGORIES) from debts import (load_debts, save_debts, load_debt_log, save_debt_log, DEBT_CATEGORIES, OWNER_COLORS, month_key as debt_month_key, monthly_interest, payoff_months, compute_ending_balance, update_debt_month, get_debt_month, bnpl_payoff_date, simulate_payoff) from income import (load_income_log, save_income_log, income_by_month, income_ytd, log_income_entry, delete_income_entry, INCOME_STREAMS, OWNER_COLORS as INCOME_OWNER_COLORS, month_key as income_month_key) app = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY", "fullmoonbakehouse") from suggestions.routes_flask import suggestions_bp app.register_blueprint(suggestions_bp) from flask import send_from_directory @app.route("/uploads/") def _uploads(p): return send_from_directory("/data/uploads", p) import re as _re from markupsafe import Markup @app.template_filter('bold_md') def bold_md(text): """Convert **text** markdown to text.""" result = _re.sub(r'\*\*(.+?)\*\*', r'\1', text) return Markup(result) @app.template_filter('money') def money_filter(value): """Format a number as $1,234.56""" try: return "{:,.2f}".format(float(value)) except (TypeError, ValueError): return "0.00" TAX_FILE = os.path.join(os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")), "taxes.json") def load_taxes(): return load_json(TAX_FILE, {"rates": {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}, "set_aside": []}) def save_taxes(data): save_json(TAX_FILE, data) STANDARD_DEDUCTION_MFJ = 30000 # 2025 married filing jointly def month_status_summary(total_income, total_spending, budgeted_by_cat, spending_by_cat, bills_to_date, total_bills, today, year, month): """Generate a plain-English summary of how the month is going.""" import calendar as _cal _, days_in_month = _cal.monthrange(year, month) day = today.day if (today.year == year and today.month == month) else days_in_month pct_through = day / days_in_month lines = [] # Income check if total_income == 0: lines.append("No income logged yet this month.") else: lines.append(f"You've brought in **${total_income:,.2f}** so far this month.") # Spending vs budget total_budgeted = sum(budgeted_by_cat.values()) if total_budgeted > 0 and total_spending > 0: spend_pct = total_spending / total_budgeted * 100 if spend_pct < 60: lines.append(f"Spending is looking good — you've used **{spend_pct:.0f}%** of your category budgets.") elif spend_pct < 90: lines.append(f"Spending is on track at **{spend_pct:.0f}%** of budgeted categories.") else: over_cats = [c for c in spending_by_cat if budgeted_by_cat.get(c, 0) > 0 and spending_by_cat[c] > budgeted_by_cat[c]] if over_cats: lines.append(f"Heads up — you're over budget in **{', '.join(over_cats)}**.") else: lines.append(f"You've used **{spend_pct:.0f}%** of your category budgets — keep an eye on spending.") # Bills if bills_to_date: lines.append(f"**{len(bills_to_date)}** bill{'s' if len(bills_to_date) != 1 else ''} due so far this month totaling **${total_bills:,.2f}**.") # Overall net = total_income - total_spending - total_bills if total_income > 0: if net > 0: lines.append(f"Overall you're **${net:,.2f} ahead** right now.") elif net < 0: lines.append(f"Overall you're **${abs(net):,.2f} in the hole** right now — check what's pulling spending up.") return lines def week_status_summary(w): """Generate a plain-English summary for a single week.""" lines = [] income = w.get("income_total", 0) spending = w.get("spending_total", 0) leftover = w.get("leftover", 0) budgeted = sum(float(v.get("budgeted", 0) or 0) for v in w.get("cat_budget", {}).values()) actuals = w.get("cat_actuals", {}) over_cats = [c for c, amt in actuals.items() if w["cat_budget"].get(c, {}).get("budgeted") and amt > float(w["cat_budget"][c].get("budgeted", 0))] if income == 0: lines.append("No income logged for this week yet.") else: lines.append(f"**${income:,.2f}** income received this week.") if spending > 0 and budgeted > 0: pct = spending / budgeted * 100 if pct < 70: lines.append(f"Spending is under control at **{pct:.0f}%** of budget.") elif pct < 100: lines.append(f"Spending is at **{pct:.0f}%** of budget — getting close.") else: lines.append(f"You've gone over budget this week.") if over_cats: lines.append(f"Over in: **{', '.join(over_cats)}**.") if leftover > 0: lines.append(f"**${leftover:,.2f}** left over.") elif leftover < 0: lines.append(f"You're **${abs(leftover):,.2f} short** this week.") return lines def compute_tax_estimate(gross_income, rates, se_income=None): """Break down estimated taxes, married filing jointly. se_income: portion subject to self-employment tax (defaults to all income). W-2 income = gross_income - se_income, taxed normally but no SE tax. """ if se_income is None: se_income = gross_income # SE tax is on 92.35% of net self-employment earnings (IRS rule) se_tax = round(se_income * 0.9235 * (rates["se_tax"] / 100), 2) # Deduct half of SE tax, then standard deduction, before income tax taxable = max(gross_income - (se_tax / 2) - STANDARD_DEDUCTION_MFJ, 0) federal = round(taxable * (rates["federal"] / 100), 2) wi = round(taxable * (rates["wi_state"] / 100), 2) total = round(se_tax + federal + wi, 2) effective_rate = round((total / gross_income * 100), 1) if gross_income else 0 return {"se_tax": se_tax, "federal": federal, "wi_state": wi, "total": total, "effective_rate": effective_rate} @app.context_processor def inject_unreviewed_count(): # Fast COUNT query instead of parsing the whole transaction store per request. return dict(unreviewed_count=txns_db.count_unreviewed()) @app.context_processor def inject_version(): return dict(app_version=APP_VERSION) DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) TRANSACTIONS_FILE = os.path.join(DATA_DIR, "transactions.json") RULES_FILE = os.path.join(DATA_DIR, "rules.json") BUDGET_FILE = os.path.join(DATA_DIR, "budget.json") NET_WORTH_FILE = os.path.join(DATA_DIR, "net_worth_accounts.json") # Transactions live in SQLite (self-contained, on the volume). On first run this # imports any existing transactions.json; it's a no-op once the DB has rows. txns_db.init_db() _migrated = txns_db.migrate_from_json(TRANSACTIONS_FILE) if _migrated: print(f"[startup] migrated {_migrated} transactions from JSON -> SQLite") def load_nw_accounts(): return load_json(NET_WORTH_FILE, []) def save_nw_accounts(accounts): save_json(NET_WORTH_FILE, accounts) def get_transactions(): return txns_db.get_transactions() def save_transactions(transactions): txns_db.save_transactions(transactions) def get_rules(): return load_json(RULES_FILE, {}) def save_rules(rules): save_json(RULES_FILE, rules) def get_budget(): return load_json(BUDGET_FILE, {}) def save_budget(budget): save_json(BUDGET_FILE, budget) def transaction_category_amounts(t): """Returns list of (category, subcategory, amount) for a transaction, respecting splits.""" if t.get("splits"): return [(s["category"], s.get("subcategory",""), float(s["amount"])) for s in t["splits"]] return [(t.get("category"), t.get("subcategory",""), float(t.get("amount", 0)))] def auto_categorize(description, rules): desc_upper = description.upper() for keyword, category_info in rules.items(): if keyword.upper() in desc_upper: return category_info return None def parse_pdf_transactions(filepath): transactions = [] with pdfplumber.open(filepath) as pdf: full_text = "" for page in pdf.pages: text = page.extract_text() if text: full_text += text + "\n" lines = full_text.split("\n") date_pattern = re.compile(r"(\d{1,2}/\d{1,2}(?:/\d{2,4})?)") for line in lines: line = line.strip() if not line: continue date_match = date_pattern.search(line) if not date_match: continue amount_match = re.search(r"\$?([\d,]+\.\d{2})", line) if not amount_match: continue date_str = date_match.group(1) amount_str = amount_match.group(1).replace(",", "") date_pos = date_match.start() amount_pos = amount_match.start() description = line[date_pos + len(date_match.group(0)):amount_pos].strip() description = re.sub(r"\s+", " ", description).strip() if not description: description = line[:date_pos].strip() or "Unknown" try: amount = float(amount_str) except ValueError: continue if amount == 0: continue transactions.append({ "id": f"{date_str}_{description[:20]}_{amount}".replace(" ", "_"), "date": date_str, "description": description, "amount": amount, "category": None, "subcategory": None, "account": "", "notes": "", "splits": [], "reviewed": False, }) return transactions @app.route("/") def index(): today = date.today() year = today.year month = today.month mk = month_key(year, month) # Unreviewed transactions all_txns = get_transactions() unreviewed_count = sum(1 for t in all_txns if not t.get("reviewed")) recent = sorted( [t for t in all_txns if t.get("reviewed")], key=lambda t: t.get("date", ""), reverse=True )[:8] # Income from dateutil import parser as date_parser log = load_income_log() month_income_entries = [e for e in log if e.get("month") == mk] income_by_stream = {} for e in month_income_entries: s = e["stream"] income_by_stream[s] = round(income_by_stream.get(s, 0) + float(e.get("amount", 0)), 2) total_income = sum(income_by_stream.values()) ytd_by_stream, _ = income_ytd(year) income_ytd_total = sum(ytd_by_stream.values()) # Use Friday-to-Friday week ranges to define the month's budget window ranges = week_ranges(year, month) budget_start = ranges[0][0] budget_end = ranges[-1][1] # Spending from categorized transactions within budget window skip_cats = {"Income", "Debt", "Transfer"} spending_by_cat = {} for t in all_txns: if not t.get("reviewed") or not t.get("category") or t.get("ignored"): continue try: d = date_parser.parse(t["date"]).date() except Exception: continue if budget_start <= d <= budget_end: for cat, subcat, amt in transaction_category_amounts(t): if cat and cat not in skip_cats: spending_by_cat[cat] = round(spending_by_cat.get(cat, 0) + amt, 2) total_spending = sum(spending_by_cat.values()) # Bills — only count bills due up to today monthly_bills = bills_for_month(year, month) bills_to_date = [b for b in monthly_bills if b.get("due_date") and b["due_date"] <= today] total_bills = sum(float(b.get("amount") or 0) for b in bills_to_date) # Sinking contributions this month all_weekly = get_weekly() month_weekly = all_weekly.get(mk, {}) sinking_contributions = {} for wk_data in month_weekly.values(): for fund_name, info in wk_data.get("sinking", {}).items(): amt = float(info.get("contributed") or 0) sinking_contributions[fund_name] = round(sinking_contributions.get(fund_name, 0) + amt, 2) total_sinking = sum(sinking_contributions.values()) # Budgeted amounts — only sum weeks that have already started (start date <= today) budgeted_by_cat = {} for i, (wk_start, wk_end) in enumerate(ranges, 1): if wk_start > today: break wk_data = month_weekly.get(str(i), {}) for cat, info in wk_data.get("categories", {}).items(): b = float(info.get("budgeted") or 0) budgeted_by_cat[cat] = round(budgeted_by_cat.get(cat, 0) + b, 2) total_out = total_spending + total_bills + total_sinking net = round(total_income - total_out, 2) # Cash accounts for overview cash_accounts = load_nw_accounts() total_cash = sum(float(a.get("balance") or 0) for a in cash_accounts) # Plain-English summary month_summary = month_status_summary(total_income, total_spending, budgeted_by_cat, spending_by_cat, bills_to_date, total_bills, today, year, month) # Tax estimate for overview widget tax_data = load_taxes() tax_rates = tax_data.get("rates", {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}) ytd_by_stream_all, _ = income_ytd(year) ytd_total_all = sum(ytd_by_stream_all.values()) ytd_se_all = sum(v for s, v in ytd_by_stream_all.items() if s != "Auto King") tax_estimate = compute_tax_estimate(ytd_total_all, tax_rates, se_income=ytd_se_all) all_funds = load_funds() tax_fund = next((f for f in all_funds if f["name"] == "Taxes"), None) tax_set_aside = round(float(tax_fund["balance"]) if tax_fund else 0, 2) tax_still_needed = round(max(tax_estimate["total"] - tax_set_aside, 0), 2) return render_template("index.html", today=today, year=year, month=month, month_name=cal_module.month_name[month], unreviewed_count=unreviewed_count, recent=recent, total_income=total_income, income_by_stream=income_by_stream, income_streams=INCOME_STREAMS, income_ytd_total=income_ytd_total, total_spending=total_spending, spending_by_cat=spending_by_cat, budgeted_by_cat=budgeted_by_cat, total_bills=total_bills, monthly_bills=bills_to_date, total_sinking=total_sinking, sinking_contributions=sinking_contributions, total_out=total_out, net=net, tax_estimate=tax_estimate, tax_set_aside=tax_set_aside, tax_still_needed=tax_still_needed, month_summary=month_summary, cash_accounts=cash_accounts, total_cash=total_cash, colors=CATEGORY_COLORS, categories=CATEGORIES, owner_colors=INCOME_OWNER_COLORS, ) @app.route("/import", methods=["GET", "POST"]) def import_transactions(): from flask import flash if request.method == "POST": file = request.files.get("statement") account = request.form.get("account", "") if not file or not file.filename: flash("No file selected.", "error") return redirect("/transactions") filename = file.filename.lower() upload_path = os.path.join(DATA_DIR, file.filename) file.save(upload_path) if filename.endswith(".pdf"): new_txns = parse_pdf_transactions(upload_path) elif filename.endswith(".csv"): new_txns = parse_csv_transactions(upload_path) else: flash("Please upload a PDF or CSV file.", "error") return redirect("/transactions") for t in new_txns: t["account"] = account rules = get_rules() for t in new_txns: match = auto_categorize(t["description"], rules) if match: t["category"] = match.get("category") t["subcategory"] = match.get("subcategory") existing = get_transactions() existing_ids = {t["id"] for t in existing} added = 0 for t in new_txns: if t["id"] not in existing_ids: existing.append(t) added += 1 save_transactions(existing) flash(f"Imported {added} new transactions from {file.filename}.", "success") redirect_to = request.form.get("redirect_to", "") if redirect_to == "weekly": return redirect(url_for("weekly_overview")) return redirect("/transactions") return render_template("import.html", message=None) def parse_csv_transactions(filepath): import csv transactions = [] with open(filepath, newline="", encoding="utf-8-sig") as f: # Peek at first line to detect WestConsin format (starts with "Account Name") first_line = f.readline() f.seek(0) is_westconsin = first_line.startswith("Account Name") if is_westconsin: # Skip the 3 metadata rows, then read with DictReader for _ in range(3): f.readline() reader = csv.DictReader(f) for row in reader: date_val = row.get("Date", "").strip() # Memo has the real merchant name; fall back to Description memo = row.get("Memo", "").strip() desc_val = row.get("Description", "").strip() # Pull the merchant name from the memo (everything before the 4-digit MCC code) import re merchant_match = re.match(r"^(.+?)\s+\d{4}\s+", memo) description = merchant_match.group(1).strip() if merchant_match else (memo or desc_val) debit = row.get("Amount Debit", "").strip() credit = row.get("Amount Credit", "").strip() # Debits are negative in the file; credits are positive try: if debit: amount = abs(float(debit)) txn_type = "debit" elif credit: amount = float(credit) txn_type = "credit" else: continue except ValueError: continue if amount == 0: continue txn_id = row.get("Transaction Number", "").strip() or \ f"{date_val}_{description[:20]}_{amount}".replace(" ", "_") transactions.append({ "id": txn_id, "date": date_val, "description": description, "amount": amount, "txn_type": txn_type, "category": None, "subcategory": None, "account": "", "notes": "", "splits": [], "reviewed": False, }) else: reader = csv.DictReader(f) for row in reader: date_val = row.get("Date") or row.get("date") or row.get("Transaction Date") or "" desc_val = row.get("Description") or row.get("description") or row.get("Merchant") or row.get("Name") or "" amount_val = row.get("Amount") or row.get("amount") or row.get("Debit") or "" amount_str = str(amount_val).replace("$", "").replace(",", "").strip().lstrip("-") try: amount = float(amount_str) except ValueError: continue if amount == 0: continue transactions.append({ "id": f"{date_val}_{desc_val[:20]}_{amount}".replace(" ", "_"), "date": date_val, "description": desc_val, "amount": amount, "category": None, "subcategory": None, "account": "", "notes": "", "splits": [], "reviewed": False, }) return transactions @app.route("/review") def review(): transactions = get_transactions() unreviewed = [t for t in transactions if not t.get("reviewed")] if not unreviewed: return render_template("review.html", transaction=None, categories=CATEGORIES, colors=CATEGORY_COLORS) current = unreviewed[0] rules = get_rules() suggestion = auto_categorize(current["description"], rules) assets = load_assets() personal_cars = [ {**c, "display_name": car_display_name(c)} for c in assets.get("personal", []) ] # Detect BNPL transactions and find matching loans BNPL_KEYWORDS = ["klarna", "afterpay", "affirm", "sezzle", "zip", "paidy"] desc_lower = current["description"].lower() bnpl_lender = next((k for k in BNPL_KEYWORDS if k in desc_lower), None) bnpl_matches = [] if bnpl_lender: all_debts = load_debts() for d in all_debts: if not d.get("is_bnpl") or not d.get("active", True): continue provider = d.get("bnpl_provider", "").lower() if bnpl_lender in provider or provider in bnpl_lender: amt = float(d.get("payment_amount") or 0) txn_amt = float(current.get("amount") or 0) bnpl_matches.append({ "id": d["id"], "name": d["name"], "payment_amount": amt, "amount_match": abs(amt - txn_amt) < 0.02, "payments_made": d.get("payments_made", 0), "total_payments": d.get("total_payments", 0), }) # Sort exact amount matches to top bnpl_matches.sort(key=lambda x: (not x["amount_match"], x["name"])) sinking_funds = load_funds() return render_template("review.html", transaction=current, suggestion=suggestion, categories=CATEGORIES, colors=CATEGORY_COLORS, remaining=len(unreviewed), personal_cars=personal_cars, bnpl_lender=bnpl_lender, bnpl_matches=bnpl_matches, sinking_funds=sinking_funds, ) @app.route("/categorize", methods=["POST"]) def categorize(): txn_id = request.form.get("id") category = request.form.get("category") subcategory = request.form.get("subcategory", "") notes = request.form.get("notes", "") remember = request.form.get("remember") == "on" car_id = request.form.get("car_id", "") transactions = get_transactions() txn = None for t in transactions: if t["id"] == txn_id: t["category"] = category t["subcategory"] = subcategory t["notes"] = notes t["reviewed"] = True if car_id: t["car_id"] = car_id if remember and t["description"]: rules = get_rules() keyword = t["description"].split()[0] if t["description"] else t["description"] rules[keyword] = {"category": category, "subcategory": subcategory} save_rules(rules) txn = t break save_transactions(transactions) # Auto-contribute to sinking fund when categorized as Transfer > Sinking Fund if category == "Transfer" and subcategory == "Sinking Fund" and txn: fund_name = request.form.get("sinking_fund_name", "").strip() if fund_name: funds = load_funds() today_str = txn.get("date", str(date.today())) for f in funds: if f["name"] == fund_name: f["balance"] = round(float(f.get("balance", 0)) + float(txn.get("amount", 0)), 2) break save_funds(funds) # Auto-log gas to car profile if car_id and category == "Car" and subcategory == "Gas" and txn: import uuid assets = load_assets() for car in assets.get("personal", []): if car["id"] == car_id: car.setdefault("gas_log", []).append({ "id": str(uuid.uuid4()), "date": txn.get("date", ""), "amount": txn.get("amount", 0), "notes": f"Auto-logged from: {txn.get('description', '')}", "gallons": "", }) break save_assets(assets) return redirect(url_for("review")) @app.route("/split", methods=["POST"]) def split_transaction(): txn_id = request.form.get("id") transactions = get_transactions() txn = next((t for t in transactions if t["id"] == txn_id), None) if not txn: return redirect(url_for("review")) splits = [] i = 0 while True: cat = request.form.get(f"split_cat_{i}") if cat is None: break amt = request.form.get(f"split_amt_{i}", "") subcat = request.form.get(f"split_subcat_{i}", "") note = request.form.get(f"split_note_{i}", "") try: amount = float(amt) except ValueError: i += 1 continue if amount > 0 and cat: splits.append({"category": cat, "subcategory": subcat, "amount": amount, "note": note}) i += 1 redirect_to = request.form.get("redirect", "review") if splits: txn["splits"] = splits txn["category"] = splits[0]["category"] txn["subcategory"] = splits[0].get("subcategory", "") txn["reviewed"] = True save_transactions(transactions) if redirect_to == "transactions": return redirect("/transactions") return redirect(url_for("review")) @app.route("/skip", methods=["POST"]) def skip(): txn_id = request.form.get("id") transactions = get_transactions() for t in transactions: if t["id"] == txn_id: t["reviewed"] = True break save_transactions(transactions) return redirect(url_for("review")) @app.route("/bulk-ignore-before", methods=["POST"]) def bulk_ignore_before(): from dateutil import parser as date_parser before_date_str = request.form.get("before_date") try: before_date = date_parser.parse(before_date_str).date() except Exception: return redirect(url_for("review")) transactions = get_transactions() count = 0 for t in transactions: if t.get("reviewed"): continue try: d = date_parser.parse(t["date"]).date() except Exception: continue if d < before_date: t["reviewed"] = True t["ignored"] = True t["category"] = "Ignored" count += 1 save_transactions(transactions) return redirect(url_for("review")) @app.route("/ignore", methods=["POST"]) def ignore_transaction(): txn_id = request.form.get("id") label = request.form.get("label", "Ignored") redirect_to = request.form.get("redirect", "review") transactions = get_transactions() for t in transactions: if t["id"] == txn_id: t["reviewed"] = True t["ignored"] = True t["category"] = label break save_transactions(transactions) if redirect_to == "transactions": return redirect("/transactions") return redirect(url_for("review")) @app.route("/transactions") def transactions_view(): from dateutil import parser as date_parser today = date.today() year = today.year month = today.month mk = month_key(year, month) transactions = get_transactions() reviewed = sorted( [t for t in transactions if t.get("reviewed")], key=lambda t: t.get("date", ""), reverse=True, ) # Find current week range skip_cats = {"Income", "Debt", "Transfer"} ranges = week_ranges(year, month) current_week_num = 1 week_start = ranges[0][0] week_end = ranges[0][1] for i, (s, e) in enumerate(ranges, 1): if s <= today <= e: current_week_num = i week_start = s week_end = e break # Spending just for this week spending_by_cat = {} for t in transactions: if not t.get("reviewed") or not t.get("category") or t.get("ignored"): continue try: d = date_parser.parse(t["date"]).date() except Exception: continue if week_start <= d <= week_end: for cat, subcat, amt in transaction_category_amounts(t): if cat and cat not in skip_cats: spending_by_cat[cat] = round(spending_by_cat.get(cat, 0) + amt, 2) # Budget for this week only all_weekly = get_weekly() month_weekly = all_weekly.get(mk, {}) wk_data = month_weekly.get(str(current_week_num), {}) budgeted_by_cat = {} for cat, info in wk_data.get("categories", {}).items(): b = float(info.get("budgeted") or 0) if b: budgeted_by_cat[cat] = b spending_by_cat = dict(sorted(spending_by_cat.items(), key=lambda x: x[1], reverse=True)) week_label = f"Week {current_week_num} ({week_start.strftime('%b %-d')} – {week_end.strftime('%b %-d')})" # Optional filters from query string filter_from = request.args.get("from_date", "") filter_to = request.args.get("to_date", "") filter_cat = request.args.get("category", "") filter_search = request.args.get("search", "").strip() if filter_from: try: from_d = date_parser.parse(filter_from).date() reviewed = [t for t in reviewed if date_parser.parse(t["date"]).date() >= from_d] except Exception: pass if filter_to: try: to_d = date_parser.parse(filter_to).date() reviewed = [t for t in reviewed if date_parser.parse(t["date"]).date() <= to_d] except Exception: pass if filter_cat: reviewed = [t for t in reviewed if t.get("category") == filter_cat] if filter_search: q = filter_search.lower() reviewed = [t for t in reviewed if q in t.get("description", "").lower() or q in t.get("notes", "").lower()] import calendar as cal_mod return render_template("transactions.html", transactions=reviewed, categories=CATEGORIES, colors=CATEGORY_COLORS, filter_from=filter_from, filter_to=filter_to, filter_cat=filter_cat, filter_search=filter_search, spending_by_cat=spending_by_cat, budgeted_by_cat=budgeted_by_cat, week_label=week_label, month_name=cal_mod.month_name[month], year=year, month=month, ) @app.route("/edit/", methods=["GET", "POST"]) def edit_transaction(txn_id): transactions = get_transactions() txn = next((t for t in transactions if t["id"] == txn_id), None) if not txn: return redirect("/transactions") if request.method == "POST": txn["category"] = request.form.get("category") txn["subcategory"] = request.form.get("subcategory", "") txn["notes"] = request.form.get("notes", "") # Clear ignored flag when manually re-categorized txn.pop("ignored", None) save_transactions(transactions) return redirect("/transactions") return render_template("edit.html", transaction=txn, categories=CATEGORIES, colors=CATEGORY_COLORS, sinking_funds=load_funds()) @app.route("/summary") def summary(): transactions = get_transactions() reviewed = [t for t in transactions if t.get("reviewed") and t.get("category")] by_category = {} for t in reviewed: cat = t["category"] subcat = t.get("subcategory") or "General" if cat not in by_category: by_category[cat] = {"total": 0, "subcategories": {}} by_category[cat]["total"] += t["amount"] by_category[cat]["subcategories"][subcat] = by_category[cat]["subcategories"].get(subcat, 0) + t["amount"] # Sort by total descending by_category = dict(sorted(by_category.items(), key=lambda x: x[1]["total"], reverse=True)) for cat in by_category: by_category[cat]["subcategories"] = dict( sorted(by_category[cat]["subcategories"].items(), key=lambda x: x[1], reverse=True) ) return render_template("summary.html", by_category=by_category, colors=CATEGORY_COLORS, categories=CATEGORIES, ) WEEKLY_FILE = os.path.join(DATA_DIR, "weekly.json") def get_weekly(): return load_json(WEEKLY_FILE, {}) def save_weekly(data): save_json(WEEKLY_FILE, data) def month_key(year, month): return f"{year}-{month:02d}" def week_ranges(year, month): from datetime import date, timedelta # Find the Friday on or before the 1st of the month first = date(year, month, 1) days_since_friday = (first.weekday() - 4) % 7 # 4 = Friday start = first - timedelta(days=days_since_friday) # Build Friday-to-Thursday weeks until we've passed the end of the month import calendar as _cal _, num_days = _cal.monthrange(year, month) month_end = date(year, month, num_days) weeks = [] while start <= month_end: end = start + timedelta(days=6) weeks.append((start, end)) start = start + timedelta(days=7) return weeks @app.route("/calendar") @app.route("/calendar//") def bill_calendar(year=None, month=None): today = date.today() year = year or today.year month = month or today.month grid = calendar_grid(year, month) monthly_bills = bills_for_month(year, month) bills_by_day = {} for b in monthly_bills: d = b["due_day"] bills_by_day.setdefault(d, []).append(b) prev_month = month - 1 or 12 prev_year = year if month > 1 else year - 1 next_month = month % 12 + 1 next_year = year if month < 12 else year + 1 month_name = cal_module.month_name[month] return render_template("calendar.html", grid=grid, bills_by_day=bills_by_day, year=year, month=month, month_name=month_name, prev_year=prev_year, prev_month=prev_month, next_year=next_year, next_month=next_month, colors=CATEGORY_COLORS, categories=CATEGORIES, today=today, ) @app.route("/bills") def bills_list(): all_bills = load_bills() # Only show recurring bills (have due_day), not BNPL payment entries bills = [b for b in all_bills if "due_day" in b] return render_template("bills.html", bills=bills, categories=CATEGORIES, colors=CATEGORY_COLORS, ) @app.route("/bills/add", methods=["POST"]) def bill_add(): bills = load_bills() import uuid bill = { "id": str(uuid.uuid4()), "name": request.form.get("name", "").strip(), "amount": float(request.form.get("amount") or 0), "due_day": int(request.form.get("due_day") or 1), "category": request.form.get("category", ""), "subcategory": request.form.get("subcategory", ""), "active": True, "autopay": request.form.get("autopay") == "on", "remind": request.form.get("autopay") != "on", "notes": request.form.get("notes", ""), } bills.append(bill) save_bills(bills) return redirect(url_for("bills_list")) @app.route("/bills/toggle//", methods=["POST"]) def bill_toggle(bill_id, field): if field not in ("autopay", "remind"): return jsonify({"ok": False}), 400 bills = load_bills() for b in bills: if b["id"] == bill_id: b[field] = not b.get(field, False) if field == "autopay": b["remind"] = not b["autopay"] save_bills(bills) return jsonify({"ok": True}) @app.route("/reminders/run", methods=["POST"]) def run_reminders(): from reminders import check_and_send_reminders try: results = check_and_send_reminders() msg = "Reminders sent: " + ", ".join(results) if results else "No reminders due today." except Exception as e: msg = f"Error: {e}" return jsonify({"message": msg}) @app.route("/bills/delete/", methods=["POST"]) def bill_delete(bill_id): bills = [b for b in load_bills() if b["id"] != bill_id] save_bills(bills) return redirect(url_for("bills_list")) @app.route("/bills/edit/", methods=["POST"]) def bill_edit(bill_id): bills = load_bills() for b in bills: if b["id"] == bill_id: b["name"] = request.form.get("name", b["name"]).strip() b["amount"] = float(request.form.get("amount") or b["amount"]) b["due_day"] = int(request.form.get("due_day") or b["due_day"]) b["category"] = request.form.get("category", b["category"]) b["subcategory"] = request.form.get("subcategory", b.get("subcategory", "")) b["notes"] = request.form.get("notes", b.get("notes", "")) break save_bills(bills) return redirect(url_for("bills_list")) @app.route("/weekly") @app.route("/weekly//") def weekly_overview(year=None, month=None): today = date.today() year = year or today.year month = month or today.month mk = month_key(year, month) all_weekly = get_weekly() month_data = all_weekly.get(mk, {}) ranges = week_ranges(year, month) monthly_bills = bills_for_month(year, month) # Pre-process transactions: parse dates and index reviewed ones by date from dateutil import parser as date_parser all_transactions = get_transactions() reviewed_txns = [t for t in all_transactions if t.get("reviewed") and t.get("category") and not t.get("ignored")] def parse_txn_date(t): try: return date_parser.parse(t["date"]).date() except Exception: return None txns_with_dates = [(parse_txn_date(t), t) for t in reviewed_txns] txns_with_dates = [(d, t) for d, t in txns_with_dates if d] weeks = [] for i, (start, end) in enumerate(ranges, 1): wk = str(i) wdata = month_data.get(wk, {}) # Match bills whose due day falls within this week's date range week_bills = [] for b in monthly_bills: due_day = b.get("due_day") if not due_day: continue import calendar as _cal _, nm = _cal.monthrange(year, month) try: bill_date = date(year, month, min(due_day, nm)) if start <= bill_date <= end: week_bills.append(b) except Exception: pass bills_total = sum(float(b.get("amount") or 0) for b in week_bills) income_sources = ["Auto King", "Studio", "Bakery", "Misc"] income = wdata.get("income", {}) cat_budget = wdata.get("categories", {}) sinking = wdata.get("sinking", {}) extra_debt = wdata.get("extra_debt", {}) # Sum categorized transactions falling in this week's date range # Skip Income (that's tracked separately) and Transfer (moves between accounts, not real spending) # Include Debt — credit card payments etc. are real outflows from checking skip_cats = {"Income", "Transfer"} cat_actuals = {} for txn_date, t in txns_with_dates: if start <= txn_date <= end: for cat, subcat, amt in transaction_category_amounts(t): if cat and cat not in skip_cats: cat_actuals[cat] = round(cat_actuals.get(cat, 0) + amt, 2) income_total = sum(float(v.get("actual") or 0) for v in income.values()) spending_total = sum(cat_actuals.values()) sinking_total = sum(float(v.get("contributed") or 0) for v in sinking.values()) extra_debt_total = sum(float(v.get("extra") or 0) for v in extra_debt.values()) starting_balance = float(wdata.get("starting_balance", {}).get("amount", {}).get("value", 0) or 0) leftover = starting_balance + income_total - spending_total - sinking_total - extra_debt_total # Format date range label e.g. "May 29 – Jun 4" start_label = start.strftime("%b %-d") end_label = end.strftime("%b %-d") # Transactions for this week (reviewed, not ignored) week_txns = [t for d, t in txns_with_dates if start <= d <= end] week_obj = { "num": i, "start": start, "end": end, "start_label": start_label, "end_label": end_label, "bills": week_bills, "bills_total": bills_total, "income": income, "income_sources": income_sources, "income_total": income_total, "spending_total": spending_total, "sinking_total": sinking_total, "extra_debt_total": extra_debt_total, "starting_balance": starting_balance, "cat_budget": cat_budget, "cat_actuals": cat_actuals, "sinking": sinking, "extra_debt": extra_debt, "leftover": leftover, "transactions": week_txns, } week_obj["summary"] = week_status_summary(week_obj) weeks.append(week_obj) prev_month = month - 1 or 12 prev_year = year if month > 1 else year - 1 next_month = month % 12 + 1 next_year = year if month < 12 else year + 1 # Figure out which week contains today current_week = 1 if year == today.year and month == today.month: for i, (wk_start, wk_end) in enumerate(ranges, 1): if wk_start <= today <= wk_end: current_week = i break funds = load_funds() all_debts = load_debts() active_debts = [d for d in all_debts if d.get("active", True) and not d.get("is_bnpl") and float(d.get("current_balance", 0)) > 0] return render_template("weekly.html", weeks=weeks, year=year, month=month, month_name=cal_module.month_name[month], prev_year=prev_year, prev_month=prev_month, next_year=next_year, next_month=next_month, colors=CATEGORY_COLORS, categories=CATEGORIES, sinking_funds=funds, fund_colors=FUND_COLORS, active_debts=active_debts, owner_colors=OWNER_COLORS, current_week=current_week, ) @app.route("/weekly/extra-debt", methods=["POST"]) def weekly_extra_debt(): debt_id = request.form.get("debt_id") amount = float(request.form.get("amount") or 0) year = int(request.form.get("year")) month = int(request.form.get("month")) week = request.form.get("week") mk = month_key(year, month) all_weekly = get_weekly() prev = float(all_weekly.get(mk, {}).get(week, {}).get("extra_debt", {}).get(debt_id, {}).get("extra") or 0) all_weekly.setdefault(mk, {}).setdefault(week, {}).setdefault("extra_debt", {})[debt_id] = {"extra": amount} save_weekly(all_weekly) # Apply extra payment to debt balance debts = load_debts() for d in debts: if d["id"] == debt_id and not d.get("is_bnpl"): d["current_balance"] = max(round(float(d.get("current_balance", 0)) - amount + prev, 2), 0) break save_debts(debts) return jsonify({"ok": True}) @app.route("/weekly/save", methods=["POST"]) def weekly_save(): year = int(request.form.get("year")) month = int(request.form.get("month")) week = request.form.get("week") field = request.form.get("field") key = request.form.get("key") subkey = request.form.get("subkey", "actual") value = request.form.get("value", "") mk = month_key(year, month) all_weekly = get_weekly() all_weekly.setdefault(mk, {}).setdefault(week, {}).setdefault(field, {}).setdefault(key, {})[subkey] = value # Sync actual income entries to income_log.json if field == "income" and subkey == "actual": amount = float(value) if value else 0 # Remove previous entry for this stream+week if it exists prev_entry_id = all_weekly[mk][week][field][key].get("_income_log_id") if prev_entry_id: delete_income_entry(prev_entry_id) # Log new entry and store its ID so we can update it later if amount > 0: import uuid entry_id = str(uuid.uuid4()) log_income_entry(key, amount, mk, note=f"Week {week}", entry_id=entry_id) all_weekly[mk][week][field][key]["_income_log_id"] = entry_id else: all_weekly[mk][week][field][key].pop("_income_log_id", None) save_weekly(all_weekly) return jsonify({"ok": True}) @app.route("/debts") @app.route("/debts//") def debts_view(year=None, month=None): today = date.today() year = year or today.year month = month or today.month mk = debt_month_key(year, month) debts = load_debts() log = load_debt_log() month_log = log.get(mk, {}) import calendar as cal_mod prev_month = month - 1 or 12 prev_year = year if month > 1 else year - 1 next_month = month % 12 + 1 next_year = year if month < 12 else year + 1 by_category = {cat: [] for cat in DEBT_CATEGORIES} totals = {cat: {"balance": 0, "minimum": 0, "paid": 0, "extra": 0} for cat in DEBT_CATEGORIES} grand = {"balance": 0, "minimum": 0, "paid": 0, "extra": 0} for debt in debts: if not debt.get("active", True): continue entry = month_log.get(debt["id"], {}) starting = float(entry.get("starting_balance") or debt.get("current_balance", 0)) interest = float(entry.get("interest") if entry.get("interest") is not None else monthly_interest(starting, debt.get("apr", 0))) new_charges = float(entry.get("new_charges", 0)) payment = float(entry.get("payment", 0)) minimum = float(debt.get("minimum", 0)) extra = max(round(payment - minimum, 2), 0) ending = max(round(starting + interest + new_charges - payment, 2), 0) months_left = None payoff_str = None if payment > 0 or minimum > 0: pay = payment if payment > 0 else minimum m = payoff_months(starting, debt.get("apr", 0), pay) if m: months_left = m future = date(year + (month + m - 1) // 12, ((month + m - 1) % 12) + 1, 1) payoff_str = future.strftime("%b %Y") row = { **debt, "starting": starting, "interest": round(interest, 2), "new_charges": new_charges, "payment": payment, "minimum": minimum, "extra": extra, "ending": ending, "months_left": months_left, "payoff_str": payoff_str, "entry": entry, } cat = debt.get("debt_category", "Credit Cards") if cat in by_category: by_category[cat].append(row) totals[cat]["balance"] += ending totals[cat]["minimum"] += minimum totals[cat]["paid"] += payment totals[cat]["extra"] += extra grand["balance"] += ending grand["minimum"] += minimum grand["paid"] += payment grand["extra"] += extra monthly_income_by_stream, _ = income_by_month(year, month) monthly_income_total = sum(monthly_income_by_stream.values()) dti = round((grand["minimum"] / monthly_income_total * 100), 1) if monthly_income_total > 0 else None return render_template("debts.html", by_category=by_category, totals=totals, grand=grand, debt_categories=DEBT_CATEGORIES, owner_colors=OWNER_COLORS, year=year, month=month, month_name=cal_mod.month_name[month], prev_year=prev_year, prev_month=prev_month, next_year=next_year, next_month=next_month, categories=CATEGORIES, colors=CATEGORY_COLORS, bnpl_payoff_date=bnpl_payoff_date, monthly_income=monthly_income_total, dti=dti, ) @app.route("/debts/add", methods=["POST"]) def debt_add(): import uuid debts = load_debts() debt = { "id": str(uuid.uuid4()), "name": request.form.get("name", "").strip(), "debt_category": request.form.get("debt_category", "Credit Cards"), "owner": request.form.get("owner", "Bonna"), "current_balance": float(request.form.get("current_balance") or 0), "apr": float(request.form.get("apr") or 0), "minimum": float(request.form.get("minimum") or 0), "account_match": request.form.get("account_match", "").strip(), "notes": request.form.get("notes", "").strip(), "active": True, } debts.append(debt) save_debts(debts) return redirect(url_for("debts_view")) @app.route("/debts/add-bnpl", methods=["POST"]) def bnpl_add(): import uuid from datetime import timedelta debts = load_debts() freq = request.form.get("frequency", "biweekly") freq_days = {"biweekly": 14, "monthly": 30, "4-payment": 14}.get(freq, 14) total_payments = int(request.form.get("total_payments") or 1) payments_made = int(request.form.get("payments_made") or 0) payment_amount = float(request.form.get("payment_amount") or 0) remaining = total_payments - payments_made debt_id = str(uuid.uuid4()) # Parse next payment date next_payment_str = request.form.get("next_payment_date", "").strip() next_payment_date = None if next_payment_str: try: next_payment_date = datetime.strptime(next_payment_str, "%Y-%m-%d").date() except ValueError: pass description = request.form.get("description", "").strip() provider = request.form.get("bnpl_provider", "Affirm") merchant = request.form.get("merchant", "").strip() bill_name = f"{provider} – {description}" + (f" ({merchant})" if merchant else "") debt = { "id": debt_id, "name": description, "merchant": merchant, "debt_category": "BNPL", "bnpl_provider": provider, "owner": request.form.get("owner", "Bonna"), "original_amount": float(request.form.get("original_amount") or 0), "payment_amount": payment_amount, "total_payments": total_payments, "payments_made": payments_made, "frequency": freq, "frequency_days": freq_days, "current_balance": round(remaining * payment_amount, 2), "apr": 0, "minimum": payment_amount, "notes": request.form.get("notes", "").strip(), "active": True, "is_bnpl": True, "next_payment_date": next_payment_str, } debts.append(debt) save_debts(debts) # Auto-generate bill entries for each remaining payment if next_payment_date and remaining > 0: bills = load_bills() for i in range(remaining): if freq == "monthly": # advance by month m = next_payment_date.month + i y = next_payment_date.year + (m - 1) // 12 m = ((m - 1) % 12) + 1 import calendar as _cal last_day = _cal.monthrange(y, m)[1] pay_date = date(y, m, min(next_payment_date.day, last_day)) else: pay_date = next_payment_date + timedelta(days=freq_days * i) bills.append({ "id": str(uuid.uuid4()), "name": bill_name, "due_date_str": pay_date.strftime("%Y-%m-%d"), "amount": payment_amount, "category": "Debt", "active": True, "paid": False, "bnpl_debt_id": debt_id, "bnpl_payment_num": payments_made + i + 1, }) save_bills(bills) redirect_to = request.form.get("redirect_to", "") if redirect_to == "review": return redirect(url_for("review")) return redirect(url_for("debts_view")) @app.route("/debts/bnpl-edit/", methods=["POST"]) def bnpl_edit(debt_id): import uuid from datetime import timedelta debts = load_debts() for d in debts: if d["id"] == debt_id and d.get("is_bnpl"): freq = request.form.get("frequency", d.get("frequency", "biweekly")) freq_days = {"biweekly": 14, "monthly": 30, "4-payment": 14}.get(freq, 14) total_payments = int(request.form.get("total_payments") or d.get("total_payments", 1)) payments_made = int(request.form.get("payments_made") or 0) payment_amount = float(request.form.get("payment_amount") or d.get("payment_amount", 0)) remaining = total_payments - payments_made description = request.form.get("description", d.get("name", "")).strip() provider = request.form.get("bnpl_provider", d.get("bnpl_provider", "Affirm")) merchant = request.form.get("merchant", d.get("merchant", "")).strip() next_payment_str = request.form.get("next_payment_date", "").strip() d["name"] = description d["merchant"] = merchant d["bnpl_provider"] = provider d["owner"] = request.form.get("owner", d.get("owner", "Bonna")) d["original_amount"] = float(request.form.get("original_amount") or d.get("original_amount", 0)) d["payment_amount"] = payment_amount d["total_payments"] = total_payments d["payments_made"] = payments_made d["frequency"] = freq d["frequency_days"] = freq_days d["current_balance"] = max(round(remaining * payment_amount, 2), 0) d["minimum"] = payment_amount d["notes"] = request.form.get("notes", "").strip() d["next_payment_date"] = next_payment_str d["active"] = remaining > 0 break save_debts(debts) # Remove unpaid calendar bills for this plan and regenerate bills = load_bills() bills = [b for b in bills if not (b.get("bnpl_debt_id") == debt_id and not b.get("paid"))] if next_payment_str and remaining > 0: try: next_payment_date = datetime.strptime(next_payment_str, "%Y-%m-%d").date() except ValueError: next_payment_date = None if next_payment_date: bill_name = f"{provider} – {description}" + (f" ({merchant})" if merchant else "") for i in range(remaining): if freq == "monthly": m = next_payment_date.month + i y = next_payment_date.year + (m - 1) // 12 m = ((m - 1) % 12) + 1 import calendar as _cal last_day = _cal.monthrange(y, m)[1] pay_date = date(y, m, min(next_payment_date.day, last_day)) else: pay_date = next_payment_date + timedelta(days=freq_days * i) bills.append({ "id": str(uuid.uuid4()), "name": bill_name, "due_date_str": pay_date.strftime("%Y-%m-%d"), "amount": payment_amount, "category": "Debt", "active": True, "paid": False, "bnpl_debt_id": debt_id, "bnpl_payment_num": payments_made + i + 1, }) save_bills(bills) return redirect(url_for("debts_view")) @app.route("/debts/bnpl-payment/", methods=["POST"]) def bnpl_payment(debt_id): debts = load_debts() for d in debts: if d["id"] == debt_id and d.get("is_bnpl"): next_num = d.get("payments_made", 0) + 1 d["payments_made"] = next_num remaining = d["total_payments"] - d["payments_made"] d["current_balance"] = max(round(remaining * d["payment_amount"], 2), 0) if d["payments_made"] >= d["total_payments"]: d["active"] = False break save_debts(debts) # Mark the corresponding bill as paid bills = load_bills() for b in bills: if b.get("bnpl_debt_id") == debt_id and b.get("bnpl_payment_num") == next_num and not b.get("paid"): b["paid"] = True break save_bills(bills) return redirect(url_for("debts_view")) @app.route("/categorize/sinking-transfer", methods=["POST"]) def categorize_sinking_transfer(): """Categorize a transaction as Transfer > Sinking Fund and auto-contribute to the chosen fund.""" txn_id = request.form.get("id") fund_name = request.form.get("sinking_fund_name", "").strip() redirect_to = request.form.get("redirect", "transactions") # Categorize the transaction and find its date/amount transactions = get_transactions() txn_amount = 0 txn_date = None for t in transactions: if t["id"] == txn_id: t["category"] = "Transfer" t["subcategory"] = "Sinking Fund" t["notes"] = fund_name t["reviewed"] = True t.pop("ignored", None) txn_amount = float(t.get("amount", 0)) txn_date = t.get("date") # e.g. "2026-06-04" break save_transactions(transactions) if fund_name and txn_amount: # Figure out which week/month this transaction belongs to txn_week = None txn_year, txn_month = None, None if txn_date: try: d = date.fromisoformat(txn_date) txn_year = d.year txn_month = d.month txn_week = f"week{get_week_num(d)}" except Exception: pass funds = load_funds() for f in funds: if f["name"] == fund_name: if txn_year and txn_week: # Use the same dedup logic as the manual weekly entry: # subtract any previous contribution logged for this fund/week, # then add the new amount — so if someone already entered it # manually in the weekly section, we don't double-count. mk = month_key(txn_year, txn_month) all_weekly = get_weekly() prev = float( all_weekly.get(mk, {}) .get(txn_week, {}) .get("sinking", {}) .get(fund_name, {}) .get("contributed") or 0 ) old_balance = float(f.get("balance", 0)) f["balance"] = round(old_balance - prev + txn_amount, 2) # Log to weekly so the manual field shows it pre-filled # and future edits to the weekly field do correct dedup all_weekly.setdefault(mk, {}).setdefault(txn_week, {}).setdefault("sinking", {})[fund_name] = { "contributed": txn_amount, "from_transaction": txn_id, } save_weekly(all_weekly) else: # No date info — just bump the balance directly f["balance"] = round(float(f.get("balance", 0)) + txn_amount, 2) break save_funds(funds) if redirect_to == "review": return redirect(url_for("review")) return redirect("/transactions") @app.route("/review/match-bnpl", methods=["POST"]) def match_bnpl(): """Match a transaction to a BNPL loan during review — marks payment and categorizes.""" txn_id = request.form.get("txn_id") debt_id = request.form.get("debt_id") category = request.form.get("category", "Debt") notes = request.form.get("notes", "") # Mark the BNPL payment debts = load_debts() next_num = None for d in debts: if d["id"] == debt_id and d.get("is_bnpl"): next_num = d.get("payments_made", 0) + 1 d["payments_made"] = next_num remaining = d["total_payments"] - d["payments_made"] d["current_balance"] = max(round(remaining * d["payment_amount"], 2), 0) if d["payments_made"] >= d["total_payments"]: d["active"] = False notes = notes or d["name"] break save_debts(debts) # Mark the corresponding bill as paid if next_num: bills = load_bills() for b in bills: if b.get("bnpl_debt_id") == debt_id and b.get("bnpl_payment_num") == next_num and not b.get("paid"): b["paid"] = True break save_bills(bills) # Categorize the transaction transactions = get_transactions() for t in transactions: if t["id"] == txn_id: t["category"] = category t["subcategory"] = "" t["notes"] = notes t["reviewed"] = True break save_transactions(transactions) return redirect(url_for("review")) @app.route("/debts/delete/", methods=["POST"]) def debt_delete(debt_id): debts = [d for d in load_debts() if d["id"] != debt_id] save_debts(debts) return redirect(url_for("debts_view")) @app.route("/debts/log", methods=["POST"]) def debt_log_save(): debt_id = request.form.get("debt_id") year = int(request.form.get("year")) month = int(request.form.get("month")) field = request.form.get("field") value = request.form.get("value", "") allowed = {"starting_balance", "payment", "new_charges", "interest"} if field not in allowed: return jsonify({"ok": False}), 400 mk = debt_month_key(year, month) log = load_debt_log() log.setdefault(mk, {}).setdefault(debt_id, {})[field] = float(value) if value else 0 save_debt_log(log) debts = load_debts() debt = next((d for d in debts if d["id"] == debt_id), None) if debt: entry = log[mk][debt_id] starting = float(entry.get("starting_balance") or debt.get("current_balance", 0)) interest = float(entry.get("interest") if entry.get("interest") is not None else monthly_interest(starting, debt.get("apr", 0))) new_charges = float(entry.get("new_charges", 0)) payment = float(entry.get("payment", 0)) ending = max(round(starting + interest + new_charges - payment, 2), 0) extra = max(round(payment - float(debt.get("minimum", 0)), 2), 0) return jsonify({"ok": True, "ending": ending, "extra": extra, "interest": interest}) return jsonify({"ok": True}) @app.route("/debts/toggle-pending/", methods=["POST"]) def debt_toggle_pending(debt_id): debts = load_debts() for d in debts: if d["id"] == debt_id: d["statement_pending"] = not d.get("statement_pending", False) break save_debts(debts) return jsonify({"ok": True, "pending": next(d["statement_pending"] for d in debts if d["id"] == debt_id)}) @app.route("/debts/edit/", methods=["POST"]) def debt_edit(debt_id): debts = load_debts() for d in debts: if d["id"] == debt_id: d["name"] = request.form.get("name", d["name"]).strip() d["owner"] = request.form.get("owner", d.get("owner", "Bonna")) v = request.form.get("current_balance", "").strip() if v: d["current_balance"] = float(v) v = request.form.get("apr", "").strip() if v: d["apr"] = float(v) v = request.form.get("minimum", "").strip() if v: d["minimum"] = float(v) v = request.form.get("credit_limit", "").strip() if v: d["credit_limit"] = float(v) d["notes"] = request.form.get("notes", "").strip() break save_debts(debts) return redirect(url_for("debts_view")) @app.route("/debts/add-from-statement", methods=["POST"]) def debt_add_from_statement(): import re, uuid name = request.form.get("name", "").strip() owner = request.form.get("owner", "Bonna") text = request.form.get("text", "") year = int(request.form.get("year")) month = int(request.form.get("month")) if not name or not text: return jsonify({"error": "Name and statement text are required."}) def find_amount(patterns, txt): for pat in patterns: m = re.search(pat, txt, re.IGNORECASE) if m: return float(m.group(1).replace(",", "")) return None previous_balance = find_amount([r"previous balance\s*\$?([\d,]+\.\d{2})", r"previous balance[^\d]*([\d,]+\.\d{2})"], text) purchases = find_amount([r"purchases & other charges\s*\$?([\d,]+\.\d{2})", r"purchases\s*\$?([\d,]+\.\d{2})", r"transactions total\s*\$?([\d,]+\.\d{2})"], text) interest = find_amount([r"total interest for this period\s*\$?([\d,]+\.\d{2})", r"interest charged\s*\$?([\d,]+\.\d{2})", r"interest charge on purchases[^\d]*([\d,]+\.\d{2})", r"\+ interest charged\s*\$?([\d,]+\.\d{2})"], text) payment = find_amount([r"payments & credits\s*\$?([\d,]+\.\d{2})", r"payments\s*\$?-?([\d,]+\.\d{2})", r"payment\s*\$?-?([\d,]+\.\d{2})"], text) minimum = find_amount([r"minimum payment due\s*\$?([\d,]+\.\d{2})", r"minimum payment\s*\$?([\d,]+\.\d{2})"], text) new_balance = find_amount([r"new balance\s*\$?([\d,]+\.\d{2})"], text) credit_limit = find_amount([r"credit limit\s*\$?([\d,]+\.\d{2})"], text) available_credit = find_amount([r"available credit\s*\$?([\d,]+\.\d{2})"], text) apr = find_amount([r"purchase apr is ([\d.]+)%", r"purchases\s+([\d.]+)%", r"annual percentage rate \(apr\)\s*([\d.]+)", r"APR\)[^\d]*([\d.]+)", r"APR is ([\d.]+)%"], text) if previous_balance is None and new_balance is None: return jsonify({"error": "Could not find balance information. Make sure you pasted the full statement text."}) # Create the debt debt_id = str(uuid.uuid4()) debt = { "id": debt_id, "name": name, "debt_category": "Credit Cards", "owner": owner, "current_balance": new_balance or 0, "apr": apr or 0, "minimum": minimum or 0, "credit_limit": credit_limit or 0, "available_credit": available_credit or 0, "account_match": "", "notes": "", "active": True, } debts = load_debts() debts.append(debt) save_debts(debts) # Log this month's statement data mk = debt_month_key(year, month) log = load_debt_log() entry = log.setdefault(mk, {}).setdefault(debt_id, {}) if previous_balance is not None: entry["starting_balance"] = previous_balance if purchases is not None: entry["new_charges"] = purchases if interest is not None: entry["interest"] = interest if payment is not None: entry["payment"] = payment save_debt_log(log) return jsonify({"ok": True}) @app.route("/debts/parse-statement", methods=["POST"]) def debt_parse_statement(): import re text = request.form.get("text", "") debt_id = request.form.get("debt_id") year = int(request.form.get("year")) month = int(request.form.get("month")) def find_amount(patterns, txt): for pat in patterns: m = re.search(pat, txt, re.IGNORECASE) if m: return float(m.group(1).replace(",", "")) return None previous_balance = find_amount([ r"previous balance\s*\$?([\d,]+\.\d{2})", r"previous balance[^\d]*([\d,]+\.\d{2})", ], text) purchases = find_amount([ r"purchases & other charges\s*\$?([\d,]+\.\d{2})", r"purchases\s*\$?([\d,]+\.\d{2})", r"transactions total\s*\$?([\d,]+\.\d{2})", ], text) interest = find_amount([ r"total interest for this period\s*\$?([\d,]+\.\d{2})", r"interest charged\s*\$?([\d,]+\.\d{2})", r"interest charge on purchases[^\d]*([\d,]+\.\d{2})", r"\+ interest charged\s*\$?([\d,]+\.\d{2})", ], text) payment = find_amount([ r"payments & credits\s*\$?([\d,]+\.\d{2})", r"payments\s*\$?-?([\d,]+\.\d{2})", r"payment\s*\$?-?([\d,]+\.\d{2})", ], text) minimum = find_amount([ r"minimum payment due\s*\$?([\d,]+\.\d{2})", r"minimum payment\s*\$?([\d,]+\.\d{2})", ], text) new_balance = find_amount([ r"new balance\s*\$?([\d,]+\.\d{2})", ], text) credit_limit = find_amount([ r"credit limit\s*\$?([\d,]+\.\d{2})", ], text) available_credit = find_amount([ r"available credit\s*\$?([\d,]+\.\d{2})", ], text) apr = find_amount([ r"purchase apr is ([\d.]+)%", r"purchases\s+([\d.]+)%", r"annual percentage rate \(apr\)\s*([\d.]+)", r"APR\)[^\d]*([\d.]+)", r"APR is ([\d.]+)%", ], text) if previous_balance is None and new_balance is None: return jsonify({"error": "Could not find balance information. Make sure you pasted the full statement text."}) mk = debt_month_key(year, month) log = load_debt_log() entry = log.setdefault(mk, {}).setdefault(debt_id, {}) if previous_balance is not None: entry["starting_balance"] = previous_balance if purchases is not None: entry["new_charges"] = purchases if interest is not None: entry["interest"] = interest if payment is not None: entry["payment"] = payment save_debt_log(log) # Update the debt itself with current balance, minimum, APR, credit info debts = load_debts() for d in debts: if d["id"] == debt_id: if new_balance is not None: d["current_balance"] = new_balance if minimum is not None: d["minimum"] = minimum if apr is not None: d["apr"] = apr if credit_limit is not None: d["credit_limit"] = credit_limit if available_credit is not None: d["available_credit"] = available_credit break save_debts(debts) return jsonify({ "ok": True, "previous_balance": previous_balance or 0, "purchases": purchases or 0, "interest": interest or 0, "payment": payment or 0, "minimum": minimum or 0, "new_balance": new_balance or 0, "credit_limit": credit_limit, "available_credit": available_credit, }) @app.route("/debts/strategy", methods=["GET", "POST"]) def debt_strategy(): debts = load_debts() active = [d for d in debts if not d.get("is_bnpl") and d.get("active", True) and float(d.get("current_balance", 0)) > 0] total_minimums = sum(float(d.get("minimum", 0)) for d in active) total_balance = sum(float(d.get("current_balance", 0)) for d in active) extra_monthly = float(request.form.get("extra_monthly") or 0) total_monthly = total_minimums + extra_monthly avalanche, av_interest, av_months, _ = simulate_payoff(debts, total_monthly, "avalanche") snowball, sb_interest, sb_months, _ = simulate_payoff(debts, total_monthly, "snowball") interest_saved = round(sb_interest - av_interest, 2) months_diff = sb_months - av_months return render_template("strategy.html", avalanche=avalanche, snowball=snowball, av_interest=av_interest, sb_interest=sb_interest, av_months=av_months, sb_months=sb_months, interest_saved=interest_saved, months_diff=months_diff, total_minimums=total_minimums, total_balance=total_balance, extra_monthly=extra_monthly, total_monthly=total_monthly, owner_colors=OWNER_COLORS, categories=CATEGORIES, colors=CATEGORY_COLORS, ) @app.route("/sinking") def sinking_view(): funds = load_funds() total = sum(float(f.get("balance", 0)) for f in funds) for f in funds: if f.get("type") == "goal" and f.get("goal") and f.get("target_date"): f["monthly_needed"] = monthly_needed( float(f.get("balance", 0)), float(f["goal"]), f["target_date"]) f["months_left"] = months_until(f["target_date"]) goal = float(f["goal"]) bal = float(f.get("balance", 0)) f["pct"] = min(int((bal / goal * 100) if goal else 0), 100) else: f["monthly_needed"] = None f["months_left"] = None f["pct"] = None return render_template("sinking.html", funds=funds, fund_colors=FUND_COLORS, total=total, categories=CATEGORIES, colors=CATEGORY_COLORS, ) @app.route("/sinking/update", methods=["POST"]) def sinking_update(): funds = load_funds() fund_name = request.form.get("name") field = request.form.get("field") value = request.form.get("value", "") allowed = {"balance", "monthly", "goal", "target_date", "notes", "type"} if field not in allowed: return jsonify({"ok": False}), 400 for f in funds: if f["name"] == fund_name: if field in ("balance", "monthly", "goal"): f[field] = float(value) if value else 0 else: f[field] = value break save_funds(funds) return jsonify({"ok": True}) @app.route("/sinking/contribute-weekly", methods=["POST"]) def sinking_contribute_weekly(): funds = load_funds() fund_name = request.form.get("name") amount = float(request.form.get("amount") or 0) year = int(request.form.get("year")) month = int(request.form.get("month")) week = request.form.get("week") new_balance = 0 # Update fund balance for f in funds: if f["name"] == fund_name: old = float(f.get("balance", 0)) # Find previous contribution this week to avoid double-adding mk = month_key(year, month) all_weekly = get_weekly() prev = float(all_weekly.get(mk, {}).get(week, {}).get("sinking", {}).get(fund_name, {}).get("contributed") or 0) f["balance"] = round(old - prev + amount, 2) new_balance = f["balance"] break save_funds(funds) # Save contribution to weekly log mk = month_key(year, month) all_weekly = get_weekly() all_weekly.setdefault(mk, {}).setdefault(week, {}).setdefault("sinking", {})[fund_name] = {"contributed": amount} save_weekly(all_weekly) return jsonify({"ok": True, "new_balance": new_balance}) @app.route("/sinking/contribute", methods=["POST"]) def sinking_contribute(): funds = load_funds() fund_name = request.form.get("name") amount = float(request.form.get("amount") or 0) for f in funds: if f["name"] == fund_name: f["balance"] = round(float(f.get("balance", 0)) + amount, 2) break save_funds(funds) return redirect(url_for("sinking_view")) @app.route("/sinking/withdraw", methods=["POST"]) def sinking_withdraw(): funds = load_funds() fund_name = request.form.get("name") amount = float(request.form.get("amount") or 0) for f in funds: if f["name"] == fund_name: f["balance"] = max(round(float(f.get("balance", 0)) - amount, 2), 0) break save_funds(funds) return redirect(url_for("sinking_view")) @app.route("/sinking/add", methods=["POST"]) def sinking_add(): funds = load_funds() import uuid fund = { "id": str(uuid.uuid4()), "name": request.form.get("name", "").strip(), "type": request.form.get("type", "ongoing"), "balance": float(request.form.get("balance") or 0), "monthly": float(request.form.get("monthly") or 0), "goal": float(request.form.get("goal") or 0) or None, "target_date": request.form.get("target_date") or None, "notes": request.form.get("notes", "").strip(), } funds.append(fund) save_funds(funds) return redirect(url_for("sinking_view")) @app.route("/mortgage", methods=["GET", "POST"]) def mortgage_view(): data = load_mortgage() if request.method == "POST": data["principal"] = float(request.form.get("principal") or 0) data["annual_rate"] = float(request.form.get("annual_rate") or 0) data["term_years"] = int(request.form.get("term_years") or 30) data["start_date"] = request.form.get("start_date") or "" data["extra_monthly"] = float(request.form.get("extra_monthly") or 0) save_mortgage(data) principal = float(data.get("principal") or 0) annual_rate = float(data.get("annual_rate") or 0) term_years = int(data.get("term_years") or 30) start_date = data.get("start_date") or "" extra_monthly = float(data.get("extra_monthly") or 0) base_payment = monthly_payment(principal, annual_rate, term_years) if principal else 0 current_bal = current_balance_estimate(principal, annual_rate, term_years, start_date) if principal and start_date else principal schedule = [] total_interest_standard = 0 standard_months = 0 total_interest_extra = 0 extra_months = 0 bi_interest = 0 bi_payments = 0 bi_years = 0 bi_months_total = 0 if principal and annual_rate and start_date: schedule, total_interest_standard, standard_months = amortization_schedule( principal, annual_rate, term_years, start_date, 0) if extra_monthly > 0: _, total_interest_extra, extra_months = amortization_schedule( principal, annual_rate, term_years, start_date, extra_monthly) bi_interest, bi_payments, bi_years, bi_months_total = biweekly_schedule( principal, annual_rate, term_years, start_date) interest_saved_extra = round(total_interest_standard - total_interest_extra, 2) if extra_monthly else 0 months_saved_extra = standard_months - extra_months if extra_monthly else 0 interest_saved_biweekly = round(total_interest_standard - bi_interest, 2) months_saved_biweekly = standard_months - bi_months_total return render_template("mortgage.html", data=data, principal=principal, annual_rate=annual_rate, term_years=term_years, start_date=start_date, extra_monthly=extra_monthly, base_payment=base_payment, current_bal=current_bal, schedule=schedule, total_interest_standard=total_interest_standard, standard_months=standard_months, total_interest_extra=total_interest_extra, extra_months=extra_months, interest_saved_extra=interest_saved_extra, months_saved_extra=months_saved_extra, bi_interest=bi_interest, bi_years=bi_years, bi_months_total=bi_months_total, interest_saved_biweekly=interest_saved_biweekly, months_saved_biweekly=months_saved_biweekly, categories=CATEGORIES, colors=CATEGORY_COLORS, ) @app.route("/assets") def assets_view(): data = load_assets() personal = data.get("personal", []) flips = data.get("flips", []) for car in personal: car["total_spent"] = total_spent(car) car["display_name"] = car_display_name(car) active_flips = [f for f in flips if not f.get("sold")] sold_flips = [f for f in flips if f.get("sold")] for flip in flips: flip["display_name"] = car_display_name(flip) flip["total_invested"] = flip_total_invested(flip) flip["profit"] = flip_profit(flip) total_tied_up = sum(f["total_invested"] for f in active_flips) other_assets = data.get("other_assets", []) by_category = {} for cat in ASSET_CATEGORIES: by_category[cat] = [a for a in other_assets if a.get("category") == cat] total_other_value = sum(float(a.get("value") or 0) for a in other_assets) total_personal_value = sum(float(c.get("estimated_value") or 0) for c in personal) net_worth_assets = total_personal_value + total_other_value return render_template("assets.html", personal=personal, active_flips=active_flips, sold_flips=sold_flips, total_tied_up=total_tied_up, by_category=by_category, asset_categories=ASSET_CATEGORIES, net_worth_assets=net_worth_assets, owner_colors=OWNER_COLORS, categories=CATEGORIES, colors=CATEGORY_COLORS, ) @app.route("/assets/personal/update/", methods=["POST"]) def personal_car_update(car_id): data = load_assets() for car in data["personal"]: if car["id"] == car_id: for field in ("color", "owner", "purchase_price", "purchase_date", "estimated_value", "notes"): if field in request.form: val = request.form.get(field) car[field] = float(val) if field in ("purchase_price", "estimated_value") and val else val break save_assets(data) return redirect(url_for("assets_view")) @app.route("/assets/personal/log/", methods=["POST"]) def personal_car_log(car_id): import uuid data = load_assets() log_type = request.form.get("log_type", "gas") entry = { "id": str(uuid.uuid4()), "date": request.form.get("date", ""), "amount": float(request.form.get("amount") or 0), "notes": request.form.get("notes", ""), } if log_type == "gas": entry["gallons"] = request.form.get("gallons", "") else: entry["description"] = request.form.get("description", "") for car in data["personal"]: if car["id"] == car_id: if log_type == "gas": car.setdefault("gas_log", []).append(entry) else: car.setdefault("maintenance_log", []).append(entry) break save_assets(data) return redirect(url_for("assets_view")) @app.route("/assets/vehicle/add", methods=["POST"]) def vehicle_add(): import uuid data = load_assets() vehicle_type = request.form.get("vehicle_type", "personal") year = int(request.form.get("year") or 0) make = request.form.get("make", "").strip() model = request.form.get("model", "").strip() trim = request.form.get("trim", "").strip() color = request.form.get("color", "").strip() purchase_price = float(request.form.get("purchase_price") or 0) purchase_date = request.form.get("purchase_date", "") notes = request.form.get("notes", "").strip() if vehicle_type == "flip": flip = { "id": str(uuid.uuid4()), "type": "flip", "year": year, "make": make, "model": model, "trim": trim, "color": color, "purchase_price": purchase_price, "purchase_date": purchase_date, "sale_price": 0, "sold": False, "sold_date": "", "notes": notes, "expense_log": [], } data.setdefault("flips", []).append(flip) else: car = { "id": str(uuid.uuid4()), "type": "personal", "year": year, "make": make, "model": model, "trim": trim, "color": color, "owner": request.form.get("owner", "Bonna"), "purchase_price": purchase_price, "purchase_date": purchase_date, "estimated_value": float(request.form.get("estimated_value") or 0), "notes": notes, "gas_log": [], "maintenance_log": [], } data.setdefault("personal", []).append(car) save_assets(data) return redirect(url_for("assets_view")) @app.route("/assets/flip/add", methods=["POST"]) def flip_add(): import uuid data = load_assets() flip = { "id": str(uuid.uuid4()), "type": "flip", "year": int(request.form.get("year") or 0), "make": request.form.get("make", "").strip(), "model": request.form.get("model", "").strip(), "trim": request.form.get("trim", "").strip(), "color": request.form.get("color", "").strip(), "purchase_price": float(request.form.get("purchase_price") or 0), "purchase_date": request.form.get("purchase_date", ""), "sale_price": 0, "sold": False, "sold_date": "", "notes": request.form.get("notes", "").strip(), "expense_log": [], } personal_contribution = float(request.form.get("personal_contribution") or 0) if personal_contribution > 0: flip["expense_log"].append({ "id": str(uuid.uuid4()), "date": request.form.get("purchase_date", ""), "amount": personal_contribution, "description": "Initial purchase from personal funds", }) data.setdefault("flips", []).append(flip) save_assets(data) return redirect(url_for("assets_view")) @app.route("/assets/flip/expense/", methods=["POST"]) def flip_expense(flip_id): import uuid data = load_assets() entry = { "id": str(uuid.uuid4()), "date": request.form.get("date", ""), "amount": float(request.form.get("amount") or 0), "description": request.form.get("description", ""), } for flip in data.get("flips", []): if flip["id"] == flip_id: flip.setdefault("expense_log", []).append(entry) break save_assets(data) return redirect(url_for("assets_view")) @app.route("/assets/other/add", methods=["POST"]) def other_asset_add(): import uuid data = load_assets() asset = { "id": str(uuid.uuid4()), "name": request.form.get("name", "").strip(), "category": request.form.get("category", "Other Valuables"), "owner": request.form.get("owner", "Both"), "value": float(request.form.get("value") or 0), "purchase_price": float(request.form.get("purchase_price") or 0), "purchase_date": request.form.get("purchase_date", ""), "notes": request.form.get("notes", "").strip(), } data.setdefault("other_assets", []).append(asset) save_assets(data) return redirect(url_for("assets_view")) @app.route("/assets/other/delete/", methods=["POST"]) def other_asset_delete(asset_id): data = load_assets() data["other_assets"] = [a for a in data.get("other_assets", []) if a["id"] != asset_id] save_assets(data) return redirect(url_for("assets_view")) @app.route("/assets/other/update/", methods=["POST"]) def other_asset_update(asset_id): data = load_assets() for a in data.get("other_assets", []): if a["id"] == asset_id: new_value = float(request.form.get("value") or 0) a["value"] = new_value if request.form.get("notes") is not None: a["notes"] = request.form.get("notes", "").strip() # Append to value history, replacing today's entry if it exists today_str = date.today().isoformat() history = a.get("value_history", []) if history and history[-1]["date"] == today_str: history[-1]["value"] = new_value else: history.append({"date": today_str, "value": new_value}) a["value_history"] = history break save_assets(data) return jsonify({"ok": True}) @app.route("/assets/flip/sell/", methods=["POST"]) def flip_sell(flip_id): data = load_assets() for flip in data.get("flips", []): if flip["id"] == flip_id: flip["sale_price"] = float(request.form.get("sale_price") or 0) flip["sold_date"] = request.form.get("sold_date", "") flip["sold"] = True break save_assets(data) return redirect(url_for("assets_view")) @app.route("/assets/flip/edit/", methods=["POST"]) def flip_edit(flip_id): data = load_assets() for flip in data.get("flips", []): if flip["id"] == flip_id: flip["year"] = int(request.form.get("year") or flip["year"]) flip["make"] = request.form.get("make", flip["make"]).strip() flip["model"] = request.form.get("model", flip["model"]).strip() flip["trim"] = request.form.get("trim", flip.get("trim", "")).strip() flip["color"] = request.form.get("color", flip.get("color", "")).strip() flip["purchase_price"] = float(request.form.get("purchase_price") or flip.get("purchase_price", 0)) flip["purchase_date"] = request.form.get("purchase_date", flip.get("purchase_date", "")) flip["notes"] = request.form.get("notes", flip.get("notes", "")).strip() break save_assets(data) return redirect(url_for("assets_view")) @app.route("/assets/flip/delete/", methods=["POST"]) def flip_delete(flip_id): data = load_assets() data["flips"] = [f for f in data.get("flips", []) if f["id"] != flip_id] save_assets(data) return redirect(url_for("assets_view")) def ensure_tax_fund(): """Create a Taxes sinking fund if one doesn't exist.""" import uuid funds = load_funds() if not any(f["name"] == "Taxes" for f in funds): funds.append({ "id": str(uuid.uuid4()), "name": "Taxes", "goal": 0, "balance": 0, "monthly_target": 0, "notes": "Set aside for estimated quarterly taxes", }) save_funds(funds) @app.route("/taxes") def taxes_view(): ensure_tax_fund() today = date.today() tax_data = load_taxes() rates = tax_data.get("rates", {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}) set_aside_log = tax_data.get("set_aside", []) # YTD income ytd_by_stream, _ = income_ytd(today.year) ytd_total = sum(ytd_by_stream.values()) # SE income = everything except Auto King (Tony's W-2 paycheck) W2_STREAMS = {"Auto King"} ytd_se = sum(v for s, v in ytd_by_stream.items() if s not in W2_STREAMS) ytd_w2 = ytd_total - ytd_se # Tax estimates estimate = compute_tax_estimate(ytd_total, rates, se_income=ytd_se) # Read set-aside total from the Taxes sinking fund balance funds = load_funds() tax_fund = next((f for f in funds if f["name"] == "Taxes"), None) total_set_aside = round(float(tax_fund["balance"]) if tax_fund else 0, 2) still_needed = round(max(estimate["total"] - total_set_aside, 0), 2) over_saved = round(max(total_set_aside - estimate["total"], 0), 2) # Per-quarter breakdown (Jan-Mar, Apr-Jun, Jul-Sep, Oct-Dec) quarters = [ {"label": "Q1 (Jan–Mar)", "months": [1,2,3]}, {"label": "Q2 (Apr–Jun)", "months": [4,5,6]}, {"label": "Q3 (Jul–Sep)", "months": [7,8,9]}, {"label": "Q4 (Oct–Dec)", "months": [10,11,12]}, ] # Federal estimated tax due dates quarter_due_dates = ["April 15", "June 16", "September 15", "January 15"] for i, q in enumerate(quarters): q_income = sum( v for mk, v in [(income_month_key(today.year, m), 0) for m in q["months"]] ) # Get actual income by month q_total = 0 q_se = 0 for m in q["months"]: monthly, _ = income_by_month(today.year, m) q_total += sum(monthly.values()) q_se += sum(v for s, v in monthly.items() if s not in W2_STREAMS) q["income"] = round(q_total, 2) q["estimate"] = compute_tax_estimate(q_total, rates, se_income=q_se) q["due_date"] = quarter_due_dates[i] return render_template("taxes.html", today=today, year=today.year, rates=rates, ytd_total=ytd_total, ytd_se=ytd_se, ytd_w2=ytd_w2, ytd_by_stream=ytd_by_stream, estimate=estimate, set_aside_log=set_aside_log, total_set_aside=total_set_aside, still_needed=still_needed, over_saved=over_saved, quarters=quarters, ) @app.route("/taxes/set-aside", methods=["POST"]) def taxes_set_aside(): import uuid amount = float(request.form.get("amount") or 0) notes = request.form.get("notes", "") entry_date = request.form.get("date", str(date.today())) # Log to taxes.json for history tax_data = load_taxes() tax_data.setdefault("set_aside", []).append({ "id": str(uuid.uuid4()), "date": entry_date, "amount": amount, "notes": notes, }) save_taxes(tax_data) # Add to the Taxes sinking fund balance funds = load_funds() for f in funds: if f["name"] == "Taxes": f["balance"] = round(float(f.get("balance", 0)) + amount, 2) break save_funds(funds) return redirect(url_for("taxes_view")) @app.route("/taxes/set-aside/delete/", methods=["POST"]) def taxes_set_aside_delete(entry_id): tax_data = load_taxes() entry = next((e for e in tax_data.get("set_aside", []) if e["id"] == entry_id), None) if entry: # Subtract from the Taxes sinking fund funds = load_funds() for f in funds: if f["name"] == "Taxes": f["balance"] = round(max(float(f.get("balance", 0)) - float(entry.get("amount", 0)), 0), 2) break save_funds(funds) tax_data["set_aside"] = [e for e in tax_data.get("set_aside", []) if e["id"] != entry_id] save_taxes(tax_data) return redirect(url_for("taxes_view")) @app.route("/taxes/rates", methods=["POST"]) def taxes_rates(): tax_data = load_taxes() tax_data["rates"] = { "se_tax": float(request.form.get("se_tax") or 15.3), "federal": float(request.form.get("federal") or 22.0), "wi_state": float(request.form.get("wi_state") or 5.3), } save_taxes(tax_data) return redirect(url_for("taxes_view")) @app.route("/net-worth") def net_worth_view(): accounts = load_nw_accounts() funds = load_funds() asset_data = load_assets() debts = load_debts() total_cash = sum(float(a.get("balance") or 0) for a in accounts) total_sinking = sum(float(f.get("balance") or 0) for f in funds) personal_cars = asset_data.get("personal", []) for car in personal_cars: car["display_name"] = car_display_name(car) total_vehicles = sum(float(c.get("estimated_value") or 0) for c in personal_cars) other_assets = asset_data.get("other_assets", []) total_other_assets = sum(float(a.get("value") or 0) for a in other_assets) total_assets = total_cash + total_sinking + total_vehicles + total_other_assets debts_by_cat = {cat: [] for cat in DEBT_CATEGORIES} debt_cat_totals = {cat: 0.0 for cat in DEBT_CATEGORIES} for d in debts: if not d.get("active", True): continue cat = d.get("debt_category", "Credit Cards") if cat in debts_by_cat: debts_by_cat[cat].append(d) debt_cat_totals[cat] += float(d.get("current_balance") or 0) total_debts = sum(debt_cat_totals.values()) net_worth = round(total_assets - total_debts, 2) return render_template("net_worth.html", accounts=accounts, sinking_funds=funds, personal_cars=personal_cars, other_assets=other_assets, debts_by_cat=debts_by_cat, debt_cat_totals=debt_cat_totals, debt_categories=DEBT_CATEGORIES, total_cash=total_cash, total_sinking=total_sinking, total_vehicles=total_vehicles, total_other_assets=total_other_assets, total_assets=total_assets, total_debts=total_debts, net_worth=net_worth, owner_colors=OWNER_COLORS, ) @app.route("/net-worth/account/add", methods=["POST"]) def nw_account_add(): import uuid accounts = load_nw_accounts() accounts.append({ "id": str(uuid.uuid4())[:8], "name": request.form.get("name", "").strip(), "owner": request.form.get("owner", "Bonna"), "balance": float(request.form.get("balance") or 0), }) save_nw_accounts(accounts) return redirect(url_for("net_worth_view")) @app.route("/net-worth/account/update/", methods=["POST"]) def nw_account_update(acc_id): accounts = load_nw_accounts() for a in accounts: if a["id"] == acc_id: a["balance"] = float(request.form.get("balance") or 0) break save_nw_accounts(accounts) return jsonify({"ok": True}) @app.route("/net-worth/account/delete/", methods=["POST"]) def nw_account_delete(acc_id): accounts = load_nw_accounts() accounts = [a for a in accounts if a["id"] != acc_id] save_nw_accounts(accounts) return redirect(url_for("net_worth_view")) @app.route("/month") @app.route("/month//") def month_summary(year=None, month=None): today = date.today() year = year or today.year month = month or today.month mk = month_key(year, month) prev_month = month - 1 or 12 prev_year = year if month > 1 else year - 1 next_month = month % 12 + 1 next_year = year if month < 12 else year + 1 # --- Income --- log = load_income_log() month_income_entries = [e for e in log if e.get("month") == mk] income_by_stream = {} for e in month_income_entries: s = e["stream"] income_by_stream[s] = round(income_by_stream.get(s, 0) + float(e.get("amount", 0)), 2) total_income = sum(income_by_stream.values()) # --- Transactions (categorized, date falls in this month) --- from dateutil import parser as date_parser all_txns = get_transactions() month_txns = [] for t in all_txns: if not t.get("reviewed") or not t.get("category"): continue try: d = date_parser.parse(t["date"]).date() except Exception: continue if d.year == year and d.month == month: month_txns.append(t) skip_cats = {"Income", "Debt", "Transfer"} spending_by_cat = {} for t in month_txns: cat = t.get("category") if cat and cat not in skip_cats: spending_by_cat[cat] = round(spending_by_cat.get(cat, 0) + float(t.get("amount", 0)), 2) total_spending = sum(spending_by_cat.values()) # --- Bills due this month --- monthly_bills = bills_for_month(year, month) total_bills = sum(float(b.get("amount") or 0) for b in monthly_bills) # --- Debt payments from debt log --- debt_log = load_debt_log() month_debt = debt_log.get(mk, {}) debts = load_debts() debt_payments = [] total_debt_paid = 0 total_extra_paid = 0 for d in debts: if not d.get("active", True): continue entry = month_debt.get(d["id"], {}) payment = float(entry.get("payment", 0)) minimum = float(d.get("minimum", 0)) extra = max(round(payment - minimum, 2), 0) if payment > 0: debt_payments.append({"name": d["name"], "owner": d.get("owner",""), "payment": payment, "extra": extra}) total_debt_paid += payment total_extra_paid += extra # Also pull extra debt payments from weekly log all_weekly = get_weekly() month_weekly = all_weekly.get(mk, {}) extra_from_weekly = 0 for wk_data in month_weekly.values(): for debt_id, info in wk_data.get("extra_debt", {}).items(): extra_from_weekly += float(info.get("extra") or 0) # --- Sinking fund contributions from weekly log --- sinking_contributions = {} for wk_data in month_weekly.values(): for fund_name, info in wk_data.get("sinking", {}).items(): amt = float(info.get("contributed") or 0) sinking_contributions[fund_name] = round(sinking_contributions.get(fund_name, 0) + amt, 2) total_sinking = sum(sinking_contributions.values()) # --- Budgeted amounts from weekly --- budgeted_by_cat = {} for wk_data in month_weekly.values(): for cat, info in wk_data.get("categories", {}).items(): b = float(info.get("budgeted") or 0) budgeted_by_cat[cat] = round(budgeted_by_cat.get(cat, 0) + b, 2) # --- Net --- total_out = total_spending + total_bills + total_sinking net = round(total_income - total_out, 2) return render_template("month.html", year=year, month=month, month_name=cal_module.month_name[month], prev_year=prev_year, prev_month=prev_month, next_year=next_year, next_month=next_month, total_income=total_income, income_by_stream=income_by_stream, income_streams=INCOME_STREAMS, total_spending=total_spending, spending_by_cat=spending_by_cat, budgeted_by_cat=budgeted_by_cat, total_bills=total_bills, monthly_bills=monthly_bills, debt_payments=debt_payments, total_debt_paid=total_debt_paid, total_extra_paid=total_extra_paid, sinking_contributions=sinking_contributions, total_sinking=total_sinking, total_out=total_out, net=net, categories=CATEGORIES, colors=CATEGORY_COLORS, owner_colors=INCOME_OWNER_COLORS, ) @app.route("/income") @app.route("/income//") def income_view(year=None, month=None): today = date.today() year = year or today.year month = month or today.month by_stream, entries = income_by_month(year, month) ytd_by_stream, ytd_by_month = income_ytd(year) stream_names = [s["name"] for s in INCOME_STREAMS] month_total = sum(by_stream.values()) ytd_total = sum(ytd_by_stream.values()) # Build sorted month list for YTD chart sorted_months = sorted(ytd_by_month.keys()) prev_month = month - 1 or 12 prev_year = year if month > 1 else year - 1 next_month = month % 12 + 1 next_year = year if month < 12 else year + 1 # Tax estimate for income page tax_data = load_taxes() tax_rates = tax_data.get("rates", {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}) ytd_se = sum(v for s, v in ytd_by_stream.items() if s != "Auto King") tax_estimate = compute_tax_estimate(ytd_total, tax_rates, se_income=ytd_se) all_funds = load_funds() tax_fund = next((f for f in all_funds if f["name"] == "Taxes"), None) tax_set_aside = round(float(tax_fund["balance"]) if tax_fund else 0, 2) tax_still_needed = round(max(tax_estimate["total"] - tax_set_aside, 0), 2) return render_template("income.html", year=year, month=month, month_name=cal_module.month_name[month], month_names=cal_module.month_name, prev_year=prev_year, prev_month=prev_month, next_year=next_year, next_month=next_month, income_streams=INCOME_STREAMS, by_stream=by_stream, entries=entries, month_total=month_total, ytd_by_stream=ytd_by_stream, ytd_by_month=ytd_by_month, sorted_months=sorted_months, ytd_total=ytd_total, owner_colors=INCOME_OWNER_COLORS, categories=CATEGORIES, colors=CATEGORY_COLORS, tax_estimate=tax_estimate, tax_set_aside=tax_set_aside, tax_still_needed=tax_still_needed, ) @app.route("/income/log", methods=["POST"]) def income_log(): stream = request.form.get("stream") amount = float(request.form.get("amount") or 0) month_str = request.form.get("month_str") note = request.form.get("note", "") if stream and amount and month_str: log_income_entry(stream, amount, month_str, note) return redirect(request.referrer or url_for("income_view")) @app.route("/income/backfill", methods=["POST"]) def income_backfill(): year = int(request.form.get("year")) log = load_income_log() existing = {(e["stream"], e["month"]) for e in log} for m in range(1, 13): mk = income_month_key(year, m) for s in INCOME_STREAMS: field = f"amt_{m}_{s['name'].replace(' ', '_')}" val = request.form.get(field, "").strip() if not val: continue try: amount = float(val) except ValueError: continue if amount <= 0: continue if (s["name"], mk) in existing: continue log_income_entry(s["name"], amount, mk, note="backfill") existing.add((s["name"], mk)) return redirect(url_for("income_view", year=year)) @app.route("/income/delete/", methods=["POST"]) def income_delete(entry_id): delete_income_entry(entry_id) year = request.form.get("year", date.today().year) month = request.form.get("month", date.today().month) return redirect(url_for("income_view", year=year, month=month)) @app.route("/settings", methods=["GET", "POST"]) def settings_view(): current = load_settings() if request.method == "POST": new = dict(current) new["gmail_address"] = request.form.get("gmail_address", "").strip() new["reminder_recipient"] = request.form.get("reminder_recipient", "").strip() new["reminders_enabled"] = request.form.get("reminders_enabled") == "on" # Secret fields: only overwrite when a new value is supplied; a blank # field keeps the existing stored secret. pw = request.form.get("gmail_app_password", "").strip() if pw: new["gmail_app_password"] = pw save_settings(new) return redirect(url_for("settings_view", saved=1)) # Never send secret values to the template — only whether each one is set. safe = {k: ("" if k in SECRET_KEYS else v) for k, v in current.items()} secrets_status = {k: bool(current.get(k)) for k in SECRET_KEYS} return render_template("settings.html", settings=safe, secrets_status=secrets_status, saved=request.args.get("saved")) @app.route("/changelog") def changelog_view(): return render_template("changelog.html", changelog=CHANGELOG, app_version=APP_VERSION) if __name__ == "__main__": app.run(debug=os.environ.get("FLASK_DEBUG") == "1", port=5000)