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