From 338c21935b3e58c8fc35909be61b4ed1cce0a29e Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 20 Aug 2026 20:52:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Transfer-Erkennung=20symmetrisch=20(Ein?= =?UTF-8?q?g=C3=A4nge)=20+=20Gegenbuchungs-Erkennung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enthält der Buchungstext den Erkennungs-Text (Art „Text") eines ANDEREN Kontos, wird auch ein Eingang als Transfer VON diesem Konto gesetzt – nicht nur die Ausgabe. Richtung ergibt sich aus dem Vorzeichen. - Gegenbuchungs-Erkennung: existiert schon ein Transfer zwischen denselben Konten mit passendem Betrag (±5 Tage), wird die Gegenseite als „Gegenbuchung" markiert und nicht angehakt – so zählt dieselbe Bewegung beim Import beider Auszüge nicht doppelt (überschreibbar in der Prüfansicht). - Tests: Eingang übers Konto-Tag, Gegenbuchung übersprungen (78 grün) Co-Authored-By: Claude Opus 4.8 --- backend/app/services/csv_import.py | 42 +++++++++++++++++++++++++++++- backend/tests/test_keywords.py | 29 +++++++++++++++++++++ web/src/locales/de.json | 1 + web/src/locales/en.json | 1 + web/src/pages/Import.jsx | 3 ++- 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 28dcb0e..fac8539 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -209,6 +209,22 @@ def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpos return best[1] if best else None +# Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt +# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen). +COUNTERPART_DAYS = 5 + + +def _is_counterpart(transfer_index: dict, account_id: int, other_id: int, d: date, amount: int) -> bool: + """True, wenn schon ein Transfer zwischen denselben Konten existiert, dessen + Betrag (Soll- oder Haben-Seite) zu dieser Buchung passt – dann ist es die + bereits gebuchte Gegenseite und darf nicht erneut zählen.""" + absamt = abs(amount) + for t_date, amt, to_amt in transfer_index.get(frozenset((account_id, other_id)), []): + if abs((t_date - d).days) <= COUNTERPART_DAYS and absamt in (amt, to_amt): + return True + return False + + def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]: """Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen.""" cmap = json.loads(template.column_map or "{}") @@ -272,6 +288,18 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict Transaction.owner_id == user.id, Transaction.import_hash.isnot(None) ) } + # Bestehende Transfers je Konto-Paar: erkennt die Gegenbuchung, wenn beide + # Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt). + transfer_index: dict[frozenset, list[tuple]] = {} + for t_acc, t_to, t_date, t_amt, t_to_amt in ( + db.query(Transaction.account_id, Transaction.to_account_id, Transaction.date, + Transaction.amount_cents, Transaction.to_amount_cents) + .filter(Transaction.owner_id == user.id, Transaction.type == TxnType.transfer, + Transaction.to_account_id.isnot(None)) + ): + transfer_index.setdefault(frozenset((t_acc, t_to)), []).append( + (t_date, t_amt, t_to_amt if t_to_amt is not None else t_amt) + ) seen: set[str] = set() out: list[dict] = [] @@ -290,6 +318,10 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict to_account_id: int | None = None other = idents.get(iban) if iban else None + if other is None: + # Symmetrisch: enthält der Text den Erkennungs-Text eines ANDEREN + # Kontos, ist es ein Transfer von/zu diesem Konto (auch Eingänge). + other = _guess_target_account(text_idents, payee or "", purpose or "", account.id) if other is not None and other != account.id: ttype = "transfer" to_account_id = other @@ -312,6 +344,13 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict is_dup = h in existing or h in seen seen.add(h) + # Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen. + is_counterpart = ( + ttype == "transfer" and to_account_id is not None + and d is not None and amount is not None + and _is_counterpart(transfer_index, account.id, to_account_id, d, amount) + ) + out.append({ "index": i, "date": d.isoformat() if d else None, @@ -326,7 +365,8 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict "to_account_id": to_account_id, "import_hash": h, "is_duplicate": is_dup, - "include": valid and not is_dup, + "is_counterpart": is_counterpart, + "include": valid and not is_dup and not is_counterpart, }) return out diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py index 1148e16..a2ac6ce 100644 --- a/backend/tests/test_keywords.py +++ b/backend/tests/test_keywords.py @@ -270,6 +270,35 @@ def test_transfer_keyword_fixed_target(admin): assert r2.json()["target_account_id"] == giro["id"] +def test_incoming_transfer_via_account_tag(admin): + # Erkennungs-Text am Quellkonto (Raiffeisen); Eingang auf Revolut wird zum + # Transfer VON Raiffeisen (symmetrische Erkennung, ohne Schlagwort). + revolut = _account(admin, name="Revolut Sym", identifiers=[{"kind": "text", "value": "Revolut"}]) + raiff = _account(admin, name="Raiffeisen Sym", identifiers=[{"kind": "text", "value": "Raiffeisen"}]) + csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;120,00;Gutschrift;Uebertrag Raiffeisen\n" + rows = _preview_csv(admin, revolut["id"], csv, COLMAP) + assert rows[0]["type"] == "transfer" + assert rows[0]["to_account_id"] == raiff["id"] + + +def test_transfer_counterpart_skipped(admin): + a = _account(admin, name="Konto A CP", identifiers=[{"kind": "text", "value": "KontoA"}]) + b = _account(admin, name="Konto B CP", identifiers=[{"kind": "text", "value": "KontoB"}]) + # A-Auszug: Ausgang -100 mit „KontoB" -> Transfer nach B; buchen. + rowsA = _preview_csv(admin, a["id"], + "Datum;Betrag;Empfaenger;Zweck\n05.03.2026;-100,00;Uebertrag;an KontoB\n", COLMAP) + assert rowsA[0]["type"] == "transfer" and rowsA[0]["to_account_id"] == b["id"] + r = admin.post("/api/imports/commit", json={"account_id": a["id"], "filename": "a.csv", "rows": rowsA}) + assert r.status_code == 200 and r.json()["imported_count"] == 1 + + # B-Auszug: Eingang +100 (2 Tage später) mit „KontoA" -> Gegenbuchung, nicht gebucht. + rowsB = _preview_csv(admin, b["id"], + "Datum;Betrag;Empfaenger;Zweck\n07.03.2026;100,00;Gutschrift;von KontoA\n", COLMAP) + assert rowsB[0]["type"] == "transfer" + assert rowsB[0]["is_counterpart"] is True + assert rowsB[0]["include"] is False + + def test_same_word_across_kinds_allowed(admin): a, b = _expense_cats(admin, 2) inc = _income_cat(admin) diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 2f534a3..ed15a9f 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -316,6 +316,7 @@ "payeeCol": "Empfänger / Zweck", "badDate": "Datum?", "duplicate": "Duplikat", + "counterpart": "Gegenbuchung", "chooseAccount": "Konto wählen", "history": "Import-Verlauf", "undo": "Rückgängig", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 7684c84..51ce99a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -316,6 +316,7 @@ "payeeCol": "Payee / purpose", "badDate": "Date?", "duplicate": "Duplicate", + "counterpart": "Counterpart", "chooseAccount": "Choose account", "history": "Import history", "undo": "Undo", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index 592a9a0..63f4e10 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -247,6 +247,7 @@ export default function Import() { {r.payee || r.purpose || ""} {r.is_duplicate && {t("import.duplicate")}} + {r.is_counterpart && {t("import.counterpart")}} ), }, @@ -526,7 +527,7 @@ export default function Import() { id="import-review" rows={rows} getRowKey={(r) => r.index} - rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate ? "row-dup" : "")} + rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate || r.is_counterpart ? "row-dup" : "")} columns={reviewColumns} />