Eine Kreditkarte bekommt Geld nur aus einem Grund: weil ihre Rechnung bezahlt wird. Das genuegt als Hinweis – und es wird gebraucht, denn im Buchungstext steht bei so einer Zahlung oft weder ein Schlagwort noch der Name des Gegenkontos. Die Erkennung fand das Paar deshalb, liess es aber als Vorschlag liegen. Entscheidend ist die RICHTUNG, nicht dass eine Karte beteiligt ist. Die naheliegende Fassung "Kartenkonto auf einer Seite" haette einen Karteneinkauf ueber 50,00 mit einer zufaelligen Gutschrift ueber 50,00 am selben Tag verknuepft – eine echte Ausgabe UND eine echte Einnahme waeren still aus der Auswertung gefallen. Genau davor schuetzt die Huerde, die hier gelockert wird. Ein Test haelt die Gegenprobe fest. Die Schlagwortliste bleibt unberuehrt: "visa" und "mastercard" stehen in gewoehnlichen Buchungstexten jedes Kontos. Und der Import sagt jetzt, was er getan hat. Bisher lief die Suche nach dem Buchen still; wer wissen wollte, ob die Kartenrechnung erkannt wurde, musste auf "Zu erledigen" wechseln – und ahnte nicht, dass es da etwas zu sehen gab. Was nicht automatisch geht, erscheint als Vorschlag direkt auf der Import-Seite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
411 lines
16 KiB
Python
411 lines
16 KiB
Python
"""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
|
||
|
||
|
||
# --- 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
|