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:
Guido
2026-08-26 23:08:56 +02:00
co-authored by Claude Opus 5
parent f684c752d8
commit 63de8368f0
13 changed files with 342 additions and 18 deletions
+66 -3
View File
@@ -31,8 +31,11 @@ PEM = SCHLUESSEL.private_bytes(
class FakeBank:
"""Minimale Gegenstelle: nur was ``BankClient`` tatsächlich aufruft."""
def __init__(self, umsaetze=None, konten=None, seiten=1):
def __init__(self, umsaetze=None, konten=None, seiten=1, salden=None):
self.umsaetze = umsaetze or []
self.salden = salden if salden is not None else [
{"balance_amount": {"amount": "100.00", "currency": "EUR"}},
]
self.konten = konten if konten is not None else [{
"uid": "acc-1", "name": "Girokonto", "currency": "EUR",
"account_id": {"iban": "DE02120300000000202051"},
@@ -60,8 +63,7 @@ class FakeBank:
return 200, {"session_id": "sess-1", "accounts": self.konten,
"access": {"valid_until": "2027-01-01T00:00:00+00:00"}}
if "/balances" in pfad:
return 200, {"balances": [{"balance_amount": {"amount": "100.00",
"currency": "EUR"}}]}
return 200, {"balances": self.salden}
if "/transactions" in pfad:
# Blättern nachstellen: erst mit Fortsetzungsschlüssel, dann ohne.
hat_schluessel = "continuation_key=" in pfad
@@ -611,3 +613,64 @@ def test_merkmale_werden_beim_neuverbinden_aufgefrischt(admin, fake_bank):
danach = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0]
assert danach["product"] == "Savings"
assert danach["remote_name"] == "Urlaub"
def test_saldo_der_bank_wird_gefuehrt_und_gebuchter_bevorzugt(admin, fake_bank):
"""Banken melden mehrere Saldi nebeneinander. Den ersten zu nehmen waere
Zufall – vergleichbar mit Certains eigener Rechnung ist nur der gebuchte."""
_app(admin)
uid = f"acc-{secrets.token_hex(4)}"
fake_bank(
konten=[{"uid": uid, "name": "Girokonto", "currency": "EUR",
"account_id": {"iban": "DE02120300000000202051"}}],
salden=[
# Verfuegbar steht zuerst und ist hoeher – er enthaelt den
# Ueberziehungsrahmen und darf gerade NICHT gewinnen.
{"balance_type": "ITAV", "balance_amount": {"amount": "1500.00",
"currency": "EUR"}},
{"balance_type": "CLBD", "balance_amount": {"amount": "342.15",
"currency": "EUR"},
"reference_date": "2026-08-25"},
],
)
state = admin.post("/api/bank/connect",
json={"aspsp_name": f"Saldobank-{secrets.token_hex(3)}",
"country": "DE"}).json()["state"]
# Die Kennung aus der Rueckmeldung nehmen, nicht die zuletzt angelegte
# Verbindung: Die Testdatenbank lebt ueber den ganzen Lauf.
verbindung_id = admin.post(
f"/api/bank/callback?state={state}&code=guter-code").json()["connection_id"]
r = admin.post(f"/api/bank/connections/{verbindung_id}/salden")
assert r.status_code == 200, r.text
assert r.json()["saldi"] == 1
link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0]
assert link["balance_cents"] == 34215
assert link["balance_type"] == "CLBD"
assert link["balance_at"].startswith("2026-08-25")
def test_saldo_kommt_auch_ohne_zuordnung(admin, fake_bank):
"""Genau dann braucht man ihn: Bei mehreren Pockets ist der Betrag oft das
Einzige, woran man erkennt, welches man vor sich hat."""
_app(admin)
uid = f"acc-{secrets.token_hex(4)}"
fake_bank(
konten=[{"uid": uid, "currency": "CHF", "product": "Steuern"}],
salden=[{"balance_type": "CLBD",
"balance_amount": {"amount": "2480.00", "currency": "CHF"}}],
)
state = admin.post("/api/bank/connect",
json={"aspsp_name": f"Revolut-{secrets.token_hex(3)}",
"country": "LT"}).json()["state"]
verbindung_id = admin.post(
f"/api/bank/callback?state={state}&code=guter-code").json()["connection_id"]
r = admin.post(f"/api/bank/connections/{verbindung_id}/sync")
assert r.status_code == 200, r.text
# Kein Konto zugeordnet – trotzdem ist der Saldo da.
assert r.json()["ohne_zuordnung"] is True
assert r.json()["saldi"] == 1
link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0]
assert link["balance_cents"] == 248000