diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index 8aca1f4..146a656 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -17,7 +17,7 @@ from ..models import ( User, ) from ..schemas import AccountCreate, AccountIdentifierOut, AccountOrder, AccountOut, AccountUpdate -from ..services import balances, currency +from ..services import balances, bank_sync, currency from ..services.csv_import import normalize_text from .settings import BASE_CURRENCY_KEY, get_user_setting @@ -74,6 +74,8 @@ def to_out(db: Session, acc: Account, base: str, on: date, balance_base_cents=balances.account_balance_base_cents(db, acc, base, on), bank_balance_cents=bank.balance_cents if bank else None, bank_balance_at=bank.balance_at if bank else None, + bank_balance_vergleichbar=( + bank_sync.saldo_vergleichbar(bank.balance_type) if bank else False), ) diff --git a/backend/app/routers/bank.py b/backend/app/routers/bank.py index 47516c7..f62c4db 100644 --- a/backend/app/routers/bank.py +++ b/backend/app/routers/bank.py @@ -201,12 +201,15 @@ def delete_connection(connection_id: int, db: Session = Depends(get_db), @router.get("/links", response_model=list[BankAccountLinkOut]) def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)): - return ( + return [ + BankAccountLinkOut.model_validate(v).model_copy(update={ + "balance_vergleichbar": bank_sync.saldo_vergleichbar(v.balance_type)}) + for v in ( db.query(BankAccountLink) .filter(BankAccountLink.owner_id == user.id) .order_by(BankAccountLink.id) .all() - ) + )] @router.post("/links/{link_id}/account/{account_id}", response_model=BankAccountLinkOut) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b38834e..c6112b4 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -204,6 +204,9 @@ class AccountOut(BaseModel): # ersetzt die eigene Rechnung nicht, er prueft sie. bank_balance_cents: int | None = None bank_balance_at: datetime | None = None + # Nur ein GEBUCHTER Saldo ist mit Certains Rechnung vergleichbar. Ein + # verfuegbarer enthaelt Vorgemerktes, das hier bewusst nicht gebucht wird. + bank_balance_vergleichbar: bool = False # --- Kategorien ------------------------------------------------------------ @@ -677,6 +680,7 @@ class BankAccountLinkOut(BaseModel): currency: str balance_cents: int | None = None balance_type: str | None = None + balance_vergleichbar: bool = False balance_at: datetime | None = None last_sync_at: datetime | None = None account_id: int | None diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 6e4e3c3..0e973eb 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -43,6 +43,8 @@ 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 # 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 @@ -388,6 +390,14 @@ def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | None]: # 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: @@ -515,7 +525,11 @@ def _startdatum(db: Session, user: User, konto: Account, # Sonst laege es fuer immer vor dem Fenster, obwohl die Bank es haette. return heute - timedelta(days=tage) if verknuepfung.last_sync_at: - return min(heute, verknuepfung.last_sync_at.date() - timedelta(days=3)) + # 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)) @@ -593,13 +607,13 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, bucht; wer ein Konto abruft, will das andere nicht angefasst haben. """ 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, nur_link_id) - 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)} @@ -716,6 +730,11 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, 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 diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index d5c3f0b..4d29de6 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -1218,3 +1218,73 @@ def test_zweiter_bankzugang_bucht_dieselbe_zahlung_nicht_noch_einmal(admin, fake r = admin.post(f"/api/bank/links/{zwei['id']}/sync").json() assert r["doppelt"] == 1, r assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -1490 + + +def test_verfuegbarer_saldo_wird_nicht_als_abweichung_gemeldet(admin, fake_bank): + """Ein verfuegbarer Saldo enthaelt Vorgemerktes, das Certain bewusst nicht + bucht. Daraus eine Abweichung zu machen, waere eine Falschmeldung – und + genau die kam staendig.""" + _app(admin) + uid = f"acc-{secrets.token_hex(4)}" + fake_bank( + konten=[{"uid": uid, "currency": "EUR"}], + salden=[{"balance_type": "ITAV", + "balance_amount": {"amount": "150.00", "currency": "EUR"}}]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Verfuegbar-{secrets.token_hex(3)}", + "country": "DE"}).json()["state"] + verbindung = admin.post( + f"/api/bank/callback?state={state}&code=guter-code").json()["connection_id"] + link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + konto = _konto(admin, name=f"Verfuegbar {secrets.token_hex(3)}", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + admin.post(f"/api/bank/connections/{verbindung}/salden") + + aus_liste = [v for v in admin.get("/api/bank/links").json() + if v["remote_uid"] == uid][0] + assert aus_liste["balance_cents"] == 15000 + assert aus_liste["balance_vergleichbar"] is False + + am_konto = admin.get(f"/api/accounts/{konto['id']}").json() + assert am_konto["bank_balance_cents"] == 15000 + assert am_konto["bank_balance_vergleichbar"] is False + + # Und ein gebuchter Saldo ist es sehr wohl. + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], + salden=[{"balance_type": "CLBD", + "balance_amount": {"amount": "150.00", "currency": "EUR"}}]) + admin.post(f"/api/bank/connections/{verbindung}/salden") + assert admin.get(f"/api/accounts/{konto['id']}").json()["bank_balance_vergleichbar"] is True + + +def test_saldo_kommt_erst_nach_den_umsaetzen(admin, fake_bank, monkeypatch): + """Vorher geholt waere er neuer als die Umsaetze: Bricht deren Abruf ab, + stuende ein aktueller Saldo neben altem Bestand – und die Anzeige meldete + eine Abweichung, die keine ist.""" + from app.services import bank_api + + _app(admin) + uid = f"acc-{secrets.token_hex(4)}" + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], + salden=[{"balance_type": "CLBD", + "balance_amount": {"amount": "99.00", "currency": "EUR"}}]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Reihenfolge-{secrets.token_hex(3)}", + "country": "DE"}).json()["state"] + admin.post(f"/api/bank/callback?state={state}&code=guter-code") + link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + konto = _konto(admin, name=f"Reihenfolge {secrets.token_hex(3)}", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + # Der Umsatz-Abruf scheitert. + echte = bank_api.BankClient.umsaetze + monkeypatch.setattr(bank_api.BankClient, "umsaetze", + lambda *a, **kw: (_ for _ in ()).throw( + bank_api.BankFehler("Schnittstelle streikt"))) + assert admin.post(f"/api/bank/links/{link['id']}/sync").status_code == 400 + monkeypatch.setattr(bank_api.BankClient, "umsaetze", echte) + + # Dann darf auch kein Saldo dastehen, der neuer waere als der Bestand. + aus_liste = [v for v in admin.get("/api/bank/links").json() + if v["remote_uid"] == uid][0] + assert aus_liste["balance_cents"] is None, "Saldo ohne Umsaetze geschrieben" diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 0ad2eee..79c7c4b 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -177,7 +177,8 @@ "adoptConfirm": "Der Saldo von {{bank}} wird als richtig übernommen. Die Differenz von {{diff}} wandert in den Anfangsbestand – sie steht für Bewegungen, die Certain nie gesehen hat. Deine Buchungen bleiben unverändert.", "adopted": "Saldo übernommen.", "identShort_one": "{{count}} Kennung", - "identShort_other": "{{count}} Kennungen" + "identShort_other": "{{count}} Kennungen", + "bankBalanceAvailable": "Bank meldet {{bank}} als verfügbaren Saldo – der enthält Vorgemerktes und lässt sich nicht vergleichen." }, "accountTypes": { "girokonto": "Girokonto", @@ -653,7 +654,8 @@ "pollSaved": "Takt gespeichert.", "syncOlder": "Älteres nachholen", "syncedDuplicate": "{{n}} standen schon da (aus Datei oder von Hand) und wurden übersprungen.", - "addTitle": "Weitere Bank verbinden" + "addTitle": "Weitere Bank verbinden", + "balanceAvailable": "verfügbarer Saldo, nicht vergleichbar" }, "bankReturn": { "title": "Rückkehr von der Bank", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index b33cc64..2020bcf 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -177,7 +177,8 @@ "adoptConfirm": "The balance of {{bank}} is taken as correct. The difference of {{diff}} moves into the opening balance – it stands for movements Certain never saw. Your transactions stay unchanged.", "adopted": "Balance adopted.", "identShort_one": "{{count}} identifier", - "identShort_other": "{{count}} identifiers" + "identShort_other": "{{count}} identifiers", + "bankBalanceAvailable": "The bank reports {{bank}} as the available balance – it includes pending items and cannot be compared." }, "accountTypes": { "girokonto": "Checking", @@ -653,7 +654,8 @@ "pollSaved": "Interval saved.", "syncOlder": "Catch up on older", "syncedDuplicate": "{{n}} were already there (from a file or entered by hand) and were skipped.", - "addTitle": "Connect another bank" + "addTitle": "Connect another bank", + "balanceAvailable": "available balance, not comparable" }, "bankReturn": { "title": "Back from your bank", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index da8cbeb..d4568e4 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -326,7 +326,16 @@ export default function Accounts() { ist – sonst faellt das erst beim Vergleich von Hand auf. */} {a.bank_balance_cents != null && (
- {a.bank_balance_cents === a.balance_cents ? ( + {/* Ein VERFÜGBARER Saldo enthält Vorgemerktes, das + Certain bewusst nicht bucht. Daraus eine Abweichung + zu machen, wäre eine Falschmeldung. */} + {!a.bank_balance_vergleichbar ? ( + + {t("accounts.bankBalanceAvailable", { + bank: formatMoney(a.bank_balance_cents, a.currency), + })} + + ) : a.bank_balance_cents === a.balance_cents ? ( {t("accounts.bankBalanceMatches")} {a.bank_balance_at ? ` · ${formatDate(a.bank_balance_at)}` : ""} diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index 5779fd6..9a024d6 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -291,7 +291,10 @@ export default function Bankanbindung() { {/* Der eigentliche Nutzen: Certain rechnet den Saldo selbst aus. Weicht er ab, fehlt oder doppelt eine Buchung – das sieht man sonst erst beim Vergleich von Hand. */} - {l.account_id && kontoById[l.account_id] && ( + {!l.balance_vergleichbar && ( + · {t("bank.balanceAvailable")} + )} + {l.balance_vergleichbar && l.account_id && kontoById[l.account_id] && ( (() => { const diff = kontoById[l.account_id].balance_cents - l.balance_cents;