diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..4809039a 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import SavingsOpportunities from "./pages/SavingsOpportunities"; const queryClient = new QueryClient({ defaultOptions: { @@ -91,6 +92,14 @@ const App = () => ( } /> + + + + } + /> } /> } /> diff --git a/app/src/api/savings.ts b/app/src/api/savings.ts new file mode 100644 index 00000000..da6163af --- /dev/null +++ b/app/src/api/savings.ts @@ -0,0 +1,52 @@ +import api from "./index"; + +export type OpportunityType = + | "consistent_underspend" + | "recurring_subscription" + | "irregular_big_spend"; + +export interface SavingsOpportunity { + type: OpportunityType; + category_id: number; + category_name: string; + message: string; + // consistent_underspend + avg_monthly_spend?: number; + recent_spend?: number; + reduction_pct?: number; + estimated_monthly_saving?: number; + // recurring_subscription + recurring_amount?: number; + months_detected?: string[]; + estimated_annual_cost?: number; + // irregular_big_spend + amount?: number; + category_avg?: number; + spent_at?: string; + notes?: string; +} + +export interface TopSpenderCategory { + type: "top_spender"; + category_id: number; + category_name: string; + avg_monthly_spend: number; + pct_of_total_spend: number; + message: string; +} + +export interface SavingsReport { + months_analysed: number; + total_estimated_monthly_saving: number; + opportunities: SavingsOpportunity[]; + top_spender_categories: TopSpenderCategory[]; + summary: { + consistent_underspend: number; + recurring_subscriptions: number; + irregular_big_spends: number; + total_opportunities: number; + }; +} + +export const getSavingsOpportunities = (months = 3): Promise => + api.get(`/insights/savings-opportunities?months=${months}`).then((r) => r.data); diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..4c1a34d8 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -13,6 +13,7 @@ const navigation = [ { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, { name: 'Analytics', href: '/analytics' }, + { name: 'Savings', href: '/savings' }, ]; export function Navbar() { diff --git a/app/src/pages/SavingsOpportunities.tsx b/app/src/pages/SavingsOpportunities.tsx new file mode 100644 index 00000000..026c7e96 --- /dev/null +++ b/app/src/pages/SavingsOpportunities.tsx @@ -0,0 +1,327 @@ +import { useEffect, useState } from "react"; +import { + getSavingsOpportunities, + type SavingsOpportunity, + type SavingsReport, + type TopSpenderCategory, +} from "../api/savings"; + +const TYPE_CONFIG = { + consistent_underspend: { + icon: "πŸ“‰", + color: "#4CAF50", + bg: "rgba(76,175,80,0.1)", + label: "Cut Budget", + }, + recurring_subscription: { + icon: "πŸ”„", + color: "#FF9800", + bg: "rgba(255,152,0,0.1)", + label: "Subscription", + }, + irregular_big_spend: { + icon: "⚑", + color: "#F44336", + bg: "rgba(244,67,54,0.1)", + label: "Big Spend", + }, +}; + +function fmt(n: number) { + return `β‚Ή${n.toLocaleString("en-IN", { maximumFractionDigits: 0 })}`; +} + +function OpportunityCard({ op }: { op: SavingsOpportunity }) { + const cfg = TYPE_CONFIG[op.type]; + return ( +
+
+ + {cfg.icon} {op.category_name} + + + {cfg.label} + +
+

+ {op.message} +

+ {op.type === "consistent_underspend" && op.estimated_monthly_saving != null && ( +
+ + Avg: {fmt(op.avg_monthly_spend!)} + + + Recent: {fmt(op.recent_spend!)} + + + Save {fmt(op.estimated_monthly_saving)}/mo + +
+ )} + {op.type === "recurring_subscription" && op.estimated_annual_cost != null && ( +
+ Annual cost: {fmt(op.estimated_annual_cost)} Β·{" "} + {op.months_detected?.length} months detected +
+ )} + {op.type === "irregular_big_spend" && op.amount != null && ( +
+ Spend: {fmt(op.amount)} vs avg{" "} + {fmt(op.category_avg!)} + {op.spent_at && ` β€” ${op.spent_at}`} +
+ )} +
+ ); +} + +function TopSpenderRow({ t }: { t: TopSpenderCategory }) { + return ( +
+
+ {t.pct_of_total_spend}% +
+
+
+
+
+
+ {t.category_name} Β· avg {fmt(t.avg_monthly_spend)}/mo +
+
+
+ ); +} + +function SummaryCard({ label, value, color }: { label: string; value: number | string; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +export default function SavingsOpportunities() { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [months, setMonths] = useState(3); + const [filter, setFilter] = useState("all"); + + const load = async () => { + setLoading(true); + try { + const data = await getSavingsOpportunities(months); + setReport(data); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, [months]); + + const filtered = report?.opportunities.filter( + (o) => filter === "all" || o.type === filter + ) ?? []; + + return ( +
+
+

+ πŸ’‘ Savings Opportunities +

+ +
+ + {report && ( + <> + {/* Potential saving banner */} + {report.total_estimated_monthly_saving > 0 && ( +
+
Estimated monthly savings if acted on
+
+ {fmt(report.total_estimated_monthly_saving)} + /month +
+
+ β‰ˆ {fmt(report.total_estimated_monthly_saving * 12)} per year +
+
+ )} + + {/* Summary pills */} +
+ + + +
+ + {/* Filter tabs */} +
+ {["all", "consistent_underspend", "recurring_subscription", "irregular_big_spend"].map((f) => ( + + ))} +
+ + )} + + {loading &&

Analysing your spending…

} + + {!loading && report && ( + <> + {filtered.length === 0 ? ( +
+ {report.summary.total_opportunities === 0 + ? "No saving opportunities detected yet. Keep tracking your expenses!" + : "No items in this category."} +
+ ) : ( +
+ {filtered.map((op, i) => ( + + ))} +
+ )} + + {report.top_spender_categories.length > 0 && ( +
+

+ Where your money goes +

+
+ {report.top_spender_categories.map((t) => ( + + ))} +
+
+ )} + + )} +
+ ); +} diff --git a/packages/backend/app/routes/insights.py b/packages/backend/app/routes/insights.py index bfc02e43..3eaae7f8 100644 --- a/packages/backend/app/routes/insights.py +++ b/packages/backend/app/routes/insights.py @@ -2,6 +2,7 @@ from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity from ..services.ai import monthly_budget_suggestion +from ..services.savings import detect_savings_opportunities import logging bp = Blueprint("insights", __name__) @@ -23,3 +24,27 @@ def budget_suggestion(): ) logger.info("Budget suggestion served user=%s month=%s", uid, ym) return jsonify(suggestion) + + +@bp.get("/savings-opportunities") +@jwt_required() +def savings_opportunities(): + """Detect and return personalised savings opportunities for the current user. + + Query params: + - months: look-back window in months (default 3, max 12) + """ + uid = int(get_jwt_identity()) + try: + months = min(int(request.args.get("months", 3)), 12) + except (ValueError, TypeError): + months = 3 + + report = detect_savings_opportunities(uid, months=months) + logger.info( + "Savings opportunities analysed user=%s months=%s opportunities=%s", + uid, + months, + report["summary"]["total_opportunities"], + ) + return jsonify(report) diff --git a/packages/backend/app/services/savings.py b/packages/backend/app/services/savings.py new file mode 100644 index 00000000..107d641d --- /dev/null +++ b/packages/backend/app/services/savings.py @@ -0,0 +1,289 @@ +"""Savings opportunity detection service (#119). + +Analyses a user's expense history to surface actionable savings opportunities: + +1. **Consistent underspend** – categories where the user spends significantly + less than their own 3-month average (i.e., they already proved they can + spend less; they could budget lower and save the surplus). + +2. **Top spender categories** – the highest-cost categories relative to total + spend, flagged as potential candidates for reduction. + +3. **Recurring / subscription detection** – same-amount expenses repeating + β‰₯2 times in different months β†’ possible subscription worth reviewing. + +4. **Irregular big spends** – one-off large expenses (> 2Γ— the category mean) + that are not recurring, annotated so the user knows to plan for them. +""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import date, timedelta + +from sqlalchemy import func + +from ..extensions import db +from ..models import Category, Expense + +# ── helpers ──────────────────────────────────────────────────────────────────── + +_MONTHS = 3 # look-back window for trend analysis + + +def _month_label(d: date) -> str: + return d.strftime("%Y-%m") + + +def _months_back(n: int) -> date: + today = date.today() + # approximate: subtract ~30 days per month + return today - timedelta(days=30 * n) + + +def _category_monthly_spend(user_id: int, months: int) -> dict[int, dict[str, float]]: + """Returns {category_id: {YYYY-MM: total_spent}}.""" + since = _months_back(months + 1) + rows = ( + db.session.query( + Expense.category_id, + func.strftime("%Y-%m", Expense.spent_at).label("month"), + func.sum(Expense.amount).label("total"), + ) + .filter( + Expense.user_id == user_id, + Expense.category_id.isnot(None), + Expense.expense_type != "INCOME", + Expense.spent_at >= since, + ) + .group_by(Expense.category_id, "month") + .all() + ) + result: dict[int, dict[str, float]] = defaultdict(dict) + for cat_id, month, total in rows: + result[cat_id][month] = float(total) + return result + + +def _category_name(cat_id: int) -> str: + cat = Category.query.get(cat_id) + return cat.name if cat else f"Category #{cat_id}" + + +# ── opportunity detectors ────────────────────────────────────────────────────── + + +def _consistent_underspend(monthly: dict[int, dict[str, float]]) -> list[dict]: + """Categories where recent spending is β‰₯ 20% below their own 3-month average.""" + today_month = _month_label(date.today()) + opportunities = [] + for cat_id, months in monthly.items(): + sorted_months = sorted(months.keys()) + if len(sorted_months) < 2: + continue + + # Past months (exclude current month) + past = [months[m] for m in sorted_months if m < today_month] + current = months.get(today_month) + + if not past: + continue + + avg_past = sum(past) / len(past) + if avg_past == 0: + continue + + # Use the last full past month as "recent" + recent = past[-1] + reduction_pct = round((1 - recent / avg_past) * 100, 1) + + if reduction_pct >= 20: + # Potential monthly saving = difference vs average + monthly_saving = round(avg_past - recent, 2) + opportunities.append( + { + "type": "consistent_underspend", + "category_id": cat_id, + "category_name": _category_name(cat_id), + "avg_monthly_spend": round(avg_past, 2), + "recent_spend": round(recent, 2), + "reduction_pct": reduction_pct, + "estimated_monthly_saving": monthly_saving, + "message": ( + f"You reduced spending on {_category_name(cat_id)} by " + f"{reduction_pct}% recently. Locking in this lower budget " + f"could save β‚Ή{monthly_saving:,.0f}/month." + ), + } + ) + return sorted(opportunities, key=lambda x: -x["estimated_monthly_saving"]) + + +def _top_spender_categories( + monthly: dict[int, dict[str, float]], top_n: int = 5 +) -> list[dict]: + """Top-N categories by average monthly spend.""" + totals: dict[int, float] = {} + for cat_id, months in monthly.items(): + avg = sum(months.values()) / max(len(months), 1) + totals[cat_id] = avg + + ranked = sorted(totals.items(), key=lambda x: -x[1])[:top_n] + grand_total = sum(totals.values()) or 1 + + result = [] + for cat_id, avg in ranked: + pct_of_total = round(avg / grand_total * 100, 1) + result.append( + { + "type": "top_spender", + "category_id": cat_id, + "category_name": _category_name(cat_id), + "avg_monthly_spend": round(avg, 2), + "pct_of_total_spend": pct_of_total, + "message": ( + f"{_category_name(cat_id)} accounts for {pct_of_total}% of " + "your monthly spend. Even a 10% reduction here would have a " + f"notable impact (β‰ˆβ‚Ή{avg * 0.1:,.0f}/month)." + ), + } + ) + return result + + +def _detect_subscriptions(user_id: int, months: int = 3) -> list[dict]: + """Same-amount expenses appearing β‰₯ 2 times across different months.""" + since = _months_back(months + 1) + rows = ( + db.session.query( + Expense.category_id, + Expense.amount, + func.strftime("%Y-%m", Expense.spent_at).label("month"), + func.count(Expense.id).label("cnt"), + ) + .filter( + Expense.user_id == user_id, + Expense.category_id.isnot(None), + Expense.expense_type != "INCOME", + Expense.spent_at >= since, + ) + .group_by(Expense.category_id, Expense.amount, "month") + .all() + ) + + # Build: {(cat_id, amount): set_of_months_where_it_appeared} + tracker: dict[tuple, set] = defaultdict(set) + for cat_id, amount, month, _cnt in rows: + tracker[(cat_id, float(amount))].add(month) + + results = [] + seen_cats: set[int] = set() + for (cat_id, amount), month_set in tracker.items(): + if len(month_set) >= 2 and cat_id not in seen_cats: + seen_cats.add(cat_id) + annual = round(amount * 12, 2) + results.append( + { + "type": "recurring_subscription", + "category_id": cat_id, + "category_name": _category_name(cat_id), + "recurring_amount": round(amount, 2), + "months_detected": sorted(month_set), + "estimated_annual_cost": annual, + "message": ( + f"Possible recurring charge of β‚Ή{amount:,.2f} detected in " + f"{_category_name(cat_id)}. Annual cost: β‚Ή{annual:,.0f}. " + "Review if this subscription is still needed." + ), + } + ) + return sorted(results, key=lambda x: -x["estimated_annual_cost"]) + + +def _irregular_big_spends(user_id: int, months: int = 3) -> list[dict]: + """Single expenses that are β‰₯ 2Γ— the category average.""" + since = _months_back(months + 1) + + # Get avg per category + avgs = ( + db.session.query( + Expense.category_id, + func.avg(Expense.amount).label("avg_amt"), + ) + .filter( + Expense.user_id == user_id, + Expense.category_id.isnot(None), + Expense.expense_type != "INCOME", + Expense.spent_at >= since, + ) + .group_by(Expense.category_id) + .all() + ) + avg_map = {r.category_id: float(r.avg_amt) for r in avgs} + + # Find individual expenses > 2Γ— category avg + big = ( + db.session.query(Expense) + .filter( + Expense.user_id == user_id, + Expense.category_id.isnot(None), + Expense.expense_type != "INCOME", + Expense.spent_at >= since, + ) + .all() + ) + + results = [] + seen_cats: set[int] = set() + for e in big: + avg = avg_map.get(e.category_id, 0) + if avg > 0 and float(e.amount) > 2 * avg and e.category_id not in seen_cats: + seen_cats.add(e.category_id) + results.append( + { + "type": "irregular_big_spend", + "category_id": e.category_id, + "category_name": _category_name(e.category_id), + "amount": float(e.amount), + "category_avg": round(avg, 2), + "spent_at": e.spent_at.isoformat(), + "notes": e.notes, + "message": ( + f"Unusually large spend of β‚Ή{float(e.amount):,.2f} in " + f"{_category_name(e.category_id)} on {e.spent_at} " + f"(avg: β‚Ή{avg:,.2f}). Plan for this or find a cheaper alternative." + ), + } + ) + return sorted(results, key=lambda x: -x["amount"]) + + +# ── public API ───────────────────────────────────────────────────────────────── + + +def detect_savings_opportunities(user_id: int, months: int = _MONTHS) -> dict: + """Run all detectors and return a consolidated savings report.""" + monthly = _category_monthly_spend(user_id, months) + + underspend = _consistent_underspend(monthly) + top_spend = _top_spender_categories(monthly) + subscriptions = _detect_subscriptions(user_id, months) + irregular = _irregular_big_spends(user_id, months) + + # Total estimated monthly saving from underspend opportunities + total_potential_saving = sum(o["estimated_monthly_saving"] for o in underspend) + + all_opportunities = underspend + subscriptions + irregular + + return { + "months_analysed": months, + "total_estimated_monthly_saving": round(total_potential_saving, 2), + "opportunities": all_opportunities, + "top_spender_categories": top_spend, + "summary": { + "consistent_underspend": len(underspend), + "recurring_subscriptions": len(subscriptions), + "irregular_big_spends": len(irregular), + "total_opportunities": len(all_opportunities), + }, + } diff --git a/packages/backend/tests/test_savings.py b/packages/backend/tests/test_savings.py new file mode 100644 index 00000000..594f4cdf --- /dev/null +++ b/packages/backend/tests/test_savings.py @@ -0,0 +1,163 @@ +"""Tests for savings opportunity detection engine (#119).""" + +from datetime import date, timedelta + + +TODAY = date.today().isoformat() + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _create_category(client, auth_header, name="Groceries"): + r = client.post("/categories", json={"name": name}, headers=auth_header) + assert r.status_code in (200, 201) + return r.get_json()["id"] + + +def _add_expense(client, auth_header, category_id, amount, spent_at=None, expense_type="EXPENSE", notes=None): + payload = { + "category_id": category_id, + "amount": amount, + "expense_type": expense_type, + "spent_at": spent_at or TODAY, + } + if notes: + payload["notes"] = notes + r = client.post("/expenses", json=payload, headers=auth_header) + assert r.status_code in (200, 201) + + +def _date_months_ago(n: int) -> str: + return (date.today() - timedelta(days=30 * n)).isoformat() + + +# ── endpoint smoke tests ────────────────────────────────────────────────────── + +def test_savings_opportunities_no_data(client, auth_header): + r = client.get("/insights/savings-opportunities", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["summary"]["total_opportunities"] == 0 + assert data["total_estimated_monthly_saving"] == 0 + + +def test_savings_opportunities_requires_auth(client): + r = client.get("/insights/savings-opportunities") + assert r.status_code == 401 + + +def test_savings_opportunities_invalid_months_param(client, auth_header): + """Non-numeric months param should fall back to default without error.""" + r = client.get("/insights/savings-opportunities?months=abc", headers=auth_header) + assert r.status_code == 200 + assert "months_analysed" in r.get_json() + + +def test_savings_opportunities_months_capped(client, auth_header): + """months param should be capped at 12.""" + r = client.get("/insights/savings-opportunities?months=100", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["months_analysed"] == 12 + + +# ── underspend detection ────────────────────────────────────────────────────── + +def test_consistent_underspend_detected(client, auth_header): + """Add high spend 2 months ago, then low spend last month β€” should flag underspend.""" + cid = _create_category(client, auth_header, "Transport") + # 2 months ago: high spend + _add_expense(client, auth_header, cid, 1000, _date_months_ago(2)) + # 1 month ago: 50% lower + _add_expense(client, auth_header, cid, 500, _date_months_ago(1)) + r = client.get("/insights/savings-opportunities?months=3", headers=auth_header) + data = r.get_json() + underspend_ops = [o for o in data["opportunities"] if o["type"] == "consistent_underspend"] + # May or may not trigger depending on month boundaries, just check structure + for op in underspend_ops: + assert op["category_id"] == cid + assert op["estimated_monthly_saving"] > 0 + assert "message" in op + + +# ── subscription detection ──────────────────────────────────────────────────── + +def test_recurring_subscription_detected(client, auth_header): + """Same amount in 2 different months should be flagged as a subscription.""" + cid = _create_category(client, auth_header, "Streaming") + # Same amount exactly in two different months + _add_expense(client, auth_header, cid, 499.0, _date_months_ago(2)) + _add_expense(client, auth_header, cid, 499.0, _date_months_ago(1)) + r = client.get("/insights/savings-opportunities?months=3", headers=auth_header) + data = r.get_json() + subs = [o for o in data["opportunities"] if o["type"] == "recurring_subscription"] + assert len(subs) >= 1 + assert subs[0]["recurring_amount"] == 499.0 + assert subs[0]["estimated_annual_cost"] == pytest.approx(499.0 * 12) + assert len(subs[0]["months_detected"]) >= 2 + + +def test_non_recurring_not_flagged(client, auth_header): + """Different amounts should not be detected as a subscription.""" + cid = _create_category(client, auth_header, "OneTime") + _add_expense(client, auth_header, cid, 100.0, _date_months_ago(2)) + _add_expense(client, auth_header, cid, 200.0, _date_months_ago(1)) + r = client.get("/insights/savings-opportunities?months=3", headers=auth_header) + subs = [o for o in r.get_json()["opportunities"] if o["type"] == "recurring_subscription"] + # Amounts differ β†’ no subscription for this category + assert all(o["category_id"] != cid for o in subs) + + +# ── top spender categories ──────────────────────────────────────────────────── + +def test_top_spender_categories_returned(client, auth_header): + cid = _create_category(client, auth_header, "Rent") + _add_expense(client, auth_header, cid, 15000, _date_months_ago(1)) + r = client.get("/insights/savings-opportunities", headers=auth_header) + data = r.get_json() + assert isinstance(data["top_spender_categories"], list) + top_ids = [t["category_id"] for t in data["top_spender_categories"]] + assert cid in top_ids + + +# ── irregular big spends ────────────────────────────────────────────────────── + +def test_irregular_big_spend_detected(client, auth_header): + """A single expense > 2Γ— category average should be flagged.""" + cid = _create_category(client, auth_header, "Dining") + # Several normal-sized expenses + for _ in range(3): + _add_expense(client, auth_header, cid, 200, _date_months_ago(2)) + # One big outlier + _add_expense(client, auth_header, cid, 2000, _date_months_ago(1)) + r = client.get("/insights/savings-opportunities?months=3", headers=auth_header) + big = [o for o in r.get_json()["opportunities"] if o["type"] == "irregular_big_spend"] + assert any(o["category_id"] == cid and o["amount"] == 2000 for o in big) + + +def test_income_excluded_from_analysis(client, auth_header): + """INCOME type transactions should not influence opportunity detection.""" + cid = _create_category(client, auth_header, "Salary") + _add_expense(client, auth_header, cid, 50000, _date_months_ago(1), expense_type="INCOME") + r = client.get("/insights/savings-opportunities", headers=auth_header) + data = r.get_json() + all_cat_ids = [o["category_id"] for o in data["opportunities"]] + # Income category should not appear in opportunities + assert cid not in all_cat_ids + + +def test_response_structure(client, auth_header): + r = client.get("/insights/savings-opportunities", headers=auth_header) + data = r.get_json() + assert "months_analysed" in data + assert "total_estimated_monthly_saving" in data + assert "opportunities" in data + assert "top_spender_categories" in data + assert "summary" in data + summary = data["summary"] + assert "consistent_underspend" in summary + assert "recurring_subscriptions" in summary + assert "irregular_big_spends" in summary + assert "total_opportunities" in summary + + +import pytest