fix: restrict transaction splits to one parent category, show subcategory breakdown

Раньше сплит позволял смешивать вообще любые категории. Реальная задача —
разложить одну покупку по подкатегориям ОДНОЙ базовой категории (100 € на
продукты: сколько на сладости, сколько на уход). А раз Dashboard и рейтинг
категорий сворачивают подкатегории в родителя, эта разбивка нигде не была
видна.

- _build_splits() требует, чтобы все категории сплита были одной родительской
  категорией или её прямыми подкатегориями.
- category_rollup.py отдаёт для каждого родительского тотала список
  category-источников, которые в него скинулись (children) — пусто, если
  источник один.
- Dashboard и рейтинг категорий: строка с непустым children раскрывается по
  клику, показывая вклад каждой подкатегории.
- Форма транзакции: выбор категории при сплите — сначала базовая (верхний
  уровень), затем строки сплита выбирают саму базовую или её подкатегорию.
This commit is contained in:
ZProger
2026-08-30 19:06:29 +03:00
parent f778742134
commit 140f58d33f
16 changed files with 580 additions and 130 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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"] == []

View File

@@ -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")

View File

@@ -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