Files
certain/backend/app/routers/transactions.py
T
GuidoandClaude Opus 5 6cd1c100c0 fix: Uebertrag pruefen, ob die Gegenseite schon gebucht ist
Ein Franken-in-Euro-Tausch stand zweimal da: die CHF-Seite als Uebertrag nach
Revolut Euro, die EUR-Seite weiterhin als eigene Einnahme. Revolut Euro war
damit doppelt gutgeschrieben. Drei Fehler auf einmal.

Erstens prueft "zum Uebertrag machen" jetzt, ob die Buchung bereits mit ihrer
Gegenseite VERBUNDEN ist. Dann sind beide Seiten gebucht und zaehlen schon in
keiner Auswertung; ein Uebertrag bewegt das Geld auf dem Zielkonto ein zweites
Mal. Das laesst sich auch nicht mit "trotzdem" wegdruecken – es ist nicht
Ansichtssache.

Zweitens wird auf dem Zielkonto nach einer passenden gebuchten Gegenseite
gesehen, auch ohne Verknuepfung: gleiche Tage, passender Betrag, ueber
Waehrungen hinweg mit Spielraum. Findet sich eine, kommt die Warnung samt dem
richtigen Weg – die beiden verbinden statt einen Uebertrag anzulegen. Der
Treffer ist unscharf, deshalb hier mit "trotzdem" uebergehbar.

Drittens der beobachtete Punkt: Der nachtraegliche Weg reichte den
Eingangs-Eintrag nicht durch und rechnete deshalb IMMER mit dem EZB-Kurs, auch
wenn die Bank ihren eigenen genannt hatte. Jetzt wird er nachgeschlagen, und
der Kurs der Bank landet an der Buchung.

Und Loeschen raeumt die Eingangs-Eintraege mit. Der Fremdschluessel setzt sonst
nur ihre Verweisspalte auf NULL, und sie blieben als "gebucht" ohne Buchung
stehen – bei einem Uebertrag sind das zwei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:22:13 +02:00

267 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import (
Account,
Category,
InboxItem,
InboxStatus,
Transaction,
TxnType,
User,
)
from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate
from ..services import buchung, currency, uebertrag_buchen
from ..services.uebertrag_buchen import UebertragFehler
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,
import_batch_id=txn.import_batch_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))
class TransactionTransferIn(BaseModel):
to_account_id: int
# Nur setzen, wenn man es besser weiss als Certain – siehe uebertrag_buchen.
trotzdem: bool = False
@router.post("/{txn_id}/transfer", response_model=TransactionOut)
def make_transfer(txn_id: int, payload: TransactionTransferIn,
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Eine gebuchte Ausgabe oder Einnahme zum Kontoübertrag machen.
Nicht über PATCH: Der dreht die Richtung bei einer Einnahme nicht um und
legt keine Gegenseite auf dem Zielkonto an. Beides gehört zusammen, deshalb
läuft es durch denselben Dienst wie der Weg aus dem Eingang.
"""
txn = get_owned(db, Transaction, txn_id, user)
ziel = get_owned(db, Account, payload.to_account_id, user)
try:
uebertrag_buchen.aus_buchung(db, user, txn, ziel, payload.trotzdem)
except UebertragFehler as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT if e.nachfragen
else status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
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)
# Eingangs-Eintraege, die auf diese Buchung zeigen, mit verwerfen. Der
# Fremdschluessel setzt sonst nur ihre Verweisspalte auf NULL, und sie
# blieben als "gebucht" ohne Buchung stehen – bei einem Uebertrag sind das
# zwei, die Quelle und die Gegenseite.
for eintrag in db.query(InboxItem).filter(InboxItem.owner_id == user.id,
InboxItem.transaction_id == txn.id):
eintrag.status = InboxStatus.discarded
eintrag.transaction_id = None
db.delete(txn)
db.commit()