mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
Initial public release
Aurum — a self-hosted personal finance operating system: cash flow, net worth tracking, budgets, goals, recurring payments, reports, proactive alerts, and a compound-interest returns calculator, all bilingual (RU/EN) and mobile-first. Ships as a three-container Docker Compose stack (Postgres + FastAPI + nginx-served React frontend). See README.md for setup and CONTRIBUTING.md for development.
This commit is contained in:
139
backend/app/api/routes/transactions.py
Normal file
139
backend/app/api/routes/transactions.py
Normal file
@@ -0,0 +1,139 @@
|
||||
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.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()
|
||||
Reference in New Issue
Block a user