Files
aurum/backend/app/api/routes/transactions.py
ZProger 19f5cfa429 feat: import transactions from a CSV bank statement
Fourth of the four ideas picked from the Maybe/Sure feature audit, and the
last one queued up. A 3-step wizard (account -> column mapping -> preview)
at /transactions/import, parsed entirely client-side (new lib/csv.ts —
delimiter auto-detect, 5 date formats, thousands/decimal-separator-aware
amounts) and submitted through a new all-or-nothing POST /transactions/bulk
endpoint, mirroring backup restore's validate-everything-before-touching-
anything approach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 02:44:30 +03:00

217 lines
9.6 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,
)
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"})
effective_type = updates.get("type", transaction.type)
effective_category_id = updates.get("category_id", transaction.category_id)
await _ensure_category_matches_type(session, effective_category_id, effective_type)
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()