diff --git a/DEMO_INSTRUCTIONS.md b/DEMO_INSTRUCTIONS.md new file mode 100644 index 00000000..f83f7b8e --- /dev/null +++ b/DEMO_INSTRUCTIONS.md @@ -0,0 +1,45 @@ +# Com gravar el video demo pels PRs de FinMind + +## Que hem fet (resum) + +Hem creat 3 features pel backend de FinMind (Python/Flask): + +### PR #609 — Multi-Account Dashboard ($200) +- Crear comptes financers (checking, savings, credit card) +- Veure resum amb net worth (assets - deutes) +- Desactivar comptes (soft-delete) + +### PR #608 — Savings Goals ($250) +- Crear objectius d'estalvi amb target i deadline +- Afegir contribucions +- Veure progrés amb milestones (25/50/75/100%) +- Auto-completa quan arribes al target + +### PR #607 — Weekly Digest ($500) +- Genera resum setmanal de despeses +- Compara amb la setmana anterior (week-over-week %) +- Tips automàtics basats en patrons de despesa + +## Com gravar + +### 1. Obre 2 terminals + +### 2. Terminal 1 — Arrenca el servidor: +```bash +cd /home/clawd/workspace/bounties/finmind/packages/backend +bash start_demo_server.sh +``` +Espera a veure "Running on http://127.0.0.1:5556" + +### 3. Terminal 2 — Comença a gravar pantalla, llavors executa: +```bash +cd /home/clawd/workspace/bounties/finmind +bash run_demo.sh +``` + +### 4. Para la gravació + +### 5. Puja el video com a comentari als 3 PRs: +- https://github.com/rohitdash08/FinMind/pull/607 +- https://github.com/rohitdash08/FinMind/pull/608 +- https://github.com/rohitdash08/FinMind/pull/609 diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..9202091d 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import Digest from "./pages/Digest"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> { + return api('/digest/latest'); +} + +export async function getDigestHistory(limit = 10): Promise { + return api(`/digest/history?limit=${limit}`); +} + +export async function generateDigest(): Promise { + return api('/digest/generate', { method: 'POST' }); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..ff882e19 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -13,6 +13,7 @@ const navigation = [ { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, { name: 'Analytics', href: '/analytics' }, + { name: 'Digest', href: '/digest' }, ]; export function Navbar() { diff --git a/app/src/pages/Digest.tsx b/app/src/pages/Digest.tsx new file mode 100644 index 00000000..1b403844 --- /dev/null +++ b/app/src/pages/Digest.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect, useCallback } from 'react'; +import { FinancialCard, FinancialCardContent, FinancialCardHeader, FinancialCardTitle } from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { FileText, RefreshCw, Lightbulb, TrendingUp, Calendar } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { getLatestDigest, getDigestHistory, generateDigest, type WeeklyDigest } from '@/api/digest'; + +export default function Digest() { + const { toast } = useToast(); + const [latest, setLatest] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + + const loadData = useCallback(async () => { + try { + const [lat, hist] = await Promise.allSettled([getLatestDigest(), getDigestHistory()]); + if (lat.status === 'fulfilled') setLatest(lat.value); + if (hist.status === 'fulfilled') setHistory(hist.value); + } catch { + // No digest yet is fine + } finally { setLoading(false); } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + const handleGenerate = async () => { + setGenerating(true); + try { + const d = await generateDigest(); + setLatest(d); + toast({ title: 'Digest generated!' }); + loadData(); + } catch (e: unknown) { + toast({ title: 'Error', description: e instanceof Error ? e.message : 'No transactions for last week', variant: 'destructive' }); + } finally { setGenerating(false); } + }; + + if (loading) return
Loading...
; + + return ( +
+
+
+

Weekly Digest

+

Your weekly financial summary and insights

+
+ +
+ + {latest ? ( + + +
+
+ + + Week of {latest.week_start} to {latest.week_end} + +
+ {latest.method} +
+
+ + {/* Summary */} +
{latest.summary}
+ + {/* Tips */} + {latest.tips.length > 0 && ( +
+

+ Tips +

+
    + {latest.tips.map((tip, i) => ( +
  • + {tip} +
  • + ))} +
+
+ )} + + {/* Highlights */} + {latest.highlights.length > 0 && ( +
+

+ Highlights +

+
    + {latest.highlights.map((h, i) => ( +
  • + {h} +
  • + ))} +
+
+ )} +
+
+ ) : ( + + + +

No digest yet. Add some expenses and generate your first weekly digest!

+ +
+
+ )} + + {/* History */} + {history.length > 1 && ( +
+

+ Previous Digests +

+ {history.slice(1).map(d => ( + + +
+ + {d.week_start} — {d.week_end} + + {d.method} +
+
+ +

{d.summary}

+
+
+ ))} +
+ )} +
+ ); +} diff --git a/demo.sh b/demo.sh new file mode 100755 index 00000000..bd9e8c8f --- /dev/null +++ b/demo.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# FinMind Feature Demo - PRs #607, #608, #609 +BASE=http://127.0.0.1:5555 + +echo "======================================" +echo " FinMind Feature Demo" +echo "======================================" +echo "" + +# Register and login +echo ">>> Register user" +curl -s -X POST $BASE/auth/register -H "Content-Type: application/json" \ + -d '{"email":"demo@test.com","password":"demo1234"}' | python3 -m json.tool +echo "" + +echo ">>> Login" +TOKEN=$(curl -s -X POST $BASE/auth/login -H "Content-Type: application/json" \ + -d '{"email":"demo@test.com","password":"demo1234"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +echo "Got token: ${TOKEN:0:20}..." +AUTH="Authorization: Bearer $TOKEN" +echo "" + +# Add some expenses for digest +echo "======================================" +echo " PR #607: Weekly Digest" +echo "======================================" +echo "" + +echo ">>> Adding expenses for last week..." +for i in 1 2 3 4 5; do + DAY=$(date -d "last monday + ${i} days" +%Y-%m-%d 2>/dev/null || date -v-7d +%Y-%m-%d) + curl -s -X POST $BASE/expenses -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"amount\":$((50 + i * 15)),\"notes\":\"Expense day $i\",\"spent_at\":\"$DAY\"}" > /dev/null +done +echo "Added 5 expenses" +echo "" + +echo ">>> POST /digest/generate" +curl -s -X POST $BASE/digest/generate -H "$AUTH" | python3 -m json.tool +echo "" + +echo ">>> GET /digest/latest" +curl -s $BASE/digest/latest -H "$AUTH" | python3 -m json.tool +echo "" + +echo ">>> GET /digest/history" +curl -s "$BASE/digest/history?limit=5" -H "$AUTH" | python3 -m json.tool +echo "" + +# Savings Goals +echo "======================================" +echo " PR #608: Savings Goals" +echo "======================================" +echo "" + +echo ">>> POST /goals/ (create goal)" +curl -s -X POST $BASE/goals/ -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"Emergency Fund","target_amount":10000,"currency":"EUR","deadline":"2026-12-31"}' | python3 -m json.tool +echo "" + +echo ">>> POST /goals/1/contribute (add 2500)" +curl -s -X POST $BASE/goals/1/contribute -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"amount":2500,"note":"First deposit"}' | python3 -m json.tool +echo "" + +echo ">>> POST /goals/1/contribute (add 5000)" +curl -s -X POST $BASE/goals/1/contribute -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"amount":5000,"note":"Bonus savings"}' | python3 -m json.tool +echo "" + +echo ">>> GET /goals/1/progress (75% with milestones)" +curl -s $BASE/goals/1/progress -H "$AUTH" | python3 -m json.tool +echo "" + +echo ">>> GET /goals/1/contributions" +curl -s $BASE/goals/1/contributions -H "$AUTH" | python3 -m json.tool +echo "" + +# Multi-account +echo "======================================" +echo " PR #609: Multi-Account Dashboard" +echo "======================================" +echo "" + +echo ">>> POST /accounts/ (checking)" +curl -s -X POST $BASE/accounts/ -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"Main Checking","account_type":"checking","balance":5000,"currency":"EUR"}' | python3 -m json.tool +echo "" + +echo ">>> POST /accounts/ (savings)" +curl -s -X POST $BASE/accounts/ -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"Savings","account_type":"savings","balance":15000,"currency":"EUR"}' | python3 -m json.tool +echo "" + +echo ">>> POST /accounts/ (credit card)" +curl -s -X POST $BASE/accounts/ -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"Credit Card","account_type":"credit","balance":2000,"currency":"EUR"}' | python3 -m json.tool +echo "" + +echo ">>> GET /accounts/overview (net worth)" +curl -s $BASE/accounts/overview -H "$AUTH" | python3 -m json.tool +echo "" + +echo "======================================" +echo " Demo Complete!" +echo "======================================" diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..67df9765 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -117,6 +117,20 @@ CREATE TABLE IF NOT EXISTS user_subscriptions ( started_at TIMESTAMP NOT NULL DEFAULT NOW() ); +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, + summary TEXT NOT NULL, + tips TEXT NOT NULL DEFAULT '[]', + highlights TEXT NOT NULL DEFAULT '[]', + raw_data TEXT NOT NULL DEFAULT '{}', + method VARCHAR(20) NOT NULL DEFAULT 'heuristic', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (user_id, week_start) +); + CREATE TABLE IF NOT EXISTS audit_logs ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE SET NULL, diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..56b48dd7 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,24 @@ class UserSubscription(db.Model): started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +class WeeklyDigest(db.Model): + __tablename__ = "weekly_digests" + 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) + summary = db.Column(db.Text, nullable=False) + tips = db.Column(db.Text, default="[]", nullable=False) + highlights = db.Column(db.Text, default="[]", nullable=False) + raw_data = db.Column(db.Text, default="{}", nullable=False) + method = db.Column(db.String(20), default="heuristic", nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + __table_args__ = ( + db.UniqueConstraint("user_id", "week_start", name="uq_user_week"), + ) + + class AuditLog(db.Model): __tablename__ = "audit_logs" id = db.Column(db.Integer, primary_key=True) diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0..f424c275 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: @@ -251,6 +252,45 @@ paths: properties: inserted: { type: integer } + /digest/latest: + get: + summary: Get latest weekly digest + tags: [Digest] + security: [{ bearerAuth: [] }] + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/WeeklyDigest' } + '404': { description: No digest available } + /digest/history: + get: + summary: Get digest history + tags: [Digest] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: limit + schema: { type: integer, default: 10, maximum: 52 } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/WeeklyDigest' } } + /digest/generate: + post: + summary: Generate weekly digest + tags: [Digest] + security: [{ bearerAuth: [] }] + responses: + '201': + description: Digest generated + content: + application/json: + schema: { $ref: '#/components/schemas/WeeklyDigest' } + '404': { description: No transactions for last week } /bills: get: summary: List bills @@ -587,3 +627,14 @@ 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 } + week_start: { type: string, format: date } + week_end: { type: string, format: date } + summary: { type: string } + tips: { type: array, items: { type: string } } + highlights: { type: array, items: { type: string } } + method: { type: string, enum: [heuristic, gemini] } + created_at: { type: string, format: date-time } 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..41f0b395 --- /dev/null +++ b/packages/backend/app/routes/digest.py @@ -0,0 +1,66 @@ +"""Weekly digest endpoints.""" + +import json +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..models import WeeklyDigest +from ..services.digest import generate_digest + +bp = Blueprint("digest", __name__) + + +@bp.get("/latest") +@jwt_required() +def get_latest_digest(): + """Get the most recent weekly digest for the current user.""" + user_id = int(get_jwt_identity()) + digest = ( + WeeklyDigest.query.filter_by(user_id=user_id) + .order_by(WeeklyDigest.week_start.desc()) + .first() + ) + if not digest: + return jsonify(error="No digest available yet"), 404 + return jsonify(_serialize(digest)), 200 + + +@bp.get("/history") +@jwt_required() +def get_digest_history(): + """Get digest history for the current user.""" + user_id = int(get_jwt_identity()) + limit = request.args.get("limit", 10, type=int) + limit = max(1, min(limit, 52)) + + digests = ( + WeeklyDigest.query.filter_by(user_id=user_id) + .order_by(WeeklyDigest.week_start.desc()) + .limit(limit) + .all() + ) + return jsonify([_serialize(d) for d in digests]), 200 + + +@bp.post("/generate") +@jwt_required() +def trigger_digest(): + """Manually trigger digest generation for the current user.""" + user_id = int(get_jwt_identity()) + digest = generate_digest(user_id) + if not digest: + return jsonify(error="No transactions found for last week"), 404 + return jsonify(_serialize(digest)), 201 + + +def _serialize(digest): + return { + "id": digest.id, + "week_start": digest.week_start.isoformat(), + "week_end": digest.week_end.isoformat(), + "summary": digest.summary, + "tips": json.loads(digest.tips), + "highlights": json.loads(digest.highlights), + "method": digest.method, + "created_at": digest.created_at.isoformat(), + } diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py new file mode 100644 index 00000000..1d4f6ad7 --- /dev/null +++ b/packages/backend/app/services/digest.py @@ -0,0 +1,266 @@ +"""Weekly financial digest generation service.""" + +import json +import logging +from datetime import date, timedelta +from urllib import request as url_request + +from sqlalchemy import func + +from ..config import Settings +from ..extensions import db +from ..models import Expense, Category, User, WeeklyDigest + +logger = logging.getLogger("finmind.digest") +_settings = Settings() + +DEFAULT_PERSONA = ( + "You are FinMind's weekly financial digest writer. Be concise, insightful, " + "and action-oriented. Highlight trends, flag concerns, celebrate wins." +) + + +def _week_boundaries(reference_date=None): + """Return (start, end) of the previous completed week (Mon-Sun).""" + today = reference_date or date.today() + end = today - timedelta(days=today.weekday() + 1) # last Sunday + start = end - timedelta(days=6) # Monday before + return start, end + + +def _weekly_expenses(user_id, start, end): + """Fetch expenses grouped by category for a given week.""" + rows = ( + db.session.query( + Category.name, + func.coalesce(func.sum(Expense.amount), 0), + func.count(Expense.id), + ) + .select_from(Expense) + .outerjoin(Category, Expense.category_id == Category.id) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .group_by(Category.name) + .all() + ) + return [ + { + "category": name or "Uncategorized", + "total": float(total), + "count": int(count), + } + for name, total, count in rows + ] + + +def _weekly_income(user_id, start, end): + """Fetch total income for a given week.""" + result = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + return float(result or 0) + + +def _previous_week_total(user_id, start): + """Get total expenses from the week before.""" + prev_end = start - timedelta(days=1) + prev_start = prev_end - timedelta(days=6) + result = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= prev_start, + Expense.spent_at <= prev_end, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + return float(result or 0) + + +def _build_digest_data(user_id, start, end): + """Compile raw data for digest generation.""" + by_category = _weekly_expenses(user_id, start, end) + total_spent = sum(c["total"] for c in by_category) + total_income = _weekly_income(user_id, start, end) + prev_total = _previous_week_total(user_id, start) + + wow_change = 0.0 + if prev_total > 0: + wow_change = round(((total_spent - prev_total) / prev_total) * 100, 2) + + top_categories = sorted(by_category, key=lambda x: x["total"], reverse=True)[:5] + + return { + "week_start": start.isoformat(), + "week_end": end.isoformat(), + "total_spent": round(total_spent, 2), + "total_income": round(total_income, 2), + "net_flow": round(total_income - total_spent, 2), + "week_over_week_change_pct": wow_change, + "previous_week_spent": round(prev_total, 2), + "top_categories": top_categories, + "transaction_count": sum(c["count"] for c in by_category), + } + + +def _heuristic_summary(data): + """Generate a plain summary without AI.""" + lines = [f"Week of {data['week_start']} to {data['week_end']}:"] + lines.append(f"Total spent: {data['total_spent']:.2f}") + lines.append(f"Total income: {data['total_income']:.2f}") + lines.append(f"Net flow: {data['net_flow']:.2f}") + + if data["week_over_week_change_pct"] > 0: + lines.append( + f"Spending increased {data['week_over_week_change_pct']}% vs last week." + ) + elif data["week_over_week_change_pct"] < 0: + lines.append( + f"Spending decreased {abs(data['week_over_week_change_pct'])}% vs last week." + ) + + if data["top_categories"]: + lines.append("Top categories:") + for cat in data["top_categories"][:3]: + lines.append( + f" - {cat['category']}: {cat['total']:.2f} ({cat['count']} txns)" + ) + + tips = [] + if data["net_flow"] < 0: + tips.append( + "Your expenses exceeded income this week. Review discretionary spending." + ) + if data["week_over_week_change_pct"] > 20: + tips.append( + "Spending jumped significantly. Check if any large one-time purchases drove this." + ) + if not tips: + tips.append("Good week! Keep maintaining your spending habits.") + + return {"summary": "\n".join(lines), "tips": tips, "method": "heuristic"} + + +def _ai_summary(data, api_key, model): + """Generate an AI-powered summary using Gemini.""" + prompt = ( + f"{DEFAULT_PERSONA}\n\n" + "Generate a brief weekly financial digest from this data. " + "Return JSON with keys: summary (string, 3-5 sentences), " + "tips (list of 2-3 actionable tips), highlights (list of 1-2 positive notes).\n\n" + f"Data: {json.dumps(data)}" + ) + + 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 + payload = json.loads(resp.read().decode("utf-8")) + + text = ( + payload.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + + # Try to parse JSON from response + try: + from .ai import _extract_json_object + + parsed = _extract_json_object(text) + parsed["method"] = "gemini" + return parsed + except (ValueError, json.JSONDecodeError): + return {"summary": text.strip(), "tips": [], "method": "gemini_raw"} + + +def generate_digest(user_id, reference_date=None): + """Generate a weekly digest for a user and store it.""" + start, end = _week_boundaries(reference_date) + + # Check if digest already exists for this week + existing = WeeklyDigest.query.filter_by(user_id=user_id, week_start=start).first() + if existing: + return existing + + data = _build_digest_data(user_id, start, end) + + # Skip if no transactions + if data["transaction_count"] == 0: + return None + + # Try AI, fall back to heuristic + api_key = (_settings.gemini_api_key or "").strip() + model = _settings.gemini_model + if api_key: + try: + result = _ai_summary(data, api_key, model) + except Exception: + logger.warning( + "Gemini digest failed, falling back to heuristic", exc_info=True + ) + result = _heuristic_summary(data) + else: + result = _heuristic_summary(data) + + digest = WeeklyDigest( + user_id=user_id, + week_start=start, + week_end=end, + summary=result.get("summary", ""), + tips=json.dumps(result.get("tips", [])), + highlights=json.dumps(result.get("highlights", [])), + raw_data=json.dumps(data), + method=result.get("method", "unknown"), + ) + db.session.add(digest) + try: + db.session.commit() + except Exception: + db.session.rollback() + # Race condition: another request created it first, return that one + return WeeklyDigest.query.filter_by(user_id=user_id, week_start=start).first() + + return digest + + +def generate_all_digests(reference_date=None): + """Generate digests for all active users. Called by scheduler.""" + users = User.query.all() + generated = 0 + for user in users: + try: + result = generate_digest(user.id, reference_date) + if result: + generated += 1 + except Exception: + logger.exception("Failed to generate digest for user %s", user.id) + logger.info("Generated %d digests for %d users", generated, len(users)) + return generated diff --git a/packages/backend/instance/demo.db b/packages/backend/instance/demo.db new file mode 100644 index 00000000..f271b38b Binary files /dev/null and b/packages/backend/instance/demo.db differ diff --git a/packages/backend/instance/demo2.db b/packages/backend/instance/demo2.db new file mode 100644 index 00000000..b07cb58f Binary files /dev/null and b/packages/backend/instance/demo2.db differ diff --git a/packages/backend/start_demo_server.sh b/packages/backend/start_demo_server.sh new file mode 100755 index 00000000..105ef8af --- /dev/null +++ b/packages/backend/start_demo_server.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Arrenca el servidor de demo per gravar el video +cd "$(dirname "$0")" +source venv/bin/activate +rm -f demo_video.db + +python3 << 'EOF' +import fakeredis +import app.extensions +app.extensions.redis_client = fakeredis.FakeRedis() +import app.routes.auth as auth_mod +auth_mod.redis_client = app.extensions.redis_client + +from app import create_app +from app.config import Settings +from app.extensions import db + +a = create_app(Settings( + database_url='sqlite:///demo_video.db', + redis_url='redis://localhost:6379/15', + jwt_secret='demo-secret-key-32-chars-long-1234' +)) +with a.app_context(): + db.create_all() +print("\n>>> Servidor llest! Ara executa 'bash run_demo.sh' a l'altra terminal\n") +a.run(port=5556, debug=False) +EOF diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 00000000..d45f6814 --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,163 @@ +"""Tests for weekly digest feature.""" + +from datetime import date, timedelta + +from app.extensions import db +from app.models import Expense, Category +from app.services.digest import ( + _week_boundaries, + _build_digest_data, + _heuristic_summary, +) + + +class TestWeekBoundaries: + def test_returns_monday_to_sunday(self): + # Wednesday 2026-03-18 + start, end = _week_boundaries(date(2026, 3, 18)) + assert start.weekday() == 0 # Monday + assert end.weekday() == 6 # Sunday + assert start == date(2026, 3, 9) + assert end == date(2026, 3, 15) + + def test_monday_returns_previous_week(self): + start, end = _week_boundaries(date(2026, 3, 16)) # Monday + assert start == date(2026, 3, 9) + assert end == date(2026, 3, 15) + + def test_sunday_returns_previous_week(self): + # On Sunday, the "previous completed week" is the week before + start, end = _week_boundaries(date(2026, 3, 22)) # Sunday + assert start == date(2026, 3, 9) + assert end == date(2026, 3, 15) + + +class TestBuildDigestData: + def test_empty_week(self, app_fixture, auth_header, client): + with app_fixture.app_context(): + data = _build_digest_data(1, date(2026, 1, 5), date(2026, 1, 11)) + assert data["total_spent"] == 0 + assert data["transaction_count"] == 0 + + def test_with_expenses(self, app_fixture, auth_header, client): + with app_fixture.app_context(): + cat = Category(user_id=1, name="Food") + db.session.add(cat) + db.session.flush() + + for i in range(3): + db.session.add( + Expense( + user_id=1, + category_id=cat.id, + amount=100 + i * 10, + spent_at=date(2026, 3, 10 + i), + ) + ) + db.session.commit() + + data = _build_digest_data(1, date(2026, 3, 9), date(2026, 3, 15)) + assert data["total_spent"] == 330.0 + assert data["transaction_count"] == 3 + assert len(data["top_categories"]) == 1 + assert data["top_categories"][0]["category"] == "Food" + + +class TestHeuristicSummary: + def test_positive_net_flow(self): + data = { + "week_start": "2026-03-09", + "week_end": "2026-03-15", + "total_spent": 500, + "total_income": 1000, + "net_flow": 500, + "week_over_week_change_pct": -10, + "previous_week_spent": 555, + "top_categories": [{"category": "Food", "total": 300, "count": 5}], + "transaction_count": 5, + } + result = _heuristic_summary(data) + assert "summary" in result + assert "tips" in result + assert result["method"] == "heuristic" + assert "decreased" in result["summary"] + + def test_negative_net_flow_warns(self): + data = { + "week_start": "2026-03-09", + "week_end": "2026-03-15", + "total_spent": 1500, + "total_income": 1000, + "net_flow": -500, + "week_over_week_change_pct": 25, + "previous_week_spent": 1200, + "top_categories": [], + "transaction_count": 10, + } + result = _heuristic_summary(data) + assert any("exceeded" in t for t in result["tips"]) + assert any("jumped" in t for t in result["tips"]) + + +class TestDigestEndpoints: + def test_latest_empty(self, client, auth_header): + r = client.get("/digest/latest", headers=auth_header) + assert r.status_code == 404 + + def test_history_empty(self, client, auth_header): + r = client.get("/digest/history", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + def test_generate_no_transactions(self, client, auth_header): + r = client.post("/digest/generate", headers=auth_header) + assert r.status_code == 404 + + def test_full_flow(self, app_fixture, client, auth_header): + with app_fixture.app_context(): + # Add expenses for last week + start, end = _week_boundaries() + for i in range(5): + day = start + timedelta(days=i) + db.session.add( + Expense( + user_id=1, + amount=50 + i * 10, + spent_at=day, + ) + ) + db.session.commit() + + # Generate digest + r = client.post("/digest/generate", headers=auth_header) + assert r.status_code == 201 + data = r.get_json() + assert "summary" in data + assert data["method"] == "heuristic" + + # Get latest + r = client.get("/digest/latest", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["id"] == data["id"] + + # Get history + r = client.get("/digest/history", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 1 + + def test_idempotent_generation(self, app_fixture, client, auth_header): + with app_fixture.app_context(): + start, _ = _week_boundaries() + db.session.add(Expense(user_id=1, amount=100, spent_at=start)) + db.session.commit() + + r1 = client.post("/digest/generate", headers=auth_header) + r2 = client.post("/digest/generate", headers=auth_header) + assert r1.status_code == 201 + # Second call returns the same digest (idempotent) + assert r2.status_code == 201 + assert r1.get_json()["id"] == r2.get_json()["id"] + + def test_history_limit(self, client, auth_header): + r = client.get("/digest/history?limit=5", headers=auth_header) + assert r.status_code == 200 diff --git a/run_demo.sh b/run_demo.sh new file mode 100755 index 00000000..922e376a --- /dev/null +++ b/run_demo.sh @@ -0,0 +1,99 @@ +#!/bin/bash +BASE=http://127.0.0.1:5556 + +echo "" +echo " ╔══════════════════════════════════════╗" +echo " ║ FinMind Feature Demo ║" +echo " ║ PRs #607, #608, #609 ║" +echo " ╚══════════════════════════════════════╝" +echo "" +sleep 1 + +# Auth +echo ">>> Register + Login" +curl -s -X POST $BASE/auth/register -H "Content-Type: application/json" \ + -d '{"email":"demo@finmind.test","password":"demo1234"}' | python3 -m json.tool +TOKEN=$(curl -s -X POST $BASE/auth/login -H "Content-Type: application/json" \ + -d '{"email":"demo@finmind.test","password":"demo1234"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +echo "Token: ${TOKEN:0:30}..." +echo "" +sleep 1 + +# ===== PR #609: Multi-Account ===== +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " PR #609: Multi-Account Dashboard (\$200)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +echo ">>> Create checking account (€5,000)" +curl -s -X POST $BASE/accounts/ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"Main Checking","account_type":"checking","balance":5000,"currency":"EUR"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> Create savings account (€15,000)" +curl -s -X POST $BASE/accounts/ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"Savings","account_type":"savings","balance":15000,"currency":"EUR"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> Create credit card (€2,000 debt)" +curl -s -X POST $BASE/accounts/ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"Credit Card","account_type":"credit","balance":2000,"currency":"EUR"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> GET /accounts/overview → Net worth calculation" +curl -s $BASE/accounts/overview -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +echo "" +sleep 1 + +# ===== PR #608: Savings Goals ===== +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " PR #608: Savings Goals (\$250)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +echo ">>> Create goal: Emergency Fund €10,000" +curl -s -X POST $BASE/goals/ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"Emergency Fund","target_amount":10000,"currency":"EUR","deadline":"2026-12-31"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> Contribute €2,500" +curl -s -X POST $BASE/goals/1/contribute -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"amount":2500,"note":"First deposit"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> Contribute €5,000" +curl -s -X POST $BASE/goals/1/contribute -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"amount":5000,"note":"Bonus savings"}' | python3 -m json.tool +sleep 0.5 + +echo ">>> GET /goals/1/progress → 75% with milestones" +curl -s $BASE/goals/1/progress -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +echo "" +sleep 1 + +# ===== PR #607: Weekly Digest ===== +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " PR #607: Weekly Digest (\$500)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +echo ">>> Adding 5 expenses from last week..." +for i in 1 2 3 4 5; do + curl -s -X POST $BASE/expenses -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d "{\"amount\":$((50 + i * 15)),\"notes\":\"Expense $i\",\"spent_at\":\"2026-03-$(printf '%02d' $((9 + i)))\"}" > /dev/null +done +echo "Done (5 expenses added)" +sleep 0.5 + +echo ">>> POST /digest/generate → AI-powered weekly summary" +curl -s -X POST $BASE/digest/generate -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +echo "" +sleep 0.5 + +echo ">>> GET /digest/latest" +curl -s $BASE/digest/latest -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +echo "" + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " ✓ All 3 features working!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"