From a7b492e7bc64ab026acddbb3ddfbc9c16ade8a32 Mon Sep 17 00:00:00 2001 From: shaidshark Date: Thu, 2 Apr 2026 18:03:16 +0200 Subject: [PATCH 1/2] feat: smart weekly financial summary digest Implements weekly financial digests with: - Comprehensive weekly summary (income, expenses, net flow, savings rate) - Week-over-week trends analysis - Spending breakdown by category - Notable transactions highlights - Upcoming bills preview - AI-generated financial insights - Email delivery via existing SMTP infrastructure - Scheduled weekly generation (Sundays 9 AM UTC via APScheduler) New endpoints: - GET /digest/weekly - Get weekly digest data - POST /digest/weekly/send - Send digest email - GET /digest/weekly/preview - Preview email content - GET /scheduler/status - Check scheduler status CLI commands: - flask generate-digest --user-id ID --send-email Fixes #121 --- README.md | 38 ++ packages/backend/app/__init__.py | 41 ++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/digest.py | 151 +++++ packages/backend/app/services/digest.py | 661 +++++++++++++++++++++ packages/backend/app/services/scheduler.py | 113 ++++ packages/backend/tests/test_digest.py | 445 ++++++++++++++ 7 files changed, 1451 insertions(+) create mode 100644 packages/backend/app/routes/digest.py create mode 100644 packages/backend/app/services/digest.py create mode 100644 packages/backend/app/services/scheduler.py create mode 100644 packages/backend/tests/test_digest.py diff --git a/README.md b/README.md index 49592bff..f51f2701 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,41 @@ OpenAPI: `backend/app/openapi.yaml` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` - Insights: `/insights/monthly`, `/insights/budget-suggestion` +- Digest: `/digest/weekly`, `/digest/weekly/send`, `/digest/weekly/preview` + +## Weekly Financial Digest + +FinMind provides a **Smart Weekly Financial Summary** that delivers actionable insights directly to users' inboxes. + +### Features +- **Comprehensive Overview**: Total income, expenses, net flow, and savings rate for the week +- **Week-over-Week Trends**: Compare spending and income changes from the previous week +- **Category Breakdown**: Detailed spending analysis by category with percentages +- **Notable Transactions**: Highlights of significant expenses and income +- **Upcoming Bills**: Preview of bills due in the next 7 days +- **Smart Insights**: AI-generated financial tips and recommendations + +### Endpoints +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/digest/weekly` | GET | Get weekly digest data (JSON) | +| `/digest/weekly/send` | POST | Send digest email to current user | +| `/digest/weekly/preview` | GET | Preview email subject and body | + +### Scheduled Delivery +The weekly digest is automatically generated and sent to all users every **Sunday at 9:00 AM UTC** via APScheduler. + +### Manual Trigger (CLI) +```bash +# Generate digest for all users +flask generate-digest + +# Generate for specific user with email +flask generate-digest --user-id 1 --send-email +``` + +### Scheduler Status +Check scheduler status at `/scheduler/status` endpoint. ## MVP UI/UX Plan - Auth screens: register/login. @@ -104,11 +139,14 @@ finmind/ bills.py reminders.py insights.py + digest.py services/ __init__.py ai.py cache.py reminders.py + digest.py + scheduler.py db/ schema.sql openapi.yaml diff --git a/packages/backend/app/__init__.py b/packages/backend/app/__init__.py index cdf76b45..9ff63794 100644 --- a/packages/backend/app/__init__.py +++ b/packages/backend/app/__init__.py @@ -14,6 +14,14 @@ import logging from datetime import timedelta +# Scheduler imports +from .services.scheduler import ( + init_scheduler, + start_scheduler, + shutdown_scheduler, + get_scheduler_status, +) + def create_app(settings: Settings | None = None) -> Flask: app = Flask(__name__) @@ -56,6 +64,12 @@ def create_app(settings: Settings | None = None) -> Flask: with app.app_context(): _ensure_schema_compatibility(app) + # Initialize scheduler (but don't start automatically in testing) + if not app.config.get("TESTING"): + init_scheduler(app) + start_scheduler() + logger.info("Scheduler initialized and started") + @app.before_request def _before_request(): init_request_context() @@ -73,6 +87,11 @@ def metrics(): obs = app.extensions["observability"] return obs.metrics_response() + @app.get("/scheduler/status") + def scheduler_status(): + """Get the current status of scheduled jobs.""" + return jsonify(get_scheduler_status()), 200 + @app.errorhandler(500) def internal_error(_error): return jsonify(error="internal server error"), 500 @@ -93,6 +112,28 @@ def init_db(): finally: conn.close() + @app.cli.command("generate-digest") + @click.option("--user-id", type=int, default=None, help="Generate for specific user") + @click.option("--send-email", is_flag=True, help="Send email to user") + def generate_digest(user_id, send_email): + """Generate weekly digest for testing.""" + from .services.digest import WeeklyDigestService + + with app.app_context(): + if user_id: + week_start, week_end = WeeklyDigestService.get_week_bounds() + summary = WeeklyDigestService.generate_weekly_summary( + user_id, week_start, week_end + ) + if send_email: + success = WeeklyDigestService.send_digest_email(user_id, summary) + click.echo(f"Digest sent: {success}") + else: + click.echo(f"Digest generated: {summary}") + else: + results = WeeklyDigestService.generate_and_send_all_digests() + click.echo(f"Batch results: {results}") + return app diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..4eb8ee8e 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 00000000..5ec6e434 --- /dev/null +++ b/packages/backend/app/routes/digest.py @@ -0,0 +1,151 @@ +""" +Weekly Digest API Routes + +Endpoints for generating and retrieving weekly financial summaries. +""" + +from datetime import date, datetime +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..services.digest import WeeklyDigestService +from ..models import User +from ..extensions import db +import logging + +bp = Blueprint("digest", __name__) +logger = logging.getLogger("finmind.digest") + + +@bp.get("/weekly") +@jwt_required() +def get_weekly_digest(): + """ + Get the weekly financial digest for the current user. + + Query params: + week_start: Optional ISO date string (YYYY-MM-DD) for the start of the week. + Defaults to the current week's Monday. + + Returns: + JSON object containing: + - period: Week start/end dates + - summary: Income, expenses, net flow, savings rate + - trends: Week-over-week changes + - spending_by_category: Category breakdown with percentages + - notable_transactions: Top transactions by amount + - upcoming_bills: Bills due in the next 7 days + - insights: Actionable financial insights + """ + uid = int(get_jwt_identity()) + + # Parse optional week_start parameter + week_start_param = request.args.get("week_start", "").strip() + + try: + if week_start_param: + week_start = date.fromisoformat(week_start_param) + # Ensure week_start is a Monday + days_since_monday = week_start.weekday() + if days_since_monday != 0: + week_start = week_start - timedelta(days=days_since_monday) + week_end = week_start + timedelta(days=6) + else: + week_start, week_end = WeeklyDigestService.get_week_bounds() + except ValueError: + return jsonify(error="Invalid week_start format. Use YYYY-MM-DD."), 400 + + try: + summary = WeeklyDigestService.generate_weekly_summary( + uid, week_start, week_end + ) + logger.info("Weekly digest generated for user %s", uid) + return jsonify(summary) + + except Exception as e: + logger.error("Error generating weekly digest: %s", str(e)) + return jsonify(error="Failed to generate weekly digest"), 500 + + +@bp.post("/weekly/send") +@jwt_required() +def send_weekly_digest(): + """ + Send the weekly digest email to the current user. + + Returns: + JSON object with success status and message. + """ + uid = int(get_jwt_identity()) + + try: + user = db.session.get(User, uid) + if not user: + return jsonify(error="User not found"), 404 + + week_start, week_end = WeeklyDigestService.get_week_bounds() + summary = WeeklyDigestService.generate_weekly_summary( + uid, week_start, week_end + ) + + if summary["summary"]["transaction_count"] == 0: + return jsonify( + success=False, + message="No transactions this week. Email not sent." + ), 200 + + success = WeeklyDigestService.send_digest_email(uid, summary) + + if success: + logger.info("Weekly digest email sent to user %s", uid) + return jsonify( + success=True, + message="Weekly digest email sent successfully." + ), 200 + else: + return jsonify( + success=False, + message="Failed to send email. Please check your email settings." + ), 500 + + except Exception as e: + logger.error("Error sending weekly digest: %s", str(e)) + return jsonify(error="Failed to send weekly digest"), 500 + + +@bp.get("/weekly/preview") +@jwt_required() +def preview_weekly_digest(): + """ + Preview the weekly digest email content. + + Returns: + JSON object with subject and body of the email. + """ + uid = int(get_jwt_identity()) + + try: + user = db.session.get(User, uid) + if not user: + return jsonify(error="User not found"), 404 + + week_start, week_end = WeeklyDigestService.get_week_bounds() + summary = WeeklyDigestService.generate_weekly_summary( + uid, week_start, week_end + ) + + subject, body = WeeklyDigestService.format_digest_email(summary, user) + + return jsonify( + subject=subject, + body=body, + summary=summary, + ), 200 + + except Exception as e: + logger.error("Error previewing weekly digest: %s", str(e)) + return jsonify(error="Failed to preview weekly digest"), 500 + + +# Import timedelta at module level +from datetime import timedelta diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py new file mode 100644 index 00000000..bdedcc49 --- /dev/null +++ b/packages/backend/app/services/digest.py @@ -0,0 +1,661 @@ +""" +Weekly Financial Digest Service + +Generates smart weekly summaries with spending trends, income analysis, +notable transactions, and savings rate calculations. +""" + +from datetime import date, timedelta, datetime +from typing import Any +from sqlalchemy import extract, func, and_, or_ +from flask import current_app +import logging + +from ..extensions import db +from ..models import Expense, Category, Bill, User +from ..services.reminders import send_email + +logger = logging.getLogger("finmind.digest") + + +class WeeklyDigestService: + """Service for generating and delivering weekly financial digests.""" + + @staticmethod + def get_week_bounds(reference_date: date | None = None) -> tuple[date, date]: + """ + Get the start and end dates for the week containing reference_date. + Week runs from Monday to Sunday. + + Args: + reference_date: Date to get week bounds for. Defaults to today. + + Returns: + Tuple of (week_start, week_end) dates + """ + if reference_date is None: + reference_date = date.today() + + # Monday is weekday 0, Sunday is 6 + days_since_monday = reference_date.weekday() + week_start = reference_date - timedelta(days=days_since_monday) + week_end = week_start + timedelta(days=6) + + return week_start, week_end + + @staticmethod + def get_previous_week_bounds(reference_date: date | None = None) -> tuple[date, date]: + """ + Get the start and end dates for the previous week. + + Args: + reference_date: Reference date. Defaults to today. + + Returns: + Tuple of (week_start, week_end) dates for previous week + """ + if reference_date is None: + reference_date = date.today() + + current_week_start, _ = WeeklyDigestService.get_week_bounds(reference_date) + prev_week_end = current_week_start - timedelta(days=1) + prev_week_start = prev_week_end - timedelta(days=6) + + return prev_week_start, prev_week_end + + @staticmethod + def generate_weekly_summary( + user_id: int, + week_start: date | None = None, + week_end: date | None = None, + ) -> dict[str, Any]: + """ + Generate a comprehensive weekly financial summary for a user. + + Args: + user_id: User ID to generate summary for + week_start: Start date of the week (inclusive). Defaults to current week. + week_end: End date of the week (inclusive). Defaults to current week. + + Returns: + Dictionary containing weekly summary data + """ + if week_start is None or week_end is None: + week_start, week_end = WeeklyDigestService.get_week_bounds() + + # Get previous week for comparison + prev_week_start, prev_week_end = WeeklyDigestService.get_previous_week_bounds( + week_start + ) + + summary = { + "period": { + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + "generated_at": datetime.utcnow().isoformat(), + }, + "summary": { + "total_income": 0.0, + "total_expenses": 0.0, + "net_flow": 0.0, + "savings_rate": 0.0, + "transaction_count": 0, + }, + "trends": { + "income_change": 0.0, + "expense_change": 0.0, + "income_change_pct": 0.0, + "expense_change_pct": 0.0, + }, + "spending_by_category": [], + "notable_transactions": [], + "upcoming_bills": [], + "insights": [], + } + + # Current week data + current_income, current_expenses, current_count = ( + WeeklyDigestService._get_week_financials(user_id, week_start, week_end) + ) + + # Previous week data for trends + prev_income, prev_expenses, _ = WeeklyDigestService._get_week_financials( + user_id, prev_week_start, prev_week_end + ) + + # Populate summary + summary["summary"]["total_income"] = float(current_income) + summary["summary"]["total_expenses"] = float(current_expenses) + summary["summary"]["net_flow"] = float(current_income - current_expenses) + summary["summary"]["transaction_count"] = current_count + + # Calculate savings rate + if current_income > 0: + savings_rate = ((current_income - current_expenses) / current_income) * 100 + summary["summary"]["savings_rate"] = round(savings_rate, 2) + + # Calculate trends + summary["trends"]["income_change"] = float(current_income - prev_income) + summary["trends"]["expense_change"] = float(current_expenses - prev_expenses) + + if prev_income > 0: + summary["trends"]["income_change_pct"] = round( + ((current_income - prev_income) / prev_income) * 100, 2 + ) + + if prev_expenses > 0: + summary["trends"]["expense_change_pct"] = round( + ((current_expenses - prev_expenses) / prev_expenses) * 100, 2 + ) + + # Spending by category + summary["spending_by_category"] = WeeklyDigestService._get_category_breakdown( + user_id, week_start, week_end + ) + + # Notable transactions (largest expenses and incomes) + summary["notable_transactions"] = WeeklyDigestService._get_notable_transactions( + user_id, week_start, week_end, limit=5 + ) + + # Upcoming bills for next week + summary["upcoming_bills"] = WeeklyDigestService._get_upcoming_bills( + user_id, week_end + timedelta(days=1), days_ahead=7 + ) + + # Generate insights + summary["insights"] = WeeklyDigestService._generate_insights(summary) + + return summary + + @staticmethod + def _get_week_financials( + user_id: int, week_start: date, week_end: date + ) -> tuple[float, float, int]: + """Get total income, expenses, and transaction count for a week.""" + try: + # Income + income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= week_start, + Expense.spent_at <= week_end, + Expense.expense_type == "INCOME", + ) + .scalar() or 0 + ) + + # Expenses + expenses = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= week_start, + Expense.spent_at <= week_end, + Expense.expense_type != "INCOME", + ) + .scalar() or 0 + ) + + # Transaction count + count = ( + db.session.query(func.count(Expense.id)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= week_start, + Expense.spent_at <= week_end, + ) + .scalar() or 0 + ) + + return float(income), float(expenses), int(count) + + except Exception as e: + logger.error("Error getting week financials: %s", str(e)) + return 0.0, 0.0, 0 + + @staticmethod + def _get_category_breakdown( + user_id: int, week_start: date, week_end: date + ) -> list[dict[str, Any]]: + """Get spending breakdown by category for the week.""" + try: + rows = ( + db.session.query( + Expense.category_id, + func.coalesce(Category.name, "Uncategorized").label("category_name"), + func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), + func.count(Expense.id).label("transaction_count"), + ) + .outerjoin( + Category, + (Category.id == Expense.category_id) + & (Category.user_id == user_id), + ) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= week_start, + Expense.spent_at <= week_end, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.category_id, Category.name) + .order_by(func.sum(Expense.amount).desc()) + .all() + ) + + total = sum(float(r.total_amount or 0) for r in rows) + + return [ + { + "category_id": r.category_id, + "category_name": r.category_name, + "amount": float(r.total_amount or 0), + "transaction_count": int(r.transaction_count or 0), + "share_pct": ( + round((float(r.total_amount or 0) / total) * 100, 2) + if total > 0 + else 0 + ), + } + for r in rows + ] + + except Exception as e: + logger.error("Error getting category breakdown: %s", str(e)) + return [] + + @staticmethod + def _get_notable_transactions( + user_id: int, week_start: date, week_end: date, limit: int = 5 + ) -> list[dict[str, Any]]: + """Get the most significant transactions (largest amounts).""" + try: + # Get largest expenses + expenses = ( + db.session.query(Expense, Category.name) + .outerjoin( + Category, + (Category.id == Expense.category_id) + & (Category.user_id == user_id), + ) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= week_start, + Expense.spent_at <= week_end, + Expense.expense_type != "INCOME", + ) + .order_by(Expense.amount.desc()) + .limit(limit) + .all() + ) + + # Get largest incomes + incomes = ( + db.session.query(Expense, Category.name) + .outerjoin( + Category, + (Category.id == Expense.category_id) + & (Category.user_id == user_id), + ) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= week_start, + Expense.spent_at <= week_end, + Expense.expense_type == "INCOME", + ) + .order_by(Expense.amount.desc()) + .limit(limit) + .all() + ) + + notable = [] + + for exp, cat_name in expenses: + notable.append({ + "id": exp.id, + "type": "EXPENSE", + "amount": float(exp.amount), + "description": exp.notes or "Expense", + "category": cat_name or "Uncategorized", + "date": exp.spent_at.isoformat(), + "currency": exp.currency, + }) + + for inc, cat_name in incomes: + notable.append({ + "id": inc.id, + "type": "INCOME", + "amount": float(inc.amount), + "description": inc.notes or "Income", + "category": cat_name or "Uncategorized", + "date": inc.spent_at.isoformat(), + "currency": inc.currency, + }) + + # Sort by amount (absolute value) and return top transactions + notable.sort(key=lambda x: abs(x["amount"]), reverse=True) + return notable[:limit] + + except Exception as e: + logger.error("Error getting notable transactions: %s", str(e)) + return [] + + @staticmethod + def _get_upcoming_bills( + user_id: int, start_date: date, days_ahead: int = 7 + ) -> list[dict[str, Any]]: + """Get upcoming bills for the next N days.""" + try: + end_date = start_date + timedelta(days=days_ahead) + + bills = ( + db.session.query(Bill) + .filter( + Bill.user_id == user_id, + Bill.active.is_(True), + Bill.next_due_date >= start_date, + Bill.next_due_date <= end_date, + ) + .order_by(Bill.next_due_date.asc()) + .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 - date.today()).days, + "cadence": b.cadence.value, + } + for b in bills + ] + + except Exception as e: + logger.error("Error getting upcoming bills: %s", str(e)) + return [] + + @staticmethod + def _generate_insights(summary: dict[str, Any]) -> list[str]: + """Generate actionable insights based on the weekly summary.""" + insights = [] + + # Savings rate insights + savings_rate = summary["summary"]["savings_rate"] + if savings_rate >= 30: + insights.append( + f"Excellent savings rate of {savings_rate:.1f}%! " + "You're on track for strong financial growth." + ) + elif savings_rate >= 20: + insights.append( + f"Good savings rate of {savings_rate:.1f}%. " + "Keep it up to build your financial cushion." + ) + elif savings_rate >= 10: + insights.append( + f"Your savings rate is {savings_rate:.1f}%. " + "Consider reducing discretionary spending to improve." + ) + elif savings_rate > 0: + insights.append( + f"Savings rate is low at {savings_rate:.1f}%. " + "Review your spending categories for potential cuts." + ) + else: + insights.append( + "Your expenses exceeded income this week. " + "Review your notable transactions for potential savings." + ) + + # Spending trend insights + expense_change_pct = summary["trends"]["expense_change_pct"] + if expense_change_pct > 20: + insights.append( + f"Spending increased {expense_change_pct:.1f}% compared to last week. " + "Check your category breakdown for areas to cut back." + ) + elif expense_change_pct < -20: + insights.append( + f"Great job! Spending decreased {abs(expense_change_pct):.1f}% " + "from last week. Keep up the good work!" + ) + + # Income trend insights + income_change_pct = summary["trends"]["income_change_pct"] + if income_change_pct > 10: + insights.append( + f"Income is up {income_change_pct:.1f}% from last week." + ) + elif income_change_pct < -10: + insights.append( + f"Income is down {abs(income_change_pct):.1f}% from last week. " + "Plan accordingly if this trend continues." + ) + + # Top category insight + if summary["spending_by_category"]: + top_category = summary["spending_by_category"][0] + insights.append( + f"Your highest spending category was '{top_category['category_name']}' " + f"at {top_category['amount']:.2f} ({top_category['share_pct']:.1f}% of total)." + ) + + # Upcoming bills insight + upcoming_bills = summary["upcoming_bills"] + if upcoming_bills: + total_due = sum(b["amount"] for b in upcoming_bills) + insights.append( + f"You have {len(upcoming_bills)} bill(s) totaling " + f"{total_due:.2f} due in the next 7 days." + ) + + return insights + + @staticmethod + def format_digest_email( + summary: dict[str, Any], user: User + ) -> tuple[str, str]: + """ + Format the digest as email subject and body. + + Args: + summary: Weekly summary dictionary + user: User object + + Returns: + Tuple of (subject, body) + """ + week_start = summary["period"]["week_start"] + week_end = summary["period"]["week_end"] + + subject = f"Your Weekly Financial Summary ({week_start} to {week_end})" + + lines = [ + f"Hello {user.email},", + "", + f"Here's your weekly financial summary for {week_start} to {week_end}:", + "", + "═══════════════════════════════════════════════════", + "OVERVIEW", + "═══════════════════════════════════════════════════", + f" Total Income: ${summary['summary']['total_income']:,.2f}", + f" Total Expenses: ${summary['summary']['total_expenses']:,.2f}", + f" Net Flow: ${summary['summary']['net_flow']:,.2f}", + f" Savings Rate: {summary['summary']['savings_rate']:.1f}%", + f" Transactions: {summary['summary']['transaction_count']}", + "", + "═══════════════════════════════════════════════════", + "WEEK OVER WEEK TRENDS", + "═══════════════════════════════════════════════════", + ] + + income_change = summary["trends"]["income_change"] + income_change_pct = summary["trends"]["income_change_pct"] + income_emoji = "↑" if income_change >= 0 else "↓" + lines.append( + f" Income: {income_emoji} ${abs(income_change):,.2f} " + f"({income_change_pct:+.1f}%)" + ) + + expense_change = summary["trends"]["expense_change"] + expense_change_pct = summary["trends"]["expense_change_pct"] + expense_emoji = "↓" if expense_change <= 0 else "↑" + lines.append( + f" Expenses: {expense_emoji} ${abs(expense_change):,.2f} " + f"({expense_change_pct:+.1f}%)" + ) + + lines.extend([ + "", + "═══════════════════════════════════════════════════", + "SPENDING BY CATEGORY", + "═══════════════════════════════════════════════════", + ]) + + for cat in summary["spending_by_category"][:5]: + lines.append( + f" • {cat['category_name']}: ${cat['amount']:,.2f} " + f"({cat['share_pct']:.1f}%)" + ) + + if summary["notable_transactions"]: + lines.extend([ + "", + "═══════════════════════════════════════════════════", + "NOTABLE TRANSACTIONS", + "═══════════════════════════════════════════════════", + ]) + + for tx in summary["notable_transactions"][:5]: + tx_emoji = "💰" if tx["type"] == "INCOME" else "💸" + lines.append( + f" {tx_emoji} {tx['description']}: ${tx['amount']:,.2f} ({tx['date']})" + ) + + if summary["upcoming_bills"]: + lines.extend([ + "", + "═══════════════════════════════════════════════════", + "UPCOMING BILLS", + "═══════════════════════════════════════════════════", + ]) + + for bill in summary["upcoming_bills"]: + days = bill["days_until_due"] + days_text = "today" if days == 0 else f"in {days} day(s)" + lines.append( + f" • {bill['name']}: ${bill['amount']:,.2f} (due {days_text})" + ) + + lines.extend([ + "", + "═══════════════════════════════════════════════════", + "INSIGHTS", + "═══════════════════════════════════════════════════", + ]) + + for insight in summary["insights"]: + lines.append(f" 💡 {insight}") + + lines.extend([ + "", + "───────────────────────────────────────────────────", + "Log in to FinMind to see more details and manage your finances.", + "", + "Best regards,", + "The FinMind Team", + ]) + + return subject, "\n".join(lines) + + @staticmethod + def send_digest_email(user_id: int, summary: dict[str, Any]) -> bool: + """ + Send the weekly digest email to a user. + + Args: + user_id: User ID to send to + summary: Weekly summary dictionary + + Returns: + True if email was sent successfully, False otherwise + """ + try: + user = db.session.get(User, user_id) + if not user: + logger.warning("User %s not found for digest email", user_id) + return False + + subject, body = WeeklyDigestService.format_digest_email(summary, user) + + # Use user's email + success = send_email(user.email, subject, body) + + if success: + logger.info("Weekly digest email sent to user %s", user_id) + else: + logger.warning("Failed to send weekly digest email to user %s", user_id) + + return success + + except Exception as e: + logger.error("Error sending digest email: %s", str(e)) + return False + + @staticmethod + def generate_and_send_all_digests() -> dict[str, Any]: + """ + Generate and send weekly digests to all users. + + This is typically called by the scheduled task. + + Returns: + Summary of digest generation results + """ + results = { + "total_users": 0, + "digests_generated": 0, + "emails_sent": 0, + "errors": [], + } + + try: + # Get all active users + users = db.session.query(User).all() + results["total_users"] = len(users) + + week_start, week_end = WeeklyDigestService.get_week_bounds() + + for user in users: + try: + summary = WeeklyDigestService.generate_weekly_summary( + user.id, week_start, week_end + ) + results["digests_generated"] += 1 + + # Only send email if user has transactions this week + if summary["summary"]["transaction_count"] > 0: + if WeeklyDigestService.send_digest_email(user.id, summary): + results["emails_sent"] += 1 + + except Exception as e: + error_msg = f"Error processing user {user.id}: {str(e)}" + logger.error(error_msg) + results["errors"].append(error_msg) + + logger.info( + "Weekly digest batch complete: %d generated, %d emails sent", + results["digests_generated"], + results["emails_sent"], + ) + + except Exception as e: + error_msg = f"Error in digest batch: {str(e)}" + logger.error(error_msg) + results["errors"].append(error_msg) + + return results diff --git a/packages/backend/app/services/scheduler.py b/packages/backend/app/services/scheduler.py new file mode 100644 index 00000000..a5ff2c31 --- /dev/null +++ b/packages/backend/app/services/scheduler.py @@ -0,0 +1,113 @@ +""" +Scheduler Service for Periodic Tasks + +Uses APScheduler to run periodic tasks like weekly digest generation. +""" + +import logging +from datetime import datetime +from flask import Flask +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_ERROR + +from .digest import WeeklyDigestService + +logger = logging.getLogger("finmind.scheduler") + +# Global scheduler instance +scheduler = BackgroundScheduler() + + +def weekly_digest_job(app: Flask): + """ + Job function to generate and send weekly digests to all users. + + This runs every Sunday at 9:00 AM UTC by default. + """ + with app.app_context(): + logger.info("Starting weekly digest generation job") + try: + results = WeeklyDigestService.generate_and_send_all_digests() + logger.info( + "Weekly digest job completed: %d users, %d digests, %d emails sent", + results["total_users"], + results["digests_generated"], + results["emails_sent"], + ) + if results["errors"]: + for error in results["errors"]: + logger.error("Digest job error: %s", error) + except Exception as e: + logger.exception("Weekly digest job failed: %s", str(e)) + + +def scheduler_event_listener(event): + """Listen to scheduler events for logging.""" + if event.exception: + logger.error( + "Scheduler job %s failed with exception: %s", + event.job_id, + event.exception, + ) + else: + logger.debug("Scheduler job %s completed successfully", event.job_id) + + +def init_scheduler(app: Flask): + """ + Initialize the scheduler with the Flask app. + + Configures weekly digest generation and other periodic tasks. + + Schedule: + - Weekly digest: Every Sunday at 9:00 AM UTC + """ + # Add event listener + scheduler.add_listener(scheduler_event_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR) + + # Add weekly digest job - runs every Sunday at 9:00 AM UTC + scheduler.add_job( + func=weekly_digest_job, + trigger=CronTrigger(day_of_week="sun", hour=9, minute=0, timezone="UTC"), + id="weekly_digest", + name="Weekly Financial Digest", + args=[app], + replace_existing=True, + misfire_grace_time=3600, # Allow 1 hour grace period for misfires + ) + + logger.info( + "Scheduler initialized with jobs: %s", + [job.id for job in scheduler.get_jobs()], + ) + + +def start_scheduler(): + """Start the scheduler if not already running.""" + if not scheduler.running: + scheduler.start() + logger.info("Scheduler started") + + +def shutdown_scheduler(): + """Shutdown the scheduler gracefully.""" + if scheduler.running: + scheduler.shutdown(wait=True) + logger.info("Scheduler shutdown complete") + + +def get_scheduler_status(): + """Get the current status of scheduled jobs.""" + jobs = [] + for job in scheduler.get_jobs(): + jobs.append({ + "id": job.id, + "name": job.name, + "next_run": job.next_run_time.isoformat() if job.next_run_time else None, + "trigger": str(job.trigger), + }) + return { + "running": scheduler.running, + "jobs": jobs, + } diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 00000000..8e54b532 --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,445 @@ +""" +Tests for Weekly Digest functionality +""" + +from datetime import date, timedelta +import pytest + + +class TestWeeklyDigestService: + """Tests for the WeeklyDigestService class.""" + + def test_get_week_bounds_returns_monday_to_sunday(self): + """Test that week bounds return Monday to Sunday.""" + from app.services.digest import WeeklyDigestService + + # Test with a Wednesday + wednesday = date(2024, 1, 10) # This is a Wednesday + week_start, week_end = WeeklyDigestService.get_week_bounds(wednesday) + + # Should return Monday Jan 8 to Sunday Jan 14 + assert week_start == date(2024, 1, 8) # Monday + assert week_end == date(2024, 1, 14) # Sunday + assert week_start.weekday() == 0 # Monday + assert week_end.weekday() == 6 # Sunday + + def test_get_week_bounds_with_monday_input(self): + """Test that Monday input returns the same week.""" + from app.services.digest import WeeklyDigestService + + monday = date(2024, 1, 8) # This is a Monday + week_start, week_end = WeeklyDigestService.get_week_bounds(monday) + + assert week_start == monday + assert week_end == date(2024, 1, 14) # Sunday + + def test_get_previous_week_bounds(self): + """Test getting previous week bounds.""" + from app.services.digest import WeeklyDigestService + + wednesday = date(2024, 1, 10) + prev_start, prev_end = WeeklyDigestService.get_previous_week_bounds(wednesday) + + # Previous week should be Jan 1-7 + assert prev_start == date(2024, 1, 1) # Monday + assert prev_end == date(2024, 1, 7) # Sunday + + def test_generate_weekly_summary_basic(self, app_fixture, auth_header): + """Test basic weekly summary generation.""" + from app.services.digest import WeeklyDigestService + from app.extensions import db + from app.models import User + + with app_fixture.app_context(): + # Get user ID from auth header setup + user = db.session.query(User).filter_by(email="test@example.com").first() + user_id = user.id + + # Create a category + client = app_fixture.test_client() + r = client.post( + "/categories", json={"name": "Food"}, headers=auth_header + ) + assert r.status_code == 201 + + # Add some transactions + today = date.today() + client.post( + "/expenses", + json={ + "amount": 1000, + "description": "Salary", + "date": today.isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + + client.post( + "/expenses", + json={ + "amount": 200, + "description": "Groceries", + "date": today.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + week_start, week_end = WeeklyDigestService.get_week_bounds() + summary = WeeklyDigestService.generate_weekly_summary( + user_id, week_start, week_end + ) + + assert "period" in summary + assert "summary" in summary + assert "trends" in summary + assert "spending_by_category" in summary + assert "notable_transactions" in summary + assert "upcoming_bills" in summary + assert "insights" in summary + + assert summary["summary"]["total_income"] == 1000.0 + assert summary["summary"]["total_expenses"] == 200.0 + assert summary["summary"]["net_flow"] == 800.0 + assert summary["summary"]["savings_rate"] == 80.0 + assert summary["summary"]["transaction_count"] == 2 + + def test_generate_weekly_summary_with_category_breakdown( + self, app_fixture, auth_header + ): + """Test category breakdown in weekly summary.""" + from app.services.digest import WeeklyDigestService + from app.extensions import db + from app.models import User + + with app_fixture.app_context(): + user = db.session.query(User).filter_by(email="test@example.com").first() + user_id = user.id + + client = app_fixture.test_client() + + # Create categories + r1 = client.post( + "/categories", json={"name": "Food"}, headers=auth_header + ) + r2 = client.post( + "/categories", json={"name": "Transport"}, headers=auth_header + ) + + food_id = r1.get_json()["id"] + transport_id = r2.get_json()["id"] + + today = date.today() + + # Add expenses in different categories + client.post( + "/expenses", + json={ + "amount": 300, + "description": "Groceries", + "date": today.isoformat(), + "expense_type": "EXPENSE", + "category_id": food_id, + }, + headers=auth_header, + ) + + client.post( + "/expenses", + json={ + "amount": 150, + "description": "Gas", + "date": today.isoformat(), + "expense_type": "EXPENSE", + "category_id": transport_id, + }, + headers=auth_header, + ) + + week_start, week_end = WeeklyDigestService.get_week_bounds() + summary = WeeklyDigestService.generate_weekly_summary( + user_id, week_start, week_end + ) + + category_breakdown = summary["spending_by_category"] + assert len(category_breakdown) == 2 + + # Check total equals sum of categories + total_from_categories = sum(c["amount"] for c in category_breakdown) + assert total_from_categories == 450.0 + + # Check percentages add up to 100 + total_pct = sum(c["share_pct"] for c in category_breakdown) + assert abs(total_pct - 100.0) < 0.1 + + def test_generate_weekly_summary_with_upcoming_bills( + self, app_fixture, auth_header + ): + """Test upcoming bills in weekly summary.""" + from app.services.digest import WeeklyDigestService + from app.extensions import db + from app.models import User + + with app_fixture.app_context(): + user = db.session.query(User).filter_by(email="test@example.com").first() + user_id = user.id + + client = app_fixture.test_client() + + # Create a bill due in 3 days + due_date = date.today() + timedelta(days=3) + r = client.post( + "/bills", + json={ + "name": "Internet", + "amount": 49.99, + "next_due_date": due_date.isoformat(), + "cadence": "MONTHLY", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + week_start, week_end = WeeklyDigestService.get_week_bounds() + summary = WeeklyDigestService.generate_weekly_summary( + user_id, week_start, week_end + ) + + upcoming = summary["upcoming_bills"] + assert len(upcoming) >= 1 + assert any(b["name"] == "Internet" for b in upcoming) + + def test_generate_insights_high_savings_rate(self): + """Test insights generation with high savings rate.""" + from app.services.digest import WeeklyDigestService + + summary = { + "summary": { + "savings_rate": 35.0, + "total_income": 1000.0, + "total_expenses": 650.0, + "transaction_count": 5, + }, + "trends": { + "income_change_pct": 0.0, + "expense_change_pct": 0.0, + "income_change": 0.0, + "expense_change": 0.0, + }, + "spending_by_category": [ + {"category_name": "Food", "amount": 400.0, "share_pct": 61.5} + ], + "upcoming_bills": [], + } + + insights = WeeklyDigestService._generate_insights(summary) + + assert len(insights) > 0 + assert any("Excellent savings rate" in i for i in insights) + + def test_generate_insights_negative_savings_rate(self): + """Test insights generation when expenses exceed income.""" + from app.services.digest import WeeklyDigestService + + summary = { + "summary": { + "savings_rate": -10.0, + "total_income": 500.0, + "total_expenses": 550.0, + "transaction_count": 5, + }, + "trends": { + "income_change_pct": 0.0, + "expense_change_pct": 25.0, + "income_change": 0.0, + "expense_change": 100.0, + }, + "spending_by_category": [ + {"category_name": "Shopping", "amount": 400.0, "share_pct": 72.7} + ], + "upcoming_bills": [], + } + + insights = WeeklyDigestService._generate_insights(summary) + + assert any("exceeded income" in i.lower() for i in insights) + + def test_format_digest_email(self, app_fixture): + """Test email formatting.""" + from app.services.digest import WeeklyDigestService + from app.models import User + + with app_fixture.app_context(): + user = User( + email="test@example.com", + password_hash="hash", + preferred_currency="USD", + ) + + summary = { + "period": { + "week_start": "2024-01-08", + "week_end": "2024-01-14", + }, + "summary": { + "total_income": 1000.0, + "total_expenses": 400.0, + "net_flow": 600.0, + "savings_rate": 60.0, + "transaction_count": 5, + }, + "trends": { + "income_change": 100.0, + "expense_change": -50.0, + "income_change_pct": 11.1, + "expense_change_pct": -11.1, + }, + "spending_by_category": [ + {"category_name": "Food", "amount": 300.0, "share_pct": 75.0}, + {"category_name": "Transport", "amount": 100.0, "share_pct": 25.0}, + ], + "notable_transactions": [ + { + "type": "INCOME", + "description": "Salary", + "amount": 1000.0, + "date": "2024-01-08", + }, + { + "type": "EXPENSE", + "description": "Groceries", + "amount": 200.0, + "date": "2024-01-10", + }, + ], + "upcoming_bills": [ + {"name": "Internet", "amount": 49.99, "days_until_due": 3} + ], + "insights": ["Great savings rate!", "Income is up this week."], + } + + subject, body = WeeklyDigestService.format_digest_email(summary, user) + + assert "Weekly Financial Summary" in subject + assert "test@example.com" in body + assert "Total Income" in body + assert "$1,000.00" in body + assert "Food" in body + assert "Internet" in body + + +class TestDigestRoutes: + """Tests for digest API endpoints.""" + + def test_get_weekly_digest_unauthorized(self, client): + """Test that digest requires authentication.""" + r = client.get("/digest/weekly") + assert r.status_code == 401 + + def test_get_weekly_digest_authorized(self, client, auth_header): + """Test getting weekly digest with authentication.""" + r = client.get("/digest/weekly", headers=auth_header) + assert r.status_code == 200 + + data = r.get_json() + assert "period" in data + assert "summary" in data + assert "trends" in data + assert "spending_by_category" in data + + def test_get_weekly_digest_with_week_start(self, client, auth_header): + """Test getting digest for a specific week.""" + from datetime import date + + week_start = date.today() - timedelta(days=7) + # Adjust to Monday + days_since_monday = week_start.weekday() + week_start = week_start - timedelta(days=days_since_monday) + + r = client.get( + f"/digest/weekly?week_start={week_start.isoformat()}", + headers=auth_header, + ) + assert r.status_code == 200 + + def test_get_weekly_digest_invalid_date(self, client, auth_header): + """Test that invalid date format returns error.""" + r = client.get("/digest/weekly?week_start=invalid", headers=auth_header) + assert r.status_code == 400 + + def test_preview_weekly_digest(self, client, auth_header): + """Test digest preview endpoint.""" + r = client.get("/digest/weekly/preview", headers=auth_header) + assert r.status_code == 200 + + data = r.get_json() + assert "subject" in data + assert "body" in data + assert "summary" in data + + def test_send_weekly_digest_no_transactions(self, client, auth_header): + """Test sending digest when there are no transactions.""" + r = client.post("/digest/weekly/send", headers=auth_header) + assert r.status_code == 200 + + data = r.get_json() + assert data["success"] is False + assert "No transactions" in data["message"] + + def test_send_weekly_digest_with_transactions(self, client, auth_header): + """Test sending digest with transactions (email sending mocked).""" + # Add a transaction first + today = date.today() + client.post( + "/expenses", + json={ + "amount": 100, + "description": "Test", + "date": today.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + # Add income to have positive savings rate + client.post( + "/expenses", + json={ + "amount": 500, + "description": "Income", + "date": today.isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + + r = client.post("/digest/weekly/send", headers=auth_header) + assert r.status_code == 200 + + # Email will likely fail in test env, so we check for a response + data = r.get_json() + assert "success" in data + assert "message" in data + + +class TestScheduler: + """Tests for scheduler functionality.""" + + def test_scheduler_status_endpoint(self, client): + """Test scheduler status endpoint.""" + r = client.get("/scheduler/status") + assert r.status_code == 200 + + data = r.get_json() + assert "running" in data + assert "jobs" in data + + def test_get_scheduler_status(self): + """Test getting scheduler status.""" + from app.services.scheduler import get_scheduler_status + + status = get_scheduler_status() + assert "running" in status + assert "jobs" in status + assert isinstance(status["jobs"], list) From 18a3afb1747028cd7755433d7b657c5b78ccc573 Mon Sep 17 00:00:00 2001 From: shaidshark Date: Thu, 2 Apr 2026 23:41:49 +0200 Subject: [PATCH 2/2] fix: localize weekly digest currency display --- packages/backend/app/routes/digest.py | 5 +-- packages/backend/app/services/digest.py | 45 ++++++++++++++++++++----- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/backend/app/routes/digest.py b/packages/backend/app/routes/digest.py index 5ec6e434..9717d0f7 100644 --- a/packages/backend/app/routes/digest.py +++ b/packages/backend/app/routes/digest.py @@ -4,7 +4,7 @@ Endpoints for generating and retrieving weekly financial summaries. """ -from datetime import date, datetime +from datetime import date, datetime, timedelta from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity @@ -146,6 +146,3 @@ def preview_weekly_digest(): logger.error("Error previewing weekly digest: %s", str(e)) return jsonify(error="Failed to preview weekly digest"), 500 - -# Import timedelta at module level -from datetime import timedelta diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py index bdedcc49..7b9cc994 100644 --- a/packages/backend/app/services/digest.py +++ b/packages/backend/app/services/digest.py @@ -455,6 +455,32 @@ def _generate_insights(summary: dict[str, Any]) -> list[str]: return insights + @staticmethod + def get_currency_symbol(currency_code: str | None) -> str: + """Return a display symbol for a currency code.""" + symbols = { + "USD": "$", + "EUR": "€", + "GBP": "£", + "JPY": "¥", + "CNY": "¥", + "INR": "₹", + "KRW": "₩", + "RUB": "₽", + "TRY": "₺", + "BRL": "R$", + "CAD": "C$", + "AUD": "A$", + "CHF": "CHF ", + "SEK": "kr ", + "NOK": "kr ", + "DKK": "kr ", + "PLN": "zł ", + } + if not currency_code: + return "$" + return symbols.get(currency_code.upper(), f"{currency_code.upper()} ") + @staticmethod def format_digest_email( summary: dict[str, Any], user: User @@ -473,6 +499,9 @@ def format_digest_email( week_end = summary["period"]["week_end"] subject = f"Your Weekly Financial Summary ({week_start} to {week_end})" + currency_symbol = WeeklyDigestService.get_currency_symbol( + getattr(user, "preferred_currency", None) + ) lines = [ f"Hello {user.email},", @@ -482,9 +511,9 @@ def format_digest_email( "═══════════════════════════════════════════════════", "OVERVIEW", "═══════════════════════════════════════════════════", - f" Total Income: ${summary['summary']['total_income']:,.2f}", - f" Total Expenses: ${summary['summary']['total_expenses']:,.2f}", - f" Net Flow: ${summary['summary']['net_flow']:,.2f}", + f" Total Income: {currency_symbol}{summary['summary']['total_income']:,.2f}", + f" Total Expenses: {currency_symbol}{summary['summary']['total_expenses']:,.2f}", + f" Net Flow: {currency_symbol}{summary['summary']['net_flow']:,.2f}", f" Savings Rate: {summary['summary']['savings_rate']:.1f}%", f" Transactions: {summary['summary']['transaction_count']}", "", @@ -497,7 +526,7 @@ def format_digest_email( income_change_pct = summary["trends"]["income_change_pct"] income_emoji = "↑" if income_change >= 0 else "↓" lines.append( - f" Income: {income_emoji} ${abs(income_change):,.2f} " + f" Income: {income_emoji} {currency_symbol}{abs(income_change):,.2f} " f"({income_change_pct:+.1f}%)" ) @@ -505,7 +534,7 @@ def format_digest_email( expense_change_pct = summary["trends"]["expense_change_pct"] expense_emoji = "↓" if expense_change <= 0 else "↑" lines.append( - f" Expenses: {expense_emoji} ${abs(expense_change):,.2f} " + f" Expenses: {expense_emoji} {currency_symbol}{abs(expense_change):,.2f} " f"({expense_change_pct:+.1f}%)" ) @@ -518,7 +547,7 @@ def format_digest_email( for cat in summary["spending_by_category"][:5]: lines.append( - f" • {cat['category_name']}: ${cat['amount']:,.2f} " + f" • {cat['category_name']}: {currency_symbol}{cat['amount']:,.2f} " f"({cat['share_pct']:.1f}%)" ) @@ -533,7 +562,7 @@ def format_digest_email( for tx in summary["notable_transactions"][:5]: tx_emoji = "💰" if tx["type"] == "INCOME" else "💸" lines.append( - f" {tx_emoji} {tx['description']}: ${tx['amount']:,.2f} ({tx['date']})" + f" {tx_emoji} {tx['description']}: {currency_symbol}{tx['amount']:,.2f} ({tx['date']})" ) if summary["upcoming_bills"]: @@ -548,7 +577,7 @@ def format_digest_email( days = bill["days_until_due"] days_text = "today" if days == 0 else f"in {days} day(s)" lines.append( - f" • {bill['name']}: ${bill['amount']:,.2f} (due {days_text})" + f" • {bill['name']}: {currency_symbol}{bill['amount']:,.2f} (due {days_text})" ) lines.extend([