Eine verknuepfte Buchung gehoert zu einem Uebertrag und zaehlt in keiner Auswertung. Trotzdem stand sie unter "Zu erledigen" und wurde in der Buchungsliste nach einer Kategorie gefragt – Arbeit, die nichts bewirkt, und ein falsches Signal: Sie sah aus, als fehle noch etwas. Der Filter fuer offene Buchungen laesst verknuepfte jetzt aus, und in der Buchungsliste steht bei ihnen ein Strich statt der Auswahl – so wie bei einem echten Uebertrag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
12 KiB
Python
310 lines
12 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 pydantic import BaseModel
|
||
from sqlalchemy import select, union
|
||
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,
|
||
TransferLink,
|
||
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 _herkunft(db: Session, user: User, txns: list[Transaction]) -> dict[int, str]:
|
||
"""Woher jede Buchung stammt.
|
||
|
||
Der Eingangs-Eintrag sagt es für alles, was über Bank, Mail oder Kurzbefehl
|
||
kam; sonst entscheidet der Import-Stapel: mit ihm aus einer Datei, ohne ihn
|
||
von Hand. Als Sammelabfrage, nicht je Zeile – die Liste geht bis 500.
|
||
"""
|
||
ids = [t.id for t in txns]
|
||
if not ids:
|
||
return {}
|
||
aus_eingang: dict[int, str] = {}
|
||
for txn_id, quelle in db.query(InboxItem.transaction_id, InboxItem.source).filter(
|
||
InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(ids)
|
||
):
|
||
# Bei einem Übertrag zeigen ZWEI Einträge auf dieselbe Buchung. Die
|
||
# Gegenseite hat Certain selbst angelegt; gemeint ist die echte Quelle.
|
||
if txn_id in aus_eingang and quelle.value == "transfer":
|
||
continue
|
||
aus_eingang[txn_id] = quelle.value
|
||
return {
|
||
t.id: aus_eingang.get(t.id, "csv" if t.import_batch_id is not None else "manuell")
|
||
for t in txns
|
||
}
|
||
|
||
|
||
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
|
||
herkunft: dict[int, str] | None = None) -> 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,
|
||
herkunft=(herkunft or {}).get(txn.id, "manuell"),
|
||
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:
|
||
# Verknüpfte Buchungen sind erledigt: Sie gehören zu einem Übertrag und
|
||
# zählen in keiner Auswertung. Sie hier nach einer Kategorie zu fragen,
|
||
# verlangt Arbeit, die nichts bewirkt.
|
||
verknuepft = union(
|
||
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
|
||
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
|
||
)
|
||
q = q.filter(Transaction.category_id.is_(None),
|
||
Transaction.type != TxnType.transfer,
|
||
Transaction.id.notin_(verknuepft))
|
||
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)
|
||
woher = _herkunft(db, user, rows)
|
||
return [to_out(db, t, base, ccy, woher) 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),
|
||
_herkunft(db, user, [txn]))
|
||
|
||
|
||
@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),
|
||
_herkunft(db, user, [txn]))
|
||
|
||
|
||
@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),
|
||
_herkunft(db, user, [txn]))
|
||
|
||
|
||
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),
|
||
_herkunft(db, user, [txn]))
|
||
|
||
|
||
@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()
|