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")}

+ + )} +