feat: Geld, an das man nicht herankommt, zaehlt nicht mehr als verfuegbar

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-01 20:29:34 +02:00
co-authored by Claude Opus 5
parent 35c5d2c84a
commit 76dbd3b481
11 changed files with 272 additions and 5 deletions
+14
View File
@@ -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)
+2
View File
@@ -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,
)
+11 -3
View File
@@ -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")
+4
View File
@@ -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
@@ -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"))
+113
View File
@@ -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
+8
View File
@@ -53,6 +53,14 @@ function AccountBalances() {
formatMoney allein faerbt nicht – das Minus stuende still da. */}
<strong><Money cents={data.total} currency={data.currency} /></strong>
</div>
{/* Gesperrtes Geld steht daneben, nicht darin – sonst beantwortet die
Summe eine andere Frage, als der Nutzer stellt. */}
{data.gebunden > 0 && (
<div className="dash-line muted small">
<span className="grow">{t("cards.locked")}</span>
{formatMoney(data.gebunden, data.currency)}
</div>
)}
</div>
);
}
+19
View File
@@ -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;
+6
View File
@@ -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.",
+6
View File
@@ -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.",
+53 -2
View File
@@ -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 (
<div>
@@ -357,6 +369,25 @@ export default function Accounts() {
<span className="muted">{t("accounts.total")}</span>
<strong><Money cents={total} currency={baseCurrency} /></strong>
</div>
{/* Aufklappbar, weil eine Summe, die man nicht nachrechnen kann, man
glauben muss. Darunter steht jedes gebundene Konto einzeln. */}
{gebundene.length > 0 && (
<details className="gebunden-zeile">
<summary className="muted small">
{t("accounts.lockedTotal", {
value: formatMoney(gebunden, baseCurrency),
count: gebundene.length,
})}
</summary>
{gebundene.map((a) => (
<div className="gebunden-posten" key={a.id}>
<span className="grow ellipsis">{a.name}</span>
<Money cents={a.balance_cents} currency={a.currency}
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
</div>
))}
</details>
)}
<div className="account-list">
{accounts.map((a, i) => (
<div className={`account-row${aufschluss[a.id] ? " mit-aufschluss" : ""}`}
@@ -370,6 +401,12 @@ export default function Accounts() {
Certain eine Auszugszeile diesem Konto zuordnet. */}
{a.identifiers.length > 0
&& ` · ${t("accounts.identShort", { count: a.identifiers.length })}`}
{a.is_locked && (
<span className="badge" style={{ marginLeft: 6 }}
title={t("accounts.lockedHint")}>
{t("accounts.lockedBadge")}
</span>
)}
</div>
<Kreditrahmen account={a} t={t} />
{/* 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" && (
<>
<label className="checkline">
<input type="checkbox" checked={form.is_locked}
onChange={(e) => setForm({ ...form, is_locked: e.target.checked })} />
<span>{t("accounts.locked")}</span>
</label>
<p className="muted small">{t("accounts.lockedHint")}</p>
</>
)}
<label>
<span>{t("accounts.savingsCategory")}</span>
<CategorySelect