feat: Transfer-Erkennung symmetrisch (Eingänge) + Gegenbuchungs-Erkennung
- Enthält der Buchungstext den Erkennungs-Text (Art „Text") eines ANDEREN Kontos, wird auch ein Eingang als Transfer VON diesem Konto gesetzt – nicht nur die Ausgabe. Richtung ergibt sich aus dem Vorzeichen. - Gegenbuchungs-Erkennung: existiert schon ein Transfer zwischen denselben Konten mit passendem Betrag (±5 Tage), wird die Gegenseite als „Gegenbuchung" markiert und nicht angehakt – so zählt dieselbe Bewegung beim Import beider Auszüge nicht doppelt (überschreibbar in der Prüfansicht). - Tests: Eingang übers Konto-Tag, Gegenbuchung übersprungen (78 grün) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -209,6 +209,22 @@ def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpos
|
|||||||
return best[1] if best else None
|
return best[1] if best else None
|
||||||
|
|
||||||
|
|
||||||
|
# Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt
|
||||||
|
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
|
||||||
|
COUNTERPART_DAYS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _is_counterpart(transfer_index: dict, account_id: int, other_id: int, d: date, amount: int) -> bool:
|
||||||
|
"""True, wenn schon ein Transfer zwischen denselben Konten existiert, dessen
|
||||||
|
Betrag (Soll- oder Haben-Seite) zu dieser Buchung passt – dann ist es die
|
||||||
|
bereits gebuchte Gegenseite und darf nicht erneut zählen."""
|
||||||
|
absamt = abs(amount)
|
||||||
|
for t_date, amt, to_amt in transfer_index.get(frozenset((account_id, other_id)), []):
|
||||||
|
if abs((t_date - d).days) <= COUNTERPART_DAYS and absamt in (amt, to_amt):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
|
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
|
||||||
"""Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen."""
|
"""Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen."""
|
||||||
cmap = json.loads(template.column_map or "{}")
|
cmap = json.loads(template.column_map or "{}")
|
||||||
@@ -272,6 +288,18 @@ 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
|
||||||
|
# Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt).
|
||||||
|
transfer_index: dict[frozenset, list[tuple]] = {}
|
||||||
|
for t_acc, t_to, t_date, t_amt, t_to_amt in (
|
||||||
|
db.query(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))
|
||||||
|
):
|
||||||
|
transfer_index.setdefault(frozenset((t_acc, t_to)), []).append(
|
||||||
|
(t_date, t_amt, t_to_amt if t_to_amt is not None else t_amt)
|
||||||
|
)
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
@@ -290,6 +318,10 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
to_account_id: int | None = None
|
to_account_id: int | None = None
|
||||||
|
|
||||||
other = idents.get(iban) if iban else 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:
|
if other is not None and other != account.id:
|
||||||
ttype = "transfer"
|
ttype = "transfer"
|
||||||
to_account_id = other
|
to_account_id = other
|
||||||
@@ -312,6 +344,13 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
is_dup = h in existing or h in seen
|
is_dup = h in existing or h in seen
|
||||||
seen.add(h)
|
seen.add(h)
|
||||||
|
|
||||||
|
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen.
|
||||||
|
is_counterpart = (
|
||||||
|
ttype == "transfer" and to_account_id is not None
|
||||||
|
and d is not None and amount is not None
|
||||||
|
and _is_counterpart(transfer_index, account.id, to_account_id, d, amount)
|
||||||
|
)
|
||||||
|
|
||||||
out.append({
|
out.append({
|
||||||
"index": i,
|
"index": i,
|
||||||
"date": d.isoformat() if d else None,
|
"date": d.isoformat() if d else None,
|
||||||
@@ -326,7 +365,8 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
"to_account_id": to_account_id,
|
"to_account_id": to_account_id,
|
||||||
"import_hash": h,
|
"import_hash": h,
|
||||||
"is_duplicate": is_dup,
|
"is_duplicate": is_dup,
|
||||||
"include": valid and not is_dup,
|
"is_counterpart": is_counterpart,
|
||||||
|
"include": valid and not is_dup and not is_counterpart,
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,35 @@ def test_transfer_keyword_fixed_target(admin):
|
|||||||
assert r2.json()["target_account_id"] == giro["id"]
|
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"}])
|
||||||
|
# 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"]
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
def test_same_word_across_kinds_allowed(admin):
|
def test_same_word_across_kinds_allowed(admin):
|
||||||
a, b = _expense_cats(admin, 2)
|
a, b = _expense_cats(admin, 2)
|
||||||
inc = _income_cat(admin)
|
inc = _income_cat(admin)
|
||||||
|
|||||||
@@ -316,6 +316,7 @@
|
|||||||
"payeeCol": "Empfänger / Zweck",
|
"payeeCol": "Empfänger / Zweck",
|
||||||
"badDate": "Datum?",
|
"badDate": "Datum?",
|
||||||
"duplicate": "Duplikat",
|
"duplicate": "Duplikat",
|
||||||
|
"counterpart": "Gegenbuchung",
|
||||||
"chooseAccount": "Konto wählen",
|
"chooseAccount": "Konto wählen",
|
||||||
"history": "Import-Verlauf",
|
"history": "Import-Verlauf",
|
||||||
"undo": "Rückgängig",
|
"undo": "Rückgängig",
|
||||||
|
|||||||
@@ -316,6 +316,7 @@
|
|||||||
"payeeCol": "Payee / purpose",
|
"payeeCol": "Payee / purpose",
|
||||||
"badDate": "Date?",
|
"badDate": "Date?",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
|
"counterpart": "Counterpart",
|
||||||
"chooseAccount": "Choose account",
|
"chooseAccount": "Choose account",
|
||||||
"history": "Import history",
|
"history": "Import history",
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export default function Import() {
|
|||||||
<span className="ellipsis" style={{ display: "block" }}>
|
<span className="ellipsis" style={{ display: "block" }}>
|
||||||
{r.payee || r.purpose || ""}
|
{r.payee || r.purpose || ""}
|
||||||
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
|
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
|
||||||
|
{r.is_counterpart && <span className="badge" style={{ marginLeft: 6 }}>{t("import.counterpart")}</span>}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -526,7 +527,7 @@ export default function Import() {
|
|||||||
id="import-review"
|
id="import-review"
|
||||||
rows={rows}
|
rows={rows}
|
||||||
getRowKey={(r) => r.index}
|
getRowKey={(r) => r.index}
|
||||||
rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate ? "row-dup" : "")}
|
rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate || r.is_counterpart ? "row-dup" : "")}
|
||||||
columns={reviewColumns}
|
columns={reviewColumns}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user