mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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>
217 lines
9.6 KiB
Python
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()
|