diff --git a/backend/app/routers/bank.py b/backend/app/routers/bank.py index 48bdd47..0faabfe 100644 --- a/backend/app/routers/bank.py +++ b/backend/app/routers/bank.py @@ -58,7 +58,7 @@ def delete_app(db: Session = Depends(get_db), user: User = Depends(get_current_u @router.get("/banks") def list_banks(country: str | None = None, search: str | None = None, db: Session = Depends(get_db), - user: User = Depends(get_current_user)) -> list[dict]: + user: User = Depends(get_current_user)) -> dict: """Banken suchen – in einem Land oder über alle hinweg. Ohne ``country`` wird überall gesucht. Das braucht es, weil die IBAN nicht diff --git a/backend/app/services/bank_api.py b/backend/app/services/bank_api.py index cd04c0e..706d541 100644 --- a/backend/app/services/bank_api.py +++ b/backend/app/services/bank_api.py @@ -116,7 +116,13 @@ class BankClient: 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", []) + daten = self._anfrage("GET", pfad) + # Defensiv: manche Fassungen liefern die Liste direkt statt unter + # einem Schlüssel. Eine leere Antwort stillschweigend als „keine + # Treffer" auszugeben hat schon genug Zeit gekostet. + if isinstance(daten, list): + return daten + return daten.get("aspsps") or daten.get("data") or [] 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 a7093a3..618c83b 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -66,7 +66,7 @@ MAX_TREFFER = 200 def banken(db: Session, user: User, land: str | None = None, - suche: str | None = None) -> list[dict]: + suche: str | None = None) -> dict: """Banken suchen – in einem Land oder über alle hinweg. Die Suche über alle Länder ist kein Luxus: Das Sitzland einer Bank steht @@ -80,12 +80,20 @@ def banken(db: Session, user: User, land: str | None = None, name = b.get("name") if not name: continue - if begriff and begriff not in name.lower(): + # Auch im BIC und im Gruppennamen suchen: Banken heissen in der Liste + # nicht immer so wie im Alltag. + heu = " ".join(str(x) for x in (name, b.get("bic"), b.get("group")) if x).lower() + if begriff and begriff not in heu: continue treffer.append({"name": name, "country": b.get("country"), - "logo": b.get("logo"), "psu_types": b.get("psu_types", [])}) + "logo": b.get("logo"), "psu_types": b.get("psu_types", []), + "beta": bool(b.get("beta"))}) treffer.sort(key=lambda b: (b["name"].lower(), b.get("country") or "")) - return treffer[:MAX_TREFFER] + # „gesamt" mitliefern: Sonst sieht „keine Treffer" gleich aus, egal ob die + # Liste leer kam oder der Suchbegriff nicht passte – und man sucht den + # Fehler an der falschen Stelle. + return {"gesamt": len(roh), "treffer": treffer[:MAX_TREFFER], + "gekuerzt": len(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 8d810e5..8adf0c7 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -191,7 +191,7 @@ def test_banken_eines_landes_abrufen(admin, fake_bank): 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"] + assert [b["name"] for b in r.json()["treffer"]] == ["N26"] def test_verbinden_und_konten_finden(admin, fake_bank): @@ -377,13 +377,17 @@ def test_suche_findet_ueber_alle_laender(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" + daten = alle.json() + assert [b["name"] for b in daten["treffer"]] == ["Revolut"] + assert daten["treffer"][0]["country"] == "LT" + # Die Gesamtzahl kommt mit: Sonst sieht „keine Treffer" gleich aus, egal ob + # die Liste leer kam oder nur der Begriff nicht passte. + assert daten["gesamt"] == 2 # Unter Deutschland gesucht liefert dieselbe Bank NICHT. nur_de = admin.get("/api/bank/banks?country=DE&search=revolut").json() - assert nur_de == [] + assert nur_de["treffer"] == [] + assert nur_de["gesamt"] == 1 def test_suche_ohne_land_und_ohne_begriff_wird_abgelehnt(admin, fake_bank): @@ -393,3 +397,38 @@ def test_suche_ohne_land_und_ohne_begriff_wird_abgelehnt(admin, fake_bank): r = admin.get("/api/bank/banks") assert r.status_code == 400 assert "Suchbegriff" in r.json()["detail"] + + + +def test_leere_bankliste_ist_von_keinem_treffer_unterscheidbar(admin, fake_bank): + """„Keine Treffer" darf nicht beides heissen. + + Kam die Liste leer, liegt es an der Anwendung; passte nur der Begriff + nicht, liegt es am Begriff. Ohne die Gesamtzahl sucht man den Fehler an + der falschen Stelle – das hat schon einmal einen Abend gekostet. + """ + _app(admin) + gegenstelle = fake_bank() + gegenstelle.banken_leer = True + + def leer(methode, pfad, **kw): + if pfad.startswith("/aspsps"): + return 200, {"aspsps": []} + return FakeBank.__call__(gegenstelle, methode, pfad, **kw) + + import app.services.bank_api as bank_api_modul + urspruenglich = bank_api_modul.BankClient._anfrage + + def _anfrage(self, methode, pfad, **rest): + code, daten = leer(methode, pfad, **rest) + if code >= 400: + raise bank_api_modul.BankFehler(daten.get("message", "Fehler")) + return daten + + bank_api_modul.BankClient._anfrage = _anfrage + try: + daten = admin.get("/api/bank/banks?search=revolut").json() + assert daten["gesamt"] == 0 + assert daten["treffer"] == [] + finally: + bank_api_modul.BankClient._anfrage = urspruenglich diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 8d4737d..e19aa6c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -583,7 +583,7 @@ "country": "Land der Bank", "findBanks": "Banken suchen", "noBanks": "Keine Treffer. Versuch einen kürzeren Suchbegriff, oder lass das Land leer.", - "pickBank": "{{n}} Treffer – wähle deine Bank:", + "pickBank": "{{n}} Treffer von {{gesamt}} geladenen Banken – wähle deine aus:", "connect": "Verbinden", "notMapped": "noch keinem Konto zugeordnet", "mapHint": "Ohne Zuordnung wird von diesem Konto nichts abgerufen.", @@ -601,7 +601,9 @@ "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" + "allCountries": "alle Länder", + "noMatch": "{{gesamt}} Banken geladen, aber keine passt zum Suchbegriff. Versuch etwas Kürzeres – manche heissen in der Liste anders als im Alltag.", + "noneLoaded": "Die Schnittstelle hat gar keine Banken geliefert. Das liegt nicht am Suchbegriff: Prüf, ob deine Anwendung bei Enable Banking schon aktiv ist – sie wird es erst, wenn du dort deine eigenen Konten verknüpft hast." }, "bankReturn": { "title": "Rückkehr von der Bank", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 5290d9e..02ac531 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -583,7 +583,7 @@ "country": "Country of the bank", "findBanks": "Find banks", "noBanks": "No matches. Try a shorter term, or leave the country empty.", - "pickBank": "{{n}} matches – pick your bank:", + "pickBank": "{{n}} of {{gesamt}} loaded banks – pick yours:", "connect": "Connect", "notMapped": "not mapped to an account yet", "mapHint": "Without a mapping nothing is fetched from this account.", @@ -601,7 +601,9 @@ "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" + "allCountries": "all countries", + "noMatch": "{{gesamt}} banks loaded, but none matches. Try something shorter – some are listed under a different name than the one you know.", + "noneLoaded": "The interface returned no banks at all. That is not the search term: check whether your Enable Banking application is active yet – it only becomes active once you have linked your own accounts there." }, "bankReturn": { "title": "Back from your bank", diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index 4d57160..4b6ee96 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -33,7 +33,7 @@ export default function Bankanbindung() { const [links, setLinks] = useState([]); const [land, setLand] = useState(""); const [suche, setSuche] = useState(""); - const [banken, setBanken] = useState(null); + const [banken, setBanken] = useState(null); // { gesamt, treffer, gekuerzt } const [busy, setBusy] = useState(false); const basis = useMemo(() => window.location.origin, []); @@ -262,13 +262,20 @@ export default function Bankanbindung() { {banken && (
- {banken.length === 0 ? ( -

{t("bank.noBanks")}

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

) : ( <> -

{t("bank.pickBank", { n: banken.length })}

+

+ {t("bank.pickBank", { n: banken.treffer.length, gesamt: banken.gesamt })} +

- {banken.map((b) => ( + {banken.treffer.map((b) => (
{b.name} {b.country}