Die Liste lud nur die 200 neuesten Buchungen, wodurch ältere Monate fehlten (bei 254 importierten Zeilen reichte die Ansicht nur bis Juni zurück). Da die Tabelle im Browser filtert und sortiert, werden nun standardmäßig alle Buchungen geladen. Verifiziert am echten Auszug: 254 von 254, ab 05.01. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
222 lines
8.4 KiB
Python
222 lines
8.4 KiB
Python
"""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 currency
|
|
from .accounts import base_currency
|
|
|
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
|
|
|
|
|
def _fx(txn: Transaction) -> float | None:
|
|
return float(txn.fx_rate) if txn.fx_rate is not None else None
|
|
|
|
|
|
def _recompute(db: Session, txn: Transaction, from_acc: Account, to_acc: Account | None) -> None:
|
|
"""Setzt amount_eur_cents und (bei Transfers) to_amount_cents neu."""
|
|
txn.amount_eur_cents = currency.compute_amount_eur_cents(
|
|
db, txn.amount_cents, from_acc.currency, txn.date, _fx(txn)
|
|
)
|
|
if txn.type == TxnType.transfer and to_acc is not None:
|
|
if txn.to_amount_cents is None:
|
|
if to_acc.currency == from_acc.currency:
|
|
txn.to_amount_cents = txn.amount_cents
|
|
else:
|
|
conv = currency.convert(
|
|
db, txn.amount_cents, from_acc.currency, to_acc.currency, txn.date, _fx(txn)
|
|
)
|
|
txn.to_amount_cents = conv if conv is not None else txn.amount_cents
|
|
else:
|
|
txn.to_amount_cents = None
|
|
|
|
|
|
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=_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("", 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,
|
|
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 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),
|
|
)
|
|
_recompute(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
|
|
_recompute(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()
|