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