2862 lines
103 KiB
Python
2862 lines
103 KiB
Python
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 <strong>text</strong>."""
|
||
result = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', 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/<txn_id>", 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/<int:year>/<int:month>")
|
||
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/<bill_id>/<field>", 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/<bill_id>", 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/<bill_id>", 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/<int:year>/<int:month>")
|
||
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/<int:year>/<int:month>")
|
||
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/<debt_id>", 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/<debt_id>", 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/<debt_id>", 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/<debt_id>", 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/<debt_id>", 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/<car_id>", 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/<car_id>", 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/<flip_id>", 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/<asset_id>", 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/<asset_id>", 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/<flip_id>", 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/<flip_id>", 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/<flip_id>", 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/<entry_id>", 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/<acc_id>", 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/<acc_id>", 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/<int:year>/<int: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/<int:year>/<int:month>")
|
||
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/<entry_id>", 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)
|