woher sie kommt Die Meldung nach einem Abruf zaehlte geprueft, neu und unklar – aber nicht "schon bekannt". Wurden alle Treffer beim vorigen Abruf schon uebernommen, stand da "0 neu", und es sah aus, als waere nichts passiert, obwohl die Eintraege im Eingang liegen. Die Zahl steht jetzt dabei. Und die uebergangenen Mails wurden aufgelistet, die unklaren nicht: nur eine Zahl, ohne zu sagen, welche Mail gemeint ist oder was ihr fehlt. Jetzt stehen sie mit Grund da – kein Konto zugeordnet, kein Betrag erkannt, Richtung unklar, Zahlung noch nicht ausgefuehrt. Ausserdem traegt jede Buchung jetzt ihre Herkunft: Bank, Mail, Kurzbefehl, Datei-Import oder von Hand. In der Buchungsliste steht sie als kleines Kennzeichen unter dem Text. Ermittelt wird sie mit einer Sammelabfrage, nicht je Zeile. Bei einem Uebertrag zeigen zwei Eingangs-Eintraege auf dieselbe Buchung; die Gegenseite hat Certain selbst angelegt, gemeint ist die echte Quelle. Genau dafuer gibt es einen Test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
12 KiB
Python
299 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.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 _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:
|
||
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)
|
||
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()
|