"""Transaktionen: Ausgabe, Einnahme, interner Transfer. Owner-gescoped. Beim Speichern wird der eingefrorene EUR-Wert (``amount_eur_cents``) zum Buchungsdatum berechnet (Pivot für Budget/Dashboard). Ein manuell gesetzter Kurs (``fx_rate``) hat Vorrang vor dem EZB-Kurs. """ from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned from ..models import Account, Category, Transaction, TxnType, User from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate from ..services import buchung, currency from .accounts import base_currency router = APIRouter(prefix="/transactions", tags=["transactions"]) def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str]) -> TransactionOut: acc_ccy = ccy_by_account.get(txn.account_id, "EUR") if base == "EUR": amount_base = txn.amount_eur_cents else: conv = currency.from_eur_cents(db, txn.amount_eur_cents, base, txn.date) amount_base = conv if conv is not None else txn.amount_eur_cents return TransactionOut( id=txn.id, type=txn.type, date=txn.date, time=txn.time, account_id=txn.account_id, to_account_id=txn.to_account_id, account_currency=acc_ccy, amount_cents=txn.amount_cents, to_amount_cents=txn.to_amount_cents, fx_rate=buchung.fx(txn), amount_eur_cents=txn.amount_eur_cents, amount_base_cents=amount_base, category_id=txn.category_id, budget_treatment=txn.budget_treatment, budget_category_id=txn.budget_category_id, payee=txn.payee, description=txn.description, notes=txn.notes, ) def _currency_map(db: Session, user: User) -> dict[int, str]: rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all() return {row[0]: row[1] for row in rows} def _validate_category(db: Session, user: User, category_id: int | None) -> None: if category_id is not None: get_owned(db, Category, category_id, user) @router.get("/links") def transaction_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist. Getrennt vom Buchungs-Schema, damit dieses unverändert bleibt: Die Verknüpfung ist eine Schicht obendrauf, kein Feld der Buchung. """ from ..services import uebertraege return {str(k): v for k, v in uebertraege.linkliste(db, user).items()} @router.get("", response_model=list[TransactionOut]) def list_transactions( db: Session = Depends(get_db), user: User = Depends(get_current_user), account_id: int | None = None, category_id: int | None = None, # Zum Abarbeiten: nur Buchungen, die noch keine Kategorie haben. Überträge # bleiben aussen vor – sie ordnen kein Geld ein, sie verschieben es nur. uncategorized: bool = False, type: TxnType | None = None, date_from: date | None = None, date_to: date | None = None, # Großzügiges Limit: die Buchungsliste (DataTable) filtert/sortiert im Browser, # deshalb sollen standardmäßig ALLE Buchungen geladen werden, nicht nur 200. limit: int = Query(default=5000, le=50000), offset: int = 0, ): q = db.query(Transaction).filter(Transaction.owner_id == user.id) if account_id is not None: q = q.filter( (Transaction.account_id == account_id) | (Transaction.to_account_id == account_id) ) if category_id is not None: q = q.filter(Transaction.category_id == category_id) if uncategorized: q = q.filter(Transaction.category_id.is_(None), Transaction.type != TxnType.transfer) if type is not None: q = q.filter(Transaction.type == type) if date_from is not None: q = q.filter(Transaction.date >= date_from) if date_to is not None: q = q.filter(Transaction.date <= date_to) rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all() base = base_currency(db, user) ccy = _currency_map(db, user) return [to_out(db, t, base, ccy) for t in rows] @router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED) def create_transaction( payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): from_acc = get_owned(db, Account, payload.account_id, user) to_acc = None category_id = payload.category_id if payload.type == TxnType.transfer: if payload.to_account_id is None or payload.to_account_id == payload.account_id: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Transfer braucht ein anderes Zielkonto") to_acc = get_owned(db, Account, payload.to_account_id, user) category_id = None # Transfers werden nicht regulär kategorisiert else: if payload.to_account_id is not None: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Nur Transfers haben ein Zielkonto") _validate_category(db, user, category_id) _validate_category(db, user, payload.budget_category_id) txn = Transaction( owner_id=user.id, type=payload.type, date=payload.date, time=payload.time, account_id=from_acc.id, to_account_id=to_acc.id if to_acc else None, amount_cents=payload.amount_cents, to_amount_cents=payload.to_amount_cents, fx_rate=payload.fx_rate, category_id=category_id, budget_treatment=payload.budget_treatment, budget_category_id=payload.budget_category_id, payee=(payload.payee or None), description=(payload.description or None), notes=(payload.notes or None), ) buchung.setze_betraege(db, txn, from_acc, to_acc) db.add(txn) db.commit() db.refresh(txn) return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) @router.get("/{txn_id}", response_model=TransactionOut) def get_transaction( txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): txn = get_owned(db, Transaction, txn_id, user) return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) @router.patch("/{txn_id}", response_model=TransactionOut) def update_transaction( txn_id: int, payload: TransactionUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): txn = get_owned(db, Transaction, txn_id, user) data = payload.model_dump(exclude_unset=True) for key in ("type", "date", "time", "amount_cents", "to_amount_cents", "fx_rate", "budget_treatment", "payee", "description", "notes"): if key in data: setattr(txn, key, data[key]) if "account_id" in data and data["account_id"] is not None: get_owned(db, Account, data["account_id"], user) txn.account_id = data["account_id"] if "to_account_id" in data: if data["to_account_id"] is not None: get_owned(db, Account, data["to_account_id"], user) txn.to_account_id = data["to_account_id"] if "category_id" in data: _validate_category(db, user, data["category_id"]) txn.category_id = data["category_id"] if "budget_category_id" in data: _validate_category(db, user, data["budget_category_id"]) txn.budget_category_id = data["budget_category_id"] # Konsistenz je nach Typ + Neuberechnung der abgeleiteten Werte. from_acc = get_owned(db, Account, txn.account_id, user) to_acc = None if txn.type == TxnType.transfer: if txn.to_account_id is None or txn.to_account_id == txn.account_id: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Transfer braucht ein anderes Zielkonto") to_acc = get_owned(db, Account, txn.to_account_id, user) txn.category_id = None else: txn.to_account_id = None buchung.setze_betraege(db, txn, from_acc, to_acc) db.commit() db.refresh(txn) return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) @router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_transaction( txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): txn = get_owned(db, Transaction, txn_id, user) db.delete(txn) db.commit()