diff --git a/backend/app/services/regeln.py b/backend/app/services/regeln.py index ef002c3..106b5a6 100644 --- a/backend/app/services/regeln.py +++ b/backend/app/services/regeln.py @@ -12,6 +12,7 @@ unangetastet und werden gemeldet. Geraten wird nichts. from __future__ import annotations +from sqlalchemy import select, union from sqlalchemy.orm import Session from ..models import ( @@ -21,6 +22,7 @@ from ..models import ( CategoryGroup, CategoryKind, Transaction, + TransferLink, TxnType, User, ) @@ -81,11 +83,21 @@ def wende_an(db: Session, user: User, nur_vorschau: bool = True) -> dict: .join(CategoryGroup, CategoryGroup.id == Category.group_id) .filter(Category.owner_id == user.id) } + # Verknuepfte Buchungen sind erledigt – genau wie in der Arbeitsliste. + # Ohne diesen Ausschluss passen sie weiter auf die Uebertrags-Regel, werden + # abgelehnt („schon verbunden“) und stehen bei JEDEM Lauf erneut unter + # „uebersprungen“. Dort verdecken sie die wirklich offenen Buchungen und + # fuellen obendrein das Limit. + verknuepft = union( + select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id), + select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id), + ) offen = ( db.query(Transaction) .filter(Transaction.owner_id == user.id, Transaction.category_id.is_(None), - Transaction.type != TxnType.transfer) + Transaction.type != TxnType.transfer, + Transaction.id.notin_(verknuepft)) .order_by(Transaction.date, Transaction.id) .limit(MAX_BUCHUNGEN) .all() diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py index b050640..e8536f6 100644 --- a/backend/tests/test_keywords.py +++ b/backend/tests/test_keywords.py @@ -419,3 +419,39 @@ def test_nachtraegliches_anwenden_ruehrt_kategorisiertes_nicht_an(admin): admin.post("/api/categories/keywords/anwenden", params={"vorschau": False}) assert admin.get(f"/api/transactions/{txn['id']}").json()["category_id"] == kat + + +def test_verknuepfte_buchungen_landen_nicht_in_uebersprungen(admin): + """Eine verknuepfte Buchung ist erledigt – beide Seiten stehen, beide zaehlen + in keiner Auswertung. Sie passt aber weiter auf die Uebertrags-Regel und + wurde deshalb bei JEDEM Lauf neu abgelehnt: Die Uebersprungen-Liste fuellte + sich mit Hunderten Zeilen, die nie verschwinden, und verdeckte die + wirklich offenen Buchungen.""" + quelle = _kto(admin, name=f"Revolut {secrets.token_hex(3)}") + pocket = _kto(admin, name=f"Sparen Pocket {secrets.token_hex(3)}") + kennung = f"um chf sparen {secrets.token_hex(6)}" + betrag = 60 + secrets.randbelow(900) + + raus = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-01-22", "account_id": quelle["id"], + "amount_cents": betrag, "payee": kennung.upper()}).json() + rein = admin.post("/api/transactions", json={ + "type": "income", "date": "2026-01-22", "account_id": pocket["id"], + "amount_cents": betrag, "payee": kennung.upper()}).json() + + r = admin.post("/api/categories/transfer-keywords", json={ + "word": kennung, "target_account_id": pocket["id"]}) + assert r.status_code == 201, r.text + + # Beide Seiten sind dieselbe Bewegung – verbinden, nicht uebertragen. + assert admin.post("/api/uebertraege/verbinden", + json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200 + + ergebnis = admin.post("/api/categories/keywords/anwenden", + params={"vorschau": False}).json() + texte = [z["text"] for z in ergebnis["uebersprungen"]] + assert kennung.upper() not in texte, "verknuepfte Buchung gehoert nicht in die Liste" + + # Und sie bleibt unangetastet: kein Uebertrag, kein verschobenes Geld. + assert admin.get(f"/api/transactions/{raus['id']}").json()["type"] == "expense" + assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == betrag