Files
certain/backend/app/routers/transactions.py
T
GuidoandClaude Opus 4.8 1b7107b9bd feat: Phase 1 – Konten, Kategorien, Transaktionen, EZB-Währungsumrechnung
Backend:
- Modelle + Migration 0002: accounts, account_identifiers, category_groups,
  categories, transactions, exchange_rates (alle owner-gescoped).
- EZB-Kursdienst (90-Tage/Historie) + Backfill-Skript; Umrechnung EUR<->CHF zum
  Buchungsdatum mit lokalem Cache und manueller Übersteuerung; EUR-Pivot
  (amount_eur_cents) eingefroren.
- Kontostände + Netto-Vermögen (Umrechnung in Hauptwährung).
- Router: accounts (inkl. IBAN-Identifikatoren), categories (Gruppen+Kategorien),
  transactions (Ausgabe/Einnahme/Transfer, cross-currency), rates.
- Gruppierte Standard-Kategorien je Nutzer beim Anlegen.
- pytest-Suite: Währung, Salden, Cross-Currency-Transfer, Nutzer-Isolation.

Frontend:
- Seiten Konten, Kategorien, Buchungen; Icon-Chip, Geldbetrag mit Umrechnung,
  Icon-/Farbwähler, Modal; erweitertes SVG-Icon-Set (Konto-/Kategorie-Icons).
- Währungs-/Datumsformatierung (EUR de-DE, CHF de-CH), i18n DE/EN erweitert.
- Live-Umrechnungs-Vorschau im Transfer-Formular.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 18:59:24 +02:00

220 lines
8.3 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,
limit: int = Query(default=200, le=1000),
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()