+
+
+ Budget & Overspend Warnings
+
+
+
+
+
+
+
+ {showForm && (
+
+ )}
+
+ {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("/