Initial commit — Moon Household Budget
This commit is contained in:
29
.claude/settings.local.json
Normal file
29
.claude/settings.local.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pip3 install *)",
|
||||||
|
"Bash(python -c \"from app import app; print\\('OK'\\)\")",
|
||||||
|
"Bash(python3 -c ' *)",
|
||||||
|
"Bash(launchctl load *)",
|
||||||
|
"Bash(sed -i '' 's/total_spending + total_bills | money/\\(total_spending + total_bills\\) | money/g' index.html)",
|
||||||
|
"Bash(sed -i '' 's/a\\\\.balance or 0 | money/\\(a.balance or 0\\) | money/g' index.html net_worth.html)",
|
||||||
|
"Bash(sed -i '' 's/f\\\\.balance or 0 | money/\\(f.balance or 0\\) | money/g' weekly.html sinking.html net_worth.html edit.html review.html)",
|
||||||
|
"Bash(sed -i '' 's/d\\\\.minimum or 0 | money/\\(d.minimum or 0\\) | money/g' weekly.html)",
|
||||||
|
"Bash(sed -i '' 's/base_payment \\\\/ 2 | money/\\(base_payment \\\\/ 2\\) | money/g' mortgage.html)",
|
||||||
|
"Bash(sed -i '' 's/by_stream\\\\.get\\(s\\\\.name, 0 | money\\)/by_stream.get\\(s.name, 0\\) | money/g' income.html)",
|
||||||
|
"Bash(sed -i '' 's/ytd_by_stream\\\\.get\\(s\\\\.name, 0 | money\\)/ytd_by_stream.get\\(s.name, 0\\) | money/g' income.html)",
|
||||||
|
"Bash(sed -i '' 's/ytd_total \\\\/ months_with_income if months_with_income else 0 | money/\\(ytd_total \\\\/ months_with_income if months_with_income else 0\\) | money/g' income.html)",
|
||||||
|
"Bash(sed -i '' 's/actual - budgeted | money/\\(actual - budgeted\\) | money/g' weekly.html)",
|
||||||
|
"Bash(sed -i '' 's/amt - budgeted | money/\\(amt - budgeted\\) | money/g' transactions.html)",
|
||||||
|
"Bash(sed -i '' 's/base_payment + extra_monthly | money/\\(base_payment + extra_monthly\\) | money/g' mortgage.html)",
|
||||||
|
"Bash(sed -i '' 's/total_spending + total_bills | money/\\(total_spending + total_bills\\) | money/g' month.html)",
|
||||||
|
"Bash(find /Users/bonnamoon/Projects/fullmoonbakehouse -name \"*moon*handoff*\" -o -name \"*handoff*\" 2>/dev/null; find /Users/bonnamoon -maxdepth 3 -name \"*handoff*\" 2>/dev/null)",
|
||||||
|
"Bash(curl -fsS -o /dev/null -w \"Gitea reachable: %{http_code}\\\\n\" http://192.168.0.5:3022/)",
|
||||||
|
"Bash(ssh -o ConnectTimeout=5 root@192.168.0.5 'echo NAS reachable && docker --version')",
|
||||||
|
"Bash(cat >> *)",
|
||||||
|
"Bash(grep -v \"^$\")",
|
||||||
|
"Bash(git init *)",
|
||||||
|
"Bash(git add *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
data/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
bonnasclaude.md
|
||||||
86
assets.py
Normal file
86
assets.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||||
|
ASSETS_FILE = os.path.join(DATA_DIR, "assets.json")
|
||||||
|
|
||||||
|
DEFAULT_CARS = [
|
||||||
|
{
|
||||||
|
"id": "kia-forte",
|
||||||
|
"type": "personal",
|
||||||
|
"year": 2012,
|
||||||
|
"make": "Kia",
|
||||||
|
"model": "Forte 5 SX",
|
||||||
|
"trim": "2.4L",
|
||||||
|
"color": "",
|
||||||
|
"owner": "Bonna",
|
||||||
|
"purchase_price": 0,
|
||||||
|
"purchase_date": "",
|
||||||
|
"estimated_value": 0,
|
||||||
|
"notes": "",
|
||||||
|
"gas_log": [],
|
||||||
|
"maintenance_log": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "silverado",
|
||||||
|
"type": "personal",
|
||||||
|
"year": 2009,
|
||||||
|
"make": "Chevy",
|
||||||
|
"model": "Silverado LTZ",
|
||||||
|
"trim": "5.3L",
|
||||||
|
"color": "",
|
||||||
|
"owner": "Tony",
|
||||||
|
"purchase_price": 0,
|
||||||
|
"purchase_date": "",
|
||||||
|
"estimated_value": 0,
|
||||||
|
"notes": "",
|
||||||
|
"gas_log": [],
|
||||||
|
"maintenance_log": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
ASSET_CATEGORIES = ["Investments", "Real Estate", "Equipment", "Other Valuables"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_assets():
|
||||||
|
if os.path.exists(ASSETS_FILE):
|
||||||
|
with open(ASSETS_FILE) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
# migrate old format if needed
|
||||||
|
if "other_assets" not in data:
|
||||||
|
data["other_assets"] = []
|
||||||
|
return data
|
||||||
|
save_assets({"personal": DEFAULT_CARS, "flips": [], "other_assets": []})
|
||||||
|
return {"personal": DEFAULT_CARS, "flips": [], "other_assets": []}
|
||||||
|
|
||||||
|
|
||||||
|
def save_assets(data):
|
||||||
|
with open(ASSETS_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def car_display_name(car):
|
||||||
|
return f"{car['year']} {car['make']} {car['model']}"
|
||||||
|
|
||||||
|
|
||||||
|
def total_spent(car):
|
||||||
|
gas = sum(float(e.get("amount", 0)) for e in car.get("gas_log", []))
|
||||||
|
maintenance = sum(float(e.get("amount", 0)) for e in car.get("maintenance_log", []))
|
||||||
|
return round(gas + maintenance, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def flip_profit(flip):
|
||||||
|
invested = float(flip.get("purchase_price", 0))
|
||||||
|
repairs = sum(float(e.get("amount", 0)) for e in flip.get("expense_log", []))
|
||||||
|
sale = float(flip.get("sale_price", 0))
|
||||||
|
total_in = invested + repairs
|
||||||
|
if sale > 0:
|
||||||
|
return round(sale - total_in, 2)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def flip_total_invested(flip):
|
||||||
|
invested = float(flip.get("purchase_price", 0))
|
||||||
|
repairs = sum(float(e.get("amount", 0)) for e in flip.get("expense_log", []))
|
||||||
|
return round(invested + repairs, 2)
|
||||||
82
bills.py
Normal file
82
bills.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import calendar
|
||||||
|
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||||
|
BILLS_FILE = os.path.join(DATA_DIR, "bills.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_bills():
|
||||||
|
if os.path.exists(BILLS_FILE):
|
||||||
|
with open(BILLS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_bills(bills):
|
||||||
|
with open(BILLS_FILE, "w") as f:
|
||||||
|
json.dump(bills, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def get_week_label(day):
|
||||||
|
if day <= 7:
|
||||||
|
return "Week 1 (1–7)"
|
||||||
|
elif day <= 14:
|
||||||
|
return "Week 2 (8–14)"
|
||||||
|
elif day <= 21:
|
||||||
|
return "Week 3 (15–21)"
|
||||||
|
else:
|
||||||
|
return "Week 4 (22–end)"
|
||||||
|
|
||||||
|
|
||||||
|
def get_week_num(day):
|
||||||
|
if day <= 7:
|
||||||
|
return 1
|
||||||
|
elif day <= 14:
|
||||||
|
return 2
|
||||||
|
elif day <= 21:
|
||||||
|
return 3
|
||||||
|
else:
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
def bills_for_month(year, month):
|
||||||
|
from datetime import datetime
|
||||||
|
all_bills = load_bills()
|
||||||
|
_, num_days = calendar.monthrange(year, month)
|
||||||
|
result = []
|
||||||
|
for b in all_bills:
|
||||||
|
if not b.get("active", True):
|
||||||
|
continue
|
||||||
|
# Specific-date bills (BNPL payments)
|
||||||
|
if b.get("due_date_str"):
|
||||||
|
try:
|
||||||
|
d = datetime.strptime(b["due_date_str"], "%Y-%m-%d").date()
|
||||||
|
if d.year == year and d.month == month:
|
||||||
|
result.append({**b, "due_date": d, "due_day": d.day})
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Recurring monthly bills
|
||||||
|
elif b.get("due_day"):
|
||||||
|
due_day = b["due_day"]
|
||||||
|
if 1 <= due_day <= num_days:
|
||||||
|
result.append({**b, "due_date": date(year, month, due_day)})
|
||||||
|
return sorted(result, key=lambda x: x.get("due_day", 0))
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_grid(year, month):
|
||||||
|
_, num_days = calendar.monthrange(year, month)
|
||||||
|
first_weekday = calendar.monthrange(year, month)[0] # 0=Mon
|
||||||
|
# Convert to Sun-first
|
||||||
|
first_weekday = (first_weekday + 1) % 7
|
||||||
|
|
||||||
|
days = []
|
||||||
|
for _ in range(first_weekday):
|
||||||
|
days.append(None)
|
||||||
|
for d in range(1, num_days + 1):
|
||||||
|
days.append(d)
|
||||||
|
while len(days) % 7 != 0:
|
||||||
|
days.append(None)
|
||||||
|
|
||||||
|
return [days[i:i+7] for i in range(0, len(days), 7)]
|
||||||
37
categories.py
Normal file
37
categories.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
CATEGORIES = {
|
||||||
|
"Food": ["Groceries", "Restaurants & Takeout", "Coffee Shops"],
|
||||||
|
"Home": ["Supplies", "Tools", "Kitchen Tools", "Furnishings", "Art & Decor", "Garden & Landscaping"],
|
||||||
|
"Pets": [],
|
||||||
|
"Health": ["Health Insurance", "Medical Payment Plans", "OTC & Prescriptions"],
|
||||||
|
"Self Care": ["Skincare", "Makeup & Beauty", "Hygiene"],
|
||||||
|
"Car": ["Gas", "Maintenance & Repairs"],
|
||||||
|
"Shopping": [],
|
||||||
|
"Fun": [],
|
||||||
|
"Utilities": [],
|
||||||
|
"Subscriptions": [],
|
||||||
|
"Business": ["Studio", "Bakery", "Car Flipping"],
|
||||||
|
"Travel": [],
|
||||||
|
"Misc": ["Gifts", "Donations", "Fees & Fines", "Personal Allowance", "Other"],
|
||||||
|
"Debt": ["Credit Cards", "BNPL", "Loans & Payment Plans", "Medical Debt"],
|
||||||
|
"Income": ["Auto King", "Studio", "Bakery", "Misc"],
|
||||||
|
"Transfer": ["Savings", "Investment", "Sinking Fund"],
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORY_COLORS = {
|
||||||
|
"Food": "#8FA899", # slate sage
|
||||||
|
"Home": "#A8906E", # warm clay
|
||||||
|
"Pets": "#7A9BAD", # muted denim
|
||||||
|
"Health": "#9A8AAA", # dusty plum
|
||||||
|
"Self Care": "#AA8A90", # faded mauve
|
||||||
|
"Car": "#8A9878", # muted olive
|
||||||
|
"Shopping": "#A89A6A", # greyed gold
|
||||||
|
"Fun": "#A87860", # muted burnt sienna
|
||||||
|
"Utilities": "#6E88A0", # slate blue
|
||||||
|
"Subscriptions": "#7A70A0", # greyed indigo
|
||||||
|
"Business": "#6A9898", # desaturated teal
|
||||||
|
"Travel": "#907860", # warm umber
|
||||||
|
"Misc": "#909090", # cool grey
|
||||||
|
"Debt": "#A87878", # muted rose red
|
||||||
|
"Income": "#7A9E78", # desaturated green
|
||||||
|
"Transfer": "#8890A8", # blue grey
|
||||||
|
}
|
||||||
194
debts.py
Normal file
194
debts.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
import math
|
||||||
|
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||||
|
DEBTS_FILE = os.path.join(DATA_DIR, "debts.json")
|
||||||
|
DEBT_LOG_FILE = os.path.join(DATA_DIR, "debt_log.json")
|
||||||
|
|
||||||
|
DEBT_CATEGORIES = ["Credit Cards", "BNPL", "Medical Payment Plans"]
|
||||||
|
|
||||||
|
BNPL_FREQUENCIES = {
|
||||||
|
"biweekly": 14,
|
||||||
|
"monthly": 30,
|
||||||
|
"4-payment": 14,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_payoff(debts, extra_monthly, method="avalanche"):
|
||||||
|
import copy
|
||||||
|
from datetime import date
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
# Only credit cards and medical debts with a balance
|
||||||
|
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]
|
||||||
|
if not active:
|
||||||
|
return [], 0, 0, 0
|
||||||
|
|
||||||
|
# Build working copies
|
||||||
|
working = []
|
||||||
|
for d in active:
|
||||||
|
working.append({
|
||||||
|
"id": d["id"],
|
||||||
|
"name": d["name"],
|
||||||
|
"owner": d.get("owner", ""),
|
||||||
|
"balance": float(d.get("current_balance", 0)),
|
||||||
|
"apr": float(d.get("apr", 0)),
|
||||||
|
"minimum": float(d.get("minimum", 0)),
|
||||||
|
"paid_off_month": None,
|
||||||
|
"total_interest": 0,
|
||||||
|
"total_paid": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
total_minimum = sum(w["minimum"] for w in working)
|
||||||
|
extra = max(extra_monthly - total_minimum, 0)
|
||||||
|
|
||||||
|
current_month = date.today().replace(day=1)
|
||||||
|
total_interest = 0
|
||||||
|
months = 0
|
||||||
|
max_months = 600
|
||||||
|
|
||||||
|
while any(w["balance"] > 0 for w in working) and months < max_months:
|
||||||
|
months += 1
|
||||||
|
current_month_label = current_month.strftime("%b %Y")
|
||||||
|
|
||||||
|
# Sort target debt
|
||||||
|
unpaid = [w for w in working if w["balance"] > 0]
|
||||||
|
if method == "avalanche":
|
||||||
|
unpaid.sort(key=lambda x: -x["apr"])
|
||||||
|
else:
|
||||||
|
unpaid.sort(key=lambda x: x["balance"])
|
||||||
|
|
||||||
|
# Apply interest and minimums
|
||||||
|
for w in unpaid:
|
||||||
|
if w["balance"] <= 0:
|
||||||
|
continue
|
||||||
|
monthly_rate = (w["apr"] / 100) / 12
|
||||||
|
interest = round(w["balance"] * monthly_rate, 2)
|
||||||
|
w["balance"] = round(w["balance"] + interest, 2)
|
||||||
|
w["total_interest"] += interest
|
||||||
|
total_interest += interest
|
||||||
|
payment = min(w["minimum"], w["balance"])
|
||||||
|
w["balance"] = round(w["balance"] - payment, 2)
|
||||||
|
w["total_paid"] += payment
|
||||||
|
|
||||||
|
# Apply extra to target
|
||||||
|
remaining_extra = extra
|
||||||
|
for w in unpaid:
|
||||||
|
if w["balance"] <= 0:
|
||||||
|
continue
|
||||||
|
extra_payment = min(remaining_extra, w["balance"])
|
||||||
|
w["balance"] = round(w["balance"] - extra_payment, 2)
|
||||||
|
w["total_paid"] += extra_payment
|
||||||
|
remaining_extra -= extra_payment
|
||||||
|
if remaining_extra <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Roll over freed minimums as extra for next debt
|
||||||
|
for w in working:
|
||||||
|
if w["balance"] <= 0 and w["paid_off_month"] is None:
|
||||||
|
w["paid_off_month"] = current_month_label
|
||||||
|
extra += w["minimum"]
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
current_month = (current_month.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||||
|
|
||||||
|
return working, round(total_interest, 2), months, round(total_minimum + extra_monthly, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def bnpl_payoff_date(payments_made, total_payments, frequency_days):
|
||||||
|
from datetime import date, timedelta
|
||||||
|
today = date.today()
|
||||||
|
remaining = total_payments - payments_made
|
||||||
|
if remaining <= 0:
|
||||||
|
return None, 0
|
||||||
|
days = remaining * frequency_days
|
||||||
|
payoff = today + timedelta(days=days)
|
||||||
|
return payoff.strftime("%b %Y"), remaining
|
||||||
|
|
||||||
|
OWNER_COLORS = {
|
||||||
|
"Bonna": "#D4B8C5",
|
||||||
|
"Tony": "#A0B8C5",
|
||||||
|
"Both": "#B8A8D4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_debts():
|
||||||
|
if os.path.exists(DEBTS_FILE):
|
||||||
|
with open(DEBTS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_debts(debts):
|
||||||
|
with open(DEBTS_FILE, "w") as f:
|
||||||
|
json.dump(debts, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def load_debt_log():
|
||||||
|
if os.path.exists(DEBT_LOG_FILE):
|
||||||
|
with open(DEBT_LOG_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_debt_log(log):
|
||||||
|
with open(DEBT_LOG_FILE, "w") as f:
|
||||||
|
json.dump(log, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def month_key(year, month):
|
||||||
|
return f"{year}-{month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def monthly_interest(balance, apr):
|
||||||
|
if not apr or apr <= 0:
|
||||||
|
return 0
|
||||||
|
return round(balance * (apr / 100) / 12, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def payoff_months(balance, apr, monthly_payment, monthly_charges=0):
|
||||||
|
if monthly_payment <= 0:
|
||||||
|
return None
|
||||||
|
if apr and apr > 0:
|
||||||
|
monthly_rate = (apr / 100) / 12
|
||||||
|
else:
|
||||||
|
monthly_rate = 0
|
||||||
|
|
||||||
|
b = balance
|
||||||
|
months = 0
|
||||||
|
while b > 0 and months < 600:
|
||||||
|
b = b + (b * monthly_rate) + monthly_charges - monthly_payment
|
||||||
|
months += 1
|
||||||
|
if monthly_charges == 0 and b >= balance:
|
||||||
|
return None
|
||||||
|
return months if months < 600 else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_debt_month(debt_id, year, month):
|
||||||
|
log = load_debt_log()
|
||||||
|
mk = month_key(year, month)
|
||||||
|
return log.get(mk, {}).get(debt_id, {})
|
||||||
|
|
||||||
|
|
||||||
|
def update_debt_month(debt_id, year, month, data):
|
||||||
|
log = load_debt_log()
|
||||||
|
mk = month_key(year, month)
|
||||||
|
log.setdefault(mk, {})[debt_id] = data
|
||||||
|
save_debt_log(log)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_ending_balance(debt, year, month):
|
||||||
|
mk = month_key(year, month)
|
||||||
|
log = load_debt_log()
|
||||||
|
entry = log.get(mk, {}).get(debt["id"], {})
|
||||||
|
|
||||||
|
starting = entry.get("starting_balance", debt.get("current_balance", 0))
|
||||||
|
interest = entry.get("interest") if entry.get("interest") is not None else monthly_interest(starting, debt.get("apr", 0))
|
||||||
|
new_charges = entry.get("new_charges", 0)
|
||||||
|
payment = entry.get("payment", 0)
|
||||||
|
|
||||||
|
ending = round(float(starting) + float(interest) + float(new_charges) - float(payment), 2)
|
||||||
|
return max(ending, 0)
|
||||||
82
income.py
Normal file
82
income.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||||
|
INCOME_FILE = os.path.join(DATA_DIR, "income_log.json")
|
||||||
|
|
||||||
|
INCOME_STREAMS = [
|
||||||
|
{"name": "Auto King", "owner": "Tony", "type": "paycheck"},
|
||||||
|
{"name": "Studio", "owner": "Bonna", "type": "business"},
|
||||||
|
{"name": "Bakery", "owner": "Bonna", "type": "business"},
|
||||||
|
{"name": "Car Flipping","owner": "Tony", "type": "business"},
|
||||||
|
{"name": "Misc", "owner": "Both", "type": "misc"},
|
||||||
|
]
|
||||||
|
|
||||||
|
OWNER_COLORS = {
|
||||||
|
"Bonna": "#D4B8C5",
|
||||||
|
"Tony": "#A0B8C5",
|
||||||
|
"Both": "#B8A8D4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_income_log():
|
||||||
|
if os.path.exists(INCOME_FILE):
|
||||||
|
with open(INCOME_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_income_log(log):
|
||||||
|
with open(INCOME_FILE, "w") as f:
|
||||||
|
json.dump(log, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def month_key(year, month):
|
||||||
|
return f"{year}-{month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def income_by_month(year, month):
|
||||||
|
log = load_income_log()
|
||||||
|
mk = month_key(year, month)
|
||||||
|
entries = [e for e in log if e.get("month") == mk]
|
||||||
|
by_stream = {}
|
||||||
|
for e in entries:
|
||||||
|
s = e["stream"]
|
||||||
|
by_stream[s] = by_stream.get(s, 0) + float(e.get("amount", 0))
|
||||||
|
return by_stream, entries
|
||||||
|
|
||||||
|
|
||||||
|
def income_ytd(year):
|
||||||
|
log = load_income_log()
|
||||||
|
by_stream = {}
|
||||||
|
by_month = {}
|
||||||
|
for e in log:
|
||||||
|
mk = e.get("month", "")
|
||||||
|
if not mk.startswith(str(year)):
|
||||||
|
continue
|
||||||
|
s = e["stream"]
|
||||||
|
amt = float(e.get("amount", 0))
|
||||||
|
by_stream[s] = by_stream.get(s, 0) + amt
|
||||||
|
by_month[mk] = by_month.get(mk, 0) + amt
|
||||||
|
return by_stream, by_month
|
||||||
|
|
||||||
|
|
||||||
|
def log_income_entry(stream, amount, month_str, note="", entry_id=None):
|
||||||
|
import uuid
|
||||||
|
log = load_income_log()
|
||||||
|
log.append({
|
||||||
|
"id": entry_id or str(uuid.uuid4()),
|
||||||
|
"stream": stream,
|
||||||
|
"amount": float(amount),
|
||||||
|
"month": month_str,
|
||||||
|
"date": date.today().isoformat(),
|
||||||
|
"note": note,
|
||||||
|
})
|
||||||
|
save_income_log(log)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_income_entry(entry_id):
|
||||||
|
log = load_income_log()
|
||||||
|
log = [e for e in log if e.get("id") != entry_id]
|
||||||
|
save_income_log(log)
|
||||||
13
moon-household-budget-handoff/backup/Dockerfile
Normal file
13
moon-household-budget-handoff/backup/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Tiny rclone + sqlite sidecar that backs up /data to Backblaze B2 on a loop.
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache rclone sqlite tzdata
|
||||||
|
|
||||||
|
COPY backup.sh /backup.sh
|
||||||
|
RUN chmod +x /backup.sh
|
||||||
|
|
||||||
|
ENV BACKUP_INTERVAL=86400
|
||||||
|
|
||||||
|
# Run once on boot, then every BACKUP_INTERVAL seconds. A failed run is logged
|
||||||
|
# but does not kill the loop.
|
||||||
|
CMD ["sh", "-c", "while true; do /backup.sh || echo '[backup] FAILED'; sleep \"${BACKUP_INTERVAL}\"; done"]
|
||||||
34
moon-household-budget-handoff/backup/backup.sh
Normal file
34
moon-household-budget-handoff/backup/backup.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Snapshot the app's data to Backblaze B2 via rclone.
|
||||||
|
# - SQLite DB: a consistent .backup snapshot (safe while the app is running)
|
||||||
|
# - uploads: mirrored to B2
|
||||||
|
# rclone's B2 remote ("b2:") is configured entirely from RCLONE_CONFIG_B2_*
|
||||||
|
# env vars (set in .env / compose). Runs once; the container loops it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
DEST="b2:${B2_BUCKET}/moon-household-budget"
|
||||||
|
KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}"
|
||||||
|
|
||||||
|
echo "[backup ${STAMP}] starting"
|
||||||
|
|
||||||
|
# 1. SQLite snapshot (only if a db file is present)
|
||||||
|
if [ -f /data/app.db ]; then
|
||||||
|
sqlite3 /data/app.db ".backup '/tmp/app-${STAMP}.db'"
|
||||||
|
rclone copyto "/tmp/app-${STAMP}.db" "${DEST}/db/app-${STAMP}.db"
|
||||||
|
rm -f "/tmp/app-${STAMP}.db"
|
||||||
|
echo "[backup ${STAMP}] db snapshot uploaded"
|
||||||
|
else
|
||||||
|
echo "[backup ${STAMP}] no /data/app.db — skipping db snapshot"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Uploaded images (and anything else under /data except the live db)
|
||||||
|
if [ -d /data/uploads ]; then
|
||||||
|
rclone sync /data/uploads "${DEST}/uploads"
|
||||||
|
echo "[backup ${STAMP}] uploads synced"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Prune old db snapshots
|
||||||
|
rclone delete --min-age "${KEEP_DAYS}d" "${DEST}/db" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[backup ${STAMP}] done"
|
||||||
15
moon-household-budget-handoff/docker/.dockerignore
Normal file
15
moon-household-budget-handoff/docker/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
bonnasclaude.md
|
||||||
24
moon-household-budget-handoff/docker/.env.example
Normal file
24
moon-household-budget-handoff/docker/.env.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Copy to `.env` and fill in. NEVER commit the real .env (it's gitignored).
|
||||||
|
# Tony will hand over the two B2 keys and confirm the Gitea token.
|
||||||
|
|
||||||
|
# ---- App ----
|
||||||
|
PORT=8000
|
||||||
|
SECRET_KEY=__GENERATE_A_RANDOM_STRING__
|
||||||
|
# SQLite on the mounted volume — keeps the stack self-contained:
|
||||||
|
DATABASE_URL=sqlite:////data/app.db
|
||||||
|
# Public URL the app is reached at (used for absolute upload links):
|
||||||
|
APP_ORIGIN=http://192.168.0.5:8052
|
||||||
|
|
||||||
|
# ---- Suggestions → Gitea issues ----
|
||||||
|
GITEA_URL=http://192.168.0.5:3022
|
||||||
|
GITEA_TOKEN=__BONNA_GITEA_TOKEN__
|
||||||
|
GITEA_REPO=bonna61/moon-household-budget
|
||||||
|
|
||||||
|
# ---- Backblaze B2 backup (read by the rclone sidecar) ----
|
||||||
|
# rclone reads its remote config straight from these env vars:
|
||||||
|
RCLONE_CONFIG_B2_TYPE=b2
|
||||||
|
RCLONE_CONFIG_B2_ACCOUNT=__B2_KEY_ID__
|
||||||
|
RCLONE_CONFIG_B2_KEY=__B2_APPLICATION_KEY__
|
||||||
|
B2_BUCKET=moon-household-budget-backup
|
||||||
|
BACKUP_INTERVAL=86400 # seconds between backups (86400 = daily)
|
||||||
|
BACKUP_KEEP_DAYS=30 # prune DB snapshots older than this
|
||||||
39
moon-household-budget-handoff/docker/Dockerfile
Normal file
39
moon-household-budget-handoff/docker/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Default image for a pip-based Python web app (Flask via gunicorn).
|
||||||
|
# ADJUST the CMD at the bottom to match the app's entry point.
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
# sqlite3 CLI is used by the backup snapshot; curl for healthchecks.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends sqlite3 curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt && pip install gunicorn requests
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Persistent data lives on a mounted volume, not in the image.
|
||||||
|
RUN mkdir -p /data/uploads/suggestions
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
|
# ---- ADJUST THIS LINE to the app's real entry point ----
|
||||||
|
# Flask (app object named `app` in app.py):
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]
|
||||||
|
#
|
||||||
|
# FastAPI (app object named `app` in main.py) — swap the CMD for:
|
||||||
|
# RUN pip install "uvicorn[standard]"
|
||||||
|
# CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
#
|
||||||
|
# Django (project package `mysite`):
|
||||||
|
# CMD ["gunicorn", "--bind", "0.0.0.0:8000", "mysite.wsgi:application"]
|
||||||
|
#
|
||||||
|
# Streamlit:
|
||||||
|
# CMD ["streamlit", "run", "app.py", "--server.port=8000", "--server.address=0.0.0.0"]
|
||||||
32
moon-household-budget-handoff/docker/docker-compose.yml
Normal file
32
moon-household-budget-handoff/docker/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Moon Household Budget — self-contained stack.
|
||||||
|
# app : the Python web app (SQLite on the ./data volume — no DB container)
|
||||||
|
# backup : rclone sidecar that snapshots ./data to Backblaze B2 on a loop
|
||||||
|
#
|
||||||
|
# Deployed on the Unraid NAS under /mnt/user/appdata/moon-household-budget/.
|
||||||
|
# Bring up with: docker compose up -d --build
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
container_name: moon-household-budget
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8052:8000" # http://192.168.0.5:8052
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data # SQLite db + uploaded images live here
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:8000/", "||", "exit", "1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
backup:
|
||||||
|
build:
|
||||||
|
context: ../backup
|
||||||
|
container_name: moon-budget-backup
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data:ro # read-only — backup never writes app data
|
||||||
19
moon-household-budget-handoff/docker/entrypoint.sh
Normal file
19
moon-household-budget-handoff/docker/entrypoint.sh
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs before the app starts. Ensures the data dir exists and runs DB
|
||||||
|
# migrations if the app uses them. Uncomment the lines that apply.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
mkdir -p /data/uploads/suggestions
|
||||||
|
|
||||||
|
# --- Database migrations (pick what the app uses, leave the rest commented) ---
|
||||||
|
# Flask-Migrate / Alembic via Flask:
|
||||||
|
# flask db upgrade
|
||||||
|
# Raw Alembic:
|
||||||
|
# alembic upgrade head
|
||||||
|
# Django:
|
||||||
|
# python manage.py migrate --noinput
|
||||||
|
# python manage.py collectstatic --noinput
|
||||||
|
# Plain SQLite with a schema file (only first run):
|
||||||
|
# [ -f /data/app.db ] || sqlite3 /data/app.db < schema.sql
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
29
moon-household-budget-handoff/gitignore.example
Normal file
29
moon-household-budget-handoff/gitignore.example
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Append these to the project's .gitignore (create one if missing).
|
||||||
|
# CRITICAL: secrets and the live database must never be committed.
|
||||||
|
|
||||||
|
# Secrets / env
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Local database + uploads (live data lives on the NAS volume)
|
||||||
|
data/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# This handoff instruction file — don't ship it into the repo
|
||||||
|
bonnasclaude.md
|
||||||
116
moon-household-budget-handoff/suggestions/gitea.py
Normal file
116
moon-household-budget-handoff/suggestions/gitea.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Gitea-backed suggestions module — framework-agnostic core.
|
||||||
|
|
||||||
|
Files user-submitted suggestions as issues in a Gitea repo, mirroring the
|
||||||
|
pattern used by Tony's `dms` and `fullmoonbakehouse` apps. No database of its
|
||||||
|
own — Gitea issues ARE the storage.
|
||||||
|
|
||||||
|
Configure via environment variables:
|
||||||
|
GITEA_URL e.g. http://192.168.0.5:3022
|
||||||
|
GITEA_TOKEN a Gitea access token with repo issue read/write scope
|
||||||
|
GITEA_REPO "owner/repo", e.g. bonna61/moon-household-budget
|
||||||
|
|
||||||
|
If any are unset, gitea_config() returns None and callers should degrade
|
||||||
|
gracefully (show "feedback isn't set up yet" instead of crashing).
|
||||||
|
|
||||||
|
Depends only on `requests` (add it to requirements.txt if not already there).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_TYPES = {
|
||||||
|
"image/jpeg", "image/png", "image/webp",
|
||||||
|
"image/gif", "image/heic", "image/heif",
|
||||||
|
}
|
||||||
|
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GiteaConfig:
|
||||||
|
base_url: str
|
||||||
|
token: str
|
||||||
|
repo: str # "owner/repo"
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_config() -> Optional[GiteaConfig]:
|
||||||
|
base = os.environ.get("GITEA_URL")
|
||||||
|
token = os.environ.get("GITEA_TOKEN")
|
||||||
|
repo = os.environ.get("GITEA_REPO")
|
||||||
|
if not (base and token and repo):
|
||||||
|
return None
|
||||||
|
return GiteaConfig(base.rstrip("/"), token, repo)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClient:
|
||||||
|
def __init__(self, cfg: GiteaConfig):
|
||||||
|
self.cfg = cfg
|
||||||
|
|
||||||
|
def _url(self, path: str) -> str:
|
||||||
|
return f"{self.cfg.base_url}/api/v1/repos/{self.cfg.repo}{path}"
|
||||||
|
|
||||||
|
def _req(self, method: str, path: str, **kw):
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"token {self.cfg.token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
r = requests.request(method, self._url(path), headers=headers, timeout=15, **kw)
|
||||||
|
if not r.ok:
|
||||||
|
raise GiteaError(f"Gitea {r.status_code}: {r.text[:300]}")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def list_issues(self, state: str = "open"):
|
||||||
|
return self._req("GET", f"/issues?type=issues&state={state}&limit=50")
|
||||||
|
|
||||||
|
def create_issue(self, title: str, body: str):
|
||||||
|
return self._req("POST", "/issues", json={"title": title, "body": body})
|
||||||
|
|
||||||
|
def list_comments(self, number: int):
|
||||||
|
return self._req("GET", f"/issues/{number}/comments")
|
||||||
|
|
||||||
|
def create_comment(self, number: int, body: str):
|
||||||
|
return self._req("POST", f"/issues/{number}/comments", json={"body": body})
|
||||||
|
|
||||||
|
|
||||||
|
def sniff_image(buf: bytes) -> Optional[str]:
|
||||||
|
"""Return a MIME type by inspecting magic bytes, or None if unrecognized."""
|
||||||
|
if buf[:3] == b"\xff\xd8\xff":
|
||||||
|
return "image/jpeg"
|
||||||
|
if buf[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
return "image/png"
|
||||||
|
if buf[:4] == b"GIF8":
|
||||||
|
return "image/gif"
|
||||||
|
if buf[:4] == b"RIFF" and buf[8:12] == b"WEBP":
|
||||||
|
return "image/webp"
|
||||||
|
if buf[4:8] == b"ftyp":
|
||||||
|
brand = buf[8:12].decode("ascii", "ignore")
|
||||||
|
if brand in ("heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs"):
|
||||||
|
return "image/heic"
|
||||||
|
if brand in ("mif1", "msf1"):
|
||||||
|
return "image/heif"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_upload(filename: str, data: bytes, upload_dir: Path, origin: str) -> str:
|
||||||
|
"""Persist an upload and return an absolute URL for embedding in markdown."""
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ext = Path(filename).suffix.lower() or ".bin"
|
||||||
|
safe = f"{int(time.time() * 1000)}-{secrets.token_urlsafe(8)}{ext}"
|
||||||
|
(upload_dir / safe).write_bytes(data)
|
||||||
|
return f"{origin.rstrip('/')}/uploads/suggestions/{safe}"
|
||||||
|
|
||||||
|
|
||||||
|
def footer(name: str, email: str, kind: str = "user") -> str:
|
||||||
|
"""Attribution footer appended to a filed issue body (dms/fmb style)."""
|
||||||
|
return f"\n\n---\n_Filed by {name} ({email}) · {kind}_"
|
||||||
121
moon-household-budget-handoff/suggestions/routes_fastapi.py
Normal file
121
moon-household-budget-handoff/suggestions/routes_fastapi.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
FastAPI adapter for the Gitea-backed suggestions module.
|
||||||
|
|
||||||
|
Use this INSTEAD of routes_flask.py if the app is FastAPI. Wire it in with:
|
||||||
|
|
||||||
|
from suggestions.routes_fastapi import router as suggestions_router
|
||||||
|
app.include_router(suggestions_router)
|
||||||
|
|
||||||
|
AUTH: replace `current_user()` with the app's real dependency. It must return
|
||||||
|
something exposing `.name` and `.email`, or None.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Request, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .gitea import (
|
||||||
|
ALLOWED_IMAGE_TYPES,
|
||||||
|
MAX_UPLOAD_BYTES,
|
||||||
|
GiteaClient,
|
||||||
|
GiteaError,
|
||||||
|
footer,
|
||||||
|
gitea_config,
|
||||||
|
save_upload,
|
||||||
|
sniff_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
UPLOAD_DIR = Path("/data/uploads/suggestions")
|
||||||
|
|
||||||
|
|
||||||
|
def current_user():
|
||||||
|
"""ADAPT THIS to the app's real auth. Return obj with .name/.email or None."""
|
||||||
|
return type("U", (), {"name": "Household", "email": "household@local"})()
|
||||||
|
|
||||||
|
|
||||||
|
def _client_or_error():
|
||||||
|
cfg = gitea_config()
|
||||||
|
if not cfg:
|
||||||
|
return None, JSONResponse({"error": "not_configured"}, status_code=503)
|
||||||
|
return GiteaClient(cfg), None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/suggestions")
|
||||||
|
def list_suggestions(state: str = "open"):
|
||||||
|
if current_user() is None:
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
client, err = _client_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
return {"issues": client.list_issues(state)}
|
||||||
|
except GiteaError as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/suggestions")
|
||||||
|
async def create_suggestion(request: Request):
|
||||||
|
user = current_user()
|
||||||
|
if user is None:
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
client, err = _client_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
data = await request.json()
|
||||||
|
title = (data.get("title") or "").strip()
|
||||||
|
if len(title) < 3:
|
||||||
|
return JSONResponse({"error": "title_required"}, status_code=400)
|
||||||
|
body = (data.get("body") or "").strip() + footer(user.name, user.email)
|
||||||
|
try:
|
||||||
|
return JSONResponse({"issue": client.create_issue(title, body)}, status_code=201)
|
||||||
|
except GiteaError as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/suggestions/{number}/comments")
|
||||||
|
def list_comments(number: int):
|
||||||
|
if current_user() is None:
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
client, err = _client_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
return {"comments": client.list_comments(number)}
|
||||||
|
except GiteaError as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/suggestions/{number}/comments")
|
||||||
|
async def add_comment(number: int, request: Request):
|
||||||
|
user = current_user()
|
||||||
|
if user is None:
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
client, err = _client_or_error()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
data = await request.json()
|
||||||
|
text = (data.get("body") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return JSONResponse({"error": "body_required"}, status_code=400)
|
||||||
|
text += f"\n\n_— {user.name}_"
|
||||||
|
try:
|
||||||
|
return JSONResponse({"comment": client.create_comment(number, text)}, status_code=201)
|
||||||
|
except GiteaError as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/suggestions/upload")
|
||||||
|
async def upload(request: Request, file: UploadFile = File(...)):
|
||||||
|
if current_user() is None:
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) > MAX_UPLOAD_BYTES:
|
||||||
|
return JSONResponse({"error": "too_large"}, status_code=413)
|
||||||
|
if sniff_image(data) not in ALLOWED_IMAGE_TYPES:
|
||||||
|
return JSONResponse({"error": "unsupported_type"}, status_code=415)
|
||||||
|
origin = str(request.base_url)
|
||||||
|
url = save_upload(file.filename or "image", data, UPLOAD_DIR, origin)
|
||||||
|
return JSONResponse({"url": url}, status_code=201)
|
||||||
146
moon-household-budget-handoff/suggestions/routes_flask.py
Normal file
146
moon-household-budget-handoff/suggestions/routes_flask.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
Flask adapter for the Gitea-backed suggestions module.
|
||||||
|
|
||||||
|
Wire it into the app with:
|
||||||
|
|
||||||
|
from suggestions.routes_flask import suggestions_bp
|
||||||
|
app.register_blueprint(suggestions_bp)
|
||||||
|
|
||||||
|
Then a logged-in user can open /suggestions to file feedback. The blueprint
|
||||||
|
exposes both the HTML page and the JSON API the page's JS talks to.
|
||||||
|
|
||||||
|
AUTH: replace `current_user()` below with however this app identifies the
|
||||||
|
logged-in person. It must return an object with `.name` and `.email`, or None.
|
||||||
|
If the app has no auth at all, you can return a fixed identity (see the stub).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, render_template, request
|
||||||
|
|
||||||
|
from .gitea import (
|
||||||
|
ALLOWED_IMAGE_TYPES,
|
||||||
|
MAX_UPLOAD_BYTES,
|
||||||
|
GiteaClient,
|
||||||
|
GiteaError,
|
||||||
|
footer,
|
||||||
|
gitea_config,
|
||||||
|
save_upload,
|
||||||
|
sniff_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
suggestions_bp = Blueprint(
|
||||||
|
"suggestions",
|
||||||
|
__name__,
|
||||||
|
template_folder="templates",
|
||||||
|
static_folder="static",
|
||||||
|
static_url_path="/suggestions-static",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Where uploaded images land. Lives under the mounted /data volume so backups
|
||||||
|
# and restarts keep them. Served at /uploads/suggestions/<name> (see below).
|
||||||
|
UPLOAD_DIR = Path("/data/uploads/suggestions")
|
||||||
|
|
||||||
|
|
||||||
|
def current_user():
|
||||||
|
"""Resolve the logged-in user. ADAPT THIS to the app's real auth.
|
||||||
|
|
||||||
|
Must return an object exposing `.name` and `.email`, or None if anonymous.
|
||||||
|
"""
|
||||||
|
# --- Example: Flask-Login ---
|
||||||
|
# from flask_login import current_user as u
|
||||||
|
# return u if getattr(u, "is_authenticated", False) else None
|
||||||
|
#
|
||||||
|
# --- Example: session dict ---
|
||||||
|
# from flask import session
|
||||||
|
# if "user_email" in session:
|
||||||
|
# return type("U", (), {"name": session.get("user_name", "User"),
|
||||||
|
# "email": session["user_email"]})()
|
||||||
|
# return None
|
||||||
|
#
|
||||||
|
# --- Stub: single-household app with no real auth ---
|
||||||
|
return type("U", (), {"name": "Household", "email": "household@local"})()
|
||||||
|
|
||||||
|
|
||||||
|
def _guard():
|
||||||
|
return current_user() is not None
|
||||||
|
|
||||||
|
|
||||||
|
@suggestions_bp.route("/suggestions")
|
||||||
|
def suggestions_page():
|
||||||
|
return render_template("suggestions.html", configured=gitea_config() is not None)
|
||||||
|
|
||||||
|
|
||||||
|
@suggestions_bp.route("/api/suggestions", methods=["GET", "POST"])
|
||||||
|
def suggestions_api():
|
||||||
|
if not _guard():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
cfg = gitea_config()
|
||||||
|
if not cfg:
|
||||||
|
return jsonify({"error": "not_configured"}), 503
|
||||||
|
client = GiteaClient(cfg)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
state = request.args.get("state", "open")
|
||||||
|
try:
|
||||||
|
return jsonify({"issues": client.list_issues(state)})
|
||||||
|
except GiteaError as e:
|
||||||
|
return jsonify({"error": str(e)}), 502
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
title = (data.get("title") or "").strip()
|
||||||
|
if len(title) < 3:
|
||||||
|
return jsonify({"error": "title_required"}), 400
|
||||||
|
body = (data.get("body") or "").strip()
|
||||||
|
user = current_user()
|
||||||
|
if user is not None:
|
||||||
|
body += footer(user.name, user.email)
|
||||||
|
try:
|
||||||
|
return jsonify({"issue": client.create_issue(title, body)}), 201
|
||||||
|
except GiteaError as e:
|
||||||
|
return jsonify({"error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@suggestions_bp.route("/api/suggestions/<int:number>/comments", methods=["GET", "POST"])
|
||||||
|
def suggestion_comments(number: int):
|
||||||
|
if not _guard():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
cfg = gitea_config()
|
||||||
|
if not cfg:
|
||||||
|
return jsonify({"error": "not_configured"}), 503
|
||||||
|
client = GiteaClient(cfg)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
try:
|
||||||
|
return jsonify({"comments": client.list_comments(number)})
|
||||||
|
except GiteaError as e:
|
||||||
|
return jsonify({"error": str(e)}), 502
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
text = (data.get("body") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return jsonify({"error": "body_required"}), 400
|
||||||
|
user = current_user()
|
||||||
|
if user is not None:
|
||||||
|
text += f"\n\n_— {user.name}_"
|
||||||
|
try:
|
||||||
|
return jsonify({"comment": client.create_comment(number, text)}), 201
|
||||||
|
except GiteaError as e:
|
||||||
|
return jsonify({"error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@suggestions_bp.route("/api/suggestions/upload", methods=["POST"])
|
||||||
|
def suggestions_upload():
|
||||||
|
if not _guard():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
if "file" not in request.files:
|
||||||
|
return jsonify({"error": "file_required"}), 400
|
||||||
|
f = request.files["file"]
|
||||||
|
data = f.read(MAX_UPLOAD_BYTES + 1)
|
||||||
|
if len(data) > MAX_UPLOAD_BYTES:
|
||||||
|
return jsonify({"error": "too_large"}), 413
|
||||||
|
if sniff_image(data) not in ALLOWED_IMAGE_TYPES:
|
||||||
|
return jsonify({"error": "unsupported_type"}), 415
|
||||||
|
url = save_upload(f.filename or "image", data, UPLOAD_DIR, request.host_url)
|
||||||
|
return jsonify({"url": url}), 201
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Minimal vanilla-JS client for the suggestions page. Talks to the JSON API
|
||||||
|
// exposed by routes_flask.py / routes_fastapi.py. No build step, no framework.
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
async function loadIssues() {
|
||||||
|
const list = $("s-list");
|
||||||
|
if (!list) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/suggestions?state=open");
|
||||||
|
const data = await res.json();
|
||||||
|
const issues = data.issues || [];
|
||||||
|
if (!issues.length) {
|
||||||
|
list.innerHTML = '<p class="muted">No open suggestions yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = issues.map((i) => `
|
||||||
|
<div class="card">
|
||||||
|
<div class="issue-title">#${i.number} ${escapeHtml(i.title)}</div>
|
||||||
|
<div class="issue-meta">${i.comments} comment(s) ·
|
||||||
|
<a href="${i.html_url}" target="_blank" rel="noopener">view on Gitea</a></div>
|
||||||
|
</div>`).join("");
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = '<p class="muted">Could not load suggestions.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return (s || "").replace(/[&<>"']/g, (c) => (
|
||||||
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(file) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/suggestions/upload", { method: "POST", body: fd });
|
||||||
|
if (!res.ok) throw new Error("upload failed");
|
||||||
|
return (await res.json()).url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireComposer() {
|
||||||
|
const send = $("s-send");
|
||||||
|
const body = $("s-body");
|
||||||
|
if (!send || !body) return;
|
||||||
|
|
||||||
|
// Paste-to-attach: drop an image embed into the body.
|
||||||
|
body.addEventListener("paste", async (ev) => {
|
||||||
|
const item = [...(ev.clipboardData?.items || [])].find((i) => i.type.startsWith("image/"));
|
||||||
|
if (!item) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
$("s-status").textContent = "Uploading image…";
|
||||||
|
try {
|
||||||
|
const url = await uploadImage(item.getAsFile());
|
||||||
|
body.value += `\n\n`;
|
||||||
|
$("s-status").textContent = "Image attached.";
|
||||||
|
} catch {
|
||||||
|
$("s-status").textContent = "Image upload failed.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
send.addEventListener("click", async () => {
|
||||||
|
const title = $("s-title").value.trim();
|
||||||
|
if (title.length < 3) { $("s-status").textContent = "Add a longer title."; return; }
|
||||||
|
send.disabled = true;
|
||||||
|
$("s-status").textContent = "Sending…";
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/suggestions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, body: body.value }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
$("s-title").value = "";
|
||||||
|
body.value = "";
|
||||||
|
$("s-status").textContent = "Thanks! Sent.";
|
||||||
|
loadIssues();
|
||||||
|
} catch {
|
||||||
|
$("s-status").textContent = "Could not send — try again.";
|
||||||
|
} finally {
|
||||||
|
send.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wireComposer();
|
||||||
|
loadIssues();
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Suggestions · Moon Household Budget</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1c1c1e; }
|
||||||
|
h1 { font-size: 1.4rem; }
|
||||||
|
.card { border: 1px solid #e3e3e6; border-radius: 12px; padding: 1rem; margin: .75rem 0; }
|
||||||
|
input, textarea { width: 100%; box-sizing: border-box; padding: .6rem; border-radius: 8px;
|
||||||
|
border: 1px solid #cfcfd4; font: inherit; margin-bottom: .5rem; }
|
||||||
|
textarea { min-height: 100px; resize: vertical; }
|
||||||
|
button { background: #4338ca; color: #fff; border: 0; border-radius: 8px;
|
||||||
|
padding: .6rem 1rem; font: inherit; cursor: pointer; }
|
||||||
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
|
.muted { color: #6b6b70; font-size: .85rem; }
|
||||||
|
.issue-title { font-weight: 600; }
|
||||||
|
.issue-meta { color: #6b6b70; font-size: .8rem; }
|
||||||
|
.notice { background: #fff8e1; border: 1px solid #f0e3a8; padding: .75rem; border-radius: 8px; }
|
||||||
|
a { color: #4338ca; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>💡 Suggestions & Feedback</h1>
|
||||||
|
<p class="muted">Ideas, bugs, “can it also do…?” — they go straight to Tony.</p>
|
||||||
|
|
||||||
|
{% if not configured %}
|
||||||
|
<div class="notice">Feedback isn’t set up yet (Gitea env vars missing). Once configured, this page goes live.</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<input id="s-title" placeholder="Short title (required)" />
|
||||||
|
<textarea id="s-body" placeholder="Details… paste a screenshot to attach it."></textarea>
|
||||||
|
<div style="display:flex; gap:.5rem; align-items:center;">
|
||||||
|
<button id="s-send">Send suggestion</button>
|
||||||
|
<span id="s-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 style="font-size:1.1rem;">Open suggestions</h2>
|
||||||
|
<div id="s-list"><p class="muted">Loading…</p></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('suggestions.static', filename='suggestions.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
124
mortgage.py
Normal file
124
mortgage.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
import math
|
||||||
|
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||||
|
MORTGAGE_FILE = os.path.join(DATA_DIR, "mortgage.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_mortgage():
|
||||||
|
if os.path.exists(MORTGAGE_FILE):
|
||||||
|
with open(MORTGAGE_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_mortgage(data):
|
||||||
|
with open(MORTGAGE_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def monthly_payment(principal, annual_rate, term_years):
|
||||||
|
if annual_rate == 0:
|
||||||
|
return round(principal / (term_years * 12), 2)
|
||||||
|
r = (annual_rate / 100) / 12
|
||||||
|
n = term_years * 12
|
||||||
|
payment = principal * (r * (1 + r) ** n) / ((1 + r) ** n - 1)
|
||||||
|
return round(payment, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def amortization_schedule(principal, annual_rate, term_years, start_date_str, extra_monthly=0):
|
||||||
|
r = (annual_rate / 100) / 12 if annual_rate > 0 else 0
|
||||||
|
n = term_years * 12
|
||||||
|
base_payment = monthly_payment(principal, annual_rate, term_years)
|
||||||
|
total_payment = base_payment + extra_monthly
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = date.fromisoformat(start_date_str)
|
||||||
|
except Exception:
|
||||||
|
current = date.today().replace(day=1)
|
||||||
|
|
||||||
|
schedule = []
|
||||||
|
balance = principal
|
||||||
|
total_interest = 0
|
||||||
|
total_principal = 0
|
||||||
|
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
if balance <= 0:
|
||||||
|
break
|
||||||
|
interest = round(balance * r, 2)
|
||||||
|
principal_paid = min(round(total_payment - interest, 2), balance)
|
||||||
|
if principal_paid < 0:
|
||||||
|
principal_paid = 0
|
||||||
|
balance = round(balance - principal_paid, 2)
|
||||||
|
total_interest += interest
|
||||||
|
total_principal += principal_paid
|
||||||
|
|
||||||
|
schedule.append({
|
||||||
|
"month": i,
|
||||||
|
"date": current.strftime("%b %Y"),
|
||||||
|
"payment": round(interest + principal_paid, 2),
|
||||||
|
"principal": principal_paid,
|
||||||
|
"interest": interest,
|
||||||
|
"balance": max(balance, 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
if balance <= 0:
|
||||||
|
break
|
||||||
|
current = current + relativedelta(months=1)
|
||||||
|
|
||||||
|
return schedule, round(total_interest, 2), len(schedule)
|
||||||
|
|
||||||
|
|
||||||
|
def biweekly_schedule(principal, annual_rate, term_years, start_date_str):
|
||||||
|
base = monthly_payment(principal, annual_rate, term_years)
|
||||||
|
biweekly_payment = round(base / 2, 2)
|
||||||
|
r_biweekly = (annual_rate / 100) / 26 if annual_rate > 0 else 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = date.fromisoformat(start_date_str)
|
||||||
|
except Exception:
|
||||||
|
current = date.today().replace(day=1)
|
||||||
|
|
||||||
|
balance = principal
|
||||||
|
total_interest = 0
|
||||||
|
payments = 0
|
||||||
|
max_payments = term_years * 26 + 52
|
||||||
|
|
||||||
|
while balance > 0 and payments < max_payments:
|
||||||
|
interest = round(balance * r_biweekly, 2)
|
||||||
|
principal_paid = min(round(biweekly_payment - interest, 2), balance)
|
||||||
|
if principal_paid < 0:
|
||||||
|
principal_paid = 0
|
||||||
|
balance = round(balance - principal_paid, 2)
|
||||||
|
total_interest += interest
|
||||||
|
payments += 1
|
||||||
|
if balance <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
years = payments / 26
|
||||||
|
months_total = round(payments / 26 * 12)
|
||||||
|
return round(total_interest, 2), payments, round(years, 1), months_total
|
||||||
|
|
||||||
|
|
||||||
|
def current_balance_estimate(principal, annual_rate, term_years, start_date_str, extra_monthly=0):
|
||||||
|
schedule, _, _ = amortization_schedule(
|
||||||
|
principal, annual_rate, term_years, start_date_str, extra_monthly)
|
||||||
|
today = date.today()
|
||||||
|
for row in schedule:
|
||||||
|
try:
|
||||||
|
row_date = date.strptime(row["date"], "%b %Y") if False else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Count months elapsed
|
||||||
|
try:
|
||||||
|
start = date.fromisoformat(start_date_str)
|
||||||
|
months_elapsed = (today.year - start.year) * 12 + (today.month - start.month)
|
||||||
|
months_elapsed = max(0, months_elapsed)
|
||||||
|
if months_elapsed < len(schedule):
|
||||||
|
return schedule[months_elapsed]["balance"]
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
return principal
|
||||||
125
reminders.py
Normal file
125
reminders.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import smtplib
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import calendar as cal_module
|
||||||
|
from bills import load_bills
|
||||||
|
|
||||||
|
EMAIL = "bonna61@gmail.com"
|
||||||
|
APP_PASSWORD = "qowu zcff dbtk kqbs"
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(subject, body_html, body_text):
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = f"Moon Household Budget <{EMAIL}>"
|
||||||
|
msg["To"] = EMAIL
|
||||||
|
msg.attach(MIMEText(body_text, "plain"))
|
||||||
|
msg.attach(MIMEText(body_html, "html"))
|
||||||
|
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
|
||||||
|
server.login(EMAIL, APP_PASSWORD)
|
||||||
|
server.sendmail(EMAIL, EMAIL, msg.as_string())
|
||||||
|
|
||||||
|
|
||||||
|
def mac_notification(title, message):
|
||||||
|
script = f'display notification "{message}" with title "{title}" sound name "Default"'
|
||||||
|
subprocess.run(["osascript", "-e", script])
|
||||||
|
|
||||||
|
|
||||||
|
def bills_due_in(days):
|
||||||
|
today = date.today()
|
||||||
|
target = today + timedelta(days=days)
|
||||||
|
all_bills = load_bills()
|
||||||
|
due = []
|
||||||
|
for b in all_bills:
|
||||||
|
if not b.get("active", True):
|
||||||
|
continue
|
||||||
|
if b.get("autopay", False):
|
||||||
|
continue
|
||||||
|
if not b.get("remind", True):
|
||||||
|
continue
|
||||||
|
due_day = b.get("due_day")
|
||||||
|
if not due_day:
|
||||||
|
continue
|
||||||
|
_, num_days = cal_module.monthrange(target.year, target.month)
|
||||||
|
due_day_clamped = min(due_day, num_days)
|
||||||
|
bill_date = date(target.year, target.month, due_day_clamped)
|
||||||
|
if bill_date == target:
|
||||||
|
due.append({**b, "due_date": bill_date})
|
||||||
|
return due
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_send_reminders():
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
for days_ahead, label in [(3, "in 3 days"), (0, "TODAY")]:
|
||||||
|
due = bills_due_in(days_ahead)
|
||||||
|
if not due:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total = sum(b.get("amount", 0) for b in due if b.get("amount"))
|
||||||
|
|
||||||
|
# Mac notifications — one per bill
|
||||||
|
for b in due:
|
||||||
|
amt = f"${b['amount']:.2f}" if b.get("amount") else "amount varies"
|
||||||
|
mac_notification(
|
||||||
|
f"Bill Due {label.title()}",
|
||||||
|
f"{b['name']} — {amt}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Email — one digest
|
||||||
|
subject = f"🌙 Moon Budget: {len(due)} bill{'s' if len(due) != 1 else ''} due {label}"
|
||||||
|
|
||||||
|
rows = ""
|
||||||
|
for b in due:
|
||||||
|
amt = f"${b['amount']:.2f}" if b.get("amount") else "—"
|
||||||
|
cat = b.get("category", "")
|
||||||
|
rows += f"""
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 12px;border-bottom:1px solid #EDE8E2;">{b['name']}</td>
|
||||||
|
<td style="padding:8px 12px;border-bottom:1px solid #EDE8E2;font-weight:600;">{amt}</td>
|
||||||
|
<td style="padding:8px 12px;border-bottom:1px solid #EDE8E2;color:#7A6E68;">{cat}</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
total_row = f"${total:.2f}" if total else ""
|
||||||
|
|
||||||
|
body_html = f"""
|
||||||
|
<div style="font-family:'Helvetica Neue',Arial,sans-serif;max-width:520px;margin:0 auto;background:#FAF7F2;padding:24px;border-radius:12px;">
|
||||||
|
<h2 style="font-size:22px;color:#3A3330;margin-bottom:4px;">🌙 Moon Household Budget</h2>
|
||||||
|
<p style="color:#7A6E68;margin-bottom:20px;font-size:14px;">Bill reminder — due <strong>{label}</strong></p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(80,60,50,0.08);">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#F5F1EC;">
|
||||||
|
<th style="text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#B0A89E;">Bill</th>
|
||||||
|
<th style="text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#B0A89E;">Amount</th>
|
||||||
|
<th style="text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#B0A89E;">Category</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{rows}</tbody>
|
||||||
|
{f'<tfoot><tr><td style="padding:8px 12px;font-weight:700;" colspan="2">Total: {total_row}</td><td></td></tr></tfoot>' if total_row else ''}
|
||||||
|
</table>
|
||||||
|
<p style="color:#B0A89E;font-size:12px;margin-top:20px;text-align:center;">Moon Household Budget · sent to bonna61@gmail.com</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
body_text = f"Moon Budget — Bills due {label}:\n\n"
|
||||||
|
for b in due:
|
||||||
|
amt = f"${b['amount']:.2f}" if b.get("amount") else "varies"
|
||||||
|
body_text += f" • {b['name']} — {amt}\n"
|
||||||
|
if total:
|
||||||
|
body_text += f"\nTotal: ${total:.2f}\n"
|
||||||
|
|
||||||
|
send_email(subject, body_html, body_text)
|
||||||
|
sent.append(f"{len(due)} bills due {label}")
|
||||||
|
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
results = check_and_send_reminders()
|
||||||
|
if results:
|
||||||
|
print("Sent reminders:", ", ".join(results))
|
||||||
|
else:
|
||||||
|
print("No reminders to send today.")
|
||||||
56
sinking.py
Normal file
56
sinking.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||||
|
SINKING_FILE = os.path.join(DATA_DIR, "sinking_funds.json")
|
||||||
|
|
||||||
|
FUND_COLORS = {
|
||||||
|
"Travel": "#C5BBA0",
|
||||||
|
"Vet & Pets": "#A0B8C5",
|
||||||
|
"Holidays & Gifts": "#D4B8C5",
|
||||||
|
"Renovations": "#C5A8A0",
|
||||||
|
"Studio Build": "#B4A0C5",
|
||||||
|
"Emergency Fund": "#A8C5A0",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_FUNDS = [
|
||||||
|
{"name": "Travel", "type": "ongoing", "monthly": 0, "balance": 0, "notes": ""},
|
||||||
|
{"name": "Vet & Pets", "type": "ongoing", "monthly": 0, "balance": 0, "notes": ""},
|
||||||
|
{"name": "Holidays & Gifts", "type": "ongoing", "monthly": 0, "balance": 0, "notes": ""},
|
||||||
|
{"name": "Renovations", "type": "ongoing", "monthly": 0, "balance": 0, "notes": "Subcategories coming later"},
|
||||||
|
{"name": "Studio Build", "type": "ongoing", "monthly": 0, "balance": 0, "notes": "Goal amount TBD"},
|
||||||
|
{"name": "Emergency Fund", "type": "ongoing", "monthly": 0, "balance": 0, "notes": "Target: 3–6 months expenses"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_funds():
|
||||||
|
if os.path.exists(SINKING_FILE):
|
||||||
|
with open(SINKING_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
# First run — seed with defaults
|
||||||
|
save_funds(DEFAULT_FUNDS)
|
||||||
|
return DEFAULT_FUNDS
|
||||||
|
|
||||||
|
|
||||||
|
def save_funds(funds):
|
||||||
|
with open(SINKING_FILE, "w") as f:
|
||||||
|
json.dump(funds, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def months_until(target_date_str):
|
||||||
|
try:
|
||||||
|
target = date.fromisoformat(target_date_str)
|
||||||
|
today = date.today()
|
||||||
|
months = (target.year - today.year) * 12 + (target.month - today.month)
|
||||||
|
return max(months, 1)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def monthly_needed(balance, goal, target_date_str):
|
||||||
|
m = months_until(target_date_str)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
remaining = max(goal - balance, 0)
|
||||||
|
return round(remaining / m, 2)
|
||||||
1737
static/style.css
Normal file
1737
static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
486
templates/assets.html
Normal file
486
templates/assets.html
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Assets</h1>
|
||||||
|
<input type="text" id="asset-search" placeholder="Search assets…" class="budget-input" style="width:220px;" oninput="filterAssets(this.value)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Net worth summary -->
|
||||||
|
{% if net_worth_assets %}
|
||||||
|
<div class="sinking-total-bar" style="margin-bottom:1.75rem;">
|
||||||
|
<div>
|
||||||
|
<div class="sinking-total-label">Total Tracked Asset Value</div>
|
||||||
|
<div class="sinking-total-amount">${{ "{:,.2f}".format(net_worth_assets) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sinking-total-note">Based on estimated / current values entered below</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Add Asset button -->
|
||||||
|
<details class="add-debt-details" style="margin-bottom:2rem;">
|
||||||
|
<summary class="btn btn-primary" style="display:inline-block;cursor:pointer;">+ Add Asset</summary>
|
||||||
|
<div class="card-wide" style="margin-top:0.75rem;">
|
||||||
|
<h3 style="margin-bottom:1rem;font-family:'DM Serif Display',serif;font-weight:400;">What type of asset?</h3>
|
||||||
|
<div class="asset-type-grid">
|
||||||
|
|
||||||
|
<!-- Vehicle -->
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="asset-type-btn">🚗 Vehicle</summary>
|
||||||
|
<form method="POST" action="{{ url_for('vehicle_add') }}" style="margin-top:1rem;">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group" style="grid-column:1/-1;">
|
||||||
|
<label>Vehicle Type</label>
|
||||||
|
<div style="display:flex;gap:1rem;margin-top:0.25rem;">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:400;cursor:pointer;">
|
||||||
|
<input type="radio" name="vehicle_type" value="personal" checked> Personal / Daily Driver
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:400;cursor:pointer;">
|
||||||
|
<input type="radio" name="vehicle_type" value="flip"> Flip Car
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Year</label><input type="number" name="year" placeholder="e.g. 2020" required></div>
|
||||||
|
<div class="form-group"><label>Make</label><input type="text" name="make" placeholder="e.g. Toyota" required></div>
|
||||||
|
<div class="form-group"><label>Model</label><input type="text" name="model" placeholder="e.g. Camry" required></div>
|
||||||
|
<div class="form-group"><label>Trim</label><input type="text" name="trim" placeholder="e.g. LE, 2.5L"></div>
|
||||||
|
<div class="form-group"><label>Color</label><input type="text" name="color"></div>
|
||||||
|
<div class="form-group"><label>Owner</label>
|
||||||
|
<select name="owner">
|
||||||
|
<option value="Bonna">Bonna</option>
|
||||||
|
<option value="Tony">Tony</option>
|
||||||
|
<option value="Both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||||
|
<div class="form-group"><label>Estimated Current Value</label><input type="number" name="estimated_value" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Vehicle</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Investments -->
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="asset-type-btn">📈 Investments</summary>
|
||||||
|
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
|
||||||
|
<input type="hidden" name="category" value="Investments">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group"><label>Account / Asset Name</label><input type="text" name="name" placeholder="e.g. Roth IRA, 401k, Brokerage" required></div>
|
||||||
|
<div class="form-group"><label>Owner</label><select name="owner"><option value="Bonna">Bonna</option><option value="Tony">Tony</option><option value="Both">Both</option></select></div>
|
||||||
|
<div class="form-group"><label>Date Opened</label><input type="date" name="purchase_date"></div>
|
||||||
|
<div class="form-group"><label>Current Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="e.g. Fidelity, employer match, contribution limit..."></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Investment</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Real Estate -->
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="asset-type-btn">🏠 Real Estate</summary>
|
||||||
|
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
|
||||||
|
<input type="hidden" name="category" value="Real Estate">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group"><label>Description</label><input type="text" name="name" placeholder="e.g. Primary Home, Rental Property" required></div>
|
||||||
|
<div class="form-group"><label>Owner</label><select name="owner"><option value="Both">Both</option><option value="Bonna">Bonna</option><option value="Tony">Tony</option></select></div>
|
||||||
|
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||||
|
<div class="form-group"><label>Current Estimated Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Address, details..."></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Real Estate</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Equipment -->
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="asset-type-btn">🔧 Equipment</summary>
|
||||||
|
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
|
||||||
|
<input type="hidden" name="category" value="Equipment">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group"><label>Item Name</label><input type="text" name="name" placeholder="e.g. Studio Camera, Stand Mixer" required></div>
|
||||||
|
<div class="form-group"><label>Owner</label><select name="owner"><option value="Bonna">Bonna</option><option value="Tony">Tony</option><option value="Both">Both</option></select></div>
|
||||||
|
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||||
|
<div class="form-group"><label>Current Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Serial #, warranty..."></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Equipment</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Other Valuables -->
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="asset-type-btn">✨ Other Valuables</summary>
|
||||||
|
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
|
||||||
|
<input type="hidden" name="category" value="Other Valuables">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group"><label>Item Name</label><input type="text" name="name" placeholder="e.g. Engagement Ring, Art" required></div>
|
||||||
|
<div class="form-group"><label>Owner</label><select name="owner"><option value="Bonna">Bonna</option><option value="Tony">Tony</option><option value="Both">Both</option></select></div>
|
||||||
|
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||||
|
<div class="form-group"><label>Current / Appraised Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Item</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Vehicles -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Vehicles</h2>
|
||||||
|
|
||||||
|
<h3 class="asset-subheading">Personal</h3>
|
||||||
|
<div class="asset-grid">
|
||||||
|
{% for car in personal %}
|
||||||
|
<div class="asset-card">
|
||||||
|
<div class="asset-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="asset-car-name">{{ car.display_name }}</div>
|
||||||
|
<div class="asset-car-trim muted">{{ car.trim }}{% if car.color %} · {{ car.color }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<span class="owner-tag" style="background:{{ owner_colors.get(car.owner, '#ddd') }};">{{ car.owner }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-stat-row">
|
||||||
|
<div class="asset-stat"><div class="asset-stat-label">Total Spent</div><div class="asset-stat-value">${{ car.total_spent | money }}</div></div>
|
||||||
|
<div class="asset-stat"><div class="asset-stat-label">Est. Value</div><div class="asset-stat-value">{% if car.estimated_value %}${{ "{:,.0f}".format(car.estimated_value) }}{% else %}<span class="muted">—</span>{% endif %}</div></div>
|
||||||
|
<div class="asset-stat"><div class="asset-stat-label">Gas Logs</div><div class="asset-stat-value">{{ car.gas_log | length }}</div></div>
|
||||||
|
<div class="asset-stat"><div class="asset-stat-label">Service Logs</div><div class="asset-stat-value">{{ car.maintenance_log | length }}</div></div>
|
||||||
|
</div>
|
||||||
|
<details class="asset-details"><summary class="asset-details-toggle">+ Log Gas Fill-up</summary>
|
||||||
|
<form method="POST" action="{{ url_for('personal_car_log', car_id=car.id) }}" class="asset-log-form">
|
||||||
|
<input type="hidden" name="log_type" value="gas">
|
||||||
|
<div class="asset-log-grid">
|
||||||
|
<div class="form-group"><label>Date</label><input type="date" name="date" required></div>
|
||||||
|
<div class="form-group"><label>Amount Paid</label><input type="number" name="amount" step="0.01" placeholder="0.00" required></div>
|
||||||
|
<div class="form-group"><label>Gallons <span class="optional">(optional)</span></label><input type="number" name="gallons" step="0.001" placeholder="0.000"></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Station, grade..."></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-accept">Save</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<details class="asset-details"><summary class="asset-details-toggle">+ Log Maintenance / Repair</summary>
|
||||||
|
<form method="POST" action="{{ url_for('personal_car_log', car_id=car.id) }}" class="asset-log-form">
|
||||||
|
<input type="hidden" name="log_type" value="maintenance">
|
||||||
|
<div class="asset-log-grid">
|
||||||
|
<div class="form-group"><label>Date</label><input type="date" name="date" required></div>
|
||||||
|
<div class="form-group"><label>Amount</label><input type="number" name="amount" step="0.01" placeholder="0.00" required></div>
|
||||||
|
<div class="form-group"><label>Description</label><input type="text" name="description" placeholder="e.g. Oil change" required></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Shop, details..."></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-accept">Save</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% set recent_gas = car.gas_log[-3:] | reverse | list %}
|
||||||
|
{% set recent_maint = car.maintenance_log[-3:] | reverse | list %}
|
||||||
|
{% if recent_gas or recent_maint %}
|
||||||
|
<details class="asset-details"><summary class="asset-details-toggle">Recent Activity</summary>
|
||||||
|
{% if recent_gas %}<div class="asset-log-section-label">Gas</div>{% for e in recent_gas %}<div class="asset-log-entry"><span class="muted">{{ e.date }}</span><span>${{ e.amount | money }}</span><span class="muted">{% if e.gallons %}{{ e.gallons }} gal{% endif %} {{ e.notes }}</span></div>{% endfor %}{% endif %}
|
||||||
|
{% if recent_maint %}<div class="asset-log-section-label" style="margin-top:0.5rem;">Maintenance</div>{% for e in recent_maint %}<div class="asset-log-entry"><span class="muted">{{ e.date }}</span><span>${{ e.amount | money }}</span><span class="muted">{{ e.get('description','') }} {{ e.notes }}</span></div>{% endfor %}{% endif %}
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
<details class="asset-details"><summary class="asset-details-toggle">Edit Details</summary>
|
||||||
|
<form method="POST" action="{{ url_for('personal_car_update', car_id=car.id) }}" class="asset-log-form">
|
||||||
|
<div class="asset-log-grid">
|
||||||
|
<div class="form-group"><label>Color</label><input type="text" name="color" value="{{ car.color }}"></div>
|
||||||
|
<div class="form-group"><label>Owner</label><select name="owner">{% for o in ['Bonna','Tony','Both'] %}<option value="{{ o }}" {% if car.owner == o %}selected{% endif %}>{{ o }}</option>{% endfor %}</select></div>
|
||||||
|
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" value="{{ car.purchase_price or '' }}" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date" value="{{ car.purchase_date or '' }}"></div>
|
||||||
|
<div class="form-group"><label>Estimated Value (KBB)</label><input type="number" name="estimated_value" step="0.01" value="{{ car.estimated_value or '' }}" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes" value="{{ car.notes or '' }}"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if active_flips %}
|
||||||
|
<h3 class="asset-subheading" style="margin-top:1.5rem;">Flip Inventory</h3>
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="btn btn-powder-blue" style="display:inline-block;cursor:pointer;">+ Add Flip Car</summary>
|
||||||
|
<div class="card-wide" style="margin-top:0.75rem;">
|
||||||
|
<form method="POST" action="{{ url_for('flip_add') }}" class="bill-form">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group"><label>Year</label><input type="number" name="year" placeholder="e.g. 2018" required></div>
|
||||||
|
<div class="form-group"><label>Make</label><input type="text" name="make" required></div>
|
||||||
|
<div class="form-group"><label>Model</label><input type="text" name="model" required></div>
|
||||||
|
<div class="form-group"><label>Trim</label><input type="text" name="trim"></div>
|
||||||
|
<div class="form-group"><label>Color</label><input type="text" name="color"></div>
|
||||||
|
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00" required></div>
|
||||||
|
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||||
|
<div class="form-group"><label>Personal Funds Used</label><input type="number" name="personal_contribution" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-powder-blue">Add Flip Car</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="asset-grid">
|
||||||
|
{% for flip in active_flips %}
|
||||||
|
<div class="asset-card asset-flip-card">
|
||||||
|
<div class="asset-card-header">
|
||||||
|
<div><div class="asset-car-name">{{ flip.display_name }}</div><div class="asset-car-trim muted">{{ flip.trim }}{% if flip.color %} · {{ flip.color }}{% endif %}</div></div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<span class="flip-badge">Flip</span>
|
||||||
|
<button class="edit-link" style="background:none;border:none;cursor:pointer;font-size:0.78rem;"
|
||||||
|
onclick="openEditFlip('{{ flip.id }}','{{ flip.year }}','{{ flip.make }}','{{ flip.model }}','{{ flip.trim or '' }}','{{ flip.color or '' }}','{{ flip.purchase_price or 0 }}','{{ flip.purchase_date or '' }}','{{ flip.notes or '' }}')">
|
||||||
|
✏️ Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="asset-stat-row">
|
||||||
|
<div class="asset-stat"><div class="asset-stat-label">Purchased</div><div class="asset-stat-value">${{ "{:,.0f}".format(flip.purchase_price) }}</div></div>
|
||||||
|
<div class="asset-stat"><div class="asset-stat-label">Total In</div><div class="asset-stat-value">${{ "{:,.0f}".format(flip.total_invested) }}</div></div>
|
||||||
|
<div class="asset-stat"><div class="asset-stat-label">Expenses</div><div class="asset-stat-value">{{ flip.expense_log | length }}</div></div>
|
||||||
|
</div>
|
||||||
|
<details class="asset-details"><summary class="asset-details-toggle">+ Log Personal Expense</summary>
|
||||||
|
<form method="POST" action="{{ url_for('flip_expense', flip_id=flip.id) }}" class="asset-log-form">
|
||||||
|
<div class="asset-log-grid">
|
||||||
|
<div class="form-group"><label>Date</label><input type="date" name="date" required></div>
|
||||||
|
<div class="form-group"><label>Amount</label><input type="number" name="amount" step="0.01" placeholder="0.00" required></div>
|
||||||
|
<div class="form-group"><label>Description</label><input type="text" name="description" placeholder="Parts, paint..." required></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-accept">Save</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% if flip.expense_log %}<details class="asset-details"><summary class="asset-details-toggle">Expense Log</summary>{% for e in flip.expense_log | reverse %}<div class="asset-log-entry"><span class="muted">{{ e.date }}</span><span>${{ e.amount | money }}</span><span class="muted">{{ e.description }}</span></div>{% endfor %}</details>{% endif %}
|
||||||
|
<details class="asset-details"><summary class="asset-details-toggle" style="color:#7A9E78;">✓ Mark as Sold</summary>
|
||||||
|
<form method="POST" action="{{ url_for('flip_sell', flip_id=flip.id) }}" class="asset-log-form">
|
||||||
|
<div class="asset-log-grid">
|
||||||
|
<div class="form-group"><label>Sale Price</label><input type="number" name="sale_price" step="0.01" placeholder="0.00" required></div>
|
||||||
|
<div class="form-group"><label>Sale Date</label><input type="date" name="sold_date" required></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Mark Sold</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<form method="POST" action="{{ url_for('flip_delete', flip_id=flip.id) }}"
|
||||||
|
onsubmit="return confirm('Remove {{ flip.display_name }} from flips? This cannot be undone.');">
|
||||||
|
<button type="submit" class="debt-delete-btn">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<h3 class="asset-subheading" style="margin-top:1.5rem;">Flip Inventory</h3>
|
||||||
|
<details class="add-debt-details" style="margin-bottom:1rem;">
|
||||||
|
<summary class="btn btn-powder-blue" style="display:inline-block;cursor:pointer;">+ Add Flip Car</summary>
|
||||||
|
<div class="card-wide" style="margin-top:0.75rem;">
|
||||||
|
<form method="POST" action="{{ url_for('flip_add') }}" class="bill-form">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group"><label>Year</label><input type="number" name="year" placeholder="e.g. 2018" required></div>
|
||||||
|
<div class="form-group"><label>Make</label><input type="text" name="make" required></div>
|
||||||
|
<div class="form-group"><label>Model</label><input type="text" name="model" required></div>
|
||||||
|
<div class="form-group"><label>Trim</label><input type="text" name="trim"></div>
|
||||||
|
<div class="form-group"><label>Color</label><input type="text" name="color"></div>
|
||||||
|
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00" required></div>
|
||||||
|
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
|
||||||
|
<div class="form-group"><label>Personal Funds Used</label><input type="number" name="personal_contribution" step="0.01" placeholder="0.00"></div>
|
||||||
|
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-powder-blue">Add Flip Car</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<p class="muted">No active flips.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if sold_flips %}
|
||||||
|
<h3 class="asset-subheading" style="margin-top:1.5rem;">Sold Flips</h3>
|
||||||
|
<table class="txn-table txn-table-full">
|
||||||
|
<thead><tr><th>Car</th><th>Bought For</th><th>Total Invested</th><th>Sold For</th><th>Profit / Loss</th><th>Sold</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for flip in sold_flips %}
|
||||||
|
{% set profit = flip.profit %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ flip.display_name }}{% if flip.color %} <span class="muted">{{ flip.color }}</span>{% endif %}</td>
|
||||||
|
<td class="amount">${{ "{:,.2f}".format(flip.purchase_price) }}</td>
|
||||||
|
<td class="amount">${{ "{:,.2f}".format(flip.total_invested) }}</td>
|
||||||
|
<td class="amount">${{ "{:,.2f}".format(flip.sale_price) }}</td>
|
||||||
|
<td class="amount {% if profit and profit > 0 %}accent{% elif profit and profit < 0 %}text-red{% endif %}">{% if profit is not none %}{{ '+' if profit > 0 else '' }}${{ "{:,.2f}".format(profit) }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="muted">{{ flip.sold_date }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Investment tracking instructions -->
|
||||||
|
<div class="card-wide" style="margin-bottom:1.5rem;">
|
||||||
|
<h3 style="margin-bottom:0.5rem;">📈 How Investment Tracking Works</h3>
|
||||||
|
<p class="helper-text" style="margin-bottom:0.5rem;">Add each investment account (Roth IRA, 401k, brokerage, etc.) using the <strong>+ Add Asset</strong> button above and select <strong>Investments</strong>.</p>
|
||||||
|
<p class="helper-text" style="margin-bottom:0.5rem;">Once added, each card has an editable <strong>Current Value</strong> field. Update it whenever you check your account — once a month is a good habit. Every time you update the value, the app logs a snapshot with today's date.</p>
|
||||||
|
<p class="helper-text">After two or more updates on different days, a <strong>growth chart</strong> will appear on the card automatically, showing your balance over time in green (growth) or red (decline) with the total change displayed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Other asset categories -->
|
||||||
|
{% for cat in asset_categories %}
|
||||||
|
<section class="section">
|
||||||
|
<h2>{{ cat }}</h2>
|
||||||
|
{% set cat_assets = by_category[cat] %}
|
||||||
|
{% if cat_assets %}
|
||||||
|
<div class="asset-grid">
|
||||||
|
{% for a in cat_assets %}
|
||||||
|
<div class="asset-card">
|
||||||
|
<div class="asset-card-header">
|
||||||
|
<div><div class="asset-car-name">{{ a.name }}</div>{% if a.notes %}<div class="asset-car-trim muted">{{ a.notes }}</div>{% endif %}</div>
|
||||||
|
<span class="owner-tag" style="background:{{ owner_colors.get(a.owner, '#ddd') }};">{{ a.owner }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-stat-row">
|
||||||
|
<div class="asset-stat"><div class="asset-stat-label">Paid</div><div class="asset-stat-value">{% if a.purchase_price %}${{ "{:,.0f}".format(a.purchase_price) }}{% else %}<span class="muted">—</span>{% endif %}</div></div>
|
||||||
|
<div class="asset-stat">
|
||||||
|
<div class="asset-stat-label">Current Value</div>
|
||||||
|
<div class="asset-stat-value">
|
||||||
|
$<input type="number" step="0.01" min="0"
|
||||||
|
value="{{ a.value or 0 }}"
|
||||||
|
class="budget-input" style="width:90px;display:inline;"
|
||||||
|
onchange="updateAssetValue('{{ a.id }}', this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="asset-stat"><div class="asset-stat-label">Date</div><div class="asset-stat-value muted" style="font-size:0.78rem;">{{ a.purchase_date or '—' }}</div></div>
|
||||||
|
</div>
|
||||||
|
{% if a.value_history and a.value_history | length > 1 %}
|
||||||
|
<div style="margin:0.75rem 0 0.5rem;">
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-light);margin-bottom:0.25rem;">
|
||||||
|
Value History
|
||||||
|
{% set first = a.value_history[0].value | float %}
|
||||||
|
{% set last = a.value_history[-1].value | float %}
|
||||||
|
{% set change = last - first %}
|
||||||
|
{% if change >= 0 %}
|
||||||
|
<span style="color:#5A9E68;margin-left:0.4rem;">▲ ${{ change | money }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#c07070;margin-left:0.4rem;">▼ ${{ change | abs | money }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<canvas id="chart-{{ a.id }}" height="60"
|
||||||
|
data-labels="{{ a.value_history | map(attribute='date') | list | tojson }}"
|
||||||
|
data-values="{{ a.value_history | map(attribute='value') | list | tojson }}">
|
||||||
|
</canvas>
|
||||||
|
</div>
|
||||||
|
{% elif a.value_history and a.value_history | length == 1 %}
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-light);margin:0.5rem 0;">Update value again next month to start seeing growth chart.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('other_asset_delete', asset_id=a.id) }}"
|
||||||
|
onsubmit="return confirm('Remove {{ a.name }}?');">
|
||||||
|
<button type="submit" class="debt-delete-btn">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="margin-bottom:0.5rem;">No {{ cat | lower }} added yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Edit Flip Modal -->
|
||||||
|
<div id="edit-flip-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||||
|
<h3 style="margin:0 0 1rem;" id="edit-flip-title">Edit Flip</h3>
|
||||||
|
<form method="POST" id="edit-flip-form" action="">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
|
||||||
|
<div class="form-group"><label style="font-size:0.85rem;">Year</label><input type="number" name="year" id="ef-year" class="budget-input" style="width:100%;" required></div>
|
||||||
|
<div class="form-group"><label style="font-size:0.85rem;">Make</label><input type="text" name="make" id="ef-make" class="budget-input" style="width:100%;" required></div>
|
||||||
|
<div class="form-group"><label style="font-size:0.85rem;">Model</label><input type="text" name="model" id="ef-model" class="budget-input" style="width:100%;" required></div>
|
||||||
|
<div class="form-group"><label style="font-size:0.85rem;">Trim</label><input type="text" name="trim" id="ef-trim" class="budget-input" style="width:100%;"></div>
|
||||||
|
<div class="form-group"><label style="font-size:0.85rem;">Color</label><input type="text" name="color" id="ef-color" class="budget-input" style="width:100%;"></div>
|
||||||
|
<div class="form-group"><label style="font-size:0.85rem;">Purchase Price</label><input type="number" step="0.01" name="purchase_price" id="ef-price" class="budget-input" style="width:100%;"></div>
|
||||||
|
<div class="form-group"><label style="font-size:0.85rem;">Purchase Date</label><input type="date" name="purchase_date" id="ef-date" class="budget-input" style="width:100%;"></div>
|
||||||
|
<div class="form-group"><label style="font-size:0.85rem;">Notes</label><input type="text" name="notes" id="ef-notes" class="budget-input" style="width:100%;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1rem;">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="closeEditFlip()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateAssetValue(id, value) {
|
||||||
|
fetch('/assets/other/update/' + id, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({value: value})
|
||||||
|
}).then(() => location.reload());
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.querySelectorAll('canvas[id^="chart-"]').forEach(canvas => {
|
||||||
|
const labels = JSON.parse(canvas.dataset.labels);
|
||||||
|
const values = JSON.parse(canvas.dataset.values);
|
||||||
|
const color = values[values.length-1] >= values[0] ? '#5A9E68' : '#c07070';
|
||||||
|
new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color + '22',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointBackgroundColor: color,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: { legend: { display: false }, tooltip: {
|
||||||
|
callbacks: { label: ctx => '$' + ctx.parsed.y.toFixed(2) }
|
||||||
|
}},
|
||||||
|
scales: {
|
||||||
|
x: { display: false },
|
||||||
|
y: { display: false }
|
||||||
|
},
|
||||||
|
animation: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function openEditFlip(id, year, make, model, trim, color, price, purchaseDate, notes) {
|
||||||
|
document.getElementById('edit-flip-title').textContent = 'Edit — ' + year + ' ' + make + ' ' + model;
|
||||||
|
document.getElementById('edit-flip-form').action = '/assets/flip/edit/' + id;
|
||||||
|
document.getElementById('ef-year').value = year;
|
||||||
|
document.getElementById('ef-make').value = make;
|
||||||
|
document.getElementById('ef-model').value = model;
|
||||||
|
document.getElementById('ef-trim').value = trim;
|
||||||
|
document.getElementById('ef-color').value = color;
|
||||||
|
document.getElementById('ef-price').value = price != '0' ? price : '';
|
||||||
|
document.getElementById('ef-date').value = purchaseDate;
|
||||||
|
document.getElementById('ef-notes').value = notes;
|
||||||
|
document.getElementById('edit-flip-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditFlip() {
|
||||||
|
document.getElementById('edit-flip-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('edit-flip-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeEditFlip();
|
||||||
|
});
|
||||||
|
|
||||||
|
function filterAssets(q) {
|
||||||
|
q = q.toLowerCase();
|
||||||
|
document.querySelectorAll('.sinking-card, .debt-card, .asset-card').forEach(card => {
|
||||||
|
card.style.display = card.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||||
|
});
|
||||||
|
// Also filter table rows in the flips section
|
||||||
|
document.querySelectorAll('tbody tr').forEach(row => {
|
||||||
|
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
71
templates/base.html
Normal file
71
templates/base.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🌙 Moon Household Budget</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-brand">🌙 Moon Household Budget</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="{{ url_for('weekly_overview') }}">Weekly</a>
|
||||||
|
<a href="{{ url_for('index') }}">Overview</a>
|
||||||
|
<a href="{{ url_for('bill_calendar') }}">Calendar</a>
|
||||||
|
<a href="{{ url_for('transactions_view') }}">Transactions</a>
|
||||||
|
<a href="{{ url_for('income_view') }}">Income</a>
|
||||||
|
<a href="{{ url_for('taxes_view') }}">Taxes</a>
|
||||||
|
<a href="{{ url_for('bills_list') }}">Bills</a>
|
||||||
|
<a href="{{ url_for('sinking_view') }}">Savings</a>
|
||||||
|
<a href="{{ url_for('assets_view') }}">Assets</a>
|
||||||
|
<a href="{{ url_for('mortgage_view') }}">Mortgage</a>
|
||||||
|
<a href="{{ url_for('debts_view') }}">Debt</a>
|
||||||
|
<a href="{{ url_for('net_worth_view') }}">Net Worth</a>
|
||||||
|
<a href="{{ url_for('summary') }}">Spending Report</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{% if unreviewed_count > 0 %}
|
||||||
|
<div class="alert-banner" style="margin-bottom:1rem;">
|
||||||
|
<span>You have <strong>{{ unreviewed_count }}</strong> transaction{{ 's' if unreviewed_count != 1 }} waiting to be categorized.</span>
|
||||||
|
<a href="{{ url_for('review') }}" class="btn btn-primary">Review Now</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Wrap all number budget-inputs with a $ prefix and format on blur
|
||||||
|
document.querySelectorAll('input[type="number"].budget-input').forEach(function (input) {
|
||||||
|
// Skip if already wrapped or explicitly opted out
|
||||||
|
if (input.classList.contains('no-dollar') || input.parentElement.classList.contains('dollar-wrap')) return;
|
||||||
|
|
||||||
|
// Wrap in dollar-sign container
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'dollar-wrap';
|
||||||
|
wrap.style.width = input.style.width || '100%';
|
||||||
|
input.parentNode.insertBefore(wrap, input);
|
||||||
|
wrap.appendChild(input);
|
||||||
|
|
||||||
|
// Format existing value on load
|
||||||
|
if (input.value !== '' && !isNaN(parseFloat(input.value))) {
|
||||||
|
input.value = parseFloat(input.value).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format to 2 decimal places on blur
|
||||||
|
input.addEventListener('blur', function () {
|
||||||
|
if (this.value !== '' && !isNaN(parseFloat(this.value))) {
|
||||||
|
this.value = parseFloat(this.value).toFixed(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// On focus, select all for easy overtyping
|
||||||
|
input.addEventListener('focus', function () {
|
||||||
|
this.select();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
159
templates/bills.html
Normal file
159
templates/bills.html
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Manage Bills</h1>
|
||||||
|
<a href="{{ url_for('bill_calendar') }}" class="btn btn-ghost">View Calendar</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-wide">
|
||||||
|
<h2>Add a Bill</h2>
|
||||||
|
<form method="POST" action="{{ url_for('bill_add') }}" class="bill-form">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bill Name</label>
|
||||||
|
<input type="text" name="name" placeholder="e.g. Verizon, Affirm (home)" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Amount</label>
|
||||||
|
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Due Day of Month</label>
|
||||||
|
<input type="number" name="due_day" min="1" max="31" placeholder="1–31" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<select name="category" id="add-category" onchange="updateAddSubcats()">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat }}">{{ cat }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Subcategory <span class="optional">(optional)</span></label>
|
||||||
|
<select name="subcategory" id="add-subcategory">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notes <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" name="notes" placeholder="e.g. T=Theresa, B=both">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:0.5rem;">
|
||||||
|
<input type="checkbox" name="autopay" id="autopay-new">
|
||||||
|
<label for="autopay-new" style="margin:0;font-weight:400;">On autopay (no reminder needed)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Bill</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if bills %}
|
||||||
|
<section class="section">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:0.75rem;">
|
||||||
|
<h2>Your Bills <span class="muted">({{ bills | length }} total)</span></h2>
|
||||||
|
<input type="text" id="bills-search" placeholder="Search bills…" class="budget-input" style="width:220px;" oninput="filterBills(this.value)">
|
||||||
|
</div>
|
||||||
|
<table class="txn-table txn-table-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Due Day</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Autopay</th>
|
||||||
|
<th>Remind Me</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in bills | sort(attribute='due_day') %}
|
||||||
|
<tr>
|
||||||
|
<td class="amount">{{ b.due_day }}</td>
|
||||||
|
<td>{{ b.name }}</td>
|
||||||
|
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted">varies</span>{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if b.category %}
|
||||||
|
<span class="tag" style="background: {{ colors.get(b.category, '#ddd') }};">
|
||||||
|
{{ b.category }}{% if b.subcategory %} › {{ b.subcategory }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="toggle-btn {% if b.get('autopay') %}toggle-on{% endif %}"
|
||||||
|
onclick="toggle('{{ b.id }}', 'autopay', this)">
|
||||||
|
{{ '✓ Yes' if b.get('autopay') else 'No' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="toggle-btn {% if b.get('remind', not b.get('autopay')) %}toggle-on{% endif %}"
|
||||||
|
onclick="toggle('{{ b.id }}', 'remind', this)">
|
||||||
|
{{ '🔔 Yes' if b.get('remind', not b.get('autopay')) else 'Off' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ b.notes }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="{{ url_for('bill_delete', bill_id=b.id) }}"
|
||||||
|
onsubmit="return confirm('Delete {{ b.name }}?');" style="display:inline;">
|
||||||
|
<button type="submit" class="edit-link" style="background:none;border:none;cursor:pointer;color:#C5A0A0;">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No bills yet. Add your first one above.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="reminder-bar">
|
||||||
|
<span>Run reminders now to test your email & Mac notifications:</span>
|
||||||
|
<button class="btn btn-ghost" onclick="runReminders(this)">Send Test Reminder</button>
|
||||||
|
<span class="reminder-result" id="reminder-result"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const subcats = {{ categories | tojson }};
|
||||||
|
|
||||||
|
function updateAddSubcats() {
|
||||||
|
const cat = document.getElementById('add-category').value;
|
||||||
|
const sel = document.getElementById('add-subcategory');
|
||||||
|
sel.innerHTML = '<option value="">— none —</option>';
|
||||||
|
(subcats[cat] || []).forEach(s => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = s; o.textContent = s;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(billId, field, btn) {
|
||||||
|
fetch(`/bills/toggle/${billId}/${field}`, { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => location.reload());
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterBills(q) {
|
||||||
|
q = q.toLowerCase();
|
||||||
|
document.querySelectorAll('.txn-table tbody tr').forEach(row => {
|
||||||
|
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runReminders(btn) {
|
||||||
|
btn.textContent = 'Sending…';
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/reminders/run', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('reminder-result').textContent = data.message;
|
||||||
|
btn.textContent = 'Send Test Reminder';
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
50
templates/calendar.html
Normal file
50
templates/calendar.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>{{ month_name }} {{ year }} — Bill Calendar</h1>
|
||||||
|
<div class="month-nav">
|
||||||
|
<a href="{{ url_for('bill_calendar', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||||
|
<a href="{{ url_for('bill_calendar', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cal-wrap">
|
||||||
|
<div class="cal-grid">
|
||||||
|
{% for day_name in ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'] %}
|
||||||
|
<div class="cal-header">{{ day_name }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for week in grid %}
|
||||||
|
{% for day in week %}
|
||||||
|
{% if day is none %}
|
||||||
|
<div class="cal-cell cal-empty"></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="cal-cell {% if today.day == day and today.month == month and today.year == year %}cal-today{% endif %}">
|
||||||
|
<div class="cal-day-num">{{ day }}</div>
|
||||||
|
{% for bill in bills_by_day.get(day, []) %}
|
||||||
|
<div class="cal-bill" style="background: {{ colors.get(bill.category, '#ddd') }};{% if bill.get('paid') %}opacity:0.45;{% endif %}">
|
||||||
|
<span class="cal-bill-name" style="{% if bill.get('paid') %}text-decoration:line-through;{% endif %}">{{ bill.name }}</span>
|
||||||
|
{% if bill.amount %}<span class="cal-bill-amt">${{ bill.amount | money }}</span>{% endif %}
|
||||||
|
{% if bill.get('paid') %}<span style="font-size:0.65rem;"> ✓</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cal-legend">
|
||||||
|
{% for cat, color in colors.items() %}
|
||||||
|
<span class="legend-item">
|
||||||
|
<span class="legend-dot" style="background: {{ color }};"></span>{{ cat }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cal-actions">
|
||||||
|
<a href="{{ url_for('bills_list') }}" class="btn btn-primary">Manage Bills</a>
|
||||||
|
<a href="{{ url_for('weekly_overview', year=year, month=month) }}" class="btn btn-ghost">Weekly Overview →</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
774
templates/debts.html
Normal file
774
templates/debts.html
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Debt Tracker — {{ month_name }} {{ year }}</h1>
|
||||||
|
<div class="month-nav">
|
||||||
|
<a href="{{ url_for('debts_view', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||||
|
<a href="{{ url_for('debts_view', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||||
|
<a href="{{ url_for('debt_strategy') }}" class="btn" style="background:#C47A4A;color:#fff;border:none;">📊 Debt Payoff Strategy</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Forms -->
|
||||||
|
<div style="display:flex;gap:0.75rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||||
|
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="btn btn-primary" style="display:inline-block;cursor:pointer;">+ Add Credit Card</summary>
|
||||||
|
<div class="card-wide" style="margin-top:0.75rem;">
|
||||||
|
<form method="POST" action="{{ url_for('debt_add') }}" class="bill-form">
|
||||||
|
<input type="hidden" name="debt_category" value="Credit Cards">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Card Name</label>
|
||||||
|
<input type="text" name="name" placeholder="e.g. Discover, Chase, Indigo" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Owner</label>
|
||||||
|
<select name="owner">
|
||||||
|
<option value="Bonna">Bonna</option>
|
||||||
|
<option value="Tony">Tony</option>
|
||||||
|
<option value="Both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Current Balance</label>
|
||||||
|
<input type="number" name="current_balance" step="0.01" min="0" placeholder="0.00" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>APR % <span class="optional">(leave blank if unknown)</span></label>
|
||||||
|
<input type="number" name="apr" step="0.01" min="0" max="100" placeholder="e.g. 24.99">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Minimum Payment</label>
|
||||||
|
<input type="number" name="minimum" step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account Name in App <span class="optional">(to match transactions)</span></label>
|
||||||
|
<input type="text" name="account_match" placeholder="e.g. Discover">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notes <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" name="notes" placeholder="e.g. balance transfer, 0% promo ends Aug">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Debt</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="btn btn-dusty-rose" style="display:inline-block;cursor:pointer;">+ Add Medical Debt</summary>
|
||||||
|
<div class="card-wide" style="margin-top:0.75rem;">
|
||||||
|
<form method="POST" action="{{ url_for('debt_add') }}" class="bill-form">
|
||||||
|
<input type="hidden" name="debt_category" value="Medical Payment Plans">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Provider / Institution</label>
|
||||||
|
<input type="text" name="name" placeholder="e.g. Marshfield Clinic, Oakleaf" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Owner</label>
|
||||||
|
<select name="owner">
|
||||||
|
<option value="Bonna">Bonna</option>
|
||||||
|
<option value="Tony">Tony</option>
|
||||||
|
<option value="Both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Current Balance</label>
|
||||||
|
<input type="number" name="current_balance" step="0.01" min="0" placeholder="0.00" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>APR % <span class="optional">(0% if interest-free plan)</span></label>
|
||||||
|
<input type="number" name="apr" step="0.01" min="0" max="100" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Monthly Payment</label>
|
||||||
|
<input type="number" name="minimum" step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account Name in App <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" name="account_match" placeholder="e.g. Marshfield">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notes <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" name="notes" placeholder="e.g. interest-free, started Jan 2026">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Medical Debt</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="btn btn-powder-blue" style="display:inline-block;cursor:pointer;">+ Add Affirm / Klarna / BNPL Plan</summary>
|
||||||
|
<div class="card-wide" style="margin-top:0.75rem;">
|
||||||
|
<form method="POST" action="{{ url_for('bnpl_add') }}" class="bill-form">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Provider</label>
|
||||||
|
<select name="bnpl_provider">
|
||||||
|
<option>Affirm</option>
|
||||||
|
<option>Klarna</option>
|
||||||
|
<option>Afterpay</option>
|
||||||
|
<option>Zip</option>
|
||||||
|
<option>Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>What was it for?</label>
|
||||||
|
<input type="text" name="description" placeholder="e.g. Living room couch" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Merchant / Store</label>
|
||||||
|
<input type="text" name="merchant" placeholder="e.g. Wayfair, Amazon">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Owner</label>
|
||||||
|
<select name="owner">
|
||||||
|
<option value="Bonna">Bonna</option>
|
||||||
|
<option value="Tony">Tony</option>
|
||||||
|
<option value="Both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Original Purchase Amount</label>
|
||||||
|
<input type="number" name="original_amount" step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Payment Amount</label>
|
||||||
|
<input type="number" name="payment_amount" step="0.01" min="0" placeholder="0.00" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Total Number of Payments</label>
|
||||||
|
<input type="number" name="total_payments" min="1" placeholder="e.g. 4 or 6 or 12" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Payments Already Made</label>
|
||||||
|
<input type="number" name="payments_made" min="0" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Payment Frequency</label>
|
||||||
|
<select name="frequency">
|
||||||
|
<option value="biweekly">Every 2 weeks</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="4-payment">4 payments (every 2 weeks)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Next Payment Date</label>
|
||||||
|
<input type="date" name="next_payment_date" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notes <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" name="notes" placeholder="e.g. for bedroom, holiday gift">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add BNPL Plan</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="add-debt-details">
|
||||||
|
<summary class="btn btn-moss" style="display:inline-block;cursor:pointer;">📋 Add Card by Pasting Statement</summary>
|
||||||
|
<div class="card-wide" style="margin-top:0.75rem;">
|
||||||
|
<p class="muted" style="font-size:0.85rem;margin-bottom:1rem;">Fill in the card name and owner, paste your statement text, and we'll create the card and fill in all the numbers at once.</p>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Card Name</label>
|
||||||
|
<input type="text" id="paste-card-name" placeholder="e.g. Self Visa, Discover" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Owner</label>
|
||||||
|
<select id="paste-card-owner" class="budget-input" style="width:100%;">
|
||||||
|
<option value="Bonna">Bonna</option>
|
||||||
|
<option value="Tony">Tony</option>
|
||||||
|
<option value="Both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Paste Statement Text</label>
|
||||||
|
<textarea id="paste-new-statement" style="width:100%;height:160px;border:1px solid var(--border);border-radius:8px;padding:0.75rem;font-size:0.82rem;resize:vertical;" placeholder="Copy all the text from your statement and paste it here…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="paste-new-result" style="font-size:0.85rem;margin-bottom:0.75rem;"></div>
|
||||||
|
<button class="btn btn-primary" onclick="addCardFromStatement()">Add Card & Import Statement</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debt Summary Breakdown -->
|
||||||
|
<div class="debt-summary-block">
|
||||||
|
<table class="debt-summary-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Balance</th>
|
||||||
|
<th>Minimums</th>
|
||||||
|
<th>Paid This Month</th>
|
||||||
|
<th>Extra</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for cat in debt_categories %}
|
||||||
|
{% set ct = totals[cat] %}
|
||||||
|
{% if by_category[cat] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ cat }}</td>
|
||||||
|
<td class="amount">${{ ct.balance | money }}</td>
|
||||||
|
<td class="amount">${{ ct.minimum | money }}</td>
|
||||||
|
<td class="amount">${{ ct.paid | money }}</td>
|
||||||
|
<td class="amount accent">${{ ct.extra | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td><strong>Grand Total</strong></td>
|
||||||
|
<td class="amount"><strong>${{ grand.balance | money }}</strong></td>
|
||||||
|
<td class="amount"><strong>${{ grand.minimum | money }}</strong></td>
|
||||||
|
<td class="amount"><strong>${{ grand.paid | money }}</strong></td>
|
||||||
|
<td class="amount accent"><strong>${{ grand.extra | money }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debt-to-Income Ratio -->
|
||||||
|
{% if dti is not none %}
|
||||||
|
{% if dti <= 20 %}
|
||||||
|
{% set dti_color = "#5A9E68" %}{% set dti_label = "Healthy" %}{% set dti_tip = "Under 20% is excellent." %}
|
||||||
|
{% elif dti <= 36 %}
|
||||||
|
{% set dti_color = "#D4A96A" %}{% set dti_label = "Manageable" %}{% set dti_tip = "20–36% is average. Keep an eye on it." %}
|
||||||
|
{% elif dti <= 50 %}
|
||||||
|
{% set dti_color = "#E8956A" %}{% set dti_label = "High" %}{% set dti_tip = "36–50% is high. Prioritize paying down debt." %}
|
||||||
|
{% else %}
|
||||||
|
{% set dti_color = "#C57A7A" %}{% set dti_label = "Critical" %}{% set dti_tip = "Over 50% is a red flag. Focus on reducing debt ASAP." %}
|
||||||
|
{% endif %}
|
||||||
|
<div style="display:flex;align-items:center;gap:1.5rem;background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);margin-bottom:0.2rem;">Debt-to-Income Ratio</div>
|
||||||
|
<div style="font-size:1.8rem;font-weight:700;color:{{ dti_color }};">{{ dti }}%</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:180px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.78rem;margin-bottom:0.3rem;">
|
||||||
|
<span style="color:{{ dti_color }};font-weight:600;">{{ dti_label }}</span>
|
||||||
|
<span class="muted">${{ "%.0f" | format(grand.minimum) }} min / ${{ "%.0f" | format(monthly_income) }} income</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--border);border-radius:99px;height:8px;overflow:hidden;">
|
||||||
|
<div style="height:100%;width:{{ [dti, 100] | min }}%;background:{{ dti_color }};border-radius:99px;transition:width 0.3s;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.75rem;color:var(--text-light);margin-top:0.3rem;">{{ dti_tip }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:0.75rem 1.25rem;margin-bottom:1rem;font-size:0.85rem;color:var(--text-light);">
|
||||||
|
DTI ratio unavailable — <a href="{{ url_for('income_view') }}">log this month's income</a> to see your debt-to-income ratio.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<input type="text" id="debt-search" placeholder="Search debts…" class="budget-input" style="width:240px;" oninput="filterDebts(this.value)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- By Category -->
|
||||||
|
{% for cat in debt_categories %}
|
||||||
|
{% set debts = by_category[cat] %}
|
||||||
|
{% set cat_totals = totals[cat] %}
|
||||||
|
{% if debts %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="debt-cat-header" onclick="toggleDebtSection('debt-section-{{ loop.index }}')" style="cursor:pointer;user-select:none;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<span class="debt-collapse-arrow" id="arrow-debt-section-{{ loop.index }}">▾</span>
|
||||||
|
<h2 style="margin:0;">{{ cat }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="debt-cat-totals">
|
||||||
|
<span>Balance: <strong>${{ cat_totals.balance | money }}</strong></span>
|
||||||
|
<span>Minimums: <strong>${{ cat_totals.minimum | money }}</strong></span>
|
||||||
|
<span>Paid: <strong>${{ cat_totals.paid | money }}</strong></span>
|
||||||
|
<span class="accent">Extra: <strong>${{ cat_totals.extra | money }}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="debt-cards" id="debt-section-{{ loop.index }}">
|
||||||
|
{% for d in debts %}
|
||||||
|
<div class="debt-card">
|
||||||
|
<div class="debt-card-top">
|
||||||
|
<div>
|
||||||
|
<div class="debt-card-name">{{ d.name }}</div>
|
||||||
|
{% if d.notes %}<div class="debt-card-notes">{{ d.notes }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="debt-card-badges">
|
||||||
|
<span class="owner-tag" style="background: {{ owner_colors.get(d.owner, '#ddd') }};">{{ d.owner }}</span>
|
||||||
|
{% if d.apr %}<span class="apr-tag">{{ d.apr }}% APR</span>{% endif %}
|
||||||
|
{% if d.get('is_bnpl') %}
|
||||||
|
<button class="edit-link" style="background:none;border:none;cursor:pointer;font-size:0.78rem;"
|
||||||
|
onclick="openEditBNPL('{{ d.id }}','{{ d.name }}','{{ d.merchant or '' }}','{{ d.bnpl_provider or 'Affirm' }}','{{ d.owner }}','{{ d.original_amount or 0 }}','{{ d.payment_amount or 0 }}','{{ d.total_payments or 0 }}','{{ d.payments_made or 0 }}','{{ d.frequency or 'biweekly' }}','{{ d.next_payment_date or '' }}','{{ d.notes or '' }}')">
|
||||||
|
✏️ Edit
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="edit-link" style="background:none;border:none;cursor:pointer;font-size:0.78rem;"
|
||||||
|
onclick="openEditDebt('{{ d.id }}','{{ d.name }}','{{ d.owner }}','{{ d.apr or 0 }}','{{ d.minimum or 0 }}','{{ d.current_balance or 0 }}','{{ d.credit_limit or 0 }}','{{ d.notes or '' }}')">
|
||||||
|
✏️ Edit
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="debt-card-balance">
|
||||||
|
<span class="debt-balance-label">Balance</span>
|
||||||
|
<span class="debt-balance-value" id="balance-{{ d.id }}">${{ d.ending | money }}</span>
|
||||||
|
</div>
|
||||||
|
{% if d.credit_limit %}
|
||||||
|
{% set available = d.credit_limit - d.ending %}
|
||||||
|
{% set util_pct = [(d.ending / d.credit_limit * 100), 100] | min | int %}
|
||||||
|
<div style="margin-bottom:0.75rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.3rem;">
|
||||||
|
<span style="color:var(--text-light);">Available credit</span>
|
||||||
|
<span style="font-weight:600;color:{% if available < 0 %}#c07070{% elif util_pct > 80 %}#c07070{% elif util_pct > 50 %}#b8943f{% else %}#5A9E68{% endif %};">
|
||||||
|
${{ available | money }} of ${{ d.credit_limit | money }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#EDE8E2;border-radius:4px;height:6px;">
|
||||||
|
<div style="background:{% if util_pct > 80 %}#c07070{% elif util_pct > 50 %}#d4a843{% else %}#5A9E68{% endif %};border-radius:4px;height:6px;width:{{ util_pct }}%;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.73rem;color:var(--text-light);margin-top:0.2rem;">{{ util_pct }}% utilized</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if d.payoff_str and not d.get('is_bnpl') %}
|
||||||
|
<div style="margin-bottom:0.75rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.3rem;">
|
||||||
|
<span style="color:var(--text-light);">Payoff projection</span>
|
||||||
|
<span style="font-weight:600;color:#5A9E68;">{{ d.payoff_str }}</span>
|
||||||
|
</div>
|
||||||
|
{% if d.months_left and d.months_left <= 36 %}
|
||||||
|
{% set payoff_pct = [100 - (d.months_left / 36 * 100), 5] | max | int %}
|
||||||
|
<div style="background:#EDE8E2;border-radius:4px;height:6px;">
|
||||||
|
<div style="background:#5A9E68;border-radius:4px;height:6px;width:{{ payoff_pct }}%;"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div style="font-size:0.73rem;color:var(--text-light);margin-top:0.2rem;">
|
||||||
|
{{ d.months_left }} month{{ 's' if d.months_left != 1 }} at minimum payments
|
||||||
|
{% if d.apr %} · {{ d.apr }}% APR{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Statement pending toggle -->
|
||||||
|
{% if not d.get('is_bnpl') %}
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
|
||||||
|
<button class="toggle-btn {% if d.get('statement_pending') %}toggle-on{% endif %}"
|
||||||
|
onclick="togglePending('{{ d.id }}', this)"
|
||||||
|
style="font-size:0.75rem;padding:0.2rem 0.6rem;">
|
||||||
|
{{ '⏳ Statement Pending' if d.get('statement_pending') else 'Statement closed' }}
|
||||||
|
</button>
|
||||||
|
{% if d.get('statement_pending') %}
|
||||||
|
<span style="font-size:0.75rem;color:var(--text-light);">numbers may not reflect current statement</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if d.get('is_bnpl') %}
|
||||||
|
{% set pct = ((d.payments_made / d.total_payments) * 100) | int if d.total_payments else 0 %}
|
||||||
|
{% set remaining_payments = d.total_payments - d.payments_made %}
|
||||||
|
{% set payoff_str, _ = bnpl_payoff_date(d.payments_made, d.total_payments, d.frequency_days) %}
|
||||||
|
<div class="bnpl-progress-wrap">
|
||||||
|
<div class="bnpl-progress-bar">
|
||||||
|
<div class="bnpl-progress-fill" style="width: {{ pct }}%;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bnpl-progress-label">{{ d.payments_made }} of {{ d.total_payments }} payments made</div>
|
||||||
|
</div>
|
||||||
|
<table class="debt-month-table">
|
||||||
|
<tr><td>Payment Amount</td><td class="amount">${{ d.payment_amount | money }}</td></tr>
|
||||||
|
<tr><td>Payments Remaining</td><td class="amount">{{ remaining_payments }}</td></tr>
|
||||||
|
<tr><td>Still Owed</td><td class="amount">${{ d.current_balance | money }}</td></tr>
|
||||||
|
{% if payoff_str %}<tr><td>Payoff Date</td><td class="amount accent">{{ payoff_str }}</td></tr>{% endif %}
|
||||||
|
</table>
|
||||||
|
<form method="POST" action="{{ url_for('bnpl_payment', debt_id=d.id) }}" style="margin-bottom:0.5rem;">
|
||||||
|
<button type="submit" class="btn btn-accept" onclick="return confirm('Mark one payment as made?')">
|
||||||
|
✓ Mark Payment Made
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<table class="debt-month-table">
|
||||||
|
<tr>
|
||||||
|
<td>Starting Balance</td>
|
||||||
|
<td><input type="number" step="0.01" class="budget-input"
|
||||||
|
value="{{ d.starting }}"
|
||||||
|
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'starting_balance', this.value, '{{ d.id }}')"
|
||||||
|
placeholder="0.00"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Interest Charged</td>
|
||||||
|
<td><input type="number" step="0.01" class="budget-input"
|
||||||
|
value="{{ d.interest }}"
|
||||||
|
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'interest', this.value, '{{ d.id }}')"
|
||||||
|
placeholder="auto-calculated"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>New Charges</td>
|
||||||
|
<td><input type="number" step="0.01" class="budget-input"
|
||||||
|
value="{{ d.new_charges or '' }}"
|
||||||
|
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'new_charges', this.value, '{{ d.id }}')"
|
||||||
|
placeholder="0.00"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Minimum Due</td>
|
||||||
|
<td class="amount">${{ d.minimum | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Payment Made</td>
|
||||||
|
<td><input type="number" step="0.01" class="budget-input"
|
||||||
|
value="{{ d.payment or '' }}"
|
||||||
|
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'payment', this.value, '{{ d.id }}')"
|
||||||
|
placeholder="0.00"></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="debt-extra-row">
|
||||||
|
{% set underpaid = d.payment > 0 and d.payment < d.minimum and not d.get('statement_pending') %}
|
||||||
|
<td>{% if underpaid %}⚠️ Underpaid Minimum{% else %}Extra Paid Above Minimum{% endif %}</td>
|
||||||
|
<td class="amount {% if underpaid %}{% else %}accent{% endif %}" id="extra-{{ d.id }}"
|
||||||
|
{% if underpaid %}style="color:#c07070;"{% endif %}>
|
||||||
|
{% if underpaid %}
|
||||||
|
-${{ d.minimum - d.payment | money }} short
|
||||||
|
{% elif d.get('statement_pending') %}
|
||||||
|
<span class="muted">—</span>
|
||||||
|
{% else %}
|
||||||
|
${{ d.extra | money }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not d.get('is_bnpl') %}
|
||||||
|
<button class="btn btn-ghost" style="font-size:0.78rem;margin-bottom:0.5rem;width:100%;"
|
||||||
|
onclick="openStatementModal('{{ d.id }}', '{{ d.name }}')">📋 Paste Statement</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('debt_delete', debt_id=d.id) }}"
|
||||||
|
onsubmit="return confirm('Remove {{ d.name }} from debt tracker?');">
|
||||||
|
<button type="submit" class="debt-delete-btn">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if not (by_category.values() | map('list') | sum(start=[])) %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No debts added yet. Click "+ Add a Debt" above to get started.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleDebtSection(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const arrow = document.getElementById('arrow-' + id);
|
||||||
|
if (!el) return;
|
||||||
|
const collapsed = el.style.display === 'none';
|
||||||
|
el.style.display = collapsed ? '' : 'none';
|
||||||
|
if (arrow) arrow.textContent = collapsed ? '▾' : '▸';
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePending(debtId, btn) {
|
||||||
|
fetch(`/debts/toggle-pending/${debtId}`, {method: 'POST'})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
btn.textContent = data.pending ? '⏳ Statement Pending' : 'Statement closed';
|
||||||
|
btn.classList.toggle('toggle-on', data.pending);
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterDebts(q) {
|
||||||
|
q = q.toLowerCase();
|
||||||
|
document.querySelectorAll('.debt-card').forEach(card => {
|
||||||
|
card.style.display = card.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logDebt(debtId, year, month, field, value, cardId) {
|
||||||
|
const body = new URLSearchParams({debt_id: debtId, year, month, field, value});
|
||||||
|
fetch('/debts/log', {method: 'POST', body})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.ending !== undefined) {
|
||||||
|
const bal = document.getElementById('balance-' + cardId);
|
||||||
|
if (bal) bal.textContent = '$' + data.ending.toFixed(2);
|
||||||
|
}
|
||||||
|
if (data.extra !== undefined) {
|
||||||
|
const ext = document.getElementById('extra-' + cardId);
|
||||||
|
if (ext) ext.textContent = '$' + data.extra.toFixed(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Edit Debt Modal -->
|
||||||
|
<div id="edit-debt-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||||
|
<h3 style="margin:0 0 1rem;" id="edit-debt-title">Edit Debt</h3>
|
||||||
|
<form method="POST" id="edit-debt-form" action="">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Card / Debt Name</label>
|
||||||
|
<input type="text" name="name" id="edit-name" class="budget-input" style="width:100%;" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Owner</label>
|
||||||
|
<select name="owner" id="edit-owner" class="budget-input" style="width:100%;">
|
||||||
|
<option value="Bonna">Bonna</option>
|
||||||
|
<option value="Tony">Tony</option>
|
||||||
|
<option value="Both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Current Balance</label>
|
||||||
|
<input type="number" name="current_balance" id="edit-balance" step="0.01" min="0" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">APR %</label>
|
||||||
|
<input type="number" name="apr" id="edit-apr" step="0.01" min="0" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Minimum Payment</label>
|
||||||
|
<input type="number" name="minimum" id="edit-minimum" step="0.01" min="0" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Credit Limit</label>
|
||||||
|
<input type="number" name="credit_limit" id="edit-credit-limit" step="0.01" min="0" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:0.5rem;">
|
||||||
|
<label style="font-size:0.85rem;">Notes</label>
|
||||||
|
<input type="text" name="notes" id="edit-notes" class="budget-input" style="width:100%;" placeholder="optional">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1rem;">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="closeEditDebt()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit BNPL Modal -->
|
||||||
|
<div id="edit-bnpl-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:520px;box-shadow:0 8px 32px rgba(0,0,0,0.18);max-height:90vh;overflow-y:auto;">
|
||||||
|
<h3 style="margin:0 0 1rem;" id="edit-bnpl-title">Edit BNPL Plan</h3>
|
||||||
|
<form method="POST" id="edit-bnpl-form" action="">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">What is it for?</label>
|
||||||
|
<input type="text" name="description" id="ebnpl-description" class="budget-input" style="width:100%;" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Merchant / Store</label>
|
||||||
|
<input type="text" name="merchant" id="ebnpl-merchant" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Provider</label>
|
||||||
|
<select name="bnpl_provider" id="ebnpl-provider" class="budget-input" style="width:100%;">
|
||||||
|
<option>Affirm</option>
|
||||||
|
<option>Klarna</option>
|
||||||
|
<option>Afterpay</option>
|
||||||
|
<option>Zip</option>
|
||||||
|
<option>Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Owner</label>
|
||||||
|
<select name="owner" id="ebnpl-owner" class="budget-input" style="width:100%;">
|
||||||
|
<option value="Bonna">Bonna</option>
|
||||||
|
<option value="Tony">Tony</option>
|
||||||
|
<option value="Both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Original Purchase Amount</label>
|
||||||
|
<input type="number" step="0.01" name="original_amount" id="ebnpl-original" class="budget-input" style="width:100%;" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Payment Amount</label>
|
||||||
|
<input type="number" step="0.01" name="payment_amount" id="ebnpl-payment" class="budget-input" style="width:100%;" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Total Payments</label>
|
||||||
|
<input type="number" min="1" name="total_payments" id="ebnpl-total" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Payments Already Made</label>
|
||||||
|
<input type="number" min="0" name="payments_made" id="ebnpl-made" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Frequency</label>
|
||||||
|
<select name="frequency" id="ebnpl-frequency" class="budget-input" style="width:100%;">
|
||||||
|
<option value="biweekly">Every 2 weeks</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="4-payment">4 payments (every 2 weeks)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.85rem;">Next Payment Date</label>
|
||||||
|
<input type="date" name="next_payment_date" id="ebnpl-next-date" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:0.5rem;">
|
||||||
|
<label style="font-size:0.85rem;">Notes</label>
|
||||||
|
<input type="text" name="notes" id="ebnpl-notes" class="budget-input" style="width:100%;" placeholder="optional">
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="font-size:0.78rem;margin-top:0.75rem;">Saving will delete any unpaid calendar entries for this plan and regenerate them based on the updated dates.</p>
|
||||||
|
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1rem;">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="closeEditBNPL()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paste Statement Modal -->
|
||||||
|
<div id="statement-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:540px;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||||
|
<h3 style="margin:0 0 0.25rem;" id="statement-modal-title">Paste Statement</h3>
|
||||||
|
<p class="muted" style="font-size:0.82rem;margin-bottom:1rem;">Copy all the text from your statement and paste it below. The app will extract the numbers automatically.</p>
|
||||||
|
<textarea id="statement-text" style="width:100%;height:180px;border:1px solid var(--border);border-radius:8px;padding:0.75rem;font-size:0.82rem;resize:vertical;" placeholder="Paste statement text here…"></textarea>
|
||||||
|
<div id="statement-result" style="margin:0.75rem 0;font-size:0.85rem;"></div>
|
||||||
|
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:0.75rem;">
|
||||||
|
<button class="btn btn-ghost" onclick="closeStatementModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="parseStatement()">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let activeDebtId = null;
|
||||||
|
|
||||||
|
function addCardFromStatement() {
|
||||||
|
const name = document.getElementById('paste-card-name').value.trim();
|
||||||
|
const owner = document.getElementById('paste-card-owner').value;
|
||||||
|
const text = document.getElementById('paste-new-statement').value.trim();
|
||||||
|
const resultEl = document.getElementById('paste-new-result');
|
||||||
|
|
||||||
|
if (!name) { resultEl.style.color='#c07070'; resultEl.textContent='Please enter a card name.'; return; }
|
||||||
|
if (!text) { resultEl.style.color='#c07070'; resultEl.textContent='Please paste your statement text.'; return; }
|
||||||
|
|
||||||
|
resultEl.style.color='inherit';
|
||||||
|
resultEl.textContent='Creating card and importing…';
|
||||||
|
|
||||||
|
fetch('/debts/add-from-statement', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({name, owner, text, year: {{ year }}, month: {{ month }}})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) { resultEl.style.color='#c07070'; resultEl.textContent=data.error; return; }
|
||||||
|
resultEl.style.color='#5A9E68';
|
||||||
|
resultEl.textContent='✓ Card added and statement imported!';
|
||||||
|
setTimeout(() => location.reload(), 1200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatementModal(debtId, debtName) {
|
||||||
|
activeDebtId = debtId;
|
||||||
|
document.getElementById('statement-modal-title').textContent = 'Paste Statement — ' + debtName;
|
||||||
|
document.getElementById('statement-text').value = '';
|
||||||
|
document.getElementById('statement-result').textContent = '';
|
||||||
|
document.getElementById('statement-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeStatementModal() {
|
||||||
|
document.getElementById('statement-modal').style.display = 'none';
|
||||||
|
activeDebtId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatement() {
|
||||||
|
const text = document.getElementById('statement-text').value;
|
||||||
|
if (!text.trim()) return;
|
||||||
|
const resultEl = document.getElementById('statement-result');
|
||||||
|
resultEl.textContent = 'Parsing…';
|
||||||
|
|
||||||
|
fetch('/debts/parse-statement', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({text, debt_id: activeDebtId, year: {{ year }}, month: {{ month }}})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
resultEl.style.color = '#c07070';
|
||||||
|
resultEl.textContent = 'Could not parse: ' + data.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultEl.style.color = '#5A9E68';
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
✓ Imported:<br>
|
||||||
|
Previous balance: <strong>$${data.previous_balance}</strong>
|
||||||
|
Purchases: <strong>$${data.purchases}</strong>
|
||||||
|
Interest: <strong>$${data.interest}</strong>
|
||||||
|
Payment: <strong>$${data.payment}</strong>
|
||||||
|
Minimum due: <strong>$${data.minimum}</strong>
|
||||||
|
New balance: <strong>$${data.new_balance}</strong>
|
||||||
|
${data.credit_limit ? ' Credit limit: <strong>$' + data.credit_limit + '</strong>' : ''}
|
||||||
|
${data.available_credit ? ' Available: <strong>$' + data.available_credit + '</strong>' : ''}
|
||||||
|
`;
|
||||||
|
// Reload to show updated values
|
||||||
|
setTimeout(() => { closeStatementModal(); location.reload(); }, 1800);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('statement-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeStatementModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openEditDebt(id, name, owner, apr, minimum, balance, creditLimit, notes) {
|
||||||
|
document.getElementById('edit-debt-title').textContent = 'Edit — ' + name;
|
||||||
|
document.getElementById('edit-debt-form').action = '/debts/edit/' + id;
|
||||||
|
document.getElementById('edit-name').value = name;
|
||||||
|
document.getElementById('edit-owner').value = owner;
|
||||||
|
document.getElementById('edit-apr').value = apr != '0' ? apr : '';
|
||||||
|
document.getElementById('edit-minimum').value = minimum != '0' ? minimum : '';
|
||||||
|
document.getElementById('edit-balance').value = balance != '0' ? balance : '';
|
||||||
|
document.getElementById('edit-credit-limit').value = creditLimit != '0' ? creditLimit : '';
|
||||||
|
document.getElementById('edit-notes').value = notes;
|
||||||
|
document.getElementById('edit-debt-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditDebt() {
|
||||||
|
document.getElementById('edit-debt-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('edit-debt-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeEditDebt();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openEditBNPL(id, description, merchant, provider, owner, original, payment, total, made, frequency, nextDate, notes) {
|
||||||
|
document.getElementById('edit-bnpl-title').textContent = 'Edit — ' + description;
|
||||||
|
document.getElementById('edit-bnpl-form').action = '/debts/bnpl-edit/' + id;
|
||||||
|
document.getElementById('ebnpl-description').value = description;
|
||||||
|
document.getElementById('ebnpl-merchant').value = merchant;
|
||||||
|
document.getElementById('ebnpl-provider').value = provider;
|
||||||
|
document.getElementById('ebnpl-owner').value = owner;
|
||||||
|
document.getElementById('ebnpl-original').value = original != '0' ? original : '';
|
||||||
|
document.getElementById('ebnpl-payment').value = payment != '0' ? payment : '';
|
||||||
|
document.getElementById('ebnpl-total').value = total;
|
||||||
|
document.getElementById('ebnpl-made').value = made;
|
||||||
|
document.getElementById('ebnpl-frequency').value = frequency;
|
||||||
|
document.getElementById('ebnpl-next-date').value = nextDate;
|
||||||
|
document.getElementById('ebnpl-notes').value = notes;
|
||||||
|
document.getElementById('edit-bnpl-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditBNPL() {
|
||||||
|
document.getElementById('edit-bnpl-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('edit-bnpl-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeEditBNPL();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
234
templates/edit.html
Normal file
234
templates/edit.html
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Edit Transaction</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="review-card">
|
||||||
|
<div class="review-meta">
|
||||||
|
<span class="review-date">{{ transaction.date }}</span>
|
||||||
|
<span class="review-account">{{ transaction.account }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="review-description">{{ transaction.description }}</div>
|
||||||
|
<div class="review-amount">${{ transaction.amount | money }}</div>
|
||||||
|
|
||||||
|
<form method="POST" class="categorize-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<div class="category-grid">
|
||||||
|
{% for cat, subcats in categories.items() %}
|
||||||
|
<label class="cat-option" style="--cat-color: {{ colors.get(cat, '#ddd') }};">
|
||||||
|
<input type="radio" name="category" value="{{ cat }}"
|
||||||
|
{% if transaction.category == cat %}checked{% endif %}
|
||||||
|
onchange="updateSubcats(this.value)">
|
||||||
|
<span>{{ cat }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="subcat-group">
|
||||||
|
<label>Subcategory <span class="optional">(optional)</span></label>
|
||||||
|
<select name="subcategory" id="subcat-select">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notes <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" name="notes" value="{{ transaction.notes }}" placeholder="e.g. Costco run, birthday gift...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Split transaction</button>
|
||||||
|
<a href="{{ url_for('transactions_view') }}" class="btn btn-ghost">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Split transaction panel -->
|
||||||
|
<div id="split-panel" style="display:none;margin-top:1.5rem;">
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||||
|
<h3>Split Transaction — ${{ transaction.amount | money }} total</h3>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem;">Divide this charge across multiple categories. Amounts must add up to ${{ transaction.amount | money }}.</p>
|
||||||
|
<form method="POST" action="{{ url_for('split_transaction') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="redirect" value="transactions">
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Category</th><th>Subcategory</th><th>Amount</th><th>Note</th><th></th></tr></thead>
|
||||||
|
<tbody id="split-rows">
|
||||||
|
{% if transaction.splits %}
|
||||||
|
{% for s in transaction.splits %}
|
||||||
|
<tr class="split-row">
|
||||||
|
<td>
|
||||||
|
<select name="split_cat_{{ loop.index0 }}" class="budget-input split-cat" onchange="updateSplitSubcat(this, {{ loop.index0 }})" style="width:130px;">
|
||||||
|
<option value="">— pick —</option>
|
||||||
|
{% for cat in categories %}<option value="{{ cat }}" {% if s.category == cat %}selected{% endif %}>{{ cat }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><select name="split_subcat_{{ loop.index0 }}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||||
|
<td><input type="number" name="split_amt_{{ loop.index0 }}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" value="{{ s.amount }}" oninput="updateRemaining()"></td>
|
||||||
|
<td><input type="text" name="split_note_{{ loop.index0 }}" class="budget-input" style="width:110px;" value="{{ s.note or '' }}"></td>
|
||||||
|
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr class="split-row">
|
||||||
|
<td><select name="split_cat_0" class="budget-input split-cat" onchange="updateSplitSubcat(this,0)" style="width:130px;"><option value="">— pick —</option>{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}</select></td>
|
||||||
|
<td><select name="split_subcat_0" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||||
|
<td><input type="number" name="split_amt_0" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||||
|
<td><input type="text" name="split_note_0" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="split-row">
|
||||||
|
<td><select name="split_cat_1" class="budget-input split-cat" onchange="updateSplitSubcat(this,1)" style="width:130px;"><option value="">— pick —</option>{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}</select></td>
|
||||||
|
<td><select name="split_subcat_1" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||||
|
<td><input type="number" name="split_amt_1" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||||
|
<td><input type="text" name="split_note_1" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="margin:0.75rem 0;font-size:0.85rem;">
|
||||||
|
Remaining: <strong id="split-remaining">${{ transaction.amount | money }}</strong>
|
||||||
|
<button type="button" class="btn btn-ghost" style="margin-left:1rem;font-size:0.78rem;" onclick="addSplitRow()">+ Add row</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.75rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Split</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:1.25rem;padding-top:1.25rem;border-top:1px solid var(--border);display:flex;gap:0.5rem;flex-wrap:wrap;">
|
||||||
|
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="redirect" value="transactions">
|
||||||
|
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.82rem;">✓ Already tracked — ignore</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" class="btn btn-ghost" style="background:#e8f5e9;color:#3a7d44;border:1px solid #a5d6a7;font-size:0.82rem;" onclick="document.getElementById('sinking-picker').style.display='block';this.style.display='none';">🏦 Sinking Fund Transfer</button>
|
||||||
|
<div id="sinking-picker" style="display:none;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:var(--radius);padding:0.75rem 1rem;width:100%;margin-top:0.25rem;">
|
||||||
|
<form method="POST" action="{{ url_for('categorize_sinking_transfer') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="redirect" value="transactions">
|
||||||
|
<label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:0.4rem;">Which sinking fund?</label>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<select name="sinking_fund_name" class="budget-input" style="min-width:180px;" required>
|
||||||
|
<option value="">— select fund —</option>
|
||||||
|
{% for f in sinking_funds %}
|
||||||
|
<option value="{{ f.name }}">{{ f.name }} (${{ (f.balance or 0) | money }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;">Save</button>
|
||||||
|
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;" onclick="document.getElementById('sinking-picker').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="label" value="Investment Transfer">
|
||||||
|
<input type="hidden" name="redirect" value="transactions">
|
||||||
|
<button type="submit" class="btn btn-ghost" style="background:#e3f2fd;color:#1565c0;border:1px solid #90caf9;font-size:0.82rem;">📈 Investment Transfer</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="label" value="Savings Transfer">
|
||||||
|
<input type="hidden" name="redirect" value="transactions">
|
||||||
|
<button type="submit" class="btn btn-ghost" style="background:#f3e5f5;color:#6a1b9a;border:1px solid #ce93d8;font-size:0.82rem;">💰 Savings Transfer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const subcats = {{ categories | tojson }};
|
||||||
|
const current = {{ transaction | tojson }};
|
||||||
|
|
||||||
|
function updateSubcats(cat) {
|
||||||
|
const select = document.getElementById('subcat-select');
|
||||||
|
const group = document.getElementById('subcat-group');
|
||||||
|
const list = subcats[cat] || [];
|
||||||
|
select.innerHTML = '<option value="">— none —</option>';
|
||||||
|
list.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s;
|
||||||
|
opt.textContent = s;
|
||||||
|
if (current.subcategory === s) opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
group.style.display = list.length ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const checked = document.querySelector('input[name="category"]:checked');
|
||||||
|
if (checked) updateSubcats(checked.value);
|
||||||
|
document.querySelectorAll('input[name="category"]').forEach(r => {
|
||||||
|
r.addEventListener('change', () => updateSubcats(r.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Split transaction
|
||||||
|
const totalAmount = {{ transaction.amount }};
|
||||||
|
const allSubcats = {{ categories | tojson }};
|
||||||
|
let splitRowCount = {{ transaction.splits | length if transaction.splits else 2 }};
|
||||||
|
|
||||||
|
function toggleSplit() {
|
||||||
|
const panel = document.getElementById('split-panel');
|
||||||
|
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||||||
|
if (panel.style.display === 'block') updateRemaining();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSplitSubcat(selectEl, idx) {
|
||||||
|
const cat = selectEl.value;
|
||||||
|
const subcatSel = document.querySelector(`select[name="split_subcat_${idx}"]`);
|
||||||
|
const list = allSubcats[cat] || [];
|
||||||
|
const current = subcatSel ? subcatSel.dataset.current || '' : '';
|
||||||
|
subcatSel.innerHTML = '<option value="">— none —</option>';
|
||||||
|
list.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s; opt.textContent = s;
|
||||||
|
if (s === current) opt.selected = true;
|
||||||
|
subcatSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRemaining() {
|
||||||
|
const inputs = document.querySelectorAll('.split-amt');
|
||||||
|
let used = 0;
|
||||||
|
inputs.forEach(el => used += parseFloat(el.value) || 0);
|
||||||
|
const remaining = Math.round((totalAmount - used) * 100) / 100;
|
||||||
|
const el = document.getElementById('split-remaining');
|
||||||
|
el.textContent = '$' + remaining.toFixed(2);
|
||||||
|
el.style.color = Math.abs(remaining) < 0.01 ? '#5A9E68' : (remaining < 0 ? '#c07070' : 'inherit');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSplitRow() {
|
||||||
|
const idx = splitRowCount++;
|
||||||
|
const tbody = document.getElementById('split-rows');
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'split-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><select name="split_cat_${idx}" class="budget-input split-cat" onchange="updateSplitSubcat(this,${idx})" style="width:130px;">
|
||||||
|
<option value="">— pick —</option>
|
||||||
|
${Object.keys(allSubcats).map(c => `<option value="${c}">${c}</option>`).join('')}
|
||||||
|
</select></td>
|
||||||
|
<td><select name="split_subcat_${idx}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||||
|
<td><input type="number" name="split_amt_${idx}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||||
|
<td><input type="text" name="split_note_${idx}" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||||
|
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;">✕</button></td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-populate subcats for existing splits
|
||||||
|
document.querySelectorAll('.split-cat').forEach((sel, idx) => {
|
||||||
|
if (sel.value) updateSplitSubcat(sel, idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open split panel automatically if transaction already has splits
|
||||||
|
{% if transaction.splits %}
|
||||||
|
toggleSplit();
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
50
templates/import.html
Normal file
50
templates/import.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Import Statements</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert-banner {{ 'alert-success' if 'Imported' in message else 'alert-error' }}">
|
||||||
|
{{ message }}
|
||||||
|
{% if 'Imported' in message %}
|
||||||
|
<a href="{{ url_for('review') }}" class="btn btn-primary">Review Transactions</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-wide">
|
||||||
|
<h2>Upload a Bank or Credit Card Statement</h2>
|
||||||
|
<p class="helper-text">Supports CSV exports from your bank or credit union. PDF import only works if the PDF has selectable text — scanned statements won't work.</p>
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data" class="import-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="account">Account Name</label>
|
||||||
|
<input type="text" id="account" name="account" placeholder="e.g. Chase Checking, Discover, Affirm" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="statement">Statement File</label>
|
||||||
|
<div class="file-drop-area">
|
||||||
|
<input type="file" id="statement" name="statement" accept=".pdf,.csv" required>
|
||||||
|
<p>Click to choose a file, or drag and drop here</p>
|
||||||
|
<p class="file-types">PDF or CSV</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large">Import</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-wide tips">
|
||||||
|
<h3>How to export your statements</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>WestConsin Credit Union:</strong> Log in at westconsin.org → select your account → look for a <em>Download</em> or <em>Export</em> button near the top of your transaction list → choose CSV or Excel format → pick your date range and download</li>
|
||||||
|
<li><strong>Most other banks:</strong> Log in → Account Details or Transaction History → Export / Download → choose CSV</li>
|
||||||
|
<li><strong>Affirm:</strong> affirm.com → Account → Payment History → Export (note: their export is often broken — manual entry may be needed)</li>
|
||||||
|
<li><strong>Klarna:</strong> klarna.com → Purchases → Export CSV</li>
|
||||||
|
<li><strong>Afterpay:</strong> afterpay.com → Orders → Export</li>
|
||||||
|
</ul>
|
||||||
|
<p class="helper-text" style="margin-top:0.75rem;">If a CSV imports 0 transactions, let Claude know — the column format varies by bank and may need a quick fix.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
277
templates/income.html
Normal file
277
templates/income.html
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Income — {{ month_name }} {{ year }}</h1>
|
||||||
|
<div class="month-nav">
|
||||||
|
<a href="{{ url_for('income_view', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||||
|
<a href="{{ url_for('income_view', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- YTD Summary Cards -->
|
||||||
|
<div class="card-grid" style="margin-bottom:1.5rem;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">{{ month_name }} Total</div>
|
||||||
|
<div class="card-value">${{ month_total | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">{{ year }} YTD Total</div>
|
||||||
|
<div class="card-value">${{ ytd_total | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Monthly Average</div>
|
||||||
|
<div class="card-value">
|
||||||
|
{% set months_with_income = ytd_by_month | length %}
|
||||||
|
${{ (ytd_total / months_with_income if months_with_income else 0) | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tax estimate banner -->
|
||||||
|
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;margin-bottom:0.2rem;">🧾 {{ year }} Tax Estimate — based on ${{ ytd_total | money }} YTD income</div>
|
||||||
|
<div style="font-size:0.82rem;color:var(--text-light);">
|
||||||
|
~${{ tax_estimate.total | money }} owed ·
|
||||||
|
${{ tax_set_aside | money }} set aside ·
|
||||||
|
{% if tax_still_needed > 0 %}
|
||||||
|
<span style="color:#c07070;font-weight:600;">${{ tax_still_needed | money }} still needed</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#5A9E68;font-weight:600;">fully covered ✓</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('taxes_view') }}" class="btn btn-ghost" style="font-size:0.82rem;">View Tax Details →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- This Month by Stream -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color: #A0C5A8;">
|
||||||
|
<h3>{{ month_name }} {{ year }} — by Stream</h3>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Stream</th><th>Owner</th><th>This Month</th><th>YTD</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in income_streams %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ s.name }}</strong></td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.72rem;">{{ s.owner }}</span></td>
|
||||||
|
<td class="amount">${{ by_stream.get(s.name, 0) | money }}</td>
|
||||||
|
<td class="amount muted">${{ ytd_by_stream.get(s.name, 0) | money }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-ghost" style="font-size:0.75rem;padding:0.2rem 0.5rem;"
|
||||||
|
onclick="openLogModal('{{ s.name }}')">+ Log</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total</td>
|
||||||
|
<td class="amount">${{ month_total | money }}</td>
|
||||||
|
<td class="amount muted">${{ ytd_total | money }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- YTD by Month -->
|
||||||
|
{% if ytd_by_month %}
|
||||||
|
<div class="budget-section" style="margin-top:1.5rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color: #C5BBA0;">
|
||||||
|
<h3>{{ year }} — Month by Month</h3>
|
||||||
|
</div>
|
||||||
|
<div style="padding:1rem 0.5rem;">
|
||||||
|
<canvas id="income-monthly-chart" height="80"></canvas>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table" style="margin-top:0.5rem;">
|
||||||
|
<thead><tr><th>Month</th><th>Total Income</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for mk in sorted_months %}
|
||||||
|
{% set amt = ytd_by_month[mk] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ mk }}</td>
|
||||||
|
<td class="amount">${{ amt | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>YTD Total</td>
|
||||||
|
<td class="amount">${{ ytd_total | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Log Entries for This Month -->
|
||||||
|
{% if entries %}
|
||||||
|
<div class="budget-section" style="margin-top:1.5rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color: #B0CDD6;">
|
||||||
|
<h3>{{ month_name }} Entries</h3>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Stream</th><th>Amount</th><th>Date Logged</th><th>Note</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in entries | sort(attribute='date', reverse=True) %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ e.stream }}</td>
|
||||||
|
<td class="amount">${{ e.amount | money }}</td>
|
||||||
|
<td class="muted">{{ e.date }}</td>
|
||||||
|
<td class="muted">{{ e.note or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/income/delete/{{ e.id }}" style="display:inline;">
|
||||||
|
<input type="hidden" name="year" value="{{ year }}">
|
||||||
|
<input type="hidden" name="month" value="{{ month }}">
|
||||||
|
<button type="submit" class="btn btn-ghost" style="font-size:0.72rem;padding:0.15rem 0.4rem;color:#c07070;"
|
||||||
|
onclick="return confirm('Delete this entry?')">✕</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Log Income Modal -->
|
||||||
|
<div id="income-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:100;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:1.5rem;min-width:320px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
|
||||||
|
<h3 style="margin:0 0 1rem;">Log Income</h3>
|
||||||
|
<form method="POST" action="/income/log">
|
||||||
|
<input type="hidden" name="month_str" value="{{ '%04d-%02d' | format(year, month) }}">
|
||||||
|
<div style="margin-bottom:0.75rem;">
|
||||||
|
<label style="display:block;font-size:0.85rem;margin-bottom:0.25rem;">Stream</label>
|
||||||
|
<select name="stream" id="modal-stream" class="form-input" style="width:100%;">
|
||||||
|
{% for s in income_streams %}
|
||||||
|
<option value="{{ s.name }}">{{ s.name }} ({{ s.owner }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:0.75rem;">
|
||||||
|
<label style="display:block;font-size:0.85rem;margin-bottom:0.25rem;">Amount</label>
|
||||||
|
<input type="number" name="amount" step="0.01" min="0" class="form-input budget-input" style="width:100%;" placeholder="0.00" required>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<label style="display:block;font-size:0.85rem;margin-bottom:0.25rem;">Note (optional)</label>
|
||||||
|
<input type="text" name="note" class="form-input budget-input" style="width:100%;" placeholder="e.g. paycheck, client payment…">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.75rem;justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openLogModal(stream) {
|
||||||
|
const modal = document.getElementById('income-modal');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
if (stream) {
|
||||||
|
document.getElementById('modal-stream').value = stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('income-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
document.getElementById('income-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeModal();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="margin-top:1.5rem;">
|
||||||
|
<button class="btn btn-primary" onclick="openLogModal('')">+ Log Income</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Backfill -->
|
||||||
|
<div class="budget-section" style="margin-top:2rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
|
||||||
|
<h3>Backfill Past Months</h3>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="padding:0.5rem 0 1rem;">Enter totals for any month you haven't logged yet. Leave blank to skip. Existing entries won't be duplicated.</p>
|
||||||
|
<form method="POST" action="/income/backfill">
|
||||||
|
<input type="hidden" name="year" value="{{ year }}">
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
{% for s in income_streams %}
|
||||||
|
<th style="min-width:110px;">
|
||||||
|
{{ s.name }}<br>
|
||||||
|
<span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.68rem;">{{ s.owner }}</span>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in range(1, 13) %}
|
||||||
|
{% set mk = '%04d-%02d' | format(year, m) %}
|
||||||
|
{% set mn = month_names[m] %}
|
||||||
|
{% set is_current = (m == month) %}
|
||||||
|
<tr{% if is_current %} style="background:#f5f2ee;"{% endif %}>
|
||||||
|
<td>
|
||||||
|
<strong>{{ mn }}</strong>
|
||||||
|
{% if is_current %}<span class="muted" style="font-size:0.75rem;"> (current)</span>{% endif %}
|
||||||
|
{% if ytd_by_month.get(mk) %}
|
||||||
|
<span style="font-size:0.72rem;color:#7A9E78;margin-left:0.25rem;">✓ ${{ "%.0f"|format(ytd_by_month[mk]) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% for s in income_streams %}
|
||||||
|
<td>
|
||||||
|
<input type="number" step="0.01" min="0"
|
||||||
|
name="amt_{{ m }}_{{ s.name | replace(' ','_') }}"
|
||||||
|
class="budget-input" style="width:100px;"
|
||||||
|
placeholder="0.00">
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:1rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Save All</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{% if ytd_by_month %}
|
||||||
|
const incomeLabels = {{ sorted_months | tojson }};
|
||||||
|
const incomeValues = [{% for mk in sorted_months %}{{ ytd_by_month[mk] }},{% endfor %}];
|
||||||
|
|
||||||
|
new Chart(document.getElementById('income-monthly-chart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: incomeLabels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Income',
|
||||||
|
data: incomeValues,
|
||||||
|
backgroundColor: '#7AB87A',
|
||||||
|
borderRadius: 6,
|
||||||
|
borderSkipped: false,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toFixed(2) } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: { grid: { color: '#f0ece6' }, ticks: { callback: v => '$' + v.toLocaleString() } }
|
||||||
|
},
|
||||||
|
animation: { duration: 400 },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
385
templates/index.html
Normal file
385
templates/index.html
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>{{ month_name }} {{ year }} — Overview</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top summary cards -->
|
||||||
|
<div class="card-grid" style="margin-bottom:1.5rem;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Income</div>
|
||||||
|
<div class="card-value" style="color:#5A9E68;">${{ total_income | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Spent</div>
|
||||||
|
<div class="card-value">${{ (total_spending + total_bills) | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Saved to Funds</div>
|
||||||
|
<div class="card-value">${{ total_sinking | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="border: 2px solid {% if net >= 0 %}#A8C5A0{% else %}#C57A7A{% endif %};">
|
||||||
|
<div class="card-label">{{ 'Left Over' if net >= 0 else 'Over Budget' }}</div>
|
||||||
|
<div class="card-value" style="color:{% if net >= 0 %}#5A9E68{% else %}#c07070{% endif %};">
|
||||||
|
${{ net | abs | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plain-English summary -->
|
||||||
|
{% if month_summary %}
|
||||||
|
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.5rem;">
|
||||||
|
<div style="font-size:0.78rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);margin-bottom:0.5rem;">How's it going?</div>
|
||||||
|
{% for line in month_summary %}
|
||||||
|
<div style="font-size:0.9rem;color:var(--text-dark);margin-bottom:0.25rem;">{{ line | bold_md }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Cash flow bar -->
|
||||||
|
{% if total_income > 0 %}
|
||||||
|
{% set spent_pct = [(total_spending + total_bills) / total_income * 100, 100] | min | int %}
|
||||||
|
{% set sinking_pct = [total_sinking / total_income * 100, 100 - spent_pct] | min | int %}
|
||||||
|
<div style="margin-bottom:2rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--text-mid);margin-bottom:0.35rem;">
|
||||||
|
<span>Where did the money go?</span>
|
||||||
|
<span>${{ total_income | money }} income this month</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;height:18px;border-radius:999px;overflow:hidden;background:#EDE8E2;">
|
||||||
|
<div style="width:{{ spent_pct }}%;background:#C57A7A;" title="Spent"></div>
|
||||||
|
<div style="width:{{ sinking_pct }}%;background:#A8C5A0;" title="Savings"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:1.25rem;font-size:0.78rem;color:var(--text-mid);margin-top:0.4rem;">
|
||||||
|
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#C57A7A;margin-right:3px;"></span>Spent {{ spent_pct }}%</span>
|
||||||
|
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#A8C5A0;margin-right:3px;"></span>Saved {{ sinking_pct }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if spending_by_cat %}
|
||||||
|
<div style="display:flex;gap:1.5rem;align-items:center;margin-bottom:2rem;flex-wrap:wrap;">
|
||||||
|
<div style="flex:0 0 220px;"><canvas id="overview-donut" width="220" height="220"></canvas></div>
|
||||||
|
<div style="flex:1;min-width:180px;">
|
||||||
|
<div style="font-size:0.82rem;font-weight:600;margin-bottom:0.5rem;color:var(--text-light);text-transform:uppercase;letter-spacing:0.05em;">{{ month_name }} Spending</div>
|
||||||
|
{% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %}
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.83rem;">
|
||||||
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:{{ colors.get(cat,'#ddd') }};flex-shrink:0;"></span>
|
||||||
|
<span style="flex:1;">{{ cat }}</span>
|
||||||
|
<span style="font-weight:600;">${{ "%.0f" | format(amt) }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;">
|
||||||
|
|
||||||
|
<!-- Left column -->
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- Income by stream -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||||
|
<h3>Income</h3>
|
||||||
|
<a href="{{ url_for('income_view', year=year, month=month) }}" class="edit-link">manage →</a>
|
||||||
|
</div>
|
||||||
|
{% if income_by_stream %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for s in income_streams %}
|
||||||
|
{% set amt = income_by_stream.get(s.name, 0) %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.name }}</td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.68rem;">{{ s.owner }}</span></td>
|
||||||
|
<td class="amount {% if amt == 0 %}muted{% endif %}">${{ amt | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total · <span class="muted" style="font-weight:400;font-size:0.82rem;">{{ year }} YTD ${{ "%.0f"|format(income_ytd_total) }}</span></td>
|
||||||
|
<td class="amount">${{ total_income | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding:0.75rem 0;">No income logged yet. <a href="{{ url_for('income_view', year=year, month=month) }}">Log income →</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bills -->
|
||||||
|
<div class="budget-section" style="margin-top:1.25rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#C57A7A;">
|
||||||
|
<h3>Bills This Month</h3>
|
||||||
|
<a href="{{ url_for('bill_calendar', year=year, month=month) }}" class="edit-link">calendar →</a>
|
||||||
|
</div>
|
||||||
|
{% if monthly_bills %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for b in monthly_bills %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ b.name }}</td>
|
||||||
|
<td class="muted">day {{ b.due_day }}</td>
|
||||||
|
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total</td>
|
||||||
|
<td class="amount">${{ total_bills | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding:0.75rem 0;">No bills this month. <a href="{{ url_for('bills_list') }}">Manage bills →</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sinking contributions -->
|
||||||
|
{% if sinking_contributions %}
|
||||||
|
<div class="budget-section" style="margin-top:1.25rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
|
||||||
|
<h3>Saved to Funds</h3>
|
||||||
|
<a href="{{ url_for('sinking_view') }}" class="edit-link">manage →</a>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for name, amt in sinking_contributions.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ name }}</td>
|
||||||
|
<td class="amount">${{ amt | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="amount">${{ total_sinking | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column -->
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- Cash accounts -->
|
||||||
|
{% if cash_accounts %}
|
||||||
|
<div class="budget-section" style="margin-bottom:1.25rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||||
|
<h3>Account Balances</h3>
|
||||||
|
<a href="{{ url_for('net_worth_view') }}" class="edit-link">manage →</a>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for a in cash_accounts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.name }}</td>
|
||||||
|
<td class="muted" style="font-size:0.78rem;">{{ a.type or '' }}</td>
|
||||||
|
<td class="amount">${{ (a.balance or 0) | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total Cash</td>
|
||||||
|
<td class="amount">${{ total_cash | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="budget-section" style="margin-bottom:1.25rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||||
|
<h3>Account Balances</h3>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="padding:0.5rem 0;font-size:0.85rem;">No accounts added yet. <a href="{{ url_for('net_worth_view') }}">Add accounts →</a></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Spending by category -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||||
|
<h3>Spending by Category</h3>
|
||||||
|
<a href="{{ url_for('transactions_view') }}" class="edit-link">all transactions →</a>
|
||||||
|
</div>
|
||||||
|
{% if spending_by_cat %}
|
||||||
|
{% set max_spend = spending_by_cat.values() | max %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Category</th><th>Budgeted</th><th>Actual</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %}
|
||||||
|
{% set budgeted = budgeted_by_cat.get(cat, 0) %}
|
||||||
|
{% set over = budgeted > 0 and amt > budgeted %}
|
||||||
|
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
|
||||||
|
<td><span class="tag" style="background:{{ colors.get(cat,'#ddd') }};">{{ cat }}</span></td>
|
||||||
|
<td class="amount muted">{% if budgeted %}${{ budgeted | money }}{% else %}<span style="color:#ccc;">—</span>{% endif %}</td>
|
||||||
|
<td class="amount">
|
||||||
|
${{ amt | money }}
|
||||||
|
{% if over %}<span style="font-size:0.7rem;color:#c07070;"> over</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="width:28%;padding-right:0.5rem;">
|
||||||
|
<div style="background:#EDE8E2;border-radius:4px;height:8px;">
|
||||||
|
<div style="background:{{ colors.get(cat,'#bbb') }};border-radius:4px;height:8px;width:{{ [(amt/max_spend*100)|int,100]|min }}%;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="amount muted">{% if budgeted_by_cat %}${{ "%.2f"|format(budgeted_by_cat.values()|sum) }}{% endif %}</td>
|
||||||
|
<td class="amount">${{ total_spending | money }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding:0.75rem 0;">No categorized transactions yet. <a href="{{ url_for('review') }}">Review transactions →</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent transactions -->
|
||||||
|
{% if recent %}
|
||||||
|
<div class="budget-section" style="margin-top:1.25rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#8AABCC;">
|
||||||
|
<h3>Recent Transactions</h3>
|
||||||
|
<a href="{{ url_for('transactions_view') }}" class="edit-link">view all →</a>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for t in recent %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted" style="white-space:nowrap;">{{ t.date }}</td>
|
||||||
|
<td style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ t.description }}</td>
|
||||||
|
<td class="amount">${{ t.amount | money }}</td>
|
||||||
|
<td><span class="tag" style="background:{{ colors.get(t.category,'#ddd') }};font-size:0.72rem;">{{ t.category }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget vs Actual chart -->
|
||||||
|
{% if budgeted_by_cat %}
|
||||||
|
<div class="budget-section" style="margin-top:1.5rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||||
|
<h3>Budget vs Actual — {{ month_name }}</h3>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative;height:{{ [budgeted_by_cat | length * 42, 200] | max }}px;">
|
||||||
|
<canvas id="budget-actual-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Tax estimate widget -->
|
||||||
|
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-top:1.5rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;margin-bottom:0.2rem;">🧾 {{ year }} Tax Estimate</div>
|
||||||
|
<div style="font-size:0.82rem;color:var(--text-light);">
|
||||||
|
${{ tax_estimate.total | money }} estimated owed ·
|
||||||
|
${{ tax_set_aside | money }} set aside ·
|
||||||
|
{% if tax_still_needed > 0 %}
|
||||||
|
<span style="color:#c07070;font-weight:600;">${{ tax_still_needed | money }} still needed</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#5A9E68;font-weight:600;">fully covered ✓</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('taxes_view') }}" class="btn btn-ghost" style="font-size:0.82rem;">View Details →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Net summary bar -->
|
||||||
|
<div class="leftover-bar {% if net < 0 %}leftover-negative{% endif %}" style="margin-top:2rem;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;">{{ month_name }} {{ year }} — Net</div>
|
||||||
|
<div style="font-size:0.82rem;color:var(--text-light);margin-top:0.2rem;">
|
||||||
|
${{ "%.2f"|format(total_income) }} income − ${{ "%.2f"|format(total_spending + total_bills) }} spent − ${{ "%.2f"|format(total_sinking) }} saved
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="leftover-amount">{% if net >= 0 %}+{% endif %}${{ net | money }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{% if spending_by_cat %}
|
||||||
|
const donutLabels = {{ spending_by_cat.keys() | list | tojson }};
|
||||||
|
const donutValues = {{ spending_by_cat.values() | list | tojson }};
|
||||||
|
const donutColors = [{% for cat in spending_by_cat %}'{{ colors.get(cat, "#ddd") }}',{% endfor %}];
|
||||||
|
|
||||||
|
new Chart(document.getElementById('overview-donut'), {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: donutLabels,
|
||||||
|
datasets: [{
|
||||||
|
data: donutValues,
|
||||||
|
backgroundColor: donutColors,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#faf7f2',
|
||||||
|
hoverOffset: 6,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
cutout: '65%',
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.toFixed(2) } }
|
||||||
|
},
|
||||||
|
animation: { duration: 400 },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if budgeted_by_cat %}
|
||||||
|
const baLabels = {{ budgeted_by_cat.keys() | list | tojson }};
|
||||||
|
const baBudgeted = {{ budgeted_by_cat.values() | list | tojson }};
|
||||||
|
const baActual = [{% for cat in budgeted_by_cat %}{{ spending_by_cat.get(cat, 0) }},{% endfor %}];
|
||||||
|
const baColors = [{% for cat in budgeted_by_cat %}'{{ colors.get(cat, "#ddd") }}',{% endfor %}];
|
||||||
|
|
||||||
|
new Chart(document.getElementById('budget-actual-chart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: baLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Budgeted',
|
||||||
|
data: baBudgeted,
|
||||||
|
backgroundColor: baColors.map(c => c + '55'),
|
||||||
|
borderColor: baColors,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Actual',
|
||||||
|
data: baActual,
|
||||||
|
backgroundColor: baColors,
|
||||||
|
borderRadius: 4,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top', labels: { font: { size: 12 }, padding: 16 } },
|
||||||
|
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.x.toFixed(2) } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { callback: v => '$' + v, font: { size: 11 } }, grid: { color: '#EDE8E2' } },
|
||||||
|
y: { ticks: { font: { size: 12 } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
244
templates/month.html
Normal file
244
templates/month.html
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>{{ month_name }} {{ year }} — Monthly Summary</h1>
|
||||||
|
<div class="month-nav">
|
||||||
|
<a href="{{ url_for('month_summary', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||||
|
<a href="{{ url_for('month_summary', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top-line summary cards -->
|
||||||
|
<div class="card-grid" style="margin-bottom:1.5rem;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Total Income</div>
|
||||||
|
<div class="card-value" style="color:#5A9E68;">${{ total_income | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Total Spent</div>
|
||||||
|
<div class="card-value">${{ (total_spending + total_bills) | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Saved to Funds</div>
|
||||||
|
<div class="card-value">${{ total_sinking | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="border: 2px solid {% if net >= 0 %}#A8C5A0{% else %}#C57A7A{% endif %};">
|
||||||
|
<div class="card-label">{{ 'Left Over' if net >= 0 else 'Over Budget' }}</div>
|
||||||
|
<div class="card-value" style="color:{% if net >= 0 %}#5A9E68{% else %}#c07070{% endif %};">
|
||||||
|
${{ net | abs | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cash flow bar -->
|
||||||
|
{% if total_income > 0 %}
|
||||||
|
{% set spent_pct = [(total_spending + total_bills) / total_income * 100, 100] | min | int %}
|
||||||
|
{% set sinking_pct = [total_sinking / total_income * 100, 100 - spent_pct] | min | int %}
|
||||||
|
<div style="margin-bottom:2rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--text-mid);margin-bottom:0.35rem;">
|
||||||
|
<span>Where did the money go?</span>
|
||||||
|
<span>${{ total_income | money }} income</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;height:18px;border-radius:999px;overflow:hidden;background:#EDE8E2;">
|
||||||
|
<div style="width:{{ spent_pct }}%;background:#C57A7A;" title="Spent"></div>
|
||||||
|
<div style="width:{{ sinking_pct }}%;background:#A8C5A0;" title="Savings"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:1.25rem;font-size:0.78rem;color:var(--text-mid);margin-top:0.4rem;">
|
||||||
|
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#C57A7A;margin-right:3px;"></span>Spent {{ spent_pct }}%</span>
|
||||||
|
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#A8C5A0;margin-right:3px;"></span>Saved {{ sinking_pct }}%</span>
|
||||||
|
{% if net > 0 %}
|
||||||
|
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EDE8E2;border:1px solid #ccc;margin-right:3px;"></span>Left over {{ [100 - spent_pct - sinking_pct, 0] | max }}%</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;">
|
||||||
|
|
||||||
|
<!-- Left column -->
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- Income -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||||
|
<h3>Income</h3>
|
||||||
|
<a href="{{ url_for('income_view', year=year, month=month) }}" class="edit-link">manage</a>
|
||||||
|
</div>
|
||||||
|
{% if income_by_stream %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for s in income_streams %}
|
||||||
|
{% set amt = income_by_stream.get(s.name, 0) %}
|
||||||
|
{% if amt > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.name }}</td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.68rem;">{{ s.owner }}</span></td>
|
||||||
|
<td class="amount">${{ amt | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total</td>
|
||||||
|
<td class="amount">${{ total_income | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding:0.75rem 0;">No income logged yet. <a href="{{ url_for('income_view', year=year, month=month) }}">Log income →</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bills -->
|
||||||
|
<div class="budget-section" style="margin-top:1.25rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#C57A7A;">
|
||||||
|
<h3>Bills</h3>
|
||||||
|
<a href="{{ url_for('bills_list') }}" class="edit-link">manage</a>
|
||||||
|
</div>
|
||||||
|
{% if monthly_bills %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for b in monthly_bills %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ b.name }}</td>
|
||||||
|
<td class="muted">day {{ b.due_day }}</td>
|
||||||
|
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total</td>
|
||||||
|
<td class="amount">${{ total_bills | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding:0.75rem 0;">No bills this month.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sinking Funds -->
|
||||||
|
{% if sinking_contributions %}
|
||||||
|
<div class="budget-section" style="margin-top:1.25rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
|
||||||
|
<h3>Sinking Fund Contributions</h3>
|
||||||
|
<a href="{{ url_for('sinking_view') }}" class="edit-link">manage</a>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for name, amt in sinking_contributions.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ name }}</td>
|
||||||
|
<td class="amount">${{ amt | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total Saved</td>
|
||||||
|
<td class="amount">${{ total_sinking | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column -->
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- Spending by category -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||||
|
<h3>Spending by Category</h3>
|
||||||
|
<a href="{{ url_for('transactions_view') }}" class="edit-link">view transactions</a>
|
||||||
|
</div>
|
||||||
|
{% if spending_by_cat %}
|
||||||
|
{% set max_spend = spending_by_cat.values() | max %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Category</th><th>Budgeted</th><th>Actual</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %}
|
||||||
|
{% set budgeted = budgeted_by_cat.get(cat, 0) %}
|
||||||
|
{% set over = budgeted > 0 and amt > budgeted %}
|
||||||
|
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
|
||||||
|
<td><span class="tag" style="background:{{ colors.get(cat,'#ddd') }};">{{ cat }}</span></td>
|
||||||
|
<td class="amount muted">{% if budgeted %}${{ budgeted | money }}{% else %}<span style="color:#ccc;">—</span>{% endif %}</td>
|
||||||
|
<td class="amount">
|
||||||
|
${{ amt | money }}
|
||||||
|
{% if over %}<span style="font-size:0.7rem;color:#c07070;"> over</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="width:30%;padding-right:0.5rem;">
|
||||||
|
<div style="background:#EDE8E2;border-radius:4px;height:8px;">
|
||||||
|
<div style="background:{{ colors.get(cat,'#bbb') }};border-radius:4px;height:8px;width:{{ [(amt / max_spend * 100)|int, 100]|min }}%;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="amount muted">{% if budgeted_by_cat %}${{ budgeted_by_cat.values( | money|sum) }}{% endif %}</td>
|
||||||
|
<td class="amount">${{ total_spending | money }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding:0.75rem 0;">No categorized transactions yet. <a href="{{ url_for('review') }}">Review transactions →</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debt payments -->
|
||||||
|
{% if debt_payments %}
|
||||||
|
<div class="budget-section" style="margin-top:1.25rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#C57A7A;">
|
||||||
|
<h3>Debt Payments</h3>
|
||||||
|
<a href="{{ url_for('debts_view', year=year, month=month) }}" class="edit-link">manage</a>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Debt</th><th>Owner</th><th>Payment</th><th>Extra</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in debt_payments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ d.name }}</td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.68rem;">{{ d.owner }}</span></td>
|
||||||
|
<td class="amount">${{ d.payment | money }}</td>
|
||||||
|
<td class="amount">{% if d.extra > 0 %}<span style="color:#5A9E68;">+${{ d.extra | money }}</span>{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total Paid</td>
|
||||||
|
<td class="amount">${{ total_debt_paid | money }}</td>
|
||||||
|
<td class="amount" style="color:#5A9E68;">{% if total_extra_paid > 0 %}+${{ total_extra_paid | money }} extra{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom net summary -->
|
||||||
|
<div class="leftover-bar {% if net < 0 %}leftover-negative{% endif %}" style="margin-top:2rem;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:1rem;">{{ month_name }} {{ year }} — Final Summary</div>
|
||||||
|
<div style="font-size:0.82rem;color:var(--text-light);margin-top:0.2rem;">
|
||||||
|
${{ "%.2f"|format(total_income) }} income
|
||||||
|
− ${{ "%.2f"|format(total_spending + total_bills) }} spent
|
||||||
|
− ${{ "%.2f"|format(total_sinking) }} saved
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="leftover-amount">
|
||||||
|
{% if net >= 0 %}+{% endif %}${{ net | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
157
templates/mortgage.html
Normal file
157
templates/mortgage.html
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Mortgage Tracker</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Setup form -->
|
||||||
|
<div class="card-wide" style="margin-bottom:1.75rem;">
|
||||||
|
<h2>Mortgage Details</h2>
|
||||||
|
<p class="helper-text">Fill these in once you close on your home. Everything calculates automatically.</p>
|
||||||
|
<form method="POST" class="bill-form">
|
||||||
|
<div class="bill-form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Loan Amount</label>
|
||||||
|
<input type="number" name="principal" step="0.01" min="0"
|
||||||
|
value="{{ principal or '' }}" placeholder="e.g. 280000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Interest Rate (APR %)</label>
|
||||||
|
<input type="number" name="annual_rate" step="0.001" min="0" max="20"
|
||||||
|
value="{{ annual_rate or '' }}" placeholder="e.g. 6.875">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Loan Term</label>
|
||||||
|
<select name="term_years">
|
||||||
|
<option value="30" {% if term_years == 30 %}selected{% endif %}>30 years</option>
|
||||||
|
<option value="20" {% if term_years == 20 %}selected{% endif %}>20 years</option>
|
||||||
|
<option value="15" {% if term_years == 15 %}selected{% endif %}>15 years</option>
|
||||||
|
<option value="10" {% if term_years == 10 %}selected{% endif %}>10 years</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>First Payment Date</label>
|
||||||
|
<input type="date" name="start_date" value="{{ start_date or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Extra Monthly Payment <span class="optional">(optional)</span></label>
|
||||||
|
<input type="number" name="extra_monthly" step="0.01" min="0"
|
||||||
|
value="{{ extra_monthly or '' }}" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save & Calculate</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if principal %}
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="mortgage-summary">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Monthly Payment</div>
|
||||||
|
<div class="card-value">${{ base_payment | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Current Balance</div>
|
||||||
|
<div class="card-value">${{ "{:,.0f}".format(current_bal) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Total Interest (standard)</div>
|
||||||
|
<div class="card-value">${{ "{:,.0f}".format(total_interest_standard) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Payoff</div>
|
||||||
|
<div class="card-value">{{ standard_months }} mo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Strategy comparison -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Payment Strategies</h2>
|
||||||
|
<div class="mortgage-strategies">
|
||||||
|
|
||||||
|
<!-- Standard -->
|
||||||
|
<div class="mortgage-strategy-card" style="border-top:4px solid #B8B8B8;">
|
||||||
|
<div class="strategy-label">Standard Monthly</div>
|
||||||
|
<div class="strategy-subtitle">Pay ${{ base_payment | money }}/month</div>
|
||||||
|
<div class="strategy-stat"><span>Payoff</span><strong>{{ standard_months }} months ({{ (standard_months / 12) | round(1) }} yrs)</strong></div>
|
||||||
|
<div class="strategy-stat"><span>Total interest</span><strong>${{ "{:,.2f}".format(total_interest_standard) }}</strong></div>
|
||||||
|
<div class="strategy-stat"><span>Interest saved</span><strong>—</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Biweekly -->
|
||||||
|
<div class="mortgage-strategy-card" style="border-top:4px solid #A8C5A0;">
|
||||||
|
<div class="strategy-label">Biweekly Payments</div>
|
||||||
|
<div class="strategy-subtitle">Pay ${{ (base_payment / 2) | money }} every 2 weeks</div>
|
||||||
|
<div class="strategy-stat"><span>Payoff</span><strong>{{ bi_months_total }} months ({{ bi_years }} yrs)</strong></div>
|
||||||
|
<div class="strategy-stat"><span>Total interest</span><strong>${{ "{:,.2f}".format(bi_interest) }}</strong></div>
|
||||||
|
<div class="strategy-stat"><span>Interest saved</span>
|
||||||
|
<strong class="accent">${{ "{:,.2f}".format(interest_saved_biweekly) }}
|
||||||
|
({{ months_saved_biweekly }} months sooner)</strong>
|
||||||
|
</div>
|
||||||
|
<div class="strategy-badge">One extra payment per year</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Extra monthly -->
|
||||||
|
{% if extra_monthly > 0 %}
|
||||||
|
<div class="mortgage-strategy-card" style="border-top:4px solid #D4B8C5;">
|
||||||
|
<div class="strategy-label">Extra ${{ "%.0f" | format(extra_monthly) }}/Month</div>
|
||||||
|
<div class="strategy-subtitle">Pay ${{ (base_payment + extra_monthly) | money }}/month total</div>
|
||||||
|
<div class="strategy-stat"><span>Payoff</span><strong>{{ extra_months }} months ({{ (extra_months / 12) | round(1) }} yrs)</strong></div>
|
||||||
|
<div class="strategy-stat"><span>Total interest</span><strong>${{ "{:,.2f}".format(total_interest_extra) }}</strong></div>
|
||||||
|
<div class="strategy-stat"><span>Interest saved</span>
|
||||||
|
<strong class="accent">${{ "{:,.2f}".format(interest_saved_extra) }}
|
||||||
|
({{ months_saved_extra }} months sooner)</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mortgage-strategy-card mortgage-strategy-empty" style="border-top:4px solid #D4B8C5;">
|
||||||
|
<div class="strategy-label">Extra Monthly Payment</div>
|
||||||
|
<div class="strategy-subtitle">Enter an extra monthly amount above to see the impact</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Amortization schedule -->
|
||||||
|
<section class="section">
|
||||||
|
<details class="amortization-details">
|
||||||
|
<summary class="amortization-toggle">
|
||||||
|
View Full Amortization Schedule
|
||||||
|
<span class="muted">({{ schedule | length }} payments)</span>
|
||||||
|
</summary>
|
||||||
|
<div class="amortization-wrap">
|
||||||
|
<table class="txn-table txn-table-full amortization-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Payment</th>
|
||||||
|
<th>Principal</th>
|
||||||
|
<th>Interest</th>
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in schedule %}
|
||||||
|
<tr class="{% if row.interest > row.principal %}amort-interest-heavy{% endif %}">
|
||||||
|
<td class="muted">{{ row.month }}</td>
|
||||||
|
<td>{{ row.date }}</td>
|
||||||
|
<td class="amount">${{ row.payment | money }}</td>
|
||||||
|
<td class="amount">${{ row.principal | money }}</td>
|
||||||
|
<td class="amount amort-interest">${{ row.interest | money }}</td>
|
||||||
|
<td class="amount">${{ "{:,.2f}".format(row.balance) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Enter your mortgage details above once you close on your home and everything will calculate automatically.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
225
templates/net_worth.html
Normal file
225
templates/net_worth.html
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Net Worth</h1>
|
||||||
|
<span class="muted" style="font-size:0.85rem;">Assets minus Liabilities</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Big Net Worth Number -->
|
||||||
|
<div class="net-worth-hero {% if net_worth >= 0 %}net-worth-positive{% else %}net-worth-negative{% endif %}">
|
||||||
|
<div class="net-worth-label">Current Net Worth</div>
|
||||||
|
<div class="net-worth-number">${{ net_worth | money }}</div>
|
||||||
|
<div class="net-worth-sub">
|
||||||
|
<span>Assets: <strong>${{ total_assets | money }}</strong></span>
|
||||||
|
<span style="margin:0 0.75rem;opacity:0.5;">−</span>
|
||||||
|
<span>Debts: <strong>${{ total_debts | money }}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:1.5rem;">
|
||||||
|
|
||||||
|
<!-- ASSETS COLUMN -->
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom:1rem;color:var(--accent);">Assets</h2>
|
||||||
|
|
||||||
|
<!-- Cash & Accounts -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#7AB87A;">
|
||||||
|
<h3>Cash & Bank Accounts</h3>
|
||||||
|
<span class="muted" style="font-size:0.82rem;">${{ total_cash | money }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Account</th><th>Owner</th><th>Balance</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for acc in accounts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ acc.name }}</td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(acc.owner,'#ddd') }};font-size:0.72rem;">{{ acc.owner }}</span></td>
|
||||||
|
<td class="amount">
|
||||||
|
<input type="number" step="0.01" class="budget-input"
|
||||||
|
value="{{ acc.balance }}"
|
||||||
|
onchange="updateAccount('{{ acc.id }}', this.value)"
|
||||||
|
style="width:110px;">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/net-worth/account/delete/{{ acc.id }}" style="display:inline;">
|
||||||
|
<button type="submit" class="debt-delete-btn" onclick="return confirm('Remove this account?')">✕</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total Cash</td>
|
||||||
|
<td class="amount">${{ total_cash | money }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Add Account -->
|
||||||
|
<details style="margin-top:0.75rem;">
|
||||||
|
<summary class="btn btn-ghost" style="display:inline-block;cursor:pointer;font-size:0.82rem;">+ Add Account</summary>
|
||||||
|
<form method="POST" action="/net-worth/account/add" style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.75rem;align-items:flex-end;">
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.78rem;">Account Name</label>
|
||||||
|
<input type="text" name="name" placeholder="e.g. WestConsin Checking" class="budget-input" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.78rem;">Owner</label>
|
||||||
|
<select name="owner" class="budget-input">
|
||||||
|
<option value="Bonna">Bonna</option>
|
||||||
|
<option value="Tony">Tony</option>
|
||||||
|
<option value="Both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.78rem;">Balance</label>
|
||||||
|
<input type="number" step="0.01" name="balance" placeholder="0.00" class="budget-input" style="width:110px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="align-self:flex-end;">Add</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sinking Funds -->
|
||||||
|
<div class="budget-section" style="margin-top:1rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
|
||||||
|
<h3>Sinking Funds</h3>
|
||||||
|
<a href="{{ url_for('sinking_view') }}" class="edit-link">manage</a>
|
||||||
|
<span class="muted" style="font-size:0.82rem;">${{ total_sinking | money }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for f in sinking_funds %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ f.name }}</td>
|
||||||
|
<td class="amount">${{ (f.balance or 0) | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="amount">${{ total_sinking | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vehicles -->
|
||||||
|
{% if personal_cars %}
|
||||||
|
<div class="budget-section" style="margin-top:1rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||||
|
<h3>Vehicles</h3>
|
||||||
|
<a href="{{ url_for('assets_view') }}" class="edit-link">manage</a>
|
||||||
|
<span class="muted" style="font-size:0.82rem;">${{ total_vehicles | money }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for car in personal_cars %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ car.display_name }}</td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(car.owner,'#ddd') }};font-size:0.72rem;">{{ car.owner }}</span></td>
|
||||||
|
<td class="amount">
|
||||||
|
{% if car.estimated_value %}
|
||||||
|
${{ car.estimated_value | float | money }}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">no value set</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total</td>
|
||||||
|
<td class="amount">${{ total_vehicles | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Other Assets -->
|
||||||
|
{% if other_assets %}
|
||||||
|
<div class="budget-section" style="margin-top:1rem;">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#A08CC5;">
|
||||||
|
<h3>Other Assets</h3>
|
||||||
|
<a href="{{ url_for('assets_view') }}" class="edit-link">manage</a>
|
||||||
|
<span class="muted" style="font-size:0.82rem;">${{ total_other_assets | money }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for a in other_assets %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.name }}</td>
|
||||||
|
<td class="muted" style="font-size:0.8rem;">{{ a.category }}</td>
|
||||||
|
<td class="amount">${{ a.value | float | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total</td>
|
||||||
|
<td class="amount">${{ total_other_assets | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div><!-- end assets column -->
|
||||||
|
|
||||||
|
<!-- LIABILITIES COLUMN -->
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom:1rem;color:#C57A7A;">Liabilities (Debts)</h2>
|
||||||
|
|
||||||
|
{% for cat in debt_categories %}
|
||||||
|
{% set cat_debts = debts_by_cat[cat] %}
|
||||||
|
{% if cat_debts %}
|
||||||
|
<div class="budget-section" style="margin-top:{% if loop.first %}0{% else %}1rem{% endif %};">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#C57A7A;">
|
||||||
|
<h3>{{ cat }}</h3>
|
||||||
|
<span class="muted" style="font-size:0.82rem;">${{ debt_cat_totals[cat] | money }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
{% for d in cat_debts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ d.name }}</td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.72rem;">{{ d.owner }}</span></td>
|
||||||
|
<td class="amount">${{ d.current_balance | float | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Subtotal</td>
|
||||||
|
<td class="amount">${{ debt_cat_totals[cat] | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="leftover-bar" style="margin-top:1rem;">
|
||||||
|
<span>Total Debt</span>
|
||||||
|
<span style="font-size:1.1rem;font-weight:700;color:#C57A7A;">${{ total_debts | money }}</span>
|
||||||
|
</div>
|
||||||
|
</div><!-- end liabilities column -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateAccount(id, value) {
|
||||||
|
fetch('/net-worth/account/update/' + id, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({balance: value})
|
||||||
|
}).then(() => location.reload());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
396
templates/review.html
Normal file
396
templates/review.html
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Review Transactions</h1>
|
||||||
|
{% if remaining %}<span class="badge">{{ remaining }} remaining</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if remaining %}
|
||||||
|
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.85rem 1.25rem;margin-bottom:1.25rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<span style="font-size:0.88rem;color:var(--text-mid);">Need to bulk-clear old transactions?</span>
|
||||||
|
<form method="POST" action="{{ url_for('bulk_ignore_before') }}" onsubmit="return confirm('Ignore all unreviewed transactions before this date?')">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<label style="font-size:0.85rem;">Ignore everything before</label>
|
||||||
|
<input type="date" name="before_date" required class="budget-input" style="width:150px;">
|
||||||
|
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.85rem;">Bulk ignore</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not transaction %}
|
||||||
|
<div class="empty-state card-wide">
|
||||||
|
<div class="empty-icon">✓</div>
|
||||||
|
<h2>All caught up!</h2>
|
||||||
|
<p>Every transaction has been reviewed.</p>
|
||||||
|
<a href="{{ url_for('transactions_view') }}" class="btn btn-primary">View All Transactions</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="review-card">
|
||||||
|
<div class="review-meta">
|
||||||
|
<span class="review-date">{{ transaction.date }}</span>
|
||||||
|
<span class="review-account">{{ transaction.account }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="review-description">{{ transaction.description }}</div>
|
||||||
|
<div class="review-amount">${{ transaction.amount | money }}</div>
|
||||||
|
|
||||||
|
{% if bnpl_lender and bnpl_matches %}
|
||||||
|
<div style="background:#f0f5ff;border:1.5px solid #b0c4ef;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;">
|
||||||
|
<div style="font-weight:600;margin-bottom:0.5rem;">💳 Looks like a {{ bnpl_lender | title }} payment — which loan is this?</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.5rem;">
|
||||||
|
{% for loan in bnpl_matches %}
|
||||||
|
<form method="POST" action="{{ url_for('match_bnpl') }}">
|
||||||
|
<input type="hidden" name="txn_id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="debt_id" value="{{ loan.id }}">
|
||||||
|
<input type="hidden" name="category" value="Debt">
|
||||||
|
<button type="submit" style="width:100%;text-align:left;background:{% if loan.amount_match %}#e8f0fe{% else %}#fff{% endif %};border:1px solid {% if loan.amount_match %}#7aacf0{% else %}var(--border){% endif %};border-radius:6px;padding:0.6rem 0.85rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:1rem;">
|
||||||
|
<span>
|
||||||
|
<strong>{{ loan.name }}</strong>
|
||||||
|
<span style="font-size:0.78rem;color:var(--text-light);margin-left:0.5rem;">{{ loan.payments_made }}/{{ loan.total_payments }} payments made</span>
|
||||||
|
</span>
|
||||||
|
<span style="white-space:nowrap;">
|
||||||
|
${{ loan.payment_amount | money }}
|
||||||
|
{% if loan.amount_match %}<span style="color:#5A9E68;font-size:0.78rem;margin-left:0.4rem;">✓ amount matches</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:0.6rem;font-size:0.8rem;color:var(--text-light);">Selecting a loan will mark one payment as made and categorize this as Debt. Or ignore this and categorize manually below.</div>
|
||||||
|
</div>
|
||||||
|
{% elif bnpl_lender %}
|
||||||
|
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;">
|
||||||
|
<div style="font-weight:600;margin-bottom:0.25rem;">💳 Looks like a {{ bnpl_lender | title }} payment — no active loans found</div>
|
||||||
|
<div style="font-size:0.83rem;color:var(--text-light);margin-bottom:0.75rem;">Want to set up this loan now so future payments get tracked automatically?</div>
|
||||||
|
<button type="button" class="btn btn-ghost" style="font-size:0.85rem;" onclick="document.getElementById('bnpl-quick-add').style.display='block';this.style.display='none';">
|
||||||
|
+ Add {{ bnpl_lender | title }} loan now
|
||||||
|
</button>
|
||||||
|
<div id="bnpl-quick-add" style="display:none;margin-top:0.75rem;">
|
||||||
|
<form method="POST" action="{{ url_for('bnpl_add') }}">
|
||||||
|
<input type="hidden" name="bnpl_provider" value="{{ bnpl_lender | title }}">
|
||||||
|
<input type="hidden" name="redirect_to" value="review">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-bottom:0.75rem;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">What did you buy?</label>
|
||||||
|
<input type="text" name="description" class="budget-input" style="width:100%;" placeholder="e.g. Nike Shoes" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Merchant (optional)</label>
|
||||||
|
<input type="text" name="merchant" class="budget-input" style="width:100%;" placeholder="e.g. Nike">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Payment Amount</label>
|
||||||
|
<input type="number" name="payment_amount" step="0.01" min="0" class="budget-input" style="width:100%;" value="{{ transaction.amount }}" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Total # of Payments</label>
|
||||||
|
<input type="number" name="total_payments" min="1" class="budget-input" style="width:100%;" placeholder="e.g. 4" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Frequency</label>
|
||||||
|
<select name="frequency" class="budget-input" style="width:100%;">
|
||||||
|
<option value="biweekly">Every 2 weeks</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Next Payment Date</label>
|
||||||
|
<input type="date" name="next_payment_date" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Who's it for?</label>
|
||||||
|
<select name="owner" class="budget-input" style="width:100%;">
|
||||||
|
<option value="Shared">Shared</option>
|
||||||
|
<option value="Bonna">Bonna</option>
|
||||||
|
<option value="Tony">Tony</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Payments already made</label>
|
||||||
|
<input type="number" name="payments_made" min="0" value="1" class="budget-input" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.75rem;align-items:center;">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Loan & Continue</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="document.getElementById('bnpl-quick-add').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
|
||||||
|
<span style="font-size:0.78rem;color:var(--text-light);">This transaction will still need to be categorized on the next screen</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if suggestion %}
|
||||||
|
<div class="suggestion-banner">
|
||||||
|
<span>Suggested: <strong>{{ suggestion.category }}{% if suggestion.subcategory %} › {{ suggestion.subcategory }}{% endif %}</strong></span>
|
||||||
|
<form method="POST" action="{{ url_for('categorize') }}" style="display:inline;">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="category" value="{{ suggestion.category }}">
|
||||||
|
<input type="hidden" name="subcategory" value="{{ suggestion.subcategory or '' }}">
|
||||||
|
<button type="submit" class="btn btn-accept">Accept</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('categorize') }}" class="categorize-form">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<div class="category-grid">
|
||||||
|
{% for cat, subcats in categories.items() %}
|
||||||
|
<label class="cat-option" style="--cat-color: {{ colors.get(cat, '#ddd') }};">
|
||||||
|
<input type="radio" name="category" value="{{ cat }}"
|
||||||
|
{% if suggestion and suggestion.category == cat %}checked{% endif %}
|
||||||
|
onchange="updateSubcats(this.value)">
|
||||||
|
<span>{{ cat }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="subcat-group">
|
||||||
|
<label>Subcategory <span class="optional">(optional)</span></label>
|
||||||
|
<select name="subcategory" id="subcat-select">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sinking fund picker — shown when Transfer > Sinking Fund is selected -->
|
||||||
|
<div class="form-group" id="sinking-fund-prompt" style="display:none;">
|
||||||
|
<label>Which fund? <span class="optional">(will auto-add to that fund's balance)</span></label>
|
||||||
|
<select name="sinking_fund_name" id="sinking-fund-select" class="budget-input">
|
||||||
|
<option value="">— select fund —</option>
|
||||||
|
{% for f in sinking_funds %}
|
||||||
|
<option value="{{ f.name }}">{{ f.name }} (current: ${{ (f.balance or 0) | money }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Car prompt — shown when Gas subcategory is selected -->
|
||||||
|
<div class="form-group" id="car-prompt" style="display:none;">
|
||||||
|
<label>Which car? <span class="optional">(will auto-log to that car's profile)</span></label>
|
||||||
|
<select name="car_id" id="car-select">
|
||||||
|
<option value="">— select car —</option>
|
||||||
|
{% for car in personal_cars %}
|
||||||
|
<option value="{{ car.id }}">{{ car.display_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notes <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" name="notes" placeholder="e.g. Costco run, birthday gift...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="remember" checked>
|
||||||
|
Remember this for future transactions from this merchant
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save & Next</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Split transaction</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Quick-action buttons — must be outside the main form -->
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.75rem;">
|
||||||
|
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.82rem;"
|
||||||
|
title="Marks as reviewed but excludes from all totals — use for bill payments, debt payments, income deposits">
|
||||||
|
✓ Already tracked — ignore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" class="btn btn-ghost" style="background:#e8f5e9;color:#3a7d44;border:1px solid #a5d6a7;font-size:0.82rem;" onclick="document.getElementById('review-sinking-picker').style.display='block';this.style.display='none';">🏦 Sinking Fund Transfer</button>
|
||||||
|
<div id="review-sinking-picker" style="display:none;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:var(--radius);padding:0.75rem 1rem;width:100%;">
|
||||||
|
<form method="POST" action="{{ url_for('categorize_sinking_transfer') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="redirect" value="review">
|
||||||
|
<label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:0.4rem;">Which sinking fund?</label>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<select name="sinking_fund_name" class="budget-input" style="min-width:180px;" required>
|
||||||
|
<option value="">— select fund —</option>
|
||||||
|
{% for f in sinking_funds %}
|
||||||
|
<option value="{{ f.name }}">{{ f.name }} (${{ (f.balance or 0) | money }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;">Save & Next</button>
|
||||||
|
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;" onclick="document.getElementById('review-sinking-picker').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="label" value="Investment Transfer">
|
||||||
|
<button type="submit" class="btn btn-ghost" style="background:#e3f2fd;color:#1565c0;border:1px solid #90caf9;font-size:0.82rem;">📈 Investment Transfer</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ url_for('ignore_transaction') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<input type="hidden" name="label" value="Savings Transfer">
|
||||||
|
<button type="submit" class="btn btn-ghost" style="background:#f3e5f5;color:#6a1b9a;border:1px solid #ce93d8;font-size:0.82rem;">💰 Savings Transfer</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ url_for('skip') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<button type="submit" class="btn btn-ghost" style="font-size:0.82rem;">Skip for now</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Split transaction form -->
|
||||||
|
<div id="split-panel" style="display:none;margin-top:1.5rem;">
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||||
|
<h3>Split Transaction — ${{ transaction.amount | money }} total</h3>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem;">Divide this charge across multiple categories. Amounts must add up to ${{ transaction.amount | money }}.</p>
|
||||||
|
<form method="POST" action="{{ url_for('split_transaction') }}">
|
||||||
|
<input type="hidden" name="id" value="{{ transaction.id }}">
|
||||||
|
<table class="budget-table" id="split-table">
|
||||||
|
<thead><tr><th>Category</th><th>Subcategory</th><th>Amount</th><th>Note</th><th></th></tr></thead>
|
||||||
|
<tbody id="split-rows">
|
||||||
|
<tr class="split-row">
|
||||||
|
<td>
|
||||||
|
<select name="split_cat_0" class="budget-input split-cat" onchange="updateSplitSubcat(this, 0)" style="width:130px;">
|
||||||
|
<option value="">— pick —</option>
|
||||||
|
{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><select name="split_subcat_0" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||||
|
<td><input type="number" name="split_amt_0" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||||
|
<td><input type="text" name="split_note_0" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="split-row">
|
||||||
|
<td>
|
||||||
|
<select name="split_cat_1" class="budget-input split-cat" onchange="updateSplitSubcat(this, 1)" style="width:130px;">
|
||||||
|
<option value="">— pick —</option>
|
||||||
|
{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><select name="split_subcat_1" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||||
|
<td><input type="number" name="split_amt_1" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||||
|
<td><input type="text" name="split_note_1" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="margin:0.75rem 0;font-size:0.85rem;">
|
||||||
|
Remaining: <strong id="split-remaining">${{ transaction.amount | money }}</strong>
|
||||||
|
<button type="button" class="btn btn-ghost" style="margin-left:1rem;font-size:0.78rem;" onclick="addSplitRow()">+ Add row</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.75rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Split & Next</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const subcats = {{ categories | tojson }};
|
||||||
|
const suggestion = {{ suggestion | tojson if suggestion else 'null' }};
|
||||||
|
|
||||||
|
function updateSubcats(cat) {
|
||||||
|
const select = document.getElementById('subcat-select');
|
||||||
|
const group = document.getElementById('subcat-group');
|
||||||
|
const list = subcats[cat] || [];
|
||||||
|
select.innerHTML = '<option value="">— none —</option>';
|
||||||
|
list.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s;
|
||||||
|
opt.textContent = s;
|
||||||
|
if (suggestion && suggestion.subcategory === s) opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
group.style.display = list.length ? 'block' : 'none';
|
||||||
|
checkCarPrompt(cat, select.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCarPrompt(cat, subcat) {
|
||||||
|
const carPrompt = document.getElementById('car-prompt');
|
||||||
|
if (cat === 'Car' && subcat === 'Gas') {
|
||||||
|
carPrompt.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
carPrompt.style.display = 'none';
|
||||||
|
document.getElementById('car-select').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sinkingPrompt = document.getElementById('sinking-fund-prompt');
|
||||||
|
if (cat === 'Transfer' && subcat === 'Sinking Fund') {
|
||||||
|
sinkingPrompt.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
sinkingPrompt.style.display = 'none';
|
||||||
|
document.getElementById('sinking-fund-select').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('subcat-select').addEventListener('change', function() {
|
||||||
|
const cat = document.querySelector('input[name="category"]:checked');
|
||||||
|
checkCarPrompt(cat ? cat.value : '', this.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize on load
|
||||||
|
const checked = document.querySelector('input[name="category"]:checked');
|
||||||
|
if (checked) updateSubcats(checked.value);
|
||||||
|
document.querySelectorAll('input[name="category"]').forEach(r => {
|
||||||
|
r.addEventListener('change', () => updateSubcats(r.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Split transaction
|
||||||
|
const totalAmount = {{ transaction.amount }};
|
||||||
|
const allSubcats = {{ categories | tojson }};
|
||||||
|
let splitRowCount = 2;
|
||||||
|
|
||||||
|
function toggleSplit() {
|
||||||
|
const panel = document.getElementById('split-panel');
|
||||||
|
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSplitSubcat(selectEl, idx) {
|
||||||
|
const cat = selectEl.value;
|
||||||
|
const subcatSel = document.querySelector(`select[name="split_subcat_${idx}"]`);
|
||||||
|
const list = allSubcats[cat] || [];
|
||||||
|
subcatSel.innerHTML = '<option value="">— none —</option>';
|
||||||
|
list.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s; opt.textContent = s;
|
||||||
|
subcatSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRemaining() {
|
||||||
|
const inputs = document.querySelectorAll('.split-amt');
|
||||||
|
let used = 0;
|
||||||
|
inputs.forEach(el => used += parseFloat(el.value) || 0);
|
||||||
|
const remaining = Math.round((totalAmount - used) * 100) / 100;
|
||||||
|
const el = document.getElementById('split-remaining');
|
||||||
|
el.textContent = '$' + remaining.toFixed(2);
|
||||||
|
el.style.color = Math.abs(remaining) < 0.01 ? '#5A9E68' : (remaining < 0 ? '#c07070' : 'inherit');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSplitRow() {
|
||||||
|
const idx = splitRowCount++;
|
||||||
|
const tbody = document.getElementById('split-rows');
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'split-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><select name="split_cat_${idx}" class="budget-input split-cat" onchange="updateSplitSubcat(this,${idx})" style="width:130px;">
|
||||||
|
<option value="">— pick —</option>
|
||||||
|
${Object.keys(allSubcats).map(c => `<option value="${c}">${c}</option>`).join('')}
|
||||||
|
</select></td>
|
||||||
|
<td><select name="split_subcat_${idx}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
|
||||||
|
<td><input type="number" name="split_amt_${idx}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
|
||||||
|
<td><input type="text" name="split_note_${idx}" class="budget-input" style="width:110px;" placeholder="optional"></td>
|
||||||
|
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;">✕</button></td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
175
templates/sinking.html
Normal file
175
templates/sinking.html
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Sinking Funds</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total savings banner -->
|
||||||
|
<div class="sinking-total-bar">
|
||||||
|
<div>
|
||||||
|
<div class="sinking-total-label">Total Across All Funds</div>
|
||||||
|
<div class="sinking-total-amount">${{ total | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sinking-total-note">Match this number to your savings account balance</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fund cards -->
|
||||||
|
<div class="sinking-grid">
|
||||||
|
{% for f in funds %}
|
||||||
|
{% set color = fund_colors.get(f.name, '#C5BBA0') %}
|
||||||
|
<div class="sinking-card" style="border-top: 4px solid {{ color }};">
|
||||||
|
<div class="sinking-card-header">
|
||||||
|
<div class="sinking-card-name">{{ f.name }}</div>
|
||||||
|
<span class="sinking-type-badge {% if f.type == 'goal' %}badge-goal{% endif %}">
|
||||||
|
{{ 'Goal' if f.type == 'goal' else 'Ongoing' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if f.notes %}
|
||||||
|
<div class="sinking-notes">{{ f.notes }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sinking-balance">${{ (f.balance or 0) | money }}</div>
|
||||||
|
|
||||||
|
{% if f.type == 'goal' and f.goal %}
|
||||||
|
{% set pct = f.pct or 0 %}
|
||||||
|
<div class="sinking-progress-wrap">
|
||||||
|
<div class="sinking-progress-bar">
|
||||||
|
<div class="sinking-progress-fill" style="width:{{ pct }}%;background:{{ color }};"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sinking-progress-label">
|
||||||
|
${{ (f.balance or 0) | money }} of ${{ f.goal | money }} ({{ pct }}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if f.monthly_needed %}
|
||||||
|
<div class="sinking-monthly-needed">
|
||||||
|
Save <strong>${{ f.monthly_needed | money }}/mo</strong>
|
||||||
|
to reach goal
|
||||||
|
{% if f.months_left %} in {{ f.months_left }} months{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if f.monthly %}
|
||||||
|
<div class="sinking-monthly-needed">
|
||||||
|
Monthly contribution: <strong>${{ f.monthly | money }}</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Contribute / Withdraw -->
|
||||||
|
<div class="sinking-actions">
|
||||||
|
<form method="POST" action="{{ url_for('sinking_contribute') }}" class="sinking-action-form">
|
||||||
|
<input type="hidden" name="name" value="{{ f.name }}">
|
||||||
|
<input type="number" name="amount" step="0.01" min="0" placeholder="Amount" class="sinking-amount-input">
|
||||||
|
<button type="submit" class="btn btn-accept">+ Add</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ url_for('sinking_withdraw') }}" class="sinking-action-form">
|
||||||
|
<input type="hidden" name="name" value="{{ f.name }}">
|
||||||
|
<input type="number" name="amount" step="0.01" min="0" placeholder="Amount" class="sinking-amount-input">
|
||||||
|
<button type="submit" class="btn btn-ghost">− Use</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit settings -->
|
||||||
|
<details class="sinking-edit-details">
|
||||||
|
<summary class="sinking-edit-toggle">Edit settings</summary>
|
||||||
|
<div class="sinking-edit-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Correct Balance <span class="optional">(override if something's off)</span></label>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||||
|
<input type="number" step="0.01" id="balance-override-{{ loop.index }}" placeholder="{{ '%.2f' | format(f.balance or 0) }}" style="flex:1;">
|
||||||
|
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;white-space:nowrap;"
|
||||||
|
onclick="setBalance('{{ f.name }}', document.getElementById('balance-override-{{ loop.index }}').value)">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type</label>
|
||||||
|
<select onchange="updateFund('{{ f.name }}', 'type', this.value)">
|
||||||
|
<option value="ongoing" {% if f.type != 'goal' %}selected{% endif %}>Ongoing</option>
|
||||||
|
<option value="goal" {% if f.type == 'goal' %}selected{% endif %}>Goal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Monthly Contribution</label>
|
||||||
|
<input type="number" step="0.01" value="{{ f.monthly or '' }}" placeholder="0.00"
|
||||||
|
onchange="updateFund('{{ f.name }}', 'monthly', this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Goal Amount <span class="optional">(if goal type)</span></label>
|
||||||
|
<input type="number" step="0.01" value="{{ f.goal or '' }}" placeholder="0.00"
|
||||||
|
onchange="updateFund('{{ f.name }}', 'goal', this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Target Date <span class="optional">(if goal type)</span></label>
|
||||||
|
<input type="date" value="{{ f.target_date or '' }}"
|
||||||
|
onchange="updateFund('{{ f.name }}', 'target_date', this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notes</label>
|
||||||
|
<input type="text" value="{{ f.notes or '' }}" placeholder="Optional note..."
|
||||||
|
onchange="updateFund('{{ f.name }}', 'notes', this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Add new fund card -->
|
||||||
|
<div class="sinking-card sinking-add-card">
|
||||||
|
<div class="sinking-card-name" style="margin-bottom:1rem;">+ New Fund</div>
|
||||||
|
<form method="POST" action="{{ url_for('sinking_add') }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Fund Name</label>
|
||||||
|
<input type="text" name="name" placeholder="e.g. New Car, Vacation" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type</label>
|
||||||
|
<select name="type">
|
||||||
|
<option value="ongoing">Ongoing</option>
|
||||||
|
<option value="goal">Goal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Starting Balance <span class="optional">(optional)</span></label>
|
||||||
|
<input type="number" name="balance" step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Monthly Contribution</label>
|
||||||
|
<input type="number" name="monthly" step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Goal Amount <span class="optional">(if goal)</span></label>
|
||||||
|
<input type="number" name="goal" step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Target Date <span class="optional">(if goal)</span></label>
|
||||||
|
<input type="date" name="target_date">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notes <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" name="notes" placeholder="">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Fund</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateFund(name, field, value) {
|
||||||
|
fetch('/sinking/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({name, field, value})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBalance(name, value) {
|
||||||
|
if (value === '' || isNaN(parseFloat(value))) return;
|
||||||
|
fetch('/sinking/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({name, field: 'balance', value})
|
||||||
|
}).then(() => location.reload());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
116
templates/strategy.html
Normal file
116
templates/strategy.html
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Debt Payoff Strategy</h1>
|
||||||
|
<a href="{{ url_for('debts_view') }}" class="btn btn-ghost">← Back to Debts</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-wide" style="margin-bottom:1.5rem;">
|
||||||
|
<h2>How much can you put toward debt each month?</h2>
|
||||||
|
<p class="helper-text">Your current total minimums are <strong>${{ total_minimums | money }}</strong>. Enter your total monthly debt payment below — anything above the minimums gets applied strategically.</p>
|
||||||
|
<form method="POST" style="display:flex;gap:1rem;align-items:flex-end;flex-wrap:wrap;margin-top:1rem;">
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label>Total Monthly Payment</label>
|
||||||
|
<input type="number" name="extra_monthly" step="0.01" min="{{ total_minimums }}"
|
||||||
|
value="{{ total_monthly or total_minimums }}" placeholder="{{ total_minimums }}">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Calculate</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if av_months %}
|
||||||
|
<!-- Summary comparison -->
|
||||||
|
<div class="strategy-compare">
|
||||||
|
<div class="strategy-card strategy-avalanche">
|
||||||
|
<div class="strategy-label">Avalanche Method</div>
|
||||||
|
<div class="strategy-subtitle">Highest interest rate first</div>
|
||||||
|
<div class="strategy-stat">
|
||||||
|
<span>Debt-free in</span>
|
||||||
|
<strong>{{ av_months }} months</strong>
|
||||||
|
</div>
|
||||||
|
<div class="strategy-stat">
|
||||||
|
<span>Total interest paid</span>
|
||||||
|
<strong>${{ av_interest | money }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="strategy-badge">Saves the most money</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="strategy-vs">VS</div>
|
||||||
|
|
||||||
|
<div class="strategy-card strategy-snowball">
|
||||||
|
<div class="strategy-label">Snowball Method</div>
|
||||||
|
<div class="strategy-subtitle">Smallest balance first</div>
|
||||||
|
<div class="strategy-stat">
|
||||||
|
<span>Debt-free in</span>
|
||||||
|
<strong>{{ sb_months }} months</strong>
|
||||||
|
</div>
|
||||||
|
<div class="strategy-stat">
|
||||||
|
<span>Total interest paid</span>
|
||||||
|
<strong>${{ sb_interest | money }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="strategy-badge">Faster wins, more motivation</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if interest_saved > 0 or months_diff != 0 %}
|
||||||
|
<div class="strategy-insight">
|
||||||
|
{% if interest_saved > 0 %}
|
||||||
|
<span>The Avalanche method saves you <strong>${{ interest_saved | money }}</strong> in interest</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if months_diff > 0 %}
|
||||||
|
<span>The Snowball method takes <strong>{{ months_diff }} month{{ 's' if months_diff != 1 }}</strong> longer</span>
|
||||||
|
{% elif months_diff < 0 %}
|
||||||
|
<span>The Avalanche method takes <strong>{{ months_diff|abs }} month{{ 's' if months_diff|abs != 1 }}</strong> longer</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Side by side payoff order -->
|
||||||
|
<div class="strategy-tables">
|
||||||
|
<div class="strategy-table-wrap">
|
||||||
|
<h3>Avalanche Payoff Order</h3>
|
||||||
|
<table class="txn-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>Debt</th><th>Owner</th><th>Paid Off</th><th>Interest Paid</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in avalanche | sort(attribute='paid_off_month') %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted">{{ loop.index }}</td>
|
||||||
|
<td>{{ d.name }}</td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner, '#ddd') }};">{{ d.owner }}</span></td>
|
||||||
|
<td>{{ d.paid_off_month or '—' }}</td>
|
||||||
|
<td class="amount">${{ d.total_interest | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="strategy-table-wrap">
|
||||||
|
<h3>Snowball Payoff Order</h3>
|
||||||
|
<table class="txn-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>Debt</th><th>Owner</th><th>Paid Off</th><th>Interest Paid</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in snowball | sort(attribute='paid_off_month') %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted">{{ loop.index }}</td>
|
||||||
|
<td>{{ d.name }}</td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner, '#ddd') }};">{{ d.owner }}</span></td>
|
||||||
|
<td>{{ d.paid_off_month or '—' }}</td>
|
||||||
|
<td class="amount">${{ d.total_interest | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Add your debts and monthly payment amount above to see your payoff strategy.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
71
templates/summary.html
Normal file
71
templates/summary.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Spending Report</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if by_category %}
|
||||||
|
|
||||||
|
<!-- Horizontal bar chart -->
|
||||||
|
<div class="card-wide" style="margin-bottom:1.5rem;">
|
||||||
|
<canvas id="spending-bar-chart" height="80"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-grid">
|
||||||
|
{% for cat, data in by_category | dictsort %}
|
||||||
|
<div class="summary-card" style="border-top: 4px solid {{ colors.get(cat, '#ddd') }};">
|
||||||
|
<div class="summary-cat-name">{{ cat }}</div>
|
||||||
|
<div class="summary-cat-total">${{ data.total | money }}</div>
|
||||||
|
{% if data.subcategories %}
|
||||||
|
<ul class="subcat-list">
|
||||||
|
{% for sub, amt in data.subcategories | dictsort %}
|
||||||
|
<li>
|
||||||
|
<span>{{ sub }}</span>
|
||||||
|
<span>${{ amt | money }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No categorized transactions yet. <a href="{{ url_for('import_transactions') }}">Import a statement</a> to get started.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{% if by_category %}
|
||||||
|
const spendingLabels = {{ by_category.keys() | list | tojson }};
|
||||||
|
const spendingValues = {{ by_category.values() | map(attribute='total') | list | tojson }};
|
||||||
|
const spendingColors = [{% for cat in by_category %}'{{ colors.get(cat, "#ddd") }}',{% endfor %}];
|
||||||
|
|
||||||
|
new Chart(document.getElementById('spending-bar-chart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: spendingLabels,
|
||||||
|
datasets: [{
|
||||||
|
data: spendingValues,
|
||||||
|
backgroundColor: spendingColors,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderSkipped: false,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.x.toFixed(2) } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { color: '#f0ece6' }, ticks: { callback: v => '$' + v.toLocaleString() } },
|
||||||
|
y: { grid: { display: false } }
|
||||||
|
},
|
||||||
|
animation: { duration: 400 },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
187
templates/taxes.html
Normal file
187
templates/taxes.html
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>{{ year }} Tax Estimator</h1>
|
||||||
|
<span style="font-size:0.85rem;color:var(--text-light);">Mixed income household (self-employment + W-2) — Wisconsin, MFJ</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero numbers -->
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem;">
|
||||||
|
<div class="card-wide" style="text-align:center;padding:1.25rem;">
|
||||||
|
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">YTD Income</div>
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:var(--text-dark);">${{ ytd_total | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-wide" style="text-align:center;padding:1.25rem;">
|
||||||
|
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Estimated Tax Owed</div>
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:#c07070;">${{ estimate.total | money }}</div>
|
||||||
|
<div style="font-size:0.78rem;color:var(--text-light);">~{{ estimate.effective_rate }}% effective rate</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-wide" style="text-align:center;padding:1.25rem;">
|
||||||
|
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Set Aside So Far</div>
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:#5A9E68;">${{ total_set_aside | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-wide" style="text-align:center;padding:1.25rem;">
|
||||||
|
{% if over_saved > 0 %}
|
||||||
|
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Over-Saved</div>
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:#5A9E68;">${{ over_saved | money }}</div>
|
||||||
|
<div style="font-size:0.78rem;color:var(--text-light);">nice work!</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Still Need to Set Aside</div>
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:{% if still_needed > 500 %}#c07070{% else %}#d4a843{% endif %};">${{ still_needed | money }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
{% if estimate.total > 0 %}
|
||||||
|
{% set pct = [(total_set_aside / estimate.total * 100), 100] | min | int %}
|
||||||
|
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.5rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.85rem;margin-bottom:0.5rem;">
|
||||||
|
<span>Tax savings progress</span>
|
||||||
|
<span style="font-weight:600;">{{ pct }}% saved</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#EDE8E2;border-radius:6px;height:12px;">
|
||||||
|
<div style="background:{% if pct >= 100 %}#5A9E68{% elif pct >= 60 %}#d4a843{% else %}#c07070{% endif %};border-radius:6px;height:12px;width:{{ pct }}%;transition:width 0.3s;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Breakdown -->
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.25rem;margin-bottom:1.5rem;">
|
||||||
|
|
||||||
|
<!-- Tax breakdown -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#c07070;">
|
||||||
|
<h3>How We Got There</h3>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<tbody>
|
||||||
|
<tr style="background:var(--cream);">
|
||||||
|
<td colspan="2" style="font-size:0.78rem;color:var(--text-mid);padding:0.4rem 0.5rem;">
|
||||||
|
<strong>Self-employment income</strong> (Bonna + Car Flipping): ${{ ytd_se | money }} ·
|
||||||
|
<strong>W-2 income</strong> (Tony / Auto King): ${{ ytd_w2 | money }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Self-Employment Tax <span class="muted" style="font-size:0.78rem;">(SS + Medicare on SE income only)</span></td>
|
||||||
|
<td class="amount">${{ estimate.se_tax | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Federal Income Tax <span class="muted" style="font-size:0.78rem;">(~{{ rates.federal }}% after $30k MFJ deduction)</span></td>
|
||||||
|
<td class="amount">${{ estimate.federal | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Wisconsin State Tax <span class="muted" style="font-size:0.78rem;">(~{{ rates.wi_state }}% after $30k MFJ deduction)</span></td>
|
||||||
|
<td class="amount">${{ estimate.wi_state | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total Estimated</td>
|
||||||
|
<td class="amount">${{ estimate.total | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
<details style="margin-top:0.75rem;">
|
||||||
|
<summary style="font-size:0.8rem;color:var(--text-light);cursor:pointer;">Adjust tax rates</summary>
|
||||||
|
<form method="POST" action="{{ url_for('taxes_rates') }}" style="margin-top:0.75rem;display:grid;gap:0.5rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
|
<label style="font-size:0.82rem;width:160px;">SE Tax %</label>
|
||||||
|
<input type="number" name="se_tax" step="0.1" value="{{ rates.se_tax }}" class="budget-input no-dollar" style="width:80px;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
|
<label style="font-size:0.82rem;width:160px;">Federal %</label>
|
||||||
|
<input type="number" name="federal" step="0.1" value="{{ rates.federal }}" class="budget-input no-dollar" style="width:80px;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
|
<label style="font-size:0.82rem;width:160px;">WI State %</label>
|
||||||
|
<input type="number" name="wi_state" step="0.1" value="{{ rates.wi_state }}" class="budget-input no-dollar" style="width:80px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-ghost" style="width:fit-content;font-size:0.82rem;">Update Rates</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quarterly breakdown -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#D4A96A;">
|
||||||
|
<h3>Quarterly Estimates</h3>
|
||||||
|
</div>
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Quarter</th><th>Income</th><th>Est. Tax</th><th>Due</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for q in quarters %}
|
||||||
|
<tr{% if q.income == 0 %} style="opacity:0.5;"{% endif %}>
|
||||||
|
<td style="font-size:0.85rem;">{{ q.label }}</td>
|
||||||
|
<td class="amount">${{ q.income | money }}</td>
|
||||||
|
<td class="amount">${{ q.estimate.total | money }}</td>
|
||||||
|
<td style="font-size:0.78rem;color:var(--text-light);">{{ q.due_date }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:0.75rem;color:var(--text-light);margin-top:0.5rem;padding:0 0.25rem;">These are federal estimated payment deadlines. WI follows the same schedule.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Set aside log -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header" style="border-left-color:#5A9E68;">
|
||||||
|
<h3>Tax Savings Log</h3>
|
||||||
|
</div>
|
||||||
|
<details class="add-debt-details" style="margin-bottom:1rem;">
|
||||||
|
<summary class="btn btn-primary" style="display:inline-block;cursor:pointer;">+ Log Money Set Aside</summary>
|
||||||
|
<div style="padding:1rem 0;">
|
||||||
|
<form method="POST" action="{{ url_for('taxes_set_aside') }}" style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Date</label>
|
||||||
|
<input type="date" name="date" value="{{ today.strftime('%Y-%m-%d') }}" class="budget-input" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Amount</label>
|
||||||
|
<input type="number" name="amount" step="0.01" min="0" class="budget-input" style="width:130px;" placeholder="0.00" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Notes</label>
|
||||||
|
<input type="text" name="notes" class="budget-input" style="width:200px;" placeholder="e.g. moved to savings account">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if set_aside_log %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Date</th><th>Amount</th><th>Notes</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in set_aside_log | sort(attribute='date', reverse=True) %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ e.date }}</td>
|
||||||
|
<td class="amount" style="color:#5A9E68;">${{ e.amount | money }}</td>
|
||||||
|
<td class="muted">{{ e.notes }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="{{ url_for('taxes_set_aside_delete', entry_id=e.id) }}">
|
||||||
|
<button type="submit" class="edit-link" style="color:#c07070;background:none;border:none;cursor:pointer;">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total Set Aside</td>
|
||||||
|
<td class="amount" style="color:#5A9E68;">${{ total_set_aside | money }}</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding:0.5rem 0;">Nothing logged yet. Every time you move money to your tax savings account, record it here.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;font-size:0.83rem;color:var(--text-mid);margin-top:1rem;">
|
||||||
|
<strong>Heads up:</strong> These are estimates based on flat rates and may not perfectly match your actual tax bill. We recommend setting aside the estimated amount and working with a tax professional when it's time to file. The SE tax calculation uses the IRS method (92.35% of net income) and deducts half of SE tax before calculating income tax, which is how the IRS does it.
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
168
templates/transactions.html
Normal file
168
templates/transactions.html
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>All Transactions</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert-banner {{ 'alert-success' if category == 'success' else 'alert-error' }}" style="margin-bottom:1rem;">
|
||||||
|
{{ message }}
|
||||||
|
{% if category == 'success' %}
|
||||||
|
<a href="{{ url_for('review') }}" class="btn btn-primary">Review Now</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Import -->
|
||||||
|
<details class="add-debt-details" style="margin-bottom:1.25rem;">
|
||||||
|
<summary class="btn" style="display:inline-block;cursor:pointer;background:#7A9E7E;color:#fff;border:none;">⬆️ Import Statement</summary>
|
||||||
|
<div class="card-wide" style="margin-top:0.75rem;">
|
||||||
|
<p class="helper-text" style="margin-bottom:0.75rem;">Supports CSV exports from your bank or credit union. PDF import only works if the PDF has selectable text — scanned statements won't work.</p>
|
||||||
|
<form method="POST" action="{{ url_for('import_transactions') }}" enctype="multipart/form-data" class="import-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account Name</label>
|
||||||
|
<input type="text" name="account" placeholder="e.g. WestConsin Checking, Discover" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Statement File</label>
|
||||||
|
<div class="file-drop-area">
|
||||||
|
<input type="file" name="statement" accept=".pdf,.csv" required>
|
||||||
|
<p>Click to choose a file, or drag and drop here</p>
|
||||||
|
<p class="file-types">PDF or CSV</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Import</button>
|
||||||
|
</form>
|
||||||
|
<details style="margin-top:1rem;">
|
||||||
|
<summary class="muted" style="cursor:pointer;font-size:0.85rem;">How to export your statements ▾</summary>
|
||||||
|
<ul style="margin-top:0.5rem;font-size:0.83rem;line-height:1.7;padding-left:1.25rem;">
|
||||||
|
<li><strong>WestConsin Credit Union:</strong> Log in → select your account → Download/Export → choose CSV → pick date range</li>
|
||||||
|
<li><strong>Most other banks:</strong> Log in → Account Details or Transaction History → Export / Download → choose CSV</li>
|
||||||
|
<li><strong>Affirm:</strong> affirm.com → Account → Payment History → Export</li>
|
||||||
|
<li><strong>Klarna:</strong> klarna.com → Purchases → Export CSV</li>
|
||||||
|
<li><strong>Afterpay:</strong> afterpay.com → Orders → Export</li>
|
||||||
|
</ul>
|
||||||
|
<p class="helper-text" style="margin-top:0.5rem;">If a CSV imports 0 transactions, let Claude know — the column format varies by bank and may need a quick fix.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if spending_by_cat %}
|
||||||
|
<details class="add-debt-details" style="margin-bottom:1.25rem;">
|
||||||
|
<summary class="btn btn-ghost" style="display:inline-block;cursor:pointer;">📊 {{ week_label }} Spending</summary>
|
||||||
|
<div style="margin-top:0.75rem;background:#fff;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;">
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Category</th><th>Spent</th><th>Budgeted</th><th>Remaining</th><th style="width:25%;"></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for cat, amt in spending_by_cat.items() %}
|
||||||
|
{% set budgeted = budgeted_by_cat.get(cat, 0) %}
|
||||||
|
{% set remaining = budgeted - amt %}
|
||||||
|
{% set over = budgeted > 0 and amt > budgeted %}
|
||||||
|
{% set pct = [(amt / budgeted * 100)|int, 100]|min if budgeted > 0 else 100 %}
|
||||||
|
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
|
||||||
|
<td><span class="tag" style="background:{{ colors.get(cat,'#ddd') }};">{{ cat }}</span></td>
|
||||||
|
<td class="amount">${{ amt | money }}</td>
|
||||||
|
<td class="amount muted">{% if budgeted %}${{ budgeted | money }}{% else %}<span style="color:#ccc;">—</span>{% endif %}</td>
|
||||||
|
<td class="amount {% if over %}{% else %}{% endif %}">
|
||||||
|
{% if budgeted %}
|
||||||
|
{% if over %}
|
||||||
|
<span style="color:#c07070;">-${{ (amt - budgeted) | money }} over</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#5A9E68;">${{ remaining | money }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="background:#EDE8E2;border-radius:4px;height:7px;">
|
||||||
|
<div style="background:{{ '#c07070' if over else colors.get(cat,'#bbb') }};border-radius:4px;height:7px;width:{{ pct }}%;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="amount">${{ spending_by_cat.values() | sum | money }}</td>
|
||||||
|
<td class="amount muted">{% if budgeted_by_cat %}${{ budgeted_by_cat.values() | sum | money }}{% endif %}</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="GET" style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1.25rem;background:var(--warm-white);padding:0.85rem 1rem;border-radius:var(--radius);border:1px solid var(--border);">
|
||||||
|
<div style="flex:1;min-width:180px;">
|
||||||
|
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">Search</label>
|
||||||
|
<input type="text" name="search" value="{{ filter_search }}" class="budget-input" style="width:100%;" placeholder="merchant, notes…">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">From</label>
|
||||||
|
<input type="date" name="from_date" value="{{ filter_from }}" class="budget-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">To</label>
|
||||||
|
<input type="date" name="to_date" value="{{ filter_to }}" class="budget-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">Category</label>
|
||||||
|
<select name="category" class="budget-input">
|
||||||
|
<option value="">— all —</option>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat }}" {% if filter_cat == cat %}selected{% endif %}>{{ cat }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="height:36px;">Search</button>
|
||||||
|
{% if filter_from or filter_to or filter_cat or filter_search %}
|
||||||
|
<a href="{{ url_for('transactions_view') }}" class="btn btn-ghost" style="height:36px;line-height:36px;padding-top:0;padding-bottom:0;">Clear</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if transactions %}
|
||||||
|
<table class="txn-table txn-table-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in transactions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ t.date }}</td>
|
||||||
|
<td>{{ t.description }}</td>
|
||||||
|
<td class="muted">{{ t.account }}</td>
|
||||||
|
<td class="amount">${{ t.amount | money }}</td>
|
||||||
|
<td>
|
||||||
|
{% if t.category %}
|
||||||
|
<span class="tag" style="background: {{ colors.get(t.category, '#ddd') }};">
|
||||||
|
{{ t.category }}{% if t.subcategory %} › {{ t.subcategory }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ t.notes }}</td>
|
||||||
|
<td><a href="{{ url_for('edit_transaction', txn_id=t.id) }}" class="edit-link">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No transactions yet. <a href="{{ url_for('import_transactions') }}">Import a statement</a> to get started.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
484
templates/weekly.html
Normal file
484
templates/weekly.html
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>{{ month_name }} {{ year }} — Weekly Overview</h1>
|
||||||
|
<div class="month-nav">
|
||||||
|
<a href="{{ url_for('weekly_overview', year=prev_year, month=prev_month) }}" class="btn btn-ghost">‹ Prev</a>
|
||||||
|
<a href="{{ url_for('weekly_overview', year=next_year, month=next_month) }}" class="btn btn-ghost">Next ›</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="week-tabs">
|
||||||
|
{% for w in weeks %}
|
||||||
|
<button class="week-tab" onclick="showWeek({{ w.num }})" id="tab-{{ w.num }}">
|
||||||
|
Week {{ w.num }}<br><span>{{ w.start_label }} – {{ w.end_label }}</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for w in weeks %}
|
||||||
|
<div class="week-panel" id="week-{{ w.num }}">
|
||||||
|
|
||||||
|
<!-- Week summary -->
|
||||||
|
{% if w.summary %}
|
||||||
|
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.85rem 1.1rem;margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:0.5rem 1.5rem;">
|
||||||
|
<span style="font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);width:100%;">How's this week looking?</span>
|
||||||
|
{% for line in w.summary %}
|
||||||
|
<span style="font-size:0.88rem;color:var(--text-dark);">{{ line | bold_md }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Starting Balance -->
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.75rem 1rem;margin-bottom:1rem;">
|
||||||
|
<label style="font-weight:600;white-space:nowrap;">Checking Balance at Start of Week</label>
|
||||||
|
<input type="number" step="0.01" class="budget-input" style="max-width:160px;"
|
||||||
|
id="starting-balance-{{ w.num }}"
|
||||||
|
value="{{ w.starting_balance }}"
|
||||||
|
onchange="saveStartingBalance({{ year }}, {{ month }}, '{{ w.num }}', this.value)"
|
||||||
|
placeholder="0.00">
|
||||||
|
<span style="font-size:0.82rem;color:var(--text-light);">Enter what's in checking when the week begins</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. Income -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Income', '#A0C5A8') }};cursor:pointer;">
|
||||||
|
<h3><span class="weekly-arrow">▾</span> Income</h3>
|
||||||
|
</div>
|
||||||
|
<div class="weekly-section-body">
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Source</th><th>Budgeted</th><th>Actual</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for src in w.income_sources %}
|
||||||
|
{% set inc = w.income.get(src, {}) %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ src }}
|
||||||
|
{% if src == 'Auto King' %}
|
||||||
|
<span class="income-friday-tag">Fridays</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><input type="number" step="0.01" class="budget-input"
|
||||||
|
value="{{ inc.get('budgeted', '') }}"
|
||||||
|
onchange="saveField({{ year }}, {{ month }}, '{{ w.num }}', 'income', '{{ src }}', 'budgeted', this.value)"
|
||||||
|
placeholder="expected"></td>
|
||||||
|
<td>
|
||||||
|
<div class="income-actual-wrap">
|
||||||
|
<input type="number" step="0.01" class="budget-input income-actual-input"
|
||||||
|
id="income-actual-{{ w.num }}-{{ src | replace(' ','_') }}"
|
||||||
|
value="{{ inc.get('actual', '') }}"
|
||||||
|
onchange="logIncome({{ year }}, {{ month }}, '{{ w.num }}', '{{ src }}', this.value)"
|
||||||
|
placeholder="enter when received">
|
||||||
|
{% if inc.get('actual') %}
|
||||||
|
<span class="income-logged-badge">✓ logged</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="amount">$<span id="w{{ w.num }}-income-budget-total">{{ (w.income.values() | map(attribute='budgeted') | select | map('float') | sum if w.income else 0) | money }}</span></td>
|
||||||
|
<td class="amount">$<span id="w{{ w.num }}-income-actual-total">{{ w.income_total | money }}</span></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Bills -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Debt', '#C5A0A0') }};cursor:pointer;">
|
||||||
|
<h3><span class="weekly-arrow">▾</span> Bills Due This Week</h3>
|
||||||
|
<a href="{{ url_for('bills_list') }}" class="edit-link" onclick="event.stopPropagation()">manage bills</a>
|
||||||
|
</div>
|
||||||
|
<div class="weekly-section-body">
|
||||||
|
{% if w.bills %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Bill</th><th>Due</th><th>Amount</th><th>Category</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in w.bills %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ b.name }}</td>
|
||||||
|
<td class="muted">{{ b.due_date.strftime('%b %-d') if b.due_date else '' }}</td>
|
||||||
|
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if b.category %}
|
||||||
|
<span class="tag" style="background: {{ colors.get(b.category, '#ddd') }};">{{ b.category }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Bills Total</td>
|
||||||
|
<td class="amount">${{ w.bills_total | money }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding: 0.75rem 0;">No bills due this week.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Budget Categories -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Food', '#A8C5A0') }};cursor:pointer;">
|
||||||
|
<h3><span class="weekly-arrow">▾</span> Budget Categories</h3>
|
||||||
|
</div>
|
||||||
|
<div class="weekly-section-body">
|
||||||
|
{% set skip = ['Income', 'Debt', 'Transfer'] %}
|
||||||
|
{% set total_budgeted = namespace(v=0) %}
|
||||||
|
{% set total_actual = namespace(v=0) %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Category</th><th>Budgeted</th><th>Spent</th><th>Left to Spend</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for cat in categories if cat not in skip %}
|
||||||
|
{% set cb = w.cat_budget.get(cat, {}) %}
|
||||||
|
{% set actual = w.cat_actuals.get(cat, 0) %}
|
||||||
|
{% set budgeted = cb.get('budgeted', '') | float %}
|
||||||
|
{% set left = budgeted - actual %}
|
||||||
|
{% set over = budgeted > 0 and actual > budgeted %}
|
||||||
|
{% set total_budgeted.v = total_budgeted.v + budgeted %}
|
||||||
|
{% set total_actual.v = total_actual.v + actual %}
|
||||||
|
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
|
||||||
|
<td>
|
||||||
|
<span class="tag" style="background: {{ colors.get(cat, '#ddd') }};">{{ cat }}</span>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" step="0.01" class="budget-input"
|
||||||
|
value="{{ cb.get('budgeted', '') }}"
|
||||||
|
onchange="saveField({{ year }}, {{ month }}, '{{ w.num }}', 'categories', '{{ cat }}', 'budgeted', this.value)"
|
||||||
|
placeholder="0.00"></td>
|
||||||
|
<td class="amount{% if actual == 0 %} muted{% endif %}">
|
||||||
|
${{ actual | money }}
|
||||||
|
</td>
|
||||||
|
<td class="amount">
|
||||||
|
{% if budgeted %}
|
||||||
|
{% if over %}
|
||||||
|
<span style="color:#c07070;">-${{ (actual - budgeted) | money }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#5A9E68;">${{ left | money }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
{% set total_left = total_budgeted.v - total_actual.v %}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Totals</td>
|
||||||
|
<td class="amount">${{ total_budgeted.v | money }}</td>
|
||||||
|
<td class="amount">${{ total_actual.v | money }}</td>
|
||||||
|
<td class="amount">
|
||||||
|
{% if total_budgeted.v > 0 %}
|
||||||
|
{% if total_left < 0 %}
|
||||||
|
<span style="color:#c07070;font-weight:700;">-${{ total_left | abs | money }} over</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#5A9E68;font-weight:700;">${{ total_left | money }} left</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Sinking Funds -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Travel', '#C5BBA0') }};cursor:pointer;">
|
||||||
|
<h3><span class="weekly-arrow">▸</span> Sinking Funds</h3>
|
||||||
|
<a href="{{ url_for('sinking_view') }}" class="edit-link" onclick="event.stopPropagation()">manage funds</a>
|
||||||
|
</div>
|
||||||
|
<div class="weekly-section-body" style="display:none;">
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Fund</th><th>Current Balance</th><th>Add This Week</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in sinking_funds %}
|
||||||
|
{% set wkey = 'sinking_' ~ w.num ~ '_' ~ f.name %}
|
||||||
|
{% set sinking_entry = w.sinking.get(f.name, {}) %}
|
||||||
|
{% set contributed = sinking_entry.get('contributed', '') %}
|
||||||
|
{% set from_txn = sinking_entry.get('from_transaction') %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="tag" style="background: {{ fund_colors.get(f.name, '#C5BBA0') }};">{{ f.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="amount">${{ (f.balance or 0) | money }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="income-actual-wrap">
|
||||||
|
<input type="number" step="0.01" class="budget-input"
|
||||||
|
value="{{ contributed }}"
|
||||||
|
onchange="contributeSinking('{{ f.name }}', this.value, {{ year }}, {{ month }}, '{{ w.num }}')"
|
||||||
|
placeholder="0.00"
|
||||||
|
{% if from_txn %}title="Logged automatically from a categorized transaction"{% endif %}>
|
||||||
|
{% if from_txn %}
|
||||||
|
<span class="income-logged-badge" title="This was recorded when you categorized a transaction">✓ from transaction</span>
|
||||||
|
{% elif contributed %}
|
||||||
|
<span class="income-logged-badge">✓ added</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total Contributed This Week</td>
|
||||||
|
<td class="amount">${{ (w.sinking.values() | selectattr('contributed') | map(attribute='contributed') | map('float') | sum) | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. Extra Debt Payments -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Debt', '#C5A0A0') }};cursor:pointer;">
|
||||||
|
<h3><span class="weekly-arrow">▸</span> Extra Debt Payments</h3>
|
||||||
|
<a href="{{ url_for('debts_view') }}" class="edit-link" onclick="event.stopPropagation()">manage debts</a>
|
||||||
|
</div>
|
||||||
|
<div class="weekly-section-body" style="display:none;">
|
||||||
|
{% if active_debts %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Debt</th><th>Owner</th><th>Minimum</th><th>Extra Payment</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in active_debts %}
|
||||||
|
{% set extra_paid = w.extra_debt.get(d.id, {}).get('extra', '') %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ d.name }}</td>
|
||||||
|
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.72rem;">{{ d.owner }}</span></td>
|
||||||
|
<td class="amount muted">${{ (d.minimum or 0) | money }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="income-actual-wrap">
|
||||||
|
<input type="number" step="0.01" class="budget-input"
|
||||||
|
value="{{ extra_paid }}"
|
||||||
|
onchange="logExtraDebt('{{ d.id }}', this.value, {{ year }}, {{ month }}, '{{ w.num }}')"
|
||||||
|
placeholder="0.00">
|
||||||
|
{% if extra_paid %}
|
||||||
|
<span class="income-logged-badge">✓ logged</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="3">Total Extra This Week</td>
|
||||||
|
<td class="amount accent">${{ (w.extra_debt.values() | map(attribute='extra') | select | map('float') | sum) | money }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding:0.75rem 0;">No debts added yet. <a href="{{ url_for('debts_view') }}">Add debts</a> to track extra payments here.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. Transactions -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color:#D4A96A;cursor:pointer;">
|
||||||
|
<h3><span class="weekly-arrow">▸</span> Transactions This Week{% if w.transactions %} <span style="font-weight:400;font-size:0.82rem;color:var(--text-light);">({{ w.transactions | length }})</span>{% endif %}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="weekly-section-body" style="display:none;">
|
||||||
|
|
||||||
|
<!-- Inline import -->
|
||||||
|
<details style="margin-bottom:1rem;">
|
||||||
|
<summary class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;background:#7A9E7E;color:#fff;border:none;">⬆️ Import Statement</summary>
|
||||||
|
<div style="margin-top:0.75rem;background:var(--cream);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;">
|
||||||
|
<form method="POST" action="{{ url_for('import_transactions') }}" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="redirect_to" value="weekly">
|
||||||
|
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Account Name</label>
|
||||||
|
<input type="text" name="account" placeholder="e.g. WestConsin Checking" class="budget-input" style="width:200px;" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Statement File (PDF or CSV)</label>
|
||||||
|
<input type="file" name="statement" accept=".pdf,.csv" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="font-size:0.85rem;">Import</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if w.transactions %}
|
||||||
|
{% set txns_by_cat = {} %}
|
||||||
|
{% for t in w.transactions %}
|
||||||
|
{% set cat = t.category or 'Uncategorized' %}
|
||||||
|
{% if cat not in txns_by_cat %}{% set _ = txns_by_cat.update({cat: []}) %}{% endif %}
|
||||||
|
{% set _ = txns_by_cat[cat].append(t) %}
|
||||||
|
{% endfor %}
|
||||||
|
<table class="budget-table">
|
||||||
|
<thead><tr><th>Date</th><th>Description</th><th>Category</th><th>Amount</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in w.transactions | sort(attribute='date', reverse=True) %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted" style="white-space:nowrap;font-size:0.82rem;">{{ t.date }}</td>
|
||||||
|
<td style="font-size:0.85rem;">{{ t.description }}</td>
|
||||||
|
<td>
|
||||||
|
{% if t.category %}
|
||||||
|
<span class="tag" style="background:{{ colors.get(t.category,'#ddd') }};font-size:0.75rem;">{{ t.category }}{% if t.subcategory %} › {{ t.subcategory }}{% endif %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted" style="font-size:0.78rem;">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="amount" style="font-size:0.85rem;">${{ t.amount | money }}</td>
|
||||||
|
<td><a href="{{ url_for('edit_transaction', txn_id=t.id) }}" class="edit-link" style="font-size:0.78rem;">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="3">Total</td>
|
||||||
|
<td class="amount">${{ (w.transactions | map(attribute='amount') | map('float') | sum) | money }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="padding:0.5rem 0;">No reviewed transactions for this week yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leftover -->
|
||||||
|
<div class="leftover-bar {% if w.leftover < 0 %}leftover-negative{% endif %}" id="leftover-bar-{{ w.num }}">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;">Week {{ w.num }} — What's Left</div>
|
||||||
|
<div style="font-size:0.78rem;color:var(--text-light);margin-top:0.2rem;line-height:1.7;">
|
||||||
|
$<span id="lo-start-{{ w.num }}">{{ w.starting_balance | money }}</span> starting
|
||||||
|
+ $<span id="lo-income-{{ w.num }}">{{ w.income_total | money }}</span> income
|
||||||
|
− $<span id="lo-spending-{{ w.num }}">{{ w.spending_total | money }}</span> spending
|
||||||
|
− $<span id="lo-sinking-{{ w.num }}">{{ w.sinking_total | money }}</span> savings
|
||||||
|
− $<span id="lo-debt-{{ w.num }}">{{ w.extra_debt_total | money }}</span> extra debt
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="leftover-amount" id="leftover-amount-{{ w.num }}"
|
||||||
|
data-spending="{{ w.spending_total }}"
|
||||||
|
data-sinking="{{ w.sinking_total }}"
|
||||||
|
data-debt="{{ w.extra_debt_total }}"
|
||||||
|
data-start="{{ w.starting_balance }}"
|
||||||
|
>${{ w.leftover | money }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- end week-panel -->
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showWeek(num) {
|
||||||
|
document.querySelectorAll('.week-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.week-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.getElementById('week-' + num).classList.add('active');
|
||||||
|
document.getElementById('tab-' + num).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveField(year, month, week, field, key, subkey, value) {
|
||||||
|
fetch('/weekly/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({year, month, week, field, key, subkey, value})
|
||||||
|
}).then(() => updateLeftover(week));
|
||||||
|
}
|
||||||
|
|
||||||
|
function logIncome(year, month, week, source, value) {
|
||||||
|
fetch('/weekly/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({year, month, week, field: 'income', key: source, subkey: 'actual', value})
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
updateLeftover(week);
|
||||||
|
// show ✓ badge
|
||||||
|
const id = 'income-actual-' + week + '-' + source.replace(/ /g,'_');
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
if (input && parseFloat(value) > 0) {
|
||||||
|
let badge = input.parentElement.querySelector('.income-logged-badge');
|
||||||
|
if (!badge) {
|
||||||
|
badge = document.createElement('span');
|
||||||
|
badge.className = 'income-logged-badge';
|
||||||
|
badge.textContent = '✓ logged';
|
||||||
|
input.parentElement.appendChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLeftover(week) {
|
||||||
|
// Recalculate income total from inputs
|
||||||
|
let incomeTotal = 0;
|
||||||
|
document.querySelectorAll(`#week-${week} .income-actual-input`).forEach(el => {
|
||||||
|
incomeTotal += parseFloat(el.value) || 0;
|
||||||
|
});
|
||||||
|
const totalEl = document.getElementById(`w${week}-income-actual-total`);
|
||||||
|
if (totalEl) totalEl.textContent = incomeTotal.toFixed(2);
|
||||||
|
|
||||||
|
const loIncomeEl = document.getElementById(`lo-income-${week}`);
|
||||||
|
if (loIncomeEl) loIncomeEl.textContent = incomeTotal.toFixed(2);
|
||||||
|
|
||||||
|
// Update leftover bar using all deductions from data attributes
|
||||||
|
const leftoverEl = document.getElementById(`leftover-amount-${week}`);
|
||||||
|
if (leftoverEl) {
|
||||||
|
const startBal = parseFloat(document.getElementById(`starting-balance-${week}`)?.value) || 0;
|
||||||
|
const spending = parseFloat(leftoverEl.dataset.spending) || 0;
|
||||||
|
const sinking = parseFloat(leftoverEl.dataset.sinking) || 0;
|
||||||
|
const debt = parseFloat(leftoverEl.dataset.debt) || 0;
|
||||||
|
const leftover = startBal + incomeTotal - spending - sinking - debt;
|
||||||
|
leftoverEl.textContent = '$' + leftover.toFixed(2);
|
||||||
|
const bar = document.getElementById(`leftover-bar-${week}`);
|
||||||
|
if (bar) bar.classList.toggle('leftover-negative', leftover < 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logExtraDebt(debtId, value, year, month, week) {
|
||||||
|
fetch('/weekly/extra-debt', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({debt_id: debtId, amount: value, year, month, week})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStartingBalance(year, month, week, value) {
|
||||||
|
fetch('/weekly/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({year, month, week, field: 'starting_balance', key: 'amount', subkey: 'value', value})
|
||||||
|
}).then(() => {
|
||||||
|
const loStart = document.getElementById(`lo-start-${week}`);
|
||||||
|
if (loStart) loStart.textContent = parseFloat(value || 0).toFixed(2);
|
||||||
|
updateLeftover(week);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function contributeSinking(fundName, value, year, month, week) {
|
||||||
|
fetch('/sinking/contribute-weekly', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({name: fundName, amount: value, year, month, week})
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
// update the balance cell
|
||||||
|
document.querySelectorAll('.sinking-weekly-balance-' + fundName.replace(/ /g,'_'))
|
||||||
|
.forEach(el => el.textContent = '$' + (data.new_balance || 0).toFixed(2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWeeklySection(header) {
|
||||||
|
const body = header.nextElementSibling;
|
||||||
|
const arrow = header.querySelector('.weekly-arrow');
|
||||||
|
const collapsed = body.style.display === 'none';
|
||||||
|
body.style.display = collapsed ? '' : 'none';
|
||||||
|
arrow.textContent = collapsed ? '▾' : '▸';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show current week by default
|
||||||
|
showWeek({{ current_week }});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user