"""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, TxnType, 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 # 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 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 = 0 uebergangen: list[dict] = [] for konto in uebermittelt: uid = konto.get("uid") if not uid: uebergangen.append(konto) # Nicht stillschweigend verwerfen: Sonst sieht „3 Konten" genauso # aus, als hätte die Bank nur drei geschickt – und man sucht den # Fehler bei der Bank statt hier. 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": len(uebergangen), # Was genau übergangen wurde. Ohne das bleibt die Frage offen, ob die # Bank ein Konto gar nicht schickt oder Certain es nicht versteht. "uebergangen": [ {k: v for k, v in konto.items() if k != "identification_hashes"} for konto in uebergangen[:20] ], "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 _betrag_mit_waehrung(wert) -> tuple[int, str] | None: """Aus ``{"amount": "12.00", "currency": "EUR"}`` Cent und Waehrung.""" if not isinstance(wert, dict): return None cents = csv_import.parse_amount(str(wert.get("amount")), ".") waehrung = wert.get("currency") if cents is None or not waehrung: return None return abs(cents), str(waehrung).upper()[:3] def _umrechnung(umsatz: dict, waehrung: str | None) -> tuple[float | None, int | None, str | None]: """Kurs und Gegenbetrag, wie die Bank sie mitteilt. Die Feldnamen unterscheiden sich je Anbindung, deshalb werden mehrere Formen abgeklopft – und es wird still NICHTS gesetzt, wenn keine passt. Raten waere hier schlimmer als die EZB-Naeherung, denn ein falscher Kurs sieht aus wie ein richtiger. """ wechsel = umsatz.get("currency_exchange") if isinstance(wechsel, list): wechsel = next((w for w in wechsel if isinstance(w, dict)), None) if not isinstance(wechsel, dict): wechsel = {} kurs = None for feld in ("exchange_rate", "rate", "unit_rate"): roh = wechsel.get(feld) if wechsel else None if roh is None: roh = umsatz.get(feld) if roh is None: continue try: wert = float(str(roh).replace(",", ".")) except (TypeError, ValueError): continue if wert > 0: kurs = wert break # Der Betrag in der ANDEREN Waehrung. Nur brauchbar, wenn er sich von der # Waehrung des Umsatzes unterscheidet – sonst ist es derselbe Betrag. gegen = None for quelle in (wechsel, umsatz): for feld in ("instructed_amount", "original_amount", "source_amount"): treffer = _betrag_mit_waehrung((quelle or {}).get(feld)) if treffer and treffer[1] != (waehrung or "").upper(): gegen = treffer break if gegen: break return kurs, (gegen[0] if gegen else None), (gegen[1] if gegen else None) 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") # Nur diese Arten zaehlen gebuchte Bewegungen – und nur die sind mit Certains # Rechnung vergleichbar. Ein "verfuegbarer" Saldo enthaelt Vorgemerktes, das # Certain bewusst nicht bucht; eine Abweichung daraus waere keine. GEBUCHTE_SALDEN = {"CLBD", "ITBD", "PRCD"} def saldo_vergleichbar(art: str | None) -> bool: return (art or "").upper() in GEBUCHTE_SALDEN 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 _verknuepfungen(db: Session, user: User, verbindung: BankConnection, nur_link_id: int | None) -> list[BankAccountLink]: """Die Konten einer Verbindung – oder genau eines davon. Ein Abruf bucht. Wer ein einzelnes Konto abruft, will nicht, dass nebenbei auf einem zweiten Buchungen entstehen. """ frage = db.query(BankAccountLink).filter( BankAccountLink.connection_id == verbindung.id, BankAccountLink.owner_id == user.id, ) if nur_link_id is not None: frage = frage.filter(BankAccountLink.id == nur_link_id) return frage.all() def hole_salden(db: Session, user: User, verbindung: BankConnection, client: BankClient | None = None, nur_link_id: int | 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 _verknuepfungen(db, user, verbindung, nur_link_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, verknuepfung: BankAccountLink, tage: int | None = None) -> date: """Ab welchem Tag Umsaetze DIESES Kontos 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. Der Stempel gehoert an das Konto, nicht an die Verbindung: Sonst schoebe ein Einzelabruf das Startdatum der uebrigen Konten mit vor, und deren Umsaetze aus dem uebersprungenen Zeitraum kaemen nie an. """ heute = date.today() if tage is not None: # Uebergeht den letzten Abruf. Noetig, wenn etwas nicht angekommen ist: # Sonst laege es fuer immer vor dem Fenster, obwohl die Bank es haette. return heute - timedelta(days=tage) if verknuepfung.last_sync_at: # Sieben Tage statt drei: Banken buchen mitunter deutlich spaeter # zurueck, und ein Umsatz, der einmal ausserhalb des Fensters lag, # kaeme nie wieder. Doppelt holen kostet nichts – die Kennung des # Umsatzes faengt das exakt ab. return min(heute, verknuepfung.last_sync_at.date() - timedelta(days=RUECKGRIFF_TAGE)) frueheste = heute - 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 # NIE in die Zukunft: Die Schnittstelle lehnt das rundweg ab # ("date_from can not be in the future") – und zwar erst beim Umsatz-Abruf, # nachdem die Saldi schon gelesen sind. Dann sieht es aus, als kaemen nur # Saldi und keine Umsaetze. Liegt die neueste Buchung auf heute, waere der # Tag danach morgen. return min(heute, max(frueheste, letzte + timedelta(days=1))) def _fremde_buchungen(db: Session, user: User, konto: Account, seit: date, remote_uid: str) -> dict[tuple[str, int], list]: """Buchungen des Kontos, die NICHT aus DIESEM Bankkonto stammen. Ein Abruf ueber einen laengeren Zeitraum greift in Monate zurueck, die schon per CSV eingelesen sind. Der Duplikatschutz erkennt das nicht: Sein Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank anders als im Auszug. Ohne diese Pruefung stuende die Zahlung zweimal da. Ausgenommen sind nur Buchungen aus DEMSELBEN Bankkonto: Dort entscheidet die Kennung der Bank, und die ist exakt – zwei Aufrundungen ueber 1.00 duerfen sich nicht gegenseitig verschlucken. Buchungen aus einem ANDEREN Bankzugang zaehlen dagegen mit. Genau das passiert, wenn dieselbe Bank zweimal verbunden ist (Enable Banking vergibt je Land eigene Kennungen): Dieselbe Zahlung kaeme sonst ueber beide Zugaenge herein. """ aus_diesem_konto = { txn_id for (txn_id,) in db.query(InboxItem.transaction_id).filter( InboxItem.owner_id == user.id, InboxItem.source == InboxSource.bank, InboxItem.transaction_id.isnot(None), InboxItem.external_id.startswith(f"{remote_uid}:"), ) } je_schluessel: dict[tuple[str, int], list] = {} for txn in db.query(Transaction).filter( Transaction.owner_id == user.id, Transaction.account_id == konto.id, Transaction.type != TxnType.transfer, Transaction.date >= seit - timedelta(days=csv_import.INBOX_TAGE), ): if txn.id in aus_diesem_konto: continue je_schluessel.setdefault((txn.type.value, txn.amount_cents), []).append(txn) return je_schluessel def _findet_fremde(fremde: dict, verbraucht: set[int], cents: int, tag: date): """Passende eigene Buchung – jede erklaert hoechstens EINEN Bank-Umsatz. Ohne die Menge verschluckte eine Kaffeezahlung alle weiteren desselben Betrags in der Umgebung. """ art = "expense" if cents < 0 else "income" for txn in fremde.get((art, abs(cents)), []): if txn.id in verbraucht: continue if abs((txn.date - tag).days) <= csv_import.INBOX_TAGE: return txn return None def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, nur_link_id: int | None = None, tage: int | None = None) -> dict: """Umsätze der zugeordneten Konten dieser Verbindung in den Eingang holen. Mit ``nur_link_id`` nur das eine Konto – samt seinem Saldo. Ein Abruf bucht; wer ein Konto abruft, will das andere nicht angefasst haben. """ client = _client(db, user) verknuepfungen = [v for v in _verknuepfungen(db, user, verbindung, nur_link_id) if v.account_id is not None] if not verknuepfungen: # Ohne Zuordnung gibt es keine Umsaetze zu holen – die Saldi aber # schon: Bei mehreren gleich aussehenden Pockets ist der Betrag das # Einzige, woran man sie unterscheidet. saldi = hole_salden(db, user, verbindung, client, nur_link_id) 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 = doppelt = 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, verknuepfung, tage) ab = seit if ab is None else min(ab, seit) # Je Konto EINE Menge verbrauchter Eingangs-Eintraege ... verbraucht: set[int] = set() # ... und eine fuer schon vorhandene, nicht von der Bank stammende # Buchungen. Beide muessen ueber alle Zeilen des Kontos halten. fremde = _fremde_buchungen(db, user, konto, seit, verknuepfung.remote_uid) fremde_verbraucht: set[int] = set() 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 # ALLE Kennungen, die der Umsatz mitbringt – nicht nur die erste. # Liefert die Bank denselben Umsatz spaeter unter einer anderen # (etwa weil aus vorgemerkt gebucht wurde), waeren es sonst zwei. kennungen = [ f"{verknuepfung.remote_uid}:{wert}"[:255] # Reihenfolge nach Bestaendigkeit: Die eigene Kennung der # Schnittstelle bleibt gleich, wenn die Bank ihre Referenz beim # Buchen neu vergibt. Gespeichert wird die erste – geprueft # wird gegen alle, damit auch aeltere Eintraege noch treffen. for feld in ("transaction_id", "internal_transaction_id", "entry_reference", "uid") if (wert := umsatz.get(feld)) ] if not kennungen: continue # ohne Kennung kein Schutz vor Doppelabruf schon = ( db.query(InboxItem.id) .filter(InboxItem.owner_id == user.id, InboxItem.source == InboxSource.bank, InboxItem.external_id.in_(kennungen)) .first() ) if schon is None: # Und dann noch an den BUCHUNGEN selbst. Der Eingangs-Eintrag # ist eine Zwischenstation: Er faellt weg, wenn ein Konto # geloescht wird, und aeltere Backups kannten ihn nicht. Die # Buchung bleibt – und traegt die Kennung seit 0027 selbst. schon = eingang.gebuchte_mit_kennung( db, user, [eingang.quell_kennung(InboxSource.bank, k) for k in kennungen], ) if schon is not None: continue kennung = kennungen[0] 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}], # Gegen andere BANK-Umsaetze nicht unscharf vergleichen: Dafuer # gibt es die Kennung der Bank, und die ist exakt. Sonst # verschluckt eine Aufrundung ueber 1.00 die naechste. eingang_ohne=InboxSource.bank, # Und ueber alle Zeilen dieses Kontos hinweg dieselbe Menge, # denn hier wird je Zeile einzeln aufgerufen. verbraucht=verbraucht, ) zeile = erkannt[0] if erkannt else {} if zeile.get("is_duplicate"): continue # Steht die Zahlung schon da, weil sie frueher per Datei oder von # Hand erfasst wurde? Dann ist der Bank-Umsatz dieselbe Bewegung. schon_da = _findet_fremde(fremde, fremde_verbraucht, cents, tag) if schon_da is not None: fremde_verbraucht.add(schon_da.id) doppelt += 1 continue waehrung = ((umsatz.get("transaction_amount") or {}).get("currency") or konto.currency) kurs, gegen_betrag, gegen_waehrung = _umrechnung(umsatz, waehrung) eintrag = InboxItem( owner_id=user.id, source=InboxSource.bank, status=InboxStatus.pending, account_id=konto.id, date=tag, amount_cents=cents, currency=waehrung, payee=payee, note=zweck, category_id=zeile.get("category_id"), external_id=kennung, fx_rate=kurs, counter_amount_cents=gegen_betrag, counter_currency=gegen_waehrung, ) 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 # Der Stempel gehoert an das Konto, das gerade abgerufen wurde. verknuepfung.last_sync_at = datetime.now(timezone.utc) verbindung.last_sync_at = datetime.now(timezone.utc) verbindung.last_error = None db.commit() # Saldi ZULETZT. Vorher geholt waeren sie neuer als die Umsaetze: Bricht # der Umsatz-Abruf ab, stuende ein aktueller Saldo neben altem Bestand, # und die Anzeige meldete eine Abweichung, die keine ist. saldi = hole_salden(db, user, verbindung, client, nur_link_id) # 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, "doppelt": doppelt, "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 _mit_zeitzone(wert: datetime | None) -> datetime | None: """SQLite gibt Zeitstempel ohne Zeitzone zurueck, auch bei ``timezone=True``. Ohne diese Ergaenzung scheitert jeder Vergleich mit einem TypeError – und zwar weit weg von der Ursache. """ if wert is None or wert.tzinfo is not None: return wert return wert.replace(tzinfo=timezone.utc) def faellig(verbindung: BankConnection, jetzt: datetime) -> bool: """Ist der automatische Abruf dieser Verbindung an der Reihe? Bei ``poll_minutes == 0`` nie – nichts laeuft ungefragt los, denn ein Abruf bucht. """ 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=takt) <= jetzt def abruf_mit_fehlerbehandlung(db: Session, user: User, verbindung: BankConnection) -> dict: """Wie ``hole_umsaetze``, schreibt einen Fehler aber in die Verbindung. Und setzt dabei ``last_sync_at``: Sonst waere die Verbindung sofort wieder faellig und liefe jede Minute in denselben Fehler. """ try: return hole_umsaetze(db, user, verbindung) except Exception as e: # BankFehler und alles Unerwartete log.warning("Automatischer Abruf fuer %s fehlgeschlagen: %s", verbindung.aspsp_name, e) db.rollback() verbindung.last_error = str(e)[:500] verbindung.last_sync_at = datetime.now(timezone.utc) db.commit() return {"geholt": 0, "neu": 0, "gebucht": 0, "fehler": str(e)} 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)