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() {