feat: Kontostand von der Bank – die eigene Rechnung wird geprueft
Certain rechnet den Saldo bisher ausschliesslich selbst aus: Anfangsbestand plus alle Buchungen. Stimmt eine Buchung nicht, faellt das erst auf, wenn man von Hand mit dem Auszug vergleicht. Der von der Bank gemeldete Saldo wird jetzt mitgefuehrt und danebengestellt. Weicht er ab, fehlt oder doppelt eine Buchung – und man sieht es auf der Kontenseite und in den Einstellungen sofort, statt es zu suchen. Banken melden mehrere Saldi nebeneinander; ausgewaehlt wird der gebuchte, denn nur der ist mit Certains Rechnung vergleichbar. Ein verfuegbarer Saldo enthaelt Vorgemerktes oder einen Ueberziehungsrahmen. Die Saldi kommen auch fuer noch nicht zugeordnete Konten: Bei mehreren Revolut-Pockets ist der Betrag oft das Einzige, woran man sie unterscheidet. Ein Fehler bei einem Konto beendet den Durchlauf nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -319,8 +319,86 @@ def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | 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
|
||||
|
||||
|
||||
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,
|
||||
@@ -329,9 +407,8 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict:
|
||||
if v.account_id is not None
|
||||
]
|
||||
if not verknuepfungen:
|
||||
return {"geholt": 0, "neu": 0, "ohne_zuordnung": True}
|
||||
return {"geholt": 0, "neu": 0, "saldi": saldi, "ohne_zuordnung": True}
|
||||
|
||||
client = _client(db, user)
|
||||
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||
seit = (verbindung.last_sync_at.date() - timedelta(days=3)
|
||||
if verbindung.last_sync_at
|
||||
@@ -395,7 +472,7 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict:
|
||||
verbindung.last_sync_at = datetime.now(timezone.utc)
|
||||
verbindung.last_error = None
|
||||
db.commit()
|
||||
return {"geholt": geholt, "neu": neu, "ohne_zuordnung": False}
|
||||
return {"geholt": geholt, "neu": neu, "saldi": saldi, "ohne_zuordnung": False}
|
||||
|
||||
|
||||
def _datum(umsatz: dict) -> date | None:
|
||||
|
||||
Reference in New Issue
Block a user