diff --git a/backend/app/routers/bank.py b/backend/app/routers/bank.py index 1a1887f..e000b29 100644 --- a/backend/app/routers/bank.py +++ b/backend/app/routers/bank.py @@ -128,7 +128,7 @@ def sync(connection_id: int, db: Session = Depends(get_db), @router.post("/links/{link_id}/sync") -def sync_link(link_id: int, db: Session = Depends(get_db), +def sync_link(link_id: int, tage: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Nur die Umsätze DIESES Kontos holen. @@ -138,7 +138,11 @@ def sync_link(link_id: int, db: Session = Depends(get_db), verknuepfung = get_owned(db, BankAccountLink, link_id, user) verbindung = get_owned(db, BankConnection, verknuepfung.connection_id, user) try: - return bank_sync.hole_umsaetze(db, user, verbindung, nur_link_id=verknuepfung.id) + if tage is not None and not 1 <= tage <= 365: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Der Zeitraum muss zwischen 1 und 365 Tagen liegen.") + return bank_sync.hole_umsaetze(db, user, verbindung, + nur_link_id=verknuepfung.id, tage=tage) except BankFehler as e: verbindung.last_error = str(e)[:500] db.commit() diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 8808b16..d8f5f66 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -485,7 +485,7 @@ def _ist_gebucht(umsatz: dict) -> bool | None: def _startdatum(db: Session, user: User, konto: Account, - verknuepfung: BankAccountLink) -> date: + 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 @@ -501,6 +501,10 @@ def _startdatum(db: Session, user: User, konto: Account, ein Einzelabruf das Startdatum der uebrigen Konten mit vor, und deren Umsaetze aus dem uebersprungenen Zeitraum kaemen nie an. """ + 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 date.today() - timedelta(days=tage) if verknuepfung.last_sync_at: return verknuepfung.last_sync_at.date() - timedelta(days=3) frueheste = date.today() - timedelta(days=ERSTER_ABRUF_TAGE) @@ -515,7 +519,7 @@ def _startdatum(db: Session, user: User, konto: Account, def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, - nur_link_id: int | None = None) -> dict: + 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 @@ -541,8 +545,10 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, continue # Je Konto eigen: Das eine ist bis gestern per CSV gepflegt, das andere # ganz neu. - seit = _startdatum(db, user, konto, verknuepfung) + 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() for umsatz in client.umsaetze(verknuepfung.remote_uid, seit): geholt += 1 zustand = _ist_gebucht(umsatz) @@ -577,10 +583,18 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, # 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, - }]) + 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 diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 1c585d7..e986fa7 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -17,6 +17,7 @@ from ..models import ( Account, AccountIdentifier, InboxItem, + InboxSource, InboxStatus, AmountMode, BudgetTreatment, @@ -663,7 +664,9 @@ def finde_eingangs_treffer( return beste[1] if beste else None -def detect_rows(db: Session, user: User, account: Account, candidates: list[dict]) -> list[dict]: +def detect_rows(db: Session, user: User, account: Account, candidates: list[dict], *, + eingang_ohne: InboxSource | None = None, + verbraucht: set[int] | None = None) -> list[dict]: """Reichert Kandidaten an: Typ, Auto-Kategorie, Duplikat. Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik). @@ -701,17 +704,24 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict # wortgleich mit dem des Auszugs, und das Buchungsdatum der Bank liegt oft # ein bis zwei Tage später. Deshalb wird zusätzlich unscharf verglichen – # gleicher Betrag, Datum in der Nähe. - eingang = [ - (eid, edatum, ebetrag) - for eid, edatum, ebetrag in db.query( - InboxItem.id, InboxItem.date, InboxItem.amount_cents - ).filter( - InboxItem.owner_id == user.id, - InboxItem.account_id == account.id, - InboxItem.status.in_((InboxStatus.booked, InboxStatus.discarded)), - ) - ] - eingang_verbraucht: set[int] = set() + frage = db.query( + InboxItem.id, InboxItem.date, InboxItem.amount_cents + ).filter( + InboxItem.owner_id == user.id, + InboxItem.account_id == account.id, + InboxItem.status.in_((InboxStatus.booked, InboxStatus.discarded)), + ) + if eingang_ohne is not None: + # Zwei Umsätze DERSELBEN Quelle sind nie Dubletten voneinander – dort + # entscheidet die Kennung der Quelle, und die ist exakt. Ohne diese + # Ausnahme verschluckt eine Zahlung über 1.00 die nächste über 1.00 + # drei Tage später, und der Saldo weicht ab. + frage = frage.filter(InboxItem.source != eingang_ohne) + eingang = [(eid, edatum, ebetrag) for eid, edatum, ebetrag in frage] + # Der Aufrufer kann die Menge über mehrere Aufrufe hinweg mitführen: Der + # Bankabruf ruft je Zeile einzeln auf, und ohne gemeinsame Menge liesse + # sich derselbe Eintrag beliebig oft verbrauchen. + eingang_verbraucht: set[int] = verbraucht if verbraucht is not None else set() seen: Counter = Counter() out: list[dict] = [] diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 9e7242f..a1b5224 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -986,3 +986,70 @@ def test_geloeschte_buchung_blockiert_auch_den_csv_import(admin, fake_bank): db.close() assert zeilen[0]["is_duplicate"] is True assert zeilen[0]["duplicate_reason"] == "inbox" + + +def test_zwei_gleiche_betraege_kurz_hintereinander_kommen_beide_an(admin, fake_bank): + """Revolut-Aufrundungen sind oft mehrfach derselbe Betrag in wenigen Tagen. + Der Eingangs-Abgleich vergleicht nur Betrag und Datum (±3 Tage) – wenn er + denselben Eintrag mehrfach verbraucht, faellt die zweite Zahlung weg und + der Saldo weicht ab.""" + _app(admin) + heute = date.today() + uid = f"acc-{secrets.token_hex(4)}" + umsaetze = [ + {"entry_reference": f"tx-{secrets.token_hex(4)}", + "booking_date": (heute - timedelta(days=tage)).isoformat(), + "transaction_amount": {"amount": "1.00", "currency": "CHF"}, + "credit_debit_indicator": "DBIT", + "creditor": {"name": "To CHF MB:8f33c0aa"}} + for tage in (2, 1, 0) + ] + fake_bank(konten=[{"uid": uid, "currency": "CHF"}], umsaetze=umsaetze) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Rundung-{secrets.token_hex(3)}", + "country": "CH"}).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"Rundung {secrets.token_hex(3)}", currency="CHF") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + r = admin.post(f"/api/bank/links/{link['id']}/sync").json() + assert r["geholt"] == 3 + assert r["neu"] == 3, f"nur {r['neu']} von 3 angekommen" + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -300 + + +def test_verschluckter_umsatz_laesst_sich_nachholen(admin, fake_bank): + """Wurde etwas faelschlich als Dublette uebersprungen, entstand kein + Eingangs-Eintrag – der naechste Abruf haette es also wieder geholt. Nur + liegt es dann meist vor dem Fenster des letzten Abrufs.""" + _app(admin) + alt = (date.today() - timedelta(days=40)).isoformat() + uid = f"acc-{secrets.token_hex(4)}" + gegenstelle = fake_bank( + konten=[{"uid": uid, "currency": "EUR"}], + umsaetze=[dict(UMSATZ, entry_reference=f"tx-{secrets.token_hex(4)}", + booking_date=alt)]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Nachhol-{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"Nachhol {secrets.token_hex(3)}", currency="EUR") + # Eine vorhandene Buchung schiebt das Startdatum nach vorn. + admin.post("/api/transactions", json={ + "type": "expense", "date": date.today().isoformat(), "account_id": konto["id"], + "amount_cents": 100, "payee": "schon da"}) + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + # Der normale Abruf greift nicht weit genug zurueck. + assert admin.post(f"/api/bank/links/{link['id']}/sync").json()["neu"] == 0 + assert gegenstelle.date_from > alt + + # Mit Zeitraum schon. + r = admin.post(f"/api/bank/links/{link['id']}/sync", params={"tage": 90}).json() + assert r["neu"] == 1, r + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -100 - 1490 + + assert admin.post(f"/api/bank/links/{link['id']}/sync", + params={"tage": 400}).status_code == 400 diff --git a/web/src/api.js b/web/src/api.js index 023d4b4..ba683a3 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -243,7 +243,9 @@ export const api = { request(`/bank/connections/${id}/salden`, { method: "POST" }), // Nur dieses eine Konto abrufen – ein Abruf bucht, und wer ein Konto holt, // will das andere nicht angefasst haben. - syncBankLink: (linkId) => request(`/bank/links/${linkId}/sync`, { method: "POST" }), + syncBankLink: (linkId, tage) => + request(`/bank/links/${linkId}/sync${tage ? `?tage=${tage}` : ""}`, + { method: "POST" }), // Den Saldo der Bank als richtig uebernehmen – die Differenz wandert in // den Anfangsbestand, die Buchungen bleiben unangetastet. adoptBankBalance: (accountId) => diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 1a89651..1198937 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -650,7 +650,8 @@ "poll": "Automatisch holen", "pollUnit": "Minuten (0 = aus)", "pollHint": "Certain holt die Umsätze dann von selbst und bucht sie. 0 schaltet das ab; weniger als 15 Minuten lassen die Banken nicht zu.", - "pollSaved": "Takt gespeichert." + "pollSaved": "Takt gespeichert.", + "syncOlder": "Älteres nachholen" }, "bankReturn": { "title": "Rückkehr von der Bank", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f69eafa..a6937c5 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -650,7 +650,8 @@ "poll": "Fetch automatically", "pollUnit": "minutes (0 = off)", "pollHint": "Certain then fetches and books transactions on its own. 0 turns this off; banks do not allow less than 15 minutes.", - "pollSaved": "Interval saved." + "pollSaved": "Interval saved.", + "syncOlder": "Catch up on older" }, "bankReturn": { "title": "Back from your bank", diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index b53a897..183815c 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -122,10 +122,10 @@ export default function Bankanbindung() { return teile.join(" "); } - async function kontoAbrufen(l) { + async function kontoAbrufen(l, tage) { setBusy(true); try { - const r = await api.syncBankLink(l.id); + const r = await api.syncBankLink(l.id, tage); toast(r.ohne_zuordnung ? t("bank.noMapping") : meldung(r)); laden(); } catch (e) { @@ -307,6 +307,12 @@ export default function Bankanbindung() { onClick={() => kontoAbrufen(l)}> {t("bank.syncOne")} + {/* Fehlt etwas, liegt es meist vor dem Fenster des + letzten Abrufs und käme sonst nie wieder. */} + {l.last_sync_at ? t("bank.lastSync", { when: formatDate(l.last_sync_at) })