feat: Import verbindet keine Konten mehr automatisch

Das Raten des Gegenkontos war die Quelle fast aller Saldo-Fehler: falsch
geratene Ziele, verdrehte Richtungen und doppelt gebuchte Bewegungen,
sobald beide Ausz-uege eingelesen wurden. Zeilen ohne erkennbares Ziel
blieben ausserdem ungebucht, wodurch Geld unbemerkt fehlte.

Jede Zeile ist jetzt schlicht das, was im Auszug steht: Ausgabe oder
Einnahme. Fruehere Uebertrags-Schlagwoerter haengen stattdessen die
neutrale Kategorie an - der Saldo stimmt, die Statistik bleibt sauber.
Ein Uebertrag laesst sich weiterhin von Hand buchen.

Damit entfallen Gegenbuchungs-Suche, Zielkonto-Raten und der Zustand
"wartet auf Zielkonto".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 17:19:34 +02:00
co-authored by Claude Opus 5
parent b4cd5b7ca3
commit a4ea0e46db
3 changed files with 121 additions and 254 deletions
+22 -141
View File
@@ -6,7 +6,6 @@ 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
@@ -15,13 +14,11 @@ from sqlalchemy.orm import Session
from ..models import (
Account,
AccountIdentifier,
AmountMode,
BudgetTreatment,
CategorizationRule,
Category,
CategoryKind,
IdentifierKind,
ImportBatch,
ImportStatus,
ImportTemplate,
@@ -252,33 +249,6 @@ 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"(?<![0-9a-zäöüß]){re.escape(nadel)}(?![0-9a-zäöüß])", heu) is not None
def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> 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 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
# Status-Werte, die eine Buchung NICHT wirksam werden lassen. Bewusst als
# Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED"
# heißt auf Deutsch „ABGESCHLOSSEN". Unbekannte Werte werden gebucht, damit
@@ -301,57 +271,12 @@ def state_is_bookable(state: str | None) -> bool:
# Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
COUNTERPART_DAYS = 5
# Wie weit der tatsächlich gutgeschriebene Betrag vom geschätzten abweichen darf,
# damit er noch als dieselbe Bewegung gilt. Bei einem Währungstausch rechnen wir
# mit dem EZB-Kurs, die Bank nutzt ihren eigenen – ein paar Prozent Unterschied
# sind normal, eine andere Buchung liegt praktisch nie so nah.
COUNTERPART_TOLERANCE = 0.20
def _find_counterpart(
side_index: dict, verbraucht: set, account: Account, d: date, amount: int,
waehrung: dict, nur_konto: int | None,
):
"""Sucht einen bereits gebuchten Transfer, dessen Gegenseite diese Zeile ist.
Gesucht wird über die Seite des importierten Kontos – die Zeile braucht also
selbst keine Transfer-Regel. Genau das ist der Empfangs-Auszug: Im CHF-Auszug
entsteht der Transfer, im EUR-Auszug taucht dieselbe Bewegung als Eingang auf.
Bei einem Umtausch stehen zwei Währungen und damit zwei Beträge gegenüber;
dann zählt ein plausibler Betrag statt eines exakten."""
absamt = abs(amount)
bester = None
for eintrag in side_index.get(account.id, []):
txn_id, t_date, erwartet, other_id, richtung = eintrag
tage = abs((t_date - d).days)
if txn_id in verbraucht or tage > COUNTERPART_DAYS:
continue
# Die Richtung muss stimmen: Wo Geld ankam, kann keine Ausgabe die
# Gegenseite sein. Ohne diese Prüfung verschluckt eine Ausgabe über
# denselben Betrag den erwarteten Eingang – der Saldo liefe zu hoch.
if (amount < 0) != (richtung < 0):
continue
# Hat eine Regel ein Gegenkonto gesetzt, muss es dazu passen.
if nur_konto is not None and other_id != nur_konto:
continue
if absamt == erwartet:
return eintrag
cross = waehrung.get(other_id, account.currency) != account.currency
if not cross or not erwartet:
continue
abweichung = abs(absamt - erwartet) / erwartet
if abweichung > COUNTERPART_TOLERANCE:
continue
# Den BESTEN Treffer nehmen, nicht den ersten: Liegen mehrere Umtausche
# im Zeitfenster, schnappte sonst der frühere den passenden Transfer weg
# und die eigentlich gemeinte Zeile blieb ohne Gegenstück.
rang = (tage, abweichung)
if bester is None or rang < bester[0]:
bester = (rang, eintrag)
return bester[1] if bester else None
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
@@ -413,21 +338,12 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
def detect_rows(db: Session, user: User, account: Account, candidates: list[dict]) -> list[dict]:
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik)."""
idents: dict[str, int] = {} # IBAN/Nummer -> Konto (für Transfer via IBAN)
text_idents: list[tuple[str, int]] = [] # freie Erkennungs-Texte -> Konto
for value_norm, acc_id, kind in (
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id, AccountIdentifier.kind)
.join(Account, Account.id == AccountIdentifier.account_id)
.filter(Account.owner_id == user.id)
):
if kind == IdentifierKind.text:
text_idents.append((value_norm, acc_id))
else:
idents[value_norm] = acc_id
"""Reichert Kandidaten an: Typ, Auto-Kategorie, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik).
Konten werden hier NICHT mehr miteinander verbunden – jede Zeile ist
Ausgabe oder Einnahme. Überträge bucht man von Hand.
"""
rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
rules.sort(key=_rule_sort_key, reverse=True) # längstes/​manuell zuerst
# None = neutral: zählt für Ausgabe UND Einnahme (siehe _match_rule).
@@ -447,22 +363,12 @@ 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).
# Je Konto: welcher Betrag dort aus einem bestehenden Transfer erwartet wird.
side_index: dict[int, list[tuple]] = {}
for t_id, t_acc, t_to, t_date, t_amt, t_to_amt in (
db.query(Transaction.id, 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))
):
gutschrift = t_to_amt if t_to_amt is not None else t_amt
side_index.setdefault(t_acc, []).append((t_id, t_date, t_amt, t_to, -1))
side_index.setdefault(t_to, []).append((t_id, t_date, gutschrift, t_acc, +1))
verbraucht: set[int] = set()
waehrung_je_konto: dict[int, str] = dict(
db.query(Account.id, Account.currency).filter(Account.owner_id == user.id)
# Neutrale Kategorie („Kontoübertrag"): Buchungen, die nur Geld zwischen
# eigenen Konten verschieben, werden damit kategorisiert statt zu einem
# Übertrag verbunden. Sie verändern die Salden korrekt und bleiben aus der
# Statistik heraus.
neutrale_kategorie = next(
(cid for cid, k in cat_kind.items() if k is None), None
)
seen: Counter = Counter()
@@ -488,55 +394,30 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
d is not None and amount is not None and amount != 0
and state_ok and currency_ok
)
# Jede Zeile ist Ausgabe oder Einnahme – der Import verbindet keine
# Konten mehr selbst. Das Raten des Gegenkontos hat mehr Schaden
# angerichtet als Nutzen gebracht: geratene Ziele, verdrehte Richtungen
# und doppelt gebuchte Bewegungen, wenn beide Auszüge eingelesen wurden.
# Wer zwei Buchungen bewusst verbinden will, bucht den Übertrag von Hand.
ttype = "expense" if (amount or 0) < 0 else "income"
category_id: int | None = None
matched_rule_id: int | None = None
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
else:
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
if rule is not None:
matched_rule_id = rule.id
if rule.is_transfer:
ttype = "transfer"
# Festes Zielkonto der Regel bevorzugen, sonst aus dem Text raten.
if rule.target_account_id and rule.target_account_id != account.id:
to_account_id = rule.target_account_id
else:
to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
else:
category_id = rule.category_id
# Frühere Übertrags-Schlagwörter kennzeichnen jetzt die neutrale
# Kategorie, statt eine Verbindung zwischen Konten herzustellen.
category_id = rule.category_id or (neutrale_kategorie if rule.is_transfer else None)
h = import_hash(account.id, d, amount, payee)
is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
seen[h] += 1
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen –
# der tatsächliche Betrag dieser Zeile korrigiert aber die Schätzung der
# anderen Seite (beim Umtausch rechneten wir dort mit dem EZB-Kurs).
gegenbuchung = None
if d is not None and amount is not None and not is_dup:
gegenbuchung = _find_counterpart(
side_index, verbraucht, account, d, amount, waehrung_je_konto, to_account_id
)
if gegenbuchung is not None:
verbraucht.add(gegenbuchung[0])
ttype = "transfer" # in der Prüfansicht als Transfer zeigen
to_account_id = to_account_id or gegenbuchung[3]
is_counterpart = gegenbuchung is not None
# Transfer-Regel ohne erkennbares Gegenkonto: NICHT still als Einnahme/
# Ausgabe buchen, sondern in der Prüfansicht das Konto abfragen.
braucht_ziel = ttype == "transfer" and to_account_id is None
is_counterpart = False
braucht_ziel = False
out.append({
"index": i,
@@ -561,7 +442,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"is_duplicate": is_dup,
"is_counterpart": is_counterpart,
# Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt.
"counterpart_txn_id": gegenbuchung[0] if gegenbuchung else None,
"counterpart_txn_id": None,
"include": valid and not is_dup and not is_counterpart and not braucht_ziel,
"needs_target": braucht_ziel,
})
+58 -58
View File
@@ -63,9 +63,11 @@ def test_preview_parsing_and_detection(admin):
# Zeile 2: gleich aussehend wie Zeile 0, aber im selben Auszug KEIN Duplikat.
assert rows[2]["is_duplicate"] is False
assert rows[2]["include"] is True
# Zeile 3: interner Transfer (IBAN passt zu 'spar')
assert rows[3]["type"] == "transfer"
assert rows[3]["to_account_id"] == spar["id"]
# Zeile 3: die IBAN gehört einem eigenen Konto – der Import verbindet
# trotzdem nichts mehr, es bleibt eine gewöhnliche Ausgabe.
assert rows[3]["type"] == "expense"
assert rows[3]["to_account_id"] is None
assert spar
def test_commit_and_undo_and_learning(admin):
@@ -317,62 +319,60 @@ EUR_UMTAUSCH = (
)
def test_currency_exchange_pairs_across_statements(admin):
def test_umtausch_wird_als_ausgabe_und_einnahme_gebucht(admin):
"""Beide Seiten eines Umtauschs zählen einzeln – der Import verbindet nichts.
Früher wurde daraus ein Übertrag mit geratenem Gegenkonto. Das ging schief,
sobald beide Auszüge eingelesen wurden oder das Ziel falsch geraten war.
Jetzt ist jede Zeile das, was im Auszug steht: Abgang beim einen Konto,
Zugang beim anderen. Beide Salden stimmen damit ohne Zutun.
"""
chf = _account(admin, name="Rev CHF X", currency="CHF", opening_balance_cents=50000)
eur = _account(admin, name="Rev EUR X", currency="EUR", opening_balance_cents=0)
# EINE Regel genügt: „Umgetauscht in EUR" -> Gegenkonto ist das EUR-Konto.
admin.post("/api/categories/transfer-keywords",
json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
# 1) CHF-Auszug: Umtausch geht als Transfer CHF -> EUR raus.
rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] == eur["id"]
assert rows[0]["is_counterpart"] is False
assert rows[0]["type"] == "expense"
assert rows[0]["to_account_id"] is None
admin.post("/api/imports/commit", json={"account_id": chf["id"], "filename": "chf.csv", "rows": rows})
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000
# 2) EUR-Auszug: OHNE weitere Regel – die Gegenseite wird über den bereits
# gebuchten Transfer erkannt, obwohl der Betrag in EUR ein anderer ist.
# Der EUR-Auszug bringt den Zugang – als Einnahme, nicht als Gegenbuchung.
rows2 = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
assert rows2[0]["type"] == "transfer"
assert rows2[0]["is_counterpart"] is True, "Umtausch-Gegenseite muss erkannt werden"
assert rows2[0]["include"] is False, "darf nicht doppelt zählen"
assert rows2[0]["type"] == "income"
assert rows2[0]["is_counterpart"] is False
assert rows2[0]["include"] is True
r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "eur.csv", "rows": rows2})
assert r.json()["imported_count"] == 0
# Der echte Eingang korrigiert die EZB-Schätzung: EUR-Konto steht auf 106.42.
assert r.json()["imported_count"] == 1
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 10642
# CHF-Seite bleibt unverändert.
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000
def test_exchange_arrival_without_source_asks_instead_of_booking(admin):
"""Wird der Empfangs-Auszug zuerst eingelesen, ist die Quelle noch unbekannt.
Die Zeile darf dann NICHT still als Einnahme durchgehen, sondern fragt das
Konto ab – sonst zählt die Bewegung später doppelt."""
chf = _account(admin, name="Rev CHF Y", currency="CHF", opening_balance_cents=50000)
eur = _account(admin, name="Rev EUR Y", currency="EUR")
def test_uebertrags_schlagwort_setzt_neutrale_kategorie(admin):
"""Ein früheres Übertrags-Schlagwort kennzeichnet die Zeile jetzt nur noch.
Es verbindet keine Konten mehr, sondern hängt die neutrale Kategorie an –
die Buchung verändert den Saldo, bleibt aber aus der Statistik heraus.
"""
grp = admin.post("/api/categories/groups",
json={"name": "Neutral X", "kind": "expense"}).json()
neutral = admin.post("/api/categories", json={
"group_id": grp["id"], "name": "Kontoübertrag X", "excluded_from_stats": True}).json()
eur = _account(admin, name="Rev EUR Z", currency="EUR")
chf = _account(admin, name="Rev CHF Z", currency="CHF", opening_balance_cents=50000)
admin.post("/api/categories/transfer-keywords",
json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
rows = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] is None
assert rows[0]["needs_target"] is True
assert rows[0]["include"] is False, "ohne Gegenkonto nicht automatisch buchen"
r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "e.csv", "rows": rows})
assert r.json()["imported_count"] == 0
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 0
assert chf # Quellkonto bleibt unberührt
rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH)
assert rows[0]["type"] == "expense"
assert rows[0]["category_id"] == neutral["id"]
assert rows[0]["needs_target"] is False
def test_expense_is_not_swallowed_by_incoming_transfer(admin):
"""Eine echte Ausgabe über denselben Betrag wie ein erwarteter Transfer-Eingang
darf NICHT als Gegenbuchung gelten – sonst fehlt sie und der Saldo läuft zu hoch."""
"""Eine echte Ausgabe bleibt eine Ausgabe – auch wenn anderswo ein gleich
hoher Übertrag steht. Früher wurde sie als dessen Gegenbuchung geschluckt."""
quelle = _account(admin, name="Quelle Z", currency="EUR", opening_balance_cents=100000)
ziel = _account(admin, name="Ziel Z", currency="EUR", opening_balance_cents=10000)
@@ -398,14 +398,11 @@ def test_expense_is_not_swallowed_by_incoming_transfer(admin):
},
).json()["rows"]
assert rows[0]["is_counterpart"] is False, "Ausgabe ist keine Gegenbuchung"
assert rows[0]["include"] is True
assert rows[1]["is_counterpart"] is True, "der Eingang schon"
assert rows[1]["include"] is False
admin.post("/api/imports/commit", json={"account_id": ziel["id"], "filename": "z.csv", "rows": rows})
# 100.00 + 50.00 (Transfer) - 50.00 (Einkauf) = 100.00
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 10000
# Keine Zeile gilt mehr als Gegenbuchung – beide werden gebucht.
assert all(r["is_counterpart"] is False for r in rows)
assert all(r["include"] is True for r in rows)
assert rows[0]["type"] == "expense"
assert rows[1]["type"] == "income"
# --- Zeilenfilter: mehrere Pockets in einer Datei ---------------------------
@@ -487,12 +484,16 @@ def test_fee_row_direction_from_balance(admin):
assert rows["reconciliation"]["ok"] is True
def test_reconciliation_flags_rows_waiting_for_target(admin):
"""Eine Transfer-Regel ohne Gegenkonto lässt die Zeile ungebucht. Der
Saldo-Abgleich muss das melden – sonst fehlt das Geld unbemerkt."""
def test_keine_zeile_bleibt_wegen_fehlendem_gegenkonto_liegen(admin):
"""Jede Zeile des Auszugs wird gebucht – der Saldo-Abgleich geht auf.
Früher liess eine Übertrags-Regel ohne erkennbares Gegenkonto die Zeile
ungebucht; das Geld fehlte unbemerkt und der Abgleich schlug fehl. Ohne
automatische Kontoverknüpfung gibt es diesen Zustand nicht mehr.
"""
giro = _account(admin, name="Ziel offen", currency="CHF")
admin.post("/api/categories/transfer-keywords",
json={"word": "uebertrag", "target_account_id": giro["id"]}) # zeigt auf sich selbst
json={"word": "uebertrag", "target_account_id": giro["id"]})
csv_text = (
"Datum;Betrag;Empfaenger;Zweck;Saldo\n"
@@ -511,14 +512,13 @@ def test_reconciliation_flags_rows_waiting_for_target(admin):
},
).json()
wartend = [r for r in res["rows"] if r.get("needs_target")]
assert len(wartend) == 1 and wartend[0]["amount_cents"] == 9200
assert not [r for r in res["rows"] if r.get("needs_target")]
uebertrag = res["rows"][1]
assert uebertrag["type"] == "income" and uebertrag["include"] is True
rec = res["reconciliation"]
assert rec["ok"] is False, "die wartende Zeile muss auffallen"
assert rec["difference_cents"] == 9200
assert rec["first_mismatch"]["expected_cents"] == 9200
assert rec["first_mismatch"]["actual_cents"] == 0
assert rec["ok"] is True, "der Auszug muss ohne Rest aufgehen"
assert rec["difference_cents"] == 0
def test_remove_undone_batch_from_history(admin):
@@ -574,5 +574,5 @@ def test_kurzer_erkennungstext_trifft_nicht_mitten_im_wort(admin):
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"
# Auch als eigenständiges Wort verbindet der Erkennungs-Text keine Konten mehr.
assert typ["Umbuchung"] == "expense"
+37 -51
View File
@@ -228,75 +228,61 @@ def test_amount_condition_rule(admin):
assert rows[1]["category_id"] is None
def test_transfer_rule_and_target_guess(admin):
def test_import_verbindet_keine_konten_mehr(admin):
"""Der Import erzeugt keine Überträge – weder über Schlagwort noch über
Erkennungs-Text, weder ausgehend noch eingehend.
Das Raten des Gegenkontos hat mehr kaputt gemacht als geholfen: falsch
geratene Ziele, verdrehte Richtungen und doppelt gebuchte Bewegungen,
sobald beide Auszüge eingelesen wurden. Jede Zeile ist jetzt schlicht das,
was im Auszug steht.
"""
giro = _account(admin, name="Giro TR")
spar = _account(admin, name="Sparkonto TR", type="sparkonto",
identifiers=[{"kind": "text", "value": "Sparen"}])
assert any(i["kind"] == "text" for i in spar["identifiers"])
r = admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"})
assert r.status_code == 201, r.text
admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"})
admin.post("/api/categories/transfer-keywords",
json={"word": "revolut", "target_account_id": spar["id"]})
csv = (
"Datum;Betrag;Empfaenger;Zweck\n"
"01.03.2026;-100,00;Max;Uebertrag Sparen\n" # -> Transfer, Ziel = Sparkonto
"02.03.2026;-50,00;Max;Uebertrag woandershin\n" # -> Transfer, kein Ziel erkannt
"01.03.2026;-100,00;Max;Uebertrag Sparen\n" # Schlagwort + Erkennungs-Text
"02.03.2026;-25,00;Einkauf REVOLUT;Karte\n" # Schlagwort mit festem Ziel
"03.03.2026;120,00;Gutschrift;von Sparen\n" # Eingang mit Erkennungs-Text
)
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] == spar["id"]
assert rows[1]["type"] == "transfer"
assert rows[1]["to_account_id"] is None
assert [r["type"] for r in rows] == ["expense", "expense", "income"]
assert all(r["to_account_id"] is None for r in rows)
assert all(r["needs_target"] is False for r in rows)
def test_transfer_keyword_fixed_target(admin):
giro = _account(admin, name="Giro FT")
ziel = _account(admin, name="Revolut Konto")
# Transfer-Schlagwort „revolut" MIT festem Zielkonto (ohne Konto-Texte).
r = admin.post("/api/categories/transfer-keywords",
json={"word": "revolut", "target_account_id": ziel["id"]})
assert r.status_code == 201, r.text
assert r.json()["target_account_id"] == ziel["id"]
def test_beide_auszuege_werden_beide_gebucht(admin):
"""Lies man beide Seiten einer Bewegung ein, zählt jede auf ihrem Konto.
csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-25,00;Einkauf REVOLUT;Karte\n"
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] == ziel["id"]
# Erneutes Anlegen desselben Worts aktualisiert das Zielkonto (kein Konflikt).
r2 = admin.post("/api/categories/transfer-keywords",
json={"word": "revolut", "target_account_id": giro["id"]})
assert r2.status_code == 201, r2.text
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"}])
Früher galt die zweite Zeile als Gegenbuchung und blieb ungebucht – was
schiefging, sobald die Erkennung danebenlag. Jetzt bucht jedes Konto das,
was in seinem eigenen Auszug steht, und beide Salden stimmen.
"""
a = _account(admin, name="Konto A CP", opening_balance_cents=100000,
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"]
assert rowsA[0]["type"] == "expense"
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
assert 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
assert rowsB[0]["type"] == "income"
assert rowsB[0]["is_counterpart"] is False
assert rowsB[0]["include"] is True
r = admin.post("/api/imports/commit", json={"account_id": b["id"], "filename": "b.csv", "rows": rowsB})
assert r.json()["imported_count"] == 1
assert admin.get(f"/api/accounts/{a['id']}").json()["balance_cents"] == 90000
assert admin.get(f"/api/accounts/{b['id']}").json()["balance_cents"] == 10000
def test_same_word_across_kinds_allowed(admin):