from datetime import date as date_ from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, 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.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)) _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, type: TransactionType | None = None, 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 type is not None: stmt = stmt.where(Transaction.type == type) count_stmt = count_stmt.where(Transaction.type == type) 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) transaction = Transaction(**payload.model_dump()) 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: transaction = await session.get(Transaction, transaction_id) if transaction is None: raise HTTPException(status_code=404, detail="Transaction not found") updates = payload.model_dump(exclude_unset=True) 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) 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()