diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0f..85774703c 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -481,6 +481,54 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /insights/weekly-digest: + 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 target week; the response normalizes it to Monday. + responses: + '200': + description: Weekly digest with totals, trend comparison, top categories, and insights + content: + application/json: + schema: + type: object + additionalProperties: true + example: + week_start: 2025-08-04 + week_end: 2025-08-10 + summary: + income: 500 + spending: 150 + net_flow: 350 + transaction_count: 3 + top_categories: + - { category_id: 1, category: Groceries, amount: 120, transaction_count: 1 } + comparison: + previous_week_start: 2025-07-28 + previous_week_end: 2025-08-03 + previous_spending: 100 + spending_change_pct: 50 + insights: + - Spending increased 50.0% vs last week. + - Groceries was the largest category at 80.0% of weekly spend. + '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..1082e275e 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_financial_digest import logging bp = Blueprint("insights", __name__) @@ -23,3 +23,18 @@ def budget_suggestion(): ) logger.info("Budget suggestion served user=%s month=%s", uid, ym) return jsonify(suggestion) + + +@bp.get("/weekly-digest") +@jwt_required() +def weekly_digest(): + uid = int(get_jwt_identity()) + week_start_raw = (request.args.get("week_start") or "").strip() + try: + week_start = date.fromisoformat(week_start_raw) if week_start_raw else None + except ValueError: + return jsonify(error="invalid week_start"), 400 + + digest = weekly_financial_digest(uid, week_start) + logger.info("Weekly digest served user=%s week_start=%s", uid, digest["week_start"]) + return jsonify(digest) diff --git a/packages/backend/app/services/ai.py b/packages/backend/app/services/ai.py index 951fbd000..2c4f3ef86 100644 --- a/packages/backend/app/services/ai.py +++ b/packages/backend/app/services/ai.py @@ -1,11 +1,12 @@ import json from urllib import request +from datetime import date, timedelta from sqlalchemy import extract, func from ..config import Settings from ..extensions import db -from ..models import Expense +from ..models import Category, Expense _settings = Settings() DEFAULT_PERSONA = ( @@ -185,3 +186,123 @@ def monthly_budget_suggestion( uid, ym, persona_text, warnings=["gemini_unavailable"] ) return _heuristic_budget(uid, ym, persona_text) + + +def _week_start(value: date | None = None) -> date: + anchor = value or date.today() + return anchor - timedelta(days=anchor.weekday()) + + +def _weekly_rows(uid: int, start: date, end: date): + return ( + db.session.query( + Expense.expense_type, + Expense.category_id, + Category.name, + func.coalesce(func.sum(Expense.amount), 0), + func.count(Expense.id), + ) + .outerjoin(Category, Expense.category_id == Category.id) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + ) + .group_by(Expense.expense_type, Expense.category_id, Category.name) + .all() + ) + + +def _weekly_totals(uid: int, start: date, end: date) -> dict: + income = 0.0 + spending = 0.0 + transaction_count = 0 + categories: dict[str, dict] = {} + + for expense_type, category_id, category_name, amount, count in _weekly_rows( + uid, start, end + ): + value = float(amount or 0) + transaction_count += int(count or 0) + if expense_type == "INCOME": + income += value + continue + spending += value + key = str(category_id or "uncategorized") + categories[key] = { + "category_id": category_id, + "category": category_name or "Uncategorized", + "amount": round(value, 2), + "transaction_count": int(count or 0), + } + + top_categories = sorted( + categories.values(), key=lambda item: item["amount"], reverse=True + )[:5] + return { + "income": round(income, 2), + "spending": round(spending, 2), + "net_flow": round(income - spending, 2), + "transaction_count": transaction_count, + "top_categories": top_categories, + } + + +def weekly_financial_digest(uid: int, week_start: date | None = None) -> dict: + """Build a deterministic weekly financial summary with trend insights.""" + start = _week_start(week_start) + end = start + timedelta(days=6) + previous_start = start - timedelta(days=7) + previous_end = start - timedelta(days=1) + + current = _weekly_totals(uid, start, end) + previous = _weekly_totals(uid, previous_start, previous_end) + + previous_spending = previous["spending"] + if previous_spending: + spending_change_pct = round( + ((current["spending"] - previous_spending) / previous_spending) * 100, 2 + ) + else: + spending_change_pct = 0.0 + + insights: list[str] = [] + if current["spending"] == 0 and current["income"] == 0: + insights.append("No transactions were recorded for this week yet.") + elif spending_change_pct > 0: + insights.append(f"Spending increased {spending_change_pct}% vs last week.") + elif spending_change_pct < 0: + insights.append(f"Spending decreased {abs(spending_change_pct)}% vs last week.") + else: + insights.append("Spending was unchanged compared with last week.") + + if current["top_categories"]: + top = current["top_categories"][0] + share = ( + round((top["amount"] / current["spending"]) * 100, 2) + if current["spending"] + else 0.0 + ) + insights.append( + f"{top['category']} was the largest category at {share}% of weekly spend." + ) + + if current["net_flow"] < 0: + insights.append("Net flow was negative; review discretionary spend first.") + elif current["net_flow"] > 0: + insights.append( + "Net flow was positive; consider moving part of the surplus to savings." + ) + + return { + "week_start": start.isoformat(), + "week_end": end.isoformat(), + "summary": current, + "comparison": { + "previous_week_start": previous_start.isoformat(), + "previous_week_end": previous_end.isoformat(), + "previous_spending": previous["spending"], + "spending_change_pct": spending_change_pct, + }, + "insights": insights, + } diff --git a/packages/backend/tests/conftest.py b/packages/backend/tests/conftest.py index a7315b8c9..26fc94637 100644 --- a/packages/backend/tests/conftest.py +++ b/packages/backend/tests/conftest.py @@ -3,10 +3,44 @@ from app import create_app from app.config import Settings from app.extensions import db -from app.extensions import redis_client +from app import extensions +from app.routes import auth as auth_routes +from app.services import cache as cache_service from app import models # noqa: F401 - ensure models are registered +class FakeRedis: + def __init__(self): + self._store = {} + + def setex(self, key, _ttl, value): + self._store[key] = value + + def get(self, key): + return self._store.get(key) + + def delete(self, *keys): + for key in keys: + self._store.pop(key, None) + + def scan(self, cursor=0, match=None, count=100): + import fnmatch + + keys = list(self._store.keys()) + if match: + keys = [key for key in keys if fnmatch.fnmatch(key, match)] + return 0, keys[:count] + + def flushdb(self): + self._store.clear() + + +redis_client = FakeRedis() +extensions.redis_client = redis_client +auth_routes.redis_client = redis_client +cache_service.redis_client = redis_client + + class TestSettings(Settings): # Override defaults for tests database_url: str = "sqlite+pysqlite:///:memory:" diff --git a/packages/backend/tests/test_weekly_digest.py b/packages/backend/tests/test_weekly_digest.py new file mode 100644 index 000000000..4f7ca8290 --- /dev/null +++ b/packages/backend/tests/test_weekly_digest.py @@ -0,0 +1,70 @@ +from datetime import date, timedelta + +from app.extensions import db +from app.models import Category, User + + +def _week_anchor() -> date: + today = date.today() + return today - timedelta(days=today.weekday()) + + +def _create_category(app_fixture, auth_header, name="Groceries"): + with app_fixture.app_context(): + user = db.session.query(User).filter_by(email="test@example.com").one() + category = Category(user_id=user.id, name=name) + db.session.add(category) + db.session.commit() + return category.id + + +def test_weekly_digest_returns_summary_trends_and_insights( + client, app_fixture, auth_header +): + category_id = _create_category(app_fixture, auth_header) + week_start = _week_anchor() + previous_week = week_start - timedelta(days=7) + + expenses = [ + ("Weekly groceries", 120, week_start, "EXPENSE"), + ("Coffee", 30, week_start + timedelta(days=1), "EXPENSE"), + ("Salary", 500, week_start + timedelta(days=2), "INCOME"), + ("Previous groceries", 100, previous_week, "EXPENSE"), + ] + for description, amount, spent_at, expense_type in expenses: + r = client.post( + "/expenses", + json={ + "amount": amount, + "description": description, + "date": spent_at.isoformat(), + "expense_type": expense_type, + "category_id": category_id if expense_type != "INCOME" else None, + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get( + f"/insights/weekly-digest?week_start={week_start.isoformat()}", + headers=auth_header, + ) + assert r.status_code == 200 + payload = r.get_json() + + assert payload["week_start"] == week_start.isoformat() + assert payload["week_end"] == (week_start + timedelta(days=6)).isoformat() + assert payload["summary"]["spending"] == 150.0 + assert payload["summary"]["income"] == 500.0 + assert payload["summary"]["net_flow"] == 350.0 + assert payload["summary"]["transaction_count"] == 3 + assert payload["summary"]["top_categories"][0]["category"] == "Groceries" + assert payload["comparison"]["previous_spending"] == 100.0 + assert payload["comparison"]["spending_change_pct"] == 50.0 + assert any("Spending increased" in insight for insight in payload["insights"]) + + +def test_weekly_digest_rejects_invalid_week_start(client, auth_header): + r = client.get("/insights/weekly-digest?week_start=nope", headers=auth_header) + assert r.status_code == 400 + assert r.get_json()["error"] == "invalid week_start"