Die API kannte zwei Zustaende: angemeldet per Sitzungs-Cookie, oder eine enge Kurzbefehl-Marke, die nur erfassen darf. Eine native App passt in keinen davon - sie kann kein Cookie-Formular ausfuellen und soll das Passwort nicht auf dem Geraet halten. Neu ist die Reichweite "app": Sie gilt im Header X-Certain-Token wie eine Anmeldung, laeuft nicht nach 30 Tagen ab und laesst sich einzeln widerrufen. Die enge Marke bleibt eng - das ist der Punkt, an dem die Tests am genauesten hinsehen. Und eine App-Marke darf keine weitere ausstellen: Sonst stellte sich ein Dieb Ersatzschluessel aus und das Widerrufen liefe ins Leere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
9.6 KiB
Python
264 lines
9.6 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, require_cookie_session
|
||
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, uebertrag_buchen
|
||
from ..services.uebertrag_buchen import UebertragFehler
|
||
|
||
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(require_cookie_session),
|
||
):
|
||
"""Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen.
|
||
|
||
Bewusst ``require_cookie_session``: Eine App-Marke darf keine weitere
|
||
ausstellen. Sonst stellte ein Dieb sich Ersatzschlüssel aus und das
|
||
Widerrufen der gestohlenen Marke liefe ins Leere.
|
||
"""
|
||
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),
|
||
scope=payload.scope,
|
||
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 _uebertrags_fehler(e: UebertragFehler) -> HTTPException:
|
||
"""409, wenn sich der Fehler mit ``trotzdem`` übergehen lässt, sonst 400.
|
||
|
||
Der Unterschied ist der Oberfläche wichtig: 409 heisst nachfragen und noch
|
||
einmal senden, 400 heisst, es geht so nicht.
|
||
"""
|
||
return HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT if e.nachfragen
|
||
else status.HTTP_400_BAD_REQUEST,
|
||
detail=str(e),
|
||
)
|
||
|
||
|
||
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)
|
||
ziel = get_owned(db, Account, payload.to_account_id, user)
|
||
try:
|
||
uebertrag_buchen.aus_eingang(db, user, eintrag, ziel, payload.trotzdem)
|
||
except UebertragFehler as e:
|
||
db.rollback()
|
||
raise _uebertrags_fehler(e) from e
|
||
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)
|
||
# Bei einem Übertrag zeigen ZWEI Einträge auf dieselbe Buchung. Löscht man
|
||
# sie, bliebe die andere Seite als „gebucht" ohne Buchung stehen – der
|
||
# Fremdschlüssel setzt nur ihre Verweisspalte auf NULL.
|
||
geschwister = (
|
||
db.query(InboxItem)
|
||
.filter(InboxItem.owner_id == user.id,
|
||
InboxItem.transaction_id == eintrag.transaction_id)
|
||
.all()
|
||
if eintrag.transaction_id is not None else [eintrag]
|
||
)
|
||
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)
|
||
for e in geschwister:
|
||
e.status = InboxStatus.discarded
|
||
e.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]
|