fix: Automatischer Abruf lief gegen die Abfragegrenze der Banken
"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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -354,9 +354,9 @@ export default function Bankanbindung() {
|
||||
<label style={{ maxWidth: 260 }}>
|
||||
<span>{t("bank.poll")}</span>
|
||||
<div className="input-with-button">
|
||||
<input type="number" min="0" max="1440" defaultValue={v.poll_minutes}
|
||||
<input type="number" min="0" max="10080" step="60" defaultValue={v.poll_minutes}
|
||||
onBlur={(e) => {
|
||||
const n = Math.max(0, Math.min(1440, Number(e.target.value) || 0));
|
||||
const n = Math.max(0, Math.min(10080, Number(e.target.value) || 0));
|
||||
if (n !== v.poll_minutes) taktSetzen(v, n);
|
||||
}} />
|
||||
<span className="muted small">{t("bank.pollUnit")}</span>
|
||||
|
||||
Reference in New Issue
Block a user