diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 3925afd..6372ff0 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -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"(? 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 + 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 + # 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, }) diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 1aea19d..0780524 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -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" diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py index d53e73f..9dea73b 100644 --- a/backend/tests/test_keywords.py +++ b/backend/tests/test_keywords.py @@ -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):