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
+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