mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
@@ -52,13 +52,15 @@ _TYPE_TO_CATEGORY_KIND = {
|
|||||||
|
|
||||||
async def _ensure_category_matches_type(
|
async def _ensure_category_matches_type(
|
||||||
session: AsyncSession, category_id: int | None, transaction_type: TransactionType
|
session: AsyncSession, category_id: int | None, transaction_type: TransactionType
|
||||||
) -> None:
|
) -> Category | None:
|
||||||
"""A category picked for an income transaction must itself be an income
|
"""A category picked for an income transaction must itself be an income
|
||||||
category (and likewise for expense) — otherwise the dashboard's spending
|
category (and likewise for expense) — otherwise the dashboard's spending
|
||||||
breakdown, which only joins EXPENSE-typed rows, would silently misclassify
|
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:
|
if category_id is None:
|
||||||
return
|
return None
|
||||||
expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type)
|
expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type)
|
||||||
category = await session.get(Category, category_id)
|
category = await session.get(Category, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
@@ -68,13 +70,32 @@ async def _ensure_category_matches_type(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Category '{category.name}' is a {category.kind.value} category and cannot be used for a {transaction_type.value} transaction",
|
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(
|
async def _build_splits(
|
||||||
session: AsyncSession, splits: list[TransactionSplitInput], transaction_type: TransactionType
|
session: AsyncSession, splits: list[TransactionSplitInput], transaction_type: TransactionType
|
||||||
) -> list[TransactionSplit]:
|
) -> 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:
|
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]
|
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."""
|
instead of leaving a half-imported statement behind."""
|
||||||
for item in payload.items:
|
for item in payload.items:
|
||||||
await _ensure_category_matches_type(session, item.category_id, item.type)
|
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 = []
|
transactions = []
|
||||||
for item in payload.items:
|
for item in payload.items:
|
||||||
transaction = Transaction(**item.model_dump(exclude={"tag_ids", "splits"}))
|
transaction = Transaction(**item.model_dump(exclude={"tag_ids", "splits"}))
|
||||||
transaction.tags = await _resolve_tags(session, item.tag_ids)
|
transaction.tags = await _resolve_tags(session, item.tag_ids)
|
||||||
if item.splits:
|
if item.splits:
|
||||||
transaction.splits = [
|
transaction.splits = await _build_splits(session, item.splits, item.type)
|
||||||
TransactionSplit(category_id=s.category_id, amount=s.amount, note=s.note) for s in item.splits
|
|
||||||
]
|
|
||||||
transactions.append(transaction)
|
transactions.append(transaction)
|
||||||
|
|
||||||
session.add_all(transactions)
|
session.add_all(transactions)
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
from decimal import Decimal
|
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):
|
class CategoryBreakdownItem(BaseModel):
|
||||||
@@ -10,6 +22,10 @@ class CategoryBreakdownItem(BaseModel):
|
|||||||
icon: str | None
|
icon: str | None
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
percent: float
|
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):
|
class DashboardSummary(BaseModel):
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
from datetime import date as date_
|
from datetime import date as date_
|
||||||
from decimal import Decimal
|
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):
|
class CategorySpendingPoint(BaseModel):
|
||||||
@@ -31,6 +43,10 @@ class CategoryRankingItem(BaseModel):
|
|||||||
amount: Decimal
|
amount: Decimal
|
||||||
percent: float
|
percent: float
|
||||||
transaction_count: int
|
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):
|
class CategoryRankingReport(BaseModel):
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ funded partly by plain transactions and partly by split lines still shows
|
|||||||
one correct total.
|
one correct total.
|
||||||
"""
|
"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import date as date_
|
from datetime import date as date_
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -25,6 +25,20 @@ from app.models.enums import TransactionType
|
|||||||
from app.models.transaction import Transaction, TransactionSplit
|
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
|
@dataclass
|
||||||
class CategoryRollupItem:
|
class CategoryRollupItem:
|
||||||
category_id: int
|
category_id: int
|
||||||
@@ -37,6 +51,13 @@ class CategoryRollupItem:
|
|||||||
# transaction counts once per top-level category it touches, same as a
|
# transaction counts once per top-level category it touches, same as a
|
||||||
# plain one counts once for its own category.
|
# plain one counts once for its own category.
|
||||||
transaction_count: int
|
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(
|
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)
|
amount_by_effective: dict[int, Decimal] = defaultdict(Decimal)
|
||||||
txn_ids_by_effective: dict[int, set[int]] = defaultdict(set)
|
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:
|
for transaction_id, category_id, amount in contributions:
|
||||||
category = categories_by_id.get(category_id)
|
category = categories_by_id.get(category_id)
|
||||||
effective_id = category.parent_id if category and category.parent_id is not None else 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_effective[effective_id] += amount
|
||||||
|
amount_by_leaf[effective_id][category_id] += amount
|
||||||
txn_ids_by_effective[effective_id].add(transaction_id)
|
txn_ids_by_effective[effective_id].add(transaction_id)
|
||||||
|
|
||||||
items: list[CategoryRollupItem] = []
|
items: list[CategoryRollupItem] = []
|
||||||
for effective_id, amount in amount_by_effective.items():
|
for effective_id, amount in amount_by_effective.items():
|
||||||
category = categories_by_id.get(effective_id)
|
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(
|
items.append(
|
||||||
CategoryRollupItem(
|
CategoryRollupItem(
|
||||||
category_id=effective_id,
|
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,
|
sort_order=category.sort_order if category else 0,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
transaction_count=len(txn_ids_by_effective[effective_id]),
|
transaction_count=len(txn_ids_by_effective[effective_id]),
|
||||||
|
children=children,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
items.sort(key=lambda item: (-item.amount, item.sort_order))
|
items.sort(key=lambda item: (-item.amount, item.sort_order))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.models.enums import TransactionType
|
from app.models.enums import TransactionType
|
||||||
from app.models.transaction import Transaction
|
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
|
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",
|
# 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(
|
CategoryBreakdownItem(
|
||||||
category_id=row.category_id, name=row.name, color=row.color, icon=row.icon,
|
category_id=row.category_id, name=row.name, color=row.color, icon=row.icon,
|
||||||
amount=row.amount, percent=_percent(row.amount),
|
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
|
for row in top_rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.models.category import Category
|
|||||||
from app.models.enums import CategoryKind, TransactionType
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
from app.models.transaction import Transaction, TransactionSplit
|
from app.models.transaction import Transaction, TransactionSplit
|
||||||
from app.schemas.reports import (
|
from app.schemas.reports import (
|
||||||
|
CategoryRankingChildItem,
|
||||||
CategoryRankingItem,
|
CategoryRankingItem,
|
||||||
CategoryRankingReport,
|
CategoryRankingReport,
|
||||||
CategorySpendingPoint,
|
CategorySpendingPoint,
|
||||||
@@ -155,6 +156,13 @@ async def get_category_ranking_report(
|
|||||||
amount=row.amount,
|
amount=row.amount,
|
||||||
percent=_percent(row.amount),
|
percent=_percent(row.amount),
|
||||||
transaction_count=row.transaction_count,
|
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
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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):
|
async def test_backup_roundtrip_preserves_transaction_splits(client: AsyncClient, account_id, categories):
|
||||||
groceries = categories["Groceries"]["id"]
|
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(
|
created = await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=_txn(
|
json=_txn(
|
||||||
@@ -75,7 +77,7 @@ async def test_backup_roundtrip_preserves_transaction_splits(client: AsyncClient
|
|||||||
category_id=None,
|
category_id=None,
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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)
|
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"]}
|
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
|
assert refetched["category"] is None
|
||||||
|
|||||||
@@ -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):
|
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
|
"""A receipt split between Groceries and one of its own subcategories
|
||||||
slice toward the Groceries budget, not the full 100.00 or nothing."""
|
(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"]
|
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": groceries, "monthly_limit": "100.00"})
|
||||||
|
await client.post("/budgets", json={"category_id": sweets, "monthly_limit": "50.00"})
|
||||||
await client.post(
|
await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=txn_payload(
|
json=txn_payload(
|
||||||
@@ -149,11 +155,12 @@ async def test_budget_status_counts_a_split_transactions_share(client: AsyncClie
|
|||||||
date="2026-08-10",
|
date="2026-08-10",
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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})
|
resp = await client.get("/budgets/status", params={"year": 2026, "month": 8})
|
||||||
item = resp.json()["items"][0]
|
items = {item["category_id"]: item for item in resp.json()["items"]}
|
||||||
assert money(item["spent"]) == Decimal("70.00")
|
assert money(items[groceries]["spent"]) == Decimal("100.00")
|
||||||
|
assert money(items[sweets]["spent"]) == Decimal("30.00")
|
||||||
|
|||||||
@@ -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")
|
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
|
client: AsyncClient, account_id, categories
|
||||||
):
|
):
|
||||||
"""A hypermarket receipt split between Groceries and Shopping must show
|
"""A hypermarket receipt split between Groceries and one of its own
|
||||||
up as two separate slices on the donut, not one lump under whichever
|
subcategories (Sweets) must show up as a single Groceries slice on the
|
||||||
category happened to be picked."""
|
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"]
|
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(
|
await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=_txn(
|
json=_txn(
|
||||||
@@ -144,7 +150,7 @@ async def test_split_transaction_counts_each_split_toward_its_own_category(
|
|||||||
date="2026-08-01",
|
date="2026-08-01",
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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"]}
|
breakdown = {row["name"]: row for row in body["spending_by_category"]}
|
||||||
|
|
||||||
assert money(body["spent"]) == Decimal("100.00")
|
assert money(body["spent"]) == Decimal("100.00")
|
||||||
assert money(breakdown["Groceries"]["amount"]) == Decimal("70.00")
|
assert "Sweets" not in breakdown
|
||||||
assert money(breakdown["Shopping"]["amount"]) == Decimal("30.00")
|
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"] == []
|
||||||
|
|||||||
78
backend/tests/test_reports.py
Normal file
78
backend/tests/test_reports.py
Normal 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")
|
||||||
@@ -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
|
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):
|
async def test_create_split_transaction_across_categories(client: AsyncClient, account_id, categories):
|
||||||
"""The hypermarket-receipt case: one purchase, part groceries, part
|
"""The hypermarket-receipt case: one purchase, part everyday groceries,
|
||||||
shopping — category_id stays empty on the row itself, and its splits
|
part sweets — a split's categories must share one parent (see
|
||||||
carry the categorization instead."""
|
_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"]
|
groceries = categories["Groceries"]["id"]
|
||||||
shopping = categories["Shopping"]["id"]
|
shopping = categories["Shopping"]["id"]
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
@@ -417,22 +454,36 @@ async def test_create_split_transaction_across_categories(client: AsyncClient, a
|
|||||||
category_id=None,
|
category_id=None,
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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
|
assert resp.status_code == 400
|
||||||
body = resp.json()
|
|
||||||
assert body["category"] is None
|
|
||||||
splits = {s["category"]["name"]: s for s in body["splits"]}
|
async def test_create_split_rejects_two_different_parents_subcategories(client: AsyncClient, account_id, categories):
|
||||||
assert money(splits["Groceries"]["amount"]) == Decimal("70.00")
|
groceries = categories["Groceries"]["id"]
|
||||||
assert money(splits["Shopping"]["amount"]) == Decimal("30.00")
|
shopping = categories["Shopping"]["id"]
|
||||||
assert splits["Shopping"]["note"] == "household chemicals"
|
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):
|
async def test_create_split_rejects_category_id_alongside_splits(client: AsyncClient, account_id, categories):
|
||||||
groceries = categories["Groceries"]["id"]
|
groceries = categories["Groceries"]["id"]
|
||||||
shopping = categories["Shopping"]["id"]
|
sweets = await _subcategory(client, groceries, "Sweets")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=_txn(
|
json=_txn(
|
||||||
@@ -441,7 +492,7 @@ async def test_create_split_rejects_category_id_alongside_splits(client: AsyncCl
|
|||||||
category_id=groceries,
|
category_id=groceries,
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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):
|
async def test_create_split_rejects_amounts_not_summing_to_total(client: AsyncClient, account_id, categories):
|
||||||
groceries = categories["Groceries"]["id"]
|
groceries = categories["Groceries"]["id"]
|
||||||
shopping = categories["Shopping"]["id"]
|
sweets = await _subcategory(client, groceries, "Sweets")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=_txn(
|
json=_txn(
|
||||||
@@ -469,7 +520,7 @@ async def test_create_split_rejects_amounts_not_summing_to_total(client: AsyncCl
|
|||||||
category_id=None,
|
category_id=None,
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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):
|
async def test_create_split_rejects_on_transfer(client: AsyncClient, account_id, categories):
|
||||||
other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json()
|
other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json()
|
||||||
groceries = categories["Groceries"]["id"]
|
groceries = categories["Groceries"]["id"]
|
||||||
shopping = categories["Shopping"]["id"]
|
sweets = await _subcategory(client, groceries, "Sweets")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=_txn(
|
json=_txn(
|
||||||
@@ -490,7 +541,7 @@ async def test_create_split_rejects_on_transfer(client: AsyncClient, account_id,
|
|||||||
transfer_account_id=other["id"],
|
transfer_account_id=other["id"],
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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):
|
async def test_update_can_convert_a_transaction_into_a_split_and_back(client: AsyncClient, account_id, categories):
|
||||||
groceries = categories["Groceries"]["id"]
|
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))
|
created = await client.post("/transactions", json=_txn(account_id, amount="100.00", category_id=groceries))
|
||||||
txn_id = created.json()["id"]
|
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,
|
"category_id": None,
|
||||||
"splits": [
|
"splits": [
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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):
|
async def test_update_rejects_splits_not_summing_to_the_new_amount(client: AsyncClient, account_id, categories):
|
||||||
groceries = categories["Groceries"]["id"]
|
groceries = categories["Groceries"]["id"]
|
||||||
shopping = categories["Shopping"]["id"]
|
sweets = await _subcategory(client, groceries, "Sweets")
|
||||||
created = await client.post(
|
created = await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=_txn(
|
json=_txn(
|
||||||
@@ -534,7 +585,7 @@ async def test_update_rejects_splits_not_summing_to_the_new_amount(client: Async
|
|||||||
category_id=None,
|
category_id=None,
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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
|
state — the split's own category was never re-submitted, so nothing here
|
||||||
needs it to still exist."""
|
needs it to still exist."""
|
||||||
groceries = categories["Groceries"]["id"]
|
groceries = categories["Groceries"]["id"]
|
||||||
extra = await client.post(
|
deletable = await _subcategory(client, groceries, "Deletable")
|
||||||
"/categories", json={"name": "Deletable", "kind": "expense", "color": "#123456", "sort_order": 99}
|
|
||||||
)
|
|
||||||
extra_id = extra.json()["id"]
|
|
||||||
created = await client.post(
|
created = await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=_txn(
|
json=_txn(
|
||||||
@@ -567,12 +615,12 @@ async def test_update_of_unrelated_field_tolerates_a_split_whose_category_was_de
|
|||||||
category_id=None,
|
category_id=None,
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"category_id": groceries, "amount": "70.00"},
|
||||||
{"category_id": extra_id, "amount": "30.00"},
|
{"category_id": deletable, "amount": "30.00"},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
txn_id = created.json()["id"]
|
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"})
|
resp = await client.patch(f"/transactions/{txn_id}", json={"description": "renamed"})
|
||||||
assert resp.status_code == 200
|
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
|
client: AsyncClient, account_id, categories
|
||||||
):
|
):
|
||||||
groceries = categories["Groceries"]["id"]
|
groceries = categories["Groceries"]["id"]
|
||||||
shopping = categories["Shopping"]["id"]
|
sweets = await _subcategory(client, groceries, "Sweets")
|
||||||
await client.post(
|
await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=_txn(
|
json=_txn(
|
||||||
@@ -593,18 +641,18 @@ async def test_category_filter_matches_transactions_split_into_that_category(
|
|||||||
category_id=None,
|
category_id=None,
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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
|
assert resp.json()["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_transaction_cascades_its_splits(client: AsyncClient, account_id, categories):
|
async def test_delete_transaction_cascades_its_splits(client: AsyncClient, account_id, categories):
|
||||||
groceries = categories["Groceries"]["id"]
|
groceries = categories["Groceries"]["id"]
|
||||||
shopping = categories["Shopping"]["id"]
|
sweets = await _subcategory(client, groceries, "Sweets")
|
||||||
created = await client.post(
|
created = await client.post(
|
||||||
"/transactions",
|
"/transactions",
|
||||||
json=_txn(
|
json=_txn(
|
||||||
@@ -613,7 +661,7 @@ async def test_delete_transaction_cascades_its_splits(client: AsyncClient, accou
|
|||||||
category_id=None,
|
category_id=None,
|
||||||
splits=[
|
splits=[
|
||||||
{"category_id": groceries, "amount": "70.00"},
|
{"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
|
# If the split rows outlived their parent, this budget/dashboard-style
|
||||||
# category filter would still find a (now-orphaned) match.
|
# 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
|
assert resp.json()["total"] == 0
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { getCategoryIcon } from "@/lib/icons";
|
import { getCategoryIcon } from "@/lib/icons";
|
||||||
import { formatCurrency } from "@/lib/format";
|
import { formatCurrency } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||||
import type { CategoryBreakdownItem } from "@/types";
|
import type { CategoryBreakdownChildItem, CategoryBreakdownItem } from "@/types";
|
||||||
|
|
||||||
interface SpendingByCategoryCardProps {
|
interface SpendingByCategoryCardProps {
|
||||||
items: CategoryBreakdownItem[];
|
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) {
|
export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||||
const hasData = items.length > 0;
|
const hasData = items.length > 0;
|
||||||
// Recharts needs a numeric dataKey — API amounts arrive as strings (Decimal
|
// Recharts needs a numeric dataKey — API amounts arrive as strings (Decimal
|
||||||
// is serialized as string to avoid float precision loss).
|
// is serialized as string to avoid float precision loss).
|
||||||
const chartData = items.map((item) => ({ ...item, amount: Number(item.amount) }));
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -66,23 +86,48 @@ export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) {
|
|||||||
<ul className="w-full min-w-0 flex-1 divide-y divide-gridline">
|
<ul className="w-full min-w-0 flex-1 divide-y divide-gridline">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const Icon = getCategoryIcon(item.icon);
|
const Icon = getCategoryIcon(item.icon);
|
||||||
|
const hasChildren = item.children.length > 0;
|
||||||
|
const isExpanded = item.category_id !== null && expanded.has(item.category_id);
|
||||||
return (
|
return (
|
||||||
<li
|
<li key={item.category_id ?? "other"} className="py-2 first:pt-0 last:pb-0">
|
||||||
key={item.category_id ?? "other"}
|
<button
|
||||||
className="flex items-center gap-3 py-2 first:pt-0 last:pb-0"
|
type="button"
|
||||||
>
|
disabled={!hasChildren}
|
||||||
<span
|
onClick={() => item.category_id !== null && toggle(item.category_id)}
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
className="flex w-full items-center gap-3 text-left disabled:cursor-default"
|
||||||
style={{ backgroundColor: `${item.color}26` }}
|
|
||||||
>
|
>
|
||||||
<Icon size={14} style={{ color: item.color }} />
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-text-muted">
|
||||||
</span>
|
{hasChildren ? isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
||||||
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
</span>
|
||||||
{translateCategoryName(item.name)}
|
<span
|
||||||
</span>
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||||
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
|
style={{ backgroundColor: `${item.color}26` }}
|
||||||
{formatCurrency(item.amount)}
|
>
|
||||||
</span>
|
<Icon size={14} style={{ color: item.color }} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
||||||
|
{translateCategoryName(item.name)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<ul className="ml-11 mt-1 space-y-1 border-l border-gridline pl-3">
|
||||||
|
{item.children.map((child) => (
|
||||||
|
<li key={child.category_id} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: child.color }} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-text-secondary">
|
||||||
|
{childLabel(child, item.category_id, t)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-text-secondary">
|
||||||
|
{formatCurrency(child.amount)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { getCategoryIcon } from "@/lib/icons";
|
import { getCategoryIcon } from "@/lib/icons";
|
||||||
import { formatCurrency } from "@/lib/format";
|
import { formatCurrency } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||||
import type { CategoryRankingItem } from "@/types";
|
import type { CategoryRankingChildItem, CategoryRankingItem } from "@/types";
|
||||||
|
|
||||||
interface CategoryRankingCardProps {
|
interface CategoryRankingCardProps {
|
||||||
items: CategoryRankingItem[];
|
items: CategoryRankingItem[];
|
||||||
@@ -17,8 +19,26 @@ interface CategoryRankingCardProps {
|
|||||||
* detail chart below (locked to one category), this is "which category
|
* detail chart below (locked to one category), this is "which category
|
||||||
* costs the most" across the whole range at once. Clicking a row drills
|
* costs the most" across the whole range at once. Clicking a row drills
|
||||||
* into that category in the detail chart/transaction list below. */
|
* 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) {
|
export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSelectCategory }: CategoryRankingCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState<Set<number>>(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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -35,40 +55,72 @@ export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSe
|
|||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const Icon = getCategoryIcon(item.icon);
|
const Icon = getCategoryIcon(item.icon);
|
||||||
const isSelected = item.category_id === selectedCategoryId;
|
const isSelected = item.category_id === selectedCategoryId;
|
||||||
|
const hasChildren = item.children.length > 0;
|
||||||
|
const isExpanded = expanded.has(item.category_id);
|
||||||
return (
|
return (
|
||||||
<li key={item.category_id}>
|
<li key={item.category_id}>
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={`flex items-center gap-1 rounded-lg transition-colors hover:bg-surface-2 ${
|
||||||
onClick={() => onSelectCategory(item.category_id)}
|
|
||||||
className={`flex w-full items-center gap-3 rounded-lg py-2.5 text-left transition-colors hover:bg-surface-2 ${
|
|
||||||
isSelected ? "bg-surface-2" : ""
|
isSelected ? "bg-surface-2" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="w-4 shrink-0 text-right text-xs tabular-nums text-text-muted">{index + 1}</span>
|
<button
|
||||||
<span
|
type="button"
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
onClick={() => onSelectCategory(item.category_id)}
|
||||||
style={{ backgroundColor: `${item.color}26` }}
|
className="flex flex-1 items-center gap-3 py-2.5 text-left"
|
||||||
>
|
>
|
||||||
<Icon size={15} style={{ color: item.color }} />
|
<span className="w-4 shrink-0 text-right text-xs tabular-nums text-text-muted">{index + 1}</span>
|
||||||
</span>
|
<span
|
||||||
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||||
{translateCategoryName(item.name)}
|
style={{ backgroundColor: `${item.color}26` }}
|
||||||
</span>
|
>
|
||||||
<span className="hidden w-24 shrink-0 items-center gap-2 sm:flex">
|
<Icon size={15} style={{ color: item.color }} />
|
||||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
|
||||||
<span
|
|
||||||
className="block h-full rounded-full"
|
|
||||||
style={{ width: `${item.percent}%`, backgroundColor: item.color }}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="w-9 shrink-0 text-right text-xs text-text-muted tabular-nums">
|
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
||||||
{item.percent.toFixed(0)}%
|
{translateCategoryName(item.name)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span className="hidden w-24 shrink-0 items-center gap-2 sm:flex">
|
||||||
<span className="shrink-0 pr-1 text-sm font-medium tabular-nums text-text-primary">
|
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||||
{formatCurrency(item.amount)}
|
<span
|
||||||
</span>
|
className="block h-full rounded-full"
|
||||||
</button>
|
style={{ width: `${item.percent}%`, backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-9 shrink-0 text-right text-xs text-text-muted tabular-nums">
|
||||||
|
{item.percent.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{hasChildren && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={isExpanded ? t("common.collapse") : t("common.expand")}
|
||||||
|
onClick={() => toggle(item.category_id)}
|
||||||
|
className="mr-1 shrink-0 rounded-md p-1.5 text-text-muted hover:text-text-primary"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<ul className="ml-11 mb-2 mt-1 space-y-1 border-l border-gridline pl-3">
|
||||||
|
{item.children.map((child) => (
|
||||||
|
<li key={child.category_id} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: child.color }} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-text-secondary">
|
||||||
|
{childLabel(child, item.category_id, t)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-text-secondary">
|
||||||
|
{formatCurrency(child.amount)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -69,10 +69,23 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (transaction) {
|
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({
|
setForm({
|
||||||
type: transaction.type,
|
type: transaction.type,
|
||||||
account_id: String(transaction.account_id),
|
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) : "",
|
transfer_account_id: transaction.transfer_account_id ? String(transaction.transfer_account_id) : "",
|
||||||
amount: transaction.amount,
|
amount: transaction.amount,
|
||||||
description: transaction.description,
|
description: transaction.description,
|
||||||
@@ -81,7 +94,6 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
date: transaction.date,
|
date: transaction.date,
|
||||||
});
|
});
|
||||||
setTags(transaction.tags);
|
setTags(transaction.tags);
|
||||||
const hasSplits = transaction.splits.length > 0;
|
|
||||||
setSplitMode(hasSplits);
|
setSplitMode(hasSplits);
|
||||||
setSplitRows(
|
setSplitRows(
|
||||||
hasSplits
|
hasSplits
|
||||||
@@ -134,6 +146,34 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
const splitAllocatedCents = splitRows.reduce((sum, row) => sum + toCents(row.amount), 0);
|
const splitAllocatedCents = splitRows.reduce((sum, row) => sum + toCents(row.amount), 0);
|
||||||
const splitRemainingCents = toCents(form.amount) - splitAllocatedCents;
|
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) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -157,6 +197,10 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
// switched the transaction to a transfer, which can't carry splits).
|
// switched the transaction to a transfer, which can't carry splits).
|
||||||
let splits: TransactionSplitInput[] | undefined;
|
let splits: TransactionSplitInput[] | undefined;
|
||||||
if (isSplitEditingNow) {
|
if (isSplitEditingNow) {
|
||||||
|
if (!form.category_id) {
|
||||||
|
setError(t("transactions.form.errorSplitNoBaseCategory"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const filledRows = splitRows.filter((row) => row.category_id || row.amount);
|
const filledRows = splitRows.filter((row) => row.category_id || row.amount);
|
||||||
if (filledRows.length < 2) {
|
if (filledRows.length < 2) {
|
||||||
setError(t("transactions.form.errorSplitMinRows"));
|
setError(t("transactions.form.errorSplitMinRows"));
|
||||||
@@ -318,29 +362,35 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mb-1 text-xs text-series-1 hover:underline"
|
className="mb-1 text-xs text-series-1 hover:underline"
|
||||||
onClick={() => setSplitMode((prev) => !prev)}
|
onClick={toggleSplitMode}
|
||||||
>
|
>
|
||||||
{splitMode ? t("transactions.form.splitToggleOff") : t("transactions.form.splitToggle")}
|
{splitMode ? t("transactions.form.splitToggleOff") : t("transactions.form.splitToggle")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!splitMode ? (
|
<Select
|
||||||
<Select
|
id="category"
|
||||||
id="category"
|
value={form.category_id}
|
||||||
value={form.category_id}
|
onChange={(event) => handleBaseCategoryChange(event.target.value)}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, category_id: event.target.value }))}
|
>
|
||||||
>
|
<option value="">{t("transactions.form.noCategory")}</option>
|
||||||
<option value="">{t("transactions.form.noCategory")}</option>
|
{categorySelectOptions.map((category) => (
|
||||||
{relevantCategories.map((category) => (
|
<option key={category.id} value={category.id}>
|
||||||
<option key={category.id} value={category.id}>
|
{category.indented ? ` ↳ ` : ""}
|
||||||
{category.indented ? ` ↳ ` : ""}
|
{translateCategoryName(category.name)}
|
||||||
{translateCategoryName(category.name)}
|
</option>
|
||||||
</option>
|
))}
|
||||||
))}
|
</Select>
|
||||||
</Select>
|
|
||||||
) : (
|
{splitMode && (
|
||||||
<div className="space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<p className="text-xs text-text-muted">{t("transactions.form.splitHint")}</p>
|
{!form.category_id ? (
|
||||||
|
<p className="text-xs text-text-muted">{t("transactions.form.splitHint")}</p>
|
||||||
|
) : baseChildCategories.length === 0 ? (
|
||||||
|
<p className="text-xs text-text-muted">{t("transactions.form.splitNoChildren")}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-text-muted">{t("transactions.form.splitHint")}</p>
|
||||||
|
)}
|
||||||
{splitRows.map((row) => (
|
{splitRows.map((row) => (
|
||||||
<div key={row.key} className="space-y-1.5 rounded-lg border border-border bg-surface-1 p-2">
|
<div key={row.key} className="space-y-1.5 rounded-lg border border-border bg-surface-1 p-2">
|
||||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-2">
|
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-2">
|
||||||
@@ -348,14 +398,17 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
aria-label={t("transactions.form.splitCategoryPlaceholder")}
|
aria-label={t("transactions.form.splitCategoryPlaceholder")}
|
||||||
className="sm:flex-1"
|
className="sm:flex-1"
|
||||||
value={row.category_id}
|
value={row.category_id}
|
||||||
|
disabled={!form.category_id}
|
||||||
onChange={(event) => updateSplitRow(row.key, { category_id: event.target.value })}
|
onChange={(event) => updateSplitRow(row.key, { category_id: event.target.value })}
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{t("transactions.form.splitCategoryPlaceholder")}
|
{t("transactions.form.splitCategoryPlaceholder")}
|
||||||
</option>
|
</option>
|
||||||
{relevantCategories.map((category) => (
|
{form.category_id && (
|
||||||
|
<option value={form.category_id}>{t("transactions.form.splitDirectOption")}</option>
|
||||||
|
)}
|
||||||
|
{baseChildCategories.map((category) => (
|
||||||
<option key={category.id} value={category.id}>
|
<option key={category.id} value={category.id}>
|
||||||
{category.indented ? ` ↳ ` : ""}
|
|
||||||
{translateCategoryName(category.name)}
|
{translateCategoryName(category.name)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const ru = {
|
|||||||
"topbar.openMenu": "Открыть меню",
|
"topbar.openMenu": "Открыть меню",
|
||||||
|
|
||||||
"common.loading": "Загрузка…",
|
"common.loading": "Загрузка…",
|
||||||
|
"common.expand": "Развернуть",
|
||||||
|
"common.collapse": "Свернуть",
|
||||||
"common.back": "Назад",
|
"common.back": "Назад",
|
||||||
"common.next": "Далее",
|
"common.next": "Далее",
|
||||||
"common.clear": "Очистить",
|
"common.clear": "Очистить",
|
||||||
@@ -285,6 +287,10 @@ const ru = {
|
|||||||
"transactions.form.errorSplitMinRows": "Нужно как минимум 2 категории для разбивки",
|
"transactions.form.errorSplitMinRows": "Нужно как минимум 2 категории для разбивки",
|
||||||
"transactions.form.errorSplitIncomplete": "Выберите категорию и сумму для каждой строки разбивки",
|
"transactions.form.errorSplitIncomplete": "Выберите категорию и сумму для каждой строки разбивки",
|
||||||
"transactions.form.errorSplitMismatch": "Сумма по категориям должна совпадать с суммой транзакции",
|
"transactions.form.errorSplitMismatch": "Сумма по категориям должна совпадать с суммой транзакции",
|
||||||
|
"transactions.form.errorSplitNoBaseCategory": "Сначала выберите базовую категорию",
|
||||||
|
"transactions.form.splitDirectOption": "— без конкретной подкатегории —",
|
||||||
|
"transactions.form.splitNoChildren": "У этой категории пока нет подкатегорий — добавьте их на странице «Категории», чтобы разбивать покупки по ним.",
|
||||||
|
"reports.directSpendLabel": "Без подкатегории",
|
||||||
|
|
||||||
"reports.categoryLabel": "Категория",
|
"reports.categoryLabel": "Категория",
|
||||||
"reports.expenseGroup": "Расходы",
|
"reports.expenseGroup": "Расходы",
|
||||||
@@ -504,6 +510,8 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"topbar.openMenu": "Open menu",
|
"topbar.openMenu": "Open menu",
|
||||||
|
|
||||||
"common.loading": "Loading…",
|
"common.loading": "Loading…",
|
||||||
|
"common.expand": "Expand",
|
||||||
|
"common.collapse": "Collapse",
|
||||||
"common.back": "Back",
|
"common.back": "Back",
|
||||||
"common.next": "Next",
|
"common.next": "Next",
|
||||||
"common.clear": "Clear",
|
"common.clear": "Clear",
|
||||||
@@ -760,6 +768,10 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"transactions.form.errorSplitMinRows": "Splitting needs at least 2 categories",
|
"transactions.form.errorSplitMinRows": "Splitting needs at least 2 categories",
|
||||||
"transactions.form.errorSplitIncomplete": "Pick a category and an amount for every split row",
|
"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.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.categoryLabel": "Category",
|
||||||
"reports.expenseGroup": "Expenses",
|
"reports.expenseGroup": "Expenses",
|
||||||
|
|||||||
@@ -161,6 +161,18 @@ export interface RecurringTransactionInput {
|
|||||||
anchor_date: string;
|
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 {
|
export interface CategoryBreakdownItem {
|
||||||
category_id: number | null;
|
category_id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -168,6 +180,7 @@ export interface CategoryBreakdownItem {
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
amount: string;
|
amount: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
|
children: CategoryBreakdownChildItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
@@ -310,6 +323,14 @@ export interface CashFlowResponse {
|
|||||||
total_net: string;
|
total_net: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CategoryRankingChildItem {
|
||||||
|
category_id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
icon: string | null;
|
||||||
|
amount: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CategoryRankingItem {
|
export interface CategoryRankingItem {
|
||||||
category_id: number;
|
category_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -318,6 +339,7 @@ export interface CategoryRankingItem {
|
|||||||
amount: string;
|
amount: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
transaction_count: number;
|
transaction_count: number;
|
||||||
|
children: CategoryRankingChildItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryRankingReport {
|
export interface CategoryRankingReport {
|
||||||
|
|||||||
Reference in New Issue
Block a user