Files
aurum/backend/app/api/routes/transactions.py
technosexual 949d2df37f fix: enforce transfer invariants when updating a transaction (#3)
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>
2026-08-30 02:42:37 +03:00

231 lines
10 KiB
Python

from datetime import date as date_
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_session
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.tag import Tag
from app.models.transaction import Transaction
from app.schemas.transaction import (
TransactionBulkCreate,
TransactionBulkCreateResult,
TransactionCreate,
TransactionPage,
TransactionRead,
TransactionUpdate,
transfer_rule_violation,
)
router = APIRouter(prefix="/transactions", tags=["transactions"])
_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category), selectinload(Transaction.tags))
async def _resolve_tags(session: AsyncSession, tag_ids: list[int]) -> list[Tag]:
if not tag_ids:
return []
result = await session.execute(select(Tag).where(Tag.id.in_(tag_ids)))
tags = list(result.scalars().all())
missing = set(tag_ids) - {tag.id for tag in tags}
if missing:
raise HTTPException(status_code=400, detail=f"Unknown tag id(s): {sorted(missing)}")
return tags
_TYPE_TO_CATEGORY_KIND = {
TransactionType.INCOME: CategoryKind.INCOME,
TransactionType.EXPENSE: CategoryKind.EXPENSE,
}
async def _ensure_category_matches_type(
session: AsyncSession, category_id: int | None, transaction_type: TransactionType
) -> None:
"""A category picked for an income transaction must itself be an income
category (and likewise for expense) — otherwise the dashboard's spending
breakdown, which only joins EXPENSE-typed rows, would silently misclassify
the entry."""
if category_id is None:
return
expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type)
category = await session.get(Category, category_id)
if category is None:
raise HTTPException(status_code=400, detail="Category not found")
if expected_kind is not None and category.kind != expected_kind:
raise HTTPException(
status_code=400,
detail=f"Category '{category.name}' is a {category.kind.value} category and cannot be used for a {transaction_type.value} transaction",
)
@router.get("", response_model=TransactionPage)
async def list_transactions(
year: int | None = Query(default=None, ge=2000, le=2100),
month: int | None = Query(default=None, ge=1, le=12),
start_date: date_ | None = Query(default=None),
end_date: date_ | None = Query(default=None),
account_id: int | None = None,
category_id: int | None = None,
tag_id: int | None = None,
type: TransactionType | None = None,
search: str | None = Query(default=None, min_length=1, max_length=255),
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
session: AsyncSession = Depends(get_session),
) -> TransactionPage:
stmt = select(Transaction).options(*_EAGER)
count_stmt = select(func.count()).select_from(Transaction)
if year is not None:
stmt = stmt.where(func.extract("year", Transaction.date) == year)
count_stmt = count_stmt.where(func.extract("year", Transaction.date) == year)
if month is not None:
stmt = stmt.where(func.extract("month", Transaction.date) == month)
count_stmt = count_stmt.where(func.extract("month", Transaction.date) == month)
if start_date is not None:
stmt = stmt.where(Transaction.date >= start_date)
count_stmt = count_stmt.where(Transaction.date >= start_date)
if end_date is not None:
stmt = stmt.where(Transaction.date <= end_date)
count_stmt = count_stmt.where(Transaction.date <= end_date)
if account_id is not None:
stmt = stmt.where(Transaction.account_id == account_id)
count_stmt = count_stmt.where(Transaction.account_id == account_id)
if category_id is not None:
stmt = stmt.where(Transaction.category_id == category_id)
count_stmt = count_stmt.where(Transaction.category_id == category_id)
if tag_id is not None:
stmt = stmt.where(Transaction.tags.any(Tag.id == tag_id))
count_stmt = count_stmt.where(Transaction.tags.any(Tag.id == tag_id))
if type is not None:
stmt = stmt.where(Transaction.type == type)
count_stmt = count_stmt.where(Transaction.type == type)
if search is not None:
# Lets the user find a transaction from any period by keyword (e.g. an
# item bought months ago) without knowing which month to look in first —
# matches description, merchant, and notes so any of those fields can surface it.
pattern = f"%{search.strip()}%"
search_clause = or_(
Transaction.description.ilike(pattern),
Transaction.merchant.ilike(pattern),
Transaction.notes.ilike(pattern),
)
stmt = stmt.where(search_clause)
count_stmt = count_stmt.where(search_clause)
total = (await session.execute(count_stmt)).scalar_one()
# id as a tiebreaker keeps pagination stable when many rows share a date/amount.
if sort == "amount_desc":
stmt = stmt.order_by(Transaction.amount.desc(), Transaction.id.desc())
elif sort == "amount_asc":
stmt = stmt.order_by(Transaction.amount.asc(), Transaction.id.desc())
else:
stmt = stmt.order_by(Transaction.date.desc(), Transaction.id.desc())
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await session.execute(stmt)
items = list(result.scalars().all())
return TransactionPage(items=items, total=total, page=page, page_size=page_size)
@router.get("/years", response_model=list[int])
async def list_transaction_years(session: AsyncSession = Depends(get_session)) -> list[int]:
"""Full range of years to offer in the year picker — from the earliest
transaction through the current year, so a gap year with no activity
still shows up (as zero) instead of silently disappearing from the UI."""
bounds = await session.execute(select(func.min(Transaction.date), func.max(Transaction.date)))
min_date, max_date = bounds.one()
current_year = date_.today().year
if min_date is None:
return [current_year]
return list(range(min_date.year, max(max_date.year, current_year) + 1))
@router.post("", response_model=TransactionRead, status_code=201)
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
await _ensure_category_matches_type(session, payload.category_id, payload.type)
fields = payload.model_dump(exclude={"tag_ids"})
transaction = Transaction(**fields)
transaction.tags = await _resolve_tags(session, payload.tag_ids)
session.add(transaction)
await session.commit()
refreshed = await session.execute(
select(Transaction).options(*_EAGER).where(Transaction.id == transaction.id)
)
return refreshed.scalar_one()
@router.post("/bulk", response_model=TransactionBulkCreateResult, status_code=201)
async def bulk_create_transactions(
payload: TransactionBulkCreate, session: AsyncSession = Depends(get_session)
) -> TransactionBulkCreateResult:
"""CSV import lands here — see schemas.TransactionBulkCreate. All rows
are validated before any is added, so a bad row 400s the whole request
instead of leaving a half-imported statement behind."""
for item in payload.items:
await _ensure_category_matches_type(session, item.category_id, item.type)
transactions = []
for item in payload.items:
transaction = Transaction(**item.model_dump(exclude={"tag_ids"}))
transaction.tags = await _resolve_tags(session, item.tag_ids)
transactions.append(transaction)
session.add_all(transactions)
await session.commit()
return TransactionBulkCreateResult(created=len(transactions))
@router.patch("/{transaction_id}", response_model=TransactionRead)
async def update_transaction(
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
) -> Transaction:
# Eager-loads tags — assigning transaction.tags below would otherwise lazy
# -load the current collection first to diff against, which async
# SQLAlchemy can't do outside an explicit await (MissingGreenlet).
transaction = await session.get(Transaction, transaction_id, options=[selectinload(Transaction.tags)])
if transaction is None:
raise HTTPException(status_code=404, detail="Transaction not found")
updates = payload.model_dump(exclude_unset=True, exclude={"tag_ids"})
# Checks run against the row as it would look after the patch, not just
# the fields sent: switching type alone can invalidate fields left
# untouched.
effective_type = updates.get("type", transaction.type)
effective_category_id = updates.get("category_id", transaction.category_id)
effective_account_id = updates.get("account_id", transaction.account_id)
effective_transfer_account_id = updates.get("transfer_account_id", transaction.transfer_account_id)
await _ensure_category_matches_type(session, effective_category_id, effective_type)
violation = transfer_rule_violation(
type=effective_type,
account_id=effective_account_id,
transfer_account_id=effective_transfer_account_id,
category_id=effective_category_id,
)
if violation:
raise HTTPException(status_code=400, detail=violation)
for field, value in updates.items():
setattr(transaction, field, value)
if payload.tag_ids is not None:
transaction.tags = await _resolve_tags(session, payload.tag_ids)
await session.commit()
refreshed = await session.execute(
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)
)
return refreshed.scalar_one()
@router.delete("/{transaction_id}", status_code=204)
async def delete_transaction(transaction_id: int, session: AsyncSession = Depends(get_session)) -> None:
transaction = await session.get(Transaction, transaction_id)
if transaction is None:
raise HTTPException(status_code=404, detail="Transaction not found")
await session.delete(transaction)
await session.commit()