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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions packages/backend/app/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions packages/backend/app/routes/insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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)
221 changes: 221 additions & 0 deletions packages/backend/app/services/weekly_digest.py
Original file line number Diff line number Diff line change
@@ -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
Loading