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