"""Bankkonten über PSD2 verbinden und abrufen.""" import logging from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned from ..models import Account, BankAccountLink, BankApp, BankConnection, User from ..schemas import ( BankAccountLinkOut, BankAppIn, BankAppOut, BankConnectionOut, BankConnectStart, ) from ..services import bank_sync, geheimnis from ..services.bank_api import BankFehler log = logging.getLogger(__name__) router = APIRouter(prefix="/bank", tags=["bank"]) def _fehler(e: BankFehler) -> HTTPException: return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) # --- Die Anwendung dieser Installation --------------------------------------- @router.get("/app", response_model=BankAppOut | None) def get_app(db: Session = Depends(get_db), user: User = Depends(get_current_user)): return db.query(BankApp).filter(BankApp.owner_id == user.id).first() @router.put("/app", response_model=BankAppOut) def set_app(payload: BankAppIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Application ID, Schlüsseldatei und Rückleitungs-Adresse hinterlegen.""" app = db.query(BankApp).filter(BankApp.owner_id == user.id).first() if app is None: app = BankApp(owner_id=user.id) db.add(app) app.application_id = payload.application_id.strip() app.private_key_enc = geheimnis.verschluesseln(payload.private_key) app.base_url = payload.base_url db.commit() db.refresh(app) return app @router.delete("/app", status_code=status.HTTP_204_NO_CONTENT) def delete_app(db: Session = Depends(get_db), user: User = Depends(get_current_user)): app = db.query(BankApp).filter(BankApp.owner_id == user.id).first() if app is not None: db.delete(app) db.commit() @router.get("/banks") def list_banks(country: str | None = None, search: str | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Banken suchen – in einem Land oder über alle hinweg. Ohne ``country`` wird überall gesucht. Das braucht es, weil die IBAN nicht verrät, wo eine Bank sitzt: Revolut vergibt deutsche IBANs aus Litauen. """ if not country and not search: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte ein Land wählen oder einen Suchbegriff eingeben.", ) try: return bank_sync.banken(db, user, country, search) except BankFehler as e: raise _fehler(e) from e # --- Verbindungen ------------------------------------------------------------ @router.get("/connections", response_model=list[BankConnectionOut]) def list_connections(db: Session = Depends(get_db), user: User = Depends(get_current_user)): verbindungen = ( db.query(BankConnection) .filter(BankConnection.owner_id == user.id, BankConnection.session_id.isnot(None)) .order_by(BankConnection.id) .all() ) out = [] for v in verbindungen: eintrag = BankConnectionOut.model_validate(v) eintrag.expires_soon = bank_sync.laeuft_bald_ab(v) out.append(eintrag) return out @router.post("/connect") def connect(payload: BankConnectStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Autorisierung starten. Die Antwort enthält die Adresse der Bank.""" try: return bank_sync.starte_verbindung( db, user, payload.aspsp_name, payload.country, payload.psu_type) except BankFehler as e: raise _fehler(e) from e @router.post("/callback") def callback(state: str, code: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Der Code von der Rückleitung wird zur Sitzung samt Kontoliste.""" try: return bank_sync.schliesse_verbindung(db, user, state, code) except BankFehler as e: raise _fehler(e) from e @router.post("/connections/{connection_id}/sync") def sync(connection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: verbindung = get_owned(db, BankConnection, connection_id, user) try: return bank_sync.hole_umsaetze(db, user, verbindung) except BankFehler as e: verbindung.last_error = str(e)[:500] db.commit() raise _fehler(e) from e @router.post("/links/{link_id}/sync") def sync_link(link_id: int, tage: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Nur die Umsätze DIESES Kontos holen. Ein Abruf bucht. Wer ein einzelnes Konto abruft, will nicht, dass nebenbei auf einem zweiten Buchungen entstehen. """ verknuepfung = get_owned(db, BankAccountLink, link_id, user) verbindung = get_owned(db, BankConnection, verknuepfung.connection_id, user) try: if tage is not None and not 1 <= tage <= 365: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Der Zeitraum muss zwischen 1 und 365 Tagen liegen.") return bank_sync.hole_umsaetze(db, user, verbindung, nur_link_id=verknuepfung.id, tage=tage) except BankFehler as e: verbindung.last_error = str(e)[:500] db.commit() raise _fehler(e) from e @router.put("/connections/{connection_id}/takt") def set_poll(connection_id: int, minutes: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Takt des automatischen Abrufs. 0 schaltet ihn ab. Untergrenze 15 Minuten: Banken begrenzen unter PSD2 die Zahl der Abrufe je Tag, und häufiger bringt nichts – Umsätze erscheinen nicht im Minutentakt. """ verbindung = get_owned(db, BankConnection, connection_id, user) if minutes != 0 and not 15 <= minutes <= 1440: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Der Takt muss 0 (aus) oder zwischen 15 und 1440 Minuten liegen.", ) verbindung.poll_minutes = minutes db.commit() return {"poll_minutes": verbindung.poll_minutes} @router.post("/connections/{connection_id}/salden") def refresh_balances(connection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Nur die Saldi auffrischen, ohne Umsätze zu holen. Eigener Endpunkt, weil das auch vor jeder Zuordnung nützt: Bei mehreren Pockets ist der Betrag oft das Einzige, woran man sie unterscheidet. """ verbindung = get_owned(db, BankConnection, connection_id, user) try: return {"saldi": bank_sync.hole_salden(db, user, verbindung)} except BankFehler as e: verbindung.last_error = str(e)[:500] db.commit() raise _fehler(e) from e @router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_connection(connection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): db.delete(get_owned(db, BankConnection, connection_id, user)) db.commit() # --- Kontozuordnung ---------------------------------------------------------- @router.get("/links", response_model=list[BankAccountLinkOut]) def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)): return ( db.query(BankAccountLink) .filter(BankAccountLink.owner_id == user.id) .order_by(BankAccountLink.id) .all() ) @router.post("/links/{link_id}/account/{account_id}", response_model=BankAccountLinkOut) def set_link_account(link_id: int, account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Ein Bankkonto einem Konto in Certain zuordnen. Ohne Zuordnung wird nichts abgerufen – Certain rät nicht, wohin fremde Umsätze gehören. """ verknuepfung = get_owned(db, BankAccountLink, link_id, user) konto = get_owned(db, Account, account_id, user) if konto.currency.upper() != verknuepfung.currency.upper(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=(f"Währungen passen nicht: Das Bankkonto führt " f"{verknuepfung.currency}, das Konto '{konto.name}' führt " f"{konto.currency}."), ) # Ein Konto darf nur von EINEM Bankkonto gefuellt werden. Sonst holen zwei # Zugaenge dieselben Umsaetze – mit verschiedenen Kennungen, also ohne dass # der Duplikatschutz greift. Das passiert schnell, wenn dieselbe Bank # zweimal verbunden ist: Enable Banking fuehrt sie je Land getrennt. geloest = ( db.query(BankAccountLink) .filter(BankAccountLink.owner_id == user.id, BankAccountLink.account_id == konto.id, BankAccountLink.id != verknuepfung.id) .all() ) for andere in geloest: andere.account_id = None verknuepfung.account_id = konto.id db.commit() db.refresh(verknuepfung) if geloest: log.info("Zuordnung von %s auf %s uebernommen, %d andere geloest", verknuepfung.remote_uid, konto.name, len(geloest)) return verknuepfung @router.delete("/links/{link_id}/account", response_model=BankAccountLinkOut) def clear_link_account(link_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): verknuepfung = get_owned(db, BankAccountLink, link_id, user) verknuepfung.account_id = None db.commit() db.refresh(verknuepfung) return verknuepfung