Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/backend/app/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 16 additions & 1 deletion packages/backend/app/routes/insights.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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)
123 changes: 122 additions & 1 deletion packages/backend/app/services/ai.py
Original file line number Diff line number Diff line change
@@ -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 = (
Expand Down Expand Up @@ -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,
}
36 changes: 35 additions & 1 deletion packages/backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
70 changes: 70 additions & 0 deletions packages/backend/tests/test_weekly_digest.py
Original file line number Diff line number Diff line change
@@ -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"