From 7e73af31bec1a62db90a932322b308c54304ca62 Mon Sep 17 00:00:00 2001 From: xppert9 Date: Tue, 10 Mar 2026 12:40:07 -0300 Subject: [PATCH 1/2] feat: add GDPR PII export, account deletion, and audit trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements three endpoints under /gdpr: - GET /gdpr/export — collects all user-linked data (profile, categories, expenses, recurring expenses, bills, reminders, subscriptions) into a structured JSON response. Excludes password_hash. Logs a PII_EXPORT audit event. - POST /gdpr/delete — permanently deletes the user account after password re-verification. Leverages existing ON DELETE CASCADE foreign keys to remove all associated records. Audit log entry survives via ON DELETE SET NULL, preserving compliance records after deletion. - GET /gdpr/audit — returns the authenticated user's audit trail sorted by most recent first. Includes 18 tests covering: auth guards, export completeness, password verification, cascade deletion, audit log persistence, and edge cases. Closes #76 --- packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/gdpr.py | 218 ++++++++++++++++++++++ packages/backend/tests/test_gdpr.py | 229 ++++++++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 packages/backend/app/routes/gdpr.py create mode 100644 packages/backend/tests/test_gdpr.py diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f8..288b981 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 .gdpr import bp as gdpr_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(gdpr_bp, url_prefix="/gdpr") diff --git a/packages/backend/app/routes/gdpr.py b/packages/backend/app/routes/gdpr.py new file mode 100644 index 0000000..deef7f2 --- /dev/null +++ b/packages/backend/app/routes/gdpr.py @@ -0,0 +1,218 @@ +from flask import Blueprint, request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from werkzeug.security import check_password_hash +from ..extensions import db +from ..models import ( + User, + Category, + Expense, + RecurringExpense, + Bill, + Reminder, + UserSubscription, + AuditLog, +) +import logging + +bp = Blueprint("gdpr", __name__) +logger = logging.getLogger("finmind.gdpr") + + +def _serialize_date(val): + if val is None: + return None + return val.isoformat() + + +def _serialize_decimal(val): + if val is None: + return None + return float(val) + + +def _collect_user_data(user): + """Gather all PII-linked records for a user into a structured dict.""" + categories = ( + db.session.query(Category).filter_by(user_id=user.id).all() + ) + expenses = ( + db.session.query(Expense).filter_by(user_id=user.id).all() + ) + recurring = ( + db.session.query(RecurringExpense).filter_by(user_id=user.id).all() + ) + bills = db.session.query(Bill).filter_by(user_id=user.id).all() + reminders = ( + db.session.query(Reminder).filter_by(user_id=user.id).all() + ) + subscriptions = ( + db.session.query(UserSubscription).filter_by(user_id=user.id).all() + ) + + return { + "user": { + "id": user.id, + "email": user.email, + "preferred_currency": user.preferred_currency, + "role": user.role, + "created_at": _serialize_date(user.created_at), + }, + "categories": [ + { + "id": c.id, + "name": c.name, + "created_at": _serialize_date(c.created_at), + } + for c in categories + ], + "expenses": [ + { + "id": e.id, + "category_id": e.category_id, + "amount": _serialize_decimal(e.amount), + "currency": e.currency, + "expense_type": e.expense_type, + "notes": e.notes, + "spent_at": _serialize_date(e.spent_at), + "source_recurring_id": e.source_recurring_id, + "created_at": _serialize_date(e.created_at), + } + for e in expenses + ], + "recurring_expenses": [ + { + "id": r.id, + "category_id": r.category_id, + "amount": _serialize_decimal(r.amount), + "currency": r.currency, + "expense_type": r.expense_type, + "notes": r.notes, + "cadence": r.cadence.value if r.cadence else None, + "start_date": _serialize_date(r.start_date), + "end_date": _serialize_date(r.end_date), + "active": r.active, + "created_at": _serialize_date(r.created_at), + } + for r in recurring + ], + "bills": [ + { + "id": b.id, + "name": b.name, + "amount": _serialize_decimal(b.amount), + "currency": b.currency, + "next_due_date": _serialize_date(b.next_due_date), + "cadence": b.cadence.value if b.cadence else None, + "autopay_enabled": b.autopay_enabled, + "active": b.active, + "created_at": _serialize_date(b.created_at), + } + for b in bills + ], + "reminders": [ + { + "id": rm.id, + "bill_id": rm.bill_id, + "message": rm.message, + "send_at": _serialize_date(rm.send_at), + "sent": rm.sent, + "channel": rm.channel, + } + for rm in reminders + ], + "subscriptions": [ + { + "id": s.id, + "plan_id": s.plan_id, + "active": s.active, + "started_at": _serialize_date(s.started_at), + } + for s in subscriptions + ], + } + + +@bp.get("/export") +@jwt_required() +def export_pii(): + """Export all personal data for the authenticated user (GDPR Art. 20).""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="user not found"), 404 + + data = _collect_user_data(user) + + audit = AuditLog(user_id=uid, action="PII_EXPORT") + db.session.add(audit) + db.session.commit() + + logger.info("PII export completed for user_id=%s", uid) + return jsonify(data=data), 200 + + +@bp.post("/delete") +@jwt_required() +def delete_account(): + """Permanently delete user account and all associated data (GDPR Art. 17). + + Requires password confirmation in the request body. + """ + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="user not found"), 404 + + body = request.get_json() or {} + password = body.get("password") + if not password: + return jsonify(error="password required for account deletion"), 400 + + if not check_password_hash(user.password_hash, password): + logger.warning("Delete account failed: wrong password user_id=%s", uid) + return jsonify(error="invalid password"), 403 + + # Log the deletion event before removing the user. + # audit_logs.user_id uses ON DELETE SET NULL, so the log + # row survives user deletion with user_id becoming NULL. + audit = AuditLog(user_id=uid, action="ACCOUNT_DELETED") + db.session.add(audit) + db.session.flush() + + # Delete the user row. Cascading FKs handle related data: + # categories, expenses, recurring_expenses, bills, + # reminders, user_subscriptions -> ON DELETE CASCADE + # ad_impressions, audit_logs -> ON DELETE SET NULL + db.session.delete(user) + db.session.commit() + + logger.info("Account deleted for former user_id=%s", uid) + return jsonify(message="account and all associated data deleted"), 200 + + +@bp.get("/audit") +@jwt_required() +def audit_trail(): + """Retrieve audit log entries for the authenticated user.""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="user not found"), 404 + + logs = ( + db.session.query(AuditLog) + .filter_by(user_id=uid) + .order_by(AuditLog.created_at.desc()) + .all() + ) + + return jsonify( + audit_logs=[ + { + "id": log.id, + "action": log.action, + "created_at": _serialize_date(log.created_at), + } + for log in logs + ] + ), 200 diff --git a/packages/backend/tests/test_gdpr.py b/packages/backend/tests/test_gdpr.py new file mode 100644 index 0000000..2338b0e --- /dev/null +++ b/packages/backend/tests/test_gdpr.py @@ -0,0 +1,229 @@ +import pytest +from app.extensions import db +from app.models import ( + User, + Category, + Expense, + Bill, + BillCadence, + RecurringExpense, + RecurringCadence, + Reminder, + AuditLog, +) +from datetime import date, datetime + + +@pytest.fixture() +def seeded_user(client, auth_header): + """Create sample data for the default test user.""" + with client.application.app_context(): + user = db.session.query(User).filter_by( + email="test@example.com" + ).first() + uid = user.id + + cat = Category(user_id=uid, name="Groceries") + db.session.add(cat) + db.session.flush() + + exp = Expense( + user_id=uid, + category_id=cat.id, + amount=42.50, + currency="INR", + notes="weekly shopping", + spent_at=date(2026, 3, 1), + ) + db.session.add(exp) + + rec = RecurringExpense( + user_id=uid, + amount=9.99, + notes="streaming", + cadence=RecurringCadence.MONTHLY, + start_date=date(2026, 1, 1), + ) + db.session.add(rec) + + bill = Bill( + user_id=uid, + name="Electricity", + amount=80.00, + next_due_date=date(2026, 4, 1), + cadence=BillCadence.MONTHLY, + ) + db.session.add(bill) + db.session.flush() + + rem = Reminder( + user_id=uid, + bill_id=bill.id, + message="pay electricity", + send_at=datetime(2026, 3, 30, 9, 0), + ) + db.session.add(rem) + db.session.commit() + return auth_header + + +# ── Export Tests ──────────────────────────────────────────── + + +def test_export_returns_user_data(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + assert r.status_code == 200 + data = r.get_json()["data"] + assert data["user"]["email"] == "test@example.com" + + +def test_export_includes_categories(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + names = [c["name"] for c in data["categories"]] + assert "Groceries" in names + + +def test_export_includes_expenses(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert len(data["expenses"]) >= 1 + assert data["expenses"][0]["notes"] == "weekly shopping" + + +def test_export_includes_recurring(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert len(data["recurring_expenses"]) >= 1 + + +def test_export_includes_bills(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert any(b["name"] == "Electricity" for b in data["bills"]) + + +def test_export_includes_reminders(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert len(data["reminders"]) >= 1 + + +def test_export_excludes_password_hash(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert "password_hash" not in data["user"] + + +def test_export_creates_audit_log(client, seeded_user): + client.get("/gdpr/export", headers=seeded_user) + with client.application.app_context(): + logs = db.session.query(AuditLog).filter_by( + action="PII_EXPORT" + ).all() + assert len(logs) >= 1 + + +def test_export_requires_auth(client): + r = client.get("/gdpr/export") + assert r.status_code == 401 + + +# ── Delete Tests ──────────────────────────────────────────── + + +def test_delete_requires_password(client, seeded_user): + r = client.post("/gdpr/delete", headers=seeded_user, json={}) + assert r.status_code == 400 + assert "password required" in r.get_json()["error"] + + +def test_delete_rejects_wrong_password(client, seeded_user): + r = client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "wrong"} + ) + assert r.status_code == 403 + + +def test_delete_removes_user(client, seeded_user): + r = client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "password123"} + ) + assert r.status_code == 200 + with client.application.app_context(): + user = db.session.query(User).filter_by( + email="test@example.com" + ).first() + assert user is None + + +def test_delete_cascades_expenses(client, seeded_user): + with client.application.app_context(): + user = db.session.query(User).filter_by( + email="test@example.com" + ).first() + uid = user.id + + client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "password123"} + ) + + with client.application.app_context(): + remaining = db.session.query(Expense).filter_by( + user_id=uid + ).count() + assert remaining == 0 + + +def test_delete_cascades_categories(client, seeded_user): + with client.application.app_context(): + user = db.session.query(User).filter_by( + email="test@example.com" + ).first() + uid = user.id + + client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "password123"} + ) + + with client.application.app_context(): + remaining = db.session.query(Category).filter_by( + user_id=uid + ).count() + assert remaining == 0 + + +def test_delete_preserves_audit_log(client, seeded_user): + client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "password123"} + ) + with client.application.app_context(): + logs = db.session.query(AuditLog).filter_by( + action="ACCOUNT_DELETED" + ).all() + assert len(logs) >= 1 + # user_id should be NULL after cascade + assert logs[0].user_id is None + + +def test_delete_requires_auth(client): + r = client.post("/gdpr/delete", json={"password": "password123"}) + assert r.status_code == 401 + + +# ── Audit Tests ───────────────────────────────────────────── + + +def test_audit_returns_logs(client, seeded_user): + # Trigger an export to create an audit entry + client.get("/gdpr/export", headers=seeded_user) + r = client.get("/gdpr/audit", headers=seeded_user) + assert r.status_code == 200 + logs = r.get_json()["audit_logs"] + assert len(logs) >= 1 + assert logs[0]["action"] == "PII_EXPORT" + + +def test_audit_requires_auth(client): + r = client.get("/gdpr/audit") + assert r.status_code == 401 From 3bf538ba9a21062431a2e722c2f34ad518d9b822 Mon Sep 17 00:00:00 2001 From: xppert9 Date: Tue, 10 Mar 2026 14:37:53 -0300 Subject: [PATCH 2/2] feat: smart weekly financial digest with trends and insights (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /digest/weekly — full weekly summary with spending/income totals, week-over-week comparison, top categories, daily breakdown, upcoming bills, and auto-generated insights - GET /digest/weekly/history — multi-week history (up to 12 weeks) - 26 tests covering auth, calculations, date validation, empty data, history pagination, and audit logging - Smart insights engine: spending trend warnings, savings celebrations, top category alerts, upcoming bill reminders - Follows existing codebase patterns (Flask Blueprint, jwt_required, SQLAlchemy) - No new dependencies /claim #121 Closes #121 Co-Authored-By: Claude Opus 4.6 --- packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/digest.py | 305 ++++++++++++++++++++++++ packages/backend/tests/test_digest.py | 299 +++++++++++++++++++++++ 3 files changed, 606 insertions(+) create mode 100644 packages/backend/app/routes/digest.py create mode 100644 packages/backend/tests/test_digest.py diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index 288b981..9dc12ad 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -8,6 +8,7 @@ from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp from .gdpr import bp as gdpr_bp +from .digest import bp as digest_bp def register_routes(app: Flask): @@ -20,3 +21,4 @@ def register_routes(app: Flask): app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") app.register_blueprint(gdpr_bp, url_prefix="/gdpr") + 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 0000000..ea7463a --- /dev/null +++ b/packages/backend/app/routes/digest.py @@ -0,0 +1,305 @@ +"""Weekly financial digest — Smart summary of spending trends and insights.""" + +from datetime import date, timedelta +from decimal import Decimal +from sqlalchemy import extract, func +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..extensions import db +from ..models import Expense, Category, Bill, AuditLog + +bp = Blueprint("digest", __name__) + + +@bp.get("/weekly") +@jwt_required() +def weekly_digest(): + """Return a smart weekly financial summary with trends and insights.""" + uid = int(get_jwt_identity()) + ref_date_str = request.args.get("date") + + if ref_date_str: + try: + ref_date = date.fromisoformat(ref_date_str) + except ValueError: + return jsonify(error="invalid date, expected YYYY-MM-DD"), 400 + else: + ref_date = date.today() + + week_end = ref_date + week_start = week_end - timedelta(days=6) + prev_week_end = week_start - timedelta(days=1) + prev_week_start = prev_week_end - timedelta(days=6) + + digest = _build_digest(uid, week_start, week_end, prev_week_start, prev_week_end) + + AuditLog(user_id=uid, action="WEEKLY_DIGEST_VIEWED") + try: + db.session.add(AuditLog(user_id=uid, action="WEEKLY_DIGEST_VIEWED")) + db.session.commit() + except Exception: + db.session.rollback() + + return jsonify(digest) + + +@bp.get("/weekly/history") +@jwt_required() +def digest_history(): + """Return digests for the last N weeks.""" + uid = int(get_jwt_identity()) + weeks = min(int(request.args.get("weeks", 4)), 12) + ref_date = date.today() + + history = [] + for i in range(weeks): + week_end = ref_date - timedelta(days=7 * i) + week_start = week_end - timedelta(days=6) + prev_week_end = week_start - timedelta(days=1) + prev_week_start = prev_week_end - timedelta(days=6) + summary = _build_digest(uid, week_start, week_end, prev_week_start, prev_week_end) + history.append(summary) + + return jsonify({"weeks": weeks, "history": history}) + + +def _build_digest(uid, week_start, week_end, prev_week_start, prev_week_end): + """Build the digest payload for a single week.""" + current_expenses = _sum_expenses(uid, week_start, week_end) + current_income = _sum_income(uid, week_start, week_end) + prev_expenses = _sum_expenses(uid, prev_week_start, prev_week_end) + prev_income = _sum_income(uid, prev_week_start, prev_week_end) + + expense_delta = _pct_change(prev_expenses, current_expenses) + income_delta = _pct_change(prev_income, current_income) + + top_categories = _top_categories(uid, week_start, week_end) + daily_breakdown = _daily_breakdown(uid, week_start, week_end) + upcoming_bills = _upcoming_bills(uid, week_end) + + insights = _generate_insights( + current_expenses, prev_expenses, + current_income, prev_income, + top_categories, upcoming_bills, + ) + + return { + "period": { + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + }, + "summary": { + "total_expenses": current_expenses, + "total_income": current_income, + "net_flow": round(current_income - current_expenses, 2), + "expense_change_pct": expense_delta, + "income_change_pct": income_delta, + }, + "previous_period": { + "total_expenses": prev_expenses, + "total_income": prev_income, + }, + "top_categories": top_categories, + "daily_breakdown": daily_breakdown, + "upcoming_bills": upcoming_bills, + "insights": insights, + } + + +def _sum_expenses(uid, start, end): + """Sum non-income expenses in [start, end].""" + result = ( + 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() + ) + return round(float(result or 0), 2) + + +def _sum_income(uid, start, end): + """Sum income entries in [start, end].""" + result = ( + 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() + ) + return round(float(result or 0), 2) + + +def _pct_change(old_val, new_val): + """Calculate percentage change, None if no previous data.""" + if old_val == 0: + return None + return round(((new_val - old_val) / old_val) * 100, 2) + + +def _top_categories(uid, start, end, limit=5): + """Top spending categories for the period.""" + 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()) + .limit(limit) + .all() + ) + total_spent = sum(float(r.total or 0) for r in rows) + return [ + { + "category_id": r.category_id, + "category_name": r.category_name, + "amount": round(float(r.total or 0), 2), + "share_pct": ( + round((float(r.total or 0) / total_spent) * 100, 2) + if total_spent > 0 + else 0 + ), + } + for r in rows + ] + + +def _daily_breakdown(uid, start, end): + """Per-day expense totals for the week.""" + rows = ( + db.session.query( + Expense.spent_at, + func.coalesce(func.sum(Expense.amount), 0).label("total"), + ) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.spent_at) + .order_by(Expense.spent_at) + .all() + ) + day_map = {r.spent_at.isoformat(): round(float(r.total or 0), 2) for r in rows} + result = [] + current = start + while current <= end: + result.append({ + "date": current.isoformat(), + "amount": day_map.get(current.isoformat(), 0.0), + }) + current += timedelta(days=1) + return result + + +def _upcoming_bills(uid, ref_date, days_ahead=7): + """Bills due within the next N days.""" + cutoff = ref_date + timedelta(days=days_ahead) + bills = ( + db.session.query(Bill) + .filter( + Bill.user_id == uid, + Bill.active.is_(True), + Bill.next_due_date >= ref_date, + Bill.next_due_date <= cutoff, + ) + .order_by(Bill.next_due_date.asc()) + .all() + ) + return [ + { + "id": b.id, + "name": b.name, + "amount": round(float(b.amount), 2), + "currency": b.currency, + "due_date": b.next_due_date.isoformat(), + } + for b in bills + ] + + +def _generate_insights( + current_expenses, prev_expenses, + current_income, prev_income, + top_categories, upcoming_bills, +): + """Generate human-readable insights based on the data.""" + insights = [] + + # Spending trend + if prev_expenses > 0: + pct = ((current_expenses - prev_expenses) / prev_expenses) * 100 + if pct > 15: + insights.append({ + "type": "warning", + "message": f"Spending increased {abs(pct):.0f}% compared to last week.", + }) + elif pct < -10: + insights.append({ + "type": "positive", + "message": f"Great job! Spending decreased {abs(pct):.0f}% compared to last week.", + }) + else: + insights.append({ + "type": "neutral", + "message": "Spending is roughly stable compared to last week.", + }) + + # Net flow + net = current_income - current_expenses + if net < 0: + insights.append({ + "type": "warning", + "message": f"You spent ${abs(net):.2f} more than you earned this week.", + }) + elif net > 0: + insights.append({ + "type": "positive", + "message": f"You saved ${net:.2f} this week. Keep it up!", + }) + + # Top category alert + if top_categories: + top = top_categories[0] + if top["share_pct"] > 50: + insights.append({ + "type": "info", + "message": f"{top['category_name']} accounts for {top['share_pct']:.0f}% of your spending.", + }) + + # Upcoming bills + if upcoming_bills: + total_due = sum(b["amount"] for b in upcoming_bills) + insights.append({ + "type": "info", + "message": f"{len(upcoming_bills)} bill(s) due soon, totaling ${total_due:.2f}.", + }) + + # No data case + if current_expenses == 0 and current_income == 0: + insights.append({ + "type": "info", + "message": "No transactions recorded this week. Start tracking to get insights!", + }) + + return insights diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 0000000..8c650be --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,299 @@ +import pytest +from app.extensions import db +from app.models import ( + User, + Category, + Expense, + Bill, + BillCadence, + AuditLog, +) +from datetime import date, timedelta + + +@pytest.fixture() +def seeded_digest(client, auth_header): + """Create sample data for weekly digest testing.""" + with client.application.app_context(): + user = db.session.query(User).filter_by( + email="test@example.com" + ).first() + uid = user.id + + # Categories + groceries = Category(user_id=uid, name="Groceries") + transport = Category(user_id=uid, name="Transport") + dining = Category(user_id=uid, name="Dining") + db.session.add_all([groceries, transport, dining]) + db.session.flush() + + today = date(2026, 3, 10) + + # Current week expenses (March 4–10) + expenses = [ + Expense(user_id=uid, category_id=groceries.id, amount=50.00, + notes="weekly groceries", spent_at=date(2026, 3, 4)), + Expense(user_id=uid, category_id=transport.id, amount=15.00, + notes="bus pass", spent_at=date(2026, 3, 5)), + Expense(user_id=uid, category_id=dining.id, amount=30.00, + notes="dinner out", spent_at=date(2026, 3, 7)), + Expense(user_id=uid, category_id=groceries.id, amount=25.00, + notes="snacks", spent_at=date(2026, 3, 9)), + # Income this week + Expense(user_id=uid, amount=500.00, expense_type="INCOME", + notes="freelance payment", spent_at=date(2026, 3, 6)), + ] + db.session.add_all(expenses) + + # Previous week expenses (Feb 25 – Mar 3) + prev_expenses = [ + Expense(user_id=uid, category_id=groceries.id, amount=40.00, + notes="prev groceries", spent_at=date(2026, 2, 25)), + Expense(user_id=uid, category_id=transport.id, amount=10.00, + notes="prev bus", spent_at=date(2026, 2, 27)), + # Income previous week + Expense(user_id=uid, amount=300.00, expense_type="INCOME", + notes="prev freelance", spent_at=date(2026, 2, 26)), + ] + db.session.add_all(prev_expenses) + + # Upcoming bill + bill = Bill( + user_id=uid, + name="Internet", + amount=49.99, + next_due_date=date(2026, 3, 15), + cadence=BillCadence.MONTHLY, + active=True, + ) + db.session.add(bill) + db.session.commit() + + return auth_header + + +# ─── Authentication ─────────────────────────────────────────────── + + +class TestDigestAuth: + def test_weekly_requires_auth(self, client): + r = client.get("/digest/weekly") + assert r.status_code == 401 + + def test_history_requires_auth(self, client): + r = client.get("/digest/weekly/history") + assert r.status_code == 401 + + +# ─── Weekly Digest ──────────────────────────────────────────────── + + +class TestWeeklyDigest: + def test_returns_200(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + assert r.status_code == 200 + + def test_has_period(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + assert data["period"]["week_start"] == "2026-03-04" + assert data["period"]["week_end"] == "2026-03-10" + + def test_summary_totals(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + summary = data["summary"] + # Expenses: 50 + 15 + 30 + 25 = 120 + assert summary["total_expenses"] == 120.0 + # Income: 500 + assert summary["total_income"] == 500.0 + # Net: 500 - 120 = 380 + assert summary["net_flow"] == 380.0 + + def test_previous_period_data(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + prev = data["previous_period"] + # Previous expenses: 40 + 10 = 50 + assert prev["total_expenses"] == 50.0 + # Previous income: 300 + assert prev["total_income"] == 300.0 + + def test_expense_change_pct(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + # Change: (120 - 50) / 50 * 100 = 140% + assert data["summary"]["expense_change_pct"] == 140.0 + + def test_income_change_pct(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + # Change: (500 - 300) / 300 * 100 ≈ 66.67% + assert abs(data["summary"]["income_change_pct"] - 66.67) < 0.01 + + def test_top_categories(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + cats = data["top_categories"] + assert len(cats) == 3 + # Groceries should be first (75 > 30 > 15) + assert cats[0]["category_name"] == "Groceries" + assert cats[0]["amount"] == 75.0 + + def test_top_categories_share_pct(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + cats = data["top_categories"] + total_pct = sum(c["share_pct"] for c in cats) + assert abs(total_pct - 100.0) < 0.1 + + def test_daily_breakdown_has_7_days(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + daily = data["daily_breakdown"] + assert len(daily) == 7 + assert daily[0]["date"] == "2026-03-04" + assert daily[6]["date"] == "2026-03-10" + + def test_daily_breakdown_amounts(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + daily = data["daily_breakdown"] + # Mar 4 = 50, Mar 5 = 15, Mar 6 = 0, Mar 7 = 30, + # Mar 8 = 0, Mar 9 = 25, Mar 10 = 0 + assert daily[0]["amount"] == 50.0 + assert daily[1]["amount"] == 15.0 + assert daily[2]["amount"] == 0.0 + assert daily[3]["amount"] == 30.0 + assert daily[5]["amount"] == 25.0 + + def test_upcoming_bills(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + bills = data["upcoming_bills"] + assert len(bills) == 1 + assert bills[0]["name"] == "Internet" + assert bills[0]["amount"] == 49.99 + + def test_insights_present(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + insights = data["insights"] + assert isinstance(insights, list) + assert len(insights) > 0 + + def test_insights_spending_increase_warning(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + warnings = [i for i in data["insights"] if i["type"] == "warning"] + # 140% increase should trigger a warning + assert any("increased" in w["message"].lower() for w in warnings) + + def test_insights_positive_savings(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + positives = [i for i in data["insights"] if i["type"] == "positive"] + assert any("saved" in p["message"].lower() for p in positives) + + def test_insights_bill_reminder(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + data = r.get_json() + infos = [i for i in data["insights"] if i["type"] == "info"] + assert any("bill" in i["message"].lower() for i in infos) + + +# ─── Date Validation ────────────────────────────────────────────── + + +class TestDigestDateValidation: + def test_invalid_date_format(self, client, auth_header): + r = client.get("/digest/weekly?date=not-a-date", + headers=auth_header) + assert r.status_code == 400 + + def test_defaults_to_today(self, client, auth_header): + r = client.get("/digest/weekly", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert "period" in data + + +# ─── Empty Data ─────────────────────────────────────────────────── + + +class TestDigestEmptyData: + def test_empty_user_gets_zeros(self, client, auth_header): + r = client.get("/digest/weekly?date=2026-03-10", + headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["summary"]["total_expenses"] == 0.0 + assert data["summary"]["total_income"] == 0.0 + assert data["summary"]["net_flow"] == 0.0 + + def test_empty_user_gets_no_data_insight(self, client, auth_header): + r = client.get("/digest/weekly?date=2026-03-10", + headers=auth_header) + data = r.get_json() + messages = [i["message"] for i in data["insights"]] + assert any("no transactions" in m.lower() for m in messages) + + +# ─── History Endpoint ───────────────────────────────────────────── + + +class TestDigestHistory: + def test_returns_200(self, client, seeded_digest): + r = client.get("/digest/weekly/history", + headers=seeded_digest) + assert r.status_code == 200 + + def test_default_4_weeks(self, client, seeded_digest): + r = client.get("/digest/weekly/history", + headers=seeded_digest) + data = r.get_json() + assert data["weeks"] == 4 + assert len(data["history"]) == 4 + + def test_custom_weeks(self, client, seeded_digest): + r = client.get("/digest/weekly/history?weeks=2", + headers=seeded_digest) + data = r.get_json() + assert data["weeks"] == 2 + assert len(data["history"]) == 2 + + def test_max_12_weeks(self, client, seeded_digest): + r = client.get("/digest/weekly/history?weeks=99", + headers=seeded_digest) + data = r.get_json() + assert data["weeks"] == 12 + + +# ─── Audit Logging ──────────────────────────────────────────────── + + +class TestDigestAudit: + def test_creates_audit_log(self, client, seeded_digest): + r = client.get("/digest/weekly?date=2026-03-10", + headers=seeded_digest) + assert r.status_code == 200 + with client.application.app_context(): + logs = db.session.query(AuditLog).filter_by( + action="WEEKLY_DIGEST_VIEWED" + ).all() + assert len(logs) >= 1