05/31 Phase 3
This commit is contained in:
+5
-8
@@ -8,34 +8,32 @@ from app.utils.formatters import format_currency, format_percent, format_large_n
|
|||||||
def create_app(config_name=None):
|
def create_app(config_name=None):
|
||||||
if config_name is None:
|
if config_name is None:
|
||||||
config_name = os.environ.get('FLASK_ENV', 'development')
|
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||||
if config_name == 'production':
|
config_name = 'production' if config_name == 'production' else 'development'
|
||||||
config_name = 'production'
|
|
||||||
else:
|
|
||||||
config_name = 'development'
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(config[config_name])
|
app.config.from_object(config[config_name])
|
||||||
|
|
||||||
# Init extensions
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
csrf.init_app(app)
|
csrf.init_app(app)
|
||||||
|
|
||||||
# Register blueprints
|
|
||||||
from app.routes.auth import auth_bp
|
from app.routes.auth import auth_bp
|
||||||
from app.routes.dashboard import dashboard_bp
|
from app.routes.dashboard import dashboard_bp
|
||||||
from app.routes.accounts import accounts_bp
|
from app.routes.accounts import accounts_bp
|
||||||
from app.routes.categories import categories_bp
|
from app.routes.categories import categories_bp
|
||||||
from app.routes.transactions import transactions_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(auth_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
app.register_blueprint(accounts_bp)
|
app.register_blueprint(accounts_bp)
|
||||||
app.register_blueprint(categories_bp)
|
app.register_blueprint(categories_bp)
|
||||||
app.register_blueprint(transactions_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():
|
with app.app_context():
|
||||||
from app.models import (
|
from app.models import (
|
||||||
User, Account, Category, Receipt, RecurringRule,
|
User, Account, Category, Receipt, RecurringRule,
|
||||||
@@ -44,7 +42,6 @@ def create_app(config_name=None):
|
|||||||
AiInsight, FxRate
|
AiInsight, FxRate
|
||||||
)
|
)
|
||||||
|
|
||||||
# Jinja2 globals
|
|
||||||
app.jinja_env.globals['format_currency'] = format_currency
|
app.jinja_env.globals['format_currency'] = format_currency
|
||||||
app.jinja_env.globals['format_percent'] = format_percent
|
app.jinja_env.globals['format_percent'] = format_percent
|
||||||
app.jinja_env.globals['format_large_number'] = format_large_number
|
app.jinja_env.globals['format_large_number'] = format_large_number
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -189,10 +189,10 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="sb-section">Planning</div>
|
<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>
|
<i class="bi bi-pie-chart"></i><span class="lt">Budgets</span>
|
||||||
</a>
|
</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>
|
<i class="bi bi-bullseye"></i><span class="lt">Goals</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -14,3 +14,4 @@ Pillow==11.1.0
|
|||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
cryptography==44.0.2
|
cryptography==44.0.2
|
||||||
|
python-dateutil==2.9.0
|
||||||
|
|||||||
Reference in New Issue
Block a user