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.intro")}
{/* Schritt 1: die Anwendung dieser Installation */} {!app ? ({t("bank.setupHint")}
{t("bank.noAccountsFound")}
) : ( linksVon(v).map((l) => ({/* 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 && ({t("bank.syncHint")}
{t("bank.pollHint")}
{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")}
{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.gekuerzt ? t("bank.pickBankMore", { n: banken.treffer.length, treffer: banken.treffer_gesamt }) : t("bank.pickBank", { n: banken.treffer_gesamt, gesamt: banken.gesamt })}