From 140f58d33f1f56836e6efb978b5bc6c89587d90c Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 30 Aug 2026 19:06:29 +0300 Subject: [PATCH] fix: restrict transaction splits to one parent category, show subcategory breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше сплит позволял смешивать вообще любые категории. Реальная задача — разложить одну покупку по подкатегориям ОДНОЙ базовой категории (100 € на продукты: сколько на сладости, сколько на уход). А раз Dashboard и рейтинг категорий сворачивают подкатегории в родителя, эта разбивка нигде не была видна. - _build_splits() требует, чтобы все категории сплита были одной родительской категорией или её прямыми подкатегориями. - category_rollup.py отдаёт для каждого родительского тотала список category-источников, которые в него скинулись (children) — пусто, если источник один. - Dashboard и рейтинг категорий: строка с непустым children раскрывается по клику, показывая вклад каждой подкатегории. - Форма транзакции: выбор категории при сплите — сначала базовая (верхний уровень), затем строки сплита выбирают саму базовую или её подкатегорию. --- backend/app/api/routes/transactions.py | 35 ++++-- backend/app/schemas/dashboard.py | 18 ++- backend/app/schemas/reports.py | 18 ++- backend/app/services/category_rollup.py | 43 ++++++- backend/app/services/dashboard_service.py | 9 +- backend/app/services/reports_service.py | 8 ++ backend/tests/test_backup.py | 8 +- backend/tests/test_budgets.py | 19 ++- backend/tests/test_dashboard.py | 42 +++++-- backend/tests/test_reports.py | 78 ++++++++++++ backend/tests/test_transactions.py | 114 +++++++++++++----- .../dashboard/SpendingByCategoryCard.tsx | 79 +++++++++--- .../reports/CategoryRankingCard.tsx | 108 ++++++++++++----- .../transactions/TransactionFormModal.tsx | 97 +++++++++++---- frontend/src/lib/i18n.ts | 12 ++ frontend/src/types/index.ts | 22 ++++ 16 files changed, 580 insertions(+), 130 deletions(-) create mode 100644 backend/tests/test_reports.py diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py index 1be9dd2..39bdb08 100644 --- a/backend/app/api/routes/transactions.py +++ b/backend/app/api/routes/transactions.py @@ -52,13 +52,15 @@ _TYPE_TO_CATEGORY_KIND = { async def _ensure_category_matches_type( session: AsyncSession, category_id: int | None, transaction_type: TransactionType -) -> None: +) -> Category | None: """A category picked for an income transaction must itself be an income category (and likewise for expense) — otherwise the dashboard's spending breakdown, which only joins EXPENSE-typed rows, would silently misclassify - the entry.""" + the entry. Returns the fetched category (or None for category_id=None) so + callers that also need the row itself — _build_splits, below — don't have + to fetch it a second time.""" if category_id is None: - return + return None expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type) category = await session.get(Category, category_id) if category is None: @@ -68,13 +70,32 @@ async def _ensure_category_matches_type( status_code=400, detail=f"Category '{category.name}' is a {category.kind.value} category and cannot be used for a {transaction_type.value} transaction", ) + return category async def _build_splits( session: AsyncSession, splits: list[TransactionSplitInput], transaction_type: TransactionType ) -> list[TransactionSplit]: + """A split's whole point is dividing one purchase's total across the + *subcategories of one parent* (a hypermarket receipt: part groceries -> + Sweets, part -> Alcohol) — not across unrelated top-level categories, or + the numbers would roll up into two different parents and the "spent X on + Groceries, split between Sweets/Alcohol" picture the feature exists for + falls apart. Each split may point at that parent category itself (an + unspecified-subcategory line) or at any one of its direct children — + enforced by requiring every split's own top-level ancestor + (parent_id, or its own id if it has none) to agree. + """ + top_level_ids: set[int] = set() for split in splits: - await _ensure_category_matches_type(session, split.category_id, transaction_type) + category = await _ensure_category_matches_type(session, split.category_id, transaction_type) + assert category is not None # split.category_id is required (not Optional) on the schema + top_level_ids.add(category.parent_id if category.parent_id is not None else category.id) + if len(top_level_ids) > 1: + raise HTTPException( + status_code=400, + detail="All split categories must be the same parent category or its direct subcategories", + ) return [TransactionSplit(category_id=s.category_id, amount=s.amount, note=s.note) for s in splits] @@ -196,17 +217,13 @@ async def bulk_create_transactions( instead of leaving a half-imported statement behind.""" for item in payload.items: await _ensure_category_matches_type(session, item.category_id, item.type) - for split in item.splits or []: - await _ensure_category_matches_type(session, split.category_id, item.type) transactions = [] for item in payload.items: transaction = Transaction(**item.model_dump(exclude={"tag_ids", "splits"})) transaction.tags = await _resolve_tags(session, item.tag_ids) if item.splits: - transaction.splits = [ - TransactionSplit(category_id=s.category_id, amount=s.amount, note=s.note) for s in item.splits - ] + transaction.splits = await _build_splits(session, item.splits, item.type) transactions.append(transaction) session.add_all(transactions) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index ad4162a..a4db052 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -1,6 +1,18 @@ from decimal import Decimal -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +class CategoryBreakdownChildItem(BaseModel): + """One subcategory's (or the parent's own direct, un-subcategorized) + share of a CategoryBreakdownItem's total — see + services/category_rollup.py's CategoryRollupChildItem.""" + + category_id: int + name: str + color: str + icon: str | None + amount: Decimal class CategoryBreakdownItem(BaseModel): @@ -10,6 +22,10 @@ class CategoryBreakdownItem(BaseModel): icon: str | None amount: Decimal percent: float + # Populated only when this slice's spend came from more than one + # distinct category (subcategories, or a mix of the parent itself and + # its children) — e.g. a receipt split across "Groceries" subcategories. + children: list[CategoryBreakdownChildItem] = Field(default_factory=list) class DashboardSummary(BaseModel): diff --git a/backend/app/schemas/reports.py b/backend/app/schemas/reports.py index b2d32fa..8757400 100644 --- a/backend/app/schemas/reports.py +++ b/backend/app/schemas/reports.py @@ -1,7 +1,19 @@ from datetime import date as date_ from decimal import Decimal -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +class CategoryRankingChildItem(BaseModel): + """One subcategory's (or the parent's own direct, un-subcategorized) + share of a CategoryRankingItem's total — see + services/category_rollup.py's CategoryRollupChildItem.""" + + category_id: int + name: str + color: str + icon: str | None + amount: Decimal class CategorySpendingPoint(BaseModel): @@ -31,6 +43,10 @@ class CategoryRankingItem(BaseModel): amount: Decimal percent: float transaction_count: int + # Populated only when this category's spend came from more than one + # distinct category (subcategories, or a mix of itself and its + # children) — e.g. a receipt split across "Groceries" subcategories. + children: list[CategoryRankingChildItem] = Field(default_factory=list) class CategoryRankingReport(BaseModel): diff --git a/backend/app/services/category_rollup.py b/backend/app/services/category_rollup.py index a3bc184..9946aae 100644 --- a/backend/app/services/category_rollup.py +++ b/backend/app/services/category_rollup.py @@ -13,7 +13,7 @@ funded partly by plain transactions and partly by split lines still shows one correct total. """ from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date as date_ from decimal import Decimal @@ -25,6 +25,20 @@ from app.models.enums import TransactionType from app.models.transaction import Transaction, TransactionSplit +@dataclass +class CategoryRollupChildItem: + """One leaf category's share of its parent's total — a real + subcategory, or the parent category itself when some of the spend was + filed directly under it with no more specific subcategory (category_id + equals the enclosing CategoryRollupItem.category_id in that case).""" + + category_id: int + name: str + color: str + icon: str | None + amount: Decimal + + @dataclass class CategoryRollupItem: category_id: int @@ -37,6 +51,13 @@ class CategoryRollupItem: # transaction counts once per top-level category it touches, same as a # plain one counts once for its own category. transaction_count: int + # Populated only when more than one distinct category actually fed this + # total (a subcategory, or a direct-to-parent line, or several of + # either) — e.g. a receipt split between "Sweets" and "Alcohol" under + # "Groceries". A single-source category (the common case, no splitting + # or subcategories involved) leaves this empty rather than showing a + # redundant one-item breakdown of itself. + children: list[CategoryRollupChildItem] = field(default_factory=list) async def _raw_category_contributions( @@ -92,15 +113,34 @@ async def rollup_spending_by_top_level_category( amount_by_effective: dict[int, Decimal] = defaultdict(Decimal) txn_ids_by_effective: dict[int, set[int]] = defaultdict(set) + # effective (top-level) category id -> {actual leaf category_id: amount} + # — the leaf is the same as the effective id when a contribution was + # filed directly on the parent, and a genuine child id otherwise. + amount_by_leaf: dict[int, dict[int, Decimal]] = defaultdict(lambda: defaultdict(Decimal)) for transaction_id, category_id, amount in contributions: category = categories_by_id.get(category_id) effective_id = category.parent_id if category and category.parent_id is not None else category_id amount_by_effective[effective_id] += amount + amount_by_leaf[effective_id][category_id] += amount txn_ids_by_effective[effective_id].add(transaction_id) items: list[CategoryRollupItem] = [] for effective_id, amount in amount_by_effective.items(): category = categories_by_id.get(effective_id) + leaf_amounts = amount_by_leaf[effective_id] + children: list[CategoryRollupChildItem] = [] + if len(leaf_amounts) > 1: + for leaf_id, leaf_amount in sorted(leaf_amounts.items(), key=lambda pair: -pair[1]): + leaf_category = categories_by_id.get(leaf_id) + children.append( + CategoryRollupChildItem( + category_id=leaf_id, + name=leaf_category.name if leaf_category else "?", + color=leaf_category.color if leaf_category else "#898781", + icon=leaf_category.icon if leaf_category else None, + amount=leaf_amount, + ) + ) items.append( CategoryRollupItem( category_id=effective_id, @@ -110,6 +150,7 @@ async def rollup_spending_by_top_level_category( sort_order=category.sort_order if category else 0, amount=amount, transaction_count=len(txn_ids_by_effective[effective_id]), + children=children, ) ) items.sort(key=lambda item: (-item.amount, item.sort_order)) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 299d776..3a1a414 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.enums import TransactionType from app.models.transaction import Transaction -from app.schemas.dashboard import CategoryBreakdownItem, DashboardSummary +from app.schemas.dashboard import CategoryBreakdownChildItem, CategoryBreakdownItem, DashboardSummary from app.services.category_rollup import rollup_spending_by_top_level_category # Categorical slots are capped at 8 (dataviz skill: a 9th series folds into "Other", @@ -54,6 +54,13 @@ async def get_dashboard_summary(session: AsyncSession, year: int, month: int) -> CategoryBreakdownItem( category_id=row.category_id, name=row.name, color=row.color, icon=row.icon, amount=row.amount, percent=_percent(row.amount), + children=[ + CategoryBreakdownChildItem( + category_id=child.category_id, name=child.name, color=child.color, icon=child.icon, + amount=child.amount, + ) + for child in row.children + ], ) for row in top_rows ] diff --git a/backend/app/services/reports_service.py b/backend/app/services/reports_service.py index 6b91b6b..e75a807 100644 --- a/backend/app/services/reports_service.py +++ b/backend/app/services/reports_service.py @@ -16,6 +16,7 @@ from app.models.category import Category from app.models.enums import CategoryKind, TransactionType from app.models.transaction import Transaction, TransactionSplit from app.schemas.reports import ( + CategoryRankingChildItem, CategoryRankingItem, CategoryRankingReport, CategorySpendingPoint, @@ -155,6 +156,13 @@ async def get_category_ranking_report( amount=row.amount, percent=_percent(row.amount), transaction_count=row.transaction_count, + children=[ + CategoryRankingChildItem( + category_id=child.category_id, name=child.name, color=child.color, icon=child.icon, + amount=child.amount, + ) + for child in row.children + ], ) for row in rows ] diff --git a/backend/tests/test_backup.py b/backend/tests/test_backup.py index 7de81cf..7da876e 100644 --- a/backend/tests/test_backup.py +++ b/backend/tests/test_backup.py @@ -66,7 +66,9 @@ async def test_backup_import_rejects_transaction_with_unknown_tag_id(client: Asy async def test_backup_roundtrip_preserves_transaction_splits(client: AsyncClient, account_id, categories): groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets = ( + await client.post("/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries}) + ).json()["id"] created = await client.post( "/transactions", json=_txn( @@ -75,7 +77,7 @@ async def test_backup_roundtrip_preserves_transaction_splits(client: AsyncClient category_id=None, splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00", "note": "household chemicals"}, + {"category_id": sweets, "amount": "30.00", "note": "candy and snacks"}, ], ), ) @@ -89,5 +91,5 @@ async def test_backup_roundtrip_preserves_transaction_splits(client: AsyncClient refetched = next(t for t in (await client.get("/transactions")).json()["items"] if t["id"] == txn_id) splits_by_note = {s["note"]: s for s in refetched["splits"]} - assert splits_by_note["household chemicals"]["category_id"] == shopping + assert splits_by_note["candy and snacks"]["category_id"] == sweets assert refetched["category"] is None diff --git a/backend/tests/test_budgets.py b/backend/tests/test_budgets.py index ed80351..e760e93 100644 --- a/backend/tests/test_budgets.py +++ b/backend/tests/test_budgets.py @@ -135,11 +135,17 @@ async def test_a_subcategory_keeps_its_own_budget_separate(client: AsyncClient, async def test_budget_status_counts_a_split_transactions_share(client: AsyncClient, account_id, categories): - """A receipt split between Groceries and Shopping must count its 70.00 - slice toward the Groceries budget, not the full 100.00 or nothing.""" + """A receipt split between Groceries and one of its own subcategories + (Sweets) must count each line toward the right budget: the full 100.00 + rolls up into the Groceries budget, but only the 30.00 Sweets line + counts toward a budget set on Sweets itself.""" groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets_resp = await client.post( + "/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries} + ) + sweets = sweets_resp.json()["id"] await client.post("/budgets", json={"category_id": groceries, "monthly_limit": "100.00"}) + await client.post("/budgets", json={"category_id": sweets, "monthly_limit": "50.00"}) await client.post( "/transactions", json=txn_payload( @@ -149,11 +155,12 @@ async def test_budget_status_counts_a_split_transactions_share(client: AsyncClie date="2026-08-10", splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00"}, + {"category_id": sweets, "amount": "30.00"}, ], ), ) resp = await client.get("/budgets/status", params={"year": 2026, "month": 8}) - item = resp.json()["items"][0] - assert money(item["spent"]) == Decimal("70.00") + items = {item["category_id"]: item for item in resp.json()["items"]} + assert money(items[groceries]["spent"]) == Decimal("100.00") + assert money(items[sweets]["spent"]) == Decimal("30.00") diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 2d80f97..293db39 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -127,14 +127,20 @@ async def test_more_than_eight_expense_categories_roll_up_into_other(client: Asy assert money(other[0]["amount"]) == Decimal("10.00") -async def test_split_transaction_counts_each_split_toward_its_own_category( +async def test_split_transaction_rolls_up_into_one_slice_with_a_children_breakdown( client: AsyncClient, account_id, categories ): - """A hypermarket receipt split between Groceries and Shopping must show - up as two separate slices on the donut, not one lump under whichever - category happened to be picked.""" + """A hypermarket receipt split between Groceries and one of its own + subcategories (Sweets) must show up as a single Groceries slice on the + donut with the full 100.00 total — a split's categories always share one + parent (see routes/transactions.py's _build_splits), so it never + fragments into separate top-level slices — but the slice must expose a + children breakdown so the Groceries/Sweets split is still visible.""" groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets_resp = await client.post( + "/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries} + ) + sweets = sweets_resp.json()["id"] await client.post( "/transactions", json=_txn( @@ -144,7 +150,7 @@ async def test_split_transaction_counts_each_split_toward_its_own_category( date="2026-08-01", splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00"}, + {"category_id": sweets, "amount": "30.00"}, ], ), ) @@ -154,5 +160,25 @@ async def test_split_transaction_counts_each_split_toward_its_own_category( breakdown = {row["name"]: row for row in body["spending_by_category"]} assert money(body["spent"]) == Decimal("100.00") - assert money(breakdown["Groceries"]["amount"]) == Decimal("70.00") - assert money(breakdown["Shopping"]["amount"]) == Decimal("30.00") + assert "Sweets" not in breakdown + assert money(breakdown["Groceries"]["amount"]) == Decimal("100.00") + + children = {child["name"]: child for child in breakdown["Groceries"]["children"]} + # The 70.00 line was filed directly on Groceries itself (no + # subcategory), so it shows up as a child entry with Groceries' own id + # and name too — same as any genuine subcategory would. + assert money(children["Groceries"]["amount"]) == Decimal("70.00") + assert money(children["Sweets"]["amount"]) == Decimal("30.00") + + +async def test_a_single_category_slice_has_no_children_breakdown(client: AsyncClient, account_id, categories): + """A category funded by exactly one source (the common case — no + splitting, no subcategories involved) shouldn't show a redundant + one-item breakdown of itself.""" + groceries = categories["Groceries"]["id"] + await client.post("/transactions", json=_txn(account_id, amount="50.00", category_id=groceries, date="2026-08-01")) + + resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8}) + breakdown = {row["name"]: row for row in resp.json()["spending_by_category"]} + + assert breakdown["Groceries"]["children"] == [] diff --git a/backend/tests/test_reports.py b/backend/tests/test_reports.py new file mode 100644 index 0000000..0efb728 --- /dev/null +++ b/backend/tests/test_reports.py @@ -0,0 +1,78 @@ +"""Long-range reports: the category ranking (which categories cost the +most over an arbitrary period) and the single-category spending detail — +both split-aware via services/category_rollup.py, same as the Dashboard +breakdown (see test_dashboard.py). +""" +from decimal import Decimal + +from httpx import AsyncClient + +from tests.helpers import money, txn_payload as _txn + + +async def test_ranking_rolls_up_a_split_into_one_item_with_children(client: AsyncClient, account_id, categories): + groceries = categories["Groceries"]["id"] + sweets = ( + await client.post("/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries}) + ).json()["id"] + await client.post( + "/transactions", + json=_txn( + account_id, + amount="100.00", + category_id=None, + date="2026-08-01", + splits=[ + {"category_id": groceries, "amount": "70.00"}, + {"category_id": sweets, "amount": "30.00"}, + ], + ), + ) + + resp = await client.get("/reports/category-ranking", params={"kind": "expense"}) + items = {item["name"]: item for item in resp.json()["items"]} + + assert "Sweets" not in items + assert money(items["Groceries"]["amount"]) == Decimal("100.00") + children = {child["name"]: child for child in items["Groceries"]["children"]} + assert money(children["Groceries"]["amount"]) == Decimal("70.00") + assert money(children["Sweets"]["amount"]) == Decimal("30.00") + + +async def test_ranking_item_with_a_single_source_has_no_children(client: AsyncClient, account_id, categories): + groceries = categories["Groceries"]["id"] + await client.post("/transactions", json=_txn(account_id, amount="50.00", category_id=groceries, date="2026-08-01")) + + resp = await client.get("/reports/category-ranking", params={"kind": "expense"}) + items = {item["name"]: item for item in resp.json()["items"]} + + assert items["Groceries"]["children"] == [] + + +async def test_category_spending_report_counts_a_subcategorys_split_share(client: AsyncClient, account_id, categories): + """A top-level category's own report folds in both its plain + transactions and any split lines landing on it or its subcategories.""" + groceries = categories["Groceries"]["id"] + sweets = ( + await client.post("/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries}) + ).json()["id"] + await client.post( + "/transactions", + json=_txn( + account_id, + amount="100.00", + category_id=None, + date="2026-08-01", + splits=[ + {"category_id": groceries, "amount": "70.00"}, + {"category_id": sweets, "amount": "30.00"}, + ], + ), + ) + + parent_report = (await client.get("/reports/category-spending", params={"category_id": groceries})).json() + assert money(parent_report["total_amount"]) == Decimal("100.00") + assert parent_report["transaction_count"] == 1 + + child_report = (await client.get("/reports/category-spending", params={"category_id": sweets})).json() + assert money(child_report["total_amount"]) == Decimal("30.00") diff --git a/backend/tests/test_transactions.py b/backend/tests/test_transactions.py index 2a11a63..6ca73a0 100644 --- a/backend/tests/test_transactions.py +++ b/backend/tests/test_transactions.py @@ -403,10 +403,47 @@ async def test_a_transfer_whose_destination_account_was_deleted_can_still_be_lis assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204 +async def _subcategory(client: AsyncClient, parent_id: int, name: str) -> int: + resp = await client.post( + "/categories", json={"name": name, "kind": "expense", "color": "#7a869a", "parent_id": parent_id} + ) + return resp.json()["id"] + + async def test_create_split_transaction_across_categories(client: AsyncClient, account_id, categories): - """The hypermarket-receipt case: one purchase, part groceries, part - shopping — category_id stays empty on the row itself, and its splits - carry the categorization instead.""" + """The hypermarket-receipt case: one purchase, part everyday groceries, + part sweets — a split's categories must share one parent (see + _build_splits), so both lines here are Groceries itself and one of its + own subcategories. category_id stays empty on the row itself, and its + splits carry the categorization instead.""" + groceries = categories["Groceries"]["id"] + sweets = await _subcategory(client, groceries, "Sweets") + resp = await client.post( + "/transactions", + json=_txn( + account_id, + amount="100.00", + category_id=None, + splits=[ + {"category_id": groceries, "amount": "70.00"}, + {"category_id": sweets, "amount": "30.00", "note": "candy and snacks"}, + ], + ), + ) + assert resp.status_code == 201 + body = resp.json() + assert body["category"] is None + splits = {s["category"]["name"]: s for s in body["splits"]} + assert money(splits["Groceries"]["amount"]) == Decimal("70.00") + assert money(splits["Sweets"]["amount"]) == Decimal("30.00") + assert splits["Sweets"]["note"] == "candy and snacks" + + +async def test_create_split_rejects_categories_from_different_parents(client: AsyncClient, account_id, categories): + """The whole point of a split is dividing one purchase across the + subcategories of ONE parent — Groceries and Shopping are two unrelated + top-level categories, so mixing them must be rejected even though both + are individually valid expense categories.""" groceries = categories["Groceries"]["id"] shopping = categories["Shopping"]["id"] resp = await client.post( @@ -417,22 +454,36 @@ async def test_create_split_transaction_across_categories(client: AsyncClient, a category_id=None, splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00", "note": "household chemicals"}, + {"category_id": shopping, "amount": "30.00"}, ], ), ) - assert resp.status_code == 201 - body = resp.json() - assert body["category"] is None - splits = {s["category"]["name"]: s for s in body["splits"]} - assert money(splits["Groceries"]["amount"]) == Decimal("70.00") - assert money(splits["Shopping"]["amount"]) == Decimal("30.00") - assert splits["Shopping"]["note"] == "household chemicals" + assert resp.status_code == 400 + + +async def test_create_split_rejects_two_different_parents_subcategories(client: AsyncClient, account_id, categories): + groceries = categories["Groceries"]["id"] + shopping = categories["Shopping"]["id"] + sweets = await _subcategory(client, groceries, "Sweets") + electronics = await _subcategory(client, shopping, "Electronics") + resp = await client.post( + "/transactions", + json=_txn( + account_id, + amount="100.00", + category_id=None, + splits=[ + {"category_id": sweets, "amount": "70.00"}, + {"category_id": electronics, "amount": "30.00"}, + ], + ), + ) + assert resp.status_code == 400 async def test_create_split_rejects_category_id_alongside_splits(client: AsyncClient, account_id, categories): groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets = await _subcategory(client, groceries, "Sweets") resp = await client.post( "/transactions", json=_txn( @@ -441,7 +492,7 @@ async def test_create_split_rejects_category_id_alongside_splits(client: AsyncCl category_id=groceries, splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00"}, + {"category_id": sweets, "amount": "30.00"}, ], ), ) @@ -460,7 +511,7 @@ async def test_create_split_rejects_a_single_split(client: AsyncClient, account_ async def test_create_split_rejects_amounts_not_summing_to_total(client: AsyncClient, account_id, categories): groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets = await _subcategory(client, groceries, "Sweets") resp = await client.post( "/transactions", json=_txn( @@ -469,7 +520,7 @@ async def test_create_split_rejects_amounts_not_summing_to_total(client: AsyncCl category_id=None, splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "20.00"}, + {"category_id": sweets, "amount": "20.00"}, ], ), ) @@ -479,7 +530,7 @@ async def test_create_split_rejects_amounts_not_summing_to_total(client: AsyncCl async def test_create_split_rejects_on_transfer(client: AsyncClient, account_id, categories): other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json() groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets = await _subcategory(client, groceries, "Sweets") resp = await client.post( "/transactions", json=_txn( @@ -490,7 +541,7 @@ async def test_create_split_rejects_on_transfer(client: AsyncClient, account_id, transfer_account_id=other["id"], splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00"}, + {"category_id": sweets, "amount": "30.00"}, ], ), ) @@ -499,7 +550,7 @@ async def test_create_split_rejects_on_transfer(client: AsyncClient, account_id, async def test_update_can_convert_a_transaction_into_a_split_and_back(client: AsyncClient, account_id, categories): groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets = await _subcategory(client, groceries, "Sweets") created = await client.post("/transactions", json=_txn(account_id, amount="100.00", category_id=groceries)) txn_id = created.json()["id"] @@ -509,7 +560,7 @@ async def test_update_can_convert_a_transaction_into_a_split_and_back(client: As "category_id": None, "splits": [ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00"}, + {"category_id": sweets, "amount": "30.00"}, ], }, ) @@ -525,7 +576,7 @@ async def test_update_can_convert_a_transaction_into_a_split_and_back(client: As async def test_update_rejects_splits_not_summing_to_the_new_amount(client: AsyncClient, account_id, categories): groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets = await _subcategory(client, groceries, "Sweets") created = await client.post( "/transactions", json=_txn( @@ -534,7 +585,7 @@ async def test_update_rejects_splits_not_summing_to_the_new_amount(client: Async category_id=None, splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00"}, + {"category_id": sweets, "amount": "30.00"}, ], ), ) @@ -555,10 +606,7 @@ async def test_update_of_unrelated_field_tolerates_a_split_whose_category_was_de state — the split's own category was never re-submitted, so nothing here needs it to still exist.""" groceries = categories["Groceries"]["id"] - extra = await client.post( - "/categories", json={"name": "Deletable", "kind": "expense", "color": "#123456", "sort_order": 99} - ) - extra_id = extra.json()["id"] + deletable = await _subcategory(client, groceries, "Deletable") created = await client.post( "/transactions", json=_txn( @@ -567,12 +615,12 @@ async def test_update_of_unrelated_field_tolerates_a_split_whose_category_was_de category_id=None, splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": extra_id, "amount": "30.00"}, + {"category_id": deletable, "amount": "30.00"}, ], ), ) txn_id = created.json()["id"] - assert (await client.delete(f"/categories/{extra_id}")).status_code == 204 + assert (await client.delete(f"/categories/{deletable}")).status_code == 204 resp = await client.patch(f"/transactions/{txn_id}", json={"description": "renamed"}) assert resp.status_code == 200 @@ -584,7 +632,7 @@ async def test_category_filter_matches_transactions_split_into_that_category( client: AsyncClient, account_id, categories ): groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets = await _subcategory(client, groceries, "Sweets") await client.post( "/transactions", json=_txn( @@ -593,18 +641,18 @@ async def test_category_filter_matches_transactions_split_into_that_category( category_id=None, splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00"}, + {"category_id": sweets, "amount": "30.00"}, ], ), ) - resp = await client.get("/transactions", params={"category_id": shopping}) + resp = await client.get("/transactions", params={"category_id": sweets}) assert resp.json()["total"] == 1 async def test_delete_transaction_cascades_its_splits(client: AsyncClient, account_id, categories): groceries = categories["Groceries"]["id"] - shopping = categories["Shopping"]["id"] + sweets = await _subcategory(client, groceries, "Sweets") created = await client.post( "/transactions", json=_txn( @@ -613,7 +661,7 @@ async def test_delete_transaction_cascades_its_splits(client: AsyncClient, accou category_id=None, splits=[ {"category_id": groceries, "amount": "70.00"}, - {"category_id": shopping, "amount": "30.00"}, + {"category_id": sweets, "amount": "30.00"}, ], ), ) @@ -623,5 +671,5 @@ async def test_delete_transaction_cascades_its_splits(client: AsyncClient, accou # If the split rows outlived their parent, this budget/dashboard-style # category filter would still find a (now-orphaned) match. - resp = await client.get("/transactions", params={"category_id": groceries}) + resp = await client.get("/transactions", params={"category_id": sweets}) assert resp.json()["total"] == 0 diff --git a/frontend/src/components/dashboard/SpendingByCategoryCard.tsx b/frontend/src/components/dashboard/SpendingByCategoryCard.tsx index e54701d..ceae726 100644 --- a/frontend/src/components/dashboard/SpendingByCategoryCard.tsx +++ b/frontend/src/components/dashboard/SpendingByCategoryCard.tsx @@ -1,10 +1,12 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { getCategoryIcon } from "@/lib/icons"; import { formatCurrency } from "@/lib/format"; -import { useTranslation } from "@/lib/i18n"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; -import type { CategoryBreakdownItem } from "@/types"; +import type { CategoryBreakdownChildItem, CategoryBreakdownItem } from "@/types"; interface SpendingByCategoryCardProps { items: CategoryBreakdownItem[]; @@ -23,13 +25,31 @@ function DonutTooltip({ active, payload }: { active?: boolean; payload?: Array<{ ); } +// A child's category_id equals its parent's own — the enclosing +// CategoryBreakdownItem's — when that slice of spend was filed directly on +// the parent with no more specific subcategory chosen (see +// services/category_rollup.py's CategoryRollupChildItem). +function childLabel(child: CategoryBreakdownChildItem, parentId: number | null, t: (key: TranslationKey) => string) { + return child.category_id === parentId ? t("reports.directSpendLabel") : translateCategoryName(child.name); +} + export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) { const { t } = useTranslation(); + const [expanded, setExpanded] = useState>(new Set()); const hasData = items.length > 0; // Recharts needs a numeric dataKey — API amounts arrive as strings (Decimal // is serialized as string to avoid float precision loss). const chartData = items.map((item) => ({ ...item, amount: Number(item.amount) })); + function toggle(categoryId: number) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(categoryId)) next.delete(categoryId); + else next.add(categoryId); + return next; + }); + } + return ( @@ -66,23 +86,48 @@ export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) {
    {items.map((item) => { const Icon = getCategoryIcon(item.icon); + const hasChildren = item.children.length > 0; + const isExpanded = item.category_id !== null && expanded.has(item.category_id); return ( -
  • - + + + {hasChildren && isExpanded && ( +
      + {item.children.map((child) => ( +
    • + + + {childLabel(child, item.category_id, t)} + + + {formatCurrency(child.amount)} + +
    • + ))} +
    + )}
  • ); })} diff --git a/frontend/src/components/reports/CategoryRankingCard.tsx b/frontend/src/components/reports/CategoryRankingCard.tsx index e12c5ad..57eee15 100644 --- a/frontend/src/components/reports/CategoryRankingCard.tsx +++ b/frontend/src/components/reports/CategoryRankingCard.tsx @@ -1,9 +1,11 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { getCategoryIcon } from "@/lib/icons"; import { formatCurrency } from "@/lib/format"; -import { useTranslation } from "@/lib/i18n"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; -import type { CategoryRankingItem } from "@/types"; +import type { CategoryRankingChildItem, CategoryRankingItem } from "@/types"; interface CategoryRankingCardProps { items: CategoryRankingItem[]; @@ -17,8 +19,26 @@ interface CategoryRankingCardProps { * detail chart below (locked to one category), this is "which category * costs the most" across the whole range at once. Clicking a row drills * into that category in the detail chart/transaction list below. */ +// A child's category_id equals its parent's own — the enclosing +// CategoryRankingItem's — when that slice of spend was filed directly on +// the parent with no more specific subcategory chosen (see +// services/category_rollup.py's CategoryRollupChildItem). +function childLabel(child: CategoryRankingChildItem, parentId: number, t: (key: TranslationKey) => string) { + return child.category_id === parentId ? t("reports.directSpendLabel") : translateCategoryName(child.name); +} + export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSelectCategory }: CategoryRankingCardProps) { const { t } = useTranslation(); + const [expanded, setExpanded] = useState>(new Set()); + + function toggle(categoryId: number) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(categoryId)) next.delete(categoryId); + else next.add(categoryId); + return next; + }); + } return ( @@ -35,40 +55,72 @@ export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSe {items.map((item, index) => { const Icon = getCategoryIcon(item.icon); const isSelected = item.category_id === selectedCategoryId; + const hasChildren = item.children.length > 0; + const isExpanded = expanded.has(item.category_id); return (
  • - + + + + + + {item.percent.toFixed(0)}% + + + + {formatCurrency(item.amount)} + + + {hasChildren && ( + + )} + + + {hasChildren && isExpanded && ( +
      + {item.children.map((child) => ( +
    • + + + {childLabel(child, item.category_id, t)} + + + {formatCurrency(child.amount)} + +
    • + ))} +
    + )}
  • ); })} diff --git a/frontend/src/components/transactions/TransactionFormModal.tsx b/frontend/src/components/transactions/TransactionFormModal.tsx index ecd5e90..bd847c8 100644 --- a/frontend/src/components/transactions/TransactionFormModal.tsx +++ b/frontend/src/components/transactions/TransactionFormModal.tsx @@ -69,10 +69,23 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction useEffect(() => { if (!open) return; if (transaction) { + const hasSplits = transaction.splits.length > 0; + // A split's categories always share one parent (see + // routes/transactions.py's _build_splits) — derive that shared base + // from whichever split's category is still live. If every split's + // category was since deleted, there's nothing to derive from; the + // base field is left blank and the user has to pick one again. + const baseCategory = hasSplits ? transaction.splits.find((split) => split.category)?.category : null; setForm({ type: transaction.type, account_id: String(transaction.account_id), - category_id: transaction.category_id ? String(transaction.category_id) : "", + category_id: hasSplits + ? baseCategory + ? String(baseCategory.parent_id ?? baseCategory.id) + : "" + : transaction.category_id + ? String(transaction.category_id) + : "", transfer_account_id: transaction.transfer_account_id ? String(transaction.transfer_account_id) : "", amount: transaction.amount, description: transaction.description, @@ -81,7 +94,6 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction date: transaction.date, }); setTags(transaction.tags); - const hasSplits = transaction.splits.length > 0; setSplitMode(hasSplits); setSplitRows( hasSplits @@ -134,6 +146,34 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction const splitAllocatedCents = splitRows.reduce((sum, row) => sum + toCents(row.amount), 0); const splitRemainingCents = toCents(form.amount) - splitAllocatedCents; + // The category select becomes the split's "base" category while + // splitting — restricted to top-level categories — and each split row can + // only pick that base itself or one of its direct children (see + // routes/transactions.py's _build_splits: a split's categories always + // share one parent). + const topLevelCategories = relevantCategories.filter((category) => !category.indented); + const baseChildCategories = kindCategories.filter((category) => category.parent_id === Number(form.category_id)); + const categorySelectOptions = splitMode ? topLevelCategories : relevantCategories; + + function toggleSplitMode() { + setSplitMode((prev) => { + const next = !prev; + if (next) { + const current = relevantCategories.find((category) => String(category.id) === form.category_id); + if (current?.parent_id) { + setForm((f) => ({ ...f, category_id: String(current.parent_id) })); + } + setSplitRows([emptySplitRow(), emptySplitRow()]); + } + return next; + }); + } + + function handleBaseCategoryChange(value: string) { + setForm((prev) => ({ ...prev, category_id: value })); + if (splitMode) setSplitRows([emptySplitRow(), emptySplitRow()]); + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setError(null); @@ -157,6 +197,10 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction // switched the transaction to a transfer, which can't carry splits). let splits: TransactionSplitInput[] | undefined; if (isSplitEditingNow) { + if (!form.category_id) { + setError(t("transactions.form.errorSplitNoBaseCategory")); + return; + } const filledRows = splitRows.filter((row) => row.category_id || row.amount); if (filledRows.length < 2) { setError(t("transactions.form.errorSplitMinRows")); @@ -318,29 +362,35 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction - {!splitMode ? ( - - ) : ( -
    -

    {t("transactions.form.splitHint")}

    + + + {splitMode && ( +
    + {!form.category_id ? ( +

    {t("transactions.form.splitHint")}

    + ) : baseChildCategories.length === 0 ? ( +

    {t("transactions.form.splitNoChildren")}

    + ) : ( +

    {t("transactions.form.splitHint")}

    + )} {splitRows.map((row) => (
    @@ -348,14 +398,17 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction aria-label={t("transactions.form.splitCategoryPlaceholder")} className="sm:flex-1" value={row.category_id} + disabled={!form.category_id} onChange={(event) => updateSplitRow(row.key, { category_id: event.target.value })} > - {relevantCategories.map((category) => ( + {form.category_id && ( + + )} + {baseChildCategories.map((category) => ( ))} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 173e8c0..c762d1a 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -29,6 +29,8 @@ const ru = { "topbar.openMenu": "Открыть меню", "common.loading": "Загрузка…", + "common.expand": "Развернуть", + "common.collapse": "Свернуть", "common.back": "Назад", "common.next": "Далее", "common.clear": "Очистить", @@ -285,6 +287,10 @@ const ru = { "transactions.form.errorSplitMinRows": "Нужно как минимум 2 категории для разбивки", "transactions.form.errorSplitIncomplete": "Выберите категорию и сумму для каждой строки разбивки", "transactions.form.errorSplitMismatch": "Сумма по категориям должна совпадать с суммой транзакции", + "transactions.form.errorSplitNoBaseCategory": "Сначала выберите базовую категорию", + "transactions.form.splitDirectOption": "— без конкретной подкатегории —", + "transactions.form.splitNoChildren": "У этой категории пока нет подкатегорий — добавьте их на странице «Категории», чтобы разбивать покупки по ним.", + "reports.directSpendLabel": "Без подкатегории", "reports.categoryLabel": "Категория", "reports.expenseGroup": "Расходы", @@ -504,6 +510,8 @@ const en: Record = { "topbar.openMenu": "Open menu", "common.loading": "Loading…", + "common.expand": "Expand", + "common.collapse": "Collapse", "common.back": "Back", "common.next": "Next", "common.clear": "Clear", @@ -760,6 +768,10 @@ const en: Record = { "transactions.form.errorSplitMinRows": "Splitting needs at least 2 categories", "transactions.form.errorSplitIncomplete": "Pick a category and an amount for every split row", "transactions.form.errorSplitMismatch": "Split amounts must add up to the transaction amount", + "transactions.form.errorSplitNoBaseCategory": "Pick a base category first", + "transactions.form.splitDirectOption": "— no specific subcategory —", + "transactions.form.splitNoChildren": "This category has no subcategories yet — add some on the Categories page to split purchases across them.", + "reports.directSpendLabel": "No subcategory", "reports.categoryLabel": "Category", "reports.expenseGroup": "Expenses", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0601fb3..b1c124a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -161,6 +161,18 @@ export interface RecurringTransactionInput { anchor_date: string; } +// One subcategory's (or the parent's own direct, un-subcategorized) share +// of a CategoryBreakdownItem's total. Populated only when more than one +// distinct category fed the slice — a single-source category (the common +// case) leaves this off the parent item entirely. +export interface CategoryBreakdownChildItem { + category_id: number; + name: string; + color: string; + icon: string | null; + amount: string; +} + export interface CategoryBreakdownItem { category_id: number | null; name: string; @@ -168,6 +180,7 @@ export interface CategoryBreakdownItem { icon: string | null; amount: string; percent: number; + children: CategoryBreakdownChildItem[]; } export interface DashboardSummary { @@ -310,6 +323,14 @@ export interface CashFlowResponse { total_net: string; } +export interface CategoryRankingChildItem { + category_id: number; + name: string; + color: string; + icon: string | null; + amount: string; +} + export interface CategoryRankingItem { category_id: number; name: string; @@ -318,6 +339,7 @@ export interface CategoryRankingItem { amount: string; percent: number; transaction_count: number; + children: CategoryRankingChildItem[]; } export interface CategoryRankingReport {