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) })