Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,20 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Smart weekly financial digest
CREATE TABLE IF NOT EXISTS weekly_digests (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
period_type VARCHAR(20) NOT NULL DEFAULT 'weekly',
total_spent NUMERIC(12,2) NOT NULL DEFAULT 0,
category_breakdown JSONB NOT NULL DEFAULT '{}',
trends JSONB NOT NULL DEFAULT '[]',
insights JSONB NOT NULL DEFAULT '[]',
generated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_weekly_digests_user_period
ON weekly_digests(user_id, period_start, period_end);
16 changes: 16 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,19 @@ 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"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
period_start = db.Column(db.Date, nullable=False)
period_end = db.Column(db.Date, nullable=False)
period_type = db.Column(db.String(20), default="weekly", nullable=False)
total_spent = db.Column(db.Numeric(12, 2), default=0, nullable=False)
category_breakdown = db.Column(db.JSON, default=dict, nullable=False)
trends = db.Column(db.JSON, default=list, nullable=False)
insights = db.Column(db.JSON, default=list, nullable=False)
generated_at = db.Column(
db.DateTime, default=datetime.utcnow, nullable=False
)
3 changes: 3 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from flask import Flask

from .auth import bp as auth_bp
from .expenses import bp as expenses_bp
from .bills import bp as bills_bp
Expand All @@ -7,6 +8,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):
Expand All @@ -18,3 +20,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")
60 changes: 60 additions & 0 deletions packages/backend/app/routes/digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Smart digest routes — weekly financial summaries with trends."""

from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity

from ..services import digest as digest_service

bp = Blueprint("digest", __name__)


@bp.route("", methods=["GET"])
@jwt_required()
def get_digest():
"""Generate or retrieve digest for the current period."""
user_id = get_jwt_identity()
period = request.args.get("period", "weekly")
if period not in ("weekly", "monthly"):
return jsonify({"error": "period must be 'weekly' or 'monthly'"}), 400

data = digest_service.generate_digest(user_id, period=period)
return jsonify(data), 200


@bp.route("/history", methods=["GET"])
@jwt_required()
def get_digest_history():
"""Return historical digests."""
user_id = get_jwt_identity()
limit = request.args.get("limit", 10, type=int)
period = request.args.get("period")

data = digest_service.get_digest_history(user_id, limit=limit, period=period)
return jsonify(data), 200


@bp.route("/generate", methods=["POST"])
@jwt_required()
def force_generate():
"""Force generate a digest for a specific period."""
user_id = get_jwt_identity()
body = request.get_json(silent=True) or {}
period = body.get("period", "weekly")
reference_date_str = body.get("reference_date")

if period not in ("weekly", "monthly"):
return jsonify({"error": "period must be 'weekly' or 'monthly'"}), 400

reference_date = None
if reference_date_str:
try:
from datetime import date

reference_date = date.fromisoformat(reference_date_str)
except ValueError:
return jsonify({"error": "Invalid reference_date format"}), 400

data = digest_service.generate_digest(
user_id, period=period, reference_date=reference_date
)
return jsonify(data), 201
239 changes: 239 additions & 0 deletions packages/backend/app/services/digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""Smart digest service — weekly financial summary with trends and insights."""

from datetime import datetime, timedelta
from sqlalchemy import func

from ..extensions import db
from ..models import Expense, WeeklyDigest

import logging

logger = logging.getLogger("finmind.digest")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _period_bounds(period="weekly", reference_date=None):
"""Return (start, end) for the requested period.

Weekly periods start on Monday. Monthly periods start on the 1st.
"""
ref = reference_date or datetime.utcnow().date()
if period == "weekly":
start = ref - timedelta(days=ref.weekday())
end = start + timedelta(days=6)
elif period == "monthly":
start = ref.replace(day=1)
if ref.month == 12:
end = ref.replace(year=ref.year + 1, month=1, day=1) - timedelta(days=1)
else:
end = ref.replace(month=ref.month + 1, day=1) - timedelta(days=1)
else:
raise ValueError(f"Unknown period: {period}")
return start, end


def _category_breakdown(query_result):
"""Convert raw query rows into {category: total} dict."""
breakdown = {}
for cat, total in query_result:
if cat:
breakdown[cat] = float(total)
return breakdown


def _detect_trends(current, previous):
"""Compare two breakdown dicts and return trend list."""
trends = []
all_cats = set(current) | set(previous)
for cat in sorted(all_cats):
cur = current.get(cat, 0)
prev = previous.get(cat, 0)
if prev == 0:
if cur > 0:
change_pct = 100.0
direction = "new"
else:
continue
else:
change_pct = round(((cur - prev) / prev) * 100, 1)
direction = "up" if change_pct > 0 else "down"

if abs(change_pct) >= 15:
trends.append(
{
"category": cat,
"current": cur,
"previous": prev,
"change_pct": change_pct,
"direction": direction,
}
)
return trends


def _generate_insights(total_current, total_previous, breakdown, trends):
"""Produce human-readable insight strings."""
insights = []

# Top spending category
if breakdown:
top_cat = max(breakdown, key=breakdown.get)
top_amount = breakdown[top_cat]
if total_current > 0:
pct = round((top_amount / total_current) * 100, 1)
insights.append(f"Top category: {top_cat} ({pct}% of total spending)")

# Significant changes
for t in trends:
if t["direction"] == "up":
insights.append(
f"{t['category']} spending up {abs(t['change_pct'])}% "
f"({t['previous']:.0f} -> {t['current']:.0f})"
)
elif t["direction"] == "down":
insights.append(
f"{t['category']} spending down {abs(t['change_pct'])}% "
f"({t['previous']:.0f} -> {t['current']:.0f})"
)
elif t["direction"] == "new":
insights.append(f"New spending in {t['category']} ({t['current']:.0f})")

# Overall comparison
if total_previous > 0:
overall_change = round(
((total_current - total_previous) / total_previous) * 100, 1
)
if overall_change > 10:
insights.append(f"Overall spending up {overall_change}% from last period")
elif overall_change < -10:
insights.append(
f"Overall spending down {abs(overall_change)}% from last period"
)

if not insights:
insights.append("Spending patterns are stable this period.")

return insights


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def generate_digest(user_id, period="weekly", reference_date=None):
"""Generate a financial digest for the given period.

Returns a dict with total, breakdown, trends, and insights.
Creates/updates a WeeklyDigest record.
"""
start, end = _period_bounds(period, reference_date)

# --- Current period ---
current_rows = (
db.session.query(Expense.category, func.sum(Expense.amount))
.filter(
Expense.user_id == user_id,
Expense.date >= start,
Expense.date <= end,
)
.group_by(Expense.category)
.all()
)
current_breakdown = _category_breakdown(current_rows)
total_current = sum(current_breakdown.values())

# --- Previous period ---
period_days = (end - start).days + 1
prev_end = start - timedelta(days=1)
prev_start = prev_end - timedelta(days=period_days - 1)

previous_rows = (
db.session.query(Expense.category, func.sum(Expense.amount))
.filter(
Expense.user_id == user_id,
Expense.date >= prev_start,
Expense.date <= prev_end,
)
.group_by(Expense.category)
.all()
)
previous_breakdown = _category_breakdown(previous_rows)
total_previous = sum(previous_breakdown.values())

# --- Trends & insights ---
trends = _detect_trends(current_breakdown, previous_breakdown)
insights = _generate_insights(
total_current, total_previous, current_breakdown, trends
)

digest_data = {
"period_start": start.isoformat(),
"period_end": end.isoformat(),
"period_type": period,
"total_spent": total_current,
"total_previous": total_previous,
"category_breakdown": current_breakdown,
"trends": trends,
"insights": insights,
}

# Upsert digest record
existing = WeeklyDigest.query.filter_by(
user_id=user_id,
period_start=start,
period_end=end,
).first()

if existing:
existing.total_spent = total_current
existing.category_breakdown = current_breakdown
existing.trends = trends
existing.insights = insights
existing.generated_at = datetime.utcnow()
else:
existing = WeeklyDigest(
user_id=user_id,
period_start=start,
period_end=end,
period_type=period,
total_spent=total_current,
category_breakdown=current_breakdown,
trends=trends,
insights=insights,
generated_at=datetime.utcnow(),
)
db.session.add(existing)

db.session.commit()
digest_data["id"] = existing.id
return digest_data


def get_digest_history(user_id, limit=10, period=None):
"""Return historical digests for a user."""
q = WeeklyDigest.query.filter_by(user_id=user_id)
if period:
q = q.filter_by(period_type=period)
q = q.order_by(WeeklyDigest.period_start.desc())
if limit:
q = q.limit(limit)
digests = q.all()
return [
{
"id": d.id,
"period_start": d.period_start.isoformat(),
"period_end": d.period_end.isoformat(),
"period_type": d.period_type,
"total_spent": d.total_spent,
"category_breakdown": d.category_breakdown,
"trends": d.trends,
"insights": d.insights,
"generated_at": d.generated_at.isoformat(),
}
for d in digests
]
Loading