From 51af9eacffdf02955054934e57999f6d3e5f2692 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 17:01:40 +0200 Subject: [PATCH] fix: Konto loeschen veraendert keine fremden Salden mehr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beide Konto-Spalten einer Buchung hingen an ON DELETE CASCADE. Wurde ein Zielkonto geloescht, verschwand die ganze Buchung – auch die Abgangsseite, die einem anderen Konto gehoert. Dessen Saldo stieg dadurch stillschweigend um den Uebertrag; beim Loeschen von 'Revolut investments' waren es 1'020.99. Vor dem Loeschen werden solche Uebertraege jetzt zu gewoehnlicher Ausgabe bzw. Einnahme auf dem verbleibenden Konto. Nur was allein am geloeschten Konto haengt, geht mit ihm. Co-Authored-By: Claude Opus 5 --- backend/app/routers/accounts.py | 49 +++++++++++++++++++++++++++++++-- backend/tests/test_accounts.py | 38 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index e057cc8..0bc5a4a 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -7,9 +7,9 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned -from ..models import Account, AccountIdentifier, IdentifierKind, User +from ..models import Account, AccountIdentifier, IdentifierKind, Transaction, TxnType, User from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate -from ..services import balances +from ..services import balances, currency from ..services.csv_import import normalize_text from .settings import BASE_CURRENCY_KEY, get_user_setting @@ -146,10 +146,55 @@ def update_account( return to_out(db, acc, base_currency(db, user), date.today()) +def _entkoppele_uebertraege(db: Session, acc: Account) -> None: + """Überträge lösen, bevor das Konto verschwindet. + + Beide Konto-Spalten einer Buchung hängen an ON DELETE CASCADE. Beim Löschen + eines Kontos verschwand deshalb auch die Gegenseite eines Übertrags – und + damit Geld, das ein ANDERES Konto verlassen hat. Dessen Saldo stieg + stillschweigend. Solche Überträge werden hier zu gewöhnlichen Ausgaben bzw. + Einnahmen auf dem verbleibenden Konto; nur was allein am gelöschten Konto + hängt, geht mit ihm. + """ + # Gelöschtes Konto ist das Ziel: Der Abgang bleibt Ausgabe beim Quellkonto. + for t in ( + db.query(Transaction) + .filter(Transaction.to_account_id == acc.id, Transaction.account_id != acc.id) + .all() + ): + t.type = TxnType.expense + t.to_account_id = None + t.to_amount_cents = None + + # Gelöschtes Konto ist die Quelle: Der Zugang bleibt Einnahme beim Zielkonto. + for t in ( + db.query(Transaction) + .filter( + Transaction.account_id == acc.id, + Transaction.to_account_id.isnot(None), + Transaction.to_account_id != acc.id, + ) + .all() + ): + ziel = db.get(Account, t.to_account_id) + betrag = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents + t.type = TxnType.income + t.account_id = t.to_account_id + t.amount_cents = betrag + t.to_account_id = None + t.to_amount_cents = None + if ziel is not None: + t.amount_eur_cents = currency.compute_amount_eur_cents( + db, betrag, ziel.currency, t.date + ) + db.flush() + + @router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_account( account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): acc = get_owned(db, Account, account_id, user) + _entkoppele_uebertraege(db, acc) db.delete(acc) db.commit() diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py index 82044ba..4b6ec8f 100644 --- a/backend/tests/test_accounts.py +++ b/backend/tests/test_accounts.py @@ -162,3 +162,41 @@ def test_ohne_stichtag_zaehlt_alles(admin): admin.post("/api/transactions", json={"type": "expense", "date": "2020-01-01", "account_id": acc["id"], "amount_cents": 2500}) assert admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] == 7500 + + +def test_konto_loeschen_laesst_fremden_saldo_unberuehrt(admin): + """Ein gelöschtes Konto darf den Saldo eines anderen nicht verändern. + + ``to_account_id`` hängt an ON DELETE CASCADE. Wurde das Zielkonto gelöscht, + verschwand die ganze Buchung – auch die Abgangsseite, die dem Quellkonto + gehört. Dessen Saldo stieg dadurch stillschweigend um den Übertrag. + """ + quelle = _mk_account(admin, name="Quelle", currency="CHF", opening_balance_cents=100000) + ziel = _mk_account(admin, name="Depot", currency="CHF") + r = admin.post("/api/transactions", json={ + "type": "transfer", "date": "2026-03-01", "account_id": quelle["id"], + "to_account_id": ziel["id"], "amount_cents": 30000, + }) + assert r.status_code == 201, r.text + assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000 + + assert admin.delete(f"/api/accounts/{ziel['id']}").status_code == 204 + # Das Geld ist abgeflossen und bleibt abgeflossen. + assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000 + verbleibend = [t for t in admin.get("/api/transactions").json() + if t["account_id"] == quelle["id"] and t["date"] == "2026-03-01"] + assert len(verbleibend) == 1 + assert verbleibend[0]["type"] == "expense" + + +def test_konto_loeschen_erhaelt_zugang_beim_zielkonto(admin): + """Umgekehrt: War das gelöschte Konto die Quelle, bleibt der Zugang beim Ziel.""" + quelle = _mk_account(admin, name="Weg", currency="CHF", opening_balance_cents=50000) + ziel = _mk_account(admin, name="Bleibt", currency="CHF") + admin.post("/api/transactions", json={ + "type": "transfer", "date": "2026-03-02", "account_id": quelle["id"], + "to_account_id": ziel["id"], "amount_cents": 20000, + }) + assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000 + assert admin.delete(f"/api/accounts/{quelle['id']}").status_code == 204 + assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000