From 76dbd3b481f756b62b31244447fa2cf762c1fcab Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 1 Oct 2026 20:29:34 +0200 Subject: [PATCH] feat: Geld, an das man nicht herankommt, zaehlt nicht mehr als verfuegbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Saeule 3a ist gesperrt – bis zur Rente, zur Selbstaendigkeit oder zur Auswanderung. Bisher zaehlte ihr Stand in dieselbe Summe wie das Girokonto, und die Antwort auf "wie viel habe ich" war um genau diesen Betrag zu hoch. Das Geld steht da und ist doch nicht da. Konten lassen sich jetzt als gebunden kennzeichnen. "Gesamt" zeigt dann nur noch, was wirklich ausgegeben werden kann; darunter steht "zusaetzlich CHF 2'004.61 gebunden" – aufklappbar, mit jedem Konto einzeln. Eine Summe, die man nicht nachrechnen kann, muss man glauben. Verschwiegen wird nichts: Der Saldo bleibt am Konto stehen, und Vermoegensverlauf wie Nettovermoegen rechnen weiter damit. Es nicht ausgeben zu koennen heisst nicht, es nicht zu besitzen – das ist der ganze Unterschied, und ein Test haelt ihn fest. Ein eigenes Kennzeichen, keine Ableitung aus dem Kontotyp: Ein frei verkaeufliches Depot und eine gesperrte Saeule 3a kommen beide als "depot" herein. Am Typ laesst sich das nicht ablesen – am ausdruecklichen Eintrag schon. Co-Authored-By: Claude Opus 5 --- backend/app/models.py | 14 +++ backend/app/routers/accounts.py | 2 + backend/app/routers/dashboard.py | 14 ++- backend/app/schemas.py | 4 + .../versions/0036_gebundenes_geld.py | 36 ++++++ backend/tests/test_gebundenes_geld.py | 113 ++++++++++++++++++ web/src/dashboard/cards.jsx | 8 ++ web/src/finance.css | 19 +++ web/src/locales/de.json | 6 + web/src/locales/en.json | 6 + web/src/pages/Accounts.jsx | 55 ++++++++- 11 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/versions/0036_gebundenes_geld.py create mode 100644 backend/tests/test_gebundenes_geld.py diff --git a/backend/app/models.py b/backend/app/models.py index 3723201..63fc3df 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -354,6 +354,20 @@ class Account(Base): savings_category_id: Mapped[int | None] = mapped_column( ForeignKey("categories.id", ondelete="SET NULL"), nullable=True ) + # Geld, an das man nicht herankommt. + # + # Eine Saeule 3a ist gesperrt bis zur Rente, zur Selbstaendigkeit oder zur + # Auswanderung. Es als Teil dessen zu zaehlen, was man ausgeben kann, ist + # schlicht falsch – der Betrag steht da und ist doch nicht da. + # + # Es bleibt Vermoegen: Der Verlauf und das Nettovermoegen rechnen es weiter + # mit. Nur die Summe, die beantwortet „wie viel habe ich zur Verfuegung", + # laesst es aussen vor und weist es daneben aus. + # + # Deshalb ein eigenes Kennzeichen und keine Ableitung aus dem Kontotyp: Ein + # Depot ist frei verkaeuflich, eine Saeule 3a nicht, und beide kommen als + # „depot" herein. Am Typ laesst sich das nicht ablesen. + is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index 7d87b7c..8b05fe9 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -68,6 +68,7 @@ def to_out(db: Session, acc: Account, base: str, on: date, opening_balance_cents=acc.opening_balance_cents, opening_date=acc.opening_date, credit_limit_cents=acc.credit_limit_cents, + is_locked=acc.is_locked, is_archived=acc.is_archived, position=acc.position, savings_category_id=acc.savings_category_id, @@ -164,6 +165,7 @@ def create_account( opening_balance_cents=payload.opening_balance_cents, opening_date=payload.opening_date, credit_limit_cents=payload.credit_limit_cents, + is_locked=payload.is_locked, savings_category_id=payload.savings_category_id, position=(max_pos[0] + 1) if max_pos else 0, ) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index b5e5652..8dbf81b 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -305,16 +305,24 @@ def dashboard_balances(db: Session = Depends(get_db), user: User = Depends(get_c ) rows = [] total = 0 + gebunden = 0 for a in accounts: bb = balances.account_balance_base_cents(db, a, base, today) rows.append({ "id": a.id, "name": a.name, "icon": a.icon, "color": a.color, "currency": a.currency, "balance_cents": balances.account_balance_cents(db, a), - "balance_base_cents": bb, "archived": a.is_archived, + "balance_base_cents": bb, "archived": a.is_archived, "locked": a.is_locked, }) - if not a.is_archived: + if a.is_archived: + continue + # Gesperrtes Geld steht neben der Summe, nicht darin: Die Frage, die + # diese Zahl beantwortet, ist „wie viel habe ich zur Verfuegung". + # Verschwinden darf es trotzdem nicht – es gehoert dem Nutzer. + if a.is_locked: + gebunden += bb + else: total += bb - return {"accounts": rows, "total": total, "currency": base} + return {"accounts": rows, "total": total, "gebunden": gebunden, "currency": base} @router.get("/net-worth") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 8959737..3d18fb6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -164,6 +164,8 @@ class AccountCreate(BaseModel): opening_date: date_t | None = None # Was die Karte ausgeben darf – kein Guthaben. Nur bei Kreditkarten sinnvoll. credit_limit_cents: int | None = Field(default=None, ge=0) + # Gesperrtes Geld (z. B. Saeule 3a): zaehlt nicht als verfuegbar. + is_locked: bool = False # Ueberweisungen hierher zaehlen als Sparen gegen diese Ausgabe-Kategorie. savings_category_id: int | None = None identifiers: list[AccountIdentifierIn] = Field(default_factory=list) @@ -189,6 +191,7 @@ class AccountUpdate(BaseModel): opening_balance_cents: int | None = None opening_date: date_t | None = None credit_limit_cents: int | None = Field(default=None, ge=0) + is_locked: bool | None = None is_archived: bool | None = None position: int | None = None savings_category_id: int | None = None @@ -211,6 +214,7 @@ class AccountOut(BaseModel): opening_balance_cents: int opening_date: date_t | None credit_limit_cents: int | None = None + is_locked: bool = False is_archived: bool position: int savings_category_id: int | None = None diff --git a/backend/migrations/versions/0036_gebundenes_geld.py b/backend/migrations/versions/0036_gebundenes_geld.py new file mode 100644 index 0000000..c7d5236 --- /dev/null +++ b/backend/migrations/versions/0036_gebundenes_geld.py @@ -0,0 +1,36 @@ +"""Geld, an das man nicht herankommt. + +Eine Saeule 3a ist gesperrt – bis zur Rente, zur Selbstaendigkeit oder zur +Auswanderung. Bisher zaehlte ihr Stand in dieselbe Summe wie das Girokonto, und +die Antwort auf „wie viel habe ich" war um genau diesen Betrag zu hoch. + +Das Konto behaelt seinen Saldo und bleibt Teil des Vermoegens. Nur die Summe, +die sagt, was ausgegeben werden kann, laesst es aussen vor und weist es +daneben aus. + +Kein Ableiten aus dem Kontotyp: Ein frei verkaeufliches Depot und eine +gesperrte Saeule 3a kommen beide als „depot" herein. + +Revision ID: 0036 +Revises: 0035 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0036" +down_revision = "0035" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Ohne batch_alter_table, wie in 0034/0035: Eine Spalte anhaengen kann + # SQLite direkt, der Tabellen-Umbau risse ueber ON DELETE CASCADE haengende + # Zeilen mit (tests/test_migrations.py). + op.execute(sa.text( + "ALTER TABLE accounts ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0")) + + +def downgrade() -> None: + op.execute(sa.text("ALTER TABLE accounts DROP COLUMN is_locked")) diff --git a/backend/tests/test_gebundenes_geld.py b/backend/tests/test_gebundenes_geld.py new file mode 100644 index 0000000..edbc54f --- /dev/null +++ b/backend/tests/test_gebundenes_geld.py @@ -0,0 +1,113 @@ +"""Geld, an das man nicht herankommt. + +Eine Saeule 3a ist gesperrt bis zur Rente, zur Selbstaendigkeit oder zur +Auswanderung. Sie in dieselbe Summe zu werfen wie das Girokonto beantwortet +eine andere Frage, als der Nutzer stellt. + +Der Unterschied, um den es geht: VERMOEGEN bleibt es – VERFUEGBAR ist es nicht. +""" + +import json +from datetime import date + + +def _konto(admin, name, **kw): + body = {"name": name, "type": "girokonto", "currency": "EUR", + "opening_balance_cents": 0} + body.update(kw) + r = admin.post("/api/accounts", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def test_gebunden_wird_gespeichert_und_laesst_sich_loesen(admin): + konto = _konto(admin, "Saeule 3a", type="depot", is_locked=True, + opening_balance_cents=200461) + assert konto["is_locked"] is True + + r = admin.patch(f"/api/accounts/{konto['id']}", json={"is_locked": False}) + assert r.json()["is_locked"] is False, "ausgewandert – jetzt kommt man dran" + + r = admin.patch(f"/api/accounts/{konto['id']}", json={"name": "Saeule 3a ZKB"}) + assert r.json()["is_locked"] is False, "ein Namenswechsel aendert nichts daran" + + +def test_neue_konten_sind_nicht_gebunden(admin): + assert _konto(admin, "Ganz normal")["is_locked"] is False + + +def test_gebundenes_geld_faellt_aus_der_verfuegbaren_summe(admin): + """Der Punkt der ganzen Sache.""" + vorher = admin.get("/api/dashboard/balances").json() + + _konto(admin, "Giro Verfuegbar", opening_balance_cents=300000) + _konto(admin, "Saeule 3a Gesperrt", type="depot", is_locked=True, + opening_balance_cents=200461) + + jetzt = admin.get("/api/dashboard/balances").json() + assert jetzt["total"] == vorher["total"] + 300000, "nur das Freie zaehlt" + assert jetzt["gebunden"] == vorher.get("gebunden", 0) + 200461 + + gesperrt = [a for a in jetzt["accounts"] if a["name"] == "Saeule 3a Gesperrt"][0] + assert gesperrt["locked"] is True + assert gesperrt["balance_base_cents"] == 200461, "der Saldo bleibt, was er ist" + + +def test_gebundenes_geld_bleibt_vermoegen(admin): + """Der Unterschied, auf den es ankommt. + + Es nicht ausgeben zu koennen heisst nicht, es nicht zu besitzen. Der + Vermoegensverlauf und das Nettovermoegen rechnen es weiter mit – nur die + Summe, die „wie viel habe ich zur Verfuegung" beantwortet, laesst es weg. + """ + from app.database import SessionLocal + from app.models import Account, User + from app.services import balances + + def vermoegen() -> int: + db = SessionLocal() + try: + user = db.query(User).filter(User.username == "admin").one() + konten = db.query(Account).filter(Account.owner_id == user.id).all() + return balances.net_worth_cents(db, konten, "EUR", date.today()) + finally: + db.close() + + vorher = vermoegen() + _konto(admin, "Saeule 3a Vermoegen", type="depot", is_locked=True, + opening_balance_cents=200461) + assert vermoegen() == vorher + 200461 + + +def test_archiviertes_gebundenes_konto_zaehlt_nirgends(admin): + konto = _konto(admin, "Alte Saeule", type="depot", is_locked=True, + opening_balance_cents=50000) + vorher = admin.get("/api/dashboard/balances").json() + + admin.patch(f"/api/accounts/{konto['id']}", json={"is_archived": True}) + + jetzt = admin.get("/api/dashboard/balances").json() + assert jetzt["total"] == vorher["total"] + assert jetzt["gebunden"] == vorher["gebunden"] - 50000 + + +def test_gebunden_ueberlebt_das_backup(admin): + konto = _konto(admin, "Saeule 3a Backup", type="depot", is_locked=True, + opening_balance_cents=123456) + + daten = json.loads(admin.get("/api/export/backup.json").content) + gesichert = [a for a in daten["data"]["accounts"] if a["name"] == "Saeule 3a Backup"] + assert gesichert and gesichert[0]["is_locked"] in (True, 1) + + admin.delete(f"/api/accounts/{konto['id']}") + r = admin.post( + "/api/backup/restore", + files={"file": ("backup.json", json.dumps(daten).encode("utf-8"), + "application/json")}, + data={"mode": "replace"}, + ) + assert r.status_code == 200, r.text + + zurueck = [a for a in admin.get("/api/accounts").json() + if a["name"] == "Saeule 3a Backup"] + assert zurueck and zurueck[0]["is_locked"] is True diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 9280c31..c3684bf 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -53,6 +53,14 @@ function AccountBalances() { formatMoney allein faerbt nicht – das Minus stuende still da. */} + {/* Gesperrtes Geld steht daneben, nicht darin – sonst beantwortet die + Summe eine andere Frage, als der Nutzer stellt. */} + {data.gebunden > 0 && ( +
+ {t("cards.locked")} + {formatMoney(data.gebunden, data.currency)} +
+ )} ); } diff --git a/web/src/finance.css b/web/src/finance.css index cc20e60..e75384a 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1189,6 +1189,25 @@ tr.row-dup { } .rahmen-balken.ueber .rahmen-fuellung { background: var(--warn); } +/* ---------- Gebundenes Geld ---------- + Steht unter „Gesamt", nicht darin. Aufklappbar, weil eine Summe, die man + nicht nachrechnen kann, man glauben muss. */ +.gebunden-zeile { + padding: 0 var(--sp-4) var(--sp-2); + margin-top: -6px; +} +.gebunden-zeile summary { + cursor: pointer; + padding: 4px 0; +} +.gebunden-posten { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 3px 0 3px var(--sp-3); + font-size: 0.9em; +} + /* ---------- Übertrags-Vorschläge ---------- */ .uebertrag-vorschlag { padding: var(--sp-3) 0; diff --git a/web/src/locales/de.json b/web/src/locales/de.json index cba634f..2e3b8e7 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -137,6 +137,7 @@ "incomeExpense": "Einnahmen & Ausgaben", "recent": "Letzte Buchungen", "total": "Gesamt", + "locked": "zusätzlich gebunden", "planned": "geplant", "spent": "ausgegeben", "assets": "Vermögen", @@ -185,6 +186,11 @@ "creditLimitHint": "Was die Karte ausgeben darf – kein Guthaben. Der Kontostand gehört nicht hierher: Eine abbezahlte Karte steht auf 0, jede Ausgabe drückt sie ins Minus, die Rechnung holt sie zurück.", "creditLimitLeft": "noch {{left}} von {{limit}} frei", "creditLimitExceeded": "{{over}} über dem Rahmen von {{limit}}", + "locked": "Geld ist gebunden – nicht verfügbar", + "lockedHint": "Für Konten, an die man nicht herankommt (z. B. Säule 3a: gesperrt bis zur Rente, Selbstständigkeit oder Auswanderung). Der Betrag zählt weiter zum Vermögen, aber nicht zu „Gesamt“ – denn ausgeben lässt er sich nicht.", + "lockedBadge": "gebunden", + "lockedTotal_one": "zusätzlich {{value}} gebunden ({{count}} Konto)", + "lockedTotal_other": "zusätzlich {{value}} gebunden ({{count}} Konten)", "savingsCategory": "Sparen gegen Kategorie", "savingsCategoryNone": "Kein Sparziel", "savingsCategoryHint": "Überweisungen auf dieses Konto zählen im Budget als Sparen gegen diese Kategorie – auch dann, wenn sie über die Bank als zwei verknüpfte Buchungen hereinkommen. Einzelne Buchungen lassen sich davon ausnehmen.", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index c25fd5b..3e6660c 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -137,6 +137,7 @@ "incomeExpense": "Income & expenses", "recent": "Recent transactions", "total": "Total", + "locked": "plus locked", "planned": "planned", "spent": "spent", "assets": "Net worth", @@ -185,6 +186,11 @@ "creditLimitHint": "What the card may spend — not money you have. The balance does not belong here: a paid-off card sits at 0, every purchase pushes it negative, and the bill brings it back.", "creditLimitLeft": "{{left}} of {{limit}} left", "creditLimitExceeded": "{{over}} over the {{limit}} limit", + "locked": "Money is locked — not available", + "lockedHint": "For accounts you cannot access (e.g. a Swiss pillar 3a: locked until retirement, self-employment or emigration). The amount still counts towards your wealth, but not towards “Total” — because you cannot spend it.", + "lockedBadge": "locked", + "lockedTotal_one": "plus {{value}} locked ({{count}} account)", + "lockedTotal_other": "plus {{value}} locked ({{count}} accounts)", "savingsCategory": "Count savings towards", "savingsCategoryNone": "Not a savings account", "savingsCategoryHint": "Transfers into this account count as savings against this category in the budget – including those that arrive from the bank as two linked transactions. Individual transactions can be excluded.", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index 29b3761..92cf1e5 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -34,6 +34,7 @@ function emptyForm() { opening_balance: "", opening_date: "", credit_limit: "", + is_locked: false, savings_category_id: "", identifiers: [], }; @@ -260,6 +261,7 @@ export default function Accounts() { opening_date: acc.opening_date || "", credit_limit: acc.credit_limit_cents != null ? centsToInput(acc.credit_limit_cents) : "", + is_locked: !!acc.is_locked, savings_category_id: acc.savings_category_id ? String(acc.savings_category_id) : "", identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })), }); @@ -289,6 +291,8 @@ export default function Accounts() { // Girokonto macht, soll den Rahmen nicht unsichtbar mitschleppen. credit_limit_cents: form.type === "kreditkarte" ? (parseAmountToCents(form.credit_limit) ?? null) : null, + // Eine Kreditkarte hat kein Guthaben, das gebunden sein koennte. + is_locked: form.type === "kreditkarte" ? false : !!form.is_locked, savings_category_id: form.savings_category_id ? Number(form.savings_category_id) : null, identifiers: form.identifiers .filter((i) => i.value.trim()) @@ -336,9 +340,17 @@ export default function Accounts() { // Archivierte bleiben draussen – das Backend rechnet ebenso // (balances.net_worth_cents, dashboard.py). Zwei Zahlen fuer dieselbe Sache // sind eine Zahl zu viel. - const total = accounts - .filter((a) => !a.is_archived) + // + // Gesperrtes Geld ebenfalls: „Gesamt" beantwortet die Frage „wie viel habe + // ich zur Verfuegung", und eine Saeule 3a gehoert darauf nicht. Es wird + // daneben ausgewiesen, nicht verschwiegen – es gehoert dem Nutzer, nur eben + // noch nicht. + const aktiv = accounts.filter((a) => !a.is_archived); + const total = aktiv + .filter((a) => !a.is_locked) .reduce((s, a) => s + a.balance_base_cents, 0); + const gebundene = aktiv.filter((a) => a.is_locked); + const gebunden = gebundene.reduce((s, a) => s + a.balance_base_cents, 0); return (
@@ -357,6 +369,25 @@ export default function Accounts() { {t("accounts.total")}
+ {/* Aufklappbar, weil eine Summe, die man nicht nachrechnen kann, man + glauben muss. Darunter steht jedes gebundene Konto einzeln. */} + {gebundene.length > 0 && ( +
+ + {t("accounts.lockedTotal", { + value: formatMoney(gebunden, baseCurrency), + count: gebundene.length, + })} + + {gebundene.map((a) => ( +
+ {a.name} + +
+ ))} +
+ )}
{accounts.map((a, i) => (
0 && ` · ${t("accounts.identShort", { count: a.identifiers.length })}`} + {a.is_locked && ( + + {t("accounts.lockedBadge")} + + )}
{/* Bei angebundenen Konten der Saldo, den die Bank fuehrt. @@ -515,6 +552,20 @@ export default function Accounts() { )} + {/* Nicht aus dem Kontotyp abgeleitet: Ein frei verkaeufliches Depot + und eine gesperrte Saeule 3a kommen beide als „depot" herein. + Bei einer Kreditkarte gibt es nichts zu binden. */} + {form.type !== "kreditkarte" && ( + <> + +

{t("accounts.lockedHint")}

+ + )} +