From d0c50e37d3d88a93e6a6acd44ec23e6372b93078 Mon Sep 17 00:00:00 2001 From: Mohye24k Date: Mon, 13 Apr 2026 22:33:26 +0200 Subject: [PATCH] feat: add monthly financial review with income/expense analysis Add monthly_review blueprint with GET /review/monthly providing income/expense totals, top categories, savings rate, month-over-month comparison, and highlights. Includes tests and TypeScript client. Fixes #102 --- app/src/api/monthlyReview.ts | 22 ++++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/monthly_review.py | 117 ++++++++++++++++++ packages/backend/tests/test_monthly_review.py | 65 ++++++++++ 4 files changed, 206 insertions(+) create mode 100644 app/src/api/monthlyReview.ts create mode 100644 packages/backend/app/routes/monthly_review.py create mode 100644 packages/backend/tests/test_monthly_review.py diff --git a/app/src/api/monthlyReview.ts b/app/src/api/monthlyReview.ts new file mode 100644 index 000000000..f11dd42ec --- /dev/null +++ b/app/src/api/monthlyReview.ts @@ -0,0 +1,22 @@ +import { api } from './client'; + +export type MonthlyReview = { + month: string; + income: number; + expenses: number; + savings_rate: number; + top_categories: Array<{ + category: string; + amount: number; + }>; + vs_previous_month: { + income_change: number; + expense_change: number; + }; + highlights: string[]; +}; + +export async function getMonthlyReview(month?: string): Promise { + const query = month ? `?month=${encodeURIComponent(month)}` : ''; + return api(`/review/monthly${query}`); +} diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..b0e8bdf29 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 .monthly_review import bp as monthly_review_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(monthly_review_bp, url_prefix="/review") diff --git a/packages/backend/app/routes/monthly_review.py b/packages/backend/app/routes/monthly_review.py new file mode 100644 index 000000000..eb7c85ab0 --- /dev/null +++ b/packages/backend/app/routes/monthly_review.py @@ -0,0 +1,117 @@ +import logging +from datetime import date +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 + +bp = Blueprint("monthly_review", __name__) +logger = logging.getLogger("finmind.monthly_review") + + +def _is_valid_month(ym: str) -> bool: + if len(ym) != 7 or ym[4] != "-": + return False + parts = ym.split("-") + if not (parts[0].isdigit() and parts[1].isdigit()): + return False + m = int(parts[1]) + return 1 <= m <= 12 + + +def _get_month_totals(uid, year, month): + income = float( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + expenses = float( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + return income, expenses + + +def _top_categories(uid, year, month, limit=5): + rows = ( + db.session.query( + func.coalesce(Category.name, "Uncategorized").label("name"), + func.sum(Expense.amount).label("total"), + ) + .outerjoin(Category, (Category.id == Expense.category_id) & (Category.user_id == uid)) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type != "INCOME", + ) + .group_by(Category.name) + .order_by(func.sum(Expense.amount).desc()) + .limit(limit) + .all() + ) + return [{"category": r.name, "amount": float(r.total)} for r in rows] + + +def _prev_month(year, month): + if month == 1: + return year - 1, 12 + return year, month - 1 + + +@bp.get("/monthly") +@jwt_required() +def monthly_review(): + uid = int(get_jwt_identity()) + ym = (request.args.get("month") or date.today().strftime("%Y-%m")).strip() + if not _is_valid_month(ym): + return jsonify(error="invalid month, expected YYYY-MM"), 400 + + year, month = map(int, ym.split("-")) + income, expenses = _get_month_totals(uid, year, month) + savings_rate = round(((income - expenses) / income) * 100, 2) if income > 0 else 0.0 + + prev_y, prev_m = _prev_month(year, month) + prev_income, prev_expenses = _get_month_totals(uid, prev_y, prev_m) + + income_change = round(income - prev_income, 2) + expense_change = round(expenses - prev_expenses, 2) + + highlights = [] + if income > prev_income: + highlights.append("Income increased vs previous month") + elif income < prev_income: + highlights.append("Income decreased vs previous month") + if expenses > prev_expenses: + highlights.append("Spending increased vs previous month") + elif expenses < prev_expenses: + highlights.append("Spending decreased vs previous month") + if savings_rate > 20: + highlights.append("Great savings rate above 20%") + + review = { + "month": ym, + "income": income, + "expenses": expenses, + "savings_rate": savings_rate, + "top_categories": _top_categories(uid, year, month), + "vs_previous_month": { + "income_change": income_change, + "expense_change": expense_change, + }, + "highlights": highlights, + } + logger.info("Monthly review user=%s month=%s", uid, ym) + return jsonify(review) diff --git a/packages/backend/tests/test_monthly_review.py b/packages/backend/tests/test_monthly_review.py new file mode 100644 index 000000000..756d62bed --- /dev/null +++ b/packages/backend/tests/test_monthly_review.py @@ -0,0 +1,65 @@ +from datetime import date, timedelta + + +def test_monthly_review_requires_auth(client): + r = client.get("/review/monthly") + assert r.status_code in (401, 422) + + +def test_monthly_review_empty(client, auth_header): + r = client.get("/review/monthly?month=2025-01", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["month"] == "2025-01" + assert data["income"] == 0 + assert data["expenses"] == 0 + assert data["savings_rate"] == 0 + assert data["top_categories"] == [] + + +def test_monthly_review_with_data(client, auth_header): + today = date.today() + ym = today.strftime("%Y-%m") + + r = client.post("/categories", json={"name": "Food"}, headers=auth_header) + assert r.status_code == 201 + food_id = r.get_json()["id"] + + client.post( + "/expenses", + json={ + "amount": 5000, + "description": "Salary", + "date": today.isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + client.post( + "/expenses", + json={ + "amount": 800, + "description": "Groceries", + "date": today.isoformat(), + "expense_type": "EXPENSE", + "category_id": food_id, + }, + headers=auth_header, + ) + + r = client.get(f"/review/monthly?month={ym}", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["income"] >= 5000 + assert data["expenses"] >= 800 + assert data["savings_rate"] > 0 + assert len(data["top_categories"]) >= 1 + assert "vs_previous_month" in data + assert "highlights" in data + + +def test_monthly_review_default_month(client, auth_header): + r = client.get("/review/monthly", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["month"] == date.today().strftime("%Y-%m")