Files
certain/backend/app/services/bank_sync.py
T
GuidoandClaude Opus 5 b9abb912e3 feat: Angebundene Konten buchen selbst – der Saldo kann nicht abweichen
Bisher legte der Bankabruf Vorschlaege in den Eingang, die man von Hand buchen
musste. Ein angebundenes Konto ist aber die Quelle der Wahrheit: Was die Bank
gebucht hat, IST gebucht. Jetzt wird es sofort gebucht; uebrig bleibt das
Zuordnen der Kategorie.

Drei Dinge mussten dafuer stimmen, sonst haette es Salden verfaelscht:

- Der erste Abruf greift nicht mehr hinter die neueste vorhandene Buchung des
  Kontos zurueck. Vorher holte er neunzig Tage – mitten in Monate, die schon
  per CSV eingelesen sind. Der Duplikatschutz haette die nicht gefunden: Sein
  Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank anders
  als im Kontoauszug. Das Startdatum steht in der Rueckmeldung.
- Vorgemerkte Umsaetze werden nicht gebucht. Sie kommen spaeter noch einmal,
  dann mit anderer Kennung, weil die Bank beim Buchen eine neue vergibt.
  Beides zu nehmen hiesse doppelt buchen. Ihre Zahl wird gemeldet, nicht
  verschwiegen.
- Nach dem Buchen wird nach Uebertraegen gesucht. Ein Uebertrag zwischen zwei
  angebundenen Konten kommt in EINEM Abruf an – beide Seiten.

Nebenbei schliesst das eine bestehende Luecke: Der Eingangs-Abgleich beim
CSV-Import sieht nur gebuchte Eintraege. Solange Bank-Vorschlaege unbeachtet
liegen blieben, haette ein spaeterer Import derselben Wochen sie verdoppelt.

Das Buchen aus dem Eingang ist dafuer vom Router in einen Dienst gewandert;
zwei Kopien derselben Umrechnung waeren genau die Stelle, an der Salden
auseinanderlaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:21:10 +02:00

580 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Bankkonten verbinden und Umsätze abholen.
Der Weg in drei Schritten, genau wie ihn die Schnittstelle vorgibt:
1. ``starte_verbindung`` → Adresse, zu der der Nutzer geschickt wird
2. ``schliesse_verbindung`` → der Rückleitungs-Code wird zur Sitzung,
und wir erfahren, welche Konten es dort gibt
3. ``hole_umsaetze`` → Umsätze je zugeordnetem Konto
Abgeholte Umsätze werden NICHT gebucht. Sie landen als Vorschlag im Eingang,
dieselbe Strecke wie Mails und Kurzbefehl-Erfassungen – und laufen dabei durch
``detect_rows``, sodass Duplikatschutz und Schlagwortregeln ohne
Sonderbehandlung greifen. Eine dritte Quelle, kein dritter Sonderweg.
"""
from __future__ import annotations
import json
import logging
import secrets
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import (
Account,
BankAccountLink,
BankApp,
BankConnection,
InboxItem,
InboxSource,
InboxStatus,
Transaction,
User,
)
from . import csv_import, eingang, geheimnis
from .bank_api import ERSTER_ABRUF_TAGE, BankClient, BankFehler
log = logging.getLogger(__name__)
# Wie lange die Zustimmung gelten soll. Die Bank kürzt selbst, wenn sie weniger
# erlaubt; mehr als 180 Tage lässt die Regelung nicht zu.
ZUSTIMMUNG_TAGE = 180
# 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
def _client(db: Session, user: User) -> BankClient:
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
if app is None:
raise BankFehler(
"Es ist noch keine Bank-Anwendung hinterlegt. Trag Application ID "
"und Schlüsseldatei in den Einstellungen ein."
)
schluessel = geheimnis.entschluesseln(app.private_key_enc)
if schluessel is None:
raise BankFehler(
"Der gespeicherte Schlüssel lässt sich nicht mehr entschlüsseln. "
"Wurde das Server-Geheimnis getauscht? Dann bitte neu hinterlegen."
)
return BankClient(app.application_id, schluessel)
# Nicht die ganze Liste ins Formular kippen – über alle Länder sind es
# tausende. Wer sucht, bekommt Treffer; wer ein Land wählt, dessen Banken.
MAX_TREFFER = 200
def _rang(name: str, begriff: str, in_name: bool) -> int:
"""Wie gut passt ein Name zum Suchbegriff? Kleiner ist besser.
Ohne Rangfolge entscheidet das Alphabet, welche Treffer die Obergrenze
überleben – und dann fällt „N26" bei der Suche nach „n" hinter 200 Banken
heraus, deren Name irgendwo ein n enthält. Genau dieser Fehler hat hier
einen Abend gekostet.
"""
klein = name.lower()
if klein == begriff:
return 0
if klein.startswith(begriff):
return 1
if any(wort.startswith(begriff) for wort in klein.replace("-", " ").split()):
return 2
if begriff in klein:
return 3
return 4 if not in_name else 3
def banken(db: Session, user: User, land: str | None = None,
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
nicht in der IBAN. Revolut gibt deutsche IBANs aus, ist aber in Litauen
ansässig – wer unter „Deutschland" sucht, findet es dort nie.
"""
roh = _client(db, user).banken(land)
begriff = (suche or "").strip().lower()
passend = []
for b in roh:
name = b.get("name")
if not name:
continue
name = str(name)
# Auch BIC und Gruppenname durchsuchen: Banken heissen in der Liste
# nicht immer so wie im Alltag.
neben = " ".join(str(x) for x in (b.get("bic"), b.get("group")) if x).lower()
if begriff and begriff not in name.lower() and begriff not in neben:
continue
passend.append({
"name": name, "country": b.get("country"), "logo": b.get("logo"),
"psu_types": b.get("psu_types", []), "beta": bool(b.get("beta")),
"_rang": _rang(name, begriff, True) if begriff else 3,
})
# Erst nach Güte, dann alphabetisch – und ERST DANN kürzen.
passend.sort(key=lambda b: (b["_rang"], b["name"].lower(), b.get("country") or ""))
gezeigt = [{k: v for k, v in b.items() if k != "_rang"} for b in passend[:MAX_TREFFER]]
namen = sorted({str(b.get("name")) for b in roh if b.get("name")})
beispiele = (namen[:3] + namen[len(namen) // 2: len(namen) // 2 + 2] + namen[-3:]
if len(namen) > 8 else namen)
return {
"gesamt": len(roh),
# Die ECHTE Trefferzahl, nicht die gekürzte. Sonst steht dort immer
# „200" und man haelt die Obergrenze für ein Suchergebnis.
"treffer_gesamt": len(passend),
"treffer": gezeigt,
"gekuerzt": len(passend) > MAX_TREFFER,
"beispiele": beispiele,
"von": namen[0] if namen else None,
"bis": namen[-1] if namen else None,
"anfangsbuchstaben": sorted({n[:1].upper() for n in namen}),
"gleicher_buchstabe": (
[n for n in namen if begriff and n[:1].lower() == begriff[:1]][:40]
if begriff else []
),
}
def starte_verbindung(db: Session, user: User, aspsp_name: str, land: str,
psu_type: str = "personal") -> dict:
"""Autorisierung anstossen und die Weiterleitungs-Adresse liefern."""
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
if app is None:
raise BankFehler("Es ist noch keine Bank-Anwendung hinterlegt.")
state = secrets.token_urlsafe(24)
# Dieselbe Bank erneut verbinden frischt den bestehenden Eintrag auf, statt
# einen zweiten anzulegen. Sonst müsste man vorher löschen – und dabei
# gingen die Kontozuordnungen mit verloren, obwohl man sie behalten will.
verbindung = (
db.query(BankConnection)
.filter(BankConnection.owner_id == user.id,
BankConnection.aspsp_name == aspsp_name,
BankConnection.aspsp_country == land.upper())
.order_by(BankConnection.id.desc())
.first()
)
if verbindung is None:
verbindung = BankConnection(
owner_id=user.id, aspsp_name=aspsp_name, aspsp_country=land.upper(),
)
db.add(verbindung)
verbindung.pending_state = state
verbindung.last_error = None
db.flush()
antwort = _client(db, user).starte_autorisierung(
aspsp_name, land, f"{app.base_url.rstrip('/')}/bank/rueckkehr",
state, ZUSTIMMUNG_TAGE, psu_type,
)
db.commit()
return {"url": antwort.get("url"), "connection_id": verbindung.id, "state": state}
# Wie viel des uebermittelten Datensatzes aufgehoben wird. Ein Konto sind ein
# paar hundert Zeichen; die Grenze ist nur da, damit eine ungewoehnlich
# gespraechige Bank die Zeile nicht sprengt.
ROHDATEN_MAX = 4000
def _merkmale(konto: dict) -> dict:
"""Woran sich ein Bankkonto erkennen laesst.
Ein Revolut-Pocket bringt weder eigene IBAN noch eigenen Namen mit. Ohne
Produktbezeichnung und Ersatzkennung sehen mehrere Konten derselben Bank
identisch aus – man kann sie dann weder unterscheiden noch zuordnen.
"""
kennung = konto.get("account_id") or {}
iban = kennung.get("iban")
# PSD2 fuehrt Konten ohne IBAN unter ``other``; manche Banken liefern
# stattdessen nur die Sammelliste.
andere = (kennung.get("other") or {}).get("identification")
if not andere:
for eintrag in konto.get("all_account_ids") or []:
wert = (eintrag or {}).get("identification")
if wert and wert != iban:
andere = wert
break
produkt = konto.get("product") or konto.get("cash_account_type")
# Reihenfolge nach Aussagekraft. Die uid steht bewusst am Ende: Sie ist
# eindeutig, sagt aber niemandem, welches Konto gemeint ist.
name = (konto.get("name") or konto.get("details") or produkt
or andere or iban or konto.get("uid"))
return {
"iban": str(iban)[:64] if iban else None,
"other_id": str(andere)[:128] if andere else None,
"product": str(produkt)[:120] if produkt else None,
"remote_name": str(name)[:255] if name else None,
"currency": (konto.get("currency") or "EUR").upper()[:3],
"raw_json": json.dumps(konto, ensure_ascii=False, default=str)[:ROHDATEN_MAX],
}
def schliesse_verbindung(db: Session, user: User, state: str, code: str) -> dict:
"""Code gegen die Sitzung tauschen und die gefundenen Konten ablegen.
Die Konten werden nur VERZEICHNET, nicht zugeordnet: Welches Bankkonto zu
welchem Konto in Certain gehört, entscheidet der Nutzer. Raten wäre hier
besonders teuer, weil eine falsche Zuordnung fremde Umsätze in ein Konto
schreiben würde.
"""
verbindung = (
db.query(BankConnection)
.filter(BankConnection.owner_id == user.id, BankConnection.pending_state == state)
.first()
)
if verbindung is None:
raise BankFehler("Zu dieser Rückmeldung gibt es keine offene Verbindung.")
daten = _client(db, user).sitzung(code)
verbindung.session_id = daten.get("session_id")
verbindung.pending_state = None
verbindung.last_error = None
gueltig = (daten.get("access") or {}).get("valid_until")
if gueltig:
try:
verbindung.valid_until = datetime.fromisoformat(gueltig.replace("Z", "+00:00"))
except ValueError:
verbindung.valid_until = None
uebermittelt = daten.get("accounts") or []
neu = bekannt = ohne_kennung = 0
for konto in uebermittelt:
uid = konto.get("uid")
if not uid:
# Nicht stillschweigend verwerfen: Sonst sieht „0 Konten" genauso
# aus, als hätte die Bank gar nichts geschickt – und man sucht den
# Fehler bei der Bank statt hier.
ohne_kennung += 1
continue
vorhanden = (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id, BankAccountLink.remote_uid == uid)
.first()
)
merkmale = _merkmale(konto)
if vorhanden is not None:
# Schon bekannt – die Zuordnung bleibt bewusst erhalten. Die
# Merkmale werden aber aufgefrischt, sonst muesste man loeschen,
# um an eine spaetere Produktbezeichnung zu kommen.
vorhanden.connection_id = verbindung.id
for feld, wert in merkmale.items():
setattr(vorhanden, feld, wert)
bekannt += 1
continue
db.add(BankAccountLink(
owner_id=user.id, connection_id=verbindung.id, remote_uid=uid, **merkmale,
))
neu += 1
db.commit()
return {
"connection_id": verbindung.id,
"konten": neu + bekannt, # nutzbar, egal ob neu oder schon bekannt
"neu": neu,
"bekannt": bekannt,
"uebermittelt": len(uebermittelt),
"ohne_kennung": ohne_kennung,
"valid_until": verbindung.valid_until.isoformat() if verbindung.valid_until else None,
}
def _betrag_cents(umsatz: dict) -> int | None:
"""Vorzeichenbehafteter Betrag in Cent.
PSD2 führt den Betrag ohne Vorzeichen und die Richtung getrennt in
``credit_debit_indicator`` (CRDT/DBIT). Manche Banken liefern trotzdem ein
Vorzeichen mit – beides wird berücksichtigt, damit eine Ausgabe nicht als
Einnahme ankommt.
"""
betrag = (umsatz.get("transaction_amount") or {}).get("amount")
if betrag is None:
return None
cents = csv_import.parse_amount(str(betrag), ".")
if cents is None:
return None
richtung = (umsatz.get("credit_debit_indicator") or "").upper()
if richtung == "DBIT":
return -abs(cents)
if richtung == "CRDT":
return abs(cents)
return cents
def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | None]:
"""Empfänger und Verwendungszweck. Bei einer Ausgabe zählt der Empfänger,
bei einer Einnahme der Absender."""
gegen = umsatz.get("creditor") if ausgabe else umsatz.get("debtor")
name = (gegen or {}).get("name")
zweck = umsatz.get("remittance_information")
if isinstance(zweck, list):
zweck = " ".join(str(z) for z in zweck if z)
return (str(name)[:255] if name else None,
str(zweck)[:255] if zweck else None)
# Saldo-Arten nach Aussagekraft. Gebucht schlaegt verfuegbar: Certain rechnet
# mit gebuchten Bewegungen. Ein "verfuegbarer" Saldo enthaelt Vorgemerktes oder
# einen Ueberziehungsrahmen und waere mit der eigenen Rechnung nicht vergleichbar.
SALDO_RANG = ("CLBD", "ITBD", "PRCD", "ITAV", "XPCD", "FWAV", "OTHR")
def _zeitpunkt(saldo: dict) -> datetime | None:
"""Wann der Saldo galt. Ohne Zeitpunkt ist eine Abweichung nicht deutbar –
ein Saldo von gestern darf von heute abweichen."""
roh = saldo.get("last_change_date_time") or saldo.get("reference_date")
if not roh:
return None
try:
wert = datetime.fromisoformat(str(roh).replace("Z", "+00:00"))
except ValueError:
return None
return wert if wert.tzinfo else wert.replace(tzinfo=timezone.utc)
def _saldo(salden: list[dict]) -> tuple[int | None, str | None, datetime | None]:
"""Der aussagekraeftigste der gemeldeten Saldi.
Banken liefern mehrere nebeneinander. Welcher gemeint ist, steht in
``balance_type``; einfach den ersten zu nehmen waere Zufall.
"""
beste = None
for eintrag in salden or []:
art = str(eintrag.get("balance_type") or "").upper()
betrag = (eintrag.get("balance_amount") or {}).get("amount")
if betrag is None:
continue
cents = csv_import.parse_amount(str(betrag), ".")
if cents is None:
continue
rang = SALDO_RANG.index(art) if art in SALDO_RANG else len(SALDO_RANG)
if beste is None or rang < beste[0]:
beste = (rang, cents, art or None, _zeitpunkt(eintrag))
if beste is None:
return None, None, None
return beste[1], beste[2], beste[3]
def hole_salden(db: Session, user: User, verbindung: BankConnection,
client: BankClient | None = None) -> int:
"""Den von der Bank gefuehrten Saldo je Konto auffrischen.
Auch fuer noch nicht zugeordnete Konten: Bei Revolut-Pockets ist der Betrag
oft das Einzige, woran man erkennt, welches Pocket man vor sich hat.
Ein Fehler bei einem Konto beendet den Durchlauf nicht. Sonst blieben wegen
eines nicht freigegebenen Kontos alle uebrigen Saldi leer.
"""
client = client or _client(db, user)
aktualisiert = 0
for verknuepfung in db.query(BankAccountLink).filter(
BankAccountLink.connection_id == verbindung.id,
BankAccountLink.owner_id == user.id,
):
try:
cents, art, stand = _saldo(client.salden(verknuepfung.remote_uid))
except BankFehler as e:
log.warning("Saldo fuer %s nicht abrufbar: %s", verknuepfung.remote_uid, e)
continue
if cents is None:
continue
verknuepfung.balance_cents = cents
verknuepfung.balance_type = art
verknuepfung.balance_at = stand
aktualisiert += 1
db.commit()
return aktualisiert
# Nur abgeschlossene Umsaetze zaehlen fuer den Saldo. Vorgemerkte kommen ein
# oder zwei Tage spaeter noch einmal – dann mit anderer Kennung, weil die Bank
# beim Buchen eine neue vergibt. Beides aufzunehmen hiesse: doppelt gebucht.
GEBUCHT = {"BOOK", "BOOKED"}
VORGEMERKT = {"PDNG", "PENDING"}
def _ist_gebucht(umsatz: dict) -> bool | None:
"""True = gebucht, False = vorgemerkt, None = weder noch (storniert o. ae.).
Fehlt die Angabe, gilt der Umsatz als gebucht: Die meisten Anbindungen
liefern ueber diesen Endpunkt ohnehin nur gebuchte.
"""
status = str(umsatz.get("status") or "").upper()
if not status or status in GEBUCHT:
return True
if status in VORGEMERKT:
return False
return None
def _startdatum(db: Session, user: User, konto: Account,
verbindung: BankConnection) -> date:
"""Ab welchem Tag Umsaetze geholt werden.
Beim ERSTEN Abruf ab dem Tag nach der neuesten vorhandenen Buchung des
Kontos. Ohne das holte die Bank neunzig Tage zurueck – mitten in Monate,
die schon per CSV eingelesen sind. Der Duplikatschutz faende die nicht:
Sein Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank
anders als im Kontoauszug.
Spaeter genuegt ein kleiner Rueckgriff hinter den letzten Abruf; einen
doppelten Abruf faengt die Kennung des Umsatzes exakt ab.
"""
if verbindung.last_sync_at:
return verbindung.last_sync_at.date() - timedelta(days=3)
frueheste = date.today() - timedelta(days=ERSTER_ABRUF_TAGE)
letzte = (
db.query(func.max(Transaction.date))
.filter(Transaction.owner_id == user.id, Transaction.account_id == konto.id)
.scalar()
)
if letzte is None:
return frueheste
return max(frueheste, letzte + timedelta(days=1))
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict:
"""Umsätze aller zugeordneten Konten dieser Verbindung in den Eingang holen."""
client = _client(db, user)
# Zuerst die Saldi – auch fuer nicht zugeordnete Konten. Sonst saehe man
# bei drei gleich aussehenden Pockets nie, welches welches ist.
saldi = hole_salden(db, user, verbindung, client)
verknuepfungen = [
v for v in db.query(BankAccountLink).filter(
BankAccountLink.connection_id == verbindung.id,
BankAccountLink.owner_id == user.id,
)
if v.account_id is not None
]
if not verknuepfungen:
return {"geholt": 0, "neu": 0, "saldi": saldi, "ohne_zuordnung": True}
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
geholt = neu = gebucht = vorgemerkt = 0
ab: date | None = None
for verknuepfung in verknuepfungen:
konto = konten.get(verknuepfung.account_id)
if konto is None:
continue
# Je Konto eigen: Das eine ist bis gestern per CSV gepflegt, das andere
# ganz neu.
seit = _startdatum(db, user, konto, verbindung)
ab = seit if ab is None else min(ab, seit)
for umsatz in client.umsaetze(verknuepfung.remote_uid, seit):
geholt += 1
zustand = _ist_gebucht(umsatz)
if zustand is None:
continue
if zustand is False:
# Nicht stillschweigend verwerfen: Sonst fehlt die Zahlung im
# Eingang und niemand weiss, warum.
vorgemerkt += 1
continue
kennung = (umsatz.get("entry_reference") or umsatz.get("transaction_id")
or umsatz.get("uid"))
if not kennung:
continue # ohne Kennung kein Schutz vor Doppelabruf
kennung = f"{verknuepfung.remote_uid}:{kennung}"[:255]
schon = (
db.query(InboxItem.id)
.filter(InboxItem.owner_id == user.id,
InboxItem.source == InboxSource.bank,
InboxItem.external_id == kennung)
.first()
)
if schon is not None:
continue
cents = _betrag_cents(umsatz)
tag = _datum(umsatz)
if cents is None or cents == 0 or tag is None:
continue
payee, zweck = _text(umsatz, cents < 0)
# Durch dieselbe Erkennung wie CSV und Kurzbefehl: Schlagwortregeln
# schlagen die Kategorie vor, der Eingangs-Abgleich verhindert, dass
# eine bereits erfasste Zahlung ein zweites Mal auftaucht.
erkannt = csv_import.detect_rows(db, user, konto, [{
"date": tag.isoformat(), "amount_cents": cents,
"payee": payee, "purpose": zweck,
}])
zeile = erkannt[0] if erkannt else {}
if zeile.get("is_duplicate"):
continue
eintrag = InboxItem(
owner_id=user.id,
source=InboxSource.bank,
status=InboxStatus.pending,
account_id=konto.id,
date=tag,
amount_cents=cents,
currency=(umsatz.get("transaction_amount") or {}).get("currency")
or konto.currency,
payee=payee,
note=zweck,
category_id=zeile.get("category_id"),
external_id=kennung,
)
db.add(eintrag)
db.flush()
neu += 1
# Sofort buchen. Ein angebundenes Konto ist die Quelle der Wahrheit:
# Was die Bank gebucht hat, IST gebucht – erst dadurch kann Certains
# Saldo gar nicht mehr abweichen. Eine Kategorie fehlt oft, die
# traegt man in Ruhe nach; ein fehlender Betrag waere schlimmer.
if eingang.buche(db, user, eintrag) is not None:
gebucht += 1
verbindung.last_sync_at = datetime.now(timezone.utc)
verbindung.last_error = None
db.commit()
# Ein Uebertrag zwischen zwei angebundenen Konten kommt in EINEM Abruf an –
# beide Seiten. Ohne diesen Aufruf zaehlten sie als Ausgabe und Einnahme.
verknuepft = eingang.suche_uebertraege(db, user) if gebucht else 0
return {
"uebertraege": verknuepft,
"geholt": geholt, "neu": neu, "gebucht": gebucht, "vorgemerkt": vorgemerkt,
"saldi": saldi, "ab": ab.isoformat() if ab else None, "ohne_zuordnung": False,
}
def _datum(umsatz: dict) -> date | None:
for feld in ("booking_date", "value_date", "transaction_date"):
roh = umsatz.get(feld)
if not roh:
continue
try:
return date.fromisoformat(str(roh)[:10])
except ValueError:
continue
return None
def laeuft_bald_ab(verbindung: BankConnection) -> bool:
"""Läuft die Zustimmung demnächst ab?
SQLite gibt Zeitstempel ohne Zeitzone zurück, auch wenn die Spalte als
``timezone=True`` deklariert ist. Ohne diese Ergänzung scheitert der
Vergleich mit einem TypeError – und zwar erst beim Anzeigen der
Verbindungsliste, also weit weg von der Ursache.
"""
if verbindung.valid_until is None:
return False
bis = verbindung.valid_until
if bis.tzinfo is None:
bis = bis.replace(tzinfo=timezone.utc)
return bis - datetime.now(timezone.utc) < timedelta(days=WARNUNG_TAGE)