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 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 = "fullmoonbakehouse"
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():
if os.path.exists(TAX_FILE):
with open(TAX_FILE) as f:
return json.load(f)
return {"rates": {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}, "set_aside": []}
def save_taxes(data):
with open(TAX_FILE, "w") as f:
json.dump(data, f, indent=2)
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():
transactions = get_transactions()
count = sum(1 for t in transactions if not t.get("reviewed"))
return dict(unreviewed_count=count)
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")
def load_nw_accounts():
if os.path.exists(NET_WORTH_FILE):
with open(NET_WORTH_FILE) as f:
return json.load(f)
return []
def save_nw_accounts(accounts):
with open(NET_WORTH_FILE, "w") as f:
json.dump(accounts, f, indent=2)
def load_json(path, default):
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return default
def save_json(path, data):
with open(path, "w") as f:
json.dump(data, f, indent=2)
def get_transactions():
return load_json(TRANSACTIONS_FILE, [])
def save_transactions(transactions):
save_json(TRANSACTIONS_FILE, 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))
if __name__ == "__main__":
app.run(debug=True, port=5000)