"""Kreditkarten-Auszuege: + ist eine Ausgabe. Aus Sicht der Karte ist das richtig – ein Einkauf erhoeht die Schuld, die Rechnungszahlung senkt sie. Certain kannte diese Leserichtung nicht. Nebenher werden hier auch ``abs_expense`` und ``debit_credit`` geprueft: Beide Modi gab es seit jeher, getestet war keiner von beiden. """ import io import json COLMAP = json.dumps({"date": 0, "amount": 1, "payee": 2, "balance": 3}) # Startschuld 100,00. Der Auszug fuehrt Belastungen positiv – und die # Saldo-Spalte in derselben Richtung: Die Schuld waechst. # # Einkauf 60,00 -> Schuld 160,00 # Tanken 24,50 -> Schuld 184,50 # Rechnung -184,50 -> Schuld 0,00 # Baeckerei 12,00 -> Schuld 12,00 KARTE_CSV = ( "Datum;Betrag;Haendler;Saldo\n" "15.03.2026;60,00;Elektronikladen;160,00\n" "16.03.2026;24,50;Tankstelle;184,50\n" "20.03.2026;-184,50;Rechnungsausgleich;0,00\n" "22.03.2026;12,00;Baeckerei;12,00\n" ) def _konto(admin, **kw): body = {"name": "Visa Import", "type": "kreditkarte", "currency": "EUR", "opening_balance_cents": 0} body.update(kw) r = admin.post("/api/accounts", json=body) assert r.status_code == 201, r.text return r.json() def _vorschau(admin, account_id, csv_text, modus, colmap=COLMAP, **extra): daten = { "account_id": str(account_id), "delimiter": ";", "decimal_sep": ",", "date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true", "skip_rows": "0", "amount_mode": modus, "column_map": colmap, } daten.update(extra) r = admin.post( "/api/imports/preview", files={"file": ("karte.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")}, data=daten, ) assert r.status_code == 200, r.text return r.json() def test_signed_inverted_dreht_die_richtung(admin): karte = _konto(admin) zeilen = _vorschau(admin, karte["id"], KARTE_CSV, "signed_inverted")["rows"] assert [r["amount_cents"] for r in zeilen] == [-6000, -2450, 18450, -1200] assert [r["type"] for r in zeilen] == ["expense", "expense", "income", "expense"] def test_signed_inverted_dreht_auch_die_saldospalte(admin): """Der Test, der den ersten Entwurf gerettet hat. Wer nur den Betrag dreht und die Saldo-Spalte stehen laesst, bekommt bei JEDER Zeile eine Abweichung: Der Abgleich rechnet „erwartet = Saldo[i] − Saldo[i−1]" und vergliche zwei entgegengesetzte Richtungen. Die einzige Kontrollinstanz des Imports schluege bei einem korrekten Auszug durchgehend Alarm – und waere damit wertlos. """ karte = _konto(admin) rec = _vorschau(admin, karte["id"], KARTE_CSV, "signed_inverted")["reconciliation"] assert rec["available"] is True assert rec["ok"] is True, rec assert rec["mismatches"] == 0 # Die Startschuld, aus der Datei zurueckgerechnet: -160,00 − (-60,00). assert rec["opening_balance_cents"] == -10000 assert rec["final_balance_cents"] == -1200 assert rec["computed_final_cents"] == -1200 def test_halb_gedrehte_datei_faellt_auf(admin): """Gegenprobe zum vorigen Test – von der anderen Seite. Eine Datei, deren Saldo-Spalte NICHT der Betragsspalte folgt, ist in sich widerspruechlich. Genau das soll der Abgleich melden. """ karte = _konto(admin) halb = ( "Datum;Betrag;Haendler;Saldo\n" "15.03.2026;60,00;Elektronikladen;-160,00\n" "16.03.2026;24,50;Tankstelle;-184,50\n" ) rec = _vorschau(admin, karte["id"], halb, "signed_inverted")["reconciliation"] assert rec["ok"] is False, rec def test_signed_liest_denselben_auszug_verkehrt_herum(admin): """Was ohne den neuen Modus herauskam – und warum der Abgleich es nicht merkt. Die Datei ist in BEIDEN Leserichtungen in sich stimmig: Betrag und Saldo stehen in derselben Konvention. Der Abgleich prueft nur diese Stimmigkeit, also meldet er auch hier „ok". Falsch ist trotzdem alles – die Einkaeufe werden zu Einnahmen, und die Karte startete angeblich mit Guthaben. Deshalb genuegt der Abgleich als Schutz nicht; der Modus muss von selbst richtig gewaehlt werden. """ karte = _konto(admin) res = _vorschau(admin, karte["id"], KARTE_CSV, "signed") assert [r["type"] for r in res["rows"]] == ["income", "income", "expense", "income"] assert res["reconciliation"]["ok"] is True assert res["reconciliation"]["opening_balance_cents"] == 10000 def test_abs_expense_taugt_nicht_fuer_karten(admin): """Der scheinbare Ausweg, den es schon gab. ``abs_expense`` macht jede Zeile zur Ausgabe – auch die Rechnungszahlung. Die Schuld waechst dann immer weiter, und der Abgleich schlaegt an. """ karte = _konto(admin) res = _vorschau(admin, karte["id"], KARTE_CSV, "abs_expense") assert [r["amount_cents"] for r in res["rows"]] == [-6000, -2450, -18450, -1200] assert all(r["type"] == "expense" for r in res["rows"]) assert res["reconciliation"]["ok"] is False def test_debit_credit_trennt_soll_und_haben(admin): """Getrennte Spalten – bisher ungetestet. Hier benennen die Spalten die Richtung selbst; eine Umkehr gibt es nicht und braucht es nicht. """ karte = _konto(admin, name="Soll Haben") csv = ( "Datum;Soll;Haben;Text\n" "15.03.2026;50,00;;Einkauf\n" "16.03.2026;;120,00;Gutschrift\n" "17.03.2026;-30,00;;Vorzeichen egal\n" ) colmap = json.dumps({"date": 0, "debit": 1, "credit": 2, "payee": 3}) zeilen = _vorschau(admin, karte["id"], csv, "debit_credit", colmap)["rows"] assert [r["amount_cents"] for r in zeilen] == [-5000, 12000, -3000] assert [r["type"] for r in zeilen] == ["expense", "income", "expense"] def test_gebuehr_wird_auch_bei_umgekehrtem_vorzeichen_abgezogen(admin): """Eine Gebuehr mindert den Betrag – in jeder Leserichtung. Sie geht ueber abs() ein und bleibt deshalb von der Umkehr unberuehrt. Wuerde sie mitgedreht, zoege sie die Ausgabe faelschlich zusammen statt sie zu erhoehen. """ karte = _konto(admin, name="Mit Gebuehr") csv = ( "Datum;Betrag;Haendler;Gebuehr;Saldo\n" "15.03.2026;60,00;Laden im Ausland;2,00;162,00\n" "16.03.2026;10,00;Zweiter Laden;0,00;172,00\n" ) colmap = json.dumps({"date": 0, "amount": 1, "payee": 2, "fee": 3, "balance": 4}) res = _vorschau(admin, karte["id"], csv, "signed_inverted", colmap) assert [r["amount_cents"] for r in res["rows"]] == [-6200, -1000] assert res["reconciliation"]["ok"] is True, res["reconciliation"] def test_gebuchte_karte_steht_im_minus_und_die_rechnung_holt_sie_zurueck(admin): """Vom Auszug bis zum Kontostand – der Weg, um den es geht.""" karte = _konto(admin, name="Visa Gebucht", credit_limit_cents=250000) zeilen = _vorschau(admin, karte["id"], KARTE_CSV, "signed_inverted")["rows"] r = admin.post("/api/imports/commit", json={ "account_id": karte["id"], "filename": "karte.csv", "rows": zeilen, }) assert r.status_code == 200, r.text # 3 Ausgaben (−97,50) und die Rechnungszahlung (+184,50) = +87,00 … aber # der Auszug beginnt mit einer Schuld, die Certain nicht kennt. Gebucht # wird nur, was in der Datei steht. saldo = admin.get(f"/api/accounts/{karte['id']}").json()["balance_cents"] assert saldo == 8800 # Und nach dem Nachtragen der Startschuld stimmt der Stand des Auszugs. admin.patch(f"/api/accounts/{karte['id']}", json={"opening_balance_cents": -10000}) saldo = admin.get(f"/api/accounts/{karte['id']}").json()["balance_cents"] assert saldo == -1200, "der Schlussstand der Datei" # --- Der Abgleich folgt der Buchungsart ------------------------------------ # # Direkt gegen die Funktion, nicht ueber HTTP: Die Vorschau rechnet den # Abgleich einmal serverseitig und liefert ihn mit. Eine von Hand geaenderte # Richtung erreicht ihn auf diesem Weg gar nicht – die Lage laesst sich ueber # die Schnittstelle also nicht herstellen. def test_abgleich_folgt_der_buchungsart(): """Wer eine Zeile umstellt, aendert, was im Konto ankommt. Gebucht wird abs(Betrag) plus Art (_make_transaction). Rechnete der Abgleich weiter mit dem Vorzeichen, prueft er eine Buchung, die so nie entsteht. """ from app.services.csv_import import reconcile zeilen = [ {"valid": True, "type": "expense", "amount_cents": -5000, "balance_cents": 5000}, # Dieselbe Zeile, vom Nutzer auf Einnahme gestellt: Das Vorzeichen des # Betrags blieb negativ, gebucht werden aber +30,00. {"valid": True, "type": "income", "amount_cents": -3000, "balance_cents": 8000}, ] rec = reconcile(zeilen) assert rec["ok"] is True, rec assert rec["opening_balance_cents"] == 10000 # 5000 − (−5000) def test_abgleich_bleibt_bei_ungetypten_zeilen_beim_vorzeichen(): """Ohne Art – und bei Uebertraegen – zaehlt weiter das Vorzeichen.""" from app.services.csv_import import reconcile zeilen = [ {"valid": True, "amount_cents": -5000, "balance_cents": 5000}, {"valid": True, "type": "transfer", "amount_cents": -3000, "balance_cents": 2000}, ] assert reconcile(zeilen)["ok"] is True # --- Erkennung: das Konto und die Leserichtung ------------------------------ def _erkennen(admin, csv_text): r = admin.post( "/api/imports/detect", files={"file": ("karte.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")}, data={"encoding": "utf-8"}, ) assert r.status_code == 200, r.text return r.json() # Mit eigener IBAN-Spalte, damit erkenne_konto das Konto sicher findet. MIT_IBAN = ( "Konto;Datum;Betrag;Haendler;Saldo\n" "CH9300762011623852957;15.03.2026;60,00;Elektronikladen;160,00\n" "CH9300762011623852957;16.03.2026;24,50;Tankstelle;184,50\n" "CH9300762011623852957;20.03.2026;-184,50;Rechnungsausgleich;0,00\n" ) def test_erkennung_schlaegt_bei_kreditkarte_die_umkehr_vor(admin): karte = _konto(admin, name="Visa Erkennung", identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}]) erkannt = _erkennen(admin, MIT_IBAN) assert erkannt["ok"] is True, erkannt assert erkannt["konto"]["account_id"] == karte["id"] assert erkannt["config"]["amount_mode"] == "signed_inverted" assert erkannt.get("kreditkarte") is True # Und es steht dabei, warum – eine stille Umkehr waere schlimmer als keine. assert "Kreditkarte" in erkannt["begruendung"] def test_erkennung_laesst_ein_girokonto_in_ruhe(admin): """Die Gegenprobe. Dieselbe Datei, anderer Kontotyp.""" giro = admin.post("/api/accounts", json={ "name": "Giro Erkennung", "type": "girokonto", "currency": "EUR", "identifiers": [{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}], }).json() erkannt = _erkennen(admin, MIT_IBAN) assert erkannt["konto"]["account_id"] == giro["id"] assert erkannt["config"]["amount_mode"] == "signed" assert "kreditkarte" not in erkannt def test_erkennung_raet_kein_konto(admin): """Ohne Anhaltspunkt bleibt die Wahl beim Nutzer – und die Umkehr aus. Ein Auszug im falschen Konto verfaelscht zwei Salden auf einmal. Lieber einmal fragen als einmal raten. """ _konto(admin, name="Visa Ohne Merkmal") erkannt = _erkennen(admin, KARTE_CSV) assert erkannt["konto"]["account_id"] is None assert erkannt["config"]["amount_mode"] == "signed" # --- Spaltenerkennung bei Kartenauszuegen ---------------------------------- def test_haendlerspalte_wird_als_empfaenger_erkannt(admin): """„Haendler" heisst die Spalte auf Kartenauszuegen – „Empfaenger" nie. Und die eigene Kontonummer, die viele Auszuege in jeder Zeile mitfuehren, darf den Empfaenger nicht gewinnen: Sie ist laenger als jeder Haendlername, und die Wahl lief bisher ueber die Textlaenge. Heraus kaeme eine Liste, in der bei jeder Buchung dasselbe steht. """ erkannt = _erkennen(admin, MIT_IBAN) spalten = erkannt["config"]["column_map"] assert spalten["payee"] == 3, erkannt["begruendung"] # „Haendler" assert spalten["amount"] == 2 assert spalten["balance"] == 4 def test_zweckspalte_bleibt_erhalten(admin): """Gegenprobe: Lange Verwendungszwecke sind keine Kontonummern. Ohne die Bedingung „in fast jeder Zeile steht dasselbe" haette die Aussortierung auch sie getroffen. """ csv_text = ( "Datum;Betrag;Empfaenger;Verwendungszweck\n" "15.03.2026;-50,00;REWE;Rechnung 4711 vom 01.02.2026\n" "16.03.2026;-20,00;Migros;Bestellung 998877 Lieferung Nord\n" "17.03.2026;-30,00;Coop;Abschlagszahlung 20260317 Vertrag 55\n" ) spalten = _erkennen(admin, csv_text)["config"]["column_map"] assert spalten["payee"] == 2 assert spalten["purpose"] == 3