""" Financial Health Score — synthesises savings rate, budget adherence, goal progress, and emergency fund coverage into a single 0–100 score. Each component is worth 25 points. Returns the total score plus a breakdown so the UI can show per-component detail and suggestions. """ from datetime import date from dateutil.relativedelta import relativedelta from sqlalchemy import func from app.extensions import db from app.models.transaction import Transaction from app.models.goal import Goal # ── Component scorers ──────────────────────────────────────────────────────── def _score_savings(max_pts=25): """ Avg savings rate over the last 3 full months. ≥ 20% → full marks; 10–20% → 18; 1–10% → 10; ≤ 0% → 0. """ today = date.today() month_start = today.replace(day=1) # Collect last 3 complete months incomes, expenses = [], [] for i in range(1, 4): mo_end = (month_start - relativedelta(days=1)) mo_start = mo_end.replace(day=1) month_start = mo_start inc = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0)) .filter(Transaction.transaction_type == 'income', Transaction.date >= mo_start, Transaction.date <= mo_end).scalar()) exp = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0)) .filter(Transaction.transaction_type == 'expense', Transaction.date >= mo_start, Transaction.date <= mo_end).scalar()) incomes.append(inc) expenses.append(exp) total_inc = sum(incomes) total_exp = sum(expenses) rate = ((total_inc - total_exp) / total_inc * 100) if total_inc > 0 else 0 if rate >= 20: pts = max_pts elif rate >= 10: pts = round(max_pts * 0.72) # 18/25 elif rate > 0: pts = round(max_pts * 0.40) # 10/25 else: pts = 0 return { 'points': pts, 'max': max_pts, 'value': round(rate, 1), 'label': f'{rate:+.1f}% savings rate (3-mo avg)', 'tip': None if rate >= 20 else ( 'Aim for 20%+ savings rate.' if rate < 10 else 'Good start — push toward 20%.'), } def _score_budgets(max_pts=25): """ What fraction of budgeted categories are currently under their limit? All under → full marks; scales linearly. """ from app.services.budget_service import get_budget_summary month_str = date.today().strftime('%Y-%m') summary = [s for s in get_budget_summary(month_str) if s['has_budget']] if not summary: return { 'points': max_pts, # no budgets set → not penalised 'max': max_pts, 'value': None, 'label': 'No budgets set', 'tip': 'Set monthly budgets to track spending limits.', } under = sum(1 for s in summary if not s['is_over']) ratio = under / len(summary) pts = round(ratio * max_pts) over_cats = [s['category'].name for s in summary if s['is_over']] tip = None if over_cats: tip = f'Over budget: {", ".join(over_cats[:3])}{"…" if len(over_cats) > 3 else ""}.' return { 'points': pts, 'max': max_pts, 'value': round(ratio * 100, 1), 'label': f'{under}/{len(summary)} categories under budget', 'tip': tip, } def _score_goals(max_pts=25): """ Average completion % across active (non-completed) goals. 100% avg → full marks; scales linearly. """ goals = Goal.query.filter_by(is_completed=False).all() if not goals: completed = Goal.query.filter_by(is_completed=True).count() return { 'points': max_pts if completed else round(max_pts * 0.5), 'max': max_pts, 'value': 100.0 if completed else 0.0, 'label': 'All goals completed!' if completed else 'No savings goals set', 'tip': None if completed else 'Create a savings goal to track progress.', } pcts = [] for g in goals: target = float(g.target_amount) if target > 0: pcts.append(min(float(g.current_amount) / target * 100, 100)) avg = (sum(pcts) / len(pcts)) if pcts else 0 pts = round(avg / 100 * max_pts) behind = [g.name for g, p in zip(goals, pcts) if p < 25] tip = None if behind: tip = f'Behind on: {", ".join(behind[:2])}{"…" if len(behind) > 2 else ""}.' return { 'points': pts, 'max': max_pts, 'value': round(avg, 1), 'label': f'{round(avg, 0):.0f}% avg goal progress ({len(goals)} active)', 'tip': tip, } def _score_emergency_fund(max_pts=25): """ Liquid assets vs 3-month expense target. ≥ 3 months → full marks; scales linearly up to 3 months. """ from app.services.goal_service import get_emergency_fund_status ef = get_emergency_fund_status() liquid = ef['liquid_assets'] target3 = ef['target_3mo'] covered = ef['months_covered'] pct3 = ef['pct_3mo'] # 0–100, capped pts = round(pct3 / 100 * max_pts) if covered >= 3: tip = None elif covered >= 1: short = target3 - liquid tip = f'Build to 3-month emergency fund (need ${short:,.0f} more).' else: tip = 'Start an emergency fund — aim for 1 month of expenses first.' return { 'points': pts, 'max': max_pts, 'value': round(covered, 1), 'label': f'{covered:.1f} months emergency fund', 'tip': tip, } # ── Public API ─────────────────────────────────────────────────────────────── def compute_health_score(): """ Compute the overall financial health score. Returns: { score: int 0–100 grade: str 'A' | 'B' | 'C' | 'D' | 'F' color: str CSS color components: list of component dicts } """ components = { 'savings': _score_savings(), 'budgets': _score_budgets(), 'goals': _score_goals(), 'emergency_fund': _score_emergency_fund(), } score = sum(c['points'] for c in components.values()) if score >= 85: grade, color = 'A', '#10b981' elif score >= 70: grade, color = 'B', '#3b82f6' elif score >= 55: grade, color = 'C', '#f59e0b' elif score >= 40: grade, color = 'D', '#f97316' else: grade, color = 'F', '#ef4444' # Add display names for the template labels = { 'savings': 'Savings Rate', 'budgets': 'Budget Adherence', 'goals': 'Goal Progress', 'emergency_fund': 'Emergency Fund', } icons = { 'savings': 'bi-piggy-bank', 'budgets': 'bi-pie-chart', 'goals': 'bi-bullseye', 'emergency_fund': 'bi-shield-check', } component_list = [ { 'key': key, 'name': labels[key], 'icon': icons[key], 'points': c['points'], 'max': c['max'], 'value': c['value'], 'label': c['label'], 'tip': c['tip'], 'pct': round(c['points'] / c['max'] * 100), } for key, c in components.items() ] return { 'score': score, 'grade': grade, 'color': color, 'components': component_list, 'tips': [c['tip'] for c in component_list if c['tip']], }