fix: "Keine Treffer" sagt jetzt, woran es liegt

Bei der Bank-Suche kam nur "Keine Treffer" – das hiess zweierlei: Die
Schnittstelle lieferte gar keine Liste, oder sie lieferte eine und der
Suchbegriff passte nicht. Beides sieht gleich aus, fuehrt aber an ganz
verschiedene Stellen. Genau diese Verwechslung hat heute schon zweimal Zeit
gekostet.

Die Antwort traegt jetzt die Gesamtzahl mit. Kam nichts, steht der
wahrscheinliche Grund dabei: Die Anwendung bei Enable Banking wird erst
aktiv, wenn man dort die eigenen Konten verknuepft hat.

Ausserdem wird breiter gesucht – auch in BIC und Gruppenname, weil Banken in
so einer Liste nicht immer so heissen wie im Alltag – und die Antwort wird
defensiv gelesen, falls die Liste ohne Schluessel kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-26 21:48:59 +02:00
co-authored by Claude Opus 5
parent 71e8419634
commit 9a0c607e8c
7 changed files with 84 additions and 20 deletions
+1 -1
View File
@@ -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
+7 -1
View File
@@ -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:
+12 -4
View File
@@ -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,
+44 -5
View File
@@ -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
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+12 -5
View File
@@ -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 && (
<div style={{ marginTop: "var(--sp-2)" }}>
{banken.length === 0 ? (
<p className="muted small">{t("bank.noBanks")}</p>
{banken.treffer.length === 0 ? (
// Der Unterschied zwischen „Liste kam leer" und „nichts passte"
// entscheidet, wo man den Fehler sucht.
<p className="muted small">
{banken.gesamt === 0 ? t("bank.noneLoaded")
: t("bank.noMatch", { gesamt: banken.gesamt })}
</p>
) : (
<>
<p className="muted small">{t("bank.pickBank", { n: banken.length })}</p>
<p className="muted small">
{t("bank.pickBank", { n: banken.treffer.length, gesamt: banken.gesamt })}
</p>
<div className="account-list">
{banken.map((b) => (
{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>