From e0d8882851b9fbd52ca08fa9e89e48ee045021da Mon Sep 17 00:00:00 2001 From: Guido Date: Sun, 30 Aug 2026 13:11:35 +0200 Subject: [PATCH] fix: Automatischer Abruf lief gegen die Abfragegrenze der Banken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Weniger als 15 Minuten lassen die Banken nicht zu" stand als Hinweis im Formular. Das war erfunden. PSD2 verpflichtet die Banken zu VIER unbeaufsichtigten Abfragen je Konto und Tag – haeufiger duerfen sie ablehnen, und N26 tut es: "Too many requests", und der Abruf scheitert, bis das Zeitfenster weiterrueckt. Bei zehn angebundenen Konten und einem Takt von 15 Minuten waren es tausende Anfragen am Tag. Der Takt betraegt jetzt mindestens sechs Stunden, also genau die zugesicherten vier je Tag. Bestehende, zu knappe Einstellungen werden beim Faelligkeitspruefen mitgebremst – sonst liefe eine gespeicherte 15 weiter dagegen. Von Hand abrufen bleibt unbegrenzt: Dabei sitzt der Nutzer davor, und fuer diesen Fall gilt die Grenze nicht. Co-Authored-By: Claude Opus 5 --- backend/app/routers/bank.py | 13 +++++++--- backend/app/services/bank_sync.py | 11 ++++++++- backend/tests/test_bank.py | 31 +++++++++++++++++++----- web/src/locales/de.json | 2 +- web/src/locales/en.json | 2 +- web/src/pages/settings/Bankanbindung.jsx | 4 +-- 6 files changed, 48 insertions(+), 15 deletions(-) diff --git a/backend/app/routers/bank.py b/backend/app/routers/bank.py index f62c4db..000bcab 100644 --- a/backend/app/routers/bank.py +++ b/backend/app/routers/bank.py @@ -158,14 +158,19 @@ def set_poll(connection_id: int, minutes: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Takt des automatischen Abrufs. 0 schaltet ihn ab. - Untergrenze 15 Minuten: Banken begrenzen unter PSD2 die Zahl der Abrufe je - Tag, und häufiger bringt nichts – Umsätze erscheinen nicht im Minutentakt. + Untergrenze sechs Stunden: PSD2 verpflichtet die Banken zu VIER + unbeaufsichtigten Abfragen je Konto und Tag. Häufiger dürfen sie ablehnen, + und sie tun es – N26 antwortet dann mit „Too many requests", und der Abruf + scheitert, bis das Zeitfenster weiterrückt. Von Hand abrufen darf man + weiterhin so oft man will: Dabei sitzt der Nutzer davor. """ verbindung = get_owned(db, BankConnection, connection_id, user) - if minutes != 0 and not 15 <= minutes <= 1440: + if minutes != 0 and not bank_sync.MIN_TAKT_MINUTEN <= minutes <= 10080: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Der Takt muss 0 (aus) oder zwischen 15 und 1440 Minuten liegen.", + detail=(f"Der Takt muss 0 (aus) oder mindestens " + f"{bank_sync.MIN_TAKT_MINUTEN} Minuten sein. Die Banken lassen " + f"nur vier unbeaufsichtigte Abrufe je Konto und Tag zu."), ) verbindung.poll_minutes = minutes db.commit() diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 0e973eb..b2c181e 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -45,6 +45,12 @@ log = logging.getLogger(__name__) ZUSTIMMUNG_TAGE = 180 # Wie weit jeder weitere Abruf hinter den letzten zurueckgreift. RUECKGRIFF_TAGE = 7 +# Kuerzester Takt fuer den automatischen Abruf. PSD2 verpflichtet die Banken zu +# VIER unbeaufsichtigten Abfragen je Konto und Tag – haeufiger duerfen sie +# ablehnen, und N26 tut das ("Too many requests"). Sechs Stunden sind genau +# diese vier. Von Hand abrufen darf man weiterhin so oft man will: Dabei sitzt +# der Nutzer davor, und dafuer gilt die Grenze nicht. +MIN_TAKT_MINUTEN = 360 # Ab wann gewarnt wird, dass neu autorisiert werden muss. Läuft die Zustimmung # ab, hört der Abruf sonst stumm auf – dieselbe Falle wie „0 neu" ohne Grund. WARNUNG_TAGE = 14 @@ -777,10 +783,13 @@ def faellig(verbindung: BankConnection, jetzt: datetime) -> bool: """ if not verbindung.poll_minutes: return False + # Auch aeltere, zu knapp eingestellte Werte werden hier gebremst – sonst + # liefe eine bestehende Einstellung von 15 Minuten weiter gegen die Grenze. + takt = max(verbindung.poll_minutes, MIN_TAKT_MINUTEN) zuletzt = _mit_zeitzone(verbindung.last_sync_at) if zuletzt is None: return True - return zuletzt + timedelta(minutes=verbindung.poll_minutes) <= jetzt + return zuletzt + timedelta(minutes=takt) <= jetzt def abruf_mit_fehlerbehandlung(db: Session, user: User, diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 4d29de6..6e1bfe7 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -812,29 +812,29 @@ def test_takt_null_laeuft_nie_und_sonst_erst_wenn_faellig(admin): assert bank_sync.faellig(aus, jetzt) is False nie_gelaufen = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE", - poll_minutes=60) + poll_minutes=720) assert bank_sync.faellig(nie_gelaufen, jetzt) is True frisch = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE", - poll_minutes=60, + poll_minutes=720, last_sync_at=jetzt - timedelta(minutes=30)) assert bank_sync.faellig(frisch, jetzt) is False # SQLite gibt Zeitstempel OHNE Zeitzone zurueck – das darf nicht werfen. ohne_zone = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE", - poll_minutes=60, - last_sync_at=(jetzt - timedelta(hours=2)).replace(tzinfo=None)) + poll_minutes=720, + last_sync_at=(jetzt - timedelta(hours=13)).replace(tzinfo=None)) assert bank_sync.faellig(ohne_zone, jetzt) is True def test_takt_wird_geprueft(admin, fake_bank): verbindung, _, _ = _verbunden(admin, fake_bank) assert admin.put(f"/api/bank/connections/{verbindung}/takt", - params={"minutes": 5}).status_code == 400 + params={"minutes": 15}).status_code == 400, "zu haeufig" assert admin.put(f"/api/bank/connections/{verbindung}/takt", params={"minutes": 0}).json()["poll_minutes"] == 0 assert admin.put(f"/api/bank/connections/{verbindung}/takt", - params={"minutes": 240}).json()["poll_minutes"] == 240 + params={"minutes": 720}).json()["poll_minutes"] == 720 def test_spaeter_gelieferte_gegenseite_wird_nicht_noch_einmal_gebucht(admin, fake_bank): @@ -1288,3 +1288,22 @@ def test_saldo_kommt_erst_nach_den_umsaetzen(admin, fake_bank, monkeypatch): aus_liste = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] assert aus_liste["balance_cents"] is None, "Saldo ohne Umsaetze geschrieben" + + +def test_zu_knapper_takt_wird_gebremst(): + """Eine bestehende Einstellung von 15 Minuten liefe sonst weiter gegen die + Grenze der Bank – und jeder Abruf scheiterte mit 'Too many requests'.""" + from datetime import datetime, timezone + + from app.models import BankConnection + from app.services import bank_sync + + jetzt = datetime.now(timezone.utc) + knapp = BankConnection(owner_id=1, aspsp_name="N26", aspsp_country="DE", + poll_minutes=15, + last_sync_at=jetzt - timedelta(minutes=20)) + assert bank_sync.faellig(knapp, jetzt) is False, "15 Minuten duerfen nicht greifen" + + # Nach sechs Stunden schon. + knapp.last_sync_at = jetzt - timedelta(hours=7) + assert bank_sync.faellig(knapp, jetzt) is True diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 5e8b8aa..7cd21f9 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -650,7 +650,7 @@ "syncOne": "Umsätze holen", "poll": "Automatisch holen", "pollUnit": "Minuten", - "pollHint": "Certain holt die Umsätze dann von selbst und bucht sie. 0 schaltet das ab; weniger als 15 Minuten lassen die Banken nicht zu.", + "pollHint": "Certain holt die Umsätze dann von selbst und bucht sie. 0 schaltet das ab. Mindestens 360 Minuten: Die Banken müssen nur vier unbeaufsichtigte Abrufe je Konto und Tag zulassen – häufiger lehnen sie ab. Von Hand abrufen darfst du so oft du willst.", "pollSaved": "Takt gespeichert.", "syncOlder": "Älteres nachholen", "syncedDuplicate": "{{n}} standen schon da (aus Datei oder von Hand) und wurden übersprungen.", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index de0b613..6c6bd9c 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -650,7 +650,7 @@ "syncOne": "Fetch transactions", "poll": "Fetch automatically", "pollUnit": "minutes", - "pollHint": "Certain then fetches and books transactions on its own. 0 turns this off; banks do not allow less than 15 minutes.", + "pollHint": "Certain then fetches and books transactions on its own. 0 turns this off. At least 360 minutes: banks only have to allow four unattended fetches per account per day – more often they refuse. Fetching by hand is unlimited.", "pollSaved": "Interval saved.", "syncOlder": "Catch up on older", "syncedDuplicate": "{{n}} were already there (from a file or entered by hand) and were skipped.", diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index 9a024d6..14a4dd9 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -354,9 +354,9 @@ export default function Bankanbindung() {