From 6fdce870e220436b01cbbe0ec70bc4d8ee9ff951 Mon Sep 17 00:00:00 2001 From: sungdark Date: Sun, 29 Mar 2026 11:19:25 +0000 Subject: [PATCH] feat(insights): add weekly financial summary endpoint Implements weekly financial digest with: - GET /insights/weekly-summary endpoint with JWT auth - Weekly income/expense/net flow calculation - Category breakdown and unusual spend detection - Gemini AI insights with heuristic fallback - Frontend analytics integration - Comprehensive test suite Addresses bounty issue #121 --- README.md | 2 +- app/src/api/insights.ts | 39 +++ app/src/pages/Analytics.tsx | 170 +++++++++++- packages/backend/app/routes/insights.py | 20 +- packages/backend/app/services/ai.py | 244 ++++++++++++++++++ packages/backend/tests/test_weekly_summary.py | 223 ++++++++++++++++ 6 files changed, 695 insertions(+), 3 deletions(-) create mode 100644 packages/backend/tests/test_weekly_summary.py diff --git a/README.md b/README.md index 49592bff..65d68434 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ OpenAPI: `backend/app/openapi.yaml` - Expenses: CRUD `/expenses` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` -- Insights: `/insights/monthly`, `/insights/budget-suggestion` +- Insights: `/insights/monthly`, `/insights/budget-suggestion`, `/insights/weekly-summary` ## MVP UI/UX Plan - Auth screens: register/login. diff --git a/app/src/api/insights.ts b/app/src/api/insights.ts index 031d1e53..e763eaea 100644 --- a/app/src/api/insights.ts +++ b/app/src/api/insights.ts @@ -32,3 +32,42 @@ export async function getBudgetSuggestion(params?: { if (params?.persona) headers['X-Insight-Persona'] = params.persona; return api(`/insights/budget-suggestion${monthQuery}`, { headers }); } + +export type WeeklySummary = { + week: string; + week_start: string; + week_end: string; + total_income: number; + total_expenses: number; + net_flow: number; + insights: string[]; + tips: string[]; + analytics: { + week_start: string; + week_end: string; + current_week_expenses: number; + previous_week_expenses: number; + week_over_week_change_pct: number; + top_categories: Array<{ category_id: string; amount: number }>; + unusual_spend: Array<{ + category_id: string; + current_amount: number; + previous_amount: number; + increase_pct: number; + }>; + }; + method: 'gemini' | 'heuristic' | string; + warnings?: string[]; +}; + +export async function getWeeklySummary(params?: { + week?: string; + geminiApiKey?: string; + persona?: string; +}): Promise { + const weekQuery = params?.week ? `?week=${encodeURIComponent(params.week)}` : ''; + const headers: Record = {}; + if (params?.geminiApiKey) headers['X-Gemini-Api-Key'] = params.geminiApiKey; + if (params?.persona) headers['X-Insight-Persona'] = params.persona; + return api(`/insights/weekly-summary${weekQuery}`, { headers }); +} diff --git a/app/src/pages/Analytics.tsx b/app/src/pages/Analytics.tsx index 3efc8acc..3c2f99dd 100644 --- a/app/src/pages/Analytics.tsx +++ b/app/src/pages/Analytics.tsx @@ -10,7 +10,7 @@ import { FinancialCardTitle, } from '@/components/ui/financial-card'; import { useToast } from '@/hooks/use-toast'; -import { getBudgetSuggestion, type BudgetSuggestion } from '@/api/insights'; +import { getBudgetSuggestion, getWeeklySummary, type BudgetSuggestion, type WeeklySummary } from '@/api/insights'; import { formatMoney } from '@/lib/currency'; const PERSONAS = [ @@ -27,6 +27,9 @@ export function Analytics() { const [loading, setLoading] = useState(true); const [data, setData] = useState(null); const [error, setError] = useState(null); + const [weeklyData, setWeeklyData] = useState(null); + const [weeklyLoading, setWeeklyLoading] = useState(true); + const [weeklyError, setWeeklyError] = useState(null); async function load() { setLoading(true); @@ -47,8 +50,25 @@ export function Analytics() { } } + async function loadWeekly() { + setWeeklyLoading(true); + setWeeklyError(null); + try { + const payload = await getWeeklySummary({ + geminiApiKey: geminiKey.trim() || undefined, + }); + setWeeklyData(payload); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load weekly digest'; + setWeeklyError(message); + } finally { + setWeeklyLoading(false); + } + } + useEffect(() => { void load(); + void loadWeekly(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -193,6 +213,154 @@ export function Analytics() { ) : null} + + {/* Weekly Digest Section */} +
+
+

Weekly Financial Digest

+

+ This week's spending snapshot with trend analysis and unusual spend detection. +

+
+ + {weeklyLoading ? ( +
Loading weekly digest...
+ ) : weeklyError ? ( +
{weeklyError}
+ ) : weeklyData ? ( +
+
+ + + Week + + {weeklyData.week} + + + + Income + + {formatMoney(weeklyData.total_income)} + + + + Expenses + + {formatMoney(weeklyData.total_expenses)} + + + + Net Flow + + + + {formatMoney(weeklyData.net_flow)} + + + +
+ +
+ + + Week-over-Week Trend + + +
+
+ Expenses vs last week + 0 ? 'text-red-600' : 'text-green-600'}`}> + {weeklyData.analytics.week_over_week_change_pct > 0 ? '+' : ''}{weeklyData.analytics.week_over_week_change_pct}% + +
+
+ {weeklyData.week_start} → {weeklyData.week_end} +
+
+
+
+ + + + Top Categories This Week + + + {weeklyData.analytics.top_categories.length > 0 ? ( +
    + {weeklyData.analytics.top_categories.slice(0, 3).map((cat) => ( +
  • + {cat.category_id} + {formatMoney(cat.amount)} +
  • + ))} +
+ ) : ( +
No expenses this week.
+ )} +
+
+
+ + {weeklyData.analytics.unusual_spend.length > 0 && ( + + + ⚠️ Unusual Spending Detected + + +
    + {weeklyData.analytics.unusual_spend.map((u) => ( +
  • + {u.category_id}:{' '} + {formatMoney(u.current_amount)} this week vs {formatMoney(u.previous_amount)} last week + {' '}(+{u.increase_pct}%) +
  • + ))} +
+
+
+ )} + + + + Weekly Insights + + + {weeklyData.insights.length > 0 ? ( +
    + {weeklyData.insights.map((insight, i) => ( +
  • {insight}
  • + ))} +
+ ) : ( +
No insights generated.
+ )} +
+
+ + + + Actionable Tips + + + {weeklyData.tips.length > 0 ? ( +
    + {weeklyData.tips.map((tip, i) => ( +
  • {tip}
  • + ))} +
+ ) : ( +
No tips for this week.
+ )} + {weeklyData.warnings?.length ? ( +
+ Warning: {weeklyData.warnings.join(', ')} +
+ ) : null} +
+
+
+ ) : null} +
); } diff --git a/packages/backend/app/routes/insights.py b/packages/backend/app/routes/insights.py index bfc02e43..2f39d412 100644 --- a/packages/backend/app/routes/insights.py +++ b/packages/backend/app/routes/insights.py @@ -1,7 +1,7 @@ from datetime import date 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.ai import monthly_budget_suggestion, weekly_summary_service import logging bp = Blueprint("insights", __name__) @@ -23,3 +23,21 @@ def budget_suggestion(): ) logger.info("Budget suggestion served user=%s month=%s", uid, ym) return jsonify(suggestion) + + +@bp.get("/weekly-summary") +@jwt_required() +def weekly_summary(): + """Return a smart weekly financial summary.""" + uid = int(get_jwt_identity()) + week_str = (request.args.get("week") or "").strip() or None + user_gemini_key = (request.headers.get("X-Gemini-Api-Key") or "").strip() or None + persona = (request.headers.get("X-Insight-Persona") or "").strip() or None + result = weekly_summary_service( + uid, + week_str=week_str, + gemini_api_key=user_gemini_key, + persona=persona, + ) + logger.info("Weekly summary served user=%s week=%s", uid, week_str) + return jsonify(result) diff --git a/packages/backend/app/services/ai.py b/packages/backend/app/services/ai.py index 951fbd00..efb802c8 100644 --- a/packages/backend/app/services/ai.py +++ b/packages/backend/app/services/ai.py @@ -185,3 +185,247 @@ def monthly_budget_suggestion( uid, ym, persona_text, warnings=["gemini_unavailable"] ) return _heuristic_budget(uid, ym, persona_text) + + +# --- Weekly Summary --- +from datetime import date, timedelta + + +def _week_identifier(week_str: str | None) -> tuple[str, date, date]: + """Return (week_id, start, end) for a week. week_str is YYYY-Www or None for current.""" + today = date.today() + if week_str and "-" in week_str: + parts = week_str.split("-") + year = int(parts[0]) + week_num = int(parts[1].lstrip("Ww")) + jan4 = date(year, 1, 4) + start = jan4 + timedelta(weeks=week_num - 1, days=-jan4.weekday()) + end = start + timedelta(days=6) + return week_str, start, end + # Current week: Monday to Sunday + start = today - timedelta(days=today.weekday()) + end = start + timedelta(days=6) + year, week_num, _ = today.isocalendar() + return f"{year}-W{week_num:02d}", start, end + + +def _weekly_totals(uid: int, start: date, end: date) -> tuple[float, float]: + income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + expenses = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + return float(income or 0), float(expenses or 0) + + +def _weekly_category_spend(uid: int, start: date, end: date) -> dict[str, float]: + rows = ( + db.session.query( + Expense.category_id, func.coalesce(func.sum(Expense.amount), 0) + ) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.category_id) + .all() + ) + return {str(k or "uncat"): float(v) for k, v in rows} + + +def _prior_week_spend(uid: int, start: date, end: date) -> dict[str, float]: + """Get expenses by category for the prior week.""" + week_len = (end - start).days + 1 + prev_start = start - timedelta(days=week_len) + prev_end = prev_start + timedelta(days=week_len - 1) + return _weekly_category_spend(uid, prev_start, prev_end) + + +def _build_weekly_analytics(uid: int, start: date, end: date) -> dict: + _, current_expenses = _weekly_totals(uid, start, end) + prev_cats = _prior_week_spend(uid, start, end) + _, prev_expenses = 0, sum(prev_cats.values()) + + wow = ( + round(((current_expenses - prev_expenses) / prev_expenses) * 100, 2) + if prev_expenses > 0 + else 0.0 + ) + + cats = _weekly_category_spend(uid, start, end) + top = sorted(cats.items(), key=lambda x: x[1], reverse=True)[:5] + + # Detect unusual spend: any category up 50%+ vs prior week + unusual = [] + for cat_id, amount in cats.items(): + prev_amount = prev_cats.get(cat_id, 0) + if prev_amount > 0 and amount > prev_amount * 1.5: + unusual.append({ + "category_id": cat_id, + "current_amount": round(amount, 2), + "previous_amount": round(prev_amount, 2), + "increase_pct": round(((amount - prev_amount) / prev_amount) * 100, 2), + }) + unusual.sort(key=lambda x: x["increase_pct"], reverse=True) + + return { + "week_start": start.isoformat(), + "week_end": end.isoformat(), + "current_week_expenses": round(current_expenses, 2), + "previous_week_expenses": round(prev_expenses, 2), + "week_over_week_change_pct": wow, + "top_categories": [{"category_id": k, "amount": round(v, 2)} for k, v in top], + "unusual_spend": unusual, + } + + +def _build_heuristic_response( + uid: int, week_id: str, start: date, end: date, + persona: str, warnings: list[str] | None = None, +) -> dict: + income, expenses = _weekly_totals(uid, start, end) + net = round(income - expenses, 2) + analytics = _build_weekly_analytics(uid, start, end) + + insights = [] + tips = [] + + if net < 0: + insights.append(f"You spent ₹{abs(net)} more than you earned this week.") + tips.append("Review non-essential purchases to get back on track.") + elif net > 0: + insights.append(f"You saved ₹{net} this week — great discipline!") + tips.append("Consider moving your savings to a dedicated account.") + + wow = analytics["week_over_week_change_pct"] + if wow > 10: + insights.append(f"Spending is up {wow}% vs last week.") + tips.append("Identify the top category driving the increase and set a cap.") + elif wow < -10: + insights.append(f"Spending is down {abs(wow)}% vs last week. Keep it up!") + tips.append("You are building good financial habits.") + + if income > 0: + savings_rate = round((net / income) * 100, 1) + insights.append(f"Savings rate: {savings_rate}% of income.") + + unusual = analytics["unusual_spend"] + if unusual: + top_unusual = unusual[0] + insights.append( + f"Unusual spend detected in '{top_unusual['category_id']}': " + f"₹{top_unusual['current_amount']} vs ₹{top_unusual['previous_amount']} last week " + f"(+{top_unusual['increase_pct']}%)." + ) + + result = { + "week": week_id, + "week_start": start.isoformat(), + "week_end": end.isoformat(), + "total_income": round(income, 2), + "total_expenses": round(expenses, 2), + "net_flow": net, + "insights": insights, + "tips": tips[:3], + "analytics": analytics, + "method": "heuristic", + } + if warnings: + result["warnings"] = warnings + return result + + +def _gemini_weekly_summary( + uid: int, week_id: str, start: date, end: date, + api_key: str, model: str, persona: str, +) -> dict: + categories = _weekly_category_spend(uid, start, end) + analytics = _build_weekly_analytics(uid, start, end) + income, expenses = _weekly_totals(uid, start, end) + net = round(income - expenses, 2) + prompt = ( + f"{persona}\n" + "Use this week data and return strict JSON with keys: " + "insights (list of strings), tips (list of up to 3 strings).\n" + f"week={week_id} ({start.isoformat()} to {end.isoformat()})\n" + f"total_income={round(income,2)} total_expenses={round(expenses,2)} net_flow={net}\n" + f"category_spend={categories}\n" + f"week_over_week_change_pct={analytics['week_over_week_change_pct']}\n" + f"unusual_spend={analytics['unusual_spend']}" + ) + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{model}:generateContent?key={api_key}" + ) + body = json.dumps( + { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.3}, + } + ).encode("utf-8") + req = request.Request( + url=url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with request.urlopen(req, timeout=10) as resp: # nosec B310 + payload = json.loads(resp.read().decode("utf-8")) + text = ( + payload.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + parsed = _extract_json_object(text) + result = { + "week": week_id, + "week_start": start.isoformat(), + "week_end": end.isoformat(), + "total_income": round(income, 2), + "total_expenses": round(expenses, 2), + "net_flow": net, + "insights": parsed.get("insights", []), + "tips": parsed.get("tips", [])[:3], + "analytics": analytics, + "method": "gemini", + } + return result + + +def weekly_summary_service( + uid: int, + week_str: str | None = None, + gemini_api_key: str | None = None, + persona: str | None = None, +): + key = (gemini_api_key or "").strip() or (_settings.gemini_api_key or "") + model = _settings.gemini_model + persona_text = (persona or DEFAULT_PERSONA).strip() + week_id, start, end = _week_identifier(week_str) + + if key: + try: + return _gemini_weekly_summary(uid, week_id, start, end, key, model, persona_text) + except Exception: + return _build_heuristic_response( + uid, week_id, start, end, persona_text, warnings=["gemini_unavailable"] + ) + return _build_heuristic_response(uid, week_id, start, end, persona_text) diff --git a/packages/backend/tests/test_weekly_summary.py b/packages/backend/tests/test_weekly_summary.py new file mode 100644 index 00000000..ec33ada9 --- /dev/null +++ b/packages/backend/tests/test_weekly_summary.py @@ -0,0 +1,223 @@ +from datetime import date, timedelta + + +def test_weekly_summary_returns_required_fields(client, auth_header): + today = date.today() + year, week_num, _ = today.isocalendar() + current_week = f"{year}-W{week_num:02d}" + + # Add current week expense + r = client.post( + "/expenses", + json={ + "amount": 100, + "description": "Groceries", + "date": today.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + # Add previous week expense + prev_week_date = today - timedelta(days=7) + r = client.post( + "/expenses", + json={ + "amount": 80, + "description": "Dining out", + "date": prev_week_date.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + # Get weekly summary + r = client.get(f"/insights/weekly-summary?week={current_week}", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + # Verify required fields + assert payload["week"] == current_week + assert "week_start" in payload + assert "week_end" in payload + assert "total_income" in payload + assert "total_expenses" in payload + assert payload["total_expenses"] == 100.0 + assert "net_flow" in payload + assert "analytics" in payload + assert "insights" in payload + assert isinstance(payload["insights"], list) + assert "tips" in payload + assert isinstance(payload["tips"], list) + assert "method" in payload + assert payload["method"] == "heuristic" + + # Verify analytics fields + analytics = payload["analytics"] + assert "week_over_week_change_pct" in analytics + assert analytics["current_week_expenses"] == 100.0 + assert analytics["previous_week_expenses"] == 80.0 + assert "top_categories" in analytics + assert "unusual_spend" in analytics + assert "week_start" in analytics + assert "week_end" in analytics + + +def test_weekly_summary_defaults_to_current_week(client, auth_header): + today = date.today() + year, week_num, _ = today.isocalendar() + expected_week = f"{year}-W{week_num:02d}" + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert payload["week"] == expected_week + + +def test_weekly_summary_unusual_spend_detection(client, auth_header): + today = date.today() + year, week_num, _ = today.isocalendar() + current_week = f"{year}-W{week_num:02d}" + + # Add 2x more spend than previous week in same category + r = client.post( + "/expenses", + json={ + "amount": 200, + "description": "Electronics", + "date": today.isoformat(), + "expense_type": "EXPENSE", + "category_id": 1, + }, + headers=auth_header, + ) + assert r.status_code == 201 + + prev_week_date = today - timedelta(days=7) + r = client.post( + "/expenses", + json={ + "amount": 100, + "description": "Previous electronics", + "date": prev_week_date.isoformat(), + "expense_type": "EXPENSE", + "category_id": 1, + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get(f"/insights/weekly-summary?week={current_week}", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + # Check unusual spend is detected + unusual = payload["analytics"]["unusual_spend"] + assert len(unusual) >= 1 + assert unusual[0]["category_id"] == "1" + assert unusual[0]["current_amount"] == 200.0 + assert unusual[0]["previous_amount"] == 100.0 + assert unusual[0]["increase_pct"] == 100.0 + + +def test_weekly_summary_prefers_user_gemini_key(client, auth_header, monkeypatch): + captured = {} + + def _fake_gemini(uid, week, api_key, model, persona): + captured["uid"] = uid + captured["week"] = week + captured["api_key"] = api_key + captured["model"] = model + captured["persona"] = persona + return { + "week": "2024-W10", + "week_start": "2024-03-04", + "week_end": "2024-03-10", + "total_income": 1000.0, + "total_expenses": 700.0, + "net_flow": 300.0, + "analytics": {}, + "insights": ["Spending is on track"], + "tips": ["Save more next week"], + "method": "gemini", + } + + monkeypatch.setattr("app.services.ai._gemini_weekly_summary", _fake_gemini) + + r = client.get( + "/insights/weekly-summary", + headers={ + **auth_header, + "X-Gemini-Api-Key": "user-supplied-key", + }, + ) + assert r.status_code == 200 + payload = r.get_json() + assert payload["method"] == "gemini" + assert captured["api_key"] == "user-supplied-key" + + +def test_weekly_summary_falls_back_when_gemini_fails(client, auth_header, monkeypatch): + def _boom(*_args, **_kwargs): + raise RuntimeError("gemini down") + + monkeypatch.setattr("app.services.ai._gemini_weekly_summary", _boom) + + r = client.get( + "/insights/weekly-summary", + headers={ + **auth_header, + "X-Gemini-Api-Key": "user-supplied-key", + }, + ) + assert r.status_code == 200 + payload = r.get_json() + assert payload["method"] == "heuristic" + assert "warnings" in payload + assert "gemini_unavailable" in payload["warnings"] + + +def test_weekly_summary_with_income(client, auth_header): + today = date.today() + year, week_num, _ = today.isocalendar() + current_week = f"{year}-W{week_num:02d}" + + # Add income + r = client.post( + "/expenses", + json={ + "amount": 1000, + "description": "Salary", + "date": today.isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + # Add expense + r = client.post( + "/expenses", + json={ + "amount": 600, + "description": "Rent", + "date": today.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get(f"/insights/weekly-summary?week={current_week}", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + assert payload["total_income"] == 1000.0 + assert payload["total_expenses"] == 600.0 + assert payload["net_flow"] == 400.0 + + # Should include savings rate insight + savings_insight = [i for i in payload["insights"] if "savings rate" in i.lower()] + assert len(savings_insight) > 0