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. */}
{t("accounts.lockedHint")}
+ > + )} +