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>
220 lines
8.3 KiB
Python
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()
|