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:
|
user: User = Depends(get_current_user)) -> dict:
|
||||||
"""Takt des automatischen Abrufs. 0 schaltet ihn ab.
|
"""Takt des automatischen Abrufs. 0 schaltet ihn ab.
|
||||||
|
|
||||||
Untergrenze 15 Minuten: Banken begrenzen unter PSD2 die Zahl der Abrufe je
|
Untergrenze sechs Stunden: PSD2 verpflichtet die Banken zu VIER
|
||||||
Tag, und häufiger bringt nichts – Umsätze erscheinen nicht im Minutentakt.
|
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)
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
verbindung.poll_minutes = minutes
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ log = logging.getLogger(__name__)
|
|||||||
ZUSTIMMUNG_TAGE = 180
|
ZUSTIMMUNG_TAGE = 180
|
||||||
# Wie weit jeder weitere Abruf hinter den letzten zurueckgreift.
|
# Wie weit jeder weitere Abruf hinter den letzten zurueckgreift.
|
||||||
RUECKGRIFF_TAGE = 7
|
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 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.
|
# ab, hört der Abruf sonst stumm auf – dieselbe Falle wie „0 neu" ohne Grund.
|
||||||
WARNUNG_TAGE = 14
|
WARNUNG_TAGE = 14
|
||||||
@@ -777,10 +783,13 @@ def faellig(verbindung: BankConnection, jetzt: datetime) -> bool:
|
|||||||
"""
|
"""
|
||||||
if not verbindung.poll_minutes:
|
if not verbindung.poll_minutes:
|
||||||
return False
|
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)
|
zuletzt = _mit_zeitzone(verbindung.last_sync_at)
|
||||||
if zuletzt is None:
|
if zuletzt is None:
|
||||||
return True
|
return True
|
||||||
return zuletzt + timedelta(minutes=verbindung.poll_minutes) <= jetzt
|
return zuletzt + timedelta(minutes=takt) <= jetzt
|
||||||
|
|
||||||
|
|
||||||
def abruf_mit_fehlerbehandlung(db: Session, user: User,
|
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
|
assert bank_sync.faellig(aus, jetzt) is False
|
||||||
|
|
||||||
nie_gelaufen = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE",
|
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
|
assert bank_sync.faellig(nie_gelaufen, jetzt) is True
|
||||||
|
|
||||||
frisch = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE",
|
frisch = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE",
|
||||||
poll_minutes=60,
|
poll_minutes=720,
|
||||||
last_sync_at=jetzt - timedelta(minutes=30))
|
last_sync_at=jetzt - timedelta(minutes=30))
|
||||||
assert bank_sync.faellig(frisch, jetzt) is False
|
assert bank_sync.faellig(frisch, jetzt) is False
|
||||||
|
|
||||||
# SQLite gibt Zeitstempel OHNE Zeitzone zurueck – das darf nicht werfen.
|
# SQLite gibt Zeitstempel OHNE Zeitzone zurueck – das darf nicht werfen.
|
||||||
ohne_zone = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE",
|
ohne_zone = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE",
|
||||||
poll_minutes=60,
|
poll_minutes=720,
|
||||||
last_sync_at=(jetzt - timedelta(hours=2)).replace(tzinfo=None))
|
last_sync_at=(jetzt - timedelta(hours=13)).replace(tzinfo=None))
|
||||||
assert bank_sync.faellig(ohne_zone, jetzt) is True
|
assert bank_sync.faellig(ohne_zone, jetzt) is True
|
||||||
|
|
||||||
|
|
||||||
def test_takt_wird_geprueft(admin, fake_bank):
|
def test_takt_wird_geprueft(admin, fake_bank):
|
||||||
verbindung, _, _ = _verbunden(admin, fake_bank)
|
verbindung, _, _ = _verbunden(admin, fake_bank)
|
||||||
assert admin.put(f"/api/bank/connections/{verbindung}/takt",
|
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",
|
assert admin.put(f"/api/bank/connections/{verbindung}/takt",
|
||||||
params={"minutes": 0}).json()["poll_minutes"] == 0
|
params={"minutes": 0}).json()["poll_minutes"] == 0
|
||||||
assert admin.put(f"/api/bank/connections/{verbindung}/takt",
|
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):
|
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()
|
aus_liste = [v for v in admin.get("/api/bank/links").json()
|
||||||
if v["remote_uid"] == uid][0]
|
if v["remote_uid"] == uid][0]
|
||||||
assert aus_liste["balance_cents"] is None, "Saldo ohne Umsaetze geschrieben"
|
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",
|
"syncOne": "Umsätze holen",
|
||||||
"poll": "Automatisch holen",
|
"poll": "Automatisch holen",
|
||||||
"pollUnit": "Minuten",
|
"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.",
|
"pollSaved": "Takt gespeichert.",
|
||||||
"syncOlder": "Älteres nachholen",
|
"syncOlder": "Älteres nachholen",
|
||||||
"syncedDuplicate": "{{n}} standen schon da (aus Datei oder von Hand) und wurden übersprungen.",
|
"syncedDuplicate": "{{n}} standen schon da (aus Datei oder von Hand) und wurden übersprungen.",
|
||||||
|
|||||||
@@ -650,7 +650,7 @@
|
|||||||
"syncOne": "Fetch transactions",
|
"syncOne": "Fetch transactions",
|
||||||
"poll": "Fetch automatically",
|
"poll": "Fetch automatically",
|
||||||
"pollUnit": "minutes",
|
"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.",
|
"pollSaved": "Interval saved.",
|
||||||
"syncOlder": "Catch up on older",
|
"syncOlder": "Catch up on older",
|
||||||
"syncedDuplicate": "{{n}} were already there (from a file or entered by hand) and were skipped.",
|
"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 }}>
|
<label style={{ maxWidth: 260 }}>
|
||||||
<span>{t("bank.poll")}</span>
|
<span>{t("bank.poll")}</span>
|
||||||
<div className="input-with-button">
|
<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) => {
|
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);
|
if (n !== v.poll_minutes) taktSetzen(v, n);
|
||||||
}} />
|
}} />
|
||||||
<span className="muted small">{t("bank.pollUnit")}</span>
|
<span className="muted small">{t("bank.pollUnit")}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user