diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..398c8ca4 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .scenarios import bp as scenarios_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(scenarios_bp, url_prefix="/scenarios") diff --git a/packages/backend/app/routes/scenarios.py b/packages/backend/app/routes/scenarios.py new file mode 100644 index 00000000..7331c3d2 --- /dev/null +++ b/packages/backend/app/routes/scenarios.py @@ -0,0 +1,127 @@ +""" +Financial Scenario Simulator routes (Issue #94). + +Endpoints: + POST /scenarios/simulate → run a what-if simulation + GET /scenarios/presets → return common preset adjustment templates +""" + +from __future__ import annotations + +import logging +from datetime import date + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from ..services.scenario_simulator import ADJUSTMENT_TYPES, run_scenario + +bp = Blueprint("scenarios", __name__) +logger = logging.getLogger("finmind.scenarios") + +# ── Preset templates (no DB needed) ────────────────────────────────────────── + +_PRESETS = [ + { + "id": "reduce_dining_10", + "label": "Reduce dining out by 10%", + "description": "Cut restaurant / dining expenses by 10% per month.", + "adjustments": [{"type": "category_change", "label": "dining", "pct_change": -10}], + }, + { + "id": "salary_raise_15", + "label": "Salary raise +15%", + "description": "Model a 15% income increase.", + "adjustments": [{"type": "income_change", "pct_change": 15}], + }, + { + "id": "rent_increase_20pct", + "label": "Rent increase +20%", + "description": "Model a 20% rent increase as a fixed-cost change.", + "adjustments": [{"type": "fixed_cost_change", "label": "rent", "pct_change": 20}], + }, + { + "id": "lose_job", + "label": "Job loss (income → 0)", + "description": "What if your income dropped to zero?", + "adjustments": [{"type": "income_change", "pct_change": -100}], + }, + { + "id": "save_more_50_30_20", + "label": "Adopt 50/30/20 rule (cut wants by 20%)", + "description": "Trim discretionary spending by 20% to approach the 50/30/20 budget.", + "adjustments": [{"type": "category_change", "label": "discretionary", "pct_change": -20}], + }, +] + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + + +@bp.post("/simulate") +@jwt_required() +def simulate(): + """ + Run a what-if financial simulation. + + Request body (JSON): + adjustments (list, required) — list of adjustment objects. + Each must have: + type (str): one of 'category_change', 'income_change', 'fixed_cost_change' + Plus one of: + pct_change (float) — percentage change, e.g. -10 = −10% + amount_change (float) — absolute change in local currency + For category_change: also category_id (str/int, default 'uncat') + For fixed_cost_change: also label (str, e.g. 'rent') + + months (int, 1-12, default 3) — look-back window for baseline + anchor (YYYY-MM-DD, optional) — end date for baseline window + + Response keys: + baseline, scenario, delta, applied_adjustments, errors, + analysis_months, generated_at + """ + uid = int(get_jwt_identity()) + data = request.get_json(silent=True) or {} + + adjustments = data.get("adjustments") + if not isinstance(adjustments, list) or len(adjustments) == 0: + return jsonify(error="'adjustments' must be a non-empty list"), 400 + + if len(adjustments) > 20: + return jsonify(error="maximum 20 adjustments per simulation"), 400 + + months_raw = data.get("months", 3) + try: + months = int(months_raw) + if not (1 <= months <= 12): + raise ValueError + except (ValueError, TypeError): + return jsonify(error="'months' must be an integer between 1 and 12"), 400 + + anchor = None + if data.get("anchor"): + try: + anchor = date.fromisoformat(str(data["anchor"])) + except ValueError: + return jsonify(error="'anchor' must be a valid ISO date (YYYY-MM-DD)"), 400 + + result = run_scenario(uid, adjustments=adjustments, months=months, anchor=anchor) + + # If all adjustments failed validation, return 422 + if result["errors"] and not result["applied_adjustments"]: + return jsonify(result), 422 + + return jsonify(result), 200 + + +@bp.get("/presets") +@jwt_required() +def list_presets(): + """ + Return a list of common what-if scenario presets. + + Clients can use these templates directly or modify their adjustments + before posting to /scenarios/simulate. + """ + return jsonify(_PRESETS) diff --git a/packages/backend/app/services/scenario_simulator.py b/packages/backend/app/services/scenario_simulator.py new file mode 100644 index 00000000..f22339d3 --- /dev/null +++ b/packages/backend/app/services/scenario_simulator.py @@ -0,0 +1,304 @@ +""" +Financial Scenario Simulator — What-If Planning (Issue #94). + +Allows users to simulate the financial impact of prospective decisions +without mutating any real data: + + • reduce / increase a spending category by a percentage or fixed amount + • apply a salary / income change + • model a rent or fixed-cost change + • combine multiple adjustments in a single simulation + +All calculations are performed in-memory against the user's *current* monthly +average; nothing is written to the database. + +Public API +---------- +run_scenario(uid, adjustments, months, anchor) → ScenarioResult dict +""" + +from __future__ import annotations + +import logging +from datetime import date +from decimal import Decimal +from typing import Any + +from sqlalchemy import extract, func + +from ..extensions import db +from ..models import Category, Expense + +logger = logging.getLogger("finmind.scenario") + +# ── Types & constants ───────────────────────────────────────────────────────── + +ADJUSTMENT_TYPES = frozenset( + ["category_change", "income_change", "fixed_cost_change"] +) + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _avg_monthly_income(uid: int, months: int, anchor: date) -> float: + totals = [] + y, m = anchor.year, anchor.month + for _ in range(months): + val = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == y, + extract("month", Expense.spent_at) == m, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + totals.append(float(val or 0)) + m -= 1 + if m == 0: + m = 12 + y -= 1 + return round(sum(totals) / months, 2) if months else 0.0 + + +def _avg_monthly_category_spend(uid: int, months: int, anchor: date) -> dict[str, float]: + """Return {category_id_str: avg_monthly_amount} over last *months* months.""" + combined: dict[str | None, list[float]] = {} + y, m = anchor.year, anchor.month + for _ in range(months): + rows = ( + db.session.query( + Expense.category_id, + func.coalesce(func.sum(Expense.amount), 0).label("total"), + ) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == y, + extract("month", Expense.spent_at) == m, + Expense.expense_type == "EXPENSE", + ) + .group_by(Expense.category_id) + .all() + ) + for row in rows: + key = str(row.category_id) if row.category_id is not None else "uncat" + combined.setdefault(key, []).append(float(row.total)) + m -= 1 + if m == 0: + m = 12 + y -= 1 + + return { + k: round(sum(v) / months, 2) + for k, v in combined.items() + } + + +def _category_name(cat_id: int | None) -> str: + if cat_id is None: + return "Uncategorized" + cat = db.session.get(Category, cat_id) + return cat.name if cat else f"Category {cat_id}" + + +def _apply_adjustment( + baseline_expenses: dict[str, float], + baseline_income: float, + adj: dict, +) -> tuple[dict[str, float], float, str | None]: + """ + Apply a single adjustment to (baseline_expenses, baseline_income). + + Returns (new_expenses, new_income, error_message). + error_message is None on success. + """ + adj_type = adj.get("type") + if adj_type not in ADJUSTMENT_TYPES: + return baseline_expenses, baseline_income, ( + f"unknown adjustment type '{adj_type}'; " + f"must be one of {sorted(ADJUSTMENT_TYPES)}" + ) + + new_expenses = dict(baseline_expenses) + new_income = baseline_income + + if adj_type == "category_change": + # Required: category_id (str/int) or "uncat" + # One of: pct_change (float, e.g. -10 = −10%) or amount_change (float) + cat_key = str(adj.get("category_id", "uncat")) + pct = adj.get("pct_change") + fixed = adj.get("amount_change") + + if pct is None and fixed is None: + return baseline_expenses, baseline_income, ( + "category_change requires 'pct_change' or 'amount_change'" + ) + + current = new_expenses.get(cat_key, 0.0) + if pct is not None: + delta = current * float(pct) / 100 + else: + delta = float(fixed) + new_val = max(0.0, current + delta) + new_expenses[cat_key] = round(new_val, 2) + + elif adj_type == "income_change": + pct = adj.get("pct_change") + fixed = adj.get("amount_change") + if pct is None and fixed is None: + return baseline_expenses, baseline_income, ( + "income_change requires 'pct_change' or 'amount_change'" + ) + if pct is not None: + new_income = round(max(0.0, baseline_income * (1 + float(pct) / 100)), 2) + else: + new_income = round(max(0.0, baseline_income + float(fixed)), 2) + + elif adj_type == "fixed_cost_change": + # Model a rent / subscription / loan change as an unnamed fixed entry + # stored under a synthetic key "fixed: