From c85791f1de9736d78e4a7f1f765a241ec605415f Mon Sep 17 00:00:00 2001 From: Entr0zy <267706715+Entr0zy@users.noreply.github.com> Date: Sun, 24 May 2026 05:15:02 +0100 Subject: [PATCH] feat(digest): add weekly spending digest service and routes (#121) Implements the weekly digest feature requested in issue #121. ## What changed ### New: packages/backend/app/services/digest.py - build_weekly_digest(uid, reference_date) - aggregates the past 7 days of EXPENSE transactions: total spend, category breakdown (top 3), week-over-week % change vs the prior 7-day window, and the largest single expense. - render_digest_html(data) - renders digest data into a styled HTML email (inline CSS, no extra template-engine dependency). - send_weekly_digest(user) - builds and emails a digest to one user. - send_all_weekly_digests() - fan-out over all users. - init_digest_scheduler(app) - APScheduler job every Monday at 08:00. ### New: packages/backend/app/routes/digest.py - GET /digest/preview - returns digest JSON for the authenticated user. - POST /digest/send-test - immediately emails the digest (202 if SMTP not configured). ### Modified: routes/__init__.py and app/__init__.py - Registers blueprint and initialises scheduler on startup. ### New: packages/backend/tests/test_digest.py - 14 tests: aggregation, income exclusion, 7-day boundary, WoW change, top-3 cap, send-test, JWT protection, HTML renderer. Co-Authored-By: Claude Sonnet 4.6 --- packages/backend/app/__init__.py | 6 + packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/digest.py | 50 ++++ packages/backend/app/services/digest.py | 347 ++++++++++++++++++++++++ packages/backend/tests/test_digest.py | 225 +++++++++++++++ 5 files changed, 630 insertions(+) create mode 100644 packages/backend/app/routes/digest.py create mode 100644 packages/backend/app/services/digest.py create mode 100644 packages/backend/tests/test_digest.py diff --git a/packages/backend/app/__init__.py b/packages/backend/app/__init__.py index cdf76b45f..c6f08a550 100644 --- a/packages/backend/app/__init__.py +++ b/packages/backend/app/__init__.py @@ -52,6 +52,12 @@ def create_app(settings: Settings | None = None) -> Flask: # Blueprint routes register_routes(app) + # Weekly digest scheduler – skipped when TESTING=true or DISABLE_SCHEDULER env var is set + import os as _os + if not _os.environ.get("TESTING") and not _os.environ.get("DISABLE_SCHEDULER"): + from .services.digest import init_digest_scheduler + init_digest_scheduler(app) + # Backward-compatible schema patch for existing databases. with app.app_context(): _ensure_schema_compatibility(app) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..4eb8ee8ec 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .digest import bp as digest_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(digest_bp, url_prefix="/digest") diff --git a/packages/backend/app/routes/digest.py b/packages/backend/app/routes/digest.py new file mode 100644 index 000000000..b4ef2a76b --- /dev/null +++ b/packages/backend/app/routes/digest.py @@ -0,0 +1,50 @@ +"""Digest routes. + +GET /digest/preview – return this week's digest data as JSON (auth required) +POST /digest/send-test – immediately email the digest to the authenticated user +""" +from flask import Blueprint, jsonify +from flask_jwt_extended import get_jwt_identity, jwt_required +import logging + +from ..extensions import db +from ..models import User +from ..services.digest import build_weekly_digest, send_weekly_digest + +bp = Blueprint("digest", __name__) +logger = logging.getLogger("finmind.digest") + + +@bp.get("/preview") +@jwt_required() +def preview_digest(): + """Return the current week's digest data as JSON for the authenticated user.""" + uid = int(get_jwt_identity()) + data = build_weekly_digest(uid) + logger.info("Digest preview requested by user=%s", uid) + return jsonify(data), 200 + + +@bp.post("/send-test") +@jwt_required() +def send_test_digest(): + """Immediately send a digest email to the authenticated user. + + Returns 200 when the email was dispatched via SMTP, or 202 when SMTP + is not configured (digest was built but not sent). + """ + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="user not found"), 404 + + ok = send_weekly_digest(user) + if ok: + logger.info("Test digest sent to user=%s (%s)", uid, user.email) + return jsonify(message="digest sent", email=user.email), 200 + + logger.info("Test digest built but not sent (SMTP unconfigured) user=%s", uid) + return jsonify( + message="digest built but not sent — configure SMTP_URL to enable email delivery", + email=user.email, + ), 202 diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py new file mode 100644 index 000000000..453539722 --- /dev/null +++ b/packages/backend/app/services/digest.py @@ -0,0 +1,347 @@ +"""Weekly digest service. + +Aggregates the past 7 days of transactions per user and sends a +formatted HTML summary email. The scheduler job runs every Monday +at 08:00 server time via APScheduler. + +Public API +---------- +build_weekly_digest(uid, reference_date=None) -> dict +render_digest_html(data) -> str +send_weekly_digest(user) -> bool +send_all_weekly_digests() -> {"sent": int, "failed": int} +init_digest_scheduler(app) -> None +""" +from __future__ import annotations + +import logging +import re +import smtplib +from datetime import date, timedelta +from email.message import EmailMessage +from typing import Any + +from ..extensions import db +from ..models import Category, Expense, User + +logger = logging.getLogger("finmind.digest") + +# --------------------------------------------------------------------------- +# HTML template (single-file; no extra template-engine dependency) +# --------------------------------------------------------------------------- +_DIGEST_HTML = """\ + + + + + + + + +

📊 Your Weekly FinMind Digest

+

{date_range}

+ +
+
+
{currency} {total_spent:.2f}
+
Total Spent
+
+
+
{wow_sign}{wow_abs:.1f}%
+
vs Previous Week
+
+
+
{num_transactions}
+
Transactions
+
+
+ +

Top Categories

+{category_table} + +{largest_section} + + + + +""" + + +# --------------------------------------------------------------------------- +# Data layer helpers +# --------------------------------------------------------------------------- + +def _week_expenses(uid: int, week_start: date) -> list[Expense]: + """Return EXPENSE rows for *uid* within the 7-day window starting at *week_start*.""" + week_end = week_start + timedelta(days=6) + return ( + db.session.query(Expense) + .filter( + Expense.user_id == uid, + Expense.expense_type != "INCOME", + Expense.spent_at >= week_start, + Expense.spent_at <= week_end, + ) + .order_by(Expense.spent_at.desc()) + .all() + ) + + +def _category_name(cat_id: int | None) -> str: + if cat_id is None: + return "Uncategorised" + cat = db.session.get(Category, cat_id) + return cat.name if cat else f"Category {cat_id}" + + +# --------------------------------------------------------------------------- +# Public: build digest data +# --------------------------------------------------------------------------- + +def build_weekly_digest(uid: int, reference_date: date | None = None) -> dict[str, Any]: + """Build a digest dict for the 7 days ending on *reference_date* (default: today). + + Returns a plain dict that can be serialised to JSON or passed to + :func:`render_digest_html`. + """ + today = reference_date or date.today() + week_start = today - timedelta(days=6) + prev_start = week_start - timedelta(days=7) + + current_expenses = _week_expenses(uid, week_start) + previous_expenses = _week_expenses(uid, prev_start) + + total_current = sum(float(e.amount) for e in current_expenses) + total_previous = sum(float(e.amount) for e in previous_expenses) + + if total_previous > 0: + wow_change = round( + ((total_current - total_previous) / total_previous) * 100, 1 + ) + else: + wow_change = 0.0 + + # Category breakdown + cat_totals: dict[int | None, float] = {} + for e in current_expenses: + cat_totals[e.category_id] = cat_totals.get(e.category_id, 0.0) + float(e.amount) + + categories = sorted( + [ + { + "category_id": k, + "name": _category_name(k), + "amount": round(v, 2), + } + for k, v in cat_totals.items() + ], + key=lambda x: x["amount"], + reverse=True, + ) + + largest = ( + max(current_expenses, key=lambda e: float(e.amount)) + if current_expenses + else None + ) + + user = db.session.get(User, uid) + currency = (user.preferred_currency if user else None) or "INR" + + return { + "user_id": uid, + "currency": currency, + "date_range": f"{week_start.isoformat()} – {today.isoformat()}", + "week_start": week_start.isoformat(), + "week_end": today.isoformat(), + "total_spent": round(total_current, 2), + "total_previous_week": round(total_previous, 2), + "wow_change_pct": wow_change, + "num_transactions": len(current_expenses), + "top_categories": categories[:3], + "all_categories": categories, + "largest_expense": { + "amount": round(float(largest.amount), 2), + "notes": largest.notes or "", + "date": largest.spent_at.isoformat(), + } + if largest + else None, + } + + +# --------------------------------------------------------------------------- +# Public: render HTML +# --------------------------------------------------------------------------- + +def render_digest_html(data: dict[str, Any]) -> str: + """Render a digest data dict into an HTML email string.""" + currency = data["currency"] + total = data["total_spent"] or 1.0 + wow = data["wow_change_pct"] + + if wow > 0: + wow_class, wow_sign, wow_abs = "up", "+", wow + elif wow < 0: + wow_class, wow_sign, wow_abs = "down", "-", abs(wow) + else: + wow_class, wow_sign, wow_abs = "flat", "", 0.0 + + # Category table rows + rows = "" + for cat in data["top_categories"]: + share = round(cat["amount"] / total * 100, 1) + rows += ( + f"" + f"{cat['name']}" + f"{cat['amount']:.2f}" + f"{share}%" + f"\n" + ) + category_table = ( + f"" + f"{rows}
Category{currency}Share
" + if rows + else "

No expenses recorded this week.

" + ) + + # Largest expense block + largest = data.get("largest_expense") + if largest: + desc = largest["notes"] or "No description" + largest_section = ( + "

Largest Single Expense

" + f"

{currency} {largest['amount']:.2f}" + f" — {desc} ({largest['date']})

" + ) + else: + largest_section = "" + + return _DIGEST_HTML.format( + date_range=data["date_range"], + currency=currency, + total_spent=data["total_spent"], + wow_class=wow_class, + wow_sign=wow_sign, + wow_abs=wow_abs, + num_transactions=data["num_transactions"], + category_table=category_table, + largest_section=largest_section, + ) + + +# --------------------------------------------------------------------------- +# Public: send helpers +# --------------------------------------------------------------------------- + +def _send_html_email(to_email: str, subject: str, html_body: str) -> bool: + """Send an HTML email via SMTP. Returns False if SMTP is not configured.""" + from ..config import Settings + + cfg = Settings() + if not cfg.smtp_url or not cfg.email_from: + logger.warning("SMTP not configured; digest not sent to %s", to_email) + return False + try: + m = re.match(r"smtp\+ssl://(.+?):(.+?)@(.+?):(\d+)", cfg.smtp_url) + if not m: + logger.error("Unparseable SMTP_URL: %s", cfg.smtp_url) + return False + user, pwd, host, port = m.groups() + msg = EmailMessage() + msg["From"] = cfg.email_from + msg["To"] = to_email + msg["Subject"] = subject + msg.set_content("Please view this email in an HTML-capable mail client.") + msg.add_alternative(html_body, subtype="html") + with smtplib.SMTP_SSL(host, int(port)) as s: + s.login(user, pwd) + s.send_message(msg) + logger.info("Digest sent to %s", to_email) + return True + except Exception: + logger.exception("SMTP error sending digest to %s", to_email) + return False + + +def send_weekly_digest(user: User) -> bool: + """Build and email the weekly digest for *user*. Returns True on success.""" + try: + data = build_weekly_digest(user.id) + html = render_digest_html(data) + subject = f"\U0001f4ca Your weekly spending digest ({data['date_range']})" + return _send_html_email(user.email, subject, html) + except Exception: + logger.exception("Failed to build/send digest for user %s", user.id) + return False + + +def send_all_weekly_digests() -> dict[str, int]: + """Iterate all users and send each a weekly digest. + + Returns a summary dict ``{"sent": N, "failed": M}``. + """ + users = db.session.query(User).all() + sent = failed = 0 + for user in users: + if send_weekly_digest(user): + sent += 1 + else: + failed += 1 + logger.info("Weekly digest run complete: sent=%d failed=%d", sent, failed) + return {"sent": sent, "failed": failed} + + +# --------------------------------------------------------------------------- +# Public: scheduler init +# --------------------------------------------------------------------------- + +def init_digest_scheduler(app) -> None: + """Register the weekly digest APScheduler job inside *app*. + + Called once from :func:`app.create_app`. The job fires every Monday + at 08:00 local server time. + """ + try: + from apscheduler.schedulers.background import BackgroundScheduler + + scheduler = BackgroundScheduler(daemon=True) + + def _job() -> None: + with app.app_context(): + send_all_weekly_digests() + + scheduler.add_job( + _job, + trigger="cron", + day_of_week="mon", + hour=8, + minute=0, + id="weekly_digest", + replace_existing=True, + ) + scheduler.start() + app.extensions["digest_scheduler"] = scheduler + logger.info("Weekly digest scheduler started (cron: Monday 08:00)") + except Exception: + logger.exception("Could not start digest scheduler") diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 000000000..6fa53b974 --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,225 @@ +"""Tests for the weekly digest service and routes.""" +from datetime import date, timedelta + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _add_expense(client, auth_header, amount, days_ago=0, notes="test expense"): + spent = (date.today() - timedelta(days=days_ago)).isoformat() + r = client.post( + "/expenses", + json={ + "amount": amount, + "description": notes, + "date": spent, + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201, r.get_json() + return r + + +# --------------------------------------------------------------------------- +# Preview endpoint +# --------------------------------------------------------------------------- + +def test_digest_preview_empty(client, auth_header): + """Preview with no expenses returns zero totals.""" + r = client.get("/digest/preview", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["total_spent"] == 0.0 + assert data["num_transactions"] == 0 + assert data["top_categories"] == [] + assert data["largest_expense"] is None + assert "date_range" in data + assert "wow_change_pct" in data + + +def test_digest_preview_with_expenses(client, auth_header): + """Preview aggregates this week's expenses correctly.""" + _add_expense(client, auth_header, 100, days_ago=1, notes="Grocery run") + _add_expense(client, auth_header, 50, days_ago=2, notes="Bus pass") + _add_expense(client, auth_header, 200, days_ago=3, notes="Monthly rent") + + r = client.get("/digest/preview", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + + assert data["total_spent"] == 350.0 + assert data["num_transactions"] == 3 + assert data["largest_expense"]["amount"] == 200.0 + assert data["largest_expense"]["notes"] == "Monthly rent" + + +def test_digest_preview_excludes_income(client, auth_header): + """Income transactions must not be included in the digest totals.""" + _add_expense(client, auth_header, 500, days_ago=0, notes="Salary") + r_income = client.post( + "/expenses", + json={ + "amount": 500, + "description": "Salary", + "date": date.today().isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + assert r_income.status_code == 201 + + r = client.get("/digest/preview", headers=auth_header) + data = r.get_json() + # Only the EXPENSE record should be counted + assert data["total_spent"] == 500.0 + assert data["num_transactions"] == 1 + + +def test_digest_preview_excludes_old_expenses(client, auth_header): + """Expenses older than 7 days must not appear in the current digest.""" + _add_expense(client, auth_header, 999, days_ago=8, notes="Old expense") + _add_expense(client, auth_header, 50, days_ago=1, notes="Recent expense") + + r = client.get("/digest/preview", headers=auth_header) + data = r.get_json() + assert data["total_spent"] == 50.0 + assert data["num_transactions"] == 1 + + +def test_digest_wow_change_zero_when_no_previous(client, auth_header): + """Week-over-week change is 0 when there are no prior-week expenses.""" + _add_expense(client, auth_header, 100, days_ago=0) + + r = client.get("/digest/preview", headers=auth_header) + data = r.get_json() + assert data["wow_change_pct"] == 0.0 + + +def test_digest_wow_change_calculated(client, auth_header): + """Week-over-week change reflects spend vs the previous 7-day window.""" + # Previous week: spend 100 + _add_expense(client, auth_header, 100, days_ago=10, notes="Last week") + # Current week: spend 150 → +50 % + _add_expense(client, auth_header, 150, days_ago=2, notes="This week") + + r = client.get("/digest/preview", headers=auth_header) + data = r.get_json() + assert data["wow_change_pct"] == 50.0 + + +def test_digest_top_categories_capped_at_three(client, auth_header): + """top_categories contains at most 3 entries.""" + for i, amount in enumerate([10, 20, 30, 40, 50], start=1): + _add_expense(client, auth_header, amount, days_ago=i % 6, notes=f"Item {i}") + + r = client.get("/digest/preview", headers=auth_header) + data = r.get_json() + assert len(data["top_categories"]) <= 3 + + +# --------------------------------------------------------------------------- +# Send-test endpoint +# --------------------------------------------------------------------------- + +def test_digest_send_test_no_smtp(client, auth_header): + """send-test returns 202 when SMTP is not configured (test environment).""" + r = client.post("/digest/send-test", headers=auth_header) + # 200 = sent, 202 = built but SMTP not configured + assert r.status_code in (200, 202) + body = r.get_json() + assert "email" in body + assert "message" in body + + +def test_digest_send_test_requires_auth(client): + """send-test must be protected by JWT.""" + r = client.post("/digest/send-test") + assert r.status_code == 401 + + +def test_digest_preview_requires_auth(client): + """preview must be protected by JWT.""" + r = client.get("/digest/preview") + assert r.status_code == 401 + + +# --------------------------------------------------------------------------- +# HTML renderer (unit test — no DB needed) +# --------------------------------------------------------------------------- + +def test_digest_html_render_basic(): + from app.services.digest import render_digest_html + + data = { + "currency": "INR", + "date_range": "2026-05-18 – 2026-05-24", + "total_spent": 350.0, + "wow_change_pct": 12.5, + "num_transactions": 3, + "top_categories": [ + {"name": "Food", "amount": 200.0}, + {"name": "Transport", "amount": 150.0}, + ], + "largest_expense": { + "amount": 200.0, + "notes": "Restaurant dinner", + "date": "2026-05-22", + }, + } + html = render_digest_html(data) + assert "350.00" in html + assert "Food" in html + assert "+12.5%" in html + assert "Restaurant dinner" in html + assert "INR" in html + + +def test_digest_html_render_no_expenses(): + from app.services.digest import render_digest_html + + data = { + "currency": "USD", + "date_range": "2026-05-18 – 2026-05-24", + "total_spent": 0.0, + "wow_change_pct": 0.0, + "num_transactions": 0, + "top_categories": [], + "largest_expense": None, + } + html = render_digest_html(data) + assert "No expenses recorded this week" in html + assert "0.00" in html + + +def test_digest_html_render_negative_wow(): + from app.services.digest import render_digest_html + + data = { + "currency": "GBP", + "date_range": "2026-05-18 – 2026-05-24", + "total_spent": 80.0, + "wow_change_pct": -20.0, + "num_transactions": 2, + "top_categories": [{"name": "Groceries", "amount": 80.0}], + "largest_expense": {"amount": 80.0, "notes": "Supermarket", "date": "2026-05-20"}, + } + html = render_digest_html(data) + assert "-20.0%" in html + assert "down" in html + + +# --------------------------------------------------------------------------- +# build_weekly_digest unit test +# --------------------------------------------------------------------------- + +def test_build_weekly_digest_returns_required_keys(client, auth_header): + r = client.get("/digest/preview", headers=auth_header) + data = r.get_json() + required = { + "user_id", "currency", "date_range", "week_start", "week_end", + "total_spent", "total_previous_week", "wow_change_pct", + "num_transactions", "top_categories", "all_categories", "largest_expense", + } + assert required.issubset(data.keys()), f"Missing keys: {required - data.keys()}"