mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
@@ -22,6 +22,7 @@ from app.models import ( # noqa: F401 — registers metadata
|
||||
RecurringTransaction,
|
||||
Tag,
|
||||
Transaction,
|
||||
TransactionSplit,
|
||||
)
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
116
backend/app/services/category_rollup.py
Normal file
116
backend/app/services/category_rollup.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Tag[]>([]);
|
||||
const [splitMode, setSplitMode] = useState(false);
|
||||
const [splitRows, setSplitRows] = useState<SplitRowState[]>([emptySplitRow(), emptySplitRow()]);
|
||||
const [error, setError] = useState<string | null>(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<SplitRowState>) {
|
||||
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
|
||||
<Select
|
||||
id="type"
|
||||
value={form.type}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, type: event.target.value as TransactionType, category_id: "" }))
|
||||
}
|
||||
onChange={(event) => {
|
||||
const nextType = event.target.value as TransactionType;
|
||||
setForm((prev) => ({ ...prev, type: nextType, category_id: "" }));
|
||||
if (nextType === "transfer") setSplitMode(false);
|
||||
}}
|
||||
>
|
||||
<option value="expense">{t("transactions.form.typeExpense")}</option>
|
||||
<option value="income">{t("transactions.form.typeIncome")}</option>
|
||||
@@ -223,20 +313,106 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="category">{t("transactions.form.categoryLabel")}</Label>
|
||||
<Select
|
||||
id="category"
|
||||
value={form.category_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, category_id: event.target.value }))}
|
||||
>
|
||||
<option value="">{t("transactions.form.noCategory")}</option>
|
||||
{relevantCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.indented ? ` ↳ ` : ""}
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="category">{t("transactions.form.categoryLabel")}</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1 text-xs text-series-1 hover:underline"
|
||||
onClick={() => setSplitMode((prev) => !prev)}
|
||||
>
|
||||
{splitMode ? t("transactions.form.splitToggleOff") : t("transactions.form.splitToggle")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!splitMode ? (
|
||||
<Select
|
||||
id="category"
|
||||
value={form.category_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, category_id: event.target.value }))}
|
||||
>
|
||||
<option value="">{t("transactions.form.noCategory")}</option>
|
||||
{relevantCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.indented ? ` ↳ ` : ""}
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-text-muted">{t("transactions.form.splitHint")}</p>
|
||||
{splitRows.map((row) => (
|
||||
<div key={row.key} className="space-y-1.5 rounded-lg border border-border bg-surface-1 p-2">
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-2">
|
||||
<Select
|
||||
aria-label={t("transactions.form.splitCategoryPlaceholder")}
|
||||
className="sm:flex-1"
|
||||
value={row.category_id}
|
||||
onChange={(event) => updateSplitRow(row.key, { category_id: event.target.value })}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("transactions.form.splitCategoryPlaceholder")}
|
||||
</option>
|
||||
{relevantCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.indented ? ` ↳ ` : ""}
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
className="w-24"
|
||||
placeholder={t("transactions.form.amountLabel")}
|
||||
value={row.amount}
|
||||
onChange={(event) => updateSplitRow(row.key, { amount: event.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("transactions.form.splitRemoveRow")}
|
||||
onClick={() => removeSplitRow(row.key)}
|
||||
disabled={splitRows.length <= 2}
|
||||
className="shrink-0 rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:opacity-30"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
className="text-xs"
|
||||
placeholder={t("transactions.form.splitNotePlaceholder")}
|
||||
value={row.note}
|
||||
onChange={(event) => updateSplitRow(row.key, { note: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSplitRow}
|
||||
className="flex items-center gap-1 rounded-md py-1 text-xs text-series-1 hover:underline"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("transactions.form.splitAddRow")}
|
||||
</button>
|
||||
<p className={`text-xs ${splitRemainingCents === 0 ? "text-success" : "text-text-muted"}`}>
|
||||
{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")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<li key={tx.id} className="group flex items-center gap-3 py-3">
|
||||
@@ -52,7 +58,7 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
|
||||
<span className="block truncate text-xs text-text-muted">
|
||||
{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(", ")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -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<keyof typeof ru, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user