diff --git a/README.md b/README.md index 49592bff..1b131380 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/budget-suggestion`, `/insights/weekly-summary` ## MVP UI/UX Plan - Auth screens: register/login. diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0..fc9a2e55 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -481,6 +481,76 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /insights/weekly-summary: + get: + summary: Get weekly financial summary with trends and insights + description: | + Generate a weekly financial digest that includes: + - Week-over-week spending comparison + - Category breakdown + - Daily spending pattern + - Upcoming bills + - Trend analysis (increasing/decreasing/stable) + - AI-powered insights and tips + tags: [Insights] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: week + required: false + schema: { type: integer, default: 0 } + description: Week offset (0 = current week, -1 = last week, etc.) + responses: + '200': + description: Weekly summary + content: + application/json: + schema: + type: object + additionalProperties: true + example: + week_start: "2025-08-11" + week_end: "2025-08-17" + trend: "increasing" + method: "heuristic" + analytics: + week_over_week_change_pct: 15.5 + current_week_expenses: 350.0 + previous_week_expenses: 303.0 + current_week_income: 500.0 + net_flow: 150.0 + top_categories: + - category_id: "food" + amount: 120.0 + - category_id: "transport" + amount: 80.0 + daily_spending: + "2025-08-11": 50.0 + "2025-08-12": 45.0 + "2025-08-13": 30.0 + "2025-08-14": 75.0 + "2025-08-15": 60.0 + "2025-08-16": 55.0 + "2025-08-17": 35.0 + upcoming_bills: + - id: 1 + name: "Internet Bill" + amount: 50.0 + currency: "INR" + due_date: "2025-08-15" + days_until_due: 3 + insights: + - "Spending increased by 15.5% compared to last week." + - "Your top spending category was food at 120.0." + - "You have 1 bill(s) due this week totaling 50.0." + tips: + - "Try to identify one expense you can reduce next week." + '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 bfc02e43..f93a02e0 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_digest import logging bp = Blueprint("insights", __name__) @@ -23,3 +23,26 @@ 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(): + """Get weekly financial summary with trends and insights. + + Query parameters: + - week: Week offset (0 = current week, -1 = last week, etc.) + """ + uid = int(get_jwt_identity()) + week_offset = int(request.args.get("week", 0)) + 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 + + summary = weekly_digest( + uid, + week_offset=week_offset, + gemini_api_key=user_gemini_key, + persona=persona, + ) + logger.info("Weekly summary served user=%s week_offset=%s", uid, week_offset) + return jsonify(summary) diff --git a/packages/backend/app/services/ai.py b/packages/backend/app/services/ai.py index 951fbd00..90e54fa5 100644 --- a/packages/backend/app/services/ai.py +++ b/packages/backend/app/services/ai.py @@ -1,11 +1,12 @@ import json +from datetime import date, timedelta from urllib import request from sqlalchemy import extract, func from ..config import Settings from ..extensions import db -from ..models import Expense +from ..models import Expense, Bill _settings = Settings() DEFAULT_PERSONA = ( @@ -185,3 +186,293 @@ def monthly_budget_suggestion( uid, ym, persona_text, warnings=["gemini_unavailable"] ) return _heuristic_budget(uid, ym, persona_text) + + +# ============ Weekly Summary Functions ============ + + +def _get_week_range(week_offset: int = 0) -> tuple[date, date]: + """Get the start and end dates for a week. + + Args: + week_offset: 0 for current week, -1 for last week, etc. + + Returns: + Tuple of (start_date, end_date) for the week + """ + today = date.today() + # Find Monday of the current week + monday = today - timedelta(days=today.weekday()) + # Apply offset + target_monday = monday + timedelta(weeks=week_offset) + sunday = target_monday + timedelta(days=6) + return target_monday, sunday + + +def _weekly_totals(uid: int, start_date: date, end_date: date) -> tuple[float, float]: + """Get income and expenses for a date range.""" + income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start_date, + Expense.spent_at <= end_date, + 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_date, + Expense.spent_at <= end_date, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + return float(income or 0), float(expenses or 0) + + +def _weekly_category_spend(uid: int, start_date: date, end_date: date) -> dict[str, float]: + """Get category spending for a date range.""" + rows = ( + db.session.query( + Expense.category_id, func.coalesce(func.sum(Expense.amount), 0) + ) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start_date, + Expense.spent_at <= end_date, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.category_id) + .all() + ) + return {str(k or "uncat"): float(v) for k, v in rows} + + +def _upcoming_bills(uid: int, days_ahead: int = 7) -> list[dict]: + """Get bills due in the next N days.""" + today = date.today() + future_date = today + timedelta(days=days_ahead) + bills = ( + db.session.query(Bill) + .filter( + Bill.user_id == uid, + Bill.active == True, + Bill.next_due_date >= today, + Bill.next_due_date <= future_date, + ) + .order_by(Bill.next_due_date) + .all() + ) + return [ + { + "id": b.id, + "name": b.name, + "amount": float(b.amount), + "currency": b.currency, + "due_date": b.next_due_date.isoformat(), + "days_until_due": (b.next_due_date - today).days, + } + for b in bills + ] + + +def _build_weekly_analytics(uid: int, week_start: date, week_end: date) -> dict: + """Build analytics comparing current week to previous week.""" + # Current week totals + curr_income, curr_expenses = _weekly_totals(uid, week_start, week_end) + + # Previous week totals + prev_start = week_start - timedelta(days=7) + prev_end = week_start - timedelta(days=1) + _, prev_expenses = _weekly_totals(uid, prev_start, prev_end) + + # Week-over-week change + if prev_expenses > 0: + wow_change = round(((curr_expenses - prev_expenses) / prev_expenses) * 100, 2) + else: + wow_change = 0.0 + + # Category breakdown + cats = _weekly_category_spend(uid, week_start, week_end) + top_categories = sorted(cats.items(), key=lambda x: x[1], reverse=True)[:5] + + # Daily spending pattern + daily_spend = {} + current = week_start + while current <= week_end: + day_total = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.spent_at == current, + Expense.expense_type != "INCOME", + ) + .scalar() or 0 + ) + daily_spend[current.isoformat()] = float(day_total) + current += timedelta(days=1) + + return { + "week_over_week_change_pct": wow_change, + "current_week_expenses": round(curr_expenses, 2), + "previous_week_expenses": round(prev_expenses, 2), + "current_week_income": round(curr_income, 2), + "net_flow": round(curr_income - curr_expenses, 2), + "top_categories": [ + {"category_id": k, "amount": round(v, 2)} for k, v in top_categories + ], + "daily_spending": daily_spend, + } + + +def _heuristic_weekly_summary(uid: int, week_start: date, week_end: date) -> dict: + """Generate weekly summary using heuristic methods.""" + analytics = _build_weekly_analytics(uid, week_start, week_end) + bills = _upcoming_bills(uid) + + # Generate insights based on the data + insights = [] + if analytics["week_over_week_change_pct"] > 20: + insights.append( + f"Spending increased by {analytics['week_over_week_change_pct']}% compared to last week. Consider reviewing recent purchases." + ) + elif analytics["week_over_week_change_pct"] < -20: + insights.append( + f"Great job! Spending decreased by {abs(analytics['week_over_week_change_pct'])}% compared to last week." + ) + + if analytics["top_categories"]: + top_cat = analytics["top_categories"][0] + insights.append( + f"Your top spending category was {top_cat['category_id']} at {top_cat['amount']}." + ) + + if bills: + total_due = sum(b["amount"] for b in bills) + insights.append( + f"You have {len(bills)} bill(s) due this week totaling {total_due}." + ) + + # Generate tips based on patterns + tips = [] + if analytics["net_flow"] < 0: + tips.append("Your expenses exceeded income this week. Review discretionary spending.") + if analytics["week_over_week_change_pct"] > 10: + tips.append("Try to identify one expense you can reduce next week.") + + # Determine trend + if analytics["week_over_week_change_pct"] > 5: + trend = "increasing" + elif analytics["week_over_week_change_pct"] < -5: + trend = "decreasing" + else: + trend = "stable" + + return { + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + "analytics": analytics, + "upcoming_bills": bills, + "insights": insights, + "tips": tips, + "trend": trend, + "method": "heuristic", + } + + +def _gemini_weekly_summary( + uid: int, week_start: date, week_end: date, api_key: str, model: str, persona: str +) -> dict: + """Generate weekly summary using Gemini AI.""" + analytics = _build_weekly_analytics(uid, week_start, week_end) + bills = _upcoming_bills(uid) + categories = _weekly_category_spend(uid, week_start, week_end) + + prompt = ( + f"{persona}\n" + "Generate a weekly financial digest. Return strict JSON with keys: " + "insights(list <=4), tips(list <=3), trend(string: increasing/decreasing/stable). " + "Analyze the weekly data and provide actionable guidance.\n" + f"week_start={week_start.isoformat()}\n" + f"week_end={week_end.isoformat()}\n" + f"category_spend={categories}\n" + f"analytics={analytics}\n" + f"upcoming_bills={bills}" + ) + + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{model}:generateContent?key={api_key}" + ) + body = json.dumps( + { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.2}, + } + ).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) + parsed["week_start"] = week_start.isoformat() + parsed["week_end"] = week_end.isoformat() + parsed["analytics"] = analytics + parsed["upcoming_bills"] = bills + parsed["method"] = "gemini" + + return parsed + + +def weekly_digest( + uid: int, + week_offset: int = 0, + gemini_api_key: str | None = None, + gemini_model: str | None = None, + persona: str | None = None, +) -> dict: + """Generate a weekly financial summary with trends and insights. + + Args: + uid: User ID + week_offset: 0 for current week, -1 for last week, etc. + gemini_api_key: Optional Gemini API key for AI-powered insights + gemini_model: Gemini model to use + persona: Optional custom persona for AI + + Returns: + Dictionary containing weekly summary, analytics, insights, and tips + """ + week_start, week_end = _get_week_range(week_offset) + + 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_weekly_summary( + uid, week_start, week_end, key, model, persona_text + ) + except Exception: + return _heuristic_weekly_summary(uid, week_start, week_end) + + return _heuristic_weekly_summary(uid, week_start, week_end) diff --git a/packages/backend/tests/conftest.py b/packages/backend/tests/conftest.py index a7315b8c..2ca69977 100644 --- a/packages/backend/tests/conftest.py +++ b/packages/backend/tests/conftest.py @@ -1,9 +1,9 @@ import os import pytest +from unittest.mock import patch 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 @@ -19,6 +19,51 @@ def _setup_db(app): db.create_all() +# Create a fake redis client +class FakeRedis: + """Fake Redis client for testing.""" + def __init__(self): + self._store = {} + + def setex(self, name, time, value): + self._store[name] = value + return True + + def get(self, name): + return self._store.get(name) + + def delete(self, *names): + for name in names: + self._store.pop(name, None) + return len(names) + + def flushdb(self): + self._store.clear() + return True + + +# Store original redis client +_original_redis_client = None + + +@pytest.fixture(scope="session", autouse=True) +def setup_fake_redis(): + """Set up fake redis for all tests.""" + global _original_redis_client + from app import extensions + + # Save original + _original_redis_client = extensions.redis_client + + # Replace with fake + extensions.redis_client = FakeRedis() + + yield + + # Restore original (not really needed since we're in test mode) + extensions.redis_client = _original_redis_client + + @pytest.fixture() def app_fixture(): # Ensure a clean env for tests @@ -28,21 +73,20 @@ def app_fixture(): redis_url="redis://localhost:6379/15", jwt_secret="test-secret-with-32-plus-chars-1234567890", ) + app = create_app(settings) app.config.update(TESTING=True) _setup_db(app) - try: - redis_client.flushdb() - except Exception: - pass + + # Ensure the fake redis is used + from app import extensions + extensions.redis_client = FakeRedis() + yield app + with app.app_context(): db.session.remove() db.drop_all() - try: - redis_client.flushdb() - except Exception: - pass @pytest.fixture() diff --git a/packages/backend/tests/test_insights.py b/packages/backend/tests/test_insights.py index 84f1d4ba..c8cb3fd4 100644 --- a/packages/backend/tests/test_insights.py +++ b/packages/backend/tests/test_insights.py @@ -90,3 +90,224 @@ def _boom(*_args, **_kwargs): assert payload["method"] == "heuristic" assert "warnings" in payload assert "gemini_unavailable" in payload["warnings"] + + +# ============ Weekly Summary Tests ============ + + +def test_weekly_summary_returns_weekly_data(client, auth_header): + """Test that weekly summary returns required fields.""" + # Add some expenses for the current week + today = date.today() + r = client.post( + "/expenses", + json={ + "amount": 50, + "description": "Weekly expense 1", + "date": today.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.post( + "/expenses", + json={ + "amount": 30, + "description": "Weekly expense 2", + "date": (today - timedelta(days=2)).isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + # Check required fields + assert "week_start" in payload + assert "week_end" in payload + assert "analytics" in payload + assert "upcoming_bills" in payload + assert "insights" in payload + assert "tips" in payload + assert "trend" in payload + assert payload["method"] == "heuristic" + + +def test_weekly_summary_analytics_structure(client, auth_header): + """Test that weekly analytics contains expected fields.""" + today = date.today() + client.post( + "/expenses", + json={ + "amount": 100, + "description": "Test expense", + "date": today.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + analytics = payload["analytics"] + + # Check analytics fields + assert "week_over_week_change_pct" in analytics + assert "current_week_expenses" in analytics + assert "previous_week_expenses" in analytics + assert "current_week_income" in analytics + assert "net_flow" in analytics + assert "top_categories" in analytics + assert "daily_spending" in analytics + + +def test_weekly_summary_with_bills(client, auth_header): + """Test that upcoming bills are included in weekly summary.""" + today = date.today() + due_date = (today + timedelta(days=3)).isoformat() + + # Create a bill due this week + r = client.post( + "/bills", + json={ + "name": "Test Bill", + "amount": 50, + "next_due_date": due_date, + "cadence": "MONTHLY", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + # Check upcoming bills + assert "upcoming_bills" in payload + assert len(payload["upcoming_bills"]) >= 1 + assert payload["upcoming_bills"][0]["name"] == "Test Bill" + + +def test_weekly_summary_with_week_offset(client, auth_header): + """Test that week_offset parameter works correctly.""" + today = date.today() + + # Add expense for this week + client.post( + "/expenses", + json={ + "amount": 100, + "description": "Current week", + "date": today.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + # Get last week's summary (-1) + r = client.get("/insights/weekly-summary?week=-1", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + # Last week should have no expenses (or minimal) + # Current week should show the expense we just added + r_current = client.get("/insights/weekly-summary?week=0", headers=auth_header) + payload_current = r_current.get_json() + + assert payload_current["analytics"]["current_week_expenses"] >= 100 + + +def test_weekly_summary_prefers_user_gemini_key(client, auth_header, monkeypatch): + """Test that user-provided Gemini API key is used.""" + captured = {} + + def _fake_gemini(uid, week_start, week_end, api_key, model, persona): + captured["uid"] = uid + captured["api_key"] = api_key + captured["model"] = model + return { + "insights": ["AI insight"], + "tips": ["AI tip"], + "trend": "stable", + "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-gemini-key", + }, + ) + assert r.status_code == 200 + payload = r.get_json() + assert payload["method"] == "gemini" + assert captured["api_key"] == "user-gemini-key" + + +def test_weekly_summary_falls_back_when_gemini_fails(client, auth_header, monkeypatch): + """Test fallback to heuristic when Gemini fails.""" + 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" + # Heuristic should still work + assert "analytics" in payload + assert "insights" in payload + + +def test_weekly_summary_with_income(client, auth_header): + """Test that income is properly tracked in weekly summary.""" + today = date.today() + + # Add income + client.post( + "/expenses", + json={ + "amount": 500, + "description": "Salary", + "date": today.isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + + # Add expense + client.post( + "/expenses", + json={ + "amount": 200, + "description": "Rent", + "date": today.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + r = client.get("/insights/weekly-summary", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + assert payload["analytics"]["current_week_income"] == 500 + assert payload["analytics"]["current_week_expenses"] == 200 + assert payload["analytics"]["net_flow"] == 300 # 500 - 200