05/31 Phase 3

This commit is contained in:
2026-05-31 16:18:37 -04:00
parent 9f2a0acc38
commit 1560041cdb
13 changed files with 1152 additions and 10 deletions
+5 -8
View File
@@ -8,34 +8,32 @@ from app.utils.formatters import format_currency, format_percent, format_large_n
def create_app(config_name=None):
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'development')
if config_name == 'production':
config_name = 'production'
else:
config_name = 'development'
config_name = 'production' if config_name == 'production' else 'development'
app = Flask(__name__)
app.config.from_object(config[config_name])
# Init extensions
db.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
csrf.init_app(app)
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.dashboard import dashboard_bp
from app.routes.accounts import accounts_bp
from app.routes.categories import categories_bp
from app.routes.transactions import transactions_bp
from app.routes.budgets import budgets_bp
from app.routes.goals import goals_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(accounts_bp)
app.register_blueprint(categories_bp)
app.register_blueprint(transactions_bp)
app.register_blueprint(budgets_bp)
app.register_blueprint(goals_bp)
# Import all models so Flask-Migrate can see them
with app.app_context():
from app.models import (
User, Account, Category, Receipt, RecurringRule,
@@ -44,7 +42,6 @@ def create_app(config_name=None):
AiInsight, FxRate
)
# Jinja2 globals
app.jinja_env.globals['format_currency'] = format_currency
app.jinja_env.globals['format_percent'] = format_percent
app.jinja_env.globals['format_large_number'] = format_large_number
+163
View File
@@ -0,0 +1,163 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import SelectField, DecimalField, BooleanField, SubmitField, HiddenField
from wtforms.validators import DataRequired, NumberRange
from app.extensions import db
from app.models.budget import Budget
from app.models.category import Category
from app.services.budget_service import get_budget_summary, get_total_budget, get_total_spent
from datetime import date
budgets_bp = Blueprint('budgets', __name__, url_prefix='/budgets')
def _current_month():
return date.today().strftime('%Y-%m')
def _expense_category_choices():
cats = Category.query.filter(
Category.category_type.in_(['expense', 'both']),
Category.is_active == True,
Category.parent_id == None,
).order_by(Category.name).all()
return [(str(c.id), c.name) for c in cats]
class BudgetForm(FlaskForm):
category_id = SelectField('Category', validators=[DataRequired()])
month = HiddenField()
limit_amount = DecimalField('Monthly Limit', validators=[DataRequired(), NumberRange(min=0.01)], places=2)
rollover_enabled = BooleanField('Roll over unused amount to next month')
submit = SubmitField('Save Budget')
@budgets_bp.route('/')
@login_required
def index():
month = request.args.get('month', _current_month())
summary = get_budget_summary(month)
total_budget = get_total_budget(month)
total_spent = get_total_spent(month)
overall_pct = min(round((total_spent / total_budget * 100), 1), 100) if total_budget > 0 else 0
# Month navigation
year, mo = map(int, month.split('-'))
if mo == 1:
prev_month = f'{year-1}-12'
else:
prev_month = f'{year}-{mo-1:02d}'
if mo == 12:
next_month = f'{year+1}-01'
else:
next_month = f'{year}-{mo+1:02d}'
return render_template('budgets/index.html',
month=month,
month_label=date(year, mo, 1).strftime('%B %Y'),
prev_month=prev_month,
next_month=next_month,
summary=summary,
total_budget=total_budget,
total_spent=total_spent,
overall_pct=overall_pct)
@budgets_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
month = request.args.get('month', _current_month())
form = BudgetForm()
form.category_id.choices = _expense_category_choices()
form.month.data = month
if form.validate_on_submit():
month_val = form.month.data or month
existing = Budget.query.filter_by(
category_id=int(form.category_id.data),
month=month_val
).first()
if existing:
flash('Budget for this category already exists this month. Edit it instead.', 'warning')
return redirect(url_for('budgets.index', month=month_val))
budget = Budget(
category_id=int(form.category_id.data),
month=month_val,
limit_amount=form.limit_amount.data,
rollover_enabled=form.rollover_enabled.data,
rollover_amount=0,
)
db.session.add(budget)
db.session.commit()
flash('Budget created.', 'success')
return redirect(url_for('budgets.index', month=month_val))
return render_template('budgets/form.html', form=form, month=month,
title='New Budget', month_label=date(*map(int, month.split('-')), 1).strftime('%B %Y'))
@budgets_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
budget = db.get_or_404(Budget, id)
form = BudgetForm(obj=budget)
form.category_id.choices = _expense_category_choices()
if request.method == 'GET':
form.category_id.data = str(budget.category_id)
form.month.data = budget.month
if form.validate_on_submit():
budget.limit_amount = form.limit_amount.data
budget.rollover_enabled = form.rollover_enabled.data
db.session.commit()
flash('Budget updated.', 'success')
return redirect(url_for('budgets.index', month=budget.month))
year, mo = map(int, budget.month.split('-'))
return render_template('budgets/form.html', form=form, month=budget.month,
title='Edit Budget', budget=budget,
month_label=date(year, mo, 1).strftime('%B %Y'))
@budgets_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
budget = db.get_or_404(Budget, id)
month = budget.month
db.session.delete(budget)
db.session.commit()
flash('Budget removed.', 'info')
return redirect(url_for('budgets.index', month=month))
@budgets_bp.route('/copy', methods=['POST'])
@login_required
def copy_month():
"""Copy all budgets from one month to another."""
from_month = request.form.get('from_month')
to_month = request.form.get('to_month')
if not from_month or not to_month:
flash('Invalid months.', 'danger')
return redirect(url_for('budgets.index'))
from_budgets = Budget.query.filter_by(month=from_month).all()
copied = 0
for fb in from_budgets:
existing = Budget.query.filter_by(
month=to_month, category_id=fb.category_id
).first()
if not existing:
db.session.add(Budget(
category_id=fb.category_id,
month=to_month,
limit_amount=fb.limit_amount,
rollover_enabled=fb.rollover_enabled,
rollover_amount=0,
))
copied += 1
db.session.commit()
flash(f'Copied {copied} budget(s) to {to_month}.', 'success')
return redirect(url_for('budgets.index', month=to_month))
+180
View File
@@ -0,0 +1,180 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import StringField, DecimalField, DateField, SelectField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Optional, NumberRange, Length
from app.extensions import db
from app.models.goal import Goal, GoalContribution
from app.models.account import Account
from app.services.goal_service import get_projected_completion, get_emergency_fund_status
from datetime import date
goals_bp = Blueprint('goals', __name__, url_prefix='/goals')
GOAL_COLORS = ['#10b981','#3b82f6','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#f97316']
GOAL_ICONS = [
'bi-piggy-bank','bi-house','bi-airplane','bi-car-front','bi-mortarboard',
'bi-heart-pulse','bi-laptop','bi-gift','bi-trophy','bi-stars',
'bi-umbrella','bi-bicycle','bi-camera','bi-music-note',
]
def _account_choices():
accts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
return [('', '— None —')] + [(str(a.id), a.name) for a in accts]
class GoalForm(FlaskForm):
name = StringField('Goal Name', validators=[DataRequired(), Length(1, 100)])
description = TextAreaField('Description', validators=[Optional()])
target_amount = DecimalField('Target Amount', validators=[DataRequired(), NumberRange(min=0.01)], places=2)
target_date = DateField('Target Date', validators=[Optional()])
linked_account_id = SelectField('Linked Account', validators=[Optional()])
color = StringField('Color', default='#10b981')
icon = StringField('Icon', default='bi-piggy-bank')
submit = SubmitField('Save Goal')
class ContributionForm(FlaskForm):
amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0.01)], places=2)
date = DateField('Date', validators=[DataRequired()], default=date.today)
notes = StringField('Notes', validators=[Optional(), Length(max=255)])
submit = SubmitField('Add Contribution')
@goals_bp.route('/')
@login_required
def index():
active_goals = Goal.query.filter_by(is_completed=False).order_by(Goal.created_at.desc()).all()
completed_goals = Goal.query.filter_by(is_completed=True).order_by(Goal.completed_at.desc()).limit(5).all()
emergency = get_emergency_fund_status()
projections = {}
for g in active_goals:
proj = get_projected_completion(g)
projections[g.id] = proj
return render_template('goals/index.html',
active_goals=active_goals,
completed_goals=completed_goals,
projections=projections,
emergency=emergency)
@goals_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
form = GoalForm()
form.linked_account_id.choices = _account_choices()
if form.validate_on_submit():
goal = Goal(
name=form.name.data.strip(),
description=form.description.data,
target_amount=form.target_amount.data,
target_date=form.target_date.data,
linked_account_id=int(form.linked_account_id.data) if form.linked_account_id.data else None,
color=form.color.data or '#10b981',
icon=form.icon.data or 'bi-piggy-bank',
current_amount=0,
)
db.session.add(goal)
db.session.commit()
flash(f'Goal "{goal.name}" created.', 'success')
return redirect(url_for('goals.index'))
return render_template('goals/form.html', form=form, title='New Goal',
colors=GOAL_COLORS, icons=GOAL_ICONS)
@goals_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
goal = db.get_or_404(Goal, id)
form = GoalForm(obj=goal)
form.linked_account_id.choices = _account_choices()
if request.method == 'GET':
form.linked_account_id.data = str(goal.linked_account_id) if goal.linked_account_id else ''
if form.validate_on_submit():
goal.name = form.name.data.strip()
goal.description = form.description.data
goal.target_amount = form.target_amount.data
goal.target_date = form.target_date.data
goal.linked_account_id = int(form.linked_account_id.data) if form.linked_account_id.data else None
goal.color = form.color.data or goal.color
goal.icon = form.icon.data or goal.icon
db.session.commit()
flash('Goal updated.', 'success')
return redirect(url_for('goals.index'))
return render_template('goals/form.html', form=form, title='Edit Goal',
goal=goal, colors=GOAL_COLORS, icons=GOAL_ICONS)
@goals_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
goal = db.get_or_404(Goal, id)
db.session.delete(goal)
db.session.commit()
flash(f'Goal "{goal.name}" deleted.', 'info')
return redirect(url_for('goals.index'))
@goals_bp.route('/<int:id>/contribute', methods=['GET', 'POST'])
@login_required
def contribute(id):
goal = db.get_or_404(Goal, id)
form = ContributionForm()
if form.validate_on_submit():
contrib = GoalContribution(
goal_id=goal.id,
amount=form.amount.data,
date=form.date.data,
notes=form.notes.data,
)
db.session.add(contrib)
goal.current_amount = float(goal.current_amount) + float(form.amount.data)
# Auto-complete if target reached
if float(goal.current_amount) >= float(goal.target_amount):
from datetime import datetime
goal.is_completed = True
goal.completed_at = datetime.utcnow()
flash(f'🎉 Goal "{goal.name}" completed!', 'success')
else:
flash(f'Contribution of {form.amount.data} added to "{goal.name}".', 'success')
db.session.commit()
return redirect(url_for('goals.index'))
return render_template('goals/contribute.html', form=form, goal=goal)
@goals_bp.route('/<int:id>/contributions')
@login_required
def contributions(id):
goal = db.get_or_404(Goal, id)
contribs = goal.contributions.order_by(GoalContribution.date.desc()).all()
projection = get_projected_completion(goal)
return render_template('goals/contributions.html',
goal=goal, contribs=contribs, projection=projection)
@goals_bp.route('/contributions/<int:id>/delete', methods=['POST'])
@login_required
def delete_contribution(id):
contrib = db.get_or_404(GoalContribution, id)
goal = contrib.goal
goal.current_amount = max(float(goal.current_amount) - float(contrib.amount), 0)
if goal.is_completed and float(goal.current_amount) < float(goal.target_amount):
goal.is_completed = False
goal.completed_at = None
db.session.delete(contrib)
db.session.commit()
flash('Contribution removed.', 'info')
return redirect(url_for('goals.contributions', id=goal.id))
+146
View File
@@ -0,0 +1,146 @@
"""
Budget Service — calculates spending vs budget limits per category per month.
"""
from decimal import Decimal
from sqlalchemy import func
from app.extensions import db
from app.models.budget import Budget
from app.models.transaction import Transaction
from app.models.category import Category
import calendar
from datetime import date
def get_month_spending(category_id, month_str):
"""
Return total spending for a category in a given month.
month_str: 'YYYY-MM'
"""
year, month = map(int, month_str.split('-'))
last_day = calendar.monthrange(year, month)[1]
date_from = date(year, month, 1)
date_to = date(year, month, last_day)
result = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.category_id == category_id,
Transaction.transaction_type == 'expense',
Transaction.date >= date_from,
Transaction.date <= date_to,
).scalar()
return float(result)
def get_budget_summary(month_str):
"""
Return list of dicts with budget vs actual for each budgeted category.
Also includes unbudgeted categories that have spending.
"""
year, month = map(int, month_str.split('-'))
last_day = calendar.monthrange(year, month)[1]
date_from = date(year, month, 1)
date_to = date(year, month, last_day)
# All budgets for this month
budgets = Budget.query.filter_by(month=month_str).all()
budgeted_cat_ids = {b.category_id for b in budgets}
# Spending per category this month
spending_rows = db.session.query(
Transaction.category_id,
func.sum(Transaction.amount).label('total')
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= date_from,
Transaction.date <= date_to,
Transaction.category_id != None,
).group_by(Transaction.category_id).all()
spending_map = {row.category_id: float(row.total) for row in spending_rows}
summary = []
# Budgeted categories
for b in budgets:
spent = spending_map.get(b.category_id, 0.0)
limit = float(b.limit_amount) + float(b.rollover_amount or 0)
remaining = limit - spent
pct = min(round((spent / limit * 100), 1), 100) if limit > 0 else 0
summary.append({
'budget': b,
'category': b.category,
'spent': spent,
'limit': limit,
'remaining': remaining,
'pct': pct,
'is_over': spent > limit,
'has_budget': True,
})
# Unbudgeted categories with spending
for cat_id, spent in spending_map.items():
if cat_id not in budgeted_cat_ids:
cat = db.session.get(Category, cat_id)
if cat:
summary.append({
'budget': None,
'category': cat,
'spent': spent,
'limit': None,
'remaining': None,
'pct': None,
'is_over': False,
'has_budget': False,
})
# Sort: budgeted first (by % used desc), then unbudgeted
summary.sort(key=lambda x: (not x['has_budget'], -(x['pct'] or 0)))
return summary
def get_total_budget(month_str):
"""Total budgeted amount for a month."""
budgets = Budget.query.filter_by(month=month_str).all()
return sum(float(b.limit_amount) + float(b.rollover_amount or 0) for b in budgets)
def get_total_spent(month_str):
"""Total expense spending for a month (all categories)."""
year, month = map(int, month_str.split('-'))
last_day = calendar.monthrange(year, month)[1]
result = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= date(year, month, 1),
Transaction.date <= date(year, month, last_day),
).scalar()
return float(result)
def apply_rollovers(from_month, to_month):
"""
Copy budgets from one month to next, applying rollover amounts.
Call on 1st of each month.
"""
from_budgets = Budget.query.filter_by(month=from_month).all()
for fb in from_budgets:
existing = Budget.query.filter_by(
month=to_month, category_id=fb.category_id
).first()
if not existing:
spent = get_month_spending(fb.category_id, from_month)
limit = float(fb.limit_amount)
rollover = max(limit - spent, 0) if fb.rollover_enabled else 0
new_budget = Budget(
category_id=fb.category_id,
month=to_month,
limit_amount=fb.limit_amount,
rollover_enabled=fb.rollover_enabled,
rollover_amount=rollover,
)
db.session.add(new_budget)
db.session.commit()
+89
View File
@@ -0,0 +1,89 @@
"""
Goal Service — projected completion, emergency fund calc.
"""
from datetime import date
from dateutil.relativedelta import relativedelta
from app.extensions import db
from app.models.goal import Goal
from app.models.transaction import Transaction
from sqlalchemy import func
def get_projected_completion(goal):
"""
Estimate completion date based on average monthly contribution.
Returns date or None if can't be calculated.
"""
if goal.is_completed:
return goal.completed_at
remaining = float(goal.target_amount) - float(goal.current_amount)
if remaining <= 0:
return date.today()
contributions = goal.contributions.all()
if len(contributions) < 2:
return None
# Average monthly contribution from history
contribs_sorted = sorted(contributions, key=lambda c: c.date)
first = contribs_sorted[0].date
last = contribs_sorted[-1].date
total_contrib = sum(float(c.amount) for c in contribs_sorted)
months_elapsed = (
(last.year - first.year) * 12 + (last.month - first.month)
) or 1
avg_monthly = total_contrib / months_elapsed
if avg_monthly <= 0:
return None
months_needed = remaining / avg_monthly
projected = date.today() + relativedelta(months=int(months_needed) + 1)
return projected
def get_emergency_fund_status():
"""
Calculate emergency fund status:
- 3-month and 6-month expense targets
- Current liquid assets (checking + savings + cash)
"""
from app.models.account import Account
# Average monthly expense (last 3 months)
today = date.today()
three_months_ago = today - relativedelta(months=3)
total_expenses = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= three_months_ago,
Transaction.date <= today,
).scalar()
avg_monthly = float(total_expenses) / 3
# Liquid assets
liquid = db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type.in_(['checking', 'savings', 'cash']),
).scalar()
liquid = float(liquid)
months_covered = (liquid / avg_monthly) if avg_monthly > 0 else 0
return {
'avg_monthly_expense': avg_monthly,
'liquid_assets': liquid,
'target_3mo': avg_monthly * 3,
'target_6mo': avg_monthly * 6,
'months_covered': round(months_covered, 1),
'pct_3mo': min(round((liquid / (avg_monthly * 3)) * 100, 1), 100) if avg_monthly > 0 else 0,
'pct_6mo': min(round((liquid / (avg_monthly * 6)) * 100, 1), 100) if avg_monthly > 0 else 0,
}
+2 -2
View File
@@ -189,10 +189,10 @@
</a>
<div class="sb-section">Planning</div>
<a href="#" class="sb-link">
<a href="{{ url_for('budgets.index') }}" class="sb-link {% if request.blueprint == 'budgets' %}active{% endif %}">
<i class="bi bi-pie-chart"></i><span class="lt">Budgets</span>
</a>
<a href="#" class="sb-link">
<a href="{{ url_for('goals.index') }}" class="sb-link {% if request.blueprint == 'goals' %}active{% endif %}">
<i class="bi bi-bullseye"></i><span class="lt">Goals</span>
</a>
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }} — {{ month_label }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-7 col-lg-5">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
{{ form.month() }}
<div class="mb-3">
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.category_id(class="form-select" + (" is-invalid" if form.category_id.errors else ""), **({'disabled': 'disabled'} if budget else {})) }}
{% if budget %}
<input type="hidden" name="category_id" value="{{ budget.category_id }}">
{% endif %}
</div>
<div class="mb-3">
{{ form.limit_amount.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:13px;">{{ current_user.currency_symbol }}</span>
{{ form.limit_amount(class="form-control" + (" is-invalid" if form.limit_amount.errors else ""), placeholder="0.00") }}
</div>
{% for e in form.limit_amount.errors %}<div class="text-danger mt-1" style="font-size:12px;">{{ e }}</div>{% endfor %}
</div>
<div class="mb-4">
<div class="form-check">
{{ form.rollover_enabled(class="form-check-input") }}
{{ form.rollover_enabled.label(class="form-check-label", style="font-size:13px;") }}
</div>
<small class="text-muted" style="font-size:12px;">Unused budget rolls over to next month.</small>
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('budgets.index', month=month) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+150
View File
@@ -0,0 +1,150 @@
{% extends "base.html" %}
{% block title %}Budgets{% endblock %}
{% block page_title %}Budgets{% endblock %}
{% block extra_css %}
.progress-bar-budget { height: 8px; border-radius: 4px; background: #f1f5f9; overflow: hidden; }
.progress-bar-fill { height: 100%; border-radius: 4px; transition: width .6s ease; }
.budget-over { background: #fee2e2; border-color: #fca5a5 !important; }
{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('budgets.new', month=month) }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Add Budget</a>
{% endblock %}
{% block content %}
<!-- Month nav -->
<div class="d-flex align-items-center justify-content-between mb-4">
<a href="{{ url_for('budgets.index', month=prev_month) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-chevron-left"></i></a>
<h5 class="mb-0 fw-semibold">{{ month_label }}</h5>
<a href="{{ url_for('budgets.index', month=next_month) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-chevron-right"></i></a>
</div>
<!-- Overall summary -->
{% if total_budget > 0 %}
<div class="pcard mb-4">
<div class="row g-3 align-items-center">
<div class="col-12 col-md-8">
<div class="d-flex justify-content-between mb-2">
<span style="font-size:13px;font-weight:500;">Total Budget</span>
<span class="mono" style="font-size:13px;">
<span class="{% if total_spent > total_budget %}text-expense{% else %}text-income{% endif %}">{{ total_spent | currency }}</span>
<span class="text-muted"> / {{ total_budget | currency }}</span>
</span>
</div>
<div class="progress-bar-budget">
<div class="progress-bar-fill" style="width:{{ overall_pct }}%;background:{% if overall_pct >= 100 %}#ef4444{% elif overall_pct >= 80 %}#f59e0b{% else %}#10b981{% endif %};"></div>
</div>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">{{ overall_pct }}% used</small>
<small class="{% if total_budget - total_spent >= 0 %}text-income{% else %}text-expense{% endif %}">
{{ (total_budget - total_spent) | currency }} {% if total_budget - total_spent >= 0 %}remaining{% else %}over{% endif %}
</small>
</div>
</div>
<div class="col-12 col-md-4 text-md-end">
<!-- Copy from prev month -->
<form method="POST" action="{{ url_for('budgets.copy_month') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="from_month" value="{{ prev_month }}">
<input type="hidden" name="to_month" value="{{ month }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"
onclick="return confirm('Copy budgets from previous month?')">
<i class="bi bi-copy me-1"></i>Copy from {{ prev_month }}
</button>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Budget Items -->
{% if summary %}
<div class="pcard p-0">
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Category</th>
<th>Spent</th>
<th class="d-none d-md-table-cell">Limit</th>
<th>Progress</th>
<th class="d-none d-md-table-cell text-end">Remaining</th>
<th class="text-end" style="padding-right:20px;">Actions</th>
</tr>
</thead>
<tbody>
{% for item in summary %}
<tr class="{% if item.is_over %}budget-over{% endif %}">
<td style="padding-left:20px;">
<div class="d-flex align-items-center gap-2">
{% if item.category %}
<div style="width:28px;height:28px;border-radius:7px;background:{{ item.category.color }}22;color:{{ item.category.color }};display:flex;align-items:center;justify-content:center;font-size:14px;">
<i class="bi {{ item.category.icon }}"></i>
</div>
<span style="font-size:13px;font-weight:500;">{{ item.category.name }}</span>
{% endif %}
{% if not item.has_budget %}
<span style="font-size:10px;background:#fef9c3;color:#854d0e;border-radius:4px;padding:1px 5px;">no budget</span>
{% endif %}
{% if item.budget and item.budget.rollover_amount and item.budget.rollover_amount > 0 %}
<span style="font-size:10px;background:#dbeafe;color:#1e40af;border-radius:4px;padding:1px 5px;" title="Includes {{ item.budget.rollover_amount | currency }} rollover">+rollover</span>
{% endif %}
</div>
</td>
<td>
<span class="mono {% if item.is_over %}text-expense{% endif %}" style="font-size:13px;font-weight:500;">{{ item.spent | currency }}</span>
</td>
<td class="d-none d-md-table-cell">
{% if item.limit is not none %}
<span class="mono text-muted" style="font-size:12px;">{{ item.limit | currency }}</span>
{% else %}
<span class="text-muted" style="font-size:12px;"></span>
{% endif %}
</td>
<td style="min-width:120px;">
{% if item.pct is not none %}
<div class="progress-bar-budget">
<div class="progress-bar-fill" style="width:{{ item.pct }}%;background:{% if item.pct >= 100 %}#ef4444{% elif item.pct >= 80 %}#f59e0b{% else %}#10b981{% endif %};"></div>
</div>
<small class="text-muted" style="font-size:10px;">{{ item.pct }}%</small>
{% else %}
<span class="text-muted" style="font-size:12px;"></span>
{% endif %}
</td>
<td class="d-none d-md-table-cell text-end">
{% if item.remaining is not none %}
<span class="mono {% if item.remaining >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:12px;">{{ item.remaining | currency }}</span>
{% endif %}
</td>
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
{% if item.budget %}
<a href="{{ url_for('budgets.edit', id=item.budget.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 8px;">Edit</a>
<form method="POST" action="{{ url_for('budgets.delete', id=item.budget.id) }}" style="display:inline;" onsubmit="return confirm('Remove this budget?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger ms-1" style="font-size:11px;padding:2px 8px;">Del</button>
</form>
{% else %}
<a href="{{ url_for('budgets.new', month=month) }}" class="btn btn-sm btn-outline-primary" style="font-size:11px;padding:2px 8px;">Set Budget</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="pcard text-center py-5">
<i class="bi bi-pie-chart text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No budgets for {{ month_label }}</h5>
<p class="text-muted small mb-3">Set spending limits per category to track your budget.</p>
<a href="{{ url_for('budgets.new', month=month) }}" class="btn btn-primary btn-sm">Set First Budget</a>
{% set prev_year, prev_mo = prev_month.split('-') %}
<form method="POST" action="{{ url_for('budgets.copy_month') }}" class="mt-2 d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="from_month" value="{{ prev_month }}">
<input type="hidden" name="to_month" value="{{ month }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">Copy from {{ prev_month }}</button>
</form>
</div>
{% endif %}
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Add Contribution{% endblock %}
{% block page_title %}Add Contribution{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-7 col-lg-5">
<!-- Goal summary card -->
<div class="pcard mb-3" style="border-left: 4px solid {{ goal.color }};">
<div class="d-flex align-items-center gap-2 mb-3">
<div style="width:36px;height:36px;border-radius:9px;background:{{ goal.color }}22;color:{{ goal.color }};display:flex;align-items:center;justify-content:center;font-size:18px;">
<i class="bi {{ goal.icon }}"></i>
</div>
<div>
<div style="font-size:14px;font-weight:600;">{{ goal.name }}</div>
<div style="font-size:12px;color:var(--muted);">{{ goal.progress_percent }}% complete</div>
</div>
</div>
<div style="height:8px;background:#f1f5f9;border-radius:4px;overflow:hidden;">
<div style="height:8px;border-radius:4px;background:{{ goal.color }};width:{{ goal.progress_percent }}%;"></div>
</div>
<div class="d-flex justify-content-between mt-2">
<span class="mono" style="font-size:13px;color:{{ goal.color }};font-weight:600;">{{ goal.current_amount | currency }}</span>
<span class="mono text-muted" style="font-size:12px;">/ {{ goal.target_amount | currency }}</span>
</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">
Remaining: <span class="mono text-income">{{ (goal.target_amount - goal.current_amount) | currency }}</span>
</div>
</div>
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:13px;">{{ current_user.currency_symbol }}</span>
{{ form.amount(class="form-control", placeholder="0.00") }}
</div>
{% for e in form.amount.errors %}<div class="text-danger mt-1" style="font-size:12px;">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.date(class="form-control") }}
</div>
<div class="mb-4">
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.notes(class="form-control", placeholder="Optional note") }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" style="background:{{ goal.color }};border-color:{{ goal.color }};">Add Contribution</button>
<a href="{{ url_for('goals.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}{{ goal.name }} — History{% endblock %}
{% block page_title %}{{ goal.name }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('goals.contribute', id=goal.id) }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Add Contribution</a>
{% endblock %}
{% block content %}
<!-- Goal summary -->
<div class="pcard mb-4" style="border-left:4px solid {{ goal.color }};">
<div class="row align-items-center g-3">
<div class="col-12 col-md-8">
<div style="height:10px;background:#f1f5f9;border-radius:5px;overflow:hidden;">
<div style="height:10px;border-radius:5px;background:{{ goal.color }};width:{{ goal.progress_percent }}%;transition:width .6s;"></div>
</div>
<div class="d-flex justify-content-between mt-2">
<span class="mono" style="font-size:14px;font-weight:600;color:{{ goal.color }};">{{ goal.current_amount | currency }}</span>
<span class="mono text-muted" style="font-size:13px;">{{ goal.target_amount | currency }}</span>
</div>
<small class="text-muted">{{ goal.progress_percent }}% complete</small>
</div>
<div class="col-12 col-md-4 text-md-end">
{% if projection %}
<div style="font-size:12px;color:var(--muted);">Projected completion</div>
<div style="font-size:15px;font-weight:600;">{{ projection.strftime('%B %Y') }}</div>
{% endif %}
{% if goal.target_date %}
<div style="font-size:12px;color:var(--muted);">Target: {{ goal.target_date.strftime('%b %d, %Y') }}</div>
{% endif %}
</div>
</div>
</div>
<!-- Contribution history -->
<div class="pcard p-0">
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Contribution History</span>
<span class="text-muted" style="font-size:12px;">{{ contribs|length }} contributions</span>
</div>
{% if contribs %}
<table class="pfm-table">
<thead><tr><th style="padding-left:20px;">Date</th><th>Amount</th><th>Notes</th><th class="text-end" style="padding-right:20px;">Actions</th></tr></thead>
<tbody>
{% for c in contribs %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);">{{ c.date.strftime('%b %d, %Y') }}</td>
<td><span class="mono text-income" style="font-size:13px;font-weight:500;">+{{ c.amount | currency }}</span></td>
<td style="font-size:12px;color:var(--muted);">{{ c.notes or '—' }}</td>
<td class="text-end" style="padding-right:20px;">
<form method="POST" action="{{ url_for('goals.delete_contribution', id=c.id) }}" onsubmit="return confirm('Remove this contribution?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;padding:2px 8px;">Del</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-4">
<p class="text-muted small mb-0">No contributions yet.</p>
</div>
{% endif %}
</div>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="e.g. Vacation Fund") }}
{% for e in form.name.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ form.target_amount.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:13px;">{{ current_user.currency_symbol }}</span>
{{ form.target_amount(class="form-control", placeholder="0.00") }}
</div>
</div>
<div class="col-6">
{{ form.target_date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.target_date(class="form-control") }}
</div>
</div>
<div class="mb-3">
{{ form.linked_account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.linked_account_id(class="form-select") }}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.description(class="form-control", rows=2, placeholder="What are you saving for?") }}
</div>
<!-- Color -->
<div class="mb-3">
<label class="form-label fw-medium" style="font-size:13px;">Color</label>
<div class="d-flex gap-2 flex-wrap">
{% for c in colors %}
<label style="cursor:pointer;">
<input type="radio" name="color" value="{{ c }}" style="display:none;" {% if (goal and goal.color==c) or (not goal and loop.first) %}checked{% endif %}>
<div style="width:26px;height:26px;border-radius:50%;background:{{ c }};border:3px solid transparent;" class="color-swatch" data-color="{{ c }}"></div>
</label>
{% endfor %}
</div>
{{ form.color(type="hidden", id="colorInput") }}
</div>
<!-- Icon -->
<div class="mb-4">
<label class="form-label fw-medium" style="font-size:13px;">Icon</label>
<div class="d-flex gap-2 flex-wrap">
{% for icon_val in icons %}
<label style="cursor:pointer;">
<input type="radio" name="icon" value="{{ icon_val }}" style="display:none;" {% if goal and goal.icon==icon_val %}checked{% elif not goal and loop.first %}checked{% endif %}>
<div style="width:34px;height:34px;border-radius:8px;background:#f1f5f9;display:flex;align-items:center;justify-content:center;font-size:16px;border:2px solid transparent;" class="icon-swatch">
<i class="bi {{ icon_val }}"></i>
</div>
</label>
{% endfor %}
</div>
{{ form.icon(type="hidden", id="iconInput") }}
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('goals.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.querySelectorAll('.color-swatch').forEach(function(sw) {
const inp = sw.closest('label').querySelector('input');
inp.addEventListener('change', function() {
document.getElementById('colorInput').value = this.value;
document.querySelectorAll('.color-swatch').forEach(s => { s.style.borderColor='transparent'; s.style.outline='none'; });
sw.style.borderColor='#fff'; sw.style.outline='2px solid '+this.value;
});
if (inp.checked) { sw.style.borderColor='#fff'; sw.style.outline='2px solid '+sw.dataset.color; document.getElementById('colorInput').value=sw.dataset.color; }
});
document.querySelectorAll('.icon-swatch').forEach(function(sw) {
const inp = sw.closest('label').querySelector('input');
inp.addEventListener('change', function() {
document.getElementById('iconInput').value = this.value;
document.querySelectorAll('.icon-swatch').forEach(s => { s.style.background='#f1f5f9'; s.style.borderColor='transparent'; });
sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6';
});
if (inp.checked) { sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6'; document.getElementById('iconInput').value=inp.value; }
});
</script>
{% endblock %}
+142
View File
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}Goals{% endblock %}
{% block page_title %}Goals & Savings{% endblock %}
{% block extra_css %}
.goal-card { border-radius: 12px; border: 1px solid var(--border); background: var(--card-bg); padding: 18px 20px; }
.goal-progress { height: 10px; background: #f1f5f9; border-radius: 5px; overflow: hidden; margin: 10px 0 6px; }
.goal-progress-fill { height: 100%; border-radius: 5px; transition: width .6s ease; }
{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('goals.new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>New Goal</a>
{% endblock %}
{% block content %}
<!-- Emergency Fund -->
{% if emergency and emergency.avg_monthly_expense > 0 %}
<div class="pcard mb-4" style="border-left:4px solid #f59e0b;">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#92400e;">Emergency Fund</span>
<div style="font-size:13px;color:var(--muted);margin-top:2px;">Based on avg monthly expense of <span class="mono">{{ emergency.avg_monthly_expense | currency }}</span></div>
</div>
<span class="mono fw-bold" style="font-size:18px;color:{% if emergency.months_covered >= 6 %}#10b981{% elif emergency.months_covered >= 3 %}#f59e0b{% else %}#ef4444{% endif %};">
{{ emergency.months_covered }}mo
</span>
</div>
<div class="row g-3">
<div class="col-6">
<div style="font-size:11px;color:var(--muted);margin-bottom:4px;">3-Month Target: <span class="mono">{{ emergency.target_3mo | currency }}</span></div>
<div style="height:6px;background:#f1f5f9;border-radius:3px;"><div style="height:6px;border-radius:3px;background:#f59e0b;width:{{ emergency.pct_3mo }}%;"></div></div>
<small class="text-muted" style="font-size:10px;">{{ emergency.pct_3mo }}%</small>
</div>
<div class="col-6">
<div style="font-size:11px;color:var(--muted);margin-bottom:4px;">6-Month Target: <span class="mono">{{ emergency.target_6mo | currency }}</span></div>
<div style="height:6px;background:#f1f5f9;border-radius:3px;"><div style="height:6px;border-radius:3px;background:#10b981;width:{{ emergency.pct_6mo }}%;"></div></div>
<small class="text-muted" style="font-size:10px;">{{ emergency.pct_6mo }}%</small>
</div>
</div>
<div style="font-size:12px;margin-top:8px;color:var(--muted);">
Liquid assets: <span class="mono text-income">{{ emergency.liquid_assets | currency }}</span>
</div>
</div>
{% endif %}
<!-- Active Goals -->
{% if active_goals %}
<div class="row g-3 mb-4">
{% for goal in active_goals %}
<div class="col-12 col-md-6 col-xl-4">
<div class="goal-card" style="border-left: 4px solid {{ goal.color }};">
<div class="d-flex justify-content-between align-items-start">
<div class="d-flex align-items-center gap-2">
<div style="width:36px;height:36px;border-radius:9px;background:{{ goal.color }}22;color:{{ goal.color }};display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;">
<i class="bi {{ goal.icon }}"></i>
</div>
<div>
<div style="font-size:14px;font-weight:600;">{{ goal.name }}</div>
{% if goal.target_date %}
<div style="font-size:11px;color:var(--muted);">By {{ goal.target_date.strftime('%b %d, %Y') }}</div>
{% endif %}
</div>
</div>
<div class="dropdown">
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
<li><a class="dropdown-item" href="{{ url_for('goals.contribute', id=goal.id) }}"><i class="bi bi-plus-circle me-2"></i>Add Contribution</a></li>
<li><a class="dropdown-item" href="{{ url_for('goals.contributions', id=goal.id) }}"><i class="bi bi-clock-history me-2"></i>History</a></li>
<li><a class="dropdown-item" href="{{ url_for('goals.edit', id=goal.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST" action="{{ url_for('goals.delete', id=goal.id) }}" onsubmit="return confirm('Delete this goal?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Delete</button>
</form>
</li>
</ul>
</div>
</div>
<div class="goal-progress">
<div class="goal-progress-fill" style="width:{{ goal.progress_percent }}%;background:{{ goal.color }};"></div>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="mono" style="font-size:13px;font-weight:600;color:{{ goal.color }};">{{ goal.current_amount | currency }}</span>
<span class="text-muted mono" style="font-size:12px;">/ {{ goal.target_amount | currency }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<small class="text-muted">{{ goal.progress_percent }}% complete</small>
{% if projections[goal.id] %}
<small class="text-muted"><i class="bi bi-calendar-check me-1"></i>~{{ projections[goal.id].strftime('%b %Y') }}</small>
{% endif %}
</div>
{% if goal.description %}
<div style="font-size:12px;color:var(--muted);margin-top:8px;">{{ goal.description | truncate(80) }}</div>
{% endif %}
<a href="{{ url_for('goals.contribute', id=goal.id) }}" class="btn btn-sm w-100 mt-3" style="background:{{ goal.color }}22;color:{{ goal.color }};border:1px solid {{ goal.color }}44;font-size:12px;">
<i class="bi bi-plus-lg me-1"></i>Add Contribution
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="pcard text-center py-5 mb-4">
<i class="bi bi-bullseye text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No active goals</h5>
<p class="text-muted small mb-3">Set savings goals and track your progress.</p>
<a href="{{ url_for('goals.new') }}" class="btn btn-primary btn-sm">Create First Goal</a>
</div>
{% endif %}
<!-- Completed Goals -->
{% if completed_goals %}
<div class="pcard">
<div class="pcard-title mb-3">Completed Goals 🎉</div>
<table class="pfm-table">
<thead><tr><th>Goal</th><th>Target</th><th>Completed</th></tr></thead>
<tbody>
{% for goal in completed_goals %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:26px;height:26px;border-radius:6px;background:{{ goal.color }}22;color:{{ goal.color }};display:flex;align-items:center;justify-content:center;font-size:13px;">
<i class="bi {{ goal.icon }}"></i>
</div>
<span style="font-size:13px;">{{ goal.name }}</span>
</div>
</td>
<td><span class="mono" style="font-size:13px;">{{ goal.target_amount | currency }}</span></td>
<td style="font-size:12px;color:var(--muted);">{{ goal.completed_at.strftime('%b %d, %Y') if goal.completed_at else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}