Files
certain/web/src/pages/settings/Bankanbindung.jsx
T
GuidoandClaude Opus 5 4c2e72cb20 fix: Bankanbindung gliedern – und ein Platzhalter, der nicht ersetzt wurde
Im Kopf jeder Verbindung stand woertlich "zuletzt {{when}}": Der Text erwartet
`when`, uebergeben wurde `datum`.

Die Gliederung lieh sich die Klassen des Postfachs – gestrichelte Trennlinien,
sonst nichts. Damit war weder zu sehen, wo eine Bank aufhoert, noch welcher
Knopf zu welchem Konto gehoert, und das Suchfeld fuer eine neue Bank
verschwamm mit den Angaben der schon verbundenen.

Jetzt: Jede Verbindung ist ein umrandeter Block mit Kopfzeile. Jedes Konto
darin hat eine eigene linke Kante – die bindet seine Knoepfe sichtbar an es.
Die Knoepfe der VERBINDUNG (alle Konten, Salden, trennen) stehen ohne
Einrueckung hinter einer Trennlinie, gehoeren also erkennbar zur Bank und
nicht zum letzten Konto. "Weitere Bank verbinden" ist mit eigener Ueberschrift
abgesetzt.

Nebenbei brach "Minuten (0 = aus)" neben dem Zahlenfeld um und las sich wie
ein Fehler; die Klammer steht ohnehin im Hinweis darunter.

Im Browser nachgemessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:04:32 +02:00

514 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="card">
<div className="card-head">
<Icon name="landmark" size={18} />
<h2>{t("bank.title")}</h2>
</div>
<p className="muted small">{t("bank.intro")}</p>
{/* Schritt 1: die Anwendung dieser Installation */}
{!app ? (
<div className="alert" style={{ display: "block", marginTop: "var(--sp-3)" }}>
<strong>{t("bank.setupTitle")}</strong>
<p className="muted small">{t("bank.setupHint")}</p>
<ol className="muted small" style={{ paddingLeft: "1.2em" }}>
<li>{t("bank.setupStep1")}</li>
<li>{t("bank.setupStep2")}</li>
<li>
{t("bank.setupStep3")}
<div className="input-with-button" style={{ marginTop: 4 }}>
<input readOnly className="selectable" value={`${basis}/bank/rueckkehr`} />
<button type="button" className="btn sm"
onClick={() => navigator.clipboard?.writeText(`${basis}/bank/rueckkehr`)}>
<Icon name="copy" size={14} />
</button>
</div>
</li>
</ol>
<button className="btn primary" style={{ marginTop: "var(--sp-2)" }}
onClick={() => setForm({ application_id: "", private_key: "", base_url: basis })}>
<Icon name="plus" size={15} /> {t("bank.setupStart")}
</button>
</div>
) : (
<div className="mail-source">
<div><strong>{t("bank.appReady")}</strong></div>
<div className="muted small selectable">{app.application_id}</div>
<div className="muted small">{app.base_url}</div>
<div className="mail-actions">
<button className="btn sm ghost"
onClick={() => setForm({ application_id: app.application_id,
private_key: "", base_url: app.base_url })}>
<Icon name="edit" size={15} /> {t("common.edit")}
</button>
</div>
</div>
)}
{/* Schritt 2: bestehende Verbindungen samt Kontozuordnung */}
{verbindungen.map((v) => (
<div className="bank-verbindung" key={v.id}>
<div className="bank-verbindung-kopf">
<Icon name="landmark" size={16} />
<strong>{v.aspsp_name}</strong>
<span className="muted small">{v.aspsp_country}</span>
</div>
<div className="muted small">
{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")}
</div>
{v.valid_until && (
<div className={v.expires_soon ? "muted small warn" : "muted small"}>
{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)) })}
</div>
)}
<div style={{ marginTop: "var(--sp-2)" }}>
{linksVon(v).length === 0 ? (
<p className="muted small">{t("bank.noAccountsFound")}</p>
) : (
linksVon(v).map((l) => (
<div className="bank-konto" key={l.id}>
<div className="muted small selectable">
<strong>{l.remote_name || l.iban || l.remote_uid}</strong>
{/* 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}`}
</div>
<select value={l.account_id || ""}
onChange={(e) => zuordnen(l, e.target.value)}>
<option value="">{t("bank.notMapped")}</option>
{konten.filter((k) => k.currency === l.currency).map((k) => (
<option key={k.id} value={k.id}>{k.name}</option>
))}
</select>
{l.account_id ? null : (
<p className="muted small" style={{ margin: "4px 0 0" }}>
{/* 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 })}
</p>
)}
{l.balance_cents != null && (
<p className="muted small" style={{ margin: "4px 0 0" }}>
{t("bank.bankBalance")}:{" "}
<strong>{formatMoney(l.balance_cents, l.currency)}</strong>
{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 ? (
<span className="saldo-ok"> · {t("bank.balanceMatches")}</span>
) : (
<span className="saldo-abweichung">
{" "}· {t("bank.balanceDiffers", {
diff: formatMoney(diff, l.currency),
})}
</span>
);
})()
)}
</p>
)}
{l.account_id && (
<div className="mail-actions" style={{ marginTop: 4 }}>
<button className="btn sm" disabled={busy}
onClick={() => kontoAbrufen(l)}>
{t("bank.syncOne")}
</button>
{/* Fehlt etwas, liegt es meist vor dem Fenster des
letzten Abrufs und käme sonst nie wieder. */}
<button className="btn sm" disabled={busy}
onClick={() => kontoAbrufen(l, 90)}>
{t("bank.syncOlder")}
</button>
<span className="muted small">
{l.last_sync_at
? t("bank.lastSync", { when: formatDate(l.last_sync_at) })
: t("bank.neverSynced")}
</span>
</div>
)}
{Object.keys(l.bank_felder || {}).length > 0 && (
<details className="muted small" style={{ marginTop: 4 }}>
<summary>{t("bank.rawFields")}</summary>
<div className="selectable" style={{ marginTop: 4 }}>
{Object.entries(l.bank_felder).map(([feld, wert]) => (
<div key={feld}>
{feld}: {typeof wert === "object" && wert !== null
? JSON.stringify(wert)
: String(wert)}
</div>
))}
</div>
</details>
)}
</div>
))
)}
</div>
<p className="muted small">{t("bank.syncHint")}</p>
<label style={{ maxWidth: 260 }}>
<span>{t("bank.poll")}</span>
<div className="input-with-button">
<input type="number" min="0" max="1440" defaultValue={v.poll_minutes}
onBlur={(e) => {
const n = Math.max(0, Math.min(1440, Number(e.target.value) || 0));
if (n !== v.poll_minutes) taktSetzen(v, n);
}} />
<span className="muted small">{t("bank.pollUnit")}</span>
</div>
</label>
<p className="muted small" style={{ marginTop: -8 }}>{t("bank.pollHint")}</p>
<div className="mail-actions">
<button className="btn sm primary" disabled={busy} onClick={() => abrufen(v)}>
{t("bank.syncAll")}
</button>
<button className="btn sm" disabled={busy} onClick={() => saldenHolen(v)}>
{t("bank.fetchBalances")}
</button>
<button className="btn sm danger" onClick={() => trennen(v)}>
<Icon name="trash" size={15} />
</button>
</div>
</div>
))}
{/* Schritt 3: neue Bank verbinden. Deutlich abgesetzt – sonst verschwimmt
das Suchfeld mit den Angaben der schon verbundenen Banken. */}
{app && (
<div className="bank-neu">
<h3>{t("bank.addTitle")}</h3>
<form onSubmit={bankenLaden}>
<label className="field">
<span>{t("bank.search")}</span>
<input value={suche} placeholder={t("bank.searchPlaceholder")}
onChange={(e) => setSuche(e.target.value)} />
</label>
<p className="muted small">{t("bank.searchHint")}</p>
<label className="field">
<span>{t("bank.country")}</span>
<select value={land} onChange={(e) => setLand(e.target.value)}>
<option value="">{t("bank.allCountries")}</option>
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</label>
<button className="btn" disabled={busy || (!suche.trim() && !land)}>
<Icon name="search" size={15} /> {t("bank.findBanks")}
</button>
</form>
{banken && (
<div style={{ marginTop: "var(--sp-2)" }}>
{banken.treffer.length === 0 ? (
// Der Unterschied zwischen „Liste kam leer" und „nichts passte"
// entscheidet, wo man den Fehler sucht.
<div className="alert" style={{ display: "block" }}>
<p className="muted small" style={{ marginTop: 0 }}>
{banken.gesamt === 0 ? t("bank.noneLoaded")
: t("bank.noMatch", { gesamt: banken.gesamt })}
</p>
{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. */}
<p className="muted small" style={{ marginBottom: 2 }}>
{t("bank.sampleTitle")}
</p>
<ul className="muted small selectable"
style={{ paddingLeft: "1.2em", margin: 0 }}>
{(banken.beispiele || []).map((n) => <li key={n}>{n}</li>)}
</ul>
<p className="muted small" style={{ marginBottom: 2 }}>
{t("bank.range", { von: banken.von, bis: banken.bis })}
</p>
<p className="muted small selectable">
{t("bank.letters")}: {(banken.anfangsbuchstaben || []).join(" ")}
</p>
<p className="muted small">{t("bank.truncatedHint")}</p>
{(banken.gleicher_buchstabe || []).length > 0 && (
<>
<p className="muted small" style={{ marginBottom: 2 }}>
{t("bank.sameLetter", { buchstabe: suche.trim()[0].toUpperCase() })}
</p>
<ul className="muted small selectable"
style={{ paddingLeft: "1.2em", margin: 0 }}>
{banken.gleicher_buchstabe.map((n) => <li key={n}>{n}</li>)}
</ul>
</>
)}
</>
)}
</div>
) : (
<>
<p className="muted small">
{banken.gekuerzt
? t("bank.pickBankMore", { n: banken.treffer.length,
treffer: banken.treffer_gesamt })
: t("bank.pickBank", { n: banken.treffer_gesamt,
gesamt: banken.gesamt })}
</p>
<div className="account-list">
{banken.treffer.map((b) => (
<div className="account-row" key={`${b.name}-${b.country}`}>
<span className="grow ellipsis">
{b.name} <span className="muted small">{b.country}</span>
</span>
<button className="btn sm primary" disabled={busy}
onClick={() => verbinden(b)}>
{t("bank.connect")}
</button>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
)}
{form !== null && (
<Modal
title={t("bank.appTitle")}
onClose={() => setForm(null)}
footer={
<>
<button type="button" className="btn ghost" onClick={() => setForm(null)}>
{t("common.cancel")}
</button>
<button type="submit" form="bank-app-form" className="btn primary" disabled={busy}>
{t("common.save")}
</button>
</>
}
>
<form id="bank-app-form" onSubmit={speichern}>
<label className="field">
<span>{t("bank.applicationId")}</span>
<input value={form.application_id} required
onChange={(e) => setForm({ ...form, application_id: e.target.value })} />
</label>
<label className="field">
<span>{app ? t("bank.privateKeyKeep") : t("bank.privateKey")}</span>
<textarea rows={6} value={form.private_key} required={!app}
placeholder={"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkq…\n-----END PRIVATE KEY-----"}
onChange={(e) => setForm({ ...form, private_key: e.target.value })} />
</label>
<p className="muted small">{t("bank.privateKeyHint")}</p>
<label className="field">
<span>{t("bank.baseUrl")}</span>
<input value={form.base_url} required
onChange={(e) => setForm({ ...form, base_url: e.target.value })} />
</label>
<p className="muted small">{t("bank.baseUrlHint")}</p>
</form>
</Modal>
)}
</div>
);
}