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

@@ -22,6 +22,7 @@ from app.models import ( # noqa: F401 — registers metadata
RecurringTransaction,
Tag,
Transaction,
TransactionSplit,
)
config = context.config

View File

@@ -0,0 +1,36 @@
"""add transaction splits
Revision ID: 7b535ef9e189
Revises: 3e3503b61b20
Create Date: 2026-08-30 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7b535ef9e189'
down_revision: Union[str, None] = '3e3503b61b20'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'transaction_splits',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('transaction_id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('amount', sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column('note', sa.String(length=200), nullable=True),
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
def downgrade() -> None:
op.drop_table('transaction_splits')

View File

@@ -1,4 +1,5 @@
from datetime import date as date_
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -10,20 +11,27 @@ from app.api.deps import get_session
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.tag import Tag
from app.models.transaction import Transaction
from app.models.transaction import Transaction, TransactionSplit
from app.schemas.transaction import (
TransactionBulkCreate,
TransactionBulkCreateResult,
TransactionCreate,
TransactionPage,
TransactionRead,
TransactionSplitInput,
TransactionUpdate,
split_rule_violation,
transfer_rule_violation,
)
router = APIRouter(prefix="/transactions", tags=["transactions"])
_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category), selectinload(Transaction.tags))
_EAGER = (
selectinload(Transaction.account),
selectinload(Transaction.category),
selectinload(Transaction.tags),
selectinload(Transaction.splits).selectinload(TransactionSplit.category),
)
async def _resolve_tags(session: AsyncSession, tag_ids: list[int]) -> list[Tag]:
@@ -62,6 +70,14 @@ async def _ensure_category_matches_type(
)
async def _build_splits(
session: AsyncSession, splits: list[TransactionSplitInput], transaction_type: TransactionType
) -> list[TransactionSplit]:
for split in splits:
await _ensure_category_matches_type(session, split.category_id, transaction_type)
return [TransactionSplit(category_id=s.category_id, amount=s.amount, note=s.note) for s in splits]
@router.get("", response_model=TransactionPage)
async def list_transactions(
year: int | None = Query(default=None, ge=2000, le=2100),
@@ -97,8 +113,15 @@ async def list_transactions(
stmt = stmt.where(Transaction.account_id == account_id)
count_stmt = count_stmt.where(Transaction.account_id == account_id)
if category_id is not None:
stmt = stmt.where(Transaction.category_id == category_id)
count_stmt = count_stmt.where(Transaction.category_id == category_id)
# A split transaction has category_id=NULL on the row itself — the
# category lives on its split lines instead, so filtering by exact
# column match alone would silently drop it from a category filter
# it genuinely belongs to.
category_filter = or_(
Transaction.category_id == category_id, Transaction.splits.any(TransactionSplit.category_id == category_id)
)
stmt = stmt.where(category_filter)
count_stmt = count_stmt.where(category_filter)
if tag_id is not None:
stmt = stmt.where(Transaction.tags.any(Tag.id == tag_id))
count_stmt = count_stmt.where(Transaction.tags.any(Tag.id == tag_id))
@@ -151,9 +174,11 @@ async def list_transaction_years(session: AsyncSession = Depends(get_session)) -
@router.post("", response_model=TransactionRead, status_code=201)
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
await _ensure_category_matches_type(session, payload.category_id, payload.type)
fields = payload.model_dump(exclude={"tag_ids"})
fields = payload.model_dump(exclude={"tag_ids", "splits"})
transaction = Transaction(**fields)
transaction.tags = await _resolve_tags(session, payload.tag_ids)
if payload.splits:
transaction.splits = await _build_splits(session, payload.splits, payload.type)
session.add(transaction)
await session.commit()
refreshed = await session.execute(
@@ -171,11 +196,17 @@ async def bulk_create_transactions(
instead of leaving a half-imported statement behind."""
for item in payload.items:
await _ensure_category_matches_type(session, item.category_id, item.type)
for split in item.splits or []:
await _ensure_category_matches_type(session, split.category_id, item.type)
transactions = []
for item in payload.items:
transaction = Transaction(**item.model_dump(exclude={"tag_ids"}))
transaction = Transaction(**item.model_dump(exclude={"tag_ids", "splits"}))
transaction.tags = await _resolve_tags(session, item.tag_ids)
if item.splits:
transaction.splits = [
TransactionSplit(category_id=s.category_id, amount=s.amount, note=s.note) for s in item.splits
]
transactions.append(transaction)
session.add_all(transactions)
@@ -187,13 +218,16 @@ async def bulk_create_transactions(
async def update_transaction(
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
) -> Transaction:
# Eager-loads tags — assigning transaction.tags below would otherwise lazy
# -load the current collection first to diff against, which async
# SQLAlchemy can't do outside an explicit await (MissingGreenlet).
transaction = await session.get(Transaction, transaction_id, options=[selectinload(Transaction.tags)])
# Eager-loads tags and splits — assigning transaction.tags/splits below
# would otherwise lazy-load the current collection first to diff
# against, which async SQLAlchemy can't do outside an explicit await
# (MissingGreenlet).
transaction = await session.get(
Transaction, transaction_id, options=[selectinload(Transaction.tags), selectinload(Transaction.splits)]
)
if transaction is None:
raise HTTPException(status_code=404, detail="Transaction not found")
updates = payload.model_dump(exclude_unset=True, exclude={"tag_ids"})
updates = payload.model_dump(exclude_unset=True, exclude={"tag_ids", "splits"})
# Checks run against the row as it would look after the patch, not just
# the fields sent: switching type alone can invalidate fields left
# untouched.
@@ -201,6 +235,7 @@ async def update_transaction(
effective_category_id = updates.get("category_id", transaction.category_id)
effective_account_id = updates.get("account_id", transaction.account_id)
effective_transfer_account_id = updates.get("transfer_account_id", transaction.transfer_account_id)
effective_amount = updates.get("amount", transaction.amount)
await _ensure_category_matches_type(session, effective_category_id, effective_type)
violation = transfer_rule_violation(
type=effective_type,
@@ -210,10 +245,36 @@ async def update_transaction(
)
if violation:
raise HTTPException(status_code=400, detail=violation)
if payload.splits is not None:
split_count = len(payload.splits)
split_total = sum((s.amount for s in payload.splits), Decimal("0")) if payload.splits else None
else:
# Splits weren't touched by this patch — check the existing rows as
# they stand. Reading straight off the ORM objects here (not
# rebuilding TransactionSplitInput) on purpose: an existing split's
# category_id can be None if that category was since deleted, and
# the sum check below needs none of that — only tags/category
# values that were actually just fetched from the caller could ever
# need to be re-validated, and those go through payload.splits above.
split_count = len(transaction.splits)
split_total = sum((s.amount for s in transaction.splits), Decimal("0")) if transaction.splits else None
split_violation = split_rule_violation(
type=effective_type,
amount=effective_amount,
category_id=effective_category_id,
split_count=split_count,
split_total=split_total,
)
if split_violation:
raise HTTPException(status_code=400, detail=split_violation)
for field, value in updates.items():
setattr(transaction, field, value)
if payload.tag_ids is not None:
transaction.tags = await _resolve_tags(session, payload.tag_ids)
if payload.splits is not None:
transaction.splits = await _build_splits(session, payload.splits, effective_type)
await session.commit()
refreshed = await session.execute(
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)

View File

@@ -7,7 +7,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
__all__ = [
"Account",
@@ -24,4 +24,5 @@ __all__ = [
"RecurringTransaction",
"Tag",
"Transaction",
"TransactionSplit",
]

View File

@@ -35,3 +35,32 @@ class Transaction(Base, TimestampMixin):
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
category: Mapped["Category | None"] = relationship(back_populates="transactions")
tags: Mapped[list["Tag"]] = relationship(secondary=transaction_tags, back_populates="transactions")
splits: Mapped[list["TransactionSplit"]] = relationship(
back_populates="transaction", cascade="all, delete-orphan", order_by="TransactionSplit.id"
)
class TransactionSplit(Base):
"""One category's slice of a transaction whose amount is divided across
several categories (one receipt, several kinds of goods) — an
alternative to Transaction.category_id, not an addition to it: a split
transaction has category_id=NULL and two or more of these instead, and
their amounts must add up to the parent's amount exactly (see
schemas/transaction.py's split_rule_violation).
category_id is nullable + SET NULL, same as Transaction.category_id
itself — deleting a category must not break *reading* a split that used
to point at it, only creating/editing one requires a live category (see
routes/transactions.py, and the same lesson already applied to
transfer_account_id)."""
__tablename__ = "transaction_splits"
id: Mapped[int] = mapped_column(primary_key=True)
transaction_id: Mapped[int] = mapped_column(ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
note: Mapped[str | None] = mapped_column(String(200), nullable=True)
transaction: Mapped["Transaction"] = relationship(back_populates="splits")
category: Mapped["Category | None"] = relationship()

View File

@@ -70,6 +70,16 @@ class TransactionBackup(BaseModel):
tag_ids: list[int] = Field(default_factory=list)
class TransactionSplitBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
transaction_id: int
category_id: int | None
amount: Decimal
note: str | None
class AssetBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -202,6 +212,9 @@ class BackupPayload(BaseModel):
# cleanly under the same format version.
tags: list[TagBackup] = Field(default_factory=list)
transactions: list[TransactionBackup]
# Defaulted so a backup exported before transaction splitting existed
# still imports cleanly under the same format version.
transaction_splits: list[TransactionSplitBackup] = Field(default_factory=list)
assets: list[AssetBackup]
asset_valuations: list[AssetValuationBackup]
# Defaulted so a backup exported before crypto holdings existed still

View File

@@ -39,6 +39,37 @@ def transfer_rule_violation(
return None
def split_rule_violation(
*,
type: TransactionType,
amount: Decimal,
category_id: int | None,
split_count: int,
split_total: Decimal | None,
) -> str | None:
"""The split invariants in one place, same shape as transfer_rule_violation
above — checked on create, and against the row *as it would look after
a patch* on update (see routes/transactions.py), so an edit can't leave
a transaction whose splits no longer add up to its own amount.
Takes counts/totals rather than the split objects themselves: the
update path's "splits weren't touched by this patch" case has to check
the existing ORM rows, whose category_id can be None (the category was
since deleted) — nothing here needs a live category to check the sum.
"""
if split_count == 0:
return None
if type == TransactionType.TRANSFER:
return "splits are not valid for transfer transactions"
if category_id is not None:
return "category_id must be omitted when splitting a transaction across categories"
if split_count < 2:
return "splitting a transaction needs at least 2 categories"
if split_total != amount:
return f"split amounts ({split_total}) must add up to the transaction amount ({amount})"
return None
class TransactionFields(BaseModel):
"""A transaction's shape, without the rules that only make sense while
writing one. Reading goes through this: rows already in the database can
@@ -83,8 +114,34 @@ class TransactionBase(TransactionFields):
return self
class TransactionSplitInput(BaseModel):
category_id: int
amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2)
note: str | None = Field(default=None, max_length=200)
class TransactionCreate(TransactionBase):
tag_ids: list[int] = Field(default_factory=list)
# None/omitted -> a normal single-category transaction, unchanged from
# before. 2+ entries -> the amount is divided across categories instead
# of using category_id (which must then be omitted — see
# split_rule_violation). A single entry isn't accepted: that's just
# category_id with extra steps.
splits: list[TransactionSplitInput] | None = None
@model_validator(mode="after")
def _validate_splits(self) -> "TransactionCreate":
splits = self.splits or []
violation = split_rule_violation(
type=self.type,
amount=self.amount,
category_id=self.category_id,
split_count=len(splits),
split_total=sum((s.amount for s in splits), Decimal("0")) if splits else None,
)
if violation:
raise ValueError(violation)
return self
class TransactionUpdate(BaseModel):
@@ -99,6 +156,10 @@ class TransactionUpdate(BaseModel):
date: date_ | None = None
# Omitted -> tags untouched; sent (even as []) -> replaces the full tag set.
tag_ids: list[int] | None = None
# Omitted -> splits untouched; sent (even as []) -> replaces the full
# split set (send [] together with a category_id to turn a split
# transaction back into a normal single-category one).
splits: list[TransactionSplitInput] | None = None
@field_validator("description")
@classmethod
@@ -106,6 +167,16 @@ class TransactionUpdate(BaseModel):
return capitalize_first_letter(value) if value is not None else None
class TransactionSplitRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
category_id: int | None
category: CategoryRead | None = None
amount: Decimal
note: str | None
class TransactionRead(TransactionFields):
model_config = ConfigDict(from_attributes=True)
@@ -113,6 +184,7 @@ class TransactionRead(TransactionFields):
account: AccountRead
category: CategoryRead | None = None
tags: list[TagRead] = Field(default_factory=list)
splits: list[TransactionSplitRead] = Field(default_factory=list)
class TransactionPage(BaseModel):

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)

View File

@@ -62,3 +62,32 @@ async def test_backup_import_rejects_transaction_with_unknown_tag_id(client: Asy
resp = await client.post("/backup/import", json=payload)
assert resp.status_code == 400
async def test_backup_roundtrip_preserves_transaction_splits(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
created = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00", "note": "household chemicals"},
],
),
)
txn_id = created.json()["id"]
payload = (await client.get("/backup/export")).json()
assert len(payload["transaction_splits"]) == 2
import_resp = await client.post("/backup/import", json=payload)
assert import_resp.status_code == 200, import_resp.text
refetched = next(t for t in (await client.get("/transactions")).json()["items"] if t["id"] == txn_id)
splits_by_note = {s["note"]: s for s in refetched["splits"]}
assert splits_by_note["household chemicals"]["category_id"] == shopping
assert refetched["category"] is None

View File

@@ -132,3 +132,28 @@ async def test_a_subcategory_keeps_its_own_budget_separate(client: AsyncClient,
assert items[child_id]["is_over_budget"] is True
assert money(items[parent_id]["spent"]) == money("900.00")
assert items[parent_id]["is_over_budget"] is False
async def test_budget_status_counts_a_split_transactions_share(client: AsyncClient, account_id, categories):
"""A receipt split between Groceries and Shopping must count its 70.00
slice toward the Groceries budget, not the full 100.00 or nothing."""
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
await client.post("/budgets", json={"category_id": groceries, "monthly_limit": "100.00"})
await client.post(
"/transactions",
json=txn_payload(
account_id,
amount="100.00",
category_id=None,
date="2026-08-10",
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00"},
],
),
)
resp = await client.get("/budgets/status", params={"year": 2026, "month": 8})
item = resp.json()["items"][0]
assert money(item["spent"]) == Decimal("70.00")

View File

@@ -125,3 +125,34 @@ async def test_more_than_eight_expense_categories_roll_up_into_other(client: Asy
# The 9th (smallest, index 8) category's 10.00 must be folded into Other,
# not silently dropped from the total.
assert money(other[0]["amount"]) == Decimal("10.00")
async def test_split_transaction_counts_each_split_toward_its_own_category(
client: AsyncClient, account_id, categories
):
"""A hypermarket receipt split between Groceries and Shopping must show
up as two separate slices on the donut, not one lump under whichever
category happened to be picked."""
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["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": shopping, "amount": "30.00"},
],
),
)
resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8})
body = resp.json()
breakdown = {row["name"]: row for row in body["spending_by_category"]}
assert money(body["spent"]) == Decimal("100.00")
assert money(breakdown["Groceries"]["amount"]) == Decimal("70.00")
assert money(breakdown["Shopping"]["amount"]) == Decimal("30.00")

View File

@@ -401,3 +401,227 @@ async def test_a_transfer_whose_destination_account_was_deleted_can_still_be_lis
assert listing.status_code == 200
assert txn_id in [item["id"] for item in listing.json()["items"]]
assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204
async def test_create_split_transaction_across_categories(client: AsyncClient, account_id, categories):
"""The hypermarket-receipt case: one purchase, part groceries, part
shopping — category_id stays empty on the row itself, and its splits
carry the categorization instead."""
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
resp = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00", "note": "household chemicals"},
],
),
)
assert resp.status_code == 201
body = resp.json()
assert body["category"] is None
splits = {s["category"]["name"]: s for s in body["splits"]}
assert money(splits["Groceries"]["amount"]) == Decimal("70.00")
assert money(splits["Shopping"]["amount"]) == Decimal("30.00")
assert splits["Shopping"]["note"] == "household chemicals"
async def test_create_split_rejects_category_id_alongside_splits(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
resp = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=groceries,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00"},
],
),
)
assert resp.status_code == 422
async def test_create_split_rejects_a_single_split(client: AsyncClient, account_id, categories):
resp = await client.post(
"/transactions",
json=_txn(
account_id, amount="100.00", category_id=None, splits=[{"category_id": categories["Groceries"]["id"], "amount": "100.00"}]
),
)
assert resp.status_code == 422
async def test_create_split_rejects_amounts_not_summing_to_total(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
resp = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "20.00"},
],
),
)
assert resp.status_code == 422
async def test_create_split_rejects_on_transfer(client: AsyncClient, account_id, categories):
other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json()
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
resp = await client.post(
"/transactions",
json=_txn(
account_id,
type="transfer",
amount="100.00",
category_id=None,
transfer_account_id=other["id"],
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00"},
],
),
)
assert resp.status_code == 422
async def test_update_can_convert_a_transaction_into_a_split_and_back(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
created = await client.post("/transactions", json=_txn(account_id, amount="100.00", category_id=groceries))
txn_id = created.json()["id"]
split_resp = await client.patch(
f"/transactions/{txn_id}",
json={
"category_id": None,
"splits": [
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00"},
],
},
)
assert split_resp.status_code == 200
assert len(split_resp.json()["splits"]) == 2
assert split_resp.json()["category"] is None
back_resp = await client.patch(f"/transactions/{txn_id}", json={"category_id": groceries, "splits": []})
assert back_resp.status_code == 200
assert back_resp.json()["splits"] == []
assert back_resp.json()["category"]["id"] == groceries
async def test_update_rejects_splits_not_summing_to_the_new_amount(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
created = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00"},
],
),
)
txn_id = created.json()["id"]
# Splits weren't touched by this patch, but the new amount no longer
# matches their sum — the update must still be rejected.
resp = await client.patch(f"/transactions/{txn_id}", json={"amount": "50.00"})
assert resp.status_code == 400
async def test_update_of_unrelated_field_tolerates_a_split_whose_category_was_deleted(
client: AsyncClient, account_id, categories
):
"""Same lesson as the transfer_account_id/deleted-account case: deleting
a category SET NULLs any split that pointed at it, and an edit to some
other field on the transaction must not be blocked by that pre-existing
state — the split's own category was never re-submitted, so nothing here
needs it to still exist."""
groceries = categories["Groceries"]["id"]
extra = await client.post(
"/categories", json={"name": "Deletable", "kind": "expense", "color": "#123456", "sort_order": 99}
)
extra_id = extra.json()["id"]
created = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": extra_id, "amount": "30.00"},
],
),
)
txn_id = created.json()["id"]
assert (await client.delete(f"/categories/{extra_id}")).status_code == 204
resp = await client.patch(f"/transactions/{txn_id}", json={"description": "renamed"})
assert resp.status_code == 200
assert resp.json()["description"] == "Renamed"
assert resp.json()["splits"][1]["category"] is None
async def test_category_filter_matches_transactions_split_into_that_category(
client: AsyncClient, account_id, categories
):
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00"},
],
),
)
resp = await client.get("/transactions", params={"category_id": shopping})
assert resp.json()["total"] == 1
async def test_delete_transaction_cascades_its_splits(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
created = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00"},
],
),
)
txn_id = created.json()["id"]
assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204
# If the split rows outlived their parent, this budget/dashboard-style
# category filter would still find a (now-orphaned) match.
resp = await client.get("/transactions", params={"category_id": groceries})
assert resp.json()["total"] == 0