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, formatMoney } 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(""); const [suche, setSuche] = useState(""); const [banken, setBanken] = useState(null); // { gesamt, treffer, gekuerzt } 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(e) { e?.preventDefault(); setBusy(true); try { setBanken(await api.listBanks(land, suche)); } catch (e) { toast(e.message, "error"); setBanken(null); } finally { setBusy(false); } } async function verbinden(bank) { setBusy(true); try { // Das Land der BANK, nicht das des Filters – bei einer Suche über alle // Länder ist das Filterfeld leer. const r = await api.connectBank({ aspsp_name: bank.name, country: bank.country }); // 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 saldenHolen(v) { setBusy(true); try { const r = await api.refreshBankBalances(v.id); toast(t("bank.balancesFetched", { n: r.saldi })); laden(); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } function meldung(r) { // Vorgemerkte nicht verschweigen: Sie fehlen sonst im Saldo und niemand // weiss, warum. const teile = [t("bank.synced", { gebucht: r.gebucht, ab: formatDate(r.ab) })]; if (r.vorgemerkt > 0) teile.push(t("bank.syncedPending", { n: r.vorgemerkt })); // Uebersprungene nicht verschweigen: Sonst sieht ein Abruf ueber schon // eingelesene Monate aus, als haette er nichts gefunden. if (r.doppelt > 0) teile.push(t("bank.syncedDuplicate", { n: r.doppelt })); if (r.uebertraege > 0) teile.push(t("bank.syncedTransfers", { n: r.uebertraege })); return teile.join(" "); } async function kontoAbrufen(l, tage) { setBusy(true); try { const r = await api.syncBankLink(l.id, tage); toast(r.ohne_zuordnung ? t("bank.noMapping") : meldung(r)); laden(); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } async function taktSetzen(v, minuten) { try { await api.setBankPoll(v.id, minuten); toast(t("bank.pollSaved")); laden(); } catch (e) { toast(e.message, "error"); laden(); // Feld auf den gespeicherten Wert zurückstellen } } async function abrufen(v) { setBusy(true); try { const r = await api.syncBankConnection(v.id); toast(r.ohne_zuordnung ? t("bank.noMapping") : meldung(r)); 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. {t("bank.setupStep2")}
  3. {t("bank.setupStep3")}
) : (
{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", { when: 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} {/* Produktbezeichnung und Ersatzkennung sind bei Pockets das Einzige, was sie unterscheidet – ohne IBAN, oft ohne eigenen Namen sähen mehrere Konten sonst gleich aus. */} {l.product ? ` · ${l.product}` : ""} {l.iban ? ` · ${l.iban}` : l.other_id ? ` · ${l.other_id}` : ""} {` · ${l.currency}`}
{l.account_id ? null : (

{/* Ohne Konto in dieser Währung ist die Auswahl leer, und man sucht den Fehler bei der Bank statt bei sich. */} {konten.some((k) => k.currency === l.currency) ? t("bank.mapHint") : t("bank.noAccountForCurrency", { currency: l.currency })}

)} {l.balance_cents != null && (

{t("bank.bankBalance")}:{" "} {formatMoney(l.balance_cents, l.currency)} {l.balance_at ? ` · ${formatDate(l.balance_at)}` : ""} {/* Der eigentliche Nutzen: Certain rechnet den Saldo selbst aus. Weicht er ab, fehlt oder doppelt eine Buchung – das sieht man sonst erst beim Vergleich von Hand. */} {l.account_id && kontoById[l.account_id] && ( (() => { const diff = kontoById[l.account_id].balance_cents - l.balance_cents; return diff === 0 ? ( · {t("bank.balanceMatches")} ) : ( {" "}· {t("bank.balanceDiffers", { diff: formatMoney(diff, l.currency), })} ); })() )}

)} {l.account_id && (
{/* Fehlt etwas, liegt es meist vor dem Fenster des letzten Abrufs und käme sonst nie wieder. */} {l.last_sync_at ? t("bank.lastSync", { when: formatDate(l.last_sync_at) }) : t("bank.neverSynced")}
)} {Object.keys(l.bank_felder || {}).length > 0 && (
{t("bank.rawFields")}
{Object.entries(l.bank_felder).map(([feld, wert]) => (
{feld}: {typeof wert === "object" && wert !== null ? JSON.stringify(wert) : String(wert)}
))}
)}
)) )}

{t("bank.syncHint")}

{t("bank.pollHint")}

))} {/* Schritt 3: neue Bank verbinden. Deutlich abgesetzt – sonst verschwimmt das Suchfeld mit den Angaben der schon verbundenen Banken. */} {app && (

{t("bank.addTitle")}

{t("bank.searchHint")}

{banken && (
{banken.treffer.length === 0 ? ( // Der Unterschied zwischen „Liste kam leer" und „nichts passte" // entscheidet, wo man den Fehler sucht.

{banken.gesamt === 0 ? t("bank.noneLoaded") : t("bank.noMatch", { gesamt: banken.gesamt })}

{banken.gesamt > 0 && ( <> {/* Eine Kostprobe der geladenen Liste. Stehen dort lauter Test-Institute, ist die Anwendung als Sandbox angelegt – das sieht man hier auf einen Blick. */}

{t("bank.sampleTitle")}

    {(banken.beispiele || []).map((n) =>
  • {n}
  • )}

{t("bank.range", { von: banken.von, bis: banken.bis })}

{t("bank.letters")}: {(banken.anfangsbuchstaben || []).join(" ")}

{t("bank.truncatedHint")}

{(banken.gleicher_buchstabe || []).length > 0 && ( <>

{t("bank.sameLetter", { buchstabe: suche.trim()[0].toUpperCase() })}

    {banken.gleicher_buchstabe.map((n) =>
  • {n}
  • )}
)} )}
) : ( <>

{banken.gekuerzt ? t("bank.pickBankMore", { n: banken.treffer.length, treffer: banken.treffer_gesamt }) : t("bank.pickBank", { n: banken.treffer_gesamt, gesamt: banken.gesamt })}

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