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 && (
+