diff --git a/backend/app/schemas.py b/backend/app/schemas.py index acdca38..8959737 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -587,6 +587,10 @@ class ImportBatchOut(BaseModel): imported_count: int status: ImportStatus created_at: datetime + # Wie viele Uebertraege beim Buchen erkannt wurden. Nicht gespeichert – + # nur die Rueckmeldung eines Vorgangs. Sonst erfaehrt niemand, dass eine + # Kartenrechnung gerade still mit ihrer Gegenbuchung verbunden wurde. + uebertraege_verknuepft: int = 0 class ImportCommitIn(BaseModel): diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index d07166d..558ee87 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -1100,9 +1100,10 @@ def commit_import(db: Session, user: User, account: Account, template_id: int | # Frisch gebuchte Zeilen auf Überträge prüfen. Läuft NACH dem Buchen, nicht # währenddessen: Der Import bleibt damit unangetastet, und wenn die Suche # etwas falsch macht, ist es ein Etikett – keine Buchung. + verknuepft = 0 try: from . import uebertraege - uebertraege.finde(db, user) + verknuepft = uebertraege.finde(db, user).get("verknuepft", 0) except Exception: # Nie den Import daran scheitern lassen log.exception("Übertrags-Suche nach dem Import fehlgeschlagen") @@ -1116,6 +1117,10 @@ def commit_import(db: Session, user: User, account: Account, template_id: int | log.exception("Vertrags-Zuordnung nach dem Import fehlgeschlagen") db.refresh(batch) + # Kein Datenbankfeld, sondern die Rueckmeldung dieses Vorgangs: Was still + # verknuepft wurde, soll der Nutzer erfahren. Danach interessiert es + # niemanden mehr. + batch.uebertraege_verknuepft = verknuepft return batch diff --git a/backend/app/services/uebertraege.py b/backend/app/services/uebertraege.py index 821c0cc..b823ce4 100644 --- a/backend/app/services/uebertraege.py +++ b/backend/app/services/uebertraege.py @@ -32,6 +32,7 @@ from sqlalchemy.orm import Session from ..models import ( Account, AccountIdentifier, + AccountType, IdentifierKind, LinkIgnored, Transaction, @@ -92,7 +93,38 @@ def _kontobezeichner(db: Session, user: User) -> dict[int, list[str]]: return je_konto -def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str]]) -> bool: +def _kontotypen(db: Session, user: User) -> dict[int, AccountType]: + return dict(db.query(Account.id, Account.type).filter(Account.owner_id == user.id)) + + +def _ist_kartenzahlung(a: Transaction, b: Transaction, + typen: dict[int, AccountType]) -> bool: + """Geht hier eine Kreditkartenrechnung auf? + + Eine Karte bekommt Geld nur aus einem Grund: weil ihre Rechnung bezahlt + wird. Das ist Hinweis genug – und noetig, denn im Buchungstext steht bei + so einer Zahlung oft weder ein Schlagwort noch der Name des Gegenkontos. + + Entscheidend ist die RICHTUNG, nicht bloss, dass eine Karte beteiligt ist. + Die weitere Fassung „Kartenkonto auf einer Seite" wuerde einen + Karteneinkauf ueber 50,00 mit einer zufaelligen Gutschrift ueber 50,00 am + selben Tag verknuepfen – eine echte Ausgabe UND eine echte Einnahme fielen + still aus der Auswertung. Genau der Schaden, gegen den _hat_hinweis + gebaut wurde. + + Zwei Karten untereinander bleiben aussen vor: Karten zahlen einander + nicht. + """ + for karte, quelle in ((a, b), (b, a)): + if (karte.type == TxnType.income + and typen.get(karte.account_id) == AccountType.kreditkarte + and typen.get(quelle.account_id) != AccountType.kreditkarte): + return True + return False + + +def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str]], + typen: dict[int, AccountType] | None = None) -> bool: """Deutet mindestens eine der beiden Zeilen auf einen Übertrag hin? Ohne diese Hürde würde ein zufälliger Treffer – 50.00 im Laden bezahlt, am @@ -100,6 +132,8 @@ def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str] gelten und eine echte Ausgabe samt echter Einnahme aus der Statistik nehmen. Der Saldo bliebe richtig, die Auswertung nicht. """ + if typen and _ist_kartenzahlung(a, b, typen): + return True for txn, gegen in ((a, b), (b, a)): text = _text(txn) if any(h in text for h in HINWEISE): @@ -235,6 +269,7 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict: """ kandidaten = _kandidaten(db, user) bezeichner = _kontobezeichner(db, user) + typen = _kontotypen(db, user) nach_id = {} for a_id, liste in kandidaten.items(): for b in liste: @@ -262,7 +297,7 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict: if a.category_id is not None or gegen.category_id is not None: vorschlaege.append((a, gegen)) continue - if not _hat_hinweis(a, gegen, bezeichner): + if not _hat_hinweis(a, gegen, bezeichner, typen): vorschlaege.append((a, gegen)) continue if nur_vorschlaege: diff --git a/backend/tests/test_import_kreditkarte.py b/backend/tests/test_import_kreditkarte.py index e8ec8de..a70bedb 100644 --- a/backend/tests/test_import_kreditkarte.py +++ b/backend/tests/test_import_kreditkarte.py @@ -324,3 +324,87 @@ def test_zweckspalte_bleibt_erhalten(admin): spalten = _erkennen(admin, csv_text)["config"]["column_map"] assert spalten["payee"] == 2 assert spalten["purpose"] == 3 + + +# --- Die Rechnungszahlung als Uebertrag ------------------------------------ + +def _giro(admin, **kw): + body = {"name": "Giro Karte", "type": "girokonto", "currency": "EUR", + "opening_balance_cents": 500000} + body.update(kw) + r = admin.post("/api/accounts", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def _buchung(admin, konto, art, betrag, wer, tag="2026-03-20", **kw): + body = {"type": art, "date": tag, "account_id": konto["id"], + "amount_cents": betrag, "payee": wer} + body.update(kw) + r = admin.post("/api/transactions", json=body) + assert r.status_code in (200, 201), r.text + return r.json() + + +def test_kartenrechnung_wird_ohne_schlagwort_verknuepft(admin): + """Im Buchungstext steht oft weder ein Schlagwort noch der Kontoname. + + Eine Karte bekommt Geld nur aus einem Grund: weil ihre Rechnung bezahlt + wird. Das genuegt als Hinweis. + """ + giro = _giro(admin, name="Giro Rechnung") + karte = _konto(admin, name="Visa Rechnung") + + _buchung(admin, giro, "expense", 19730, "Kartenabrechnung 03/26") + _buchung(admin, karte, "income", 19730, "Zahlungseingang") + + ergebnis = admin.post("/api/uebertraege/suchen").json() + assert ergebnis["verknuepft"] == 1, ergebnis + + +def test_karteneinkauf_wird_nicht_mit_zufalls_gutschrift_verknuepft(admin): + """Die Gegenprobe – und der Grund für die enge Fassung. + + Ein Einkauf mit der Karte ueber 50,00 und eine zufaellige Gutschrift ueber + 50,00 am selben Tag auf dem Giro sind kein Uebertrag. Die weitere Regel + „Kartenkonto auf einer Seite" haette hier eine echte Ausgabe UND eine + echte Einnahme still aus der Auswertung genommen. + """ + giro = _giro(admin, name="Giro Zufall") + karte = _konto(admin, name="Visa Zufall") + + _buchung(admin, karte, "expense", 5000, "Elektronikladen", tag="2026-04-10") + _buchung(admin, giro, "income", 5000, "Erstattung Krankenkasse", tag="2026-04-10") + + ergebnis = admin.post("/api/uebertraege/suchen").json() + assert ergebnis["verknuepft"] == 0, ergebnis + # Als Vorschlag darf es erscheinen – entscheiden soll der Nutzer. + assert len(ergebnis["vorschlaege"]) >= 1 + + +def test_import_meldet_erkannte_uebertraege(admin): + """Was still geschieht, muss wenigstens gesagt werden. + + Die Kartenrechnung wird beim Buchen automatisch mit ihrer Gegenbuchung + verbunden – und faellt damit aus der Auswertung. Ohne Rueckmeldung erfaehrt + das niemand: In der Vorschlagsliste steht danach nichts mehr, weil es + nichts mehr vorzuschlagen gibt. + """ + giro = _giro(admin, name="Giro Meldung") + karte = _konto(admin, name="Visa Meldung", + identifiers=[{"kind": "iban", "value": "CH55 0076 2011 6238 5295 7"}]) + _buchung(admin, giro, "expense", 19730, "Abrechnung 4711") + + csv_text = ( + "Konto;Datum;Betrag;Haendler;Saldo\n" + "CH5500762011623852957;15.03.2026;60,00;Elektronikladen;160,00\n" + "CH5500762011623852957;20.03.2026;-197,30;Rechnungsausgleich;-37,30\n" + ) + zeilen = _vorschau(admin, karte["id"], csv_text, "signed_inverted", + json.dumps({"date": 1, "amount": 2, "payee": 3, "balance": 4}))["rows"] + antwort = admin.post("/api/imports/commit", json={ + "account_id": karte["id"], "filename": "karte.csv", "rows": zeilen, + }).json() + + assert antwort["imported_count"] == 2 + assert antwort["uebertraege_verknuepft"] == 1, antwort diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 8712961..4c179b2 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -459,6 +459,7 @@ "noneValid": "Keine gültige Buchung erkannt. Prüfe Datumsformat, Dezimaltrenner und Spaltenzuordnung – am einfachsten oben die passende Bank-Vorlage wählen.", "commit": "{{n}} Buchungen übernehmen", "imported": "{{n}} Buchungen importiert.", + "importedWithTransfers": "{{n}} Buchungen importiert · {{u}} als Übertrag erkannt und verknüpft.", "importedCount": "{{n}} gebucht", "typeCol": "Typ", "payeeCol": "Empfänger / Zweck", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f72b31a..9bbc145 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -459,6 +459,7 @@ "noneValid": "No valid transaction detected. Check date format, decimal separator and column mapping – easiest is to pick the matching bank preset above.", "commit": "Import {{n}} transactions", "imported": "Imported {{n}} transactions.", + "importedWithTransfers": "{{n}} transactions imported · {{u}} recognised as transfers and linked.", "importedCount": "{{n}} imported", "typeCol": "Type", "payeeCol": "Payee / purpose", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index 5ce90e9..1c0d846 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -7,6 +7,7 @@ import { useToast } from "../toast"; import Icon from "../components/Icon"; import AccountSelect from "../components/AccountSelect"; import CategorySelect from "../components/CategorySelect"; +import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege"; import DataTable from "../components/DataTable"; import { gruppenFuer } from "../kategorien"; import { formatMoney } from "../format"; @@ -54,7 +55,7 @@ function defaultConfig() { export default function Import() { const { t } = useTranslation(); - const { formatDate } = useSettings(); + const { formatDate, baseCurrency } = useSettings(); const confirm = useConfirm(); const toast = useToast(); const fileRef = useRef(null); @@ -74,6 +75,8 @@ export default function Import() { const [uploads, setUploads] = useState([]); // Ergebnis der automatischen Formaterkennung. const [erkannt, setErkannt] = useState(null); + // 0 = noch nichts gebucht, also auch nichts zu zeigen. + const [uebertragsLauf, setUebertragsLauf] = useState(0); const [quelle, setQuelle] = useState(""); // "preset:i" | "tpl:id" | "" const [rows, setRows] = useState(null); const [recon, setRecon] = useState(null); @@ -211,10 +214,22 @@ export default function Import() { filename: file?.name || "", rows, }); - toast(t("import.imported", { n: batch.imported_count })); + toast(batch.uebertraege_verknuepft > 0 + ? t("import.importedWithTransfers", { + n: batch.imported_count, u: batch.uebertraege_verknuepft }) + : t("import.imported", { n: batch.imported_count })); setRows(null); setRecon(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]); if (fileRef.current) fileRef.current.value = ""; loadStamm(); + // Uebertraege sind nach einem Import am wahrscheinlichsten – eine + // Kartenrechnung etwa taucht erst auf, wenn beide Auszuege drin sind. + // Bisher musste man dafuer auf „Zu erledigen" wechseln und wusste es + // auch nicht. + // + // Die Komponente holt ihre Vorschlaege beim Mounten selbst und rendert + // nichts, wenn es keine gibt. Der hochgezaehlte Schluessel montiert sie + // nach jedem Import neu. + setUebertragsLauf((n) => n + 1); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } @@ -933,6 +948,11 @@ export default function Import() { )} + {uebertragsLauf > 0 && ( + + )} + {/* Verlauf */} {batches.length > 0 && (