Die Zeile im Eingang zeigte die Kategorie-Auswahl bedingungslos – auch bei einem Kontouebertrag, der gar keine bekommt. Die Oberflaeche konnte es auch nicht wissen: InboxItemOut hatte kein Feld, an dem sich ein Uebertrag erkennen laesst. Jetzt liefert der Eintrag transfer_account_id mit – das jeweils ANDERE Konto, aus seiner Sicht. Steht es, zeigt die Zeile '↔ Kontoname' statt der Auswahl. Ermittelt wird es mit einer Sammelabfrage, nicht je Zeile: Die Liste geht bis 200 Eintraege. Dazu die Luecke dahinter: set_category schrieb die Kategorie auch auf die Buchung und konnte sie so auf einem TxnType.transfer ablegen – genau das, was das Buchungsformular beim Anlegen verhindert. Bei einem Uebertrag lehnt der Endpunkt jetzt ab; bei allem anderen bleibt es, wie es war. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
9.3 KiB
Python
249 lines
9.3 KiB
Python
"""Eingang extern erfasster Zahlungen und die Gerätemarken dafür.
|
||
|
||
Der Eingang zeigt, was per Kurzbefehl (später auch per Mail) hereinkam. Die
|
||
Einträge bleiben nach dem Buchen bestehen: Der spätere CSV-Import gleicht
|
||
gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal verbucht wird.
|
||
"""
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, 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,
|
||
ApiToken,
|
||
InboxItem,
|
||
InboxStatus,
|
||
Transaction,
|
||
TxnType,
|
||
User,
|
||
)
|
||
from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut, InboxItemOut
|
||
from ..security import generate_session_token, hash_session_token
|
||
from ..services import eingang
|
||
|
||
router = APIRouter(prefix="/inbox", tags=["inbox"])
|
||
|
||
|
||
# --- Gerätemarken ------------------------------------------------------------
|
||
|
||
|
||
@router.get("/tokens", response_model=list[ApiTokenOut])
|
||
def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
return (
|
||
db.query(ApiToken)
|
||
.filter(ApiToken.owner_id == user.id)
|
||
.order_by(ApiToken.created_at.desc())
|
||
.all()
|
||
)
|
||
|
||
|
||
@router.post("/tokens", response_model=ApiTokenCreated, status_code=status.HTTP_201_CREATED)
|
||
def create_token(
|
||
payload: ApiTokenCreate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen."""
|
||
if payload.default_account_id is not None:
|
||
get_owned(db, Account, payload.default_account_id, user)
|
||
klartext = generate_session_token()
|
||
marke = ApiToken(
|
||
owner_id=user.id,
|
||
name=payload.name.strip(),
|
||
token_hash=hash_session_token(klartext),
|
||
default_account_id=payload.default_account_id,
|
||
)
|
||
db.add(marke)
|
||
db.commit()
|
||
db.refresh(marke)
|
||
return ApiTokenCreated(**ApiTokenOut.model_validate(marke).model_dump(), token=klartext)
|
||
|
||
|
||
@router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_token(token_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
marke = get_owned(db, ApiToken, token_id, user)
|
||
db.delete(marke)
|
||
db.commit()
|
||
|
||
|
||
# --- Eingang -----------------------------------------------------------------
|
||
|
||
|
||
def _gegenkonten(db: Session, user: User, eintraege: list[InboxItem]) -> dict[int, int]:
|
||
"""Je Eintrag das Gegenkonto, falls seine Buchung ein Übertrag ist.
|
||
|
||
Als Sammelabfrage und nicht je Zeile: Die Liste geht bis 200 Einträge.
|
||
"""
|
||
txn_ids = {e.transaction_id for e in eintraege if e.transaction_id is not None}
|
||
if not txn_ids:
|
||
return {}
|
||
buchungen = {
|
||
b.id: b
|
||
for b in db.query(Transaction).filter(
|
||
Transaction.owner_id == user.id,
|
||
Transaction.id.in_(txn_ids),
|
||
Transaction.type == TxnType.transfer,
|
||
)
|
||
}
|
||
gegen: dict[int, int] = {}
|
||
for e in eintraege:
|
||
buchung = buchungen.get(e.transaction_id)
|
||
if buchung is None:
|
||
continue
|
||
# Das jeweils ANDERE Konto, aus Sicht dieses Eintrags.
|
||
gegen[e.id] = (buchung.to_account_id if buchung.account_id == e.account_id
|
||
else buchung.account_id)
|
||
return gegen
|
||
|
||
|
||
def _out(db: Session, user: User, eintraege: list[InboxItem]) -> list[InboxItemOut]:
|
||
gegen = _gegenkonten(db, user, eintraege)
|
||
return [
|
||
InboxItemOut.model_validate(e).model_copy(
|
||
update={"transfer_account_id": gegen.get(e.id)})
|
||
for e in eintraege
|
||
]
|
||
|
||
|
||
@router.get("", response_model=list[InboxItemOut])
|
||
def list_inbox(
|
||
limit: int = 200,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
return _out(db, user, (
|
||
db.query(InboxItem)
|
||
.filter(InboxItem.owner_id == user.id, InboxItem.status != InboxStatus.discarded)
|
||
.order_by(InboxItem.date.desc(), InboxItem.id.desc())
|
||
.limit(min(limit, 1000))
|
||
.all()
|
||
))
|
||
|
||
|
||
@router.post("/{item_id}/book", response_model=InboxItemOut)
|
||
def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
eintrag = get_owned(db, InboxItem, item_id, user)
|
||
if eintrag.account_id is None:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Kein Konto zugeordnet")
|
||
if eingang.buche(db, user, eintrag) is None:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Lässt sich nicht buchen")
|
||
db.commit()
|
||
eingang.suche_uebertraege(db, user)
|
||
db.refresh(eintrag)
|
||
return _out(db, user, [eintrag])[0]
|
||
|
||
|
||
class InboxTransferIn(BaseModel):
|
||
to_account_id: int
|
||
# Nur setzen, wenn man es besser weiss als Certain – siehe unten.
|
||
trotzdem: bool = False
|
||
|
||
|
||
@router.post("/{item_id}/transfer", response_model=InboxItemOut)
|
||
def book_as_transfer(item_id: int, payload: InboxTransferIn,
|
||
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
"""Einen Vorschlag als Kontoübertrag buchen statt als Ausgabe oder Einnahme.
|
||
|
||
Es entsteht EINE Buchung, die das Geld vom einen Konto ins andere bewegt.
|
||
Sie zählt auf beiden Seiten im Saldo und in keiner Auswertung – ein
|
||
Übertrag ist keine Ausgabe.
|
||
"""
|
||
eintrag = get_owned(db, InboxItem, item_id, user)
|
||
if eintrag.account_id is None:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Kein Konto zugeordnet")
|
||
ziel = get_owned(db, Account, payload.to_account_id, user)
|
||
if ziel.id == eintrag.account_id:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Ein Übertrag braucht ein anderes Zielkonto")
|
||
|
||
# Der teuerste Fehler an dieser Stelle: Das Zielkonto hängt selbst an einer
|
||
# Bank, liefert die Gegenbuchung also von allein. Wer hier trotzdem eine
|
||
# anlegt, hat das Geld zweimal auf dem Zielkonto.
|
||
if not payload.trotzdem and eingang.ist_angebunden(db, user, ziel):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail=(f"'{ziel.name}' ist selbst an eine Bank angebunden und meldet "
|
||
f"den Zugang von allein. Buche das hier als Ausgabe – beide "
|
||
f"Seiten werden danach automatisch als Übertrag verbunden."),
|
||
)
|
||
|
||
if eingang.buche_als_uebertrag(db, user, eintrag, ziel) is None:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Lässt sich nicht buchen")
|
||
db.commit()
|
||
db.refresh(eintrag)
|
||
return _out(db, user, [eintrag])[0]
|
||
|
||
|
||
@router.post("/book-all")
|
||
def book_all(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
"""Alle offenen Vorschläge buchen, die ein Konto haben.
|
||
|
||
Unklare bleiben liegen: Bei ihnen fehlt etwas oder die Erkennung war
|
||
unsicher – die soll man ansehen, nicht im Stapel durchwinken.
|
||
"""
|
||
offen = (
|
||
db.query(InboxItem)
|
||
.filter(InboxItem.owner_id == user.id,
|
||
InboxItem.status == InboxStatus.pending,
|
||
InboxItem.account_id.isnot(None))
|
||
.all()
|
||
)
|
||
gebucht = sum(1 for e in offen if eingang.buche(db, user, e) is not None)
|
||
db.commit()
|
||
return {"gebucht": gebucht, "uebertraege": eingang.suche_uebertraege(db, user)}
|
||
|
||
|
||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def discard_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
"""Eintrag verwerfen – samt der Buchung, die daraus entstanden ist.
|
||
|
||
Der Eintrag selbst bleibt als ``discarded`` stehen. Sonst wäre die Zahlung
|
||
beim nächsten Auszug wieder neu, obwohl der Nutzer sie bewusst weggeworfen
|
||
hat; so kann der Abgleich sie weiterhin erkennen.
|
||
"""
|
||
eintrag = get_owned(db, InboxItem, item_id, user)
|
||
if eintrag.transaction_id is not None:
|
||
txn = db.get(Transaction, eintrag.transaction_id)
|
||
if txn is not None and txn.owner_id == user.id:
|
||
db.delete(txn)
|
||
eintrag.status = InboxStatus.discarded
|
||
eintrag.transaction_id = None
|
||
db.commit()
|
||
|
||
|
||
@router.post("/{item_id}/category/{category_id}", response_model=InboxItemOut)
|
||
def set_category(
|
||
item_id: int,
|
||
category_id: int,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""Kategorie setzen – auf dem Eintrag UND auf der erzeugten Buchung."""
|
||
from ..models import Category
|
||
|
||
eintrag = get_owned(db, InboxItem, item_id, user)
|
||
kategorie = get_owned(db, Category, category_id, user)
|
||
txn = (db.get(Transaction, eintrag.transaction_id)
|
||
if eintrag.transaction_id is not None else None)
|
||
if txn is not None and txn.owner_id == user.id:
|
||
# Ein Übertrag ist keine Ausgabe. Ohne diese Sperre landete über den
|
||
# Eingang eine Kategorie auf einer Übertrags-Buchung – genau das, was
|
||
# das Buchungsformular beim Anlegen verhindert.
|
||
if txn.type == TxnType.transfer:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Ein Kontoübertrag bekommt keine Kategorie.",
|
||
)
|
||
txn.category_id = kategorie.id
|
||
eintrag.category_id = kategorie.id
|
||
db.commit()
|
||
db.refresh(eintrag)
|
||
return _out(db, user, [eintrag])[0]
|