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:
@@ -629,6 +629,7 @@ class ApiToken(Base):
|
||||
class InboxSource(str, enum.Enum):
|
||||
shortcut = "shortcut" # per Kurzbefehl vom Telefon erfasst
|
||||
mail = "mail" # aus einer Benachrichtigungs-Mail gelesen
|
||||
bank = "bank" # direkt von der Bank geholt (PSD2)
|
||||
|
||||
|
||||
class InboxStatus(str, enum.Enum):
|
||||
@@ -831,3 +832,84 @@ class LinkIgnored(Base):
|
||||
__table_args__ = (
|
||||
UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"),
|
||||
)
|
||||
|
||||
|
||||
class BankApp(Base):
|
||||
"""Die bei Enable Banking registrierte Anwendung dieser Installation.
|
||||
|
||||
Es gibt keinen API-Schlüssel zum Eintippen: Enable Banking gibt eine
|
||||
Application ID und eine private Schlüsseldatei aus, mit der jede Anfrage
|
||||
einzeln signiert wird (JWT, RS256). Der Schlüssel liegt deshalb hier –
|
||||
verschlüsselt wie die IMAP-Passwörter, denn er ist genauso schützenswert.
|
||||
|
||||
Wer Certain selbst hostet, registriert eine eigene Anwendung; mitliefern
|
||||
lässt sich das nicht, weil der Schlüssel an die Registrierung gebunden ist.
|
||||
"""
|
||||
|
||||
__tablename__ = "bank_apps"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
application_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
private_key_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# Öffentliche Adresse dieser Installation, an die die Bank zurückleitet.
|
||||
# Muss HTTPS sein und im Control Panel hinterlegt werden. Bewusst
|
||||
# eingetragen statt geraten: Hinter einem Proxy sieht der Dienst „http",
|
||||
# obwohl der Browser HTTPS spricht.
|
||||
base_url: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class BankConnection(Base):
|
||||
"""Eine autorisierte Verbindung zu einer Bank – das Ergebnis von /sessions."""
|
||||
|
||||
__tablename__ = "bank_connections"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
aspsp_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
aspsp_country: Mapped[str] = mapped_column(String(2), nullable=False)
|
||||
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Der Zeitpunkt, ab dem neu autorisiert werden muss (bis zu 180 Tage).
|
||||
# Sichtbar anzeigen: Läuft er ab, hört der Abruf sonst stumm auf.
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Solange die Autorisierung läuft, steht hier der state-Wert aus /auth.
|
||||
pending_state: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class BankAccountLink(Base):
|
||||
"""Ein Konto bei der Bank, zugeordnet zu einem Konto in Certain.
|
||||
|
||||
Ohne Zuordnung wird nichts abgerufen – dieselbe Regel wie bei den
|
||||
Mail-Absendern: Certain rät nicht, wohin eine Bewegung gehört.
|
||||
"""
|
||||
|
||||
__tablename__ = "bank_account_links"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
connection_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("bank_connections.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Kennung des Kontos bei Enable Banking; damit werden Salden und Umsätze geholt.
|
||||
remote_uid: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
iban: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
remote_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "remote_uid", name="uq_bank_link_remote"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user