diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 9df911f..60dfb9a 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -22,6 +22,7 @@ from app.models import ( # noqa: F401 — registers metadata RecurringTransaction, Tag, Transaction, + TransactionSplit, ) config = context.config diff --git a/backend/alembic/versions/7b535ef9e189_add_transaction_splits.py b/backend/alembic/versions/7b535ef9e189_add_transaction_splits.py new file mode 100644 index 0000000..4f66abb --- /dev/null +++ b/backend/alembic/versions/7b535ef9e189_add_transaction_splits.py @@ -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') diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py index d8c2990..1be9dd2 100644 --- a/backend/app/api/routes/transactions.py +++ b/backend/app/api/routes/transactions.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 272f48e..baec4ec 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/transaction.py b/backend/app/models/transaction.py index c51397a..ba20c54 100644 --- a/backend/app/models/transaction.py +++ b/backend/app/models/transaction.py @@ -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() diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py index c96143d..6b05eec 100644 --- a/backend/app/schemas/backup.py +++ b/backend/app/schemas/backup.py @@ -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 diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py index 5e29355..6c11014 100644 --- a/backend/app/schemas/transaction.py +++ b/backend/app/schemas/transaction.py @@ -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): diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 089f656..846443e 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -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) diff --git a/backend/app/services/budget_service.py b/backend/app/services/budget_service.py index d685a35..8b967f6 100644 --- a/backend/app/services/budget_service.py +++ b/backend/app/services/budget_service.py @@ -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: diff --git a/backend/app/services/category_rollup.py b/backend/app/services/category_rollup.py new file mode 100644 index 0000000..a3bc184 --- /dev/null +++ b/backend/app/services/category_rollup.py @@ -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 diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index daf4b51..299d776 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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: diff --git a/backend/app/services/reports_service.py b/backend/app/services/reports_service.py index 0be806c..6b91b6b 100644 --- a/backend/app/services/reports_service.py +++ b/backend/app/services/reports_service.py @@ -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) diff --git a/backend/tests/test_backup.py b/backend/tests/test_backup.py index 2c8f1f7..7de81cf 100644 --- a/backend/tests/test_backup.py +++ b/backend/tests/test_backup.py @@ -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 diff --git a/backend/tests/test_budgets.py b/backend/tests/test_budgets.py index 042208b..ed80351 100644 --- a/backend/tests/test_budgets.py +++ b/backend/tests/test_budgets.py @@ -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") diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 0276a9a..2d80f97 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -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") diff --git a/backend/tests/test_transactions.py b/backend/tests/test_transactions.py index 3426b22..2a11a63 100644 --- a/backend/tests/test_transactions.py +++ b/backend/tests/test_transactions.py @@ -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 diff --git a/frontend/src/components/transactions/TransactionFormModal.tsx b/frontend/src/components/transactions/TransactionFormModal.tsx index 4b94a7f..ecd5e90 100644 --- a/frontend/src/components/transactions/TransactionFormModal.tsx +++ b/frontend/src/components/transactions/TransactionFormModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { Plus, X } from "lucide-react"; import { Dialog } from "@/components/ui/Dialog"; import { Button } from "@/components/ui/Button"; import { Input, Label, Select } from "@/components/ui/Input"; @@ -8,7 +9,8 @@ import { useCategories } from "@/hooks/useCategories"; import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions"; import { useTranslation } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; -import type { Category, Tag, Transaction, TransactionInput, TransactionType } from "@/types"; +import { formatCurrency } from "@/lib/format"; +import type { Category, Tag, Transaction, TransactionInput, TransactionSplitInput, TransactionType } from "@/types"; interface TransactionFormModalProps { open: boolean; @@ -32,6 +34,25 @@ const EMPTY_FORM = { date: todayIso(), }; +interface SplitRowState { + key: string; + category_id: string; + amount: string; + note: string; +} + +function emptySplitRow(): SplitRowState { + return { key: crypto.randomUUID(), category_id: "", amount: "", note: "" }; +} + +// Cents, not floats — a plain Number sum of "0.10" + "0.20" style amounts can +// drift from the transaction total by fractions of a cent, which would +// falsely trip the "must add up exactly" check the backend also enforces. +function toCents(value: string): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; +} + export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) { const { t, language } = useTranslation(); const { data: accounts } = useAccounts(); @@ -41,6 +62,8 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction const [form, setForm] = useState(EMPTY_FORM); const [tags, setTags] = useState([]); + const [splitMode, setSplitMode] = useState(false); + const [splitRows, setSplitRows] = useState([emptySplitRow(), emptySplitRow()]); const [error, setError] = useState(null); useEffect(() => { @@ -58,9 +81,23 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction date: transaction.date, }); setTags(transaction.tags); + const hasSplits = transaction.splits.length > 0; + setSplitMode(hasSplits); + setSplitRows( + hasSplits + ? transaction.splits.map((split) => ({ + key: String(split.id), + category_id: split.category_id ? String(split.category_id) : "", + amount: split.amount, + note: split.note ?? "", + })) + : [emptySplitRow(), emptySplitRow()] + ); } else { setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" }); setTags([]); + setSplitMode(false); + setSplitRows([emptySplitRow(), emptySplitRow()]); } setError(null); }, [open, transaction, accounts]); @@ -81,6 +118,22 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction const isSaving = createTransaction.isPending || updateTransaction.isPending; + function updateSplitRow(key: string, patch: Partial) { + setSplitRows((prev) => prev.map((row) => (row.key === key ? { ...row, ...patch } : row))); + } + + function addSplitRow() { + setSplitRows((prev) => [...prev, emptySplitRow()]); + } + + function removeSplitRow(key: string) { + setSplitRows((prev) => (prev.length <= 2 ? prev : prev.filter((row) => row.key !== key))); + } + + const isSplitEditingNow = form.type !== "transfer" && splitMode; + const splitAllocatedCents = splitRows.reduce((sum, row) => sum + toCents(row.amount), 0); + const splitRemainingCents = toCents(form.amount) - splitAllocatedCents; + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setError(null); @@ -98,10 +151,44 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction return; } + // Undefined -> leave the transaction's existing splits untouched on + // update, and create a normal single-category transaction. [] -> clears + // splits that used to be there (the user turned split mode off, or + // switched the transaction to a transfer, which can't carry splits). + let splits: TransactionSplitInput[] | undefined; + if (isSplitEditingNow) { + const filledRows = splitRows.filter((row) => row.category_id || row.amount); + if (filledRows.length < 2) { + setError(t("transactions.form.errorSplitMinRows")); + return; + } + if (filledRows.some((row) => !row.category_id || !row.amount || Number(row.amount) <= 0)) { + setError(t("transactions.form.errorSplitIncomplete")); + return; + } + const allocatedCents = filledRows.reduce((sum, row) => sum + toCents(row.amount), 0); + if (allocatedCents !== toCents(form.amount)) { + setError(t("transactions.form.errorSplitMismatch")); + return; + } + splits = filledRows.map((row) => ({ + category_id: Number(row.category_id), + amount: row.amount, + note: row.note || null, + })); + } else if (transaction && transaction.splits.length > 0) { + splits = []; + } + const payload: TransactionInput = { type: form.type, account_id: Number(form.account_id), - category_id: form.type === "transfer" ? null : form.category_id ? Number(form.category_id) : null, + category_id: + form.type === "transfer" || (splits && splits.length > 0) + ? null + : form.category_id + ? Number(form.category_id) + : null, transfer_account_id: form.type === "transfer" ? Number(form.transfer_account_id) : null, amount: form.amount, description: form.description, @@ -109,6 +196,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction notes: form.notes || null, date: form.date, tag_ids: tags.map((tag) => tag.id), + splits, }; try { @@ -135,9 +223,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction setForm((prev) => ({ ...prev, category_id: event.target.value }))} - > - - {relevantCategories.map((category) => ( - - ))} - +
+ + +
+ + {!splitMode ? ( + + ) : ( +
+

{t("transactions.form.splitHint")}

+ {splitRows.map((row) => ( +
+
+ +
+ updateSplitRow(row.key, { amount: event.target.value })} + /> + +
+
+ updateSplitRow(row.key, { note: event.target.value })} + /> +
+ ))} + +
+ +

+ {splitRemainingCents > 0 + ? t("transactions.form.splitRemainingLabel", { + amount: formatCurrency(splitRemainingCents / 100), + }) + : splitRemainingCents < 0 + ? t("transactions.form.splitOverAllocatedLabel", { + amount: formatCurrency(Math.abs(splitRemainingCents) / 100), + }) + : t("transactions.form.splitFullyAllocatedLabel")} +

+
+
+ )} )} diff --git a/frontend/src/components/transactions/TransactionsTable.tsx b/frontend/src/components/transactions/TransactionsTable.tsx index 61c4153..c43ab6a 100644 --- a/frontend/src/components/transactions/TransactionsTable.tsx +++ b/frontend/src/components/transactions/TransactionsTable.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { ArrowLeftRight, CalendarSearch, Pencil, StickyNote, Trash2 } from "lucide-react"; +import { ArrowLeftRight, CalendarSearch, Pencil, SquareDivide, StickyNote, Trash2 } from "lucide-react"; import { Dialog } from "@/components/ui/Dialog"; import { getCategoryIcon } from "@/lib/icons"; import { formatCurrency, formatTransactionDate } from "@/lib/format"; @@ -35,8 +35,14 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr {items.map((tx) => { const isTransfer = tx.type === "transfer"; const isExpense = tx.type === "expense"; - const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon); - const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)"; + const isSplit = tx.splits.length > 0; + const Icon = isTransfer ? ArrowLeftRight : isSplit ? SquareDivide : getCategoryIcon(tx.category?.icon); + const color = isTransfer || isSplit ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)"; + const categoryLabel = isSplit + ? tx.splits.map((split) => (split.category ? translateCategoryName(split.category.name) : "?")).join(" + ") + : tx.category + ? translateCategoryName(tx.category.name) + : null; return (
  • @@ -52,7 +58,7 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr {formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name} {isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""} - {tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""} + {categoryLabel ? ` · ${categoryLabel}` : ""} {tx.tags.length > 0 ? ` · ${tx.tags.map((tag) => tag.name).join(", ")}` : ""} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index ec67997..173e8c0 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -272,6 +272,19 @@ const ru = { "transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода", "transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта", "transactions.form.saveError": "Не удалось сохранить транзакцию. Проверьте данные и попробуйте снова.", + "transactions.form.splitToggle": "Разбить на несколько категорий", + "transactions.form.splitToggleOff": "Одна категория", + "transactions.form.splitHint": "Одна покупка — несколько категорий, например часть чека — продукты, часть — бытовая химия.", + "transactions.form.splitCategoryPlaceholder": "Выберите категорию", + "transactions.form.splitNotePlaceholder": "Заметка (необязательно)", + "transactions.form.splitAddRow": "Добавить категорию", + "transactions.form.splitRemoveRow": "Убрать категорию", + "transactions.form.splitRemainingLabel": "Осталось распределить: {{amount}}", + "transactions.form.splitOverAllocatedLabel": "Превышение суммы на: {{amount}}", + "transactions.form.splitFullyAllocatedLabel": "Вся сумма распределена", + "transactions.form.errorSplitMinRows": "Нужно как минимум 2 категории для разбивки", + "transactions.form.errorSplitIncomplete": "Выберите категорию и сумму для каждой строки разбивки", + "transactions.form.errorSplitMismatch": "Сумма по категориям должна совпадать с суммой транзакции", "reports.categoryLabel": "Категория", "reports.expenseGroup": "Расходы", @@ -734,6 +747,19 @@ const en: Record = { "transactions.form.errorSelectDestination": "Select a destination account for the transfer", "transactions.form.errorSameAccount": "The destination account must be different from the source account", "transactions.form.saveError": "Failed to save the transaction. Check the data and try again.", + "transactions.form.splitToggle": "Split across multiple categories", + "transactions.form.splitToggleOff": "Single category", + "transactions.form.splitHint": "One purchase, several categories — e.g. part of a receipt is groceries, part is household chemicals.", + "transactions.form.splitCategoryPlaceholder": "Select a category", + "transactions.form.splitNotePlaceholder": "Note (optional)", + "transactions.form.splitAddRow": "Add category", + "transactions.form.splitRemoveRow": "Remove category", + "transactions.form.splitRemainingLabel": "Remaining to allocate: {{amount}}", + "transactions.form.splitOverAllocatedLabel": "Over-allocated by: {{amount}}", + "transactions.form.splitFullyAllocatedLabel": "Fully allocated", + "transactions.form.errorSplitMinRows": "Splitting needs at least 2 categories", + "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", "reports.categoryLabel": "Category", "reports.expenseGroup": "Expenses", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 72a9e7e..0601fb3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -62,6 +62,25 @@ export interface Tag { name: string; } +// One category's slice of a transaction whose amount is divided across +// several categories (one receipt, several kinds of goods) — see +// TransactionInput.splits. category is null when the split's category was +// since deleted; reading must still work even though creating/editing a +// split always requires a live category. +export interface TransactionSplit { + id: number; + category_id: number | null; + category: Category | null; + amount: string; + note: string | null; +} + +export interface TransactionSplitInput { + category_id: number; + amount: string; + note?: string | null; +} + export interface Transaction { id: number; account_id: number; @@ -76,6 +95,7 @@ export interface Transaction { account: Account; category: Category | null; tags: Tag[]; + splits: TransactionSplit[]; } export interface TransactionPage { @@ -98,6 +118,11 @@ export interface TransactionInput { // Omitted -> tags untouched on update; sent (even as []) -> replaces the // full tag set. Always sent on create (defaults to []). tag_ids?: number[]; + // Omitted -> a normal single-category transaction (unchanged). 2+ entries + // -> the amount is divided across categories instead, and category_id + // above must then be null. On update, omitted leaves existing splits + // untouched; sent (even as []) replaces the full split set. + splits?: TransactionSplitInput[] | null; } export interface RecurringTransaction {