feat: Bankanbindung ueber PSD2 (Backend)

Seit PSD2 muessen EU-Banken eine Schnittstelle anbieten, aber nur fuer
lizenzierte Dienste mit eIDAS-Zertifikat. Enable Banking haelt die Lizenz und
reicht sie weiter: eigene Anwendung registrieren, eigene Konten verknuepfen,
danach genau diese lesen. Deckt N26 und Revolut ab; die Schweiz faellt nicht
unter PSD2, Raiffeisen bleibt beim CSV-Weg.

Es gibt keinen API-Schluessel. Jede Anfrage traegt ein selbst gebautes JWT,
signiert mit dem privaten Schluessel der Anwendung (RS256). Signiert wird mit
cryptography, das ohnehin schon fuer die Verschluesselung im Projekt ist – der
Test prueft die Signatur echt gegen den oeffentlichen Schluessel, denn genau
daran scheitert so eine Anbindung sonst als Erstes.

Abgerufene Umsaetze werden NICHT gebucht. Sie laufen durch detect_rows und
landen als Vorschlag im Eingang – dieselbe Strecke wie Mail und Kurzbefehl,
also greifen Duplikatschutz und Schlagwortregeln ohne Sonderbehandlung. Eine
dritte Quelle, kein dritter Sonderweg.

Bewusst eng gehalten: nur Lesen; Zuordnung Bankkonto -> Konto trifft der
Nutzer; die Zuordnung verlangt dieselbe Waehrung; die Rueckleitungs-Adresse
wird eingetragen statt geraten (hinter einem Proxy sieht der Dienst "http",
obwohl der Browser HTTPS spricht) und muss HTTPS sein.

Der Ablauf der Zustimmung (bis 180 Tage) wird sichtbar gemeldet, damit der
Abruf nicht eines Tages stumm aufhoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-26 21:25:20 +02:00
co-authored by Claude Opus 5
parent 3ee18c4d21
commit 7a3bd200b1
10 changed files with 1232 additions and 1 deletions
+9
View File
@@ -149,6 +149,15 @@ USER_TABLES: list[TableSpec] = [
# ``pending_uploads`` – dort liegen rohe Dateien, die noch niemand geprüft hat.
# Sie sind flüchtig, blähen ein JSON-Backup um Megabytes auf und lassen sich
# jederzeit erneut vom Telefon schicken.
#
# ``bank_apps``/``bank_connections``/``bank_account_links`` – dieselbe
# Überlegung wie beim Postfach, nur schärfer: ``bank_apps`` trägt den privaten
# Schlüssel, mit dem jede Anfrage an die Bank signiert wird, und
# ``bank_connections`` eine laufende Sitzung. Beides gehört in keine Datei, die
# man weitergibt. Die Sitzung liesse sich ohnehin nicht wiederbeleben – die
# Zustimmung hängt an der Bank, nicht an unserer Datenbank. Nach einem
# Wiedereinspielen verbindet man die Bank neu; die abgerufenen Buchungen selbst
# sind längst gesichert.
# Nur im systemweiten Backup, je Benutzer.
SYSTEM_USER_TABLES: list[TableSpec] = [
+173
View File
@@ -0,0 +1,173 @@
"""Zugriff auf Bankkonten über PSD2 (Enable Banking).
Seit PSD2 müssen EU-Banken eine Schnittstelle anbieten, aber nur für
lizenzierte Dienste mit eIDAS-Zertifikat. Enable Banking hält diese Lizenz und
reicht sie weiter: Man registriert eine eigene Anwendung, verknüpft die eigenen
Konten und darf danach genau diese lesen („Restricted Mode").
Es gibt keinen API-Schlüssel. Jede Anfrage trägt ein selbst gebautes JWT,
signiert mit dem privaten Schlüssel der Anwendung (RS256, eine Stunde gültig).
Signiert wird mit ``cryptography``, das ohnehin schon für die Verschlüsselung
der Zugangsdaten im Projekt ist – keine zusätzliche Abhängigkeit.
Nur Lesen: Salden und Umsätze. Zahlungen auslösen kann diese Anbindung nicht,
und das soll auch so bleiben.
"""
from __future__ import annotations
import base64
import json
import logging
import time
from datetime import date, datetime, timedelta, timezone
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
log = logging.getLogger(__name__)
BASIS = "https://api.enablebanking.com"
JWT_GUELTIG_SEKUNDEN = 3600
ZEITLIMIT = 30.0
# Wie weit ein erster Abruf zurückgeht. Mehr geben die meisten Banken über
# PSD2 ohnehin nicht heraus.
ERSTER_ABRUF_TAGE = 90
class BankFehler(RuntimeError):
"""Fehler beim Zugriff – mit einer Ursache, die man dem Nutzer zeigen kann."""
def _b64(roh: bytes) -> str:
"""URL-sicheres Base64 ohne Füllzeichen, wie es JWT verlangt."""
return base64.urlsafe_b64encode(roh).rstrip(b"=").decode("ascii")
def baue_jwt(application_id: str, private_key_pem: str) -> str:
"""Signiertes JWT für eine Stunde.
Kopf und Rumpf sind von Enable Banking vorgegeben; ``kid`` ist die
Application ID, an der die Gegenseite den öffentlichen Schlüssel findet.
"""
try:
schluessel = serialization.load_pem_private_key(
private_key_pem.encode("utf-8"), password=None
)
except (ValueError, TypeError) as e:
raise BankFehler(
"Der private Schlüssel lässt sich nicht lesen. Ist es die "
"unveränderte .pem-Datei aus dem Control Panel?"
) from e
if not isinstance(schluessel, rsa.RSAPrivateKey):
raise BankFehler("Der Schlüssel ist kein RSA-Schlüssel.")
jetzt = int(time.time())
kopf = {"typ": "JWT", "alg": "RS256", "kid": application_id}
rumpf = {
"iss": "enablebanking.com",
"aud": "api.enablebanking.com",
"iat": jetzt,
"exp": jetzt + JWT_GUELTIG_SEKUNDEN,
}
zu_signieren = f"{_b64(json.dumps(kopf).encode())}.{_b64(json.dumps(rumpf).encode())}"
signatur = schluessel.sign(
zu_signieren.encode("ascii"), padding.PKCS1v15(), hashes.SHA256()
)
return f"{zu_signieren}.{_b64(signatur)}"
class BankClient:
"""Dünne Hülle um die vier Endpunkte, die wir brauchen.
Als Klasse und nicht als Funktionssammlung, damit das JWT je Abruf-Runde
einmal gebaut wird statt je Anfrage – und damit die Tests einen
Doppelgänger einsetzen können, ohne dass ein Netzzugang nötig ist.
"""
def __init__(self, application_id: str, private_key_pem: str, basis: str = BASIS):
self._jwt = baue_jwt(application_id, private_key_pem)
self._basis = basis.rstrip("/")
def _anfrage(self, methode: str, pfad: str, **kw) -> dict:
kopfzeilen = {"Authorization": f"Bearer {self._jwt}",
"Content-Type": "application/json"}
try:
with httpx.Client(timeout=ZEITLIMIT) as c:
antwort = c.request(methode, f"{self._basis}{pfad}",
headers=kopfzeilen, **kw)
except httpx.HTTPError as e:
raise BankFehler(f"Die Bank-Schnittstelle ist nicht erreichbar: {e}") from e
if antwort.status_code >= 400:
raise BankFehler(_fehlertext(antwort))
try:
return antwort.json()
except ValueError as e:
raise BankFehler("Unerwartete Antwort der Schnittstelle.") from e
# --- Die vier Endpunkte -------------------------------------------------
def banken(self, land: str) -> list[dict]:
"""Verfügbare Banken eines Landes – für die Auswahl beim Verbinden."""
daten = self._anfrage("GET", f"/aspsps?country={land.upper()}")
return daten.get("aspsps", [])
def starte_autorisierung(self, aspsp_name: str, land: str, redirect_url: str,
state: str, tage: int, psu_type: str = "personal") -> dict:
"""Weiterleitungs-Adresse für die Zustimmung bei der Bank."""
gueltig_bis = datetime.now(timezone.utc) + timedelta(days=tage)
return self._anfrage("POST", "/auth", json={
"access": {"valid_until": gueltig_bis.isoformat()},
"aspsp": {"name": aspsp_name, "country": land.upper()},
"redirect_url": redirect_url,
"state": state,
"psu_type": psu_type,
})
def sitzung(self, code: str) -> dict:
"""Den Rückleitungs-Code gegen eine Sitzung samt Kontoliste tauschen."""
return self._anfrage("POST", "/sessions", json={"code": code})
def salden(self, konto_uid: str) -> list[dict]:
return self._anfrage("GET", f"/accounts/{konto_uid}/balances").get("balances", [])
def umsaetze(self, konto_uid: str, seit: date) -> list[dict]:
"""Alle Umsätze ab einem Datum – über alle Seiten hinweg.
Die Schnittstelle blättert über einen ``continuation_key``. Ohne diese
Schleife bekäme man stillschweigend nur die erste Seite; bei einem
Jahresauszug wäre der Rest einfach weg.
"""
gesammelt: list[dict] = []
weiter: str | None = None
for _ in range(50): # Bremse gegen eine endlose Antwortkette
pfad = f"/accounts/{konto_uid}/transactions?date_from={seit.isoformat()}"
if weiter:
pfad += f"&continuation_key={weiter}"
daten = self._anfrage("GET", pfad)
gesammelt.extend(daten.get("transactions", []))
weiter = daten.get("continuation_key")
if not weiter:
break
else:
log.warning("Umsatzabruf nach 50 Seiten abgebrochen (Konto %s)", konto_uid)
return gesammelt
def _fehlertext(antwort: httpx.Response) -> str:
"""Aus der Fehlerantwort etwas machen, das im Formular hilft."""
if antwort.status_code == 401:
return ("Die Anmeldung wurde abgelehnt. Passen Application ID und "
"Schlüsseldatei zusammen, und ist die Anwendung aktiv?")
if antwort.status_code == 403:
return ("Zugriff verweigert. Im eingeschränkten Betrieb sind nur die "
"Konten lesbar, die du selbst verknüpft hast.")
try:
inhalt = antwort.json()
grund = inhalt.get("message") or inhalt.get("error") or inhalt.get("detail")
if grund:
return f"Die Bank-Schnittstelle meldet: {grund}"
except ValueError:
pass
return f"Die Bank-Schnittstelle antwortete mit Fehler {antwort.status_code}."
+289
View File
@@ -0,0 +1,289 @@
"""Bankkonten verbinden und Umsätze abholen.
Der Weg in drei Schritten, genau wie ihn die Schnittstelle vorgibt:
1. ``starte_verbindung`` → Adresse, zu der der Nutzer geschickt wird
2. ``schliesse_verbindung`` → der Rückleitungs-Code wird zur Sitzung,
und wir erfahren, welche Konten es dort gibt
3. ``hole_umsaetze`` → Umsätze je zugeordnetem Konto
Abgeholte Umsätze werden NICHT gebucht. Sie landen als Vorschlag im Eingang,
dieselbe Strecke wie Mails und Kurzbefehl-Erfassungen – und laufen dabei durch
``detect_rows``, sodass Duplikatschutz und Schlagwortregeln ohne
Sonderbehandlung greifen. Eine dritte Quelle, kein dritter Sonderweg.
"""
from __future__ import annotations
import logging
import secrets
from datetime import date, datetime, timedelta, timezone
from sqlalchemy.orm import Session
from ..models import (
Account,
BankAccountLink,
BankApp,
BankConnection,
InboxItem,
InboxSource,
InboxStatus,
User,
)
from . import csv_import, geheimnis
from .bank_api import ERSTER_ABRUF_TAGE, BankClient, BankFehler
log = logging.getLogger(__name__)
# Wie lange die Zustimmung gelten soll. Die Bank kürzt selbst, wenn sie weniger
# erlaubt; mehr als 180 Tage lässt die Regelung nicht zu.
ZUSTIMMUNG_TAGE = 180
# Ab wann gewarnt wird, dass neu autorisiert werden muss. Läuft die Zustimmung
# ab, hört der Abruf sonst stumm auf – dieselbe Falle wie „0 neu" ohne Grund.
WARNUNG_TAGE = 14
def _client(db: Session, user: User) -> BankClient:
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
if app is None:
raise BankFehler(
"Es ist noch keine Bank-Anwendung hinterlegt. Trag Application ID "
"und Schlüsseldatei in den Einstellungen ein."
)
schluessel = geheimnis.entschluesseln(app.private_key_enc)
if schluessel is None:
raise BankFehler(
"Der gespeicherte Schlüssel lässt sich nicht mehr entschlüsseln. "
"Wurde das Server-Geheimnis getauscht? Dann bitte neu hinterlegen."
)
return BankClient(app.application_id, schluessel)
def banken(db: Session, user: User, land: str) -> list[dict]:
"""Banken eines Landes, auf das Nötige eingedampft."""
roh = _client(db, user).banken(land)
return [
{"name": b.get("name"), "country": b.get("country"),
"logo": b.get("logo"), "psu_types": b.get("psu_types", [])}
for b in roh if b.get("name")
]
def starte_verbindung(db: Session, user: User, aspsp_name: str, land: str,
psu_type: str = "personal") -> dict:
"""Autorisierung anstossen und die Weiterleitungs-Adresse liefern."""
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
if app is None:
raise BankFehler("Es ist noch keine Bank-Anwendung hinterlegt.")
state = secrets.token_urlsafe(24)
verbindung = BankConnection(
owner_id=user.id, aspsp_name=aspsp_name, aspsp_country=land.upper(),
pending_state=state,
)
db.add(verbindung)
db.flush()
antwort = _client(db, user).starte_autorisierung(
aspsp_name, land, f"{app.base_url.rstrip('/')}/bank/rueckkehr",
state, ZUSTIMMUNG_TAGE, psu_type,
)
db.commit()
return {"url": antwort.get("url"), "connection_id": verbindung.id, "state": state}
def schliesse_verbindung(db: Session, user: User, state: str, code: str) -> dict:
"""Code gegen die Sitzung tauschen und die gefundenen Konten ablegen.
Die Konten werden nur VERZEICHNET, nicht zugeordnet: Welches Bankkonto zu
welchem Konto in Certain gehört, entscheidet der Nutzer. Raten wäre hier
besonders teuer, weil eine falsche Zuordnung fremde Umsätze in ein Konto
schreiben würde.
"""
verbindung = (
db.query(BankConnection)
.filter(BankConnection.owner_id == user.id, BankConnection.pending_state == state)
.first()
)
if verbindung is None:
raise BankFehler("Zu dieser Rückmeldung gibt es keine offene Verbindung.")
daten = _client(db, user).sitzung(code)
verbindung.session_id = daten.get("session_id")
verbindung.pending_state = None
verbindung.last_error = None
gueltig = (daten.get("access") or {}).get("valid_until")
if gueltig:
try:
verbindung.valid_until = datetime.fromisoformat(gueltig.replace("Z", "+00:00"))
except ValueError:
verbindung.valid_until = None
neu = 0
for konto in daten.get("accounts", []):
uid = konto.get("uid")
if not uid:
continue
vorhanden = (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id, BankAccountLink.remote_uid == uid)
.first()
)
if vorhanden is not None:
vorhanden.connection_id = verbindung.id
continue
db.add(BankAccountLink(
owner_id=user.id,
connection_id=verbindung.id,
remote_uid=uid,
iban=(konto.get("account_id") or {}).get("iban"),
remote_name=konto.get("name"),
currency=(konto.get("currency") or "EUR").upper(),
))
neu += 1
db.commit()
return {"connection_id": verbindung.id, "konten": neu,
"valid_until": verbindung.valid_until.isoformat() if verbindung.valid_until else None}
def _betrag_cents(umsatz: dict) -> int | None:
"""Vorzeichenbehafteter Betrag in Cent.
PSD2 führt den Betrag ohne Vorzeichen und die Richtung getrennt in
``credit_debit_indicator`` (CRDT/DBIT). Manche Banken liefern trotzdem ein
Vorzeichen mit – beides wird berücksichtigt, damit eine Ausgabe nicht als
Einnahme ankommt.
"""
betrag = (umsatz.get("transaction_amount") or {}).get("amount")
if betrag is None:
return None
cents = csv_import.parse_amount(str(betrag), ".")
if cents is None:
return None
richtung = (umsatz.get("credit_debit_indicator") or "").upper()
if richtung == "DBIT":
return -abs(cents)
if richtung == "CRDT":
return abs(cents)
return cents
def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | None]:
"""Empfänger und Verwendungszweck. Bei einer Ausgabe zählt der Empfänger,
bei einer Einnahme der Absender."""
gegen = umsatz.get("creditor") if ausgabe else umsatz.get("debtor")
name = (gegen or {}).get("name")
zweck = umsatz.get("remittance_information")
if isinstance(zweck, list):
zweck = " ".join(str(z) for z in zweck if z)
return (str(name)[:255] if name else None,
str(zweck)[:255] if zweck else None)
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict:
"""Umsätze aller zugeordneten Konten dieser Verbindung in den Eingang holen."""
verknuepfungen = [
v for v in db.query(BankAccountLink).filter(
BankAccountLink.connection_id == verbindung.id,
BankAccountLink.owner_id == user.id,
)
if v.account_id is not None
]
if not verknuepfungen:
return {"geholt": 0, "neu": 0, "ohne_zuordnung": True}
client = _client(db, user)
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
seit = (verbindung.last_sync_at.date() - timedelta(days=3)
if verbindung.last_sync_at
else date.today() - timedelta(days=ERSTER_ABRUF_TAGE))
geholt = neu = 0
for verknuepfung in verknuepfungen:
konto = konten.get(verknuepfung.account_id)
if konto is None:
continue
for umsatz in client.umsaetze(verknuepfung.remote_uid, seit):
geholt += 1
kennung = (umsatz.get("entry_reference") or umsatz.get("transaction_id")
or umsatz.get("uid"))
if not kennung:
continue # ohne Kennung kein Schutz vor Doppelabruf
kennung = f"{verknuepfung.remote_uid}:{kennung}"[:255]
schon = (
db.query(InboxItem.id)
.filter(InboxItem.owner_id == user.id,
InboxItem.source == InboxSource.bank,
InboxItem.external_id == kennung)
.first()
)
if schon is not None:
continue
cents = _betrag_cents(umsatz)
tag = _datum(umsatz)
if cents is None or cents == 0 or tag is None:
continue
payee, zweck = _text(umsatz, cents < 0)
# Durch dieselbe Erkennung wie CSV und Kurzbefehl: Schlagwortregeln
# schlagen die Kategorie vor, der Eingangs-Abgleich verhindert, dass
# eine bereits erfasste Zahlung ein zweites Mal auftaucht.
erkannt = csv_import.detect_rows(db, user, konto, [{
"date": tag.isoformat(), "amount_cents": cents,
"payee": payee, "purpose": zweck,
}])
zeile = erkannt[0] if erkannt else {}
if zeile.get("is_duplicate"):
continue
db.add(InboxItem(
owner_id=user.id,
source=InboxSource.bank,
status=InboxStatus.pending,
account_id=konto.id,
date=tag,
amount_cents=cents,
currency=(umsatz.get("transaction_amount") or {}).get("currency")
or konto.currency,
payee=payee,
note=zweck,
category_id=zeile.get("category_id"),
external_id=kennung,
))
neu += 1
verbindung.last_sync_at = datetime.now(timezone.utc)
verbindung.last_error = None
db.commit()
return {"geholt": geholt, "neu": neu, "ohne_zuordnung": False}
def _datum(umsatz: dict) -> date | None:
for feld in ("booking_date", "value_date", "transaction_date"):
roh = umsatz.get(feld)
if not roh:
continue
try:
return date.fromisoformat(str(roh)[:10])
except ValueError:
continue
return None
def laeuft_bald_ab(verbindung: BankConnection) -> bool:
"""Läuft die Zustimmung demnächst ab?
SQLite gibt Zeitstempel ohne Zeitzone zurück, auch wenn die Spalte als
``timezone=True`` deklariert ist. Ohne diese Ergänzung scheitert der
Vergleich mit einem TypeError – und zwar erst beim Anzeigen der
Verbindungsliste, also weit weg von der Ursache.
"""
if verbindung.valid_until is None:
return False
bis = verbindung.valid_until
if bis.tzinfo is None:
bis = bis.replace(tzinfo=timezone.utc)
return bis - datetime.now(timezone.utc) < timedelta(days=WARNUNG_TAGE)