diff --git a/backend/app/routers/inbox.py b/backend/app/routers/inbox.py index 44ca69f..6d8be02 100644 --- a/backend/app/routers/inbox.py +++ b/backend/app/routers/inbox.py @@ -5,8 +5,6 @@ Einträge bleiben nach dem Buchen bestehen: Der spätere CSV-Import gleicht gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal verbucht wird. """ -import logging - from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -23,9 +21,7 @@ from ..models import ( ) from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut, InboxItemOut from ..security import generate_session_token, hash_session_token -from ..services import currency - -log = logging.getLogger(__name__) +from ..services import eingang router = APIRouter(prefix="/inbox", tags=["inbox"]) @@ -90,66 +86,17 @@ def list_inbox( ) -def _buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None: - """Aus einem Vorschlag eine Buchung machen. Ohne Konto geht es nicht.""" - if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None: - return None - if eintrag.account_id is None: - return None - konto = db.get(Account, eintrag.account_id) - if konto is None or konto.owner_id != user.id: - return None - betrag = abs(eintrag.amount_cents) - txn = Transaction( - owner_id=user.id, - type=TxnType.expense if eintrag.amount_cents < 0 else TxnType.income, - date=eintrag.date, - account_id=konto.id, - amount_cents=betrag, - amount_eur_cents=currency.compute_amount_eur_cents( - db, betrag, konto.currency, eintrag.date), - category_id=eintrag.category_id, - payee=eintrag.payee, - description=eintrag.note, - ) - db.add(txn) - db.flush() - eintrag.transaction_id = txn.id - eintrag.status = InboxStatus.booked - return txn - - -def _suche_uebertraege(db: Session, user: User) -> int: - """Nach dem Buchen nach Uebertraegen sehen – so wie nach einem Import. - - Seit die Bankanbindung ihre Umsaetze hierher legt, entsteht die Gegenseite - eines Uebertrags erst beim Buchen. Ohne diesen Aufruf wuerde sie nie - gefunden, und die beiden Seiten zaehlten dauerhaft in der Auswertung mit. - - Ein Fehler hier darf das Buchen nicht kippen: Die Buchung ist schon - festgeschrieben, die Verknuepfung ist nur eine Schicht darueber. - """ - from ..services import uebertraege - - try: - return uebertraege.finde(db, user).get("verknuepft", 0) - except Exception: # pragma: no cover - Sicherheitsnetz - log.exception("Uebertragssuche nach dem Buchen fehlgeschlagen") - db.rollback() - return 0 - - @router.post("/{item_id}/book", response_model=InboxItemOut) def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): eintrag = get_owned(db, InboxItem, item_id, user) if eintrag.account_id is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Kein Konto zugeordnet") - if _buche(db, user, eintrag) is None: + if eingang.buche(db, user, eintrag) is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Lässt sich nicht buchen") db.commit() - _suche_uebertraege(db, user) + eingang.suche_uebertraege(db, user) db.refresh(eintrag) return eintrag @@ -168,9 +115,9 @@ def book_all(db: Session = Depends(get_db), user: User = Depends(get_current_use InboxItem.account_id.isnot(None)) .all() ) - gebucht = sum(1 for e in offen if _buche(db, user, e) is not None) + gebucht = sum(1 for e in offen if eingang.buche(db, user, e) is not None) db.commit() - return {"gebucht": gebucht, "uebertraege": _suche_uebertraege(db, user)} + return {"gebucht": gebucht, "uebertraege": eingang.suche_uebertraege(db, user)} @router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 423a4c9..43ee751 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -20,6 +20,7 @@ import logging import secrets from datetime import date, datetime, timedelta, timezone +from sqlalchemy import func from sqlalchemy.orm import Session from ..models import ( @@ -30,9 +31,10 @@ from ..models import ( InboxItem, InboxSource, InboxStatus, + Transaction, User, ) -from . import csv_import, geheimnis +from . import csv_import, eingang, geheimnis from .bank_api import ERSTER_ABRUF_TAGE, BankClient, BankFehler log = logging.getLogger(__name__) @@ -392,6 +394,53 @@ def hole_salden(db: Session, user: User, verbindung: BankConnection, 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) @@ -410,17 +459,27 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict: 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)} - seit = (verbindung.last_sync_at.date() - timedelta(days=3) - if verbindung.last_sync_at - else date.today() - timedelta(days=ERSTER_ABRUF_TAGE)) - geholt = neu = 0 + 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: @@ -453,7 +512,7 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict: if zeile.get("is_duplicate"): continue - db.add(InboxItem( + eintrag = InboxItem( owner_id=user.id, source=InboxSource.bank, status=InboxStatus.pending, @@ -466,13 +525,30 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection) -> dict: 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() - return {"geholt": geholt, "neu": neu, "saldi": saldi, "ohne_zuordnung": False} + + # 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: diff --git a/backend/app/services/eingang.py b/backend/app/services/eingang.py new file mode 100644 index 0000000..75adfc2 --- /dev/null +++ b/backend/app/services/eingang.py @@ -0,0 +1,67 @@ +"""Aus einem Eingangs-Vorschlag eine Buchung machen. + +Als Dienst und nicht im Router, weil es zwei Aufrufer gibt: die Oberflaeche +(„Buchen") und die Bankanbindung, die ihre Umsaetze gleich selbst bucht. Zwei +Kopien derselben Umrechnung waeren genau die Stelle, an der Salden auseinander +laufen. +""" + +from __future__ import annotations + +import logging + +from sqlalchemy.orm import Session + +from ..models import Account, InboxItem, InboxStatus, Transaction, TxnType, User +from . import currency + +log = logging.getLogger(__name__) + + +def buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None: + """Aus einem Vorschlag eine Buchung machen. Ohne Konto geht es nicht.""" + if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None: + return None + if eintrag.account_id is None: + return None + konto = db.get(Account, eintrag.account_id) + if konto is None or konto.owner_id != user.id: + return None + betrag = abs(eintrag.amount_cents) + txn = Transaction( + owner_id=user.id, + type=TxnType.expense if eintrag.amount_cents < 0 else TxnType.income, + date=eintrag.date, + account_id=konto.id, + amount_cents=betrag, + amount_eur_cents=currency.compute_amount_eur_cents( + db, betrag, konto.currency, eintrag.date), + category_id=eintrag.category_id, + payee=eintrag.payee, + description=eintrag.note, + ) + db.add(txn) + db.flush() + eintrag.transaction_id = txn.id + eintrag.status = InboxStatus.booked + return txn + + +def suche_uebertraege(db: Session, user: User) -> int: + """Nach dem Buchen nach Uebertraegen sehen – so wie nach einem Import. + + Die Gegenseite eines Uebertrags entsteht erst beim Buchen. Ohne diesen + Aufruf wuerde sie nie gefunden, und beide Seiten zaehlten dauerhaft als + Ausgabe und Einnahme in der Auswertung mit. + + Ein Fehler hier darf das Buchen nicht kippen: Die Buchung ist schon + festgeschrieben, die Verknuepfung ist nur eine Schicht darueber. + """ + from . import uebertraege + + try: + return uebertraege.finde(db, user).get("verknuepft", 0) + except Exception: # pragma: no cover - Sicherheitsnetz + log.exception("Uebertragssuche nach dem Buchen fehlgeschlagen") + db.rollback() + return 0 diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 62f7882..529a414 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -13,6 +13,7 @@ die Anbindung sonst als Erstes, und der Fehler wäre von aussen nicht zu sehen. import base64 import json import secrets +from datetime import date import pytest from cryptography.hazmat.primitives import hashes, serialization @@ -42,6 +43,7 @@ class FakeBank: }] self.seiten = seiten self.aufrufe: list[str] = [] + self.date_from: str | None = None self.jwt: str | None = None def __call__(self, methode, pfad, headers=None, json=None, **kw): @@ -65,11 +67,18 @@ class FakeBank: if "/balances" in pfad: return 200, {"balances": self.salden} if "/transactions" in pfad: + if "date_from=" in pfad: + self.date_from = pfad.split("date_from=")[1].split("&")[0] + # Wie die echte Schnittstelle nur ab dem angefragten Tag liefern – + # sonst prüfte kein Test, ob das Startdatum überhaupt wirkt. + passend = [u for u in self.umsaetze + if not self.date_from + or str(u.get("booking_date", ""))[:10] >= self.date_from] # Blättern nachstellen: erst mit Fortsetzungsschlüssel, dann ohne. hat_schluessel = "continuation_key=" in pfad if self.seiten > 1 and not hat_schluessel: - return 200, {"transactions": self.umsaetze[:1], "continuation_key": "s2"} - rest = self.umsaetze[1:] if self.seiten > 1 else self.umsaetze + return 200, {"transactions": passend[:1], "continuation_key": "s2"} + rest = passend[1:] if self.seiten > 1 else passend return 200, {"transactions": rest} return 404, {"message": "unbekannt"} @@ -289,8 +298,9 @@ def test_ohne_zuordnung_wird_nichts_geholt(admin, fake_bank): assert r.json()["neu"] == 0 -def test_umsatz_landet_als_vorschlag_im_eingang(admin, fake_bank): - """Nichts wird gebucht – die Bank liefert Vorschläge wie Mail und Kurzbefehl.""" +def test_umsatz_wird_sofort_gebucht(admin, fake_bank): + """Ein angebundenes Konto ist die Quelle der Wahrheit: Was die Bank gebucht + hat, IST gebucht. Nur so kann Certains Saldo gar nicht erst abweichen.""" verbindung, link, _ = _verbunden(admin, fake_bank, umsaetze=[UMSATZ]) konto = _konto(admin, name="N26 fuer Abruf", currency="EUR") admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") @@ -298,6 +308,7 @@ def test_umsatz_landet_als_vorschlag_im_eingang(admin, fake_bank): r = admin.post(f"/api/bank/connections/{verbindung}/sync") assert r.status_code == 200, r.text assert r.json()["neu"] == 1 + assert r.json()["gebucht"] == 1 treffer = [e for e in admin.get("/api/inbox").json() if e["payee"] == "SPEEDEE RESTAURATIONS"] @@ -306,12 +317,67 @@ def test_umsatz_landet_als_vorschlag_im_eingang(admin, fake_bank): assert treffer[0]["amount_cents"] == -1490 assert treffer[0]["date"] == "2026-08-20" assert treffer[0]["source"] == "bank" - assert treffer[0]["status"] == "pending" - assert treffer[0]["transaction_id"] is None - # Und der Saldo hat sich nicht bewegt. + assert treffer[0]["status"] == "booked" + assert treffer[0]["transaction_id"] is not None + # Der Saldo steht sofort richtig, ohne dass jemand etwas tun musste. + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -1490 + + +def test_zweiter_abruf_bucht_nicht_doppelt(admin, fake_bank): + """Der Rueckgriff hinter den letzten Abruf holt dieselben Umsaetze noch + einmal. Die Kennung des Umsatzes muss sie exakt abfangen.""" + # Ein Umsatz von heute: Der zweite Abruf greift nur wenige Tage zurueck, + # ein alter faende sich dort gar nicht wieder. + heute = dict(UMSATZ, booking_date=date.today().isoformat()) + verbindung, link, _ = _verbunden(admin, fake_bank, umsaetze=[heute]) + konto = _konto(admin, name="Kein Doppel", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + admin.post(f"/api/bank/connections/{verbindung}/sync") + zweiter = admin.post(f"/api/bank/connections/{verbindung}/sync").json() + assert zweiter["geholt"] >= 1, "derselbe Umsatz kam wieder" + assert zweiter["neu"] == 0 + assert zweiter["gebucht"] == 0 + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -1490 + + +def test_vorgemerkte_werden_nicht_gebucht_sondern_gemeldet(admin, fake_bank): + """Vorgemerkte kommen spaeter noch einmal – dann mit anderer Kennung, weil + die Bank beim Buchen eine neue vergibt. Beides zu nehmen hiesse doppelt.""" + schwebend = dict(UMSATZ, entry_reference="tx-pdng", status="PDNG") + verbindung, link, _ = _verbunden(admin, fake_bank, umsaetze=[schwebend]) + konto = _konto(admin, name="Nur Vormerkung", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + r = admin.post(f"/api/bank/connections/{verbindung}/sync").json() + assert r["vorgemerkt"] == 1 + assert r["neu"] == 0 + # Nicht stillschweigend verworfen – die Zahl steht in der Rueckmeldung. assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 0 +def test_erster_abruf_greift_nicht_hinter_vorhandene_buchungen(admin, fake_bank): + """Sonst holte die Bank 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 Auszug.""" + verbindung, link, gegenstelle = _verbunden(admin, fake_bank, umsaetze=[UMSATZ]) + konto = _konto(admin, name="Schon gepflegt", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + # So sieht ein per CSV eingelesener August aus. + admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-20", "account_id": konto["id"], + "amount_cents": 1490, "payee": "SPEEDEE RESTAURATIONS ZUERICH", + }) + + r = admin.post(f"/api/bank/connections/{verbindung}/sync").json() + assert r["ab"] == "2026-08-21", "ab dem Tag NACH der letzten Buchung" + assert gegenstelle.date_from == "2026-08-21" + # Der Umsatz vom 20. liegt davor und kommt gar nicht erst an. + assert r["neu"] == 0 + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -1490 + + def test_gutschrift_kommt_als_einnahme_an(admin, fake_bank): gutschrift = dict(UMSATZ, entry_reference="tx-crdt", credit_debit_indicator="CRDT", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 14291f9..54f047e 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -592,7 +592,7 @@ "mapHint": "Ohne Zuordnung wird von diesem Konto nichts abgerufen.", "noAccountsFound": "Es wurden keine Konten übermittelt. Im eingeschränkten Betrieb gibt die Bank nur heraus, was du bei Enable Banking unter „Activate by linking accounts“ verknüpft hast – dort diese Bank ergänzen und hier neu verbinden.", "sync": "Umsätze holen", - "synced": "{{geholt}} Umsätze geprüft · {{neu}} neu im Eingang.", + "synced": "{{gebucht}} Buchungen übernommen (ab {{ab}}).", "noMapping": "Kein Konto zugeordnet – ordne oben zuerst zu, dann wird abgerufen.", "lastSync": "zuletzt geholt am {{datum}}", "neverSynced": "noch nie geholt", @@ -620,7 +620,10 @@ "balanceMatches": "stimmt mit Certain überein", "balanceDiffers": "Certain weicht um {{diff}} ab", "fetchBalances": "Salden holen", - "balancesFetched": "{{n}} Salden aktualisiert." + "balancesFetched": "{{n}} Salden aktualisiert.", + "syncedPending": "{{n}} vorgemerkt – die kommen, sobald die Bank sie bucht.", + "syncedTransfers": "{{n}} als Übertrag verbunden.", + "syncHint": "Umsätze angebundener Konten werden direkt gebucht – der Saldo stimmt damit von selbst. Übrig bleibt das Zuordnen der Kategorie." }, "bankReturn": { "title": "Rückkehr von der Bank", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index cae6f0a..5cf0f74 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -592,7 +592,7 @@ "mapHint": "Without a mapping nothing is fetched from this account.", "noAccountsFound": "No accounts were returned. In restricted mode the bank only releases what you linked at Enable Banking under “Activate by linking accounts” – add this bank there and reconnect here.", "sync": "Fetch transactions", - "synced": "{{geholt}} transactions checked · {{neu}} new in the inbox.", + "synced": "{{gebucht}} transactions booked (from {{ab}}).", "noMapping": "No account mapped – map one above first, then it will fetch.", "lastSync": "last fetched on {{datum}}", "neverSynced": "never fetched", @@ -620,7 +620,10 @@ "balanceMatches": "matches Certain", "balanceDiffers": "Certain differs by {{diff}}", "fetchBalances": "Fetch balances", - "balancesFetched": "{{n}} balances updated." + "balancesFetched": "{{n}} balances updated.", + "syncedPending": "{{n}} pending – they arrive once the bank books them.", + "syncedTransfers": "{{n}} connected as transfers.", + "syncHint": "Transactions from connected accounts are booked directly – the balance then matches by itself. What remains is assigning a category." }, "bankReturn": { "title": "Back from your bank", diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index 91f18d0..5ed5ddd 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -117,8 +117,16 @@ export default function Bankanbindung() { setBusy(true); try { const r = await api.syncBankConnection(v.id); - toast(r.ohne_zuordnung ? t("bank.noMapping") - : t("bank.synced", { geholt: r.geholt, neu: r.neu })); + if (r.ohne_zuordnung) { + toast(t("bank.noMapping")); + } else { + // Vorgemerkte nicht verschweigen: Sie fehlen sonst im Saldo und + // niemand weiss, warum. + const teile = [t("bank.synced", { gebucht: r.gebucht, ab: formatDate(r.ab) })]; + if (r.vorgemerkt > 0) teile.push(t("bank.syncedPending", { n: r.vorgemerkt })); + if (r.uebertraege > 0) teile.push(t("bank.syncedTransfers", { n: r.uebertraege })); + toast(teile.join(" ")); + } laden(); } catch (e) { toast(e.message, "error"); @@ -288,6 +296,8 @@ export default function Bankanbindung() { )} +

{t("bank.syncHint")}

+