From 1560041cdb43fe3e799d55ef85ac1405c2faf273 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sun, 31 May 2026 16:18:37 -0400 Subject: [PATCH] 05/31 Phase 3 --- app/__init__.py | 13 +- app/routes/budgets.py | 163 ++++++++++++++++++++++ app/routes/goals.py | 180 +++++++++++++++++++++++++ app/services/budget_service.py | 146 ++++++++++++++++++++ app/services/goal_service.py | 89 ++++++++++++ app/templates/base.html | 4 +- app/templates/budgets/form.html | 46 +++++++ app/templates/budgets/index.html | 150 +++++++++++++++++++++ app/templates/goals/contribute.html | 59 ++++++++ app/templates/goals/contributions.html | 66 +++++++++ app/templates/goals/form.html | 103 ++++++++++++++ app/templates/goals/index.html | 142 +++++++++++++++++++ requirements.txt | 1 + 13 files changed, 1152 insertions(+), 10 deletions(-) create mode 100644 app/routes/budgets.py create mode 100644 app/routes/goals.py create mode 100644 app/services/budget_service.py create mode 100644 app/services/goal_service.py create mode 100644 app/templates/budgets/form.html create mode 100644 app/templates/budgets/index.html create mode 100644 app/templates/goals/contribute.html create mode 100644 app/templates/goals/contributions.html create mode 100644 app/templates/goals/form.html create mode 100644 app/templates/goals/index.html diff --git a/app/__init__.py b/app/__init__.py index f61a617..4782a30 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/routes/budgets.py b/app/routes/budgets.py new file mode 100644 index 0000000..1ca01f8 --- /dev/null +++ b/app/routes/budgets.py @@ -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('//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('//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)) diff --git a/app/routes/goals.py b/app/routes/goals.py new file mode 100644 index 0000000..aa4c6d6 --- /dev/null +++ b/app/routes/goals.py @@ -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('//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('//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('//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('//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//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)) diff --git a/app/services/budget_service.py b/app/services/budget_service.py new file mode 100644 index 0000000..49845ed --- /dev/null +++ b/app/services/budget_service.py @@ -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() diff --git a/app/services/goal_service.py b/app/services/goal_service.py new file mode 100644 index 0000000..61a8892 --- /dev/null +++ b/app/services/goal_service.py @@ -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, + } diff --git a/app/templates/base.html b/app/templates/base.html index 8ceda11..cb054d9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -189,10 +189,10 @@
Planning
- + Budgets - + Goals diff --git a/app/templates/budgets/form.html b/app/templates/budgets/form.html new file mode 100644 index 0000000..4dd69b8 --- /dev/null +++ b/app/templates/budgets/form.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ title }} — {{ month_label }}{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + {{ form.month() }} + +
+ {{ 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 %} + + {% endif %} +
+ +
+ {{ form.limit_amount.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ current_user.currency_symbol }} + {{ form.limit_amount(class="form-control" + (" is-invalid" if form.limit_amount.errors else ""), placeholder="0.00") }} +
+ {% for e in form.limit_amount.errors %}
{{ e }}
{% endfor %} +
+ +
+
+ {{ form.rollover_enabled(class="form-check-input") }} + {{ form.rollover_enabled.label(class="form-check-label", style="font-size:13px;") }} +
+ Unused budget rolls over to next month. +
+ +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+
+
+{% endblock %} diff --git a/app/templates/budgets/index.html b/app/templates/budgets/index.html new file mode 100644 index 0000000..8c95e33 --- /dev/null +++ b/app/templates/budgets/index.html @@ -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 %} +Add Budget +{% endblock %} + +{% block content %} + +
+ +
{{ month_label }}
+ +
+ + +{% if total_budget > 0 %} +
+
+
+
+ Total Budget + + {{ total_spent | currency }} + / {{ total_budget | currency }} + +
+
+
+
+
+ {{ overall_pct }}% used + + {{ (total_budget - total_spent) | currency }} {% if total_budget - total_spent >= 0 %}remaining{% else %}over{% endif %} + +
+
+
+ +
+ + + + +
+
+
+
+{% endif %} + + +{% if summary %} +
+ + + + + + + + + + + + + {% for item in summary %} + + + + + + + + + {% endfor %} + +
CategorySpentLimitProgressRemainingActions
+
+ {% if item.category %} +
+ +
+ {{ item.category.name }} + {% endif %} + {% if not item.has_budget %} + no budget + {% endif %} + {% if item.budget and item.budget.rollover_amount and item.budget.rollover_amount > 0 %} + +rollover + {% endif %} +
+
+ {{ item.spent | currency }} + + {% if item.limit is not none %} + {{ item.limit | currency }} + {% else %} + + {% endif %} + + {% if item.pct is not none %} +
+
+
+ {{ item.pct }}% + {% else %} + + {% endif %} +
+ {% if item.remaining is not none %} + {{ item.remaining | currency }} + {% endif %} + + {% if item.budget %} + Edit +
+ + +
+ {% else %} + Set Budget + {% endif %} +
+
+{% else %} +
+ +
No budgets for {{ month_label }}
+

Set spending limits per category to track your budget.

+ Set First Budget + {% set prev_year, prev_mo = prev_month.split('-') %} +
+ + + + +
+
+{% endif %} +{% endblock %} diff --git a/app/templates/goals/contribute.html b/app/templates/goals/contribute.html new file mode 100644 index 0000000..a1087cb --- /dev/null +++ b/app/templates/goals/contribute.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Add Contribution{% endblock %} +{% block page_title %}Add Contribution{% endblock %} + +{% block content %} +
+
+ + +
+
+
+ +
+
+
{{ goal.name }}
+
{{ goal.progress_percent }}% complete
+
+
+
+
+
+
+ {{ goal.current_amount | currency }} + / {{ goal.target_amount | currency }} +
+
+ Remaining: {{ (goal.target_amount - goal.current_amount) | currency }} +
+
+ +
+
+ {{ form.hidden_tag() }} +
+ {{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ current_user.currency_symbol }} + {{ form.amount(class="form-control", placeholder="0.00") }} +
+ {% for e in form.amount.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.date(class="form-control") }} +
+
+ {{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.notes(class="form-control", placeholder="Optional note") }} +
+
+ + Cancel +
+
+
+
+
+{% endblock %} diff --git a/app/templates/goals/contributions.html b/app/templates/goals/contributions.html new file mode 100644 index 0000000..e2a41c8 --- /dev/null +++ b/app/templates/goals/contributions.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}{{ goal.name }} — History{% endblock %} +{% block page_title %}{{ goal.name }}{% endblock %} + +{% block topbar_actions %} +Add Contribution +{% endblock %} + +{% block content %} + +
+
+
+
+
+
+
+ {{ goal.current_amount | currency }} + {{ goal.target_amount | currency }} +
+ {{ goal.progress_percent }}% complete +
+
+ {% if projection %} +
Projected completion
+
{{ projection.strftime('%B %Y') }}
+ {% endif %} + {% if goal.target_date %} +
Target: {{ goal.target_date.strftime('%b %d, %Y') }}
+ {% endif %} +
+
+
+ + +
+
+ Contribution History + {{ contribs|length }} contributions +
+ {% if contribs %} + + + + {% for c in contribs %} + + + + + + + {% endfor %} + +
DateAmountNotesActions
{{ c.date.strftime('%b %d, %Y') }}+{{ c.amount | currency }}{{ c.notes or '—' }} +
+ + +
+
+ {% else %} +
+

No contributions yet.

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/goals/form.html b/app/templates/goals/form.html new file mode 100644 index 0000000..ae2b5b0 --- /dev/null +++ b/app/templates/goals/form.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ 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 %}
{{ e }}
{% endfor %} +
+ +
+
+ {{ form.target_amount.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ current_user.currency_symbol }} + {{ form.target_amount(class="form-control", placeholder="0.00") }} +
+
+
+ {{ form.target_date.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.target_date(class="form-control") }} +
+
+ +
+ {{ form.linked_account_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.linked_account_id(class="form-select") }} +
+ +
+ {{ 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?") }} +
+ + +
+ +
+ {% for c in colors %} + + {% endfor %} +
+ {{ form.color(type="hidden", id="colorInput") }} +
+ + +
+ +
+ {% for icon_val in icons %} + + {% endfor %} +
+ {{ form.icon(type="hidden", id="iconInput") }} +
+ +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/goals/index.html b/app/templates/goals/index.html new file mode 100644 index 0000000..e94ead8 --- /dev/null +++ b/app/templates/goals/index.html @@ -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 %} +New Goal +{% endblock %} + +{% block content %} + +{% if emergency and emergency.avg_monthly_expense > 0 %} +
+
+
+ Emergency Fund +
Based on avg monthly expense of {{ emergency.avg_monthly_expense | currency }}
+
+ + {{ emergency.months_covered }}mo + +
+
+
+
3-Month Target: {{ emergency.target_3mo | currency }}
+
+ {{ emergency.pct_3mo }}% +
+
+
6-Month Target: {{ emergency.target_6mo | currency }}
+
+ {{ emergency.pct_6mo }}% +
+
+
+ Liquid assets: {{ emergency.liquid_assets | currency }} +
+
+{% endif %} + + +{% if active_goals %} +
+ {% for goal in active_goals %} +
+
+
+
+
+ +
+
+
{{ goal.name }}
+ {% if goal.target_date %} +
By {{ goal.target_date.strftime('%b %d, %Y') }}
+ {% endif %} +
+
+ +
+ +
+
+
+ +
+ {{ goal.current_amount | currency }} + / {{ goal.target_amount | currency }} +
+ +
+ {{ goal.progress_percent }}% complete + {% if projections[goal.id] %} + ~{{ projections[goal.id].strftime('%b %Y') }} + {% endif %} +
+ + {% if goal.description %} +
{{ goal.description | truncate(80) }}
+ {% endif %} + + + Add Contribution + +
+
+ {% endfor %} +
+{% else %} +
+ +
No active goals
+

Set savings goals and track your progress.

+ Create First Goal +
+{% endif %} + + +{% if completed_goals %} +
+
Completed Goals 🎉
+ + + + {% for goal in completed_goals %} + + + + + + {% endfor %} + +
GoalTargetCompleted
+
+
+ +
+ {{ goal.name }} +
+
{{ goal.target_amount | currency }}{{ goal.completed_at.strftime('%b %d, %Y') if goal.completed_at else '—' }}
+
+{% endif %} +{% endblock %} diff --git a/requirements.txt b/requirements.txt index db8f293..28125c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ Pillow==11.1.0 apscheduler==3.10.4 requests==2.32.3 cryptography==44.0.2 +python-dateutil==2.9.0