diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..0367053c 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 BudgetOverspend from "./pages/BudgetOverspend"; const queryClient = new QueryClient({ defaultOptions: { @@ -91,6 +92,14 @@ const App = () => ( } /> + + + + } + /> } /> } /> diff --git a/app/src/api/budgets.ts b/app/src/api/budgets.ts new file mode 100644 index 00000000..4ac68dfb --- /dev/null +++ b/app/src/api/budgets.ts @@ -0,0 +1,68 @@ +import api from "./index"; + +export interface CategoryBudget { + id: number; + category_id: number; + month?: string; + budget_limit: number; + warning_threshold_pct: number; + created_at: string; +} + +export interface OverspendWarning { + budget_id: number; + category_id: number; + category_name: string | null; + month: string; + budget_limit: number; + spent: number; + remaining: number; + pct_used: number; + warning_threshold_pct: number; + warning_level: "CRITICAL" | "HIGH" | "MEDIUM" | "OK"; + is_over_budget: boolean; +} + +export interface OverspendReport { + month: string; + warnings: OverspendWarning[]; + summary: { + critical: number; + high: number; + medium: number; + ok: number; + total: number; + }; +} + +export interface CreateBudgetPayload { + category_id: number; + budget_limit: number; + month?: string; + warning_threshold_pct?: number; +} + +export const getBudgets = (): Promise => + api.get("/budgets").then((r) => r.data); + +export const createBudget = (p: CreateBudgetPayload): Promise => + api.post("/budgets", p).then((r) => r.data); + +export const updateBudget = ( + id: number, + p: Partial +): Promise => + api.patch(`/budgets/${id}`, p).then((r) => r.data); + +export const deleteBudget = (id: number): Promise => + api.delete(`/budgets/${id}`).then((r) => r.data); + +export const getOverspendWarnings = ( + month?: string, + onlyWarnings = false +): Promise => { + const params = new URLSearchParams(); + if (month) params.set("month", month); + if (onlyWarnings) params.set("only_warnings", "true"); + return api.get(`/budgets/overspend?${params}`).then((r) => r.data); +}; diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..7460bda3 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: 'Overspend', href: '/overspend' }, ]; export function Navbar() { diff --git a/app/src/pages/BudgetOverspend.tsx b/app/src/pages/BudgetOverspend.tsx new file mode 100644 index 00000000..b327f170 --- /dev/null +++ b/app/src/pages/BudgetOverspend.tsx @@ -0,0 +1,388 @@ +import { useEffect, useState } from "react"; +import { + createBudget, + deleteBudget, + getBudgets, + getOverspendWarnings, + type CategoryBudget, + type CreateBudgetPayload, + type OverspendWarning, +} from "../api/budgets"; + +interface Category { + id: number; + name: string; +} + +const LEVEL_CONFIG = { + CRITICAL: { color: "#F44336", bg: "rgba(244,67,54,0.15)", label: "OVER BUDGET" }, + HIGH: { color: "#FF5722", bg: "rgba(255,87,34,0.15)", label: "ALMOST OVER" }, + MEDIUM: { color: "#FF9800", bg: "rgba(255,152,0,0.15)", label: "WARNING" }, + OK: { color: "#4CAF50", bg: "rgba(76,175,80,0.1)", label: "ON TRACK" }, +}; + +function ProgressBar({ pct, level }: { pct: number; level: OverspendWarning["warning_level"] }) { + const cfg = LEVEL_CONFIG[level]; + const capped = Math.min(pct, 100); + return ( +
+
+
+ ); +} + +function WarningCard({ w }: { w: OverspendWarning }) { + const cfg = LEVEL_CONFIG[w.warning_level]; + return ( +
+
+ + {w.category_name ?? `Category #${w.category_id}`} + + + {cfg.label} + +
+ +
+ + Spent: ₹{w.spent.toLocaleString()} + + + {w.pct_used.toFixed(1)}% of ₹{w.budget_limit.toLocaleString()} + + + {w.remaining < 0 + ? `₹${Math.abs(w.remaining).toLocaleString()} over` + : `₹${w.remaining.toLocaleString()} left`} + +
+
+ ); +} + +function SummaryPill({ label, count, color }: { label: string; count: number; color: string }) { + return ( +
+
{count}
+
{label}
+
+ ); +} + +export default function BudgetOverspend() { + const [report, setReport] = useState> | null>(null); + const [budgets, setBudgets] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [onlyWarnings, setOnlyWarnings] = useState(false); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ category_id: 0, budget_limit: 0 }); + const [saving, setSaving] = useState(false); + + const load = async () => { + try { + setLoading(true); + const [rep, blist] = await Promise.all([ + getOverspendWarnings(undefined, onlyWarnings), + getBudgets(), + ]); + setReport(rep); + setBudgets(blist); + } finally { + setLoading(false); + } + }; + + // Load categories for the create form + const loadCategories = async () => { + try { + const { default: api } = await import("../api/index"); + const r = await api.get("/categories"); + setCategories(r.data || []); + } catch { + // ignore + } + }; + + useEffect(() => { + load(); + loadCategories(); + }, [onlyWarnings]); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + await createBudget({ ...form, budget_limit: Number(form.budget_limit) }); + setShowForm(false); + setForm({ category_id: 0, budget_limit: 0 }); + await load(); + } catch { + alert("Failed to create budget. Check the details."); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm("Remove this budget limit?")) return; + await deleteBudget(id); + await load(); + }; + + const inputStyle: React.CSSProperties = { + width: "100%", + padding: "7px 11px", + borderRadius: 7, + border: "1px solid #444", + background: "#16213e", + color: "#fff", + fontSize: 13, + boxSizing: "border-box", + }; + + return ( +
+
+

+ Budget & Overspend Warnings +

+
+ + +
+
+ + {showForm && ( +
+
+ + +
+
+ + setForm({ ...form, budget_limit: parseFloat(e.target.value) })} + /> +
+
+ + + setForm({ ...form, warning_threshold_pct: Number(e.target.value) }) + } + /> +
+ +
+ )} + + {report && ( +
+ + + + +
+ )} + + {loading &&

Loading…

} + + {!loading && report && ( + <> + {report.warnings.length === 0 ? ( +
+ {budgets.length === 0 + ? "No budget limits set yet. Click '+ Set Budget' to add one." + : "All categories are within budget. Keep it up!"} +
+ ) : ( +
+ {report.warnings.map((w) => ( + + ))} +
+ )} + + {budgets.length > 0 && ( +
+

Configured Budget Limits

+
+ {budgets.map((b) => { + const cat = categories.find((c) => c.id === b.category_id); + return ( +
+ + {cat?.name ?? `Category #${b.category_id}`} + + + ₹{b.budget_limit.toLocaleString()} / {b.month ?? "every month"} · warn @{b.warning_threshold_pct}% + + +
+ ); + })} +
+
+ )} + + )} +
+ ); +} diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..bf4403ed 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,15 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +-- Category budget limits and overspend early warnings (#117) +CREATE TABLE IF NOT EXISTS category_budgets ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category_id INT NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + month VARCHAR(7), + budget_limit NUMERIC(12,2) NOT NULL, + warning_threshold_pct INT NOT NULL DEFAULT 80, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_category_budgets_user ON category_budgets(user_id, month); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..35a84f94 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,20 @@ 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 CategoryBudget(db.Model): + """Monthly spending limit per category for overspend early warnings (#117).""" + + __tablename__ = "category_budgets" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + category_id = db.Column( + db.Integer, db.ForeignKey("categories.id"), nullable=False + ) + # YYYY-MM string, e.g. "2025-01". NULL = applies to every month (default). + month = db.Column(db.String(7), nullable=True) + budget_limit = db.Column(db.Numeric(12, 2), nullable=False) + # Warning fires when spending reaches this % of the limit (default 80 %). + warning_threshold_pct = db.Column(db.Integer, default=80, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..b3c1b0ce 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 .budgets import bp as budgets_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(budgets_bp, url_prefix="/budgets") diff --git a/packages/backend/app/routes/budgets.py b/packages/backend/app/routes/budgets.py new file mode 100644 index 00000000..6a9a883d --- /dev/null +++ b/packages/backend/app/routes/budgets.py @@ -0,0 +1,224 @@ +"""Category budget management and overspend early-warning endpoints (#117).""" + +from datetime import date +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from sqlalchemy import func + +from ..extensions import db +from ..models import Category, CategoryBudget, Expense + +bp = Blueprint("budgets", __name__) + +# Warning levels based on % of budget consumed +WARN_LEVEL_CRITICAL = 100 # Over budget +WARN_LEVEL_HIGH = 90 # ≥ 90 % — almost over +WARN_LEVEL_MEDIUM = 75 # ≥ 75 % — getting close + + +def _current_month() -> str: + return date.today().strftime("%Y-%m") + + +def _spent_this_month(user_id: int, category_id: int, month: str) -> float: + """Sum of EXPENSE-type transactions for (user, category, month).""" + year, mo = map(int, month.split("-")) + result = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.category_id == category_id, + Expense.expense_type != "INCOME", + func.strftime("%Y", Expense.spent_at) == str(year), + func.strftime("%m", Expense.spent_at) == f"{mo:02d}", + ) + .scalar() + ) + return float(result or 0) + + +def _warning_level(pct: float) -> str: + if pct >= WARN_LEVEL_CRITICAL: + return "CRITICAL" + if pct >= WARN_LEVEL_HIGH: + return "HIGH" + if pct >= WARN_LEVEL_MEDIUM: + return "MEDIUM" + return "OK" + + +def _budget_json(b: CategoryBudget) -> dict: + return { + "id": b.id, + "category_id": b.category_id, + "month": b.month, + "budget_limit": float(b.budget_limit), + "warning_threshold_pct": b.warning_threshold_pct, + "created_at": b.created_at.isoformat(), + } + + +# ── Budget CRUD ─────────────────────────────────────────────────────────────── + + +@bp.get("") +@jwt_required() +def list_budgets(): + uid = int(get_jwt_identity()) + budgets = CategoryBudget.query.filter_by(user_id=uid).order_by( + CategoryBudget.month.desc(), CategoryBudget.category_id + ).all() + return jsonify([_budget_json(b) for b in budgets]) + + +@bp.post("") +@jwt_required() +def create_budget(): + uid = int(get_jwt_identity()) + data = request.get_json(force=True) + + category_id = data.get("category_id") + if not category_id: + return jsonify(error="category_id is required"), 400 + + # Verify category belongs to user + cat = Category.query.filter_by(id=category_id, user_id=uid).first() + if not cat: + return jsonify(error="category not found"), 404 + + budget_limit = float(data.get("budget_limit", 0)) + if budget_limit <= 0: + return jsonify(error="budget_limit must be positive"), 400 + + month = (data.get("month") or "").strip() or None + if month: + try: + date(int(month[:4]), int(month[5:7]), 1) + except (ValueError, IndexError): + return jsonify(error="month must be YYYY-MM"), 400 + + threshold = int(data.get("warning_threshold_pct", 80)) + if not (1 <= threshold <= 100): + return jsonify(error="warning_threshold_pct must be 1-100"), 400 + + budget = CategoryBudget( + user_id=uid, + category_id=category_id, + month=month, + budget_limit=budget_limit, + warning_threshold_pct=threshold, + ) + db.session.add(budget) + db.session.commit() + return jsonify(_budget_json(budget)), 201 + + +@bp.patch("/") +@jwt_required() +def update_budget(budget_id: int): + uid = int(get_jwt_identity()) + budget = CategoryBudget.query.filter_by(id=budget_id, user_id=uid).first() + if not budget: + return jsonify(error="budget not found"), 404 + + data = request.get_json(force=True) + if "budget_limit" in data: + bl = float(data["budget_limit"]) + if bl <= 0: + return jsonify(error="budget_limit must be positive"), 400 + budget.budget_limit = bl + if "warning_threshold_pct" in data: + t = int(data["warning_threshold_pct"]) + if not (1 <= t <= 100): + return jsonify(error="warning_threshold_pct must be 1-100"), 400 + budget.warning_threshold_pct = t + if "month" in data: + budget.month = data["month"] or None + + db.session.commit() + return jsonify(_budget_json(budget)) + + +@bp.delete("/") +@jwt_required() +def delete_budget(budget_id: int): + uid = int(get_jwt_identity()) + budget = CategoryBudget.query.filter_by(id=budget_id, user_id=uid).first() + if not budget: + return jsonify(error="budget not found"), 404 + + db.session.delete(budget) + db.session.commit() + return jsonify(message="budget deleted"), 200 + + +# ── Overspend warning ───────────────────────────────────────────────────────── + + +@bp.get("/overspend") +@jwt_required() +def overspend_warnings(): + """Returns per-category spending status vs budget limits for the given month. + + Query params: + - month: YYYY-MM (defaults to current month) + - only_warnings: if "true", only categories at >= warning_threshold_pct + """ + uid = int(get_jwt_identity()) + month = (request.args.get("month") or _current_month()).strip() + only_warnings = request.args.get("only_warnings", "false").lower() == "true" + + # Find budget rows applicable to this month: + # a) exact match for the month, OR b) null month (default / global limit) + budgets = CategoryBudget.query.filter( + CategoryBudget.user_id == uid, + db.or_( + CategoryBudget.month == month, + CategoryBudget.month.is_(None), + ), + ).all() + + results = [] + for b in budgets: + cat = Category.query.get(b.category_id) + spent = _spent_this_month(uid, b.category_id, month) + limit = float(b.budget_limit) + pct = round((spent / limit) * 100, 1) if limit > 0 else 0.0 + level = _warning_level(pct) + + if only_warnings and level == "OK": + continue + + results.append( + { + "budget_id": b.id, + "category_id": b.category_id, + "category_name": cat.name if cat else None, + "month": month, + "budget_limit": limit, + "spent": round(spent, 2), + "remaining": round(limit - spent, 2), + "pct_used": pct, + "warning_threshold_pct": b.warning_threshold_pct, + "warning_level": level, + "is_over_budget": spent > limit, + } + ) + + # Sort: CRITICAL first, then HIGH, then MEDIUM, then OK; then by pct desc + level_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "OK": 3} + results.sort(key=lambda r: (level_order.get(r["warning_level"], 9), -r["pct_used"])) + + return jsonify( + { + "month": month, + "warnings": results, + "summary": { + "critical": sum(1 for r in results if r["warning_level"] == "CRITICAL"), + "high": sum(1 for r in results if r["warning_level"] == "HIGH"), + "medium": sum(1 for r in results if r["warning_level"] == "MEDIUM"), + "ok": sum(1 for r in results if r["warning_level"] == "OK"), + "total": len(results), + }, + } + ) diff --git a/packages/backend/tests/test_budgets.py b/packages/backend/tests/test_budgets.py new file mode 100644 index 00000000..cc5ad77e --- /dev/null +++ b/packages/backend/tests/test_budgets.py @@ -0,0 +1,175 @@ +"""Tests for category budget management and overspend early warnings (#117).""" + +from datetime import date + + +MONTH = date.today().strftime("%Y-%m") + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _create_category(client, auth_header, name="Food"): + r = client.post("/categories", json={"name": name}, headers=auth_header) + assert r.status_code in (200, 201) + return r.get_json()["id"] + + +def _create_budget(client, auth_header, category_id, limit=1000, month=None, threshold=80): + payload = { + "category_id": category_id, + "budget_limit": limit, + "warning_threshold_pct": threshold, + } + if month: + payload["month"] = month + return client.post("/budgets", json=payload, headers=auth_header) + + +def _add_expense(client, auth_header, category_id, amount, expense_type="EXPENSE"): + r = client.post( + "/expenses", + json={ + "category_id": category_id, + "amount": amount, + "expense_type": expense_type, + "spent_at": date.today().isoformat(), + }, + headers=auth_header, + ) + assert r.status_code in (200, 201) + + +# ── Budget CRUD ─────────────────────────────────────────────────────────────── + + +def test_list_budgets_empty(client, auth_header): + r = client.get("/budgets", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_create_budget(client, auth_header): + cid = _create_category(client, auth_header) + r = _create_budget(client, auth_header, category_id=cid, limit=500) + assert r.status_code == 201 + data = r.get_json() + assert data["budget_limit"] == 500.0 + assert data["category_id"] == cid + assert data["warning_threshold_pct"] == 80 + + +def test_create_budget_with_month(client, auth_header): + cid = _create_category(client, auth_header) + r = _create_budget(client, auth_header, category_id=cid, month="2025-01") + assert r.status_code == 201 + assert r.get_json()["month"] == "2025-01" + + +def test_create_budget_invalid_limit(client, auth_header): + cid = _create_category(client, auth_header) + r = _create_budget(client, auth_header, category_id=cid, limit=-100) + assert r.status_code == 400 + + +def test_create_budget_invalid_category(client, auth_header): + r = _create_budget(client, auth_header, category_id=99999) + assert r.status_code == 404 + + +def test_create_budget_missing_category(client, auth_header): + r = client.post("/budgets", json={"budget_limit": 500}, headers=auth_header) + assert r.status_code == 400 + + +def test_update_budget(client, auth_header): + cid = _create_category(client, auth_header) + bid = _create_budget(client, auth_header, category_id=cid, limit=500).get_json()["id"] + r = client.patch(f"/budgets/{bid}", json={"budget_limit": 750, "warning_threshold_pct": 90}, headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["budget_limit"] == 750.0 + assert data["warning_threshold_pct"] == 90 + + +def test_delete_budget(client, auth_header): + cid = _create_category(client, auth_header) + bid = _create_budget(client, auth_header, category_id=cid).get_json()["id"] + r = client.delete(f"/budgets/{bid}", headers=auth_header) + assert r.status_code == 200 + assert client.get("/budgets", headers=auth_header).get_json() == [] + + +# ── Overspend warnings ──────────────────────────────────────────────────────── + + +def test_overspend_no_budgets(client, auth_header): + r = client.get("/budgets/overspend", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["warnings"] == [] + assert data["summary"]["total"] == 0 + + +def test_overspend_ok_level(client, auth_header): + """25% spent → OK level.""" + cid = _create_category(client, auth_header, "Transport") + _create_budget(client, auth_header, category_id=cid, limit=1000, threshold=80) + _add_expense(client, auth_header, cid, 250) + r = client.get(f"/budgets/overspend?month={MONTH}", headers=auth_header) + warnings = r.get_json()["warnings"] + assert len(warnings) == 1 + assert warnings[0]["warning_level"] == "OK" + assert warnings[0]["pct_used"] == 25.0 + + +def test_overspend_medium_level(client, auth_header): + """80% spent with 80% threshold → MEDIUM→HIGH boundary: pct=80 >= threshold(80).""" + cid = _create_category(client, auth_header, "Shopping") + _create_budget(client, auth_header, category_id=cid, limit=1000, threshold=80) + _add_expense(client, auth_header, cid, 800) + r = client.get(f"/budgets/overspend?month={MONTH}", headers=auth_header) + w = r.get_json()["warnings"][0] + # 80% used = exactly at threshold → MEDIUM or higher + assert w["pct_used"] == 80.0 + assert w["warning_level"] in ("MEDIUM", "HIGH", "CRITICAL") + + +def test_overspend_critical_level(client, auth_header): + """Over budget → CRITICAL.""" + cid = _create_category(client, auth_header, "Dining") + _create_budget(client, auth_header, category_id=cid, limit=500, threshold=80) + _add_expense(client, auth_header, cid, 600) + r = client.get(f"/budgets/overspend?month={MONTH}", headers=auth_header) + w = r.get_json()["warnings"][0] + assert w["warning_level"] == "CRITICAL" + assert w["is_over_budget"] is True + assert w["remaining"] < 0 + + +def test_overspend_only_warnings_filter(client, auth_header): + """?only_warnings=true should exclude OK categories.""" + cid1 = _create_category(client, auth_header, "Health") + cid2 = _create_category(client, auth_header, "Utilities") + _create_budget(client, auth_header, category_id=cid1, limit=500, threshold=80) + _create_budget(client, auth_header, category_id=cid2, limit=100, threshold=80) + _add_expense(client, auth_header, cid1, 50) # 10% → OK + _add_expense(client, auth_header, cid2, 96) # 96% → HIGH + r = client.get(f"/budgets/overspend?month={MONTH}&only_warnings=true", headers=auth_header) + warnings = r.get_json()["warnings"] + assert all(w["warning_level"] != "OK" for w in warnings) + + +def test_overspend_income_not_counted(client, auth_header): + """INCOME transactions should not count toward spending.""" + cid = _create_category(client, auth_header, "Salary") + _create_budget(client, auth_header, category_id=cid, limit=1000, threshold=80) + _add_expense(client, auth_header, cid, 5000, expense_type="INCOME") + r = client.get(f"/budgets/overspend?month={MONTH}", headers=auth_header) + w = r.get_json()["warnings"][0] + assert w["spent"] == 0.0 + assert w["pct_used"] == 0.0 + + +def test_overspend_unauthorized(client): + r = client.get("/budgets/overspend") + assert r.status_code == 401