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={ + <> + + + + } + > +
+ +