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:
@@ -6,7 +6,6 @@ import csv
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
@@ -15,13 +14,11 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
Account,
|
Account,
|
||||||
AccountIdentifier,
|
|
||||||
AmountMode,
|
AmountMode,
|
||||||
BudgetTreatment,
|
BudgetTreatment,
|
||||||
CategorizationRule,
|
CategorizationRule,
|
||||||
Category,
|
Category,
|
||||||
CategoryKind,
|
CategoryKind,
|
||||||
IdentifierKind,
|
|
||||||
ImportBatch,
|
ImportBatch,
|
||||||
ImportStatus,
|
ImportStatus,
|
||||||
ImportTemplate,
|
ImportTemplate,
|
||||||
@@ -252,33 +249,6 @@ def _match_rule(
|
|||||||
return None
|
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
|
# Status-Werte, die eine Buchung NICHT wirksam werden lassen. Bewusst als
|
||||||
# Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED"
|
# Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED"
|
||||||
# heißt auf Deutsch „ABGESCHLOSSEN". Unbekannte Werte werden gebucht, damit
|
# 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
|
# Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt
|
||||||
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
|
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
|
||||||
COUNTERPART_DAYS = 5
|
|
||||||
|
|
||||||
|
|
||||||
# Wie weit der tatsächlich gutgeschriebene Betrag vom geschätzten abweichen darf,
|
# 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
|
# 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
|
# mit dem EZB-Kurs, die Bank nutzt ihren eigenen – ein paar Prozent Unterschied
|
||||||
# sind normal, eine andere Buchung liegt praktisch nie so nah.
|
# 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]:
|
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]:
|
def detect_rows(db: Session, user: User, account: Account, candidates: list[dict]) -> list[dict]:
|
||||||
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
|
"""Reichert Kandidaten an: Typ, Auto-Kategorie, 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
|
|
||||||
|
|
||||||
|
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 = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
|
||||||
rules.sort(key=_rule_sort_key, reverse=True) # längstes/manuell zuerst
|
rules.sort(key=_rule_sort_key, reverse=True) # längstes/manuell zuerst
|
||||||
# None = neutral: zählt für Ausgabe UND Einnahme (siehe _match_rule).
|
# 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)
|
Transaction.owner_id == user.id, Transaction.import_hash.isnot(None)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Bestehende Transfers je Konto-Paar: erkennt die Gegenbuchung, wenn beide
|
# Neutrale Kategorie („Kontoübertrag"): Buchungen, die nur Geld zwischen
|
||||||
# Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt).
|
# eigenen Konten verschieben, werden damit kategorisiert statt zu einem
|
||||||
# Je Konto: welcher Betrag dort aus einem bestehenden Transfer erwartet wird.
|
# Übertrag verbunden. Sie verändern die Salden korrekt und bleiben aus der
|
||||||
side_index: dict[int, list[tuple]] = {}
|
# Statistik heraus.
|
||||||
for t_id, t_acc, t_to, t_date, t_amt, t_to_amt in (
|
neutrale_kategorie = next(
|
||||||
db.query(Transaction.id, Transaction.account_id, Transaction.to_account_id,
|
(cid for cid, k in cat_kind.items() if k is None), None
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
seen: Counter = Counter()
|
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
|
d is not None and amount is not None and amount != 0
|
||||||
and state_ok and currency_ok
|
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"
|
ttype = "expense" if (amount or 0) < 0 else "income"
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
matched_rule_id: int | None = None
|
matched_rule_id: int | None = None
|
||||||
to_account_id: int | None = None
|
to_account_id: int | None = None
|
||||||
|
|
||||||
other = idents.get(iban) if iban else None
|
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
|
||||||
if other is None:
|
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
|
||||||
# Symmetrisch: enthält der Text den Erkennungs-Text eines ANDEREN
|
if rule is not None:
|
||||||
# Kontos, ist es ein Transfer von/zu diesem Konto (auch Eingänge).
|
matched_rule_id = rule.id
|
||||||
other = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
|
# Frühere Übertrags-Schlagwörter kennzeichnen jetzt die neutrale
|
||||||
if other is not None and other != account.id:
|
# Kategorie, statt eine Verbindung zwischen Konten herzustellen.
|
||||||
ttype = "transfer"
|
category_id = rule.category_id or (neutrale_kategorie if rule.is_transfer else None)
|
||||||
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
|
|
||||||
|
|
||||||
h = import_hash(account.id, d, amount, payee)
|
h = import_hash(account.id, d, amount, payee)
|
||||||
is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
|
is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
|
||||||
seen[h] += 1
|
seen[h] += 1
|
||||||
|
|
||||||
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen –
|
is_counterpart = False
|
||||||
# der tatsächliche Betrag dieser Zeile korrigiert aber die Schätzung der
|
braucht_ziel = False
|
||||||
# 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
|
|
||||||
|
|
||||||
out.append({
|
out.append({
|
||||||
"index": i,
|
"index": i,
|
||||||
@@ -561,7 +442,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
"is_duplicate": is_dup,
|
"is_duplicate": is_dup,
|
||||||
"is_counterpart": is_counterpart,
|
"is_counterpart": is_counterpart,
|
||||||
# Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt.
|
# 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,
|
"include": valid and not is_dup and not is_counterpart and not braucht_ziel,
|
||||||
"needs_target": braucht_ziel,
|
"needs_target": braucht_ziel,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,9 +63,11 @@ def test_preview_parsing_and_detection(admin):
|
|||||||
# Zeile 2: gleich aussehend wie Zeile 0, aber im selben Auszug KEIN Duplikat.
|
# Zeile 2: gleich aussehend wie Zeile 0, aber im selben Auszug KEIN Duplikat.
|
||||||
assert rows[2]["is_duplicate"] is False
|
assert rows[2]["is_duplicate"] is False
|
||||||
assert rows[2]["include"] is True
|
assert rows[2]["include"] is True
|
||||||
# Zeile 3: interner Transfer (IBAN passt zu 'spar')
|
# Zeile 3: die IBAN gehört einem eigenen Konto – der Import verbindet
|
||||||
assert rows[3]["type"] == "transfer"
|
# trotzdem nichts mehr, es bleibt eine gewöhnliche Ausgabe.
|
||||||
assert rows[3]["to_account_id"] == spar["id"]
|
assert rows[3]["type"] == "expense"
|
||||||
|
assert rows[3]["to_account_id"] is None
|
||||||
|
assert spar
|
||||||
|
|
||||||
|
|
||||||
def test_commit_and_undo_and_learning(admin):
|
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)
|
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)
|
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",
|
admin.post("/api/categories/transfer-keywords",
|
||||||
json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
|
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)
|
rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH)
|
||||||
assert rows[0]["type"] == "transfer"
|
assert rows[0]["type"] == "expense"
|
||||||
assert rows[0]["to_account_id"] == eur["id"]
|
assert rows[0]["to_account_id"] is None
|
||||||
assert rows[0]["is_counterpart"] is False
|
|
||||||
admin.post("/api/imports/commit", json={"account_id": chf["id"], "filename": "chf.csv", "rows": rows})
|
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
|
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000
|
||||||
|
|
||||||
# 2) EUR-Auszug: OHNE weitere Regel – die Gegenseite wird über den bereits
|
# Der EUR-Auszug bringt den Zugang – als Einnahme, nicht als Gegenbuchung.
|
||||||
# gebuchten Transfer erkannt, obwohl der Betrag in EUR ein anderer ist.
|
|
||||||
rows2 = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
|
rows2 = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
|
||||||
assert rows2[0]["type"] == "transfer"
|
assert rows2[0]["type"] == "income"
|
||||||
assert rows2[0]["is_counterpart"] is True, "Umtausch-Gegenseite muss erkannt werden"
|
assert rows2[0]["is_counterpart"] is False
|
||||||
assert rows2[0]["include"] is False, "darf nicht doppelt zählen"
|
assert rows2[0]["include"] is True
|
||||||
|
|
||||||
r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "eur.csv", "rows": rows2})
|
r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "eur.csv", "rows": rows2})
|
||||||
assert r.json()["imported_count"] == 0
|
assert r.json()["imported_count"] == 1
|
||||||
|
|
||||||
# Der echte Eingang korrigiert die EZB-Schätzung: EUR-Konto steht auf 106.42.
|
|
||||||
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 10642
|
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
|
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000
|
||||||
|
|
||||||
|
|
||||||
def test_exchange_arrival_without_source_asks_instead_of_booking(admin):
|
def test_uebertrags_schlagwort_setzt_neutrale_kategorie(admin):
|
||||||
"""Wird der Empfangs-Auszug zuerst eingelesen, ist die Quelle noch unbekannt.
|
"""Ein früheres Übertrags-Schlagwort kennzeichnet die Zeile jetzt nur noch.
|
||||||
Die Zeile darf dann NICHT still als Einnahme durchgehen, sondern fragt das
|
|
||||||
Konto ab – sonst zählt die Bewegung später doppelt."""
|
Es verbindet keine Konten mehr, sondern hängt die neutrale Kategorie an –
|
||||||
chf = _account(admin, name="Rev CHF Y", currency="CHF", opening_balance_cents=50000)
|
die Buchung verändert den Saldo, bleibt aber aus der Statistik heraus.
|
||||||
eur = _account(admin, name="Rev EUR Y", currency="EUR")
|
"""
|
||||||
|
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",
|
admin.post("/api/categories/transfer-keywords",
|
||||||
json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
|
json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
|
||||||
|
|
||||||
rows = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
|
rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH)
|
||||||
assert rows[0]["type"] == "transfer"
|
assert rows[0]["type"] == "expense"
|
||||||
assert rows[0]["to_account_id"] is None
|
assert rows[0]["category_id"] == neutral["id"]
|
||||||
assert rows[0]["needs_target"] is True
|
assert rows[0]["needs_target"] is False
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_expense_is_not_swallowed_by_incoming_transfer(admin):
|
def test_expense_is_not_swallowed_by_incoming_transfer(admin):
|
||||||
"""Eine echte Ausgabe über denselben Betrag wie ein erwarteter Transfer-Eingang
|
"""Eine echte Ausgabe bleibt eine Ausgabe – auch wenn anderswo ein gleich
|
||||||
darf NICHT als Gegenbuchung gelten – sonst fehlt sie und der Saldo läuft zu hoch."""
|
hoher Übertrag steht. Früher wurde sie als dessen Gegenbuchung geschluckt."""
|
||||||
quelle = _account(admin, name="Quelle Z", currency="EUR", opening_balance_cents=100000)
|
quelle = _account(admin, name="Quelle Z", currency="EUR", opening_balance_cents=100000)
|
||||||
ziel = _account(admin, name="Ziel Z", currency="EUR", opening_balance_cents=10000)
|
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"]
|
).json()["rows"]
|
||||||
|
|
||||||
assert rows[0]["is_counterpart"] is False, "Ausgabe ist keine Gegenbuchung"
|
# Keine Zeile gilt mehr als Gegenbuchung – beide werden gebucht.
|
||||||
assert rows[0]["include"] is True
|
assert all(r["is_counterpart"] is False for r in rows)
|
||||||
assert rows[1]["is_counterpart"] is True, "der Eingang schon"
|
assert all(r["include"] is True for r in rows)
|
||||||
assert rows[1]["include"] is False
|
assert rows[0]["type"] == "expense"
|
||||||
|
assert rows[1]["type"] == "income"
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# --- Zeilenfilter: mehrere Pockets in einer Datei ---------------------------
|
# --- Zeilenfilter: mehrere Pockets in einer Datei ---------------------------
|
||||||
@@ -487,12 +484,16 @@ def test_fee_row_direction_from_balance(admin):
|
|||||||
assert rows["reconciliation"]["ok"] is True
|
assert rows["reconciliation"]["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_reconciliation_flags_rows_waiting_for_target(admin):
|
def test_keine_zeile_bleibt_wegen_fehlendem_gegenkonto_liegen(admin):
|
||||||
"""Eine Transfer-Regel ohne Gegenkonto lässt die Zeile ungebucht. Der
|
"""Jede Zeile des Auszugs wird gebucht – der Saldo-Abgleich geht auf.
|
||||||
Saldo-Abgleich muss das melden – sonst fehlt das Geld unbemerkt."""
|
|
||||||
|
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")
|
giro = _account(admin, name="Ziel offen", currency="CHF")
|
||||||
admin.post("/api/categories/transfer-keywords",
|
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 = (
|
csv_text = (
|
||||||
"Datum;Betrag;Empfaenger;Zweck;Saldo\n"
|
"Datum;Betrag;Empfaenger;Zweck;Saldo\n"
|
||||||
@@ -511,14 +512,13 @@ def test_reconciliation_flags_rows_waiting_for_target(admin):
|
|||||||
},
|
},
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
wartend = [r for r in res["rows"] if r.get("needs_target")]
|
assert not [r for r in res["rows"] if r.get("needs_target")]
|
||||||
assert len(wartend) == 1 and wartend[0]["amount_cents"] == 9200
|
uebertrag = res["rows"][1]
|
||||||
|
assert uebertrag["type"] == "income" and uebertrag["include"] is True
|
||||||
|
|
||||||
rec = res["reconciliation"]
|
rec = res["reconciliation"]
|
||||||
assert rec["ok"] is False, "die wartende Zeile muss auffallen"
|
assert rec["ok"] is True, "der Auszug muss ohne Rest aufgehen"
|
||||||
assert rec["difference_cents"] == 9200
|
assert rec["difference_cents"] == 0
|
||||||
assert rec["first_mismatch"]["expected_cents"] == 9200
|
|
||||||
assert rec["first_mismatch"]["actual_cents"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_undone_batch_from_history(admin):
|
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["PayPal Europe S.a.r.l."] == "expense"
|
||||||
assert typ["Europa-Park"] == "expense"
|
assert typ["Europa-Park"] == "expense"
|
||||||
assert typ["TWINT EUROBUS AG"] == "expense"
|
assert typ["TWINT EUROBUS AG"] == "expense"
|
||||||
# Als eigenständiges Wort greift der Erkennungs-Text weiterhin.
|
# Auch als eigenständiges Wort verbindet der Erkennungs-Text keine Konten mehr.
|
||||||
assert typ["Umbuchung"] == "transfer"
|
assert typ["Umbuchung"] == "expense"
|
||||||
|
|||||||
@@ -228,75 +228,61 @@ def test_amount_condition_rule(admin):
|
|||||||
assert rows[1]["category_id"] is None
|
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")
|
giro = _account(admin, name="Giro TR")
|
||||||
spar = _account(admin, name="Sparkonto TR", type="sparkonto",
|
spar = _account(admin, name="Sparkonto TR", type="sparkonto",
|
||||||
identifiers=[{"kind": "text", "value": "Sparen"}])
|
identifiers=[{"kind": "text", "value": "Sparen"}])
|
||||||
assert any(i["kind"] == "text" for i in spar["identifiers"])
|
admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"})
|
||||||
|
admin.post("/api/categories/transfer-keywords",
|
||||||
r = admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"})
|
json={"word": "revolut", "target_account_id": spar["id"]})
|
||||||
assert r.status_code == 201, r.text
|
|
||||||
|
|
||||||
csv = (
|
csv = (
|
||||||
"Datum;Betrag;Empfaenger;Zweck\n"
|
"Datum;Betrag;Empfaenger;Zweck\n"
|
||||||
"01.03.2026;-100,00;Max;Uebertrag Sparen\n" # -> Transfer, Ziel = Sparkonto
|
"01.03.2026;-100,00;Max;Uebertrag Sparen\n" # Schlagwort + Erkennungs-Text
|
||||||
"02.03.2026;-50,00;Max;Uebertrag woandershin\n" # -> Transfer, kein Ziel erkannt
|
"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)
|
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
|
||||||
assert rows[0]["type"] == "transfer"
|
assert [r["type"] for r in rows] == ["expense", "expense", "income"]
|
||||||
assert rows[0]["to_account_id"] == spar["id"]
|
assert all(r["to_account_id"] is None for r in rows)
|
||||||
assert rows[1]["type"] == "transfer"
|
assert all(r["needs_target"] is False for r in rows)
|
||||||
assert rows[1]["to_account_id"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_transfer_keyword_fixed_target(admin):
|
def test_beide_auszuege_werden_beide_gebucht(admin):
|
||||||
giro = _account(admin, name="Giro FT")
|
"""Lies man beide Seiten einer Bewegung ein, zählt jede auf ihrem Konto.
|
||||||
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"]
|
|
||||||
|
|
||||||
csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-25,00;Einkauf REVOLUT;Karte\n"
|
Früher galt die zweite Zeile als Gegenbuchung und blieb ungebucht – was
|
||||||
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
|
schiefging, sobald die Erkennung danebenlag. Jetzt bucht jedes Konto das,
|
||||||
assert rows[0]["type"] == "transfer"
|
was in seinem eigenen Auszug steht, und beide Salden stimmen.
|
||||||
assert rows[0]["to_account_id"] == ziel["id"]
|
"""
|
||||||
|
a = _account(admin, name="Konto A CP", opening_balance_cents=100000,
|
||||||
# Erneutes Anlegen desselben Worts aktualisiert das Zielkonto (kein Konflikt).
|
identifiers=[{"kind": "text", "value": "KontoA"}])
|
||||||
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"}])
|
|
||||||
b = _account(admin, name="Konto B CP", identifiers=[{"kind": "text", "value": "KontoB"}])
|
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"],
|
rowsA = _preview_csv(admin, a["id"],
|
||||||
"Datum;Betrag;Empfaenger;Zweck\n05.03.2026;-100,00;Uebertrag;an KontoB\n", COLMAP)
|
"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})
|
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"],
|
rowsB = _preview_csv(admin, b["id"],
|
||||||
"Datum;Betrag;Empfaenger;Zweck\n07.03.2026;100,00;Gutschrift;von KontoA\n", COLMAP)
|
"Datum;Betrag;Empfaenger;Zweck\n07.03.2026;100,00;Gutschrift;von KontoA\n", COLMAP)
|
||||||
assert rowsB[0]["type"] == "transfer"
|
assert rowsB[0]["type"] == "income"
|
||||||
assert rowsB[0]["is_counterpart"] is True
|
assert rowsB[0]["is_counterpart"] is False
|
||||||
assert rowsB[0]["include"] 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):
|
def test_same_word_across_kinds_allowed(admin):
|
||||||
|
|||||||
Reference in New Issue
Block a user