feat: split a transaction across multiple categories

Один чек часто смешивает несколько категорий трат (гипермаркет: часть — продукты,
часть — бытовая химия). Раньше такую транзакцию приходилось огрублять до одной
категории или дробить вручную на несколько записей. Теперь у транзакции есть
второй режим: 2+ доли (TransactionSplit) с суммами по разным категориям, которые
обязаны в точности складываться в сумму транзакции.

- Новая модель TransactionSplit + миграция.
- split_rule_violation() проверяет инварианты на создании и редактировании.
- category_rollup.py — общий helper, которым теперь пользуются Dashboard, отчёт
  по категориям и бюджеты, учитывая доли сплитов наравне с обычными транзакциями.
- Фильтр по категории в списке транзакций находит и сплит-транзакции.
- Сплиты попадают в полный бэкап/восстановление БД.
- 13 новых backend-тестов.
- Фронтенд: переключатель «Разбить на несколько категорий» в форме транзакции
  с повторяемыми строками и индикатором остатка суммы; строка транзакции в
  списке показывает все категории сплита.
This commit is contained in:
ZProger
2026-08-30 18:42:13 +03:00
parent b045d378f0
commit 0134cdb6bf
20 changed files with 1008 additions and 144 deletions

View File

@@ -26,7 +26,7 @@ from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
from app.models.tag import Tag
from app.models.transaction import Transaction
from app.models.transaction import Transaction, TransactionSplit
from app.schemas.backup import (
AccountBackup,
AppSettingsBackup,
@@ -42,6 +42,7 @@ from app.schemas.backup import (
RecurringTransactionBackup,
TagBackup,
TransactionBackup,
TransactionSplitBackup,
)
BACKUP_FORMAT_VERSION = 1
@@ -52,6 +53,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
categories = (await session.execute(select(Category))).scalars().all()
tags = (await session.execute(select(Tag))).scalars().all()
transactions = (await session.execute(select(Transaction).options(selectinload(Transaction.tags)))).scalars().all()
transaction_splits = (await session.execute(select(TransactionSplit))).scalars().all()
assets = (await session.execute(select(Asset))).scalars().all()
valuations = (await session.execute(select(AssetValuation))).scalars().all()
crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all()
@@ -75,6 +77,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
)
for row in transactions
],
transaction_splits=[TransactionSplitBackup.model_validate(row) for row in transaction_splits],
assets=[AssetBackup.model_validate(row) for row in assets],
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
@@ -110,6 +113,13 @@ def _validate_references(payload: BackupPayload) -> None:
if tag_id not in tag_ids:
raise HTTPException(400, f"Transaction {t.id} references unknown tag_id {tag_id}")
transaction_ids = {row.id for row in payload.transactions}
for s in payload.transaction_splits:
if s.transaction_id not in transaction_ids:
raise HTTPException(400, f"Transaction split {s.id} references unknown transaction_id {s.transaction_id}")
if s.category_id is not None and s.category_id not in category_ids:
raise HTTPException(400, f"Transaction split {s.id} references unknown category_id {s.category_id}")
for v in payload.asset_valuations:
if v.asset_id not in asset_ids:
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
@@ -176,8 +186,10 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
await session.execute(delete(GoalContribution))
await session.execute(delete(Goal))
await session.execute(delete(RecurringTransaction))
# Deleting transactions cascades transaction_tags rows (ON DELETE
# CASCADE) — tags themselves still need their own delete.
# Deleting transactions cascades transaction_tags and
# transaction_splits rows (ON DELETE CASCADE) — deleted explicitly
# here anyway to keep this block's ordering self-documenting.
await session.execute(delete(TransactionSplit))
await session.execute(delete(Transaction))
await session.execute(delete(Tag))
await session.execute(delete(Asset))
@@ -208,6 +220,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
row.id: Transaction(**row.model_dump(exclude={"tag_ids"}), tags=[]) for row in payload.transactions
}
session.add_all(transactions_by_id.values())
session.add_all(TransactionSplit(**row.model_dump()) for row in payload.transaction_splits)
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
session.add_all(CryptoHolding(**row.model_dump()) for row in payload.crypto_holdings)
@@ -229,6 +242,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
await _reset_sequence(session, "tags", payload.tags)
await _reset_sequence(session, "assets", payload.assets)
await _reset_sequence(session, "transactions", payload.transactions)
await _reset_sequence(session, "transaction_splits", payload.transaction_splits)
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
await _reset_sequence(session, "budgets", payload.budgets)

View File

@@ -15,7 +15,7 @@ from sqlalchemy.orm import selectinload
from app.models.budget import Budget
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.transaction import Transaction
from app.models.transaction import Transaction, TransactionSplit
from app.schemas.budget import BudgetCreate, BudgetStatus, BudgetStatusResponse, BudgetUpdate
_EAGER = (selectinload(Budget.category),)
@@ -92,7 +92,11 @@ async def get_budget_status(session: AsyncSession, year: int, month: int) -> Bud
counted_ids = set(budget_category_ids).union(
child_id for children in children_by_parent.values() for child_id in children
)
spent_stmt = (
# A split transaction has category_id=NULL on its own row — its spend
# lives on its split lines instead (see category_rollup.py), so a plain
# sum on Transaction.category_id alone would silently under-count a
# budget funded partly by split purchases.
plain_stmt = (
select(Transaction.category_id, func.coalesce(func.sum(Transaction.amount), 0))
.where(
Transaction.category_id.in_(counted_ids),
@@ -102,7 +106,22 @@ async def get_budget_status(session: AsyncSession, year: int, month: int) -> Bud
)
.group_by(Transaction.category_id)
)
spent_by_category: dict[int, Decimal] = {row[0]: row[1] for row in (await session.execute(spent_stmt)).all()}
split_stmt = (
select(TransactionSplit.category_id, func.coalesce(func.sum(TransactionSplit.amount), 0))
.join(Transaction, Transaction.id == TransactionSplit.transaction_id)
.where(
TransactionSplit.category_id.in_(counted_ids),
Transaction.type == TransactionType.EXPENSE,
Transaction.date >= start,
Transaction.date <= end,
)
.group_by(TransactionSplit.category_id)
)
spent_by_category: dict[int, Decimal] = defaultdict(Decimal)
for category_id, amount in (await session.execute(plain_stmt)).all():
spent_by_category[category_id] += amount
for category_id, amount in (await session.execute(split_stmt)).all():
spent_by_category[category_id] += amount
items = []
for budget in budgets:

View File

@@ -0,0 +1,116 @@
"""Spend/income per top-level category, split-aware — shared by
dashboard_service's breakdown and reports_service's category ranking, the
two places that both roll a subcategory's amount up into its parent
(coalesce(parent_id, id)) and now also need a category's share of a split
transaction counted the same way a plain transaction's is.
A split transaction has Transaction.category_id=NULL (see
models/transaction.py's TransactionSplit) — a query that only joins on that
column would silently drop its spending from every category it actually
touches. This walks both sources (a transaction's own category_id, and its
splits' category_ids) and combines them before rolling up, so a category
funded partly by plain transactions and partly by split lines still shows
one correct total.
"""
from collections import defaultdict
from dataclasses import dataclass
from datetime import date as date_
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.category import Category
from app.models.enums import TransactionType
from app.models.transaction import Transaction, TransactionSplit
@dataclass
class CategoryRollupItem:
category_id: int
name: str
color: str
icon: str | None
sort_order: int
amount: Decimal
# Distinct transactions contributing to this category — a split
# transaction counts once per top-level category it touches, same as a
# plain one counts once for its own category.
transaction_count: int
async def _raw_category_contributions(
session: AsyncSession,
*,
transaction_type: TransactionType,
start_date: date_ | None,
end_date: date_ | None,
) -> list[tuple[int, int, Decimal]]:
"""(transaction_id, category_id, amount) for every category a
transaction of this type/date-range contributed to. A plain transaction
contributes one row (its own category); a split one contributes one row
per split line — never both for the same transaction, since a
transaction is either plain (category_id set, no splits) or split
(category_id NULL, 2+ splits), enforced at write time."""
plain_stmt = select(Transaction.id, Transaction.category_id, Transaction.amount).where(
Transaction.type == transaction_type, Transaction.category_id.is_not(None)
)
split_stmt = (
select(TransactionSplit.transaction_id, TransactionSplit.category_id, TransactionSplit.amount)
.join(Transaction, Transaction.id == TransactionSplit.transaction_id)
.where(Transaction.type == transaction_type, TransactionSplit.category_id.is_not(None))
)
if start_date is not None:
plain_stmt = plain_stmt.where(Transaction.date >= start_date)
split_stmt = split_stmt.where(Transaction.date >= start_date)
if end_date is not None:
plain_stmt = plain_stmt.where(Transaction.date <= end_date)
split_stmt = split_stmt.where(Transaction.date <= end_date)
plain_rows = (await session.execute(plain_stmt)).all()
split_rows = (await session.execute(split_stmt)).all()
return [(r[0], r[1], r[2]) for r in plain_rows] + [(r[0], r[1], r[2]) for r in split_rows]
async def rollup_spending_by_top_level_category(
session: AsyncSession,
*,
transaction_type: TransactionType,
start_date: date_ | None = None,
end_date: date_ | None = None,
) -> list[CategoryRollupItem]:
"""Every top-level category's total for the period, sorted by amount
desc (category sort_order as tiebreak — same order the SQL-only version
used to produce)."""
contributions = await _raw_category_contributions(
session, transaction_type=transaction_type, start_date=start_date, end_date=end_date
)
if not contributions:
return []
categories_by_id = {c.id: c for c in (await session.execute(select(Category))).scalars().all()}
amount_by_effective: dict[int, Decimal] = defaultdict(Decimal)
txn_ids_by_effective: dict[int, set[int]] = defaultdict(set)
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
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)
items.append(
CategoryRollupItem(
category_id=effective_id,
name=category.name if category else "?",
color=category.color if category else "#898781",
icon=category.icon if category else None,
sort_order=category.sort_order if category else 0,
amount=amount,
transaction_count=len(txn_ids_by_effective[effective_id]),
)
)
items.sort(key=lambda item: (-item.amount, item.sort_order))
return items

View File

@@ -5,12 +5,11 @@ from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.category import Category
from app.models.enums import TransactionType
from app.models.transaction import Transaction
from app.schemas.dashboard import 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",
# never a generated hue) — this is also the exact size of the default category set.
@@ -38,36 +37,13 @@ async def get_dashboard_summary(session: AsyncSession, year: int, month: int) ->
spent = totals.get(TransactionType.EXPENSE, Decimal("0"))
transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0"))
# A subcategory's spending rolls up into its parent's slice — Parent is a
# self-join to resolve each transaction's *top-level* category, falling
# back to the transaction's own category when it has no parent.
Parent = aliased(Category)
effective_id = func.coalesce(Category.parent_id, Category.id)
effective_name = func.coalesce(Parent.name, Category.name)
effective_color = func.coalesce(Parent.color, Category.color)
effective_icon = func.coalesce(Parent.icon, Category.icon)
effective_sort = func.coalesce(Parent.sort_order, Category.sort_order)
breakdown_stmt = (
select(
effective_id.label("category_id"),
effective_name.label("name"),
effective_color.label("color"),
effective_icon.label("icon"),
func.coalesce(func.sum(Transaction.amount), 0).label("amount"),
)
.join(Category, Category.id == Transaction.category_id)
.outerjoin(Parent, Parent.id == Category.parent_id)
.where(
Transaction.date >= start,
Transaction.date <= end,
Transaction.type == TransactionType.EXPENSE,
)
.group_by(effective_id, effective_name, effective_color, effective_icon, effective_sort)
.order_by(func.sum(Transaction.amount).desc(), effective_sort)
# A subcategory's spending rolls up into its parent's slice, and a split
# transaction's category_id=NULL means its category lives on its split
# lines instead — rollup_spending_by_top_level_category handles both
# the same way a plain transaction's category already was.
rows = await rollup_spending_by_top_level_category(
session, transaction_type=TransactionType.EXPENSE, start_date=start, end_date=end
)
breakdown_result = await session.execute(breakdown_stmt)
rows = breakdown_result.all()
top_rows, rest_rows = rows[:MAX_CHART_SLICES], rows[MAX_CHART_SLICES:]
@@ -76,9 +52,10 @@ async def get_dashboard_summary(session: AsyncSession, year: int, month: int) ->
spending_by_category = [
CategoryBreakdownItem(
category_id=cat_id, name=name, color=color, icon=icon, amount=amount, percent=_percent(amount)
category_id=row.category_id, name=row.name, color=row.color, icon=row.icon,
amount=row.amount, percent=_percent(row.amount),
)
for cat_id, name, color, icon, amount in top_rows
for row in top_rows
]
if rest_rows:

View File

@@ -4,23 +4,24 @@ and how much in total over N years" (single-category detail), and "which
categories cost the most over this whole period" (ranking, across all
categories of one kind at once, not just the current month).
"""
from collections import defaultdict
from datetime import date as date_
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import extract, func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.transaction import Transaction
from app.models.transaction import Transaction, TransactionSplit
from app.schemas.reports import (
CategoryRankingItem,
CategoryRankingReport,
CategorySpendingPoint,
CategorySpendingReport,
)
from app.services.category_rollup import rollup_spending_by_top_level_category
def _next_month(year: int, month: int) -> tuple[int, int]:
@@ -44,51 +45,56 @@ async def get_category_spending_report(
).scalars().all()
category_ids.extend(child_ids)
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
# Plain transactions filed directly under one of these categories, plus
# split lines that assign part of a transaction to one of them — same
# two sources category_rollup.py unions for the Dashboard/ranking report.
plain_stmt = select(Transaction.id, Transaction.date, Transaction.amount).where(
Transaction.category_id.in_(category_ids)
)
split_stmt = (
select(TransactionSplit.transaction_id, Transaction.date, TransactionSplit.amount)
.join(Transaction, Transaction.id == TransactionSplit.transaction_id)
.where(TransactionSplit.category_id.in_(category_ids))
)
if start_date:
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
plain_stmt = plain_stmt.where(Transaction.date >= start_date)
split_stmt = split_stmt.where(Transaction.date >= start_date)
if end_date:
bounds_stmt = bounds_stmt.where(Transaction.date <= end_date)
min_date, max_date = (await session.execute(bounds_stmt)).one()
plain_stmt = plain_stmt.where(Transaction.date <= end_date)
split_stmt = split_stmt.where(Transaction.date <= end_date)
effective_start = start_date or min_date
effective_end = end_date or max_date
plain_rows = (await session.execute(plain_stmt)).all()
split_rows = (await session.execute(split_stmt)).all()
contributions = [(r[0], r[1], r[2]) for r in plain_rows] + [(r[0], r[1], r[2]) for r in split_rows]
empty = CategorySpendingReport(
category_id=category.id,
category_name=category.name,
category_color=category.color,
category_icon=category.icon,
start_date=effective_start,
end_date=effective_end,
start_date=start_date,
end_date=end_date,
total_amount=Decimal("0"),
transaction_count=0,
average_per_month=Decimal("0"),
series=[],
)
if effective_start is None or effective_end is None:
if not contributions:
return empty
rows_stmt = (
select(
extract("year", Transaction.date).label("year"),
extract("month", Transaction.date).label("month"),
func.sum(Transaction.amount).label("amount"),
func.count(Transaction.id).label("cnt"),
)
.where(
Transaction.category_id.in_(category_ids),
Transaction.date >= effective_start,
Transaction.date <= effective_end,
)
.group_by("year", "month")
)
rows = (await session.execute(rows_stmt)).all()
by_month: dict[tuple[int, int], Decimal] = {(int(row.year), int(row.month)): row.amount for row in rows}
total_amount = sum((row.amount for row in rows), Decimal("0"))
total_count = sum(row.cnt for row in rows)
dates = [txn_date for _, txn_date, _ in contributions]
effective_start = start_date or min(dates)
effective_end = end_date or max(dates)
by_month: dict[tuple[int, int], Decimal] = defaultdict(Decimal)
for _, txn_date, amount in contributions:
by_month[(txn_date.year, txn_date.month)] += amount
total_amount = sum((amount for _, _, amount in contributions), Decimal("0"))
# Distinct transactions, not rows — a transaction split across two of
# these categories (e.g. parent + one of its children) must count once,
# the same as a plain transaction filed under just one of them.
total_count = len({transaction_id for transaction_id, _, _ in contributions})
series: list[CategorySpendingPoint] = []
year, month = effective_start.year, effective_start.month
@@ -126,36 +132,15 @@ async def get_category_ranking_report(
arbitrary period — "which category costs the most" across the whole
range, unlike the month-scoped Dashboard breakdown or the
single-category detail above."""
# Same subcategory-into-parent rollup as the Dashboard breakdown — see
# dashboard_service.get_dashboard_summary.
Parent = aliased(Category)
effective_id = func.coalesce(Category.parent_id, Category.id)
effective_name = func.coalesce(Parent.name, Category.name)
effective_color = func.coalesce(Parent.color, Category.color)
effective_icon = func.coalesce(Parent.icon, Category.icon)
effective_sort = func.coalesce(Parent.sort_order, Category.sort_order)
stmt = (
select(
effective_id.label("category_id"),
effective_name.label("name"),
effective_color.label("color"),
effective_icon.label("icon"),
func.sum(Transaction.amount).label("amount"),
func.count(Transaction.id).label("cnt"),
)
.join(Category, Category.id == Transaction.category_id)
.outerjoin(Parent, Parent.id == Category.parent_id)
.where(Category.kind == kind, Transaction.type == _KIND_TO_TRANSACTION_TYPE[kind])
# A transaction's type already restricts it to categories of the
# matching kind (enforced at write time by _ensure_category_matches_type
# in routes/transactions.py), so filtering by transaction_type below is
# enough — no separate kind filter needed, and the shared rollup already
# unions plain transactions with split lines the same way the Dashboard
# breakdown does.
rows = await rollup_spending_by_top_level_category(
session, transaction_type=_KIND_TO_TRANSACTION_TYPE[kind], start_date=start_date, end_date=end_date
)
if start_date:
stmt = stmt.where(Transaction.date >= start_date)
if end_date:
stmt = stmt.where(Transaction.date <= end_date)
stmt = stmt.group_by(effective_id, effective_name, effective_color, effective_icon, effective_sort)
stmt = stmt.order_by(func.sum(Transaction.amount).desc(), effective_sort)
rows = (await session.execute(stmt)).all()
total_amount = sum((row.amount for row in rows), Decimal("0"))
def _percent(amount: Decimal) -> float:
@@ -163,17 +148,15 @@ async def get_category_ranking_report(
items = [
CategoryRankingItem(
category_id=cat_id,
name=name,
color=color,
icon=icon,
amount=amount,
percent=_percent(amount),
transaction_count=cnt,
category_id=row.category_id,
name=row.name,
color=row.color,
icon=row.icon,
amount=row.amount,
percent=_percent(row.amount),
transaction_count=row.transaction_count,
)
for cat_id, name, color, icon, amount, cnt in rows
for row in rows
]
return CategoryRankingReport(
start_date=start_date, end_date=end_date, total_amount=total_amount, items=items
)
return CategoryRankingReport(start_date=start_date, end_date=end_date, total_amount=total_amount, items=items)