From 71e84196348c1cb8a2feda430c4897e91f1da6fd Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 26 Aug 2026 21:42:08 +0200 Subject: [PATCH] feat: Bank ueber alle Laender suchen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unter "Deutschland" liessen sich N26 und Revolut nicht finden – und bei Revolut kann das gar nicht klappen: Die Bank vergibt deutsche IBANs, ist aber die Revolut Bank UAB in Litauen. Das Sitzland einer Bank steht nicht in der IBAN, also ist die Laenderauswahl der falsche Einstieg. Jetzt ein Suchfeld, das ohne Land ueber alle sucht; das Land ist nur noch ein optionaler Filter. Der Treffer zeigt sein Land mit an, und verbunden wird mit dem Land der BANK statt dem des Filters – sonst schluege die Autorisierung fehl, sobald ohne Land gesucht wurde. Ohne Land UND ohne Begriff wird abgelehnt: Sonst kaemen alle Banken Europas ins Formular. Co-Authored-By: Claude Opus 5 --- backend/app/routers/bank.py | 16 +++++++-- backend/app/services/bank_api.py | 13 ++++--- backend/app/services/bank_sync.py | 32 ++++++++++++++---- backend/tests/test_bank.py | 41 ++++++++++++++++++++-- web/src/api.js | 7 +++- web/src/locales/de.json | 10 ++++-- web/src/locales/en.json | 10 ++++-- web/src/pages/settings/Bankanbindung.jsx | 43 ++++++++++++++++-------- 8 files changed, 134 insertions(+), 38 deletions(-) diff --git a/backend/app/routers/bank.py b/backend/app/routers/bank.py index 151a074..48bdd47 100644 --- a/backend/app/routers/bank.py +++ b/backend/app/routers/bank.py @@ -56,11 +56,21 @@ def delete_app(db: Session = Depends(get_db), user: User = Depends(get_current_u @router.get("/banks") -def list_banks(country: str, db: Session = Depends(get_db), +def list_banks(country: str | None = None, search: str | None = None, + db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]: - """Verfügbare Banken eines Landes – für die Auswahl beim Verbinden.""" + """Banken suchen – in einem Land oder über alle hinweg. + + Ohne ``country`` wird überall gesucht. Das braucht es, weil die IBAN nicht + verrät, wo eine Bank sitzt: Revolut vergibt deutsche IBANs aus Litauen. + """ + if not country and not search: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Bitte ein Land wählen oder einen Suchbegriff eingeben.", + ) try: - return bank_sync.banken(db, user, country) + return bank_sync.banken(db, user, country, search) except BankFehler as e: raise _fehler(e) from e diff --git a/backend/app/services/bank_api.py b/backend/app/services/bank_api.py index 0bd7aeb..cd04c0e 100644 --- a/backend/app/services/bank_api.py +++ b/backend/app/services/bank_api.py @@ -108,10 +108,15 @@ class BankClient: # --- Die vier Endpunkte ------------------------------------------------- - def banken(self, land: str) -> list[dict]: - """Verfügbare Banken eines Landes – für die Auswahl beim Verbinden.""" - daten = self._anfrage("GET", f"/aspsps?country={land.upper()}") - return daten.get("aspsps", []) + def banken(self, land: str | None = None) -> list[dict]: + """Verfügbare Banken – eines Landes oder alle. + + Ohne Land liefert die Schnittstelle die vollständige Liste. Das ist + nötig, weil das Sitzland einer Bank nicht an der IBAN ablesbar ist: + Revolut gibt deutsche IBANs aus, ist aber in Litauen ansässig. + """ + pfad = f"/aspsps?country={land.upper()}" if land else "/aspsps" + return self._anfrage("GET", pfad).get("aspsps", []) def starte_autorisierung(self, aspsp_name: str, land: str, redirect_url: str, state: str, tage: int, psu_type: str = "personal") -> dict: diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index c190271..a7093a3 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -60,14 +60,32 @@ def _client(db: Session, user: User) -> BankClient: return BankClient(app.application_id, schluessel) -def banken(db: Session, user: User, land: str) -> list[dict]: - """Banken eines Landes, auf das Nötige eingedampft.""" +# Nicht die ganze Liste ins Formular kippen – über alle Länder sind es +# tausende. Wer sucht, bekommt Treffer; wer ein Land wählt, dessen Banken. +MAX_TREFFER = 200 + + +def banken(db: Session, user: User, land: str | None = None, + suche: str | None = None) -> list[dict]: + """Banken suchen – in einem Land oder über alle hinweg. + + Die Suche über alle Länder ist kein Luxus: Das Sitzland einer Bank steht + nicht in der IBAN. Revolut gibt deutsche IBANs aus, ist aber in Litauen + ansässig – wer unter „Deutschland" sucht, findet es dort nie. + """ roh = _client(db, user).banken(land) - return [ - {"name": b.get("name"), "country": b.get("country"), - "logo": b.get("logo"), "psu_types": b.get("psu_types", [])} - for b in roh if b.get("name") - ] + begriff = (suche or "").strip().lower() + treffer = [] + for b in roh: + name = b.get("name") + if not name: + continue + if begriff and begriff not in name.lower(): + continue + treffer.append({"name": name, "country": b.get("country"), + "logo": b.get("logo"), "psu_types": b.get("psu_types", [])}) + treffer.sort(key=lambda b: (b["name"].lower(), b.get("country") or "")) + return treffer[:MAX_TREFFER] def starte_verbindung(db: Session, user: User, aspsp_name: str, land: str, diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 94f5b60..8d810e5 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -45,8 +45,12 @@ class FakeBank: self.jwt = (headers or {}).get("Authorization", "")[7:] self.aufrufe.append(f"{methode} {pfad.split('?')[0]}") if pfad.startswith("/aspsps"): - return 200, {"aspsps": [{"name": "N26", "country": "DE"}, - {"name": "Revolut", "country": "LT"}]} + alle = [{"name": "N26", "country": "DE"}, + {"name": "Revolut", "country": "LT"}] + if "country=" in pfad: + land = pfad.split("country=")[1].split("&")[0] + alle = [b for b in alle if b["country"] == land] + return 200, {"aspsps": alle} if pfad == "/auth": return 200, {"url": "https://bank.example/authorize?x=1", "authorization_id": "auth-1"} @@ -182,11 +186,12 @@ def test_unbrauchbarer_schluessel_meldet_sich_verstaendlich(): def test_banken_eines_landes_abrufen(admin, fake_bank): + """Ein Land liefert nur dessen Banken – Revolut sitzt in Litauen.""" _app(admin) fake_bank() r = admin.get("/api/bank/banks?country=DE") assert r.status_code == 200, r.text - assert [b["name"] for b in r.json()] == ["N26", "Revolut"] + assert [b["name"] for b in r.json()] == ["N26"] def test_verbinden_und_konten_finden(admin, fake_bank): @@ -358,3 +363,33 @@ def test_ablauf_der_zustimmung_wird_gemeldet(admin, fake_bank): liste = [c for c in admin.get("/api/bank/connections").json() if c["id"] == verbindung] assert liste[0]["expires_soon"] is True + + +def test_suche_findet_ueber_alle_laender(admin, fake_bank): + """Das Sitzland steht nicht in der IBAN. + + Revolut vergibt deutsche IBANs, ist aber in Litauen ansässig. Wer unter + „Deutschland" sucht, findet es dort nie – deshalb muss ohne Land gesucht + werden können. + """ + _app(admin) + fake_bank() + + alle = admin.get("/api/bank/banks?search=revolut") + assert alle.status_code == 200, alle.text + treffer = alle.json() + assert [b["name"] for b in treffer] == ["Revolut"] + assert treffer[0]["country"] == "LT" + + # Unter Deutschland gesucht liefert dieselbe Bank NICHT. + nur_de = admin.get("/api/bank/banks?country=DE&search=revolut").json() + assert nur_de == [] + + +def test_suche_ohne_land_und_ohne_begriff_wird_abgelehnt(admin, fake_bank): + """Sonst käme die vollständige Liste aller Banken Europas ins Formular.""" + _app(admin) + fake_bank() + r = admin.get("/api/bank/banks") + assert r.status_code == 400 + assert "Suchbegriff" in r.json()["detail"] diff --git a/web/src/api.js b/web/src/api.js index 96cc236..e63685e 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -194,7 +194,12 @@ export const api = { 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)}`), + listBanks: (country, search) => { + const p = new URLSearchParams(); + if (country) p.set("country", country); + if (search) p.set("search", search); + return request(`/bank/banks?${p.toString()}`); + }, connectBank: (body) => request("/bank/connect", { method: "POST", body }), bankCallback: (state, code) => request(`/bank/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`, diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 8a10847..8d4737d 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -582,8 +582,8 @@ "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:", + "noBanks": "Keine Treffer. Versuch einen kürzeren Suchbegriff, oder lass das Land leer.", + "pickBank": "{{n}} Treffer – wähle deine Bank:", "connect": "Verbinden", "notMapped": "noch keinem Konto zugeordnet", "mapHint": "Ohne Zuordnung wird von diesem Konto nichts abgerufen.", @@ -597,7 +597,11 @@ "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." + "disconnectConfirm": "Verbindung zu „{{name}}“ trennen? Bereits abgerufene Buchungen bleiben.", + "search": "Bank suchen", + "searchPlaceholder": "z. B. revolut oder n26", + "searchHint": "Ohne Land wird überall gesucht. Das ist meist richtig: Das Sitzland einer Bank steht nicht in der IBAN – Revolut vergibt deutsche IBANs, sitzt aber in Litauen.", + "allCountries": "alle Länder" }, "bankReturn": { "title": "Rückkehr von der Bank", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 99760ee..5290d9e 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -582,8 +582,8 @@ "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:", + "noBanks": "No matches. Try a shorter term, or leave the country empty.", + "pickBank": "{{n}} matches – pick your bank:", "connect": "Connect", "notMapped": "not mapped to an account yet", "mapHint": "Without a mapping nothing is fetched from this account.", @@ -597,7 +597,11 @@ "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." + "disconnectConfirm": "Disconnect from “{{name}}”? Transactions already fetched stay.", + "search": "Search for a bank", + "searchPlaceholder": "e.g. revolut or n26", + "searchHint": "Without a country it searches everywhere, which is usually right: a bank's home country is not in the IBAN – Revolut issues German IBANs but is based in Lithuania.", + "allCountries": "all countries" }, "bankReturn": { "title": "Back from your bank", diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index 7a661c9..4d57160 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -31,7 +31,8 @@ export default function Bankanbindung() { const [konten, setKonten] = useState([]); const [verbindungen, setVerbindungen] = useState([]); const [links, setLinks] = useState([]); - const [land, setLand] = useState("DE"); + const [land, setLand] = useState(""); + const [suche, setSuche] = useState(""); const [banken, setBanken] = useState(null); const [busy, setBusy] = useState(false); @@ -72,10 +73,11 @@ export default function Bankanbindung() { } } - async function bankenLaden() { + async function bankenLaden(e) { + e?.preventDefault(); setBusy(true); try { - setBanken(await api.listBanks(land)); + setBanken(await api.listBanks(land, suche)); } catch (e) { toast(e.message, "error"); setBanken(null); @@ -87,7 +89,9 @@ export default function Bankanbindung() { async function verbinden(bank) { setBusy(true); try { - const r = await api.connectBank({ aspsp_name: bank.name, country: land }); + // 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) { @@ -237,15 +241,24 @@ export default function Bankanbindung() { {/* Schritt 3: neue Bank verbinden */} {app && (
- - +
+ +

{t("bank.searchHint")}

+ + +
{banken && (
@@ -257,7 +270,9 @@ export default function Bankanbindung() {
{banken.map((b) => (
- {b.name} + + {b.name} {b.country} +