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,
|
RecurringTransaction,
|
||||||
Tag,
|
Tag,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
TransactionSplit,
|
||||||
)
|
)
|
||||||
|
|
||||||
config = context.config
|
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 datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
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.category import Category
|
||||||
from app.models.enums import CategoryKind, TransactionType
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction, TransactionSplit
|
||||||
from app.schemas.transaction import (
|
from app.schemas.transaction import (
|
||||||
TransactionBulkCreate,
|
TransactionBulkCreate,
|
||||||
TransactionBulkCreateResult,
|
TransactionBulkCreateResult,
|
||||||
TransactionCreate,
|
TransactionCreate,
|
||||||
TransactionPage,
|
TransactionPage,
|
||||||
TransactionRead,
|
TransactionRead,
|
||||||
|
TransactionSplitInput,
|
||||||
TransactionUpdate,
|
TransactionUpdate,
|
||||||
|
split_rule_violation,
|
||||||
transfer_rule_violation,
|
transfer_rule_violation,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
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]:
|
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)
|
@router.get("", response_model=TransactionPage)
|
||||||
async def list_transactions(
|
async def list_transactions(
|
||||||
year: int | None = Query(default=None, ge=2000, le=2100),
|
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)
|
stmt = stmt.where(Transaction.account_id == account_id)
|
||||||
count_stmt = count_stmt.where(Transaction.account_id == account_id)
|
count_stmt = count_stmt.where(Transaction.account_id == account_id)
|
||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
stmt = stmt.where(Transaction.category_id == category_id)
|
# A split transaction has category_id=NULL on the row itself — the
|
||||||
count_stmt = count_stmt.where(Transaction.category_id == category_id)
|
# 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:
|
if tag_id is not None:
|
||||||
stmt = stmt.where(Transaction.tags.any(Tag.id == tag_id))
|
stmt = stmt.where(Transaction.tags.any(Tag.id == tag_id))
|
||||||
count_stmt = count_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)
|
@router.post("", response_model=TransactionRead, status_code=201)
|
||||||
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
|
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
|
||||||
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
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 = Transaction(**fields)
|
||||||
transaction.tags = await _resolve_tags(session, payload.tag_ids)
|
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)
|
session.add(transaction)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
refreshed = await session.execute(
|
refreshed = await session.execute(
|
||||||
@@ -171,11 +196,17 @@ async def bulk_create_transactions(
|
|||||||
instead of leaving a half-imported statement behind."""
|
instead of leaving a half-imported statement behind."""
|
||||||
for item in payload.items:
|
for item in payload.items:
|
||||||
await _ensure_category_matches_type(session, item.category_id, item.type)
|
await _ensure_category_matches_type(session, item.category_id, item.type)
|
||||||
|
for split in item.splits or []:
|
||||||
|
await _ensure_category_matches_type(session, split.category_id, item.type)
|
||||||
|
|
||||||
transactions = []
|
transactions = []
|
||||||
for item in payload.items:
|
for item in payload.items:
|
||||||
transaction = Transaction(**item.model_dump(exclude={"tag_ids"}))
|
transaction = Transaction(**item.model_dump(exclude={"tag_ids", "splits"}))
|
||||||
transaction.tags = await _resolve_tags(session, item.tag_ids)
|
transaction.tags = await _resolve_tags(session, item.tag_ids)
|
||||||
|
if item.splits:
|
||||||
|
transaction.splits = [
|
||||||
|
TransactionSplit(category_id=s.category_id, amount=s.amount, note=s.note) for s in item.splits
|
||||||
|
]
|
||||||
transactions.append(transaction)
|
transactions.append(transaction)
|
||||||
|
|
||||||
session.add_all(transactions)
|
session.add_all(transactions)
|
||||||
@@ -187,13 +218,16 @@ async def bulk_create_transactions(
|
|||||||
async def update_transaction(
|
async def update_transaction(
|
||||||
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
|
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
|
||||||
) -> Transaction:
|
) -> Transaction:
|
||||||
# Eager-loads tags — assigning transaction.tags below would otherwise lazy
|
# Eager-loads tags and splits — assigning transaction.tags/splits below
|
||||||
# -load the current collection first to diff against, which async
|
# would otherwise lazy-load the current collection first to diff
|
||||||
# SQLAlchemy can't do outside an explicit await (MissingGreenlet).
|
# against, which async SQLAlchemy can't do outside an explicit await
|
||||||
transaction = await session.get(Transaction, transaction_id, options=[selectinload(Transaction.tags)])
|
# (MissingGreenlet).
|
||||||
|
transaction = await session.get(
|
||||||
|
Transaction, transaction_id, options=[selectinload(Transaction.tags), selectinload(Transaction.splits)]
|
||||||
|
)
|
||||||
if transaction is None:
|
if transaction is None:
|
||||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
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
|
# Checks run against the row as it would look after the patch, not just
|
||||||
# the fields sent: switching type alone can invalidate fields left
|
# the fields sent: switching type alone can invalidate fields left
|
||||||
# untouched.
|
# untouched.
|
||||||
@@ -201,6 +235,7 @@ async def update_transaction(
|
|||||||
effective_category_id = updates.get("category_id", transaction.category_id)
|
effective_category_id = updates.get("category_id", transaction.category_id)
|
||||||
effective_account_id = updates.get("account_id", transaction.account_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_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)
|
await _ensure_category_matches_type(session, effective_category_id, effective_type)
|
||||||
violation = transfer_rule_violation(
|
violation = transfer_rule_violation(
|
||||||
type=effective_type,
|
type=effective_type,
|
||||||
@@ -210,10 +245,36 @@ async def update_transaction(
|
|||||||
)
|
)
|
||||||
if violation:
|
if violation:
|
||||||
raise HTTPException(status_code=400, detail=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():
|
for field, value in updates.items():
|
||||||
setattr(transaction, field, value)
|
setattr(transaction, field, value)
|
||||||
if payload.tag_ids is not None:
|
if payload.tag_ids is not None:
|
||||||
transaction.tags = await _resolve_tags(session, payload.tag_ids)
|
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()
|
await session.commit()
|
||||||
refreshed = await session.execute(
|
refreshed = await session.execute(
|
||||||
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)
|
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.recurring import RecurringTransaction
|
||||||
from app.models.settings import AppSettings
|
from app.models.settings import AppSettings
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction, TransactionSplit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Account",
|
"Account",
|
||||||
@@ -24,4 +24,5 @@ __all__ = [
|
|||||||
"RecurringTransaction",
|
"RecurringTransaction",
|
||||||
"Tag",
|
"Tag",
|
||||||
"Transaction",
|
"Transaction",
|
||||||
|
"TransactionSplit",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -35,3 +35,32 @@ class Transaction(Base, TimestampMixin):
|
|||||||
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
|
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
|
||||||
category: Mapped["Category | None"] = relationship(back_populates="transactions")
|
category: Mapped["Category | None"] = relationship(back_populates="transactions")
|
||||||
tags: Mapped[list["Tag"]] = relationship(secondary=transaction_tags, 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)
|
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):
|
class AssetBackup(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -202,6 +212,9 @@ class BackupPayload(BaseModel):
|
|||||||
# cleanly under the same format version.
|
# cleanly under the same format version.
|
||||||
tags: list[TagBackup] = Field(default_factory=list)
|
tags: list[TagBackup] = Field(default_factory=list)
|
||||||
transactions: list[TransactionBackup]
|
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]
|
assets: list[AssetBackup]
|
||||||
asset_valuations: list[AssetValuationBackup]
|
asset_valuations: list[AssetValuationBackup]
|
||||||
# Defaulted so a backup exported before crypto holdings existed still
|
# Defaulted so a backup exported before crypto holdings existed still
|
||||||
|
|||||||
@@ -39,6 +39,37 @@ def transfer_rule_violation(
|
|||||||
return None
|
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):
|
class TransactionFields(BaseModel):
|
||||||
"""A transaction's shape, without the rules that only make sense while
|
"""A transaction's shape, without the rules that only make sense while
|
||||||
writing one. Reading goes through this: rows already in the database can
|
writing one. Reading goes through this: rows already in the database can
|
||||||
@@ -83,8 +114,34 @@ class TransactionBase(TransactionFields):
|
|||||||
return self
|
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):
|
class TransactionCreate(TransactionBase):
|
||||||
tag_ids: list[int] = Field(default_factory=list)
|
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):
|
class TransactionUpdate(BaseModel):
|
||||||
@@ -99,6 +156,10 @@ class TransactionUpdate(BaseModel):
|
|||||||
date: date_ | None = None
|
date: date_ | None = None
|
||||||
# Omitted -> tags untouched; sent (even as []) -> replaces the full tag set.
|
# Omitted -> tags untouched; sent (even as []) -> replaces the full tag set.
|
||||||
tag_ids: list[int] | None = None
|
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")
|
@field_validator("description")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -106,6 +167,16 @@ class TransactionUpdate(BaseModel):
|
|||||||
return capitalize_first_letter(value) if value is not None else None
|
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):
|
class TransactionRead(TransactionFields):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -113,6 +184,7 @@ class TransactionRead(TransactionFields):
|
|||||||
account: AccountRead
|
account: AccountRead
|
||||||
category: CategoryRead | None = None
|
category: CategoryRead | None = None
|
||||||
tags: list[TagRead] = Field(default_factory=list)
|
tags: list[TagRead] = Field(default_factory=list)
|
||||||
|
splits: list[TransactionSplitRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class TransactionPage(BaseModel):
|
class TransactionPage(BaseModel):
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from app.models.goal import Goal, GoalContribution
|
|||||||
from app.models.recurring import RecurringTransaction
|
from app.models.recurring import RecurringTransaction
|
||||||
from app.models.settings import AppSettings
|
from app.models.settings import AppSettings
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction, TransactionSplit
|
||||||
from app.schemas.backup import (
|
from app.schemas.backup import (
|
||||||
AccountBackup,
|
AccountBackup,
|
||||||
AppSettingsBackup,
|
AppSettingsBackup,
|
||||||
@@ -42,6 +42,7 @@ from app.schemas.backup import (
|
|||||||
RecurringTransactionBackup,
|
RecurringTransactionBackup,
|
||||||
TagBackup,
|
TagBackup,
|
||||||
TransactionBackup,
|
TransactionBackup,
|
||||||
|
TransactionSplitBackup,
|
||||||
)
|
)
|
||||||
|
|
||||||
BACKUP_FORMAT_VERSION = 1
|
BACKUP_FORMAT_VERSION = 1
|
||||||
@@ -52,6 +53,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
|
|||||||
categories = (await session.execute(select(Category))).scalars().all()
|
categories = (await session.execute(select(Category))).scalars().all()
|
||||||
tags = (await session.execute(select(Tag))).scalars().all()
|
tags = (await session.execute(select(Tag))).scalars().all()
|
||||||
transactions = (await session.execute(select(Transaction).options(selectinload(Transaction.tags)))).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()
|
assets = (await session.execute(select(Asset))).scalars().all()
|
||||||
valuations = (await session.execute(select(AssetValuation))).scalars().all()
|
valuations = (await session.execute(select(AssetValuation))).scalars().all()
|
||||||
crypto_holdings = (await session.execute(select(CryptoHolding))).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
|
for row in transactions
|
||||||
],
|
],
|
||||||
|
transaction_splits=[TransactionSplitBackup.model_validate(row) for row in transaction_splits],
|
||||||
assets=[AssetBackup.model_validate(row) for row in assets],
|
assets=[AssetBackup.model_validate(row) for row in assets],
|
||||||
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
|
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
|
||||||
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
|
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:
|
if tag_id not in tag_ids:
|
||||||
raise HTTPException(400, f"Transaction {t.id} references unknown tag_id {tag_id}")
|
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:
|
for v in payload.asset_valuations:
|
||||||
if v.asset_id not in asset_ids:
|
if v.asset_id not in asset_ids:
|
||||||
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
|
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(GoalContribution))
|
||||||
await session.execute(delete(Goal))
|
await session.execute(delete(Goal))
|
||||||
await session.execute(delete(RecurringTransaction))
|
await session.execute(delete(RecurringTransaction))
|
||||||
# Deleting transactions cascades transaction_tags rows (ON DELETE
|
# Deleting transactions cascades transaction_tags and
|
||||||
# CASCADE) — tags themselves still need their own delete.
|
# 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(Transaction))
|
||||||
await session.execute(delete(Tag))
|
await session.execute(delete(Tag))
|
||||||
await session.execute(delete(Asset))
|
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
|
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(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(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
|
||||||
session.add_all(CryptoHolding(**row.model_dump()) for row in payload.crypto_holdings)
|
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, "tags", payload.tags)
|
||||||
await _reset_sequence(session, "assets", payload.assets)
|
await _reset_sequence(session, "assets", payload.assets)
|
||||||
await _reset_sequence(session, "transactions", payload.transactions)
|
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, "asset_valuations", payload.asset_valuations)
|
||||||
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
|
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
|
||||||
await _reset_sequence(session, "budgets", payload.budgets)
|
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.budget import Budget
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.enums import CategoryKind, TransactionType
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction, TransactionSplit
|
||||||
from app.schemas.budget import BudgetCreate, BudgetStatus, BudgetStatusResponse, BudgetUpdate
|
from app.schemas.budget import BudgetCreate, BudgetStatus, BudgetStatusResponse, BudgetUpdate
|
||||||
|
|
||||||
_EAGER = (selectinload(Budget.category),)
|
_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(
|
counted_ids = set(budget_category_ids).union(
|
||||||
child_id for children in children_by_parent.values() for child_id in children
|
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))
|
select(Transaction.category_id, func.coalesce(func.sum(Transaction.amount), 0))
|
||||||
.where(
|
.where(
|
||||||
Transaction.category_id.in_(counted_ids),
|
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)
|
.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 = []
|
items = []
|
||||||
for budget in budgets:
|
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 import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.enums import TransactionType
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.schemas.dashboard import CategoryBreakdownItem, DashboardSummary
|
from app.schemas.dashboard import 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",
|
# 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.
|
# 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"))
|
spent = totals.get(TransactionType.EXPENSE, Decimal("0"))
|
||||||
transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0"))
|
transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0"))
|
||||||
|
|
||||||
# A subcategory's spending rolls up into its parent's slice — Parent is a
|
# A subcategory's spending rolls up into its parent's slice, and a split
|
||||||
# self-join to resolve each transaction's *top-level* category, falling
|
# transaction's category_id=NULL means its category lives on its split
|
||||||
# back to the transaction's own category when it has no parent.
|
# lines instead — rollup_spending_by_top_level_category handles both
|
||||||
Parent = aliased(Category)
|
# the same way a plain transaction's category already was.
|
||||||
effective_id = func.coalesce(Category.parent_id, Category.id)
|
rows = await rollup_spending_by_top_level_category(
|
||||||
effective_name = func.coalesce(Parent.name, Category.name)
|
session, transaction_type=TransactionType.EXPENSE, start_date=start, end_date=end
|
||||||
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)
|
|
||||||
)
|
|
||||||
breakdown_result = await session.execute(breakdown_stmt)
|
|
||||||
rows = breakdown_result.all()
|
|
||||||
|
|
||||||
top_rows, rest_rows = rows[:MAX_CHART_SLICES], rows[MAX_CHART_SLICES:]
|
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 = [
|
spending_by_category = [
|
||||||
CategoryBreakdownItem(
|
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:
|
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 cost the most over this whole period" (ranking, across all
|
||||||
categories of one kind at once, not just the current month).
|
categories of one kind at once, not just the current month).
|
||||||
"""
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import date as date_
|
from datetime import date as date_
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import extract, func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
|
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.enums import CategoryKind, TransactionType
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction, TransactionSplit
|
||||||
from app.schemas.reports import (
|
from app.schemas.reports import (
|
||||||
CategoryRankingItem,
|
CategoryRankingItem,
|
||||||
CategoryRankingReport,
|
CategoryRankingReport,
|
||||||
CategorySpendingPoint,
|
CategorySpendingPoint,
|
||||||
CategorySpendingReport,
|
CategorySpendingReport,
|
||||||
)
|
)
|
||||||
|
from app.services.category_rollup import rollup_spending_by_top_level_category
|
||||||
|
|
||||||
|
|
||||||
def _next_month(year: int, month: int) -> tuple[int, int]:
|
def _next_month(year: int, month: int) -> tuple[int, int]:
|
||||||
@@ -44,51 +45,56 @@ async def get_category_spending_report(
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
category_ids.extend(child_ids)
|
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)
|
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:
|
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:
|
if end_date:
|
||||||
bounds_stmt = bounds_stmt.where(Transaction.date <= end_date)
|
plain_stmt = plain_stmt.where(Transaction.date <= end_date)
|
||||||
min_date, max_date = (await session.execute(bounds_stmt)).one()
|
split_stmt = split_stmt.where(Transaction.date <= end_date)
|
||||||
|
|
||||||
effective_start = start_date or min_date
|
plain_rows = (await session.execute(plain_stmt)).all()
|
||||||
effective_end = end_date or max_date
|
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(
|
empty = CategorySpendingReport(
|
||||||
category_id=category.id,
|
category_id=category.id,
|
||||||
category_name=category.name,
|
category_name=category.name,
|
||||||
category_color=category.color,
|
category_color=category.color,
|
||||||
category_icon=category.icon,
|
category_icon=category.icon,
|
||||||
start_date=effective_start,
|
start_date=start_date,
|
||||||
end_date=effective_end,
|
end_date=end_date,
|
||||||
total_amount=Decimal("0"),
|
total_amount=Decimal("0"),
|
||||||
transaction_count=0,
|
transaction_count=0,
|
||||||
average_per_month=Decimal("0"),
|
average_per_month=Decimal("0"),
|
||||||
series=[],
|
series=[],
|
||||||
)
|
)
|
||||||
if effective_start is None or effective_end is None:
|
if not contributions:
|
||||||
return empty
|
return empty
|
||||||
|
|
||||||
rows_stmt = (
|
dates = [txn_date for _, txn_date, _ in contributions]
|
||||||
select(
|
effective_start = start_date or min(dates)
|
||||||
extract("year", Transaction.date).label("year"),
|
effective_end = end_date or max(dates)
|
||||||
extract("month", Transaction.date).label("month"),
|
|
||||||
func.sum(Transaction.amount).label("amount"),
|
by_month: dict[tuple[int, int], Decimal] = defaultdict(Decimal)
|
||||||
func.count(Transaction.id).label("cnt"),
|
for _, txn_date, amount in contributions:
|
||||||
)
|
by_month[(txn_date.year, txn_date.month)] += amount
|
||||||
.where(
|
|
||||||
Transaction.category_id.in_(category_ids),
|
total_amount = sum((amount for _, _, amount in contributions), Decimal("0"))
|
||||||
Transaction.date >= effective_start,
|
# Distinct transactions, not rows — a transaction split across two of
|
||||||
Transaction.date <= effective_end,
|
# these categories (e.g. parent + one of its children) must count once,
|
||||||
)
|
# the same as a plain transaction filed under just one of them.
|
||||||
.group_by("year", "month")
|
total_count = len({transaction_id for transaction_id, _, _ in contributions})
|
||||||
)
|
|
||||||
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)
|
|
||||||
|
|
||||||
series: list[CategorySpendingPoint] = []
|
series: list[CategorySpendingPoint] = []
|
||||||
year, month = effective_start.year, effective_start.month
|
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
|
arbitrary period — "which category costs the most" across the whole
|
||||||
range, unlike the month-scoped Dashboard breakdown or the
|
range, unlike the month-scoped Dashboard breakdown or the
|
||||||
single-category detail above."""
|
single-category detail above."""
|
||||||
# Same subcategory-into-parent rollup as the Dashboard breakdown — see
|
# A transaction's type already restricts it to categories of the
|
||||||
# dashboard_service.get_dashboard_summary.
|
# matching kind (enforced at write time by _ensure_category_matches_type
|
||||||
Parent = aliased(Category)
|
# in routes/transactions.py), so filtering by transaction_type below is
|
||||||
effective_id = func.coalesce(Category.parent_id, Category.id)
|
# enough — no separate kind filter needed, and the shared rollup already
|
||||||
effective_name = func.coalesce(Parent.name, Category.name)
|
# unions plain transactions with split lines the same way the Dashboard
|
||||||
effective_color = func.coalesce(Parent.color, Category.color)
|
# breakdown does.
|
||||||
effective_icon = func.coalesce(Parent.icon, Category.icon)
|
rows = await rollup_spending_by_top_level_category(
|
||||||
effective_sort = func.coalesce(Parent.sort_order, Category.sort_order)
|
session, transaction_type=_KIND_TO_TRANSACTION_TYPE[kind], start_date=start_date, end_date=end_date
|
||||||
|
|
||||||
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])
|
|
||||||
)
|
|
||||||
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"))
|
total_amount = sum((row.amount for row in rows), Decimal("0"))
|
||||||
|
|
||||||
def _percent(amount: Decimal) -> float:
|
def _percent(amount: Decimal) -> float:
|
||||||
@@ -163,17 +148,15 @@ async def get_category_ranking_report(
|
|||||||
|
|
||||||
items = [
|
items = [
|
||||||
CategoryRankingItem(
|
CategoryRankingItem(
|
||||||
category_id=cat_id,
|
category_id=row.category_id,
|
||||||
name=name,
|
name=row.name,
|
||||||
color=color,
|
color=row.color,
|
||||||
icon=icon,
|
icon=row.icon,
|
||||||
amount=amount,
|
amount=row.amount,
|
||||||
percent=_percent(amount),
|
percent=_percent(row.amount),
|
||||||
transaction_count=cnt,
|
transaction_count=row.transaction_count,
|
||||||
)
|
)
|
||||||
for cat_id, name, color, icon, amount, cnt in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
return CategoryRankingReport(
|
return CategoryRankingReport(start_date=start_date, end_date=end_date, total_amount=total_amount, items=items)
|
||||||
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)
|
resp = await client.post("/backup/import", json=payload)
|
||||||
assert resp.status_code == 400
|
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 items[child_id]["is_over_budget"] is True
|
||||||
assert money(items[parent_id]["spent"]) == money("900.00")
|
assert money(items[parent_id]["spent"]) == money("900.00")
|
||||||
assert items[parent_id]["is_over_budget"] is False
|
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,
|
# The 9th (smallest, index 8) category's 10.00 must be folded into Other,
|
||||||
# not silently dropped from the total.
|
# not silently dropped from the total.
|
||||||
assert money(other[0]["amount"]) == Decimal("10.00")
|
assert money(other[0]["amount"]) == Decimal("10.00")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_split_transaction_counts_each_split_toward_its_own_category(
|
||||||
|
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 listing.status_code == 200
|
||||||
assert txn_id in [item["id"] for item in listing.json()["items"]]
|
assert txn_id in [item["id"] for item in listing.json()["items"]]
|
||||||
assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204
|
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 { useEffect, useState } from "react";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
import { Dialog } from "@/components/ui/Dialog";
|
import { Dialog } from "@/components/ui/Dialog";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input, Label, Select } from "@/components/ui/Input";
|
import { Input, Label, Select } from "@/components/ui/Input";
|
||||||
@@ -8,7 +9,8 @@ import { useCategories } from "@/hooks/useCategories";
|
|||||||
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
|
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
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 {
|
interface TransactionFormModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,6 +34,25 @@ const EMPTY_FORM = {
|
|||||||
date: todayIso(),
|
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) {
|
export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) {
|
||||||
const { t, language } = useTranslation();
|
const { t, language } = useTranslation();
|
||||||
const { data: accounts } = useAccounts();
|
const { data: accounts } = useAccounts();
|
||||||
@@ -41,6 +62,8 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
|
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [splitMode, setSplitMode] = useState(false);
|
||||||
|
const [splitRows, setSplitRows] = useState<SplitRowState[]>([emptySplitRow(), emptySplitRow()]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,9 +81,23 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
date: transaction.date,
|
date: transaction.date,
|
||||||
});
|
});
|
||||||
setTags(transaction.tags);
|
setTags(transaction.tags);
|
||||||
|
const hasSplits = transaction.splits.length > 0;
|
||||||
|
setSplitMode(hasSplits);
|
||||||
|
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 {
|
} else {
|
||||||
setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" });
|
setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" });
|
||||||
setTags([]);
|
setTags([]);
|
||||||
|
setSplitMode(false);
|
||||||
|
setSplitRows([emptySplitRow(), emptySplitRow()]);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [open, transaction, accounts]);
|
}, [open, transaction, accounts]);
|
||||||
@@ -81,6 +118,22 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
|
|
||||||
const isSaving = createTransaction.isPending || updateTransaction.isPending;
|
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) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -98,10 +151,44 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
return;
|
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 = {
|
const payload: TransactionInput = {
|
||||||
type: form.type,
|
type: form.type,
|
||||||
account_id: Number(form.account_id),
|
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,
|
transfer_account_id: form.type === "transfer" ? Number(form.transfer_account_id) : null,
|
||||||
amount: form.amount,
|
amount: form.amount,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
@@ -109,6 +196,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
notes: form.notes || null,
|
notes: form.notes || null,
|
||||||
date: form.date,
|
date: form.date,
|
||||||
tag_ids: tags.map((tag) => tag.id),
|
tag_ids: tags.map((tag) => tag.id),
|
||||||
|
splits,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -135,9 +223,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
<Select
|
<Select
|
||||||
id="type"
|
id="type"
|
||||||
value={form.type}
|
value={form.type}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setForm((prev) => ({ ...prev, type: event.target.value as TransactionType, category_id: "" }))
|
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="expense">{t("transactions.form.typeExpense")}</option>
|
||||||
<option value="income">{t("transactions.form.typeIncome")}</option>
|
<option value="income">{t("transactions.form.typeIncome")}</option>
|
||||||
@@ -223,7 +313,18 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="category">{t("transactions.form.categoryLabel")}</Label>
|
<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
|
<Select
|
||||||
id="category"
|
id="category"
|
||||||
value={form.category_id}
|
value={form.category_id}
|
||||||
@@ -237,6 +338,81 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
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 { Dialog } from "@/components/ui/Dialog";
|
||||||
import { getCategoryIcon } from "@/lib/icons";
|
import { getCategoryIcon } from "@/lib/icons";
|
||||||
import { formatCurrency, formatTransactionDate } from "@/lib/format";
|
import { formatCurrency, formatTransactionDate } from "@/lib/format";
|
||||||
@@ -35,8 +35,14 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
|
|||||||
{items.map((tx) => {
|
{items.map((tx) => {
|
||||||
const isTransfer = tx.type === "transfer";
|
const isTransfer = tx.type === "transfer";
|
||||||
const isExpense = tx.type === "expense";
|
const isExpense = tx.type === "expense";
|
||||||
const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon);
|
const isSplit = tx.splits.length > 0;
|
||||||
const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)";
|
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 (
|
return (
|
||||||
<li key={tx.id} className="group flex items-center gap-3 py-3">
|
<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">
|
<span className="block truncate text-xs text-text-muted">
|
||||||
{formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
|
{formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
|
||||||
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
|
{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(", ")}` : ""}
|
{tx.tags.length > 0 ? ` · ${tx.tags.map((tag) => tag.name).join(", ")}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -272,6 +272,19 @@ const ru = {
|
|||||||
"transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода",
|
"transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода",
|
||||||
"transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта",
|
"transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта",
|
||||||
"transactions.form.saveError": "Не удалось сохранить транзакцию. Проверьте данные и попробуйте снова.",
|
"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.categoryLabel": "Категория",
|
||||||
"reports.expenseGroup": "Расходы",
|
"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.errorSelectDestination": "Select a destination account for the transfer",
|
||||||
"transactions.form.errorSameAccount": "The destination account must be different from the source account",
|
"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.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.categoryLabel": "Category",
|
||||||
"reports.expenseGroup": "Expenses",
|
"reports.expenseGroup": "Expenses",
|
||||||
|
|||||||
@@ -62,6 +62,25 @@ export interface Tag {
|
|||||||
name: string;
|
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 {
|
export interface Transaction {
|
||||||
id: number;
|
id: number;
|
||||||
account_id: number;
|
account_id: number;
|
||||||
@@ -76,6 +95,7 @@ export interface Transaction {
|
|||||||
account: Account;
|
account: Account;
|
||||||
category: Category | null;
|
category: Category | null;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
splits: TransactionSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionPage {
|
export interface TransactionPage {
|
||||||
@@ -98,6 +118,11 @@ export interface TransactionInput {
|
|||||||
// Omitted -> tags untouched on update; sent (even as []) -> replaces the
|
// Omitted -> tags untouched on update; sent (even as []) -> replaces the
|
||||||
// full tag set. Always sent on create (defaults to []).
|
// full tag set. Always sent on create (defaults to []).
|
||||||
tag_ids?: number[];
|
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 {
|
export interface RecurringTransaction {
|
||||||
|
|||||||
Reference in New Issue
Block a user