diff --git a/README.md b/README.md index 49592bffc..ba60e4323 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-digest` ## MVP UI/UX Plan - Auth screens: register/login. diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0f..c40a95cf8 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -481,6 +481,56 @@ 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: end_date + required: false + schema: { type: string, format: date } + description: Last day of the seven-day digest window. Defaults to today. + responses: + '200': + description: Weekly summary, trend comparison, top categories, and bills due next week + content: + application/json: + schema: + type: object + additionalProperties: true + example: + period: + start_date: 2026-03-08 + end_date: 2026-03-14 + days: 7 + summary: + income: 1200 + expenses: 120 + net_flow: 1080 + transaction_count: 3 + comparison: + previous_expenses: 60 + expense_change: 60 + expense_change_pct: 100 + top_categories: + - { category_id: 1, category_name: Food, amount: 120 } + upcoming_bills: + - { id: 3, name: Internet, amount: 50, currency: INR, next_due_date: 2026-03-17 } + insights: + - Spending is up 100.0% versus the previous week. + '400': + description: Invalid date filter + 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..9395202c4 100644 --- a/packages/backend/app/routes/insights.py +++ b/packages/backend/app/routes/insights.py @@ -2,6 +2,7 @@ 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.weekly_digest import build_weekly_digest import logging bp = Blueprint("insights", __name__) @@ -23,3 +24,23 @@ 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()) + end_raw = (request.args.get("end_date") or "").strip() + try: + end_date = date.fromisoformat(end_raw) if end_raw else None + except ValueError: + return jsonify(error="invalid end_date, expected YYYY-MM-DD"), 400 + + digest = build_weekly_digest(uid, end_date=end_date) + logger.info( + "Weekly digest served user=%s start=%s end=%s", + uid, + digest["period"]["start_date"], + digest["period"]["end_date"], + ) + return jsonify(digest) diff --git a/packages/backend/app/services/weekly_digest.py b/packages/backend/app/services/weekly_digest.py new file mode 100644 index 000000000..7230bbcca --- /dev/null +++ b/packages/backend/app/services/weekly_digest.py @@ -0,0 +1,221 @@ +from datetime import date, timedelta + +from sqlalchemy import func + +from ..extensions import db +from ..models import Bill, Category, Expense + + +def build_weekly_digest(uid: int, end_date: date | None = None) -> dict: + period_end = end_date or date.today() + period_start = period_end - timedelta(days=6) + previous_end = period_start - timedelta(days=1) + previous_start = previous_end - timedelta(days=6) + + current = _period_totals(uid, period_start, period_end) + previous = _period_totals(uid, previous_start, previous_end) + top_categories = _top_categories(uid, period_start, period_end) + largest_transactions = _largest_transactions(uid, period_start, period_end) + upcoming_bills = _upcoming_bills( + uid, period_end + timedelta(days=1), period_end + timedelta(days=7) + ) + + digest = { + "period": { + "start_date": period_start.isoformat(), + "end_date": period_end.isoformat(), + "days": 7, + }, + "summary": { + **current, + "net_flow": round(current["income"] - current["expenses"], 2), + "transaction_count": current["transaction_count"], + }, + "comparison": _build_comparison(current, previous), + "top_categories": top_categories, + "largest_transactions": largest_transactions, + "upcoming_bills": upcoming_bills, + "insights": _build_insights(current, previous, top_categories, upcoming_bills), + } + return digest + + +def _period_totals(uid: int, start: date, end: date) -> dict: + 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() + ) + count = ( + db.session.query(func.count(Expense.id)) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + ) + .scalar() + ) + return { + "income": round(float(income or 0), 2), + "expenses": round(float(expenses or 0), 2), + "transaction_count": int(count or 0), + } + + +def _top_categories(uid: int, start: date, end: date) -> list[dict]: + rows = ( + db.session.query( + Expense.category_id, + func.coalesce(Category.name, "Uncategorized").label("category_name"), + func.coalesce(func.sum(Expense.amount), 0).label("amount"), + ) + .outerjoin( + Category, + (Category.id == Expense.category_id) & (Category.user_id == uid), + ) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.category_id, Category.name) + .order_by(func.sum(Expense.amount).desc()) + .limit(3) + .all() + ) + return [ + { + "category_id": row.category_id, + "category_name": row.category_name, + "amount": round(float(row.amount or 0), 2), + } + for row in rows + ] + + +def _largest_transactions(uid: int, start: date, end: date) -> list[dict]: + rows = ( + db.session.query(Expense) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + ) + .order_by(Expense.amount.desc(), Expense.spent_at.desc()) + .limit(5) + .all() + ) + return [ + { + "id": row.id, + "description": row.notes or "Transaction", + "amount": round(float(row.amount), 2), + "currency": row.currency, + "date": row.spent_at.isoformat(), + "type": row.expense_type, + "category_id": row.category_id, + } + for row in rows + ] + + +def _upcoming_bills(uid: int, start: date, end: date) -> list[dict]: + bills = ( + db.session.query(Bill) + .filter( + Bill.user_id == uid, + Bill.active.is_(True), + Bill.next_due_date >= start, + Bill.next_due_date <= end, + ) + .order_by(Bill.next_due_date.asc()) + .all() + ) + return [ + { + "id": bill.id, + "name": bill.name, + "amount": round(float(bill.amount), 2), + "currency": bill.currency, + "next_due_date": bill.next_due_date.isoformat(), + } + for bill in bills + ] + + +def _build_comparison(current: dict, previous: dict) -> dict: + return { + "previous_expenses": previous["expenses"], + "expense_change": round(current["expenses"] - previous["expenses"], 2), + "expense_change_pct": _percent_change( + current["expenses"], previous["expenses"] + ), + "previous_income": previous["income"], + "income_change": round(current["income"] - previous["income"], 2), + "income_change_pct": _percent_change(current["income"], previous["income"]), + } + + +def _percent_change(current: float, previous: float) -> float: + if previous == 0: + return 0.0 + return round(((current - previous) / previous) * 100, 2) + + +def _build_insights( + current: dict, + previous: dict, + top_categories: list[dict], + upcoming_bills: list[dict], +) -> list[str]: + insights: list[str] = [] + expense_delta = current["expenses"] - previous["expenses"] + if previous["expenses"] > 0: + direction = "up" if expense_delta > 0 else "down" + change_pct = abs(_percent_change(current["expenses"], previous["expenses"])) + insights.append( + f"Spending is {direction} {change_pct}% versus the previous week." + ) + elif current["expenses"] > 0: + insights.append( + "This is the first week with recorded spending in the comparison window." + ) + else: + insights.append("No spending was recorded this week.") + + if top_categories: + top = top_categories[0] + insights.append( + f"Top spending category is {top['category_name']} at {top['amount']:.2f}." + ) + + if upcoming_bills: + total_due = sum(item["amount"] for item in upcoming_bills) + insights.append( + f"{len(upcoming_bills)} bill(s) due next week totaling {total_due:.2f}." + ) + + if current["income"] < current["expenses"]: + insights.append( + "Weekly expenses are higher than income; review discretionary spend." + ) + else: + insights.append("Weekly income covers recorded expenses.") + + return insights diff --git a/packages/backend/tests/test_weekly_digest.py b/packages/backend/tests/test_weekly_digest.py new file mode 100644 index 000000000..752dbb778 --- /dev/null +++ b/packages/backend/tests/test_weekly_digest.py @@ -0,0 +1,144 @@ +from datetime import date, timedelta + +import pytest + + +class _MemoryRedis: + def __init__(self): + self.values = {} + + def setex(self, key, _ttl, value): + self.values[key] = value + return True + + def get(self, key): + return self.values.get(key) + + def delete(self, *keys): + for key in keys: + self.values.pop(key, None) + return len(keys) + + def scan(self, cursor=0, match=None, count=None): + return 0, [] + + def flushdb(self): + self.values.clear() + return True + + +@pytest.fixture() +def auth_header_no_redis(client, monkeypatch): + from app.routes import auth as auth_routes + from app.services import cache as cache_service + + memory_redis = _MemoryRedis() + monkeypatch.setattr(auth_routes, "redis_client", memory_redis) + monkeypatch.setattr(cache_service, "redis_client", memory_redis) + + email = "weekly-digest@example.com" + password = "password123" + response = client.post( + "/auth/register", json={"email": email, "password": password} + ) + assert response.status_code in (200, 201, 409) + + response = client.post("/auth/login", json={"email": email, "password": password}) + assert response.status_code == 200 + access = response.get_json()["access_token"] + return {"Authorization": f"Bearer {access}"} + + +def test_weekly_digest_returns_summary_trends_and_upcoming_bills( + client, auth_header_no_redis +): + end = date(2026, 3, 14) + current_week = end - timedelta(days=2) + previous_week = end - timedelta(days=9) + + food = client.post( + "/categories", json={"name": "Food"}, headers=auth_header_no_redis + ) + assert food.status_code == 201 + food_id = food.get_json()["id"] + + current_rows = [ + { + "amount": 1200, + "description": "Salary", + "date": current_week.isoformat(), + "expense_type": "INCOME", + }, + { + "amount": 80, + "description": "Groceries", + "date": current_week.isoformat(), + "expense_type": "EXPENSE", + "category_id": food_id, + }, + { + "amount": 40, + "description": "Dinner", + "date": (end - timedelta(days=1)).isoformat(), + "expense_type": "EXPENSE", + "category_id": food_id, + }, + ] + for row in current_rows: + response = client.post("/expenses", json=row, headers=auth_header_no_redis) + assert response.status_code == 201 + + response = client.post( + "/expenses", + json={ + "amount": 60, + "description": "Previous week groceries", + "date": previous_week.isoformat(), + "expense_type": "EXPENSE", + "category_id": food_id, + }, + headers=auth_header_no_redis, + ) + assert response.status_code == 201 + + bill = client.post( + "/bills", + json={ + "name": "Internet", + "amount": 50, + "next_due_date": (end + timedelta(days=3)).isoformat(), + "cadence": "MONTHLY", + }, + headers=auth_header_no_redis, + ) + assert bill.status_code == 201 + + response = client.get( + f"/insights/weekly-digest?end_date={end.isoformat()}", + headers=auth_header_no_redis, + ) + assert response.status_code == 200 + payload = response.get_json() + + assert payload["period"]["start_date"] == "2026-03-08" + assert payload["period"]["end_date"] == "2026-03-14" + assert payload["summary"]["income"] == 1200.0 + assert payload["summary"]["expenses"] == 120.0 + assert payload["summary"]["net_flow"] == 1080.0 + assert payload["summary"]["transaction_count"] == 3 + assert payload["comparison"]["previous_expenses"] == 60.0 + assert payload["comparison"]["expense_change"] == 60.0 + assert payload["comparison"]["expense_change_pct"] == 100.0 + assert payload["top_categories"][0]["category_name"] == "Food" + assert payload["top_categories"][0]["amount"] == 120.0 + assert payload["upcoming_bills"][0]["name"] == "Internet" + assert any("Spending is up" in item for item in payload["insights"]) + + +def test_weekly_digest_rejects_invalid_end_date(client, auth_header_no_redis): + response = client.get( + "/insights/weekly-digest?end_date=2026-99-99", + headers=auth_header_no_redis, + ) + assert response.status_code == 400 + assert response.get_json()["error"] == "invalid end_date, expected YYYY-MM-DD"