From 3013466c05eb7f8850d2b63373d11c08987cb2c1 Mon Sep 17 00:00:00 2001 From: technosexual <92812073+atljh@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:10:53 +0300 Subject: [PATCH] fix(budgets): count subcategory spending against the parent's budget (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_budget_status() summed only transactions whose category_id matched the budgeted category exactly, while the Dashboard breakdown and the category report both roll a subcategory's spending up into its parent (coalesce(parent_id, id) in dashboard_service.py and reports_service.py). With one level of subcategories — the app supports exactly one — a budget on "Housing & Utilities" ignored everything filed under "Rent": the Dashboard showed 900 spent for the month and the Budget page showed 0 of 1000 for the same category, same month. Since insights_service's over-budget alert reads this same status, the alert stayed silent too. Budgets now count the budgeted category plus its children, matching the rollup the other two screens already use. A subcategory that has a budget of its own keeps tracking its own spending, so both bars move against their own limits — that case is pinned by a test as well. --- backend/app/services/budget_service.py | 26 ++++++++++++- backend/tests/test_budgets.py | 52 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/backend/app/services/budget_service.py b/backend/app/services/budget_service.py index 8668655..d685a35 100644 --- a/backend/app/services/budget_service.py +++ b/backend/app/services/budget_service.py @@ -3,6 +3,7 @@ get_budget_status(), which compares each limit against actual spend for a given month — the data behind the Budget page's progress bars and the budget_exceeded proactive alert (services/insights_service.py).""" import calendar +from collections import defaultdict from datetime import date from decimal import Decimal @@ -73,10 +74,28 @@ async def get_budget_status(session: AsyncSession, year: int, month: int) -> Bud if not budgets: return BudgetStatusResponse(year=year, month=month, items=[]) + # A budget on a top-level category covers its subcategories too — the + # same rollup the Dashboard breakdown and the category report already do + # (coalesce(parent_id, id) in dashboard_service/reports_service). + # Counting only exact category_id matches meant a month could read as 900 + # spent on the Dashboard and 0 against its own budget. A subcategory that + # has a budget of its own still tracks its own spending: both bars move, + # each against its own limit. + budget_category_ids = [b.category_id for b in budgets] + children_by_parent: dict[int, list[int]] = defaultdict(list) + child_rows = await session.execute( + select(Category.id, Category.parent_id).where(Category.parent_id.in_(budget_category_ids)) + ) + for child_id, parent_id in child_rows.all(): + children_by_parent[parent_id].append(child_id) + + counted_ids = set(budget_category_ids).union( + child_id for children in children_by_parent.values() for child_id in children + ) spent_stmt = ( select(Transaction.category_id, func.coalesce(func.sum(Transaction.amount), 0)) .where( - Transaction.category_id.in_([b.category_id for b in budgets]), + Transaction.category_id.in_(counted_ids), Transaction.type == TransactionType.EXPENSE, Transaction.date >= start, Transaction.date <= end, @@ -87,7 +106,10 @@ async def get_budget_status(session: AsyncSession, year: int, month: int) -> Bud items = [] for budget in budgets: - spent = spent_by_category.get(budget.category_id, Decimal("0")) + spent = spent_by_category.get(budget.category_id, Decimal("0")) + sum( + (spent_by_category.get(child_id, Decimal("0")) for child_id in children_by_parent[budget.category_id]), + Decimal("0"), + ) percent = float(spent / budget.monthly_limit * 100) if budget.monthly_limit else 0.0 items.append( BudgetStatus( diff --git a/backend/tests/test_budgets.py b/backend/tests/test_budgets.py index d499ffb..042208b 100644 --- a/backend/tests/test_budgets.py +++ b/backend/tests/test_budgets.py @@ -80,3 +80,55 @@ async def test_delete_budget_removes_it_from_status(client: AsyncClient, account status = await client.get("/budgets/status", params={"year": 2026, "month": 8}) assert status.json()["items"] == [] + + +async def _subcategory(client: AsyncClient, parent_id: int, name: str = "Rent") -> int: + resp = await client.post( + "/categories", + json={"name": name, "kind": "expense", "color": "#7a869a", "parent_id": parent_id}, + ) + return resp.json()["id"] + + +async def test_budget_on_a_parent_category_counts_spending_in_its_subcategories( + client: AsyncClient, account_id, categories +): + """The Dashboard breakdown and the category report both roll a + subcategory's spending up into its parent (coalesce(parent_id, id)). A + budget set on the parent has to agree with them, or the same month reads + as 900 spent on one screen and 0 on the other.""" + parent_id = categories["Housing & Utilities"]["id"] + child_id = await _subcategory(client, parent_id) + await client.post("/budgets", json={"category_id": parent_id, "monthly_limit": "1000.00"}) + await client.post( + "/transactions", json=txn_payload(account_id, category_id=child_id, amount="900.00", date="2026-03-10") + ) + + status = (await client.get("/budgets/status?year=2026&month=3")).json()["items"][0] + dashboard = (await client.get("/dashboard/summary?year=2026&month=3")).json() + + assert money(status["spent"]) == money("900.00") + assert money(status["remaining"]) == money("100.00") + # The two screens must show the same number for the same category. + parent_slice = next(s for s in dashboard["spending_by_category"] if s["category_id"] == parent_id) + assert money(parent_slice["amount"]) == money(status["spent"]) + + +async def test_a_subcategory_keeps_its_own_budget_separate(client: AsyncClient, account_id, categories): + """Rolling children into a parent's budget must not swallow a budget set + on the child itself — both bars track the same spending, each against its + own limit.""" + parent_id = categories["Housing & Utilities"]["id"] + child_id = await _subcategory(client, parent_id) + await client.post("/budgets", json={"category_id": parent_id, "monthly_limit": "1000.00"}) + await client.post("/budgets", json={"category_id": child_id, "monthly_limit": "800.00"}) + await client.post( + "/transactions", json=txn_payload(account_id, category_id=child_id, amount="900.00", date="2026-03-10") + ) + + items = {item["category_id"]: item for item in (await client.get("/budgets/status?year=2026&month=3")).json()["items"]} + + assert money(items[child_id]["spent"]) == money("900.00") + assert items[child_id]["is_over_budget"] is True + assert money(items[parent_id]["spent"]) == money("900.00") + assert items[parent_id]["is_over_budget"] is False