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 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.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()