Files
finance-app/backend/app/routers/transactions.py

245 lines
9.2 KiB
Python

import math
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import Account, Budget, Transaction, TransactionType, User
from ..schemas.transaction import (
AccountRef,
BudgetRef,
TransactionResponse,
TransactionUpdate,
)
router = APIRouter(prefix="/api/transactions", tags=["transactions"])
def _serialize(tx: Transaction, account: Optional[Account] = None, budget: Optional[Budget] = None) -> dict:
data = TransactionResponse.model_validate(tx)
if account:
data.account = AccountRef(name=account.name, type=account.type)
if budget:
data.budget = BudgetRef(id=budget.id, name=budget.name, color=budget.color)
return data.model_dump(by_alias=True)
@router.get("")
def list_transactions(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
account_id: Optional[str] = Query(default=None, alias="accountId"),
date_from: Optional[str] = Query(default=None, alias="dateFrom"),
date_to: Optional[str] = Query(default=None, alias="dateTo"),
type: Optional[str] = Query(default=None),
search: Optional[str] = Query(default=None),
budget_id: Optional[str] = Query(default=None, alias="budgetId"),
page: int = Query(default=1, ge=1),
limit: int = Query(default=50, ge=1, le=100),
):
stmt = (
select(Transaction)
.join(Account, Transaction.account_id == Account.id)
.where(Account.user_id == current_user.id)
)
if account_id:
stmt = stmt.where(Transaction.account_id == account_id)
if type:
try:
stmt = stmt.where(Transaction.type == TransactionType(type))
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid type: {type}")
if budget_id is not None:
stmt = stmt.where(Transaction.budget_id == (None if budget_id == "" else budget_id))
if search:
stmt = stmt.where(Transaction.description.ilike(f"%{search}%"))
if date_from:
stmt = stmt.where(Transaction.date >= datetime.fromisoformat(date_from))
if date_to:
stmt = stmt.where(Transaction.date <= datetime.fromisoformat(date_to + "T23:59:59.999"))
count_stmt = select(Transaction.id).join(Account, Transaction.account_id == Account.id)
if account_id:
count_stmt = count_stmt.where(Transaction.account_id == account_id)
if type:
count_stmt = count_stmt.where(Transaction.type == TransactionType(type))
if budget_id is not None:
count_stmt = count_stmt.where(Transaction.budget_id == (None if budget_id == "" else budget_id))
if search:
count_stmt = count_stmt.where(Transaction.description.ilike(f"%{search}%"))
if date_from:
count_stmt = count_stmt.where(Transaction.date >= datetime.fromisoformat(date_from))
if date_to:
count_stmt = count_stmt.where(Transaction.date <= datetime.fromisoformat(date_to + "T23:59:59.999"))
count_stmt = count_stmt.where(Account.user_id == current_user.id)
total = len(session.exec(count_stmt).all())
stmt = stmt.order_by(Transaction.date.desc()).offset((page - 1) * limit).limit(limit)
transactions = session.exec(stmt).all()
account_ids = {tx.account_id for tx in transactions}
budget_ids = {tx.budget_id for tx in transactions if tx.budget_id}
accounts = {a.id: a for a in session.exec(select(Account).where(Account.id.in_(account_ids))).all()}
budgets = {b.id: b for b in session.exec(select(Budget).where(Budget.id.in_(budget_ids))).all()}
rows = [_serialize(tx, accounts.get(tx.account_id), budgets.get(tx.budget_id)) for tx in transactions]
result = {
"transactions": rows,
"total": total,
"page": page,
"limit": limit,
"totalPages": math.ceil(total / limit),
}
return JSONResponse(content=jsonable_encoder(result))
@router.post("/bulk")
def bulk_transactions(
body: dict,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
action = body.get("action")
ids: list[str] = body.get("ids", [])
if not ids or action not in ("delete", "assignBudget", "addNotes", "markTransfer"):
raise HTTPException(status_code=400, detail="Invalid bulk request")
# Verify ownership
owned = session.exec(
select(Transaction).join(Account, Transaction.account_id == Account.id).where(
Transaction.id.in_(ids), Account.user_id == current_user.id
)
).all()
if len(owned) != len(ids):
raise HTTPException(status_code=404, detail="One or more transactions not found")
if action == "delete":
affected_accounts = {tx.account_id for tx in owned}
affected_months = {(tx.account_id, tx.date.year, tx.date.month) for tx in owned}
for tx in owned:
session.delete(tx)
session.commit()
for account_id in affected_accounts:
_recompute_balance(session, account_id)
for account_id, year, month in affected_months:
_recompute_snapshot(session, account_id, year, month)
session.commit()
return JSONResponse(content={"deleted": len(ids)})
if action == "assignBudget":
budget_id = body.get("budgetId")
if budget_id:
budget = session.exec(
select(Budget).where(Budget.id == budget_id, Budget.user_id == current_user.id)
).first()
if not budget:
raise HTTPException(status_code=404, detail="Budget not found")
for tx in owned:
tx.budget_id = budget_id
tx.updated_at = datetime.utcnow()
session.add(tx)
session.commit()
return JSONResponse(content={"updated": len(ids)})
if action == "addNotes":
notes = body.get("notes") or None
for tx in owned:
tx.notes = notes
tx.updated_at = datetime.utcnow()
session.add(tx)
session.commit()
return JSONResponse(content={"updated": len(ids)})
# markTransfer
for tx in owned:
tx.type = TransactionType.TRANSFER
tx.updated_at = datetime.utcnow()
session.add(tx)
session.commit()
return JSONResponse(content={"updated": len(ids)})
def _recompute_balance(session, account_id: str) -> None:
from sqlalchemy import text as sa_text
result = session.exec(
sa_text(
'SELECT COALESCE(SUM(CASE WHEN type = \'CREDIT\' THEN "amountCents" ELSE -"amountCents" END), 0)::bigint '
'FROM "Transaction" WHERE "accountId" = :aid AND type != \'TRANSFER\''
).bindparams(aid=account_id)
).first()
account = session.get(Account, account_id)
if account:
account.current_balance_cents = int(result[0]) if result else 0
account.updated_at = datetime.utcnow()
session.add(account)
def _recompute_snapshot(session, account_id: str, year: int, month: int) -> None:
from sqlalchemy import text as sa_text
from ..models import BalanceSnapshot
end = (datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1))
result = session.exec(
sa_text(
'SELECT COALESCE(SUM(CASE WHEN type = \'CREDIT\' THEN "amountCents" ELSE -"amountCents" END), 0)::bigint '
'FROM "Transaction" WHERE "accountId" = :aid AND date < :end AND type != \'TRANSFER\''
).bindparams(aid=account_id, end=end)
).first()
balance = int(result[0]) if result else 0
existing = session.exec(
select(BalanceSnapshot).where(
BalanceSnapshot.account_id == account_id,
BalanceSnapshot.year == year,
BalanceSnapshot.month == month,
)
).first()
if balance == 0:
if existing:
session.delete(existing)
elif existing:
existing.balance_cents = balance
existing.computed_at = datetime.utcnow()
session.add(existing)
else:
session.add(BalanceSnapshot(account_id=account_id, year=year, month=month, balance_cents=balance))
@router.patch("/{transaction_id}")
def update_transaction(
transaction_id: str,
body: TransactionUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
tx = session.exec(
select(Transaction)
.join(Account, Transaction.account_id == Account.id)
.where(Transaction.id == transaction_id, Account.user_id == current_user.id)
).first()
if not tx:
raise HTTPException(status_code=404, detail="Not found")
updates = body.model_dump(exclude_unset=True, by_alias=False)
if "budget_id" in updates and updates["budget_id"]:
budget = session.exec(
select(Budget).where(Budget.id == updates["budget_id"], Budget.user_id == current_user.id)
).first()
if not budget:
raise HTTPException(status_code=404, detail="Budget not found")
for field, value in updates.items():
setattr(tx, field, value)
tx.updated_at = datetime.utcnow()
session.add(tx)
session.commit()
session.refresh(tx)
return JSONResponse(content=jsonable_encoder(_serialize(tx)))