+
+
Weekly Digest
+
+ Trends & insights for your financial week at a glance.
+
+
+
+ {/* Controls */}
+
+
+
+
+ Select Week
+
+
+
+
+
+ setWeek(e.target.value)}
+ className="w-44"
+ />
+
+
+
+ setGeminiKey(e.target.value)}
+ />
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {data && (
+ <>
+ {/* Summary cards */}
+
+
+
+ Total Expenses
+
+ {formatMoney(data.total_expenses)}
+
+
+
+ {wowPositive ? (
+
+ ) : (
+
+ )}
+
+ {Math.abs(data.week_over_week_change_pct)}% vs last week
+
+
+
+
+
+
+ Total Income
+
+ {formatMoney(data.total_income)}
+
+
+
+
+
+
+ Net Flow
+ = 0 ? 'text-green-500' : 'text-destructive'}`}
+ >
+ {formatMoney(data.net_flow)}
+
+
+
+ {data.net_flow >= 0 ? (
+
+ ) : (
+
+ )}
+ {data.net_flow >= 0 ? 'In the green' : 'Over budget'}
+
+
+
+
+
+ Previous Week
+
+ {formatMoney(data.previous_week_expenses)}
+
+
+
+
+
+ {/* Top categories */}
+ {data.top_categories.length > 0 && (
+
+
+
+
+ Top Spending Categories
+
+
+ {data.week_start} – {data.week_end}
+
+
+
+
+ {data.top_categories.map((cat) => (
+ -
+ {cat.category}
+ {formatMoney(cat.amount)}
+
+ ))}
+
+
+
+ )}
+
+ {/* Daily breakdown */}
+ {data.daily_breakdown.length > 0 && (
+
+
+ Day-by-Day Spending
+
+
+
+ {data.daily_breakdown.map((day) => (
+
+
+ {new Date(day.date + 'T12:00:00').toLocaleDateString(undefined, {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+ {formatMoney(day.amount)}
+
+ ))}
+
+
+
+ )}
+
+ {/* AI / Heuristic insights */}
+ {data.insights.length > 0 && (
+
+
+
+
+ Insights
+ {data.method === 'gemini' && (
+
+ AI-powered
+
+ )}
+
+
+
+
+ {data.insights.map((tip, i) => (
+ - {tip}
+ ))}
+
+ {data.warnings?.includes('gemini_unavailable') && (
+
+ ⚠ AI insights unavailable — showing rule-based insights.
+
+ )}
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml
index 3f8ec3f0..7cd71033 100644
--- a/packages/backend/app/openapi.yaml
+++ b/packages/backend/app/openapi.yaml
@@ -480,6 +480,95 @@ paths:
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
+ /insights/weekly-digest:
+ get:
+ summary: Get weekly financial digest
+ description: |
+ Returns a weekly financial summary including total income, total expenses,
+ net flow, week-over-week change, top spending categories, a day-by-day
+ breakdown, and actionable insights (heuristic or AI-powered via Gemini).
+ tags: [Insights]
+ security: [{ bearerAuth: [] }]
+ parameters:
+ - in: query
+ name: week
+ required: false
+ description: Any ISO date (YYYY-MM-DD) within the desired week. Defaults to the current week.
+ schema: { type: string, format: date }
+ - in: header
+ name: X-Gemini-Api-Key
+ required: false
+ description: Optional user-supplied Gemini API key for AI-enhanced insights.
+ schema: { type: string }
+ - in: header
+ name: X-Insight-Persona
+ required: false
+ description: Custom system persona for the AI model.
+ schema: { type: string }
+ responses:
+ '200':
+ description: Weekly digest
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ week_start: { type: string, format: date }
+ week_end: { type: string, format: date }
+ total_expenses: { type: number }
+ total_income: { type: number }
+ net_flow: { type: number }
+ week_over_week_change_pct: { type: number }
+ previous_week_expenses: { type: number }
+ top_categories:
+ type: array
+ items:
+ type: object
+ properties:
+ category: { type: string }
+ amount: { type: number }
+ daily_breakdown:
+ type: array
+ items:
+ type: object
+ properties:
+ date: { type: string, format: date }
+ amount: { type: number }
+ insights:
+ type: array
+ items: { type: string }
+ method: { type: string, enum: [heuristic, gemini] }
+ warnings:
+ type: array
+ items: { type: string }
+ example:
+ week_start: "2025-08-11"
+ week_end: "2025-08-17"
+ total_expenses: 340.50
+ total_income: 500.00
+ net_flow: 159.50
+ week_over_week_change_pct: -12.3
+ previous_week_expenses: 388.00
+ top_categories:
+ - { category: "Groceries", amount: 120.00 }
+ - { category: "Transport", amount: 80.50 }
+ daily_breakdown:
+ - { date: "2025-08-11", amount: 45.00 }
+ - { date: "2025-08-12", amount: 110.50 }
+ insights:
+ - "Great job! You spent 12.3% less than last week. Keep it up."
+ - "Your top spend category this week: Groceries (120.0)."
+ method: heuristic
+ '400':
+ description: Invalid week format
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Error' }
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Error' }
components:
securitySchemes:
diff --git a/packages/backend/app/routes/insights.py b/packages/backend/app/routes/insights.py
index bfc02e43..5e343deb 100644
--- a/packages/backend/app/routes/insights.py
+++ b/packages/backend/app/routes/insights.py
@@ -23,3 +23,38 @@ def budget_suggestion():
)
logger.info("Budget suggestion served user=%s month=%s", uid, ym)
return jsonify(suggestion)
+
+
+from datetime import date
+from ..services.digest import weekly_digest as _weekly_digest
+
+
+@bp.get("/weekly-digest")
+@jwt_required()
+def weekly_digest():
+ """Return a weekly financial digest.
+
+ Query params:
+ week (optional): ISO date (YYYY-MM-DD) of any day in the desired week.
+ Defaults to the current week.
+ """
+ uid = int(get_jwt_identity())
+ week_param = request.args.get("week")
+ week_start = None
+ if week_param:
+ try:
+ week_start = date.fromisoformat(week_param)
+ except ValueError:
+ return jsonify({"error": "Invalid week format. Use YYYY-MM-DD."}), 400
+
+ 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_digest(
+ uid,
+ week_start=week_start,
+ gemini_api_key=user_gemini_key,
+ persona=persona,
+ )
+ logger.info("Weekly digest served user=%s week_start=%s", uid, result.get("week_start"))
+ return jsonify(result)
diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py
new file mode 100644
index 00000000..facf77cb
--- /dev/null
+++ b/packages/backend/app/services/digest.py
@@ -0,0 +1,262 @@
+"""Weekly financial digest service.
+
+Generates a weekly summary of income, expenses, top spending categories,
+day-over-day trends, and actionable insights.
+"""
+from __future__ import annotations
+
+import json
+from datetime import date, timedelta
+from urllib import request
+
+from sqlalchemy import func
+
+from ..config import Settings
+from ..extensions import db
+from ..models import Category, Expense
+
+_settings = Settings()
+
+_DEFAULT_PERSONA = (
+ "You are FinMind's pragmatic financial coach. Be concise, non-judgmental, "
+ "data-driven, and action-oriented."
+)
+
+
+# ---------------------------------------------------------------------------
+# Data helpers
+# ---------------------------------------------------------------------------
+
+
+def _week_bounds(week_start: date) -> tuple[date, date]:
+ """Return (start, end) for the ISO week containing *week_start*.
+
+ The caller may pass any date; we normalise to Monday of that week.
+ """
+ monday = week_start - timedelta(days=week_start.weekday())
+ sunday = monday + timedelta(days=6)
+ return monday, sunday
+
+
+def _expenses_in_range(uid: int, start: date, end: date) -> list[Expense]:
+ return (
+ db.session.query(Expense)
+ .filter(
+ Expense.user_id == uid,
+ Expense.spent_at >= start,
+ Expense.spent_at <= end,
+ Expense.expense_type != "INCOME",
+ )
+ .all()
+ )
+
+
+def _income_in_range(uid: int, start: date, end: date) -> float:
+ val = (
+ 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(val)
+
+
+def _category_name(cat_id: int | None) -> str:
+ if cat_id is None:
+ return "Uncategorised"
+ cat = db.session.get(Category, cat_id)
+ return cat.name if cat else "Unknown"
+
+
+def _daily_breakdown(expenses: list[Expense]) -> list[dict]:
+ by_day: dict[str, float] = {}
+ for e in expenses:
+ key = str(e.spent_at)
+ by_day[key] = by_day.get(key, 0.0) + float(e.amount)
+ return [{"date": d, "amount": round(v, 2)} for d, v in sorted(by_day.items())]
+
+
+def _top_categories(expenses: list[Expense], n: int = 5) -> list[dict]:
+ totals: dict[str | None, float] = {}
+ for e in expenses:
+ totals[e.category_id] = totals.get(e.category_id, 0.0) + float(e.amount)
+ ranked = sorted(totals.items(), key=lambda x: x[1], reverse=True)[:n]
+ return [
+ {"category": _category_name(cid), "amount": round(amt, 2)}
+ for cid, amt in ranked
+ ]
+
+
+# ---------------------------------------------------------------------------
+# Heuristic digest (no AI required)
+# ---------------------------------------------------------------------------
+
+
+def _heuristic_digest(
+ uid: int, start: date, end: date, prev_start: date, prev_end: date
+) -> dict:
+ expenses = _expenses_in_range(uid, start, end)
+ prev_expenses = _expenses_in_range(uid, prev_start, prev_end)
+ total = round(sum(float(e.amount) for e in expenses), 2)
+ prev_total = round(sum(float(e.amount) for e in prev_expenses), 2)
+ income = round(_income_in_range(uid, start, end), 2)
+
+ wow_pct = (
+ round(((total - prev_total) / prev_total) * 100, 1)
+ if prev_total > 0
+ else 0.0
+ )
+
+ # Simple insight rules
+ tips: list[str] = []
+ if total > prev_total * 1.1:
+ tips.append(
+ f"Spending rose {wow_pct}% vs the previous week — look for one-off items to cut."
+ )
+ elif total < prev_total * 0.9:
+ tips.append(
+ f"Great job! You spent {abs(wow_pct)}% less than last week. Keep it up."
+ )
+ else:
+ tips.append("Your spending is stable week-over-week — a great baseline.")
+
+ top = _top_categories(expenses)
+ if top:
+ tips.append(
+ f"Your top spend category this week: {top[0]['category']} "
+ f"({top[0]['amount']})."
+ )
+
+ if income > 0 and total > income:
+ tips.append(
+ "Heads up: you spent more than you earned this week. Consider reviewing discretionary items."
+ )
+
+ return {
+ "week_start": str(start),
+ "week_end": str(end),
+ "total_expenses": total,
+ "total_income": income,
+ "net_flow": round(income - total, 2),
+ "week_over_week_change_pct": wow_pct,
+ "previous_week_expenses": prev_total,
+ "top_categories": top,
+ "daily_breakdown": _daily_breakdown(expenses),
+ "insights": tips,
+ "method": "heuristic",
+ }
+
+
+# ---------------------------------------------------------------------------
+# Gemini-enhanced digest
+# ---------------------------------------------------------------------------
+
+
+def _gemini_digest(
+ uid: int,
+ start: date,
+ end: date,
+ prev_start: date,
+ prev_end: date,
+ api_key: str,
+ model: str,
+ persona: str,
+) -> dict:
+ base = _heuristic_digest(uid, start, end, prev_start, prev_end)
+
+ prompt = (
+ f"{persona}\n"
+ "Given this weekly financial data, return a JSON object with a single key "
+ '"insights" whose value is a list of 3-5 concise, actionable strings. '
+ "Do not return anything else.\n"
+ f"week_start={start}\n"
+ f"total_expenses={base['total_expenses']}\n"
+ f"total_income={base['total_income']}\n"
+ f"week_over_week_change_pct={base['week_over_week_change_pct']}\n"
+ f"top_categories={base['top_categories']}\n"
+ )
+
+ 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()
+ req = request.Request(url=url, data=body, headers={"Content-Type": "application/json"}, method="POST")
+
+ with request.urlopen(req, timeout=10) as resp: # nosec B310
+ raw = json.loads(resp.read().decode())
+
+ text = (
+ raw.get("candidates", [{}])[0]
+ .get("content", {})
+ .get("parts", [{}])[0]
+ .get("text", "")
+ .strip()
+ )
+ # Strip markdown fences if present
+ if text.startswith("```"):
+ text = text.strip("`")
+ if text.lower().startswith("json"):
+ text = text[4:].strip()
+
+ start_idx = text.find("{")
+ end_idx = text.rfind("}")
+ if start_idx != -1 and end_idx > start_idx:
+ parsed = json.loads(text[start_idx : end_idx + 1])
+ if isinstance(parsed.get("insights"), list):
+ base["insights"] = parsed["insights"]
+
+ base["method"] = "gemini"
+ return base
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+def weekly_digest(
+ uid: int,
+ week_start: date | None = None,
+ gemini_api_key: str | None = None,
+ gemini_model: str | None = None,
+ persona: str | None = None,
+) -> dict:
+ """Return a weekly financial digest for user *uid*.
+
+ Args:
+ uid: User ID.
+ week_start: Any date within the desired week (defaults to current week).
+ gemini_api_key: Optional Gemini API key for AI-enhanced insights.
+ gemini_model: Override Gemini model name.
+ persona: System persona for the AI.
+
+ Returns:
+ Dictionary with weekly summary data.
+ """
+ ref = week_start or date.today()
+ start, end = _week_bounds(ref)
+ prev_start, prev_end = _week_bounds(start - timedelta(days=7))
+
+ key = (gemini_api_key or "").strip() or (_settings.gemini_api_key or "")
+ model = gemini_model or _settings.gemini_model
+ persona_text = (persona or _DEFAULT_PERSONA).strip()
+
+ if key:
+ try:
+ return _gemini_digest(uid, start, end, prev_start, prev_end, key, model, persona_text)
+ except Exception:
+ result = _heuristic_digest(uid, start, end, prev_start, prev_end)
+ result["warnings"] = ["gemini_unavailable"]
+ return result
+
+ return _heuristic_digest(uid, start, end, prev_start, prev_end)
diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py
new file mode 100644
index 00000000..cd826cb9
--- /dev/null
+++ b/packages/backend/tests/test_digest.py
@@ -0,0 +1,151 @@
+"""Tests for the weekly financial digest endpoint."""
+from datetime import date, timedelta
+
+
+def _monday_of(d: date) -> date:
+ return d - timedelta(days=d.weekday())
+
+
+def test_weekly_digest_returns_required_fields(client, auth_header):
+ """GET /insights/weekly-digest returns all required response keys."""
+ today = date.today()
+ monday = _monday_of(today)
+
+ # Add an expense in the current week
+ r = client.post(
+ "/expenses",
+ json={
+ "amount": 120,
+ "description": "Groceries",
+ "date": monday.isoformat(),
+ "expense_type": "EXPENSE",
+ },
+ headers=auth_header,
+ )
+ assert r.status_code == 201
+
+ r = client.get("/insights/weekly-digest", headers=auth_header)
+ assert r.status_code == 200
+ payload = r.get_json()
+
+ required_keys = {
+ "week_start",
+ "week_end",
+ "total_expenses",
+ "total_income",
+ "net_flow",
+ "week_over_week_change_pct",
+ "previous_week_expenses",
+ "top_categories",
+ "daily_breakdown",
+ "insights",
+ "method",
+ }
+ assert required_keys.issubset(payload.keys()), (
+ f"Missing keys: {required_keys - payload.keys()}"
+ )
+ assert payload["method"] == "heuristic"
+ assert payload["total_expenses"] >= 120
+
+
+def test_weekly_digest_week_param(client, auth_header):
+ """Passing ?week= selects a specific week."""
+ target_monday = _monday_of(date.today()) - timedelta(weeks=2)
+
+ r = client.post(
+ "/expenses",
+ json={
+ "amount": 50,
+ "description": "Old week spend",
+ "date": target_monday.isoformat(),
+ "expense_type": "EXPENSE",
+ },
+ headers=auth_header,
+ )
+ assert r.status_code == 201
+
+ r = client.get(
+ f"/insights/weekly-digest?week={target_monday.isoformat()}",
+ headers=auth_header,
+ )
+ assert r.status_code == 200
+ payload = r.get_json()
+ assert payload["week_start"] == str(target_monday)
+ assert payload["total_expenses"] >= 50
+
+
+def test_weekly_digest_invalid_week_param(client, auth_header):
+ """Invalid ?week= returns 400."""
+ r = client.get("/insights/weekly-digest?week=not-a-date", headers=auth_header)
+ assert r.status_code == 400
+
+
+def test_weekly_digest_net_flow(client, auth_header):
+ """net_flow is income - expenses."""
+ today = date.today()
+ monday = _monday_of(today)
+
+ client.post(
+ "/expenses",
+ json={"amount": 200, "description": "Salary", "date": monday.isoformat(), "expense_type": "INCOME"},
+ headers=auth_header,
+ )
+ client.post(
+ "/expenses",
+ json={"amount": 80, "description": "Lunch", "date": monday.isoformat(), "expense_type": "EXPENSE"},
+ headers=auth_header,
+ )
+
+ r = client.get("/insights/weekly-digest", headers=auth_header)
+ assert r.status_code == 200
+ payload = r.get_json()
+ assert abs(payload["net_flow"] - (payload["total_income"] - payload["total_expenses"])) < 0.01
+
+
+def test_weekly_digest_uses_gemini_key(client, auth_header, monkeypatch):
+ """When X-Gemini-Api-Key is supplied, _gemini_digest is called."""
+ captured = {}
+
+ def _fake(uid, start, end, prev_start, prev_end, api_key, model, persona):
+ captured["api_key"] = api_key
+ return {
+ "week_start": str(start),
+ "week_end": str(end),
+ "total_expenses": 0.0,
+ "total_income": 0.0,
+ "net_flow": 0.0,
+ "week_over_week_change_pct": 0.0,
+ "previous_week_expenses": 0.0,
+ "top_categories": [],
+ "daily_breakdown": [],
+ "insights": ["AI tip"],
+ "method": "gemini",
+ }
+
+ monkeypatch.setattr("app.services.digest._gemini_digest", _fake)
+
+ r = client.get(
+ "/insights/weekly-digest",
+ headers={**auth_header, "X-Gemini-Api-Key": "test-key"},
+ )
+ assert r.status_code == 200
+ assert r.get_json()["method"] == "gemini"
+ assert captured["api_key"] == "test-key"
+
+
+def test_weekly_digest_falls_back_on_gemini_error(client, auth_header, monkeypatch):
+ """When Gemini fails, falls back to heuristic with warning."""
+ def _boom(*_args, **_kwargs):
+ raise RuntimeError("network error")
+
+ monkeypatch.setattr("app.services.digest._gemini_digest", _boom)
+
+ r = client.get(
+ "/insights/weekly-digest",
+ headers={**auth_header, "X-Gemini-Api-Key": "bad-key"},
+ )
+ assert r.status_code == 200
+ payload = r.get_json()
+ assert payload["method"] == "heuristic"
+ assert "warnings" in payload
+ assert "gemini_unavailable" in payload["warnings"]