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
22 changes: 22 additions & 0 deletions app/src/api/monthlyReview.ts
Original file line number Diff line number Diff line change
@@ -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<MonthlyReview> {
const query = month ? `?month=${encodeURIComponent(month)}` : '';
return api<MonthlyReview>(`/review/monthly${query}`);
}
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")
117 changes: 117 additions & 0 deletions packages/backend/app/routes/monthly_review.py
Original file line number Diff line number Diff line change
@@ -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)
65 changes: 65 additions & 0 deletions packages/backend/tests/test_monthly_review.py
Original file line number Diff line number Diff line change
@@ -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")