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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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