245 lines
9.2 KiB
Python
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)))
|