From 1e7c1ec0147d12b1bc0cb489ba8f7b170e4a47a2 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 15:31:41 +0200 Subject: [PATCH] fix: Erkennungstext trifft nur noch an Wortgrenzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Erkennungs-Text eines Kontos wurde als blosser Teilstring gesucht. Am EUR-Konto stand „Euro" – der steckt auch in „PayPal Europe", „Europa-Park" und „EUROBUS". Jede solche Zahlung wurde dadurch zum Uebertrag auf dieses Konto, das Geld dort zusaetzlich gutgeschrieben und der Saldo lief um mehr als tausend Euro daneben. Gesucht wird jetzt nur an Wortgrenzen; eine Kartenendung wie „6543" schlaegt damit auch nicht mehr mitten in einer laengeren Kontonummer an. Das Kontoformular warnt zusaetzlich bei sehr kurzen Erkennungstexten. Co-Authored-By: Claude Opus 5 --- backend/app/services/csv_import.py | 19 ++++++++++++++- backend/tests/test_import.py | 37 ++++++++++++++++++++++++++++++ web/src/locales/de.json | 3 ++- web/src/locales/en.json | 3 ++- web/src/pages/Accounts.jsx | 4 ++++ 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 3150597..3925afd 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -6,6 +6,7 @@ import csv import hashlib import io import json +import re from collections import Counter from datetime import date, datetime from decimal import Decimal, InvalidOperation, ROUND_HALF_UP @@ -251,13 +252,29 @@ def _match_rule( return None +def _trifft_als_wort(nadel: str, heu: str) -> bool: + """Erkennungs-Text nur an Wortgrenzen suchen, nicht mitten im Wort. + + Ein kurzer Text richtet als reiner Teilstring Schaden an: Der Erkennungs-Text + „Euro" eines EUR-Kontos steckt auch in „PayPal Europe", „Europa-Park" und + „EUROBUS" – jede solche Zahlung wurde dadurch zum Übertrag auf dieses Konto + und das Geld doppelt gezählt. Genauso darf die Kartenendung „6543" nicht + mitten in einer längeren Kontonummer anschlagen. + """ + return re.search(rf"(? int | None: """Zielkonto eines Transfers aus dem Buchungstext raten: längster passender Erkennungs-Text je Konto gewinnt.""" hay = f"{normalize_text(payee)} {normalize_text(purpose)}" best: tuple[int, int] | None = None # (Länge, Konto-ID) for val, acc_id in text_idents: - if val and acc_id != source_id and val in hay and (best is None or len(val) > best[0]): + if not val or acc_id == source_id: + continue + if not _trifft_als_wort(val, hay): + continue + if best is None or len(val) > best[0]: best = (len(val), acc_id) return best[1] if best else None diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 1f36b83..1aea19d 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -539,3 +539,40 @@ def test_remove_undone_batch_from_history(admin): admin.delete(f"/api/imports/batches/{batch['id']}") # rückgängig machen assert admin.delete(f"/api/imports/batches/{batch['id']}/record").status_code == 204 assert not any(b["id"] == batch["id"] for b in admin.get("/api/imports/batches").json()) + + +KURZ_CSV = ( + "Datum;Betrag;Empfaenger;Zweck;GegenIBAN\n" + "05.03.2026;-48,97;PayPal Europe S.a.r.l.;Lastschrift;\n" + "06.03.2026;-30,00;Europa-Park;Eintritt;\n" + "07.03.2026;-24,50;TWINT EUROBUS AG;Einkauf;\n" + "08.03.2026;-200,00;Umbuchung;Uebertrag Euro Konto;\n" +) + + +def test_kurzer_erkennungstext_trifft_nicht_mitten_im_wort(admin): + """Ein kurzer Erkennungs-Text darf nicht als blosser Teilstring greifen. + + Am EUR-Konto stand als Erkennungs-Text „Euro". Weil er ungeprüft als + Teilstring gesucht wurde, wurden „PayPal Europe", „Europa-Park" und + „EUROBUS" zu Überträgen auf dieses Konto – das Geld landete dort zusätzlich + und der Saldo lief weit daneben. Nur ein eigenständiges Wort zählt. + """ + giro = _account(admin, name="Giro CHF", currency="CHF") + _account(admin, name="Euro Konto", currency="EUR", + identifiers=[{"kind": "text", "value": "Euro"}]) + + r = admin.post( + "/api/imports/preview", + files={"file": ("k.csv", io.BytesIO(KURZ_CSV.encode("utf-8")), "text/csv")}, + data={"account_id": str(giro["id"]), "delimiter": ";", "decimal_sep": ",", + "date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true", + "skip_rows": "0", "amount_mode": "signed", "column_map": COLMAP}, + ) + assert r.status_code == 200, r.text + typ = {row["payee"]: row["type"] for row in r.json()["rows"]} + assert typ["PayPal Europe S.a.r.l."] == "expense" + assert typ["Europa-Park"] == "expense" + assert typ["TWINT EUROBUS AG"] == "expense" + # Als eigenständiges Wort greift der Erkennungs-Text weiterhin. + assert typ["Umbuchung"] == "transfer" diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 5147c0c..76a7707 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -166,7 +166,8 @@ "total": "Gesamt", "empty": "Noch keine Konten. Lege dein erstes Konto an.", "deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?", - "openingDateHint": "Stand am Ende dieses Tages. Buchungen bis dahin zählen nicht zum Kontostand, bleiben aber in Liste und Auswertungen. Ohne Stichtag zählt alles." + "openingDateHint": "Stand am Ende dieses Tages. Buchungen bis dahin zählen nicht zum Kontostand, bleiben aber in Liste und Auswertungen. Ohne Stichtag zählt alles.", + "shortIdentWarning": "Kurze Erkennungstexte treffen leicht Fremdes: „Euro“ steckt auch in „Europa-Park“ oder „PayPal Europe“. Solche Buchungen werden dann fälschlich zu Überträgen. Nimm lieber etwas Eindeutiges wie die IBAN oder „Revolut**5064*“." }, "accountTypes": { "girokonto": "Girokonto", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 6478113..5d284c9 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -166,7 +166,8 @@ "total": "Total", "empty": "No accounts yet. Create your first account.", "deleteConfirm": "Delete this account and all its transactions?", - "openingDateHint": "Balance at the end of that day. Bookings up to then don't count toward the balance but stay in the list and reports. Without a date, everything counts." + "openingDateHint": "Balance at the end of that day. Bookings up to then don't count toward the balance but stay in the list and reports. Without a date, everything counts.", + "shortIdentWarning": "Short match texts hit unrelated bookings: “Euro” also occurs in “Europa-Park” or “PayPal Europe”, turning those into transfers by mistake. Prefer something unique such as the IBAN." }, "accountTypes": { "girokonto": "Checking", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index cf820ec..3cd8c0c 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -340,6 +340,10 @@ export default function Accounts() { ))} + {form.identifiers.some((i) => i.kind === "text" && i.value.trim().length > 0 + && i.value.trim().length < 6) && ( +

{t("accounts.shortIdentWarning")}

+ )}