mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 19:16:40 +07:00
Year was previously hardcoded to the current year on all three pages (useState with no setter) — only the month could be changed via MonthSelector, making it impossible to look at or compare older years even though transaction history goes back further than the current year. Adds GET /transactions/years (contiguous range from the earliest transaction through the current year) and a YearSelector component matching MonthSelector's style, wired into all three pages alongside a real year useState.
153 lines
6.8 KiB
Python
153 lines
6.8 KiB
Python
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()
|