From 735ece68435a735a7a6b760f2cb7fe821c377904 Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 26 Aug 2026 21:34:41 +0200 Subject: [PATCH] feat: Oberflaeche fuer die Bankanbindung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Einstellungen -> "Bankkonten direkt verbinden": Zugangsdaten hinterlegen, Land waehlen, Bank suchen, verbinden. Nach der Autorisierung landet man auf /bank/rueckkehr, wo der Code gegen die Sitzung getauscht wird – ueber den Server, denn der Code ist ein Einmal-Geheimnis und die Anfrage muss mit dem privaten Schluessel signiert werden, der den Server nie verlaesst. Danach stehen die gefundenen Bankkonten da und werden je einem Konto in Certain zugeordnet. Die Auswahl zeigt nur Konten passender Waehrung, und solange nichts zugeordnet ist, steht dabei, dass von diesem Konto nichts abgerufen wird. Die Einrichtungsanleitung nennt die Rueckleitungs-Adresse kopierfertig – sie muss bei Enable Banking hinterlegt werden, und ohne sie scheitert die Autorisierung mit einer Fehlermeldung der Bank, die nichts erklaert. Laeuft die Zustimmung bald ab, steht das farbig an der Verbindung statt dass der Abruf eines Tages stumm aufhoert. Co-Authored-By: Claude Opus 5 --- web/src/App.jsx | 2 + web/src/api.js | 18 ++ web/src/locales/de.json | 47 ++++ web/src/locales/en.json | 47 ++++ web/src/pages/BankRueckkehr.jsx | 74 ++++++ web/src/pages/Settings.jsx | 2 + web/src/pages/settings/Bankanbindung.jsx | 314 +++++++++++++++++++++++ web/src/styles.css | 4 + 8 files changed, 508 insertions(+) create mode 100644 web/src/pages/BankRueckkehr.jsx create mode 100644 web/src/pages/settings/Bankanbindung.jsx diff --git a/web/src/App.jsx b/web/src/App.jsx index 59155ce..6d16958 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -12,6 +12,7 @@ import Categories from "./pages/Categories"; import Transactions from "./pages/Transactions"; import Budget from "./pages/Budget"; import Import from "./pages/Import"; +import BankRueckkehr from "./pages/BankRueckkehr"; import Eingang from "./pages/Eingang"; import Offen from "./pages/Offen"; import Users from "./pages/Users"; @@ -140,6 +141,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index c9e5fa4..96cc236 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -190,6 +190,24 @@ export const api = { updateMailSender: (id, body) => request(`/mail/senders/${id}`, { method: "PUT", body }), deleteMailSender: (id) => request(`/mail/senders/${id}`, { method: "DELETE" }), + // Bankanbindung über PSD2 + getBankApp: () => request("/bank/app"), + saveBankApp: (body) => request("/bank/app", { method: "PUT", body }), + deleteBankApp: () => request("/bank/app", { method: "DELETE" }), + listBanks: (country) => request(`/bank/banks?country=${encodeURIComponent(country)}`), + connectBank: (body) => request("/bank/connect", { method: "POST", body }), + bankCallback: (state, code) => + request(`/bank/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`, + { method: "POST" }), + listBankConnections: () => request("/bank/connections"), + syncBankConnection: (id) => request(`/bank/connections/${id}/sync`, { method: "POST" }), + deleteBankConnection: (id) => request(`/bank/connections/${id}`, { method: "DELETE" }), + listBankLinks: () => request("/bank/links"), + setBankLinkAccount: (linkId, accountId) => + request(`/bank/links/${linkId}/account/${accountId}`, { method: "POST" }), + clearBankLinkAccount: (linkId) => + request(`/bank/links/${linkId}/account`, { method: "DELETE" }), + // Kategorien (Gruppen + Kategorien) listCategories: () => request("/categories"), createGroup: (body) => request("/categories/groups", { method: "POST", body }), diff --git a/web/src/locales/de.json b/web/src/locales/de.json index c692aff..b4ffcc8 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -561,5 +561,52 @@ "deleteSender": "Zuordnung entfernen", "deleteSenderConfirm": "Zuordnung „{{text}}“ entfernen? Bereits erfasste Zahlungen bleiben.", "anyCurrency": "jede Währung" + }, + "bank": { + "title": "Bankkonten direkt verbinden", + "intro": "Seit PSD2 müssen EU-Banken eine Schnittstelle anbieten – aber nur für lizenzierte Dienste. Enable Banking hält diese Lizenz und reicht sie weiter. Damit lassen sich N26 und Revolut direkt abrufen, ohne CSV. Die Schweiz fällt nicht unter PSD2; Raiffeisen bleibt beim Datei-Import.", + "setupTitle": "Einmal einrichten", + "setupHint": "Du brauchst eine eigene Anwendung bei Enable Banking. Mitliefern lässt sich das nicht – der Schlüssel ist an deine Registrierung gebunden.", + "setupStep1": "Auf enablebanking.com anmelden (Konto wird beim ersten Anmelden angelegt).", + "setupStep2": "Eine Anwendung im Bereich „Production“ anlegen und die Schlüsseldatei (.pem) herunterladen. Sie wird erst aktiv, wenn du deine eigenen Konten verknüpfst.", + "setupStep3": "Diese Adresse dort als Rückleitung („redirect URL“) eintragen:", + "setupStart": "Zugangsdaten eintragen", + "appTitle": "Zugangsdaten von Enable Banking", + "appReady": "Anwendung hinterlegt", + "appSaved": "Gespeichert.", + "applicationId": "Application ID", + "privateKey": "Privater Schlüssel (Inhalt der .pem-Datei)", + "privateKeyKeep": "Privater Schlüssel (leer lassen = unverändert)", + "privateKeyHint": "Wird verschlüsselt gespeichert und verlässt den Server nie. Mit ihm wird jede Anfrage an die Bank signiert – behandle ihn wie ein Passwort.", + "baseUrl": "Öffentliche Adresse dieser Installation", + "baseUrlHint": "Muss mit https:// beginnen und von aussen erreichbar sein – Banken leiten nicht auf eine unverschlüsselte Adresse zurück. Genau diese Adresse plus /bank/rueckkehr trägst du bei Enable Banking ein.", + "country": "Land der Bank", + "findBanks": "Banken suchen", + "noBanks": "Für dieses Land wurde nichts gefunden.", + "pickBank": "{{n}} Banken gefunden – wähle deine aus:", + "connect": "Verbinden", + "notMapped": "noch keinem Konto zugeordnet", + "mapHint": "Ohne Zuordnung wird von diesem Konto nichts abgerufen.", + "noAccountsFound": "Es wurden keine Konten übermittelt.", + "sync": "Umsätze holen", + "synced": "{{geholt}} Umsätze geprüft · {{neu}} neu im Eingang.", + "noMapping": "Kein Konto zugeordnet – ordne oben zuerst zu, dann wird abgerufen.", + "lastSync": "zuletzt geholt am {{datum}}", + "neverSynced": "noch nie geholt", + "lastError": "Fehler", + "validUntil": "Zustimmung gültig bis {{datum}}", + "expiresSoon": "Zustimmung läuft am {{datum}} ab – danach musst du neu autorisieren.", + "disconnect": "Verbindung trennen", + "disconnectConfirm": "Verbindung zu „{{name}}“ trennen? Bereits abgerufene Buchungen bleiben." + }, + "bankReturn": { + "title": "Rückkehr von der Bank", + "working": "Die Zustimmung wird abgeschlossen …", + "ok": "Verbunden. {{n}} Konten gefunden.", + "next": "Ordne sie jetzt in den Einstellungen deinen Konten in Certain zu – danach lassen sich die Umsätze holen.", + "failed": "Das hat nicht geklappt", + "failedHint": "Der Code von der Bank gilt nur einmal und nur kurz. Starte die Verbindung in den Einstellungen neu.", + "missing": "Die Bank hat keinen Code zurückgegeben.", + "toSettings": "Zu den Einstellungen" } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index c3e849d..84cdbcb 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -561,5 +561,52 @@ "deleteSender": "Remove mapping", "deleteSenderConfirm": "Remove mapping “{{text}}”? Payments already captured stay.", "anyCurrency": "any currency" + }, + "bank": { + "title": "Connect bank accounts directly", + "intro": "Under PSD2, EU banks must offer an interface – but only to licensed services. Enable Banking holds that licence and passes it on, so N26 and Revolut can be read directly, without CSV. Switzerland is not covered by PSD2; Raiffeisen stays on file import.", + "setupTitle": "One-time setup", + "setupHint": "You need your own Enable Banking application. It cannot ship with Certain – the key is bound to your registration.", + "setupStep1": "Sign in at enablebanking.com (the account is created on first sign-in).", + "setupStep2": "Create a Production application and download the key file (.pem). It becomes active once you link your own accounts.", + "setupStep3": "Enter this address there as the redirect URL:", + "setupStart": "Enter credentials", + "appTitle": "Enable Banking credentials", + "appReady": "Application configured", + "appSaved": "Saved.", + "applicationId": "Application ID", + "privateKey": "Private key (contents of the .pem file)", + "privateKeyKeep": "Private key (leave empty to keep)", + "privateKeyHint": "Stored encrypted and never leaves the server. Every request to the bank is signed with it – treat it like a password.", + "baseUrl": "Public address of this installation", + "baseUrlHint": "Must start with https:// and be reachable from outside – banks do not redirect to an unencrypted address. This address plus /bank/rueckkehr is what you register with Enable Banking.", + "country": "Country of the bank", + "findBanks": "Find banks", + "noBanks": "Nothing found for this country.", + "pickBank": "{{n}} banks found – pick yours:", + "connect": "Connect", + "notMapped": "not mapped to an account yet", + "mapHint": "Without a mapping nothing is fetched from this account.", + "noAccountsFound": "No accounts were returned.", + "sync": "Fetch transactions", + "synced": "{{geholt}} transactions checked · {{neu}} new in the inbox.", + "noMapping": "No account mapped – map one above first, then it will fetch.", + "lastSync": "last fetched on {{datum}}", + "neverSynced": "never fetched", + "lastError": "Error", + "validUntil": "Consent valid until {{datum}}", + "expiresSoon": "Consent expires on {{datum}} – you will need to authorise again.", + "disconnect": "Disconnect", + "disconnectConfirm": "Disconnect from “{{name}}”? Transactions already fetched stay." + }, + "bankReturn": { + "title": "Back from your bank", + "working": "Completing the authorisation …", + "ok": "Connected. {{n}} accounts found.", + "next": "Map them to your Certain accounts in the settings – then you can fetch transactions.", + "failed": "That did not work", + "failedHint": "The code from the bank is single-use and short-lived. Start the connection again in the settings.", + "missing": "The bank did not return a code.", + "toSettings": "Go to settings" } } diff --git a/web/src/pages/BankRueckkehr.jsx b/web/src/pages/BankRueckkehr.jsx new file mode 100644 index 0000000..ea180f6 --- /dev/null +++ b/web/src/pages/BankRueckkehr.jsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { api } from "../api"; +import Icon from "../components/Icon"; + +/** + * Landeplatz nach der Autorisierung bei der Bank. + * + * Die Bank hängt an die Rückleitungs-Adresse einen ``code`` und den ``state``, + * den Certain beim Start erzeugt hat. Der Code wird hier gegen die Sitzung + * getauscht – erst danach weiss Certain, welche Konten es dort gibt. + * + * Der Austausch läuft über den Server, nicht im Browser: Der Code ist ein + * Einmal-Geheimnis, und die Anfrage muss mit dem privaten Schlüssel signiert + * werden, der den Server nie verlässt. + */ +export default function BankRueckkehr() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const [stand, setStand] = useState({ laeuft: true }); + + useEffect(() => { + const code = params.get("code"); + const state = params.get("state"); + const fehler = params.get("error"); + + if (fehler) { + setStand({ laeuft: false, fehler: params.get("error_description") || fehler }); + return; + } + if (!code || !state) { + setStand({ laeuft: false, fehler: t("bankReturn.missing") }); + return; + } + api.bankCallback(state, code) + .then((r) => setStand({ laeuft: false, konten: r.konten })) + .catch((e) => setStand({ laeuft: false, fehler: e.message })); + // Nur einmal ausführen: Der Code lässt sich kein zweites Mal einlösen. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+

{t("bankReturn.title")}

+
+
+ {stand.laeuft &&

{t("bankReturn.working")}

} + + {!stand.laeuft && stand.fehler && ( +
+ {t("bankReturn.failed")} +

{stand.fehler}

+

{t("bankReturn.failedHint")}

+
+ )} + + {!stand.laeuft && !stand.fehler && ( +
+ {t("bankReturn.ok", { n: stand.konten })} +

{t("bankReturn.next")}

+
+ )} + +
+ + {t("bankReturn.toSettings")} + +
+
+
+ ); +} diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index 257dc51..743a645 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -7,6 +7,7 @@ import { api } from "../api"; import Icon from "../components/Icon"; import Kartenspalten from "../components/Kartenspalten"; import PolicyCard from "./settings/PolicyCard"; +import Bankanbindung from "./settings/Bankanbindung"; import Postfaecher from "./settings/Postfaecher"; import RestoreCard from "./settings/RestoreCard"; @@ -163,6 +164,7 @@ export default function Settings() { )} + diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx new file mode 100644 index 0000000..3f3b298 --- /dev/null +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -0,0 +1,314 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../../api"; +import { useToast } from "../../toast"; +import { useConfirm } from "../../confirm"; +import Icon from "../../components/Icon"; +import Modal from "../../components/Modal"; +import { formatDate } from "../../format"; + +// Länder, in denen die Banken dieses Nutzers liegen können. Die Schweiz fehlt +// bewusst: Sie fällt nicht unter PSD2, dort gibt es keine Schnittstelle. +const LAENDER = ["DE", "LT", "AT", "NL", "FR", "IT", "ES", "IE", "PL"]; + +/** + * Bankkonten über PSD2 verbinden. + * + * Seit PSD2 müssen EU-Banken eine Schnittstelle anbieten, aber nur für + * lizenzierte Dienste. Enable Banking hält diese Lizenz; man registriert eine + * eigene Anwendung und darf danach die selbst verknüpften Konten lesen. + * + * Abgerufene Umsätze werden nicht gebucht – sie landen als Vorschlag im + * Eingang, wie Mails und Kurzbefehl-Erfassungen. + */ +export default function Bankanbindung() { + const { t } = useTranslation(); + const toast = useToast(); + const confirm = useConfirm(); + + const [app, setApp] = useState(null); + const [form, setForm] = useState(null); + const [konten, setKonten] = useState([]); + const [verbindungen, setVerbindungen] = useState([]); + const [links, setLinks] = useState([]); + const [land, setLand] = useState("DE"); + const [banken, setBanken] = useState(null); + const [busy, setBusy] = useState(false); + + const basis = useMemo(() => window.location.origin, []); + const kontoById = useMemo( + () => Object.fromEntries(konten.map((k) => [k.id, k])), [konten]); + + async function laden() { + try { + const [a, k, v, l] = await Promise.all([ + api.getBankApp().catch(() => null), + api.listAccounts(), + api.listBankConnections().catch(() => []), + api.listBankLinks().catch(() => []), + ]); + setApp(a); + setKonten(k); + setVerbindungen(v); + setLinks(l); + } catch (e) { + toast(e.message, "error"); + } + } + useEffect(() => { laden(); }, []); + + async function speichern(e) { + e.preventDefault(); + setBusy(true); + try { + await api.saveBankApp(form); + setForm(null); + laden(); + toast(t("bank.appSaved")); + } catch (e) { + toast(e.message, "error"); + } finally { + setBusy(false); + } + } + + async function bankenLaden() { + setBusy(true); + try { + setBanken(await api.listBanks(land)); + } catch (e) { + toast(e.message, "error"); + setBanken(null); + } finally { + setBusy(false); + } + } + + async function verbinden(bank) { + setBusy(true); + try { + const r = await api.connectBank({ aspsp_name: bank.name, country: land }); + // Weiter zur Bank. Von dort kommt der Nutzer auf /bank/rueckkehr zurück. + window.location.href = r.url; + } catch (e) { + toast(e.message, "error"); + setBusy(false); + } + } + + async function abrufen(v) { + setBusy(true); + try { + const r = await api.syncBankConnection(v.id); + toast(r.ohne_zuordnung ? t("bank.noMapping") + : t("bank.synced", { geholt: r.geholt, neu: r.neu })); + laden(); + } catch (e) { + toast(e.message, "error"); + } finally { + setBusy(false); + } + } + + async function trennen(v) { + if (!(await confirm({ + title: t("bank.disconnect"), + message: t("bank.disconnectConfirm", { name: v.aspsp_name }), + danger: true, confirmLabel: t("bank.disconnect"), + }))) return; + try { await api.deleteBankConnection(v.id); laden(); } catch (e) { toast(e.message, "error"); } + } + + async function zuordnen(link, kontoId) { + try { + if (kontoId) await api.setBankLinkAccount(link.id, Number(kontoId)); + else await api.clearBankLinkAccount(link.id); + laden(); + } catch (e) { + toast(e.message, "error"); + } + } + + const linksVon = (v) => links.filter((l) => l.connection_id === v.id); + + return ( +
+
+ +

{t("bank.title")}

+
+

{t("bank.intro")}

+ + {/* Schritt 1: die Anwendung dieser Installation */} + {!app ? ( +
+ {t("bank.setupTitle")} +

{t("bank.setupHint")}

+
    +
  1. {t("bank.setupStep1")}
  2. +
  3. {t("bank.setupStep2")}
  4. +
  5. + {t("bank.setupStep3")} +
    + + +
    +
  6. +
+ +
+ ) : ( +
+
{t("bank.appReady")}
+
{app.application_id}
+
{app.base_url}
+
+ +
+
+ )} + + {/* Schritt 2: bestehende Verbindungen samt Kontozuordnung */} + {verbindungen.map((v) => ( +
+
{v.aspsp_name} {v.aspsp_country}
+
+ {v.last_error + ? `${t("bank.lastError")}: ${v.last_error}` + : v.last_sync_at ? t("bank.lastSync", { datum: formatDate(v.last_sync_at.slice(0, 10)) }) + : t("bank.neverSynced")} +
+ {v.valid_until && ( +
+ {v.expires_soon + ? t("bank.expiresSoon", { datum: formatDate(v.valid_until.slice(0, 10)) }) + : t("bank.validUntil", { datum: formatDate(v.valid_until.slice(0, 10)) })} +
+ )} + +
+ {linksVon(v).length === 0 ? ( +

{t("bank.noAccountsFound")}

+ ) : ( + linksVon(v).map((l) => ( +
+
+ {l.remote_name || l.iban || l.remote_uid} + {l.iban ? ` · ${l.iban}` : ""} · {l.currency} +
+ + {l.account_id ? null : ( +

+ {t("bank.mapHint")} +

+ )} +
+ )) + )} +
+ +
+ + +
+
+ ))} + + {/* Schritt 3: neue Bank verbinden */} + {app && ( +
+ + + + {banken && ( +
+ {banken.length === 0 ? ( +

{t("bank.noBanks")}

+ ) : ( + <> +

{t("bank.pickBank", { n: banken.length })}

+
+ {banken.map((b) => ( +
+ {b.name} + +
+ ))} +
+ + )} +
+ )} +
+ )} + + {form !== null && ( + setForm(null)} + footer={ + <> + + + + } + > +
+ +