From 84054fdb8bef4fc553c6673605773cbcf6819230 Mon Sep 17 00:00:00 2001 From: Suraj Date: Fri, 13 Mar 2026 22:57:55 +0530 Subject: [PATCH 1/2] feat: weekly financial digest with AI insights and email delivery (#121) --- .env.example | 6 +- README.md | 3 + packages/backend/app/__init__.py | 28 +- packages/backend/app/config.py | 1 + packages/backend/app/db/schema.sql | 19 + packages/backend/app/models.py | 18 + packages/backend/app/openapi.yaml | 124 ++++++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/digest.py | 106 +++++ packages/backend/app/services/digest.py | 495 +++++++++++++++++++++ packages/backend/app/services/reminders.py | 49 +- packages/backend/requirements.txt | 1 + packages/backend/tests/test_digest.py | 261 +++++++++++ 13 files changed, 1107 insertions(+), 6 deletions(-) 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/.env.example b/.env.example index bfea48dd5..fa039a5b3 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,11 @@ GEMINI_MODEL="gemini-1.5-flash" TWILIO_ACCOUNT_SID="" TWILIO_AUTH_TOKEN="" TWILIO_WHATSAPP_FROM="" -EMAIL_FROM="" +# Email delivery — Resend (recommended, free tier: 3,000/mo) +# Sign up: https://resend.com → API Keys → Create API Key +RESEND_API_KEY="" +EMAIL_FROM="FinMind " +# SMTP fallback (optional, only if not using Resend) SMTP_URL="" VITE_API_URL="http://localhost:8000" diff --git a/README.md b/README.md index 49592bffc..6247e45e8 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ See `backend/app/db/schema.sql`. Key tables: - users, categories, expenses, bills, reminders - ad_impressions, subscription_plans, user_subscriptions - refresh_tokens (optional if rotating), audit_logs +- weekly_digests (weekly financial summary persistence) ## Redis Caching Policy - Keys @@ -55,6 +56,7 @@ See `backend/app/db/schema.sql`. Key tables: - `user:{id}:categories` — 24h TTL - `user:{id}:upcoming_bills` — 15 min TTL - `insights:{id}` — 24h TTL (invalidate on new expense/bill) + - `user:{id}:weekly_digest:{yyyy-mm-dd}` — 1h TTL - Invalidation - On expense/bill create/update/delete -> delete affected monthly_summary, upcoming_bills, insights - Rate limiting (optional): `rl:{userId}:{endpoint}:{minute}` with short TTL @@ -66,6 +68,7 @@ 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/history`, `/digest/weekly/send` ## MVP UI/UX Plan - Auth screens: register/login. diff --git a/packages/backend/app/__init__.py b/packages/backend/app/__init__.py index cdf76b45f..aaa8b44dd 100644 --- a/packages/backend/app/__init__.py +++ b/packages/backend/app/__init__.py @@ -110,11 +110,33 @@ def _ensure_schema_compatibility(app: Flask) -> None: NOT NULL DEFAULT 'INR' """ ) + cur.execute( + """ + ALTER TABLE users + ADD COLUMN IF NOT EXISTS digest_email_enabled BOOLEAN + NOT NULL DEFAULT TRUE + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS weekly_digests ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + week_start DATE NOT NULL, + week_end DATE NOT NULL, + payload JSONB NOT NULL DEFAULT '{}', + ai_insight TEXT, + method VARCHAR(20) NOT NULL DEFAULT 'heuristic', + delivered_at TIMESTAMP, + channel VARCHAR(20) NOT NULL DEFAULT 'email', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(user_id, week_start) + ) + """ + ) conn.commit() except Exception: - app.logger.exception( - "Schema compatibility patch failed for users.preferred_currency" - ) + app.logger.exception("Schema compatibility patch failed") conn.rollback() finally: conn.close() diff --git a/packages/backend/app/config.py b/packages/backend/app/config.py index cf789755c..10d496eb8 100644 --- a/packages/backend/app/config.py +++ b/packages/backend/app/config.py @@ -22,6 +22,7 @@ class Settings(BaseSettings): email_from: str | None = None smtp_url: str | None = None # e.g. smtp+ssl://user:pass@mail:465 + resend_api_key: str | None = None # pydantic-settings v2 configuration model_config = SettingsConfigDict( diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189def..302d9abec 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,22 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS digest_email_enabled BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE TABLE IF NOT EXISTS weekly_digests ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + week_start DATE NOT NULL, + week_end DATE NOT NULL, + payload JSONB NOT NULL DEFAULT '{}', + ai_insight TEXT, + method VARCHAR(20) NOT NULL DEFAULT 'heuristic', + delivered_at TIMESTAMP, + channel VARCHAR(20) NOT NULL DEFAULT 'email', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(user_id, week_start) +); +CREATE INDEX IF NOT EXISTS idx_weekly_digests_user_week + ON weekly_digests(user_id, week_start DESC); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..311bd50ad 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -16,6 +16,7 @@ class User(db.Model): password_hash = db.Column(db.String(255), nullable=False) preferred_currency = db.Column(db.String(10), default="INR", nullable=False) role = db.Column(db.String(20), default=Role.USER.value, nullable=False) + digest_email_enabled = db.Column(db.Boolean, default=True, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) @@ -133,3 +134,20 @@ class AuditLog(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) action = db.Column(db.String(100), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class WeeklyDigest(db.Model): + __tablename__ = "weekly_digests" + __table_args__ = ( + db.UniqueConstraint("user_id", "week_start", name="uq_digest_user_week"), + ) + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + week_start = db.Column(db.Date, nullable=False) + week_end = db.Column(db.Date, nullable=False) + payload = db.Column(db.JSON, nullable=False, default=dict) + ai_insight = db.Column(db.Text, nullable=True) + method = db.Column(db.String(20), default="heuristic", nullable=False) + delivered_at = db.Column(db.DateTime, nullable=True) + channel = db.Column(db.String(20), default="email", nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0f..d2bcaf38d 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Bills - name: Reminders - name: Insights + - name: Digest paths: /auth/register: post: @@ -481,6 +482,116 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /digest/weekly: + get: + summary: Get weekly financial digest + description: Returns a comprehensive weekly spending summary with AI insight. Generates a new digest if one does not exist for the requested week. + tags: [Digest] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: week_start + required: false + schema: { type: string, format: date } + description: Monday of the desired week (YYYY-MM-DD). Defaults to last completed week. + responses: + '200': + description: Weekly digest + content: + application/json: + schema: + $ref: '#/components/schemas/WeeklyDigest' + example: + id: 1 + user_id: 1 + week_start: "2026-03-02" + week_end: "2026-03-08" + payload: + summary: + total_income: 25000 + total_expenses: 12450 + net_flow: 12550 + week_over_week_change_pct: -8.5 + transaction_count: 23 + category_breakdown: + - { name: "Food & Dining", amount: 4200, share_pct: 33.7 } + highlights: + top_category: "Food & Dining" + daily_average: 1778.57 + upcoming_bills: + - { name: Internet, amount: 999, due_date: "2026-03-12" } + ai_insight: "Your spending decreased by 8.5% — great job!" + method: gemini + '400': + description: Invalid week_start + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /digest/weekly/history: + get: + summary: List past weekly digests + tags: [Digest] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: limit + required: false + schema: { type: integer, default: 10, maximum: 52 } + responses: + '200': + description: List of digest summaries + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: { type: integer } + week_start: { type: string, format: date } + week_end: { type: string, format: date } + total_expenses: { type: number } + net_flow: { type: number } + method: { type: string } + delivered_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /digest/weekly/send: + post: + summary: Send weekly digest email + description: Generates the digest if not already created, then sends it via email to the authenticated user. + tags: [Digest] + security: [{ bearerAuth: [] }] + responses: + '200': + description: Send result + content: + application/json: + schema: + type: object + properties: + sent: { type: boolean } + digest_id: { type: integer } + example: + sent: true + digest_id: 1 + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + components: securitySchemes: bearerAuth: @@ -587,3 +698,16 @@ components: message: { type: string } send_at: { type: string, format: date-time } channel: { type: string, enum: [email, whatsapp], default: email } + WeeklyDigest: + type: object + properties: + id: { type: integer } + user_id: { type: integer } + week_start: { type: string, format: date } + week_end: { type: string, format: date } + payload: { type: object, additionalProperties: true } + ai_insight: { type: string, nullable: true } + method: { type: string, enum: [gemini, heuristic] } + delivered_at: { type: string, format: date-time, nullable: true } + channel: { type: string } + created_at: { type: string, format: date-time } 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..c7da06ddf --- /dev/null +++ b/packages/backend/app/routes/digest.py @@ -0,0 +1,106 @@ +"""Digest endpoints for weekly financial summaries.""" + +import logging +from datetime import date + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..extensions import db +from ..models import WeeklyDigest +from ..services.cache import cache_get, cache_set +from ..services.digest import ( + deliver_digest_email, + get_or_create_digest, + week_boundaries, +) + +bp = Blueprint("digest", __name__) +logger = logging.getLogger("finmind.digest") + + +def _weekly_digest_cache_key(user_id: int, week_start: str) -> str: + return f"user:{user_id}:weekly_digest:{week_start}" + + +@bp.get("/weekly") +@jwt_required() +def get_weekly_digest(): + """Return digest for the current or specified week.""" + uid = int(get_jwt_identity()) + week_start_param = (request.args.get("week_start") or "").strip() + user_gemini_key = (request.headers.get("X-Gemini-Api-Key") or "").strip() or None + + if week_start_param: + try: + w_start = date.fromisoformat(week_start_param) + except ValueError: + return jsonify(error="invalid week_start, expected YYYY-MM-DD"), 400 + else: + w_start, _ = week_boundaries() + + cache_key = _weekly_digest_cache_key(uid, w_start.isoformat()) + cached = cache_get(cache_key) + if cached: + return jsonify(cached) + + digest_data = get_or_create_digest( + uid, w_start=w_start, gemini_api_key=user_gemini_key + ) + + cache_set(cache_key, digest_data, ttl_seconds=3600) + logger.info("Weekly digest served user=%s week=%s", uid, w_start) + return jsonify(digest_data) + + +@bp.get("/weekly/history") +@jwt_required() +def digest_history(): + """List past weekly digests for the authenticated user.""" + uid = int(get_jwt_identity()) + limit = min(int(request.args.get("limit", 10)), 52) + + digests = ( + db.session.query(WeeklyDigest) + .filter_by(user_id=uid) + .order_by(WeeklyDigest.week_start.desc()) + .limit(limit) + .all() + ) + + result = [ + { + "id": d.id, + "week_start": d.week_start.isoformat(), + "week_end": d.week_end.isoformat(), + "total_expenses": ( + d.payload.get("summary", {}).get("total_expenses", 0) + if d.payload + else 0 + ), + "net_flow": ( + d.payload.get("summary", {}).get("net_flow", 0) if d.payload else 0 + ), + "method": d.method, + "delivered_at": (d.delivered_at.isoformat() if d.delivered_at else None), + "created_at": d.created_at.isoformat(), + } + for d in digests + ] + return jsonify(result) + + +@bp.post("/weekly/send") +@jwt_required() +def send_weekly_digest(): + """Generate (if needed) and send digest email for the current user.""" + uid = int(get_jwt_identity()) + user_gemini_key = (request.headers.get("X-Gemini-Api-Key") or "").strip() or None + + w_start, _ = week_boundaries() + digest_data = get_or_create_digest( + uid, w_start=w_start, gemini_api_key=user_gemini_key + ) + sent = deliver_digest_email(uid, digest_data) + logger.info("Digest send user=%s digest=%s sent=%s", uid, digest_data["id"], sent) + return jsonify(sent=sent, digest_id=digest_data["id"]) diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py new file mode 100644 index 000000000..1e2640043 --- /dev/null +++ b/packages/backend/app/services/digest.py @@ -0,0 +1,495 @@ +"""Weekly financial digest service. + +Aggregates a 7-day spending window, generates AI insight via Gemini, +persists digests for idempotency, and delivers via email. +""" + +import json +import logging +from datetime import date, datetime, timedelta +from urllib import request as url_request + +from sqlalchemy import func + +from ..config import Settings +from ..extensions import db +from ..models import Bill, Category, Expense, User, WeeklyDigest +from .reminders import send_email + +logger = logging.getLogger("finmind.digest") +_settings = Settings() + +DIGEST_PERSONA = ( + "You are FinMind's weekly financial coach. Summarise the user's spending " + "week in 2-3 concise, encouraging sentences. Highlight the most notable " + "trend. Include one specific, actionable tip to improve next week. " + "Be data-driven, non-judgmental, and concise." +) + +# Helpers + + +def week_boundaries(reference: date | None = None) -> tuple[date, date]: + """Return (Monday, Sunday) of the last fully completed week.""" + today = reference or date.today() + # Monday of the current week + current_monday = today - timedelta(days=today.weekday()) + # Last completed week + week_end = current_monday - timedelta(days=1) # previous Sunday + week_start = week_end - timedelta(days=6) # previous Monday + return week_start, week_end + + +# Data aggregation + + +def _weekly_totals(uid: int, start: date, end: date) -> tuple[float, float, int]: + """Return (income, expenses, transaction_count) for the date range.""" + 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() + ) + tx_count = ( + db.session.query(func.count(Expense.id)) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + ) + .scalar() + ) + return float(income or 0), float(expenses or 0), int(tx_count or 0) + + +def _category_breakdown(uid: int, start: date, end: date) -> list[dict]: + """Per-category spend with percentages.""" + rows = ( + db.session.query( + Expense.category_id, + func.coalesce(Category.name, "Uncategorized").label("category_name"), + func.coalesce(func.sum(Expense.amount), 0).label("total"), + ) + .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()) + .all() + ) + grand = sum(float(r.total or 0) for r in rows) + return [ + { + "category_id": r.category_id, + "name": r.category_name, + "amount": round(float(r.total or 0), 2), + "share_pct": ( + round((float(r.total or 0) / grand) * 100, 2) if grand > 0 else 0 + ), + } + for r in rows + ] + + +def _biggest_expense(uid: int, start: date, end: date) -> dict | None: + """Single largest expense in the period.""" + row = ( + db.session.query(Expense) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .order_by(Expense.amount.desc()) + .first() + ) + if not row: + return None + return { + "amount": float(row.amount), + "notes": row.notes or "Transaction", + "date": row.spent_at.isoformat(), + } + + +def _upcoming_bills(uid: int, from_date: date) -> list[dict]: + """Bills due within the next 7 days.""" + to_date = from_date + timedelta(days=7) + bills = ( + db.session.query(Bill) + .filter( + Bill.user_id == uid, + Bill.active.is_(True), + Bill.next_due_date >= from_date, + Bill.next_due_date <= to_date, + ) + .order_by(Bill.next_due_date.asc()) + .limit(10) + .all() + ) + return [ + { + "name": b.name, + "amount": float(b.amount), + "currency": b.currency, + "due_date": b.next_due_date.isoformat(), + } + for b in bills + ] + + +# Core digest computation + + +def compute_weekly_digest(uid: int, w_start: date, w_end: date) -> dict: + """Aggregate financial data for the given week.""" + income, expenses, tx_count = _weekly_totals(uid, w_start, w_end) + + # Previous week for comparison + prev_start = w_start - timedelta(days=7) + prev_end = w_end - timedelta(days=7) + _, prev_expenses, _ = _weekly_totals(uid, prev_start, prev_end) + + wow_change = 0.0 + if prev_expenses > 0: + wow_change = round(((expenses - prev_expenses) / prev_expenses) * 100, 2) + + cats = _category_breakdown(uid, w_start, w_end) + biggest = _biggest_expense(uid, w_start, w_end) + days = (w_end - w_start).days + 1 + daily_avg = round(expenses / days, 2) if days > 0 else 0.0 + + user = db.session.get(User, uid) + currency = user.preferred_currency if user else "INR" + + return { + "user_id": uid, + "week_start": w_start.isoformat(), + "week_end": w_end.isoformat(), + "currency": currency, + "summary": { + "total_income": round(income, 2), + "total_expenses": round(expenses, 2), + "net_flow": round(income - expenses, 2), + "week_over_week_change_pct": wow_change, + "transaction_count": tx_count, + }, + "category_breakdown": cats, + "highlights": { + "top_category": cats[0]["name"] if cats else None, + "biggest_expense": biggest, + "daily_average": daily_avg, + }, + "upcoming_bills": _upcoming_bills(uid, w_end + timedelta(days=1)), + } + + +# AI insight + + +def _extract_text(raw: str) -> str: + """Strip markdown fences from model output.""" + text = (raw or "").strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:] + return text.strip() + + +def generate_ai_insight( + digest_payload: dict, + gemini_api_key: str | None = None, + persona: str | None = None, +) -> tuple[str, str]: + """Return (insight_text, method). + + Tries Gemini first, falls back to heuristic tips. + """ + key = (gemini_api_key or "").strip() or (_settings.gemini_api_key or "") + model = _settings.gemini_model + persona_text = (persona or DIGEST_PERSONA).strip() + + if key: + try: + return _gemini_insight(digest_payload, key, model, persona_text) + except Exception: + logger.warning("Gemini unavailable for digest, using heuristic") + + return _heuristic_insight(digest_payload), "heuristic" + + +def _gemini_insight( + payload: dict, api_key: str, model: str, persona: str +) -> tuple[str, str]: + prompt = ( + f"{persona}\n" + "Analyse this weekly spending data and reply with a brief plain-text " + "summary (no JSON, no markdown). 2-3 sentences max.\n" + f"data={json.dumps(payload, default=str)}" + ) + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{model}:generateContent?key={api_key}" + ) + body = json.dumps( + { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.3}, + } + ).encode("utf-8") + req = url_request.Request( + url=url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with url_request.urlopen(req, timeout=15) as resp: # nosec B310 + result = json.loads(resp.read().decode("utf-8")) + text = ( + result.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + return _extract_text(text), "gemini" + + +def _heuristic_insight(payload: dict) -> str: + """Generate a simple rule-based insight.""" + summary = payload.get("summary", {}) + wow = summary.get("week_over_week_change_pct", 0) + expenses = summary.get("total_expenses", 0) + top = payload.get("highlights", {}).get("top_category") + + parts = [] + if wow > 0: + parts.append(f"Your spending increased by {abs(wow)}% compared to last week.") + elif wow < 0: + parts.append( + f"Great job! Spending decreased by {abs(wow)}% compared to " "last week." + ) + else: + parts.append("Your spending was steady compared to last week.") + + if top: + parts.append(f"Top category: {top}.") + + if expenses > 0: + parts.append("Tip: Review your top spending category for potential savings.") + + return " ".join(parts) + + +# Persist & retrieve + + +def get_or_create_digest( + uid: int, + w_start: date | None = None, + gemini_api_key: str | None = None, +) -> dict: + """Idempotent: return existing or create new digest for the week.""" + if w_start is None: + w_start, _ = week_boundaries() + + # Normalise to Monday + w_start = w_start - timedelta(days=w_start.weekday()) + w_end = w_start + timedelta(days=6) + + existing = ( + db.session.query(WeeklyDigest) + .filter_by(user_id=uid, week_start=w_start) + .first() + ) + if existing: + return _digest_to_dict(existing) + + payload = compute_weekly_digest(uid, w_start, w_end) + insight_text, method = generate_ai_insight(payload, gemini_api_key=gemini_api_key) + + digest = WeeklyDigest( + user_id=uid, + week_start=w_start, + week_end=w_end, + payload=payload, + ai_insight=insight_text, + method=method, + ) + db.session.add(digest) + db.session.commit() + logger.info( + "Created weekly digest id=%s user=%s week=%s method=%s", + digest.id, + uid, + w_start, + method, + ) + return _digest_to_dict(digest) + + +def _digest_to_dict(d: WeeklyDigest) -> dict: + return { + "id": d.id, + "user_id": d.user_id, + "week_start": d.week_start.isoformat(), + "week_end": d.week_end.isoformat(), + "payload": d.payload, + "ai_insight": d.ai_insight, + "method": d.method, + "delivered_at": (d.delivered_at.isoformat() if d.delivered_at else None), + "channel": d.channel, + "created_at": d.created_at.isoformat(), + } + + +# Delivery + + +def _format_digest_email(user: User, digest_data: dict) -> tuple[str, str]: + """Return (subject, body) for the weekly digest email.""" + payload = digest_data.get("payload", {}) + summary = payload.get("summary", {}) + cats = payload.get("category_breakdown", []) + highlights = payload.get("highlights", {}) + bills = payload.get("upcoming_bills", []) + insight = digest_data.get("ai_insight", "") + currency = payload.get("currency", "INR") + + w_start = digest_data["week_start"] + w_end = digest_data["week_end"] + + subject = f"FinMind Weekly Digest — {w_start} to {w_end}" + + lines = [ + "Hi there,\n", + "Here's your FinMind weekly spending summary for " f"{w_start} to {w_end}.\n", + "─── SUMMARY ───", + f" Income: {currency} {summary.get('total_income', 0):,.2f}", + f" Expenses: {currency} {summary.get('total_expenses', 0):,.2f}", + f" Net Flow: {currency} {summary.get('net_flow', 0):,.2f}", + f" Transactions: {summary.get('transaction_count', 0)}", + f" vs Last Week: {summary.get('week_over_week_change_pct', 0):+.1f}%", + "", + ] + + if cats: + lines.append("─── CATEGORY BREAKDOWN ───") + for c in cats[:5]: + lines.append( + f" {c['name']}: {currency} {c['amount']:,.2f} " f"({c['share_pct']}%)" + ) + lines.append("") + + biggest = highlights.get("biggest_expense") + if biggest: + lines.append("─── HIGHLIGHTS ───") + lines.append( + f" Biggest Expense: {currency} {biggest['amount']:,.2f} " + f"— {biggest['notes']} ({biggest['date']})" + ) + lines.append( + f" Daily Average: {currency} " + f"{highlights.get('daily_average', 0):,.2f}" + ) + lines.append("") + + if bills: + lines.append("─── UPCOMING BILLS ───") + for b in bills: + lines.append( + f" {b['name']}: {b.get('currency', currency)} " + f"{b['amount']:,.2f} — due {b['due_date']}" + ) + lines.append("") + + if insight: + lines.append("─── AI INSIGHT ───") + lines.append(f" {insight}") + lines.append("") + + lines.append("Stay on track! — FinMind") + + return subject, "\n".join(lines) + + +def deliver_digest_email(uid: int, digest_data: dict) -> bool: + """Send digest email and mark as delivered.""" + user = db.session.get(User, uid) + if not user: + return False + + subject, body = _format_digest_email(user, digest_data) + success = send_email(user.email, subject, body) + + if success: + digest = db.session.get(WeeklyDigest, digest_data["id"]) + if digest: + digest.delivered_at = datetime.utcnow() + digest.channel = "email" + db.session.commit() + logger.info("Delivered digest email user=%s digest=%s", uid, digest_data["id"]) + else: + logger.warning( + "Failed to deliver digest email user=%s digest=%s", uid, digest_data["id"] + ) + + return success + + +# Scheduled job entry point + + +def run_weekly_digest_job(app) -> dict: # noqa: C901 + """Generate and deliver digests for all opted-in users. + + Intended to be called by APScheduler or a cron trigger. + Returns summary counts. + """ + with app.app_context(): + users = db.session.query(User).filter(User.digest_email_enabled.is_(True)).all() + generated = 0 + delivered = 0 + errors = 0 + + for user in users: + try: + digest_data = get_or_create_digest(user.id) + generated += 1 + if deliver_digest_email(user.id, digest_data): + delivered += 1 + except Exception: + logger.exception("Digest job failed for user=%s", user.id) + errors += 1 + + summary = { + "total_users": len(users), + "generated": generated, + "delivered": delivered, + "errors": errors, + } + logger.info("Weekly digest job complete: %s", summary) + return summary diff --git a/packages/backend/app/services/reminders.py b/packages/backend/app/services/reminders.py index 42e9c252d..093d8d57f 100644 --- a/packages/backend/app/services/reminders.py +++ b/packages/backend/app/services/reminders.py @@ -1,8 +1,14 @@ import smtplib +import logging from email.message import EmailMessage from ..config import Settings from ..models import Reminder +try: + import resend as resend_sdk +except Exception: # pragma: no cover + resend_sdk = None # type: ignore[assignment] + try: from twilio.rest import Client as TwilioClient except Exception: # pragma: no cover @@ -10,17 +16,39 @@ _settings = Settings() +logger = logging.getLogger("finmind.reminders") -def send_email(to_email: str, subject: str, body: str): +def _send_via_resend(to_email: str, subject: str, body: str) -> bool: + """Send email using Resend SDK (recommended).""" + if not resend_sdk or not _settings.resend_api_key: + return False + try: + resend_sdk.api_key = _settings.resend_api_key + params: dict = { + "from": _settings.email_from or "FinMind ", + "to": [to_email], + "subject": subject, + "text": body, + } + result = resend_sdk.Emails.send(params) + logger.info("Email sent via Resend to=%s id=%s", to_email, result.get("id")) + return True + except Exception: + logger.exception("Resend send failed to=%s", to_email) + return False + + +def _send_via_smtp(to_email: str, subject: str, body: str) -> bool: + """Send email using SMTP (fallback).""" if not _settings.smtp_url or not _settings.email_from: return False try: - # Very light SMTP URL parser: smtp+ssl://user:pass@host:465 import re m = re.match(r"smtp\+ssl://(.+?):(.+?)@(.+?):(\d+)", _settings.smtp_url) if not m: + logger.error("Email not sent: invalid SMTP_URL format") return False user, pwd, host, port = m.groups() msg = EmailMessage() @@ -31,11 +59,28 @@ def send_email(to_email: str, subject: str, body: str): with smtplib.SMTP_SSL(host, int(port)) as s: s.login(user, pwd) s.send_message(msg) + logger.info("Email sent via SMTP to=%s subject=%s", to_email, subject) return True except Exception: + logger.exception("SMTP send failed to=%s", to_email) return False +def send_email(to_email: str, subject: str, body: str) -> bool: + """Send email using Resend SDK (primary) or SMTP (fallback). + + Priority: Resend API key > SMTP URL > skip. + """ + if _settings.resend_api_key: + return _send_via_resend(to_email, subject, body) + + if _settings.smtp_url: + return _send_via_smtp(to_email, subject, body) + + logger.warning("Email not sent: neither RESEND_API_KEY nor SMTP_URL configured") + return False + + def send_whatsapp(to_number: str, body: str): if not ( _settings.twilio_account_sid diff --git a/packages/backend/requirements.txt b/packages/backend/requirements.txt index 056b10dc9..ce1986ab5 100644 --- a/packages/backend/requirements.txt +++ b/packages/backend/requirements.txt @@ -14,6 +14,7 @@ requests==2.32.3 pypdf==4.3.1 openai==1.37.1 twilio==9.3.2 +resend==2.23.0 pytest==8.2.2 black==24.8.0 flake8==7.0.0 diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 000000000..aba31106f --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,261 @@ +"""Tests for weekly financial digest feature.""" + +from datetime import date, timedelta +from unittest.mock import patch + + +# Helper + + +def _seed_expenses(client, auth_header, items): + """Create expense records via the API.""" + for item in items: + r = client.post("/expenses", json=item, headers=auth_header) + assert r.status_code == 201, f"seed failed: {r.get_json()}" + + +def _last_monday(): + today = date.today() + current_monday = today - timedelta(days=today.weekday()) + return current_monday - timedelta(days=7) + + +# GET /digest/weekly + + +def test_weekly_digest_returns_summary(client, auth_header): + """Digest should contain summary, category_breakdown, highlights.""" + monday = _last_monday() + wednesday = monday + timedelta(days=2) + + _seed_expenses( + client, + auth_header, + [ + { + "amount": 500, + "description": "Groceries", + "date": wednesday.isoformat(), + "expense_type": "EXPENSE", + }, + { + "amount": 3000, + "description": "Salary", + "date": monday.isoformat(), + "expense_type": "INCOME", + }, + ], + ) + + r = client.get( + f"/digest/weekly?week_start={monday.isoformat()}", + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + + assert "payload" in data + payload = data["payload"] + assert "summary" in payload + assert payload["summary"]["total_expenses"] >= 500 + assert payload["summary"]["total_income"] >= 3000 + assert payload["summary"]["net_flow"] >= 2500 + assert "category_breakdown" in payload + assert "highlights" in payload + assert "ai_insight" in data + + +def test_weekly_digest_empty_week(client, auth_header): + """Digest for a week with no data should return zeroed summary.""" + far_past = date(2020, 1, 6) # a Monday + r = client.get( + f"/digest/weekly?week_start={far_past.isoformat()}", + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + payload = data["payload"] + assert payload["summary"]["total_expenses"] == 0 + assert payload["summary"]["total_income"] == 0 + assert payload["summary"]["net_flow"] == 0 + assert payload["summary"]["transaction_count"] == 0 + + +def test_weekly_digest_invalid_week_start(client, auth_header): + r = client.get( + "/digest/weekly?week_start=not-a-date", + headers=auth_header, + ) + assert r.status_code == 400 + assert "invalid" in r.get_json()["error"].lower() + + +# Idempotency + + +def test_weekly_digest_idempotent(client, auth_header, app_fixture): + """Calling twice for same week should not create duplicate DB rows.""" + monday = _last_monday() + + _seed_expenses( + client, + auth_header, + [ + { + "amount": 100, + "description": "Test", + "date": monday.isoformat(), + "expense_type": "EXPENSE", + }, + ], + ) + + r1 = client.get( + f"/digest/weekly?week_start={monday.isoformat()}", + headers=auth_header, + ) + assert r1.status_code == 200 + id1 = r1.get_json()["id"] + + r2 = client.get( + f"/digest/weekly?week_start={monday.isoformat()}", + headers=auth_header, + ) + assert r2.status_code == 200 + id2 = r2.get_json()["id"] + + assert id1 == id2, "Digest should be idempotent for same week" + + +# GET /digest/weekly/history + + +def test_weekly_digest_history(client, auth_header): + """History endpoint should return a list of past digests.""" + monday = _last_monday() + + # Generate a digest first + client.get( + f"/digest/weekly?week_start={monday.isoformat()}", + headers=auth_header, + ) + + r = client.get("/digest/weekly/history", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert isinstance(data, list) + assert len(data) >= 1 + item = data[0] + assert "week_start" in item + assert "week_end" in item + assert "total_expenses" in item + assert "net_flow" in item + + +def test_weekly_digest_history_respects_limit(client, auth_header): + r = client.get("/digest/weekly/history?limit=1", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert len(data) <= 1 + + +# POST /digest/weekly/send + + +def test_weekly_digest_send_triggers_email(client, auth_header): + """Send endpoint should attempt email delivery.""" + with patch("app.services.digest.send_email", return_value=True) as mock_send: + r = client.post("/digest/weekly/send", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["sent"] is True + assert "digest_id" in data + assert mock_send.called + + +def test_weekly_digest_send_handles_email_failure(client, auth_header): + """Send endpoint should handle email failure gracefully.""" + with patch("app.services.digest.send_email", return_value=False): + r = client.post("/digest/weekly/send", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["sent"] is False + + +# AI insight + + +def test_weekly_digest_ai_insight_with_gemini(client, auth_header, monkeypatch): + """When Gemini is available, method should be gemini.""" + monday = date(2020, 6, 1) # unique week to avoid cache + + def _fake_gemini(payload, key, model, persona): + return "Great week! Your spending is on track.", "gemini" + + monkeypatch.setattr("app.services.digest._gemini_insight", _fake_gemini) + monkeypatch.setattr("app.services.digest._settings.gemini_api_key", "test-key") + + r = client.get( + f"/digest/weekly?week_start={monday.isoformat()}", + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["method"] == "gemini" + assert "Great week" in data["ai_insight"] + + +def test_weekly_digest_falls_back_to_heuristic(client, auth_header, monkeypatch): + """When Gemini fails, should fall back to heuristic.""" + monday = date(2020, 7, 6) # unique week + + def _boom(*args, **kwargs): + raise RuntimeError("gemini down") + + monkeypatch.setattr("app.services.digest._gemini_insight", _boom) + monkeypatch.setattr("app.services.digest._settings.gemini_api_key", "test-key") + + r = client.get( + f"/digest/weekly?week_start={monday.isoformat()}", + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["method"] == "heuristic" + assert data["ai_insight"] # should not be empty + + +# Auth + + +def test_weekly_digest_requires_auth(client): + """Endpoints should reject unauthenticated requests.""" + assert client.get("/digest/weekly").status_code == 401 + assert client.get("/digest/weekly/history").status_code == 401 + assert client.post("/digest/weekly/send").status_code == 401 + + +# week_boundaries helper + + +def test_week_boundaries_returns_monday_to_sunday(): + from app.services.digest import week_boundaries + + # Test with a known Wednesday: 2026-03-11 + start, end = week_boundaries(date(2026, 3, 11)) + + # Should return previous completed week: Mon Mar 2 – Sun Mar 8 + assert start.weekday() == 0, "week_start should be Monday" + assert end.weekday() == 6, "week_end should be Sunday" + assert (end - start).days == 6 + assert start == date(2026, 3, 2) + assert end == date(2026, 3, 8) + + +def test_week_boundaries_on_monday(): + from app.services.digest import week_boundaries + + # On a Monday, should still return the *previous* week + start, end = week_boundaries(date(2026, 3, 9)) # Monday + assert start == date(2026, 3, 2) + assert end == date(2026, 3, 8) From deb938cf20c24dfb0f93b4495f66015f1a611a2d Mon Sep 17 00:00:00 2001 From: Suraj Date: Fri, 13 Mar 2026 23:27:19 +0530 Subject: [PATCH 2/2] feat(digest): add rich HTML email template and week_start send param --- packages/backend/app/routes/digest.py | 10 +- packages/backend/app/services/digest.py | 126 ++++++++++++++++++++- packages/backend/app/services/reminders.py | 22 +++- 3 files changed, 146 insertions(+), 12 deletions(-) diff --git a/packages/backend/app/routes/digest.py b/packages/backend/app/routes/digest.py index c7da06ddf..72e7c48d3 100644 --- a/packages/backend/app/routes/digest.py +++ b/packages/backend/app/routes/digest.py @@ -95,9 +95,17 @@ def digest_history(): def send_weekly_digest(): """Generate (if needed) and send digest email for the current user.""" uid = int(get_jwt_identity()) + week_start_param = (request.args.get("week_start") or "").strip() user_gemini_key = (request.headers.get("X-Gemini-Api-Key") or "").strip() or None - w_start, _ = week_boundaries() + if week_start_param: + try: + w_start = date.fromisoformat(week_start_param) + except ValueError: + return jsonify(error="invalid week_start, expected YYYY-MM-DD"), 400 + else: + w_start, _ = week_boundaries() + digest_data = get_or_create_digest( uid, w_start=w_start, gemini_api_key=user_gemini_key ) diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py index 1e2640043..cee2bd79d 100644 --- a/packages/backend/app/services/digest.py +++ b/packages/backend/app/services/digest.py @@ -369,8 +369,8 @@ def _digest_to_dict(d: WeeklyDigest) -> dict: # Delivery -def _format_digest_email(user: User, digest_data: dict) -> tuple[str, str]: - """Return (subject, body) for the weekly digest email.""" +def _format_digest_email(user: User, digest_data: dict) -> tuple[str, str, str]: + """Return (subject, text_body, html_body) for the weekly digest email.""" payload = digest_data.get("payload", {}) summary = payload.get("summary", {}) cats = payload.get("category_breakdown", []) @@ -383,9 +383,10 @@ def _format_digest_email(user: User, digest_data: dict) -> tuple[str, str]: w_end = digest_data["week_end"] subject = f"FinMind Weekly Digest — {w_start} to {w_end}" + user_name = user.email.split("@")[0] if user and user.email else "there" lines = [ - "Hi there,\n", + f"Hi {user_name},\n", "Here's your FinMind weekly spending summary for " f"{w_start} to {w_end}.\n", "─── SUMMARY ───", f" Income: {currency} {summary.get('total_income', 0):,.2f}", @@ -433,7 +434,120 @@ def _format_digest_email(user: User, digest_data: dict) -> tuple[str, str]: lines.append("Stay on track! — FinMind") - return subject, "\n".join(lines) + # --- HTML Formatting --- + def format_money(amount: float) -> str: + return f"{currency} {amount:,.2f}" + + inc_str = format_money(summary.get("total_income", 0)) + exp_str = format_money(summary.get("total_expenses", 0)) + net_flow = summary.get("net_flow", 0) + net_str = format_money(net_flow) + wow_pct = summary.get("week_over_week_change_pct", 0) + wow_str = f"{wow_pct:+.1f}%" + + html_lines = [ + "", + "", + "", + "", + "
", + "
", + "

FinMind Weekly Digest

", + "
", + "
", + f"
Hi {user_name},
", + f"

Here is your financial summary for {w_start} to {w_end}.

", # noqa: E501 + # Summary Cards (Income, Expenses, Net Flow) + "
", + "", # noqa: E501 + f"", # noqa: E501 + "", + f"", # noqa: E501 + "", + f"", # noqa: E501 + "
Income
{inc_str}
Expenses
{exp_str}
Net Flow
= 0 else '#dc2626'};'>{net_str}
", + "
", + ] + + if insight: + html_lines.extend( + [ + "
", + "
AI Insight
", + f"
✨ {insight}
", + "
", + ] + ) + + if cats: + html_lines.extend( + [ + "
", + "
Category Breakdown
", + "", + ] + ) + for c in cats[:5]: + html_lines.append( + f"" # noqa: E501 + ) + html_lines.append("
{c['name']}{format_money(c['amount'])} ({c['share_pct']}%)
") + + if highlights.get("biggest_expense"): + biggest = highlights.get("biggest_expense") + html_lines.extend( + [ + "
", + "
Highlights
", + "", + f"", # noqa: E501 + f"", # noqa: E501 + f"", # noqa: E501 + "
Biggest Expense ({biggest['date']})
{biggest['notes']}
{format_money(biggest['amount'])}
Daily Average{format_money(highlights.get('daily_average', 0))}
vs Last Week{wow_str}
", + ] + ) + + if bills: + html_lines.extend( + [ + "
", + "
Upcoming Bills (Next 7 Days)
", + "", + ] + ) + for b in bills: + html_lines.append( + f"" # noqa: E501 + ) + html_lines.append("
{b['name']}
Due: {b['due_date']}
{currency} {b['amount']:,.2f}
") + + html_lines.extend( + [ + "
", + "", + "
", + "", + "", + ] + ) + + html_body = "".join(html_lines) + + return subject, "\n".join(lines), html_body def deliver_digest_email(uid: int, digest_data: dict) -> bool: @@ -442,8 +556,8 @@ def deliver_digest_email(uid: int, digest_data: dict) -> bool: if not user: return False - subject, body = _format_digest_email(user, digest_data) - success = send_email(user.email, subject, body) + subject, body, html_body = _format_digest_email(user, digest_data) + success = send_email(user.email, subject, body, html_body=html_body) if success: digest = db.session.get(WeeklyDigest, digest_data["id"]) diff --git a/packages/backend/app/services/reminders.py b/packages/backend/app/services/reminders.py index 093d8d57f..c269eba01 100644 --- a/packages/backend/app/services/reminders.py +++ b/packages/backend/app/services/reminders.py @@ -1,6 +1,8 @@ import smtplib import logging from email.message import EmailMessage +from typing import Optional + from ..config import Settings from ..models import Reminder @@ -19,7 +21,9 @@ logger = logging.getLogger("finmind.reminders") -def _send_via_resend(to_email: str, subject: str, body: str) -> bool: +def _send_via_resend( + to_email: str, subject: str, body: str, html_body: Optional[str] = None +) -> bool: """Send email using Resend SDK (recommended).""" if not resend_sdk or not _settings.resend_api_key: return False @@ -31,6 +35,8 @@ def _send_via_resend(to_email: str, subject: str, body: str) -> bool: "subject": subject, "text": body, } + if html_body: + params["html"] = html_body result = resend_sdk.Emails.send(params) logger.info("Email sent via Resend to=%s id=%s", to_email, result.get("id")) return True @@ -39,7 +45,9 @@ def _send_via_resend(to_email: str, subject: str, body: str) -> bool: return False -def _send_via_smtp(to_email: str, subject: str, body: str) -> bool: +def _send_via_smtp( + to_email: str, subject: str, body: str, html_body: Optional[str] = None +) -> bool: """Send email using SMTP (fallback).""" if not _settings.smtp_url or not _settings.email_from: return False @@ -56,6 +64,8 @@ def _send_via_smtp(to_email: str, subject: str, body: str) -> bool: msg["To"] = to_email msg["Subject"] = subject msg.set_content(body) + if html_body: + msg.add_alternative(html_body, subtype="html") with smtplib.SMTP_SSL(host, int(port)) as s: s.login(user, pwd) s.send_message(msg) @@ -66,16 +76,18 @@ def _send_via_smtp(to_email: str, subject: str, body: str) -> bool: return False -def send_email(to_email: str, subject: str, body: str) -> bool: +def send_email( + to_email: str, subject: str, body: str, html_body: Optional[str] = None +) -> bool: """Send email using Resend SDK (primary) or SMTP (fallback). Priority: Resend API key > SMTP URL > skip. """ if _settings.resend_api_key: - return _send_via_resend(to_email, subject, body) + return _send_via_resend(to_email, subject, body, html_body) if _settings.smtp_url: - return _send_via_smtp(to_email, subject, body) + return _send_via_smtp(to_email, subject, body, html_body) logger.warning("Email not sent: neither RESEND_API_KEY nor SMTP_URL configured") return False