From 0b580197b420f058a92ac4469d433abfbe9bd681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=87=95=E8=B5=84=E4=BC=9F?= <> Date: Mon, 8 Jun 2026 02:27:26 +0800 Subject: [PATCH] feat: add weekly financial digest --- README.md | 6 +- app/src/api/insights.ts | 56 +++++ packages/backend/app/openapi.yaml | 60 +++++ packages/backend/app/routes/insights.py | 297 +++++++++++++++++++++++- packages/backend/tests/conftest.py | 55 ++++- packages/backend/tests/test_insights.py | 97 ++++++++ 6 files changed, 559 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 49592bffc..915599575 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,11 @@ 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` + +### Weekly Financial Digest + +`GET /insights/weekly-summary?week_start=YYYY-MM-DD¤cy=INR` returns an authenticated, deterministic weekly digest. The endpoint normalizes `week_start` to the ISO week Monday and includes income, expenses, net flow, daily buckets, category shares and week-over-week deltas, top expenses, upcoming bills due that week, and plain-English trend insights. If `currency` is omitted, the user's preferred currency is used. ## MVP UI/UX Plan - Auth screens: register/login. diff --git a/app/src/api/insights.ts b/app/src/api/insights.ts index 031d1e531..ed8a0e7dd 100644 --- a/app/src/api/insights.ts +++ b/app/src/api/insights.ts @@ -21,6 +21,51 @@ export type BudgetSuggestion = { net_flow?: number; }; +export type WeeklySummary = { + week_start: string; + week_end: string; + currency: string; + totals: { + income: number; + expenses: number; + net_flow: number; + transaction_count: number; + average_daily_expense: number; + }; + previous_week: { + expenses: number; + change_amount: number; + change_pct: number; + }; + categories: Array<{ + category_id: number | null; + category: string; + amount: number; + share_pct: number; + previous_amount: number; + change_amount: number; + change_pct: number; + trend: 'UP' | 'DOWN' | 'FLAT'; + }>; + daily: Array<{ date: string; income: number; expenses: number; net_flow: number }>; + top_expenses: Array<{ + id: number; + amount: number; + category: string; + description: string | null; + spent_at: string; + }>; + upcoming_bills: Array<{ + id: number; + name: string; + amount: number; + currency: string; + due_date: string; + autopay_enabled: boolean; + }>; + insights: string[]; +}; + export async function getBudgetSuggestion(params?: { month?: string; geminiApiKey?: string; @@ -32,3 +77,14 @@ export async function getBudgetSuggestion(params?: { if (params?.persona) headers['X-Insight-Persona'] = params.persona; return api(`/insights/budget-suggestion${monthQuery}`, { headers }); } + +export async function getWeeklySummary(params?: { + weekStart?: string; + currency?: string; +}): Promise { + const query = new URLSearchParams(); + if (params?.weekStart) query.set('week_start', params.weekStart); + if (params?.currency) query.set('currency', params.currency); + const suffix = query.toString() ? `?${query.toString()}` : ''; + return api(`/insights/weekly-summary${suffix}`); +} diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0f..b09c5aa56 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -481,6 +481,66 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /insights/weekly-summary: + get: + summary: Get weekly financial digest + tags: [Insights] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: week_start + required: false + schema: { type: string, format: date } + description: Any date in the requested ISO week. The API normalizes it to Monday. + - in: query + name: currency + required: false + schema: { type: string } + description: Currency filter. Defaults to the user's preferred currency. + responses: + '200': + description: Weekly digest with totals, trends, categories, daily buckets, top expenses, and due bills. + content: + application/json: + schema: + type: object + additionalProperties: true + example: + week_start: 2026-03-02 + week_end: 2026-03-08 + currency: INR + totals: + income: 500 + expenses: 200 + net_flow: 300 + transaction_count: 3 + average_daily_expense: 28.57 + previous_week: + expenses: 100 + change_amount: 100 + change_pct: 100 + categories: + - category_id: 1 + category: Groceries + amount: 120 + share_pct: 60 + previous_amount: 100 + change_amount: 20 + change_pct: 20 + trend: UP + insights: + - Weekly spending is 100.0% higher than last week. + '400': + description: Invalid week_start + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + components: securitySchemes: bearerAuth: diff --git a/packages/backend/app/routes/insights.py b/packages/backend/app/routes/insights.py index bfc02e43c..7b8ae9569 100644 --- a/packages/backend/app/routes/insights.py +++ b/packages/backend/app/routes/insights.py @@ -1,6 +1,9 @@ -from datetime import date +from datetime import date, timedelta +from decimal import Decimal from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import Bill, Category, Expense, User from ..services.ai import monthly_budget_suggestion import logging @@ -23,3 +26,295 @@ 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(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + currency = ( + (request.args.get("currency") or (user.preferred_currency if user else "INR")) + .strip() + .upper() + ) + week_start = _parse_week_start(request.args.get("week_start")) + if week_start is None: + return jsonify(error="week_start must be YYYY-MM-DD"), 400 + + week_end = week_start + timedelta(days=6) + previous_start = week_start - timedelta(days=7) + previous_end = week_start - timedelta(days=1) + + current_expenses = _expenses_for_window(uid, week_start, week_end, currency) + previous_expenses = _expenses_for_window( + uid, previous_start, previous_end, currency + ) + categories = { + category.id: category.name + for category in db.session.query(Category).filter_by(user_id=uid).all() + } + upcoming_bills = _upcoming_bills(uid, week_start, week_end, currency) + + payload = _build_weekly_summary( + current_expenses=current_expenses, + previous_expenses=previous_expenses, + categories=categories, + upcoming_bills=upcoming_bills, + week_start=week_start, + week_end=week_end, + currency=currency, + ) + logger.info("Weekly summary served user=%s week_start=%s", uid, week_start) + return jsonify(payload) + + +def _parse_week_start(raw: str | None) -> date | None: + if not raw: + today = date.today() + return today - timedelta(days=today.weekday()) + try: + requested = date.fromisoformat(raw.strip()) + except ValueError: + return None + return requested - timedelta(days=requested.weekday()) + + +def _expenses_for_window( + uid: int, start: date, end: date, currency: str +) -> list[Expense]: + return ( + db.session.query(Expense) + .filter( + Expense.user_id == uid, + Expense.currency == currency, + Expense.spent_at >= start, + Expense.spent_at <= end, + ) + .order_by(Expense.spent_at.asc(), Expense.id.asc()) + .all() + ) + + +def _upcoming_bills(uid: int, start: date, end: date, currency: str) -> list[Bill]: + return ( + db.session.query(Bill) + .filter( + Bill.user_id == uid, + Bill.currency == currency, + Bill.active.is_(True), + Bill.next_due_date >= start, + Bill.next_due_date <= end, + ) + .order_by(Bill.next_due_date.asc(), Bill.id.asc()) + .all() + ) + + +def _build_weekly_summary( + *, + current_expenses: list[Expense], + previous_expenses: list[Expense], + categories: dict[int, str], + upcoming_bills: list[Bill], + week_start: date, + week_end: date, + currency: str, +) -> dict: + expense_total = sum( + _decimal(e.amount) for e in current_expenses if e.expense_type == "EXPENSE" + ) + income_total = sum( + _decimal(e.amount) for e in current_expenses if e.expense_type == "INCOME" + ) + previous_expense_total = sum( + _decimal(e.amount) for e in previous_expenses if e.expense_type == "EXPENSE" + ) + category_totals = _category_totals(current_expenses) + previous_category_totals = _category_totals(previous_expenses) + daily = _daily_totals(current_expenses, week_start) + category_rows = _category_rows( + category_totals, previous_category_totals, categories, expense_total + ) + bill_rows = [ + { + "id": bill.id, + "name": bill.name, + "amount": _round_decimal(bill.amount), + "currency": bill.currency, + "due_date": bill.next_due_date.isoformat(), + "autopay_enabled": bill.autopay_enabled, + } + for bill in upcoming_bills + ] + top_expenses = [ + { + "id": expense.id, + "amount": _round_decimal(expense.amount), + "category": categories.get(expense.category_id, "Uncategorized"), + "description": expense.notes, + "spent_at": expense.spent_at.isoformat(), + } + for expense in sorted( + [e for e in current_expenses if e.expense_type == "EXPENSE"], + key=lambda item: _decimal(item.amount), + reverse=True, + )[:5] + ] + change_amount = expense_total - previous_expense_total + + return { + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + "currency": currency, + "totals": { + "income": _round_decimal(income_total), + "expenses": _round_decimal(expense_total), + "net_flow": _round_decimal(income_total - expense_total), + "transaction_count": len(current_expenses), + "average_daily_expense": _round_decimal(expense_total / Decimal("7")), + }, + "previous_week": { + "expenses": _round_decimal(previous_expense_total), + "change_amount": _round_decimal(change_amount), + "change_pct": _percent_change(expense_total, previous_expense_total), + }, + "categories": category_rows, + "daily": daily, + "top_expenses": top_expenses, + "upcoming_bills": bill_rows, + "insights": _insights( + expense_total=expense_total, + previous_expense_total=previous_expense_total, + category_rows=category_rows, + upcoming_bills=bill_rows, + ), + } + + +def _category_totals(expenses: list[Expense]) -> dict[int | None, Decimal]: + totals: dict[int | None, Decimal] = {} + for expense in expenses: + if expense.expense_type != "EXPENSE": + continue + totals[expense.category_id] = totals.get( + expense.category_id, Decimal("0") + ) + _decimal(expense.amount) + return totals + + +def _daily_totals(expenses: list[Expense], week_start: date) -> list[dict]: + days = { + week_start + + timedelta(days=offset): { + "date": (week_start + timedelta(days=offset)).isoformat(), + "income": Decimal("0"), + "expenses": Decimal("0"), + "net_flow": Decimal("0"), + } + for offset in range(7) + } + for expense in expenses: + row = days[expense.spent_at] + amount = _decimal(expense.amount) + if expense.expense_type == "INCOME": + row["income"] += amount + row["net_flow"] += amount + else: + row["expenses"] += amount + row["net_flow"] -= amount + return [ + { + "date": row["date"], + "income": _round_decimal(row["income"]), + "expenses": _round_decimal(row["expenses"]), + "net_flow": _round_decimal(row["net_flow"]), + } + for row in days.values() + ] + + +def _category_rows( + current: dict[int | None, Decimal], + previous: dict[int | None, Decimal], + categories: dict[int, str], + expense_total: Decimal, +) -> list[dict]: + rows = [] + for category_id, amount in current.items(): + prior = previous.get(category_id, Decimal("0")) + change_amount = amount - prior + rows.append( + { + "category_id": category_id, + "category": categories.get(category_id, "Uncategorized"), + "amount": _round_decimal(amount), + "share_pct": _percent_share(amount, expense_total), + "previous_amount": _round_decimal(prior), + "change_amount": _round_decimal(change_amount), + "change_pct": _percent_change(amount, prior), + "trend": _trend(change_amount), + } + ) + return sorted(rows, key=lambda item: item["amount"], reverse=True) + + +def _insights( + *, + expense_total: Decimal, + previous_expense_total: Decimal, + category_rows: list[dict], + upcoming_bills: list[dict], +) -> list[str]: + insights: list[str] = [] + if expense_total == 0: + insights.append("No expenses were recorded for this week.") + elif previous_expense_total == 0: + insights.append( + "This is the first week with recorded spending in the comparison window." + ) + else: + change_pct = _percent_change(expense_total, previous_expense_total) + direction = "higher" if change_pct > 0 else "lower" + insights.append( + f"Weekly spending is {abs(change_pct):.1f}% {direction} than last week." + ) + if category_rows: + top = category_rows[0] + insights.append( + f"{top['category']} was the top spending category at {top['share_pct']:.1f}% of expenses." + ) + if upcoming_bills: + bill_total = sum(_decimal(bill["amount"]) for bill in upcoming_bills) + insights.append( + f"{len(upcoming_bills)} bills totaling {_round_decimal(bill_total):.2f} are due this week." + ) + return insights + + +def _trend(change_amount: Decimal) -> str: + if change_amount > 0: + return "UP" + if change_amount < 0: + return "DOWN" + return "FLAT" + + +def _percent_change(current: Decimal, previous: Decimal) -> float: + if previous == 0: + return 0.0 if current == 0 else 100.0 + return _round_decimal(((current - previous) / previous) * Decimal("100")) + + +def _percent_share(amount: Decimal, total: Decimal) -> float: + if total == 0: + return 0.0 + return _round_decimal((amount / total) * Decimal("100")) + + +def _decimal(value) -> Decimal: + return value if isinstance(value, Decimal) else Decimal(str(value)) + + +def _round_decimal(value) -> float: + return float(_decimal(value).quantize(Decimal("0.01"))) diff --git a/packages/backend/tests/conftest.py b/packages/backend/tests/conftest.py index a7315b8c9..cc1a46f0b 100644 --- a/packages/backend/tests/conftest.py +++ b/packages/backend/tests/conftest.py @@ -1,12 +1,49 @@ import os +from fnmatch import fnmatch import pytest from app import create_app from app.config import Settings from app.extensions import db -from app.extensions import redis_client from app import models # noqa: F401 - ensure models are registered +class FakeRedis: + def __init__(self): + self._store = {} + + def get(self, key): + return self._store.get(key) + + def set(self, key, value): + self._store[key] = value + return True + + def setex(self, key, _ttl, value): + self._store[key] = value + return True + + def delete(self, *keys): + deleted = 0 + for key in keys: + if key in self._store: + deleted += 1 + del self._store[key] + return deleted + + def scan(self, cursor=0, match=None, count=100): + keys = sorted(self._store) + if match: + keys = [key for key in keys if fnmatch(key, match)] + start = int(cursor or 0) + end = start + count + next_cursor = 0 if end >= len(keys) else end + return next_cursor, keys[start:end] + + def flushdb(self): + self._store.clear() + return True + + class TestSettings(Settings): # Override defaults for tests database_url: str = "sqlite+pysqlite:///:memory:" @@ -20,7 +57,7 @@ def _setup_db(app): @pytest.fixture() -def app_fixture(): +def app_fixture(monkeypatch): # Ensure a clean env for tests os.environ.setdefault("FLASK_ENV", "testing") settings = TestSettings( @@ -28,21 +65,19 @@ def app_fixture(): redis_url="redis://localhost:6379/15", jwt_secret="test-secret-with-32-plus-chars-1234567890", ) + fake_redis = FakeRedis() + monkeypatch.setattr("app.extensions.redis_client", fake_redis) + monkeypatch.setattr("app.routes.auth.redis_client", fake_redis) + monkeypatch.setattr("app.services.cache.redis_client", fake_redis) app = create_app(settings) app.config.update(TESTING=True) _setup_db(app) - try: - redis_client.flushdb() - except Exception: - pass + fake_redis.flushdb() yield app with app.app_context(): db.session.remove() db.drop_all() - try: - redis_client.flushdb() - except Exception: - pass + fake_redis.flushdb() @pytest.fixture() diff --git a/packages/backend/tests/test_insights.py b/packages/backend/tests/test_insights.py index 84f1d4ba4..4383ef756 100644 --- a/packages/backend/tests/test_insights.py +++ b/packages/backend/tests/test_insights.py @@ -1,6 +1,12 @@ from datetime import date, timedelta +def _create_category(client, auth_header, name="Groceries"): + r = client.post("/categories", json={"name": name}, headers=auth_header) + assert r.status_code == 201 + return r.get_json()["id"] + + def test_budget_suggestion_returns_analytics_fields(client, auth_header): current = date.today().replace(day=10) previous = (current.replace(day=1) - timedelta(days=1)).replace(day=10) @@ -90,3 +96,94 @@ def _boom(*_args, **_kwargs): assert payload["method"] == "heuristic" assert "warnings" in payload assert "gemini_unavailable" in payload["warnings"] + + +def test_weekly_summary_returns_trends_categories_and_bills(client, auth_header): + groceries_id = _create_category(client, auth_header, "Groceries") + dining_id = _create_category(client, auth_header, "Dining") + + # The endpoint normalizes any requested date to its ISO week Monday. + week_start = date(2026, 3, 2) + prior_week = week_start - timedelta(days=7) + payloads = [ + { + "amount": 120, + "description": "Grocery stock-up", + "date": week_start.isoformat(), + "category_id": groceries_id, + "expense_type": "EXPENSE", + }, + { + "amount": 80, + "description": "Dinner", + "date": (week_start + timedelta(days=2)).isoformat(), + "category_id": dining_id, + "expense_type": "EXPENSE", + }, + { + "amount": 500, + "description": "Paycheck", + "date": (week_start + timedelta(days=4)).isoformat(), + "expense_type": "INCOME", + }, + { + "amount": 100, + "description": "Prior groceries", + "date": prior_week.isoformat(), + "category_id": groceries_id, + "expense_type": "EXPENSE", + }, + ] + for payload in payloads: + r = client.post("/expenses", json=payload, headers=auth_header) + assert r.status_code == 201 + + r = client.post( + "/bills", + json={ + "name": "Internet", + "amount": 60, + "next_due_date": (week_start + timedelta(days=5)).isoformat(), + "cadence": "MONTHLY", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get( + "/insights/weekly-summary?week_start=2026-03-05", + headers=auth_header, + ) + assert r.status_code == 200 + payload = r.get_json() + + assert payload["week_start"] == "2026-03-02" + assert payload["week_end"] == "2026-03-08" + assert payload["currency"] == "INR" + assert payload["totals"]["expenses"] == 200.0 + assert payload["totals"]["income"] == 500.0 + assert payload["totals"]["net_flow"] == 300.0 + assert payload["previous_week"]["expenses"] == 100.0 + assert payload["previous_week"]["change_pct"] == 100.0 + assert payload["daily"][0]["expenses"] == 120.0 + assert payload["daily"][4]["income"] == 500.0 + assert payload["categories"][0]["category"] == "Groceries" + assert payload["categories"][0]["amount"] == 120.0 + assert payload["categories"][0]["previous_amount"] == 100.0 + assert payload["top_expenses"][0]["description"] == "Grocery stock-up" + assert payload["upcoming_bills"][0]["name"] == "Internet" + assert any("Weekly spending" in item for item in payload["insights"]) + + +def test_weekly_summary_rejects_invalid_week_start(client, auth_header): + r = client.get( + "/insights/weekly-summary?week_start=not-a-date", + headers=auth_header, + ) + assert r.status_code == 400 + assert r.get_json()["error"] == "week_start must be YYYY-MM-DD" + + +def test_weekly_summary_requires_authentication(client): + r = client.get("/insights/weekly-summary") + assert r.status_code == 401