+
+
Lifestyle Inflation
+
+ Categories where your spending has grown compared to the previous period.
+
+
+
+ {/* Controls */}
+
+
+
+
+ {WINDOW_OPTIONS.map((w) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {loading && (
+
+ Analysing spending patterns…
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {data && !loading && (
+ <>
+ {/* Summary banner */}
+
+
+
+ {data.summary.inflated_count}
+
+
Inflated categories
+
+
+
+ {data.summary.stable_count}
+
+
Stable categories
+
+
+
+ {currency} {data.summary.total_extra_monthly_spend.toFixed(2)}
+
+
Extra spend/month
+
+
+
+ {currency} {data.summary.total_extra_annual_spend.toFixed(2)}
+
+
Extra spend/year
+
+
+
+ {/* Inflated categories */}
+ {data.inflated_categories.length === 0 ? (
+
+
No lifestyle inflation detected 🎉
+
+ Your spending is stable or decreasing across all categories.
+
+
+ ) : (
+
+
+ Inflated Categories ({data.inflated_categories.length})
+
+
+ {data.inflated_categories.map((cat) => (
+
+ ))}
+
+
+ )}
+
+ {/* Stable categories (collapsible) */}
+ {data.stable_categories.length > 0 && (
+
+
+ {showStable && (
+
+ {data.stable_categories.map((cat) => (
+
+ ))}
+
+ )}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/packages/backend/app/routes/insights.py b/packages/backend/app/routes/insights.py
index bfc02e43..e2134ca9 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.lifestyle import detect_lifestyle_inflation
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("/lifestyle-inflation")
+@jwt_required()
+def lifestyle_inflation():
+ uid = int(get_jwt_identity())
+ try:
+ window = max(1, min(12, int(request.args.get("window_months", 3))))
+ except (TypeError, ValueError):
+ window = 3
+ try:
+ threshold = float(request.args.get("threshold_pct", 10.0))
+ threshold = max(0.0, min(100.0, threshold))
+ except (TypeError, ValueError):
+ threshold = 10.0
+
+ result = detect_lifestyle_inflation(uid, window_months=window, inflation_threshold_pct=threshold)
+ logger.info(
+ "Lifestyle inflation served user=%s window=%s inflated=%s",
+ uid,
+ window,
+ result["summary"]["inflated_count"],
+ )
+ return jsonify(result)
diff --git a/packages/backend/app/services/lifestyle.py b/packages/backend/app/services/lifestyle.py
new file mode 100644
index 00000000..2aa5b16e
--- /dev/null
+++ b/packages/backend/app/services/lifestyle.py
@@ -0,0 +1,190 @@
+"""
+Lifestyle inflation detection service.
+
+Compares spending per category between two equal time windows (recent vs
+previous) and surfaces categories where spending has grown materially,
+indicating lifestyle inflation.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from datetime import date
+from decimal import Decimal
+
+from sqlalchemy import func, extract
+
+from ..extensions import db
+from ..models import Expense, Category
+
+
+def _monthly_totals(user_id: int, months: int) -> dict[int, dict[str, Decimal]]:
+ """
+ Return {category_id: {YYYY-MM: total_spent}} for EXPENSE rows only,
+ covering the last `months` calendar months up to today.
+ """
+ today = date.today()
+ # Build a simple list of (year, month) for the last `months` months
+ periods: list[tuple[int, int]] = []
+ y, m = today.year, today.month
+ for _ in range(months):
+ periods.append((y, m))
+ m -= 1
+ if m == 0:
+ m = 12
+ y -= 1
+
+ if not periods:
+ return {}
+
+ oldest_year, oldest_month = periods[-1]
+ newest_year, newest_month = periods[0]
+
+ rows = (
+ db.session.query(
+ Expense.category_id,
+ extract("year", Expense.spent_at).label("yr"),
+ extract("month", Expense.spent_at).label("mo"),
+ func.sum(Expense.amount).label("total"),
+ )
+ .filter(
+ Expense.user_id == user_id,
+ Expense.expense_type == "EXPENSE",
+ Expense.category_id.isnot(None),
+ )
+ .filter(
+ # Keep rows within the rolling window
+ db.or_(
+ extract("year", Expense.spent_at) > oldest_year,
+ db.and_(
+ extract("year", Expense.spent_at) == oldest_year,
+ extract("month", Expense.spent_at) >= oldest_month,
+ ),
+ ),
+ db.or_(
+ extract("year", Expense.spent_at) < newest_year,
+ db.and_(
+ extract("year", Expense.spent_at) == newest_year,
+ extract("month", Expense.spent_at) <= newest_month,
+ ),
+ ),
+ )
+ .group_by(
+ Expense.category_id,
+ extract("year", Expense.spent_at),
+ extract("month", Expense.spent_at),
+ )
+ .all()
+ )
+
+ result: dict[int, dict[str, Decimal]] = defaultdict(dict)
+ for row in rows:
+ ym = f"{int(row.yr):04d}-{int(row.mo):02d}"
+ result[int(row.category_id)][ym] = Decimal(str(row.total))
+ return result
+
+
+def _category_names(user_id: int) -> dict[int, str]:
+ rows = db.session.query(Category.id, Category.name).filter_by(user_id=user_id).all()
+ return {r.id: r.name for r in rows}
+
+
+def detect_lifestyle_inflation(
+ user_id: int,
+ window_months: int = 3,
+ inflation_threshold_pct: float = 10.0,
+) -> dict:
+ """
+ Detect lifestyle inflation for a user.
+
+ Compares average monthly spend in the most recent `window_months` months
+ against the previous `window_months` months. Categories where spending
+ grew by more than `inflation_threshold_pct` percent are returned as
+ inflation signals.
+
+ Returns a dict with:
+ - inflated_categories: list of category signals (sorted by pct_change desc)
+ - stable_categories: categories with no significant growth
+ - summary: counts and total_extra_monthly_spend
+ - window_months: echo input param
+ """
+ total_window = window_months * 2
+ monthly = _monthly_totals(user_id, total_window)
+ names = _category_names(user_id)
+
+ today = date.today()
+
+ # Build ordered list of YYYY-MM for recent and previous windows
+ recent_months: list[str] = []
+ previous_months: list[str] = []
+ y, m = today.year, today.month
+ for i in range(total_window):
+ ym = f"{y:04d}-{m:02d}"
+ if i < window_months:
+ recent_months.append(ym)
+ else:
+ previous_months.append(ym)
+ m -= 1
+ if m == 0:
+ m = 12
+ y -= 1
+
+ inflated: list[dict] = []
+ stable: list[dict] = []
+
+ all_cat_ids = set(monthly.keys())
+ for cat_id in all_cat_ids:
+ totals = monthly[cat_id]
+ recent_values = [float(totals.get(mo, Decimal("0"))) for mo in recent_months]
+ prev_values = [float(totals.get(mo, Decimal("0"))) for mo in previous_months]
+
+ recent_avg = sum(recent_values) / len(recent_months)
+ prev_avg = sum(prev_values) / len(previous_months)
+
+ # Need at least some spending in the previous window to compare
+ if prev_avg < 0.01:
+ continue
+
+ pct_change = ((recent_avg - prev_avg) / prev_avg) * 100.0
+ abs_change = recent_avg - prev_avg
+
+ # Build monthly trend (all periods, ordered oldest→newest)
+ trend = []
+ for mo in reversed(previous_months):
+ trend.append({"month": mo, "amount": float(totals.get(mo, Decimal("0")))})
+ for mo in reversed(recent_months):
+ trend.append({"month": mo, "amount": float(totals.get(mo, Decimal("0")))})
+
+ entry = {
+ "category_id": cat_id,
+ "category_name": names.get(cat_id, "Unknown"),
+ "recent_avg_monthly": round(recent_avg, 2),
+ "previous_avg_monthly": round(prev_avg, 2),
+ "pct_change": round(pct_change, 1),
+ "abs_change_monthly": round(abs_change, 2),
+ "annualised_extra": round(abs_change * 12, 2),
+ "trend": trend,
+ }
+
+ if pct_change >= inflation_threshold_pct:
+ inflated.append(entry)
+ else:
+ stable.append(entry)
+
+ inflated.sort(key=lambda x: x["pct_change"], reverse=True)
+ stable.sort(key=lambda x: x["pct_change"], reverse=True)
+
+ total_extra = sum(e["abs_change_monthly"] for e in inflated)
+
+ return {
+ "inflated_categories": inflated,
+ "stable_categories": stable,
+ "summary": {
+ "inflated_count": len(inflated),
+ "stable_count": len(stable),
+ "total_extra_monthly_spend": round(total_extra, 2),
+ "total_extra_annual_spend": round(total_extra * 12, 2),
+ },
+ "window_months": window_months,
+ "inflation_threshold_pct": inflation_threshold_pct,
+ }
diff --git a/packages/backend/tests/test_lifestyle.py b/packages/backend/tests/test_lifestyle.py
new file mode 100644
index 00000000..afdf4f14
--- /dev/null
+++ b/packages/backend/tests/test_lifestyle.py
@@ -0,0 +1,199 @@
+"""Tests for lifestyle inflation detection endpoint."""
+import pytest
+from datetime import date, timedelta
+from decimal import Decimal
+
+
+# ---------------------------------------------------------------------------
+# helpers
+# ---------------------------------------------------------------------------
+
+def _add_expense(client, headers, amount, category_id, days_ago, expense_type="EXPENSE"):
+ d = (date.today() - timedelta(days=days_ago)).isoformat()
+ r = client.post(
+ "/expenses",
+ json={
+ "amount": amount,
+ "category_id": category_id,
+ "expense_type": expense_type,
+ "spent_at": d,
+ "notes": "test",
+ },
+ headers=headers,
+ )
+ return r
+
+
+def _add_category(client, headers, name):
+ r = client.post("/categories", json={"name": name}, headers=headers)
+ assert r.status_code in (200, 201)
+ return r.get_json()["id"]
+
+
+# ---------------------------------------------------------------------------
+# tests
+# ---------------------------------------------------------------------------
+
+def test_lifestyle_inflation_requires_auth(client):
+ r = client.get("/insights/lifestyle-inflation")
+ assert r.status_code == 401
+
+
+def test_lifestyle_inflation_empty(client, auth_header):
+ r = client.get("/insights/lifestyle-inflation", headers=auth_header)
+ assert r.status_code == 200
+ data = r.get_json()
+ assert data["inflated_categories"] == []
+ assert data["stable_categories"] == []
+ assert data["summary"]["inflated_count"] == 0
+
+
+def test_lifestyle_inflation_detects_growth(client, auth_header):
+ cat_id = _add_category(client, auth_header, "Dining")
+
+ # Previous window: months ~4 and ~5 ago → small spend
+ for days in [120, 150]:
+ _add_expense(client, auth_header, 100, cat_id, days_ago=days)
+
+ # Recent window: months ~1 and ~2 ago → big spend (inflated)
+ for days in [15, 45]:
+ _add_expense(client, auth_header, 300, cat_id, days_ago=days)
+
+ r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header)
+ assert r.status_code == 200
+ data = r.get_json()
+ # Should detect inflation in Dining
+ assert data["summary"]["inflated_count"] >= 1
+ names = [c["category_name"] for c in data["inflated_categories"]]
+ assert "Dining" in names
+
+
+def test_lifestyle_inflation_no_growth_stable(client, auth_header):
+ cat_id = _add_category(client, auth_header, "Transport")
+
+ # Stable spend across both windows
+ for days in [15, 45, 75, 105]:
+ _add_expense(client, auth_header, 200, cat_id, days_ago=days)
+
+ r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header)
+ assert r.status_code == 200
+ data = r.get_json()
+ # Not inflated (0% change)
+ names_inflated = [c["category_name"] for c in data["inflated_categories"]]
+ assert "Transport" not in names_inflated
+
+
+def test_lifestyle_inflation_income_excluded(client, auth_header):
+ cat_id = _add_category(client, auth_header, "Salary")
+
+ # Large INCOME entries in recent window
+ for days in [15, 45]:
+ _add_expense(client, auth_header, 5000, cat_id, days_ago=days, expense_type="INCOME")
+
+ r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header)
+ assert r.status_code == 200
+ data = r.get_json()
+ # Income should not appear in inflation results
+ for cat in data["inflated_categories"] + data["stable_categories"]:
+ assert cat["category_name"] != "Salary"
+
+
+def test_lifestyle_inflation_response_structure(client, auth_header):
+ r = client.get("/insights/lifestyle-inflation", headers=auth_header)
+ assert r.status_code == 200
+ data = r.get_json()
+ assert "inflated_categories" in data
+ assert "stable_categories" in data
+ assert "summary" in data
+ assert "window_months" in data
+ assert "inflation_threshold_pct" in data
+ summary = data["summary"]
+ assert "inflated_count" in summary
+ assert "stable_count" in summary
+ assert "total_extra_monthly_spend" in summary
+ assert "total_extra_annual_spend" in summary
+
+
+def test_lifestyle_inflation_custom_window(client, auth_header):
+ r = client.get("/insights/lifestyle-inflation?window_months=6", headers=auth_header)
+ assert r.status_code == 200
+ assert r.get_json()["window_months"] == 6
+
+
+def test_lifestyle_inflation_custom_threshold(client, auth_header):
+ r = client.get(
+ "/insights/lifestyle-inflation?threshold_pct=20", headers=auth_header
+ )
+ assert r.status_code == 200
+ assert r.get_json()["inflation_threshold_pct"] == 20.0
+
+
+def test_lifestyle_inflation_window_capped(client, auth_header):
+ # window > 12 should be capped at 12
+ r = client.get("/insights/lifestyle-inflation?window_months=99", headers=auth_header)
+ assert r.status_code == 200
+ assert r.get_json()["window_months"] == 12
+
+
+def test_lifestyle_inflation_sorted_by_pct(client, auth_header):
+ cat_a = _add_category(client, auth_header, "InflatA")
+ cat_b = _add_category(client, auth_header, "InflatB")
+
+ # cat_a: 100 → 300 (+200%)
+ for d in [120, 150]:
+ _add_expense(client, auth_header, 100, cat_a, days_ago=d)
+ for d in [15, 45]:
+ _add_expense(client, auth_header, 300, cat_a, days_ago=d)
+
+ # cat_b: 100 → 150 (+50%)
+ for d in [120, 150]:
+ _add_expense(client, auth_header, 100, cat_b, days_ago=d)
+ for d in [15, 45]:
+ _add_expense(client, auth_header, 150, cat_b, days_ago=d)
+
+ r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header)
+ assert r.status_code == 200
+ inflated = r.get_json()["inflated_categories"]
+ assert len(inflated) >= 2
+ # First item should have higher pct_change
+ assert inflated[0]["pct_change"] >= inflated[1]["pct_change"]
+
+
+def test_lifestyle_inflation_annualised_extra(client, auth_header):
+ cat_id = _add_category(client, auth_header, "Shopping")
+
+ for d in [120, 150]:
+ _add_expense(client, auth_header, 100, cat_id, days_ago=d)
+ for d in [15, 45]:
+ _add_expense(client, auth_header, 400, cat_id, days_ago=d)
+
+ r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header)
+ assert r.status_code == 200
+ inflated = r.get_json()["inflated_categories"]
+ found = next((c for c in inflated if c["category_name"] == "Shopping"), None)
+ assert found is not None
+ # annualised_extra should be 12x abs_change_monthly
+ assert abs(found["annualised_extra"] - found["abs_change_monthly"] * 12) < 0.1
+
+
+def test_lifestyle_inflation_trend_included(client, auth_header):
+ cat_id = _add_category(client, auth_header, "Gym")
+
+ for d in [120, 150]:
+ _add_expense(client, auth_header, 50, cat_id, days_ago=d)
+ for d in [15, 45]:
+ _add_expense(client, auth_header, 200, cat_id, days_ago=d)
+
+ r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header)
+ assert r.status_code == 200
+ inflated = r.get_json()["inflated_categories"]
+ found = next((c for c in inflated if c["category_name"] == "Gym"), None)
+ assert found is not None
+ assert isinstance(found["trend"], list)
+ assert len(found["trend"]) == 4 # 2 recent + 2 previous months
+ for t in found["trend"]:
+ assert "month" in t
+ assert "amount" in t
+
+
+import pytest