mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
The transfer rules — destination required, destination different from the
source account, no category, and no destination on a non-transfer — were
enforced by TransactionBase's validator on create, but the PATCH route only
re-checked category-vs-type.
So `PATCH /api/transactions/{id} {"type": "transfer"}` on an ordinary
expense committed a transfer row with transfer_account_id = NULL, and only
then failed serializing the response (TransactionRead inherits the same
validator). The caller got a 500 and would reasonably assume nothing was
saved — but from that point on every `GET /api/transactions` failed the same
validation, so the transactions list, the app's main screen, returned 500
permanently, with no way left in the UI to delete the offending row.
The rules now live in one place, transfer_rule_violation(), used by both the
create schema and the update route, so the two paths can't drift apart
again. The update path validates the row as it would look after the patch
rather than only the fields sent, since switching type alone invalidates
fields that were left untouched.
The frontend already sends a complete payload on edit (explicit nulls for
category_id / transfer_account_id) and does these same checks client-side,
so this only closes the hole for direct API calls — no UI change needed.
Claude-Session: https://claude.ai/code/session_01UmuMDFrwdvQkce885VaaTh
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
from datetime import date as date_
|
|
from decimal import Decimal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
from app.core.text import capitalize_first_letter
|
|
from app.models.enums import TransactionType
|
|
from app.schemas.account import AccountRead
|
|
from app.schemas.category import CategoryRead
|
|
from app.schemas.tag import TagRead
|
|
|
|
|
|
def transfer_rule_violation(
|
|
*,
|
|
type: TransactionType,
|
|
account_id: int,
|
|
transfer_account_id: int | None,
|
|
category_id: int | None,
|
|
) -> str | None:
|
|
"""The transfer invariants in one place, returning the problem as text or
|
|
None if the combination is valid.
|
|
|
|
Both halves of the write path go through this: the create schema below
|
|
(as a pydantic validator) and the PATCH route (see
|
|
routes/transactions.py). Enforcing it on create only used to let an edit
|
|
produce a row that create would have rejected — most damagingly a
|
|
transfer with no transfer_account_id, which TransactionRead itself can't
|
|
serialize, so the row broke every later read of the transactions list.
|
|
"""
|
|
if type == TransactionType.TRANSFER:
|
|
if not transfer_account_id:
|
|
return "transfer_account_id is required for transfer transactions"
|
|
if transfer_account_id == account_id:
|
|
return "transfer_account_id must differ from account_id"
|
|
if category_id:
|
|
return "category_id is not valid for transfer transactions"
|
|
elif transfer_account_id:
|
|
return "transfer_account_id is only valid for transfer transactions"
|
|
return None
|
|
|
|
|
|
class TransactionBase(BaseModel):
|
|
account_id: int
|
|
category_id: int | None = None
|
|
transfer_account_id: int | None = None
|
|
type: TransactionType
|
|
amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2)
|
|
description: str = Field(min_length=1, max_length=255)
|
|
merchant: str | None = Field(default=None, max_length=150)
|
|
notes: str | None = None
|
|
date: date_
|
|
|
|
# Auto-capitalizes "траты на продукты" -> "Траты на продукты" so mixed
|
|
# casing from quick manual entry doesn't need fixing by hand later.
|
|
@field_validator("description")
|
|
@classmethod
|
|
def _capitalize_description(cls, value: str) -> str:
|
|
return capitalize_first_letter(value)
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_type_specific_fields(self) -> "TransactionBase":
|
|
violation = transfer_rule_violation(
|
|
type=self.type,
|
|
account_id=self.account_id,
|
|
transfer_account_id=self.transfer_account_id,
|
|
category_id=self.category_id,
|
|
)
|
|
if violation:
|
|
raise ValueError(violation)
|
|
return self
|
|
|
|
|
|
class TransactionCreate(TransactionBase):
|
|
tag_ids: list[int] = Field(default_factory=list)
|
|
|
|
|
|
class TransactionUpdate(BaseModel):
|
|
account_id: int | None = None
|
|
category_id: int | None = None
|
|
transfer_account_id: int | None = None
|
|
type: TransactionType | None = None
|
|
amount: Decimal | None = Field(default=None, gt=0, max_digits=14, decimal_places=2)
|
|
description: str | None = Field(default=None, min_length=1, max_length=255)
|
|
merchant: str | None = Field(default=None, max_length=150)
|
|
notes: str | None = None
|
|
date: date_ | None = None
|
|
# Omitted -> tags untouched; sent (even as []) -> replaces the full tag set.
|
|
tag_ids: list[int] | None = None
|
|
|
|
@field_validator("description")
|
|
@classmethod
|
|
def _capitalize_description(cls, value: str | None) -> str | None:
|
|
return capitalize_first_letter(value) if value is not None else None
|
|
|
|
|
|
class TransactionRead(TransactionBase):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
account: AccountRead
|
|
category: CategoryRead | None = None
|
|
tags: list[TagRead] = Field(default_factory=list)
|
|
|
|
|
|
class TransactionPage(BaseModel):
|
|
items: list[TransactionRead]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
|
|
|
|
class TransactionBulkCreate(BaseModel):
|
|
"""CSV import (see routes/transactions.py's /bulk): the frontend parses
|
|
the file and maps its columns client-side, then sends already-shaped
|
|
rows here. All-or-nothing — same failure semantics as backup restore, so
|
|
a single bad row never leaves a partial import behind."""
|
|
|
|
items: list[TransactionCreate] = Field(min_length=1, max_length=5000)
|
|
|
|
|
|
class TransactionBulkCreateResult(BaseModel):
|
|
created: int
|