Memo 2026081701: Kapitalverwendung mit waehlbarem Tilgungsziel, AHV automatisch, Farb- und Textkorrekturen
Deploy App / deploy (push) Successful in 1m18s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:04:45 +02:00
co-authored by Claude Opus 5
parent f9d7ca07fd
commit 113616cc1f
9 changed files with 485 additions and 116 deletions
+30 -5
View File
@@ -75,6 +75,13 @@ export interface CellContext {
// Stammdaten des Elements. In der ERSTEN Phase ist der Startwert genau das -- er wird dort
// nur noch ANGEZEIGT, denn sonst entstünde derselbe Betrag an zwei Orten (Kap. 3.14.3).
baseData: PhaseData;
// Der TATSÄCHLICH gerechnete Stand dieser Zelle. In einer Phase, in der die Person schon
// pensioniert ist, stand hier bis 0.40 eine Regelbeschreibung im Futur («wird berechnet»,
// «wird bezogen»), obwohl der Bezug längst geschehen war. Was die Zelle braucht, ist der
// Zustand, nicht die Regel.
computedSummary: string;
computedNote: string | null;
computedValue: number;
// Einmalige Kapitalverwendungen, die am Übergang IN diese Phase beschlossen wurden. Sie
// liegen technisch an der Vorphase, gehören aber sichtbar hierher -- zusammen mit der
// Zusatzeinlage, die aus demselben Topf stammt.
@@ -750,11 +757,23 @@ export function ElementPhaseFields({
}
case "PENSION_FUND":
if (!context.ownerWorking) {
// Zustand statt Regel: entweder läuft eine Rente, oder das Guthaben ist bezogen.
const hasPension = context.computedValue > 0;
return (
<p className="col-span-2 text-sm text-muted">
Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollständig bezogen&quot;.
</p>
<div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
{hasPension ? (
<>
<strong className="text-fg">Rente: {formatChf(context.computedValue)} pro Jahr.</strong> Das
Guthaben wurde beim Übergang in die Pension verrentet – hier gibt es nichts mehr einzustellen.
Den Umwandlungssatz und den Kapitalanteil änderst du in der Übergangsspalte davor.
</>
) : (
<>
<strong className="text-fg">Vollständig bezogen.</strong> Das Guthaben wurde beim Übergang in die
Pension als Kapital ausbezahlt; wohin es geflossen ist, steht dort im Bezugs-Entscheid.
</>
)}
</div>
);
}
return (
@@ -786,7 +805,13 @@ export function ElementPhaseFields({
);
case "PILLAR_3A": {
if (!context.ownerWorking) {
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
return (
<div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
<strong className="text-fg">Vollständig bezogen.</strong> Ein 3a-Konto lässt sich nur ganz auflösen –
das ist beim Übergang geschehen. Bezugsalter, Steuersatz und die Verwendung des Kapitals stehen in der
Übergangsspalte davor.
</div>
);
}
// «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c).
// Der Schalter steht seit 0.40 im Verteil-Dialog, weil er dort die Obergrenze der
+32 -1
View File
@@ -481,7 +481,18 @@ function BaseDataCard({
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
/>
</div>
{kind?.baseFields(values, set)}
{kind ? (
kind.baseFields(values, set)
) : (
// Die AHV ist die einzige Kategorie ohne Bestand: Es gibt kein Guthaben zum
// Nachschlagen, nur eine Beitragskarriere. Eine leere Karte sähe nach einem Fehler
// aus -- deshalb steht hier, warum nichts zu tun ist.
<p className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Hier gibt es nichts zu erfassen. Die AHV-Rente entsteht aus deiner Beitragskarriere – FPT leitet sie
aus den Einkommen dieses Plans ab. Beim Übergang in die Pension kannst du sie prüfen und um
Beitragslücken vor Planbeginn ergänzen.
</p>
)}
</div>
</div>
);
@@ -502,6 +513,26 @@ export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft)
if (draft.cash !== null && draft.cash !== plan.initialCash) {
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: draft.cash });
}
// Die AHV entsteht am Schluss von selbst -- je Person eine.
//
// Sie hat als einzige Kategorie KEINEN Bestand: Es gibt kein Guthaben, das man nachschlagen
// könnte, nur eine Beitragskarriere. Ein Plus-Knopf mit einer leeren Karte darunter wäre
// deshalb sinnlos gewesen. Weglassen ist aber keine Option: In der Schweiz hat jede Person
// eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer Rente von 0.
//
// IDEMPOTENT: Wer die Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt
// kein zweites Element -- geprüft wird je Person, nicht global.
for (const person of plan.persons) {
const exists = plan.elements.some((e) => e.category === "AHV" && e.ownerRole === person.role);
if (exists) continue;
const name = person.name?.trim() ? `AHV ${person.name.trim()}` : "AHV";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: "AHV",
name,
ownerRole: person.role,
});
}
}
+76 -15
View File
@@ -333,6 +333,9 @@ export function PlanView({
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
capitalFromTransferSources: ce?.capitalFromTransferSources ?? [],
baseData: element.baseData ?? {},
computedSummary: ce?.summary ?? "",
computedNote: ce?.note ?? null,
computedValue: ce?.startValue ?? 0,
oneOffAmortization: num(prevTd.extraAmortization),
oneOffRepayment: num(prevTd.immediateRepayment),
onEditBase: () => setPanel({ kind: "base", elementId: element.id }),
@@ -367,6 +370,9 @@ export function PlanView({
capitalFromTransfer: 0,
capitalFromTransferSources: [],
baseData: element.baseData ?? {},
computedSummary: ce?.summary ?? "",
computedNote: ce?.note ?? null,
computedValue: ce?.startValue ?? 0,
// Einmalige Kapitalverwendungen gehören in die FOLGEphase, nicht an den Übergang selbst.
oneOffAmortization: 0,
oneOffRepayment: 0,
@@ -690,6 +696,11 @@ Erste Lebensphase anlegen
// Übergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben.
const ct = cashTransitionFor(col.fromPhase.id);
const open = !isCashTransitionAnswered(ct);
// Die Farbe folgt der RICHTUNG des Geldes, nicht dem Zustand der Zelle.
// Vorher trug jede beantwortete Zelle die Akzentfarbe -- im warmen Schema
// ein Orangerot, das eine Erbschaft wie einen Fehler aussehen liess.
const inflow = ct.mode === "INFLOW" || ct.mode === "BOTH";
const outflow = ct.mode === "OUTFLOW" || ct.mode === "BOTH";
return (
<td
key={`cash-t-${col.fromPhase.id}`}
@@ -700,7 +711,11 @@ Erste Lebensphase anlegen
? "bg-attention font-semibold text-attention-fg"
: diff?.cashTransitionCell.has(col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: "bg-accent-soft/40 text-accent"
: inflow && !outflow
? "bg-success/10 font-semibold text-success"
: outflow
? "text-fg"
: "text-muted"
}`}
>
{cashTransitionSummary(ct)}
@@ -966,7 +981,7 @@ Erste Lebensphase anlegen
{showAddPhase && (
<AddPhaseDialog
maxDurationYears={nextPhaseCap()}
cap={nextPhaseCap()}
onClose={() => setShowAddPhase(false)}
onCreate={handleAddPhase}
/>
@@ -1140,13 +1155,22 @@ Erste Lebensphase anlegen
}
}
function nextPhaseCap(): number | null {
// Obergrenze der nächsten Phase UND ihr Grund. Die blosse Zahl («max. 12») beantwortet nicht,
// warum sie gilt -- und ohne diese Antwort wirkt die Sperre willkürlich.
function nextPhaseCap(): { years: number | null; reason: string | null } {
// Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern).
const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0);
const caps = plan.persons
.map((p) => p.retirementAge - (p.age + yearsBefore))
.filter((d) => d > 0);
return caps.length > 0 ? Math.min(...caps) : null;
.map((p) => ({ years: p.retirementAge - (p.age + yearsBefore), person: p }))
.filter((c) => c.years > 0)
.sort((a, b) => a.years - b.years);
if (caps.length === 0) return { years: null, reason: null };
const first = caps[0];
const name = first.person.name?.trim() || (first.person.role === "PERSON_B" ? "Person B" : "Person A");
return {
years: first.years,
reason: `${name} geht mit ${first.person.retirementAge} in Pension – dort endet diese Phase zwingend.`,
};
}
// Inhalt des Inspector-Panels je nach Auswahl.
@@ -1458,6 +1482,11 @@ function PhaseHeader({
}) {
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
const pot = capitalPot(phase);
// «Offen» heisst hier: Es ist Kapital da und noch nichts davon zugeteilt. Wer bewusst nur
// einen Teil verteilt und den Rest liegen lässt, hat entschieden -- das gilt als erledigt.
// Anders als bei der Quote gibt es dafür kein gespeichertes Kennzeichen; das Kapital ist
// aber auch keine Frage, die man beantworten MUSS, sondern eine Gelegenheit.
const capitalOpen = pot.total > 0 && pot.allocatedInvestments + pot.allocatedRepayments === 0;
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen)
const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote)
@@ -1576,15 +1605,27 @@ function PhaseHeader({
<span>Rest auf Cash</span>
<ValueSingle value={pot.rest} deflator={dS} mode={mode} />
</div>
{/* Grün, sobald nichts mehr offen ist: Der Knopf sagt damit auf einen Blick
«erledigt» statt bloss «hier kannst du klicken». Im warmen Schema war die
Akzentfarbe ein Orangerot und las sich wie eine Rüge. */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDistributeCapital();
}}
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
title={
capitalOpen
? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto"
: "Verteilt"
}
className={`mt-1 w-full rounded border px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
capitalOpen
? "border-attention bg-attention text-attention-fg hover:opacity-90"
: "border-success text-success hover:bg-success hover:text-white"
}`}
>
Kapital verteilen
Kapital verteilen{capitalOpen ? "" : " ✓"}
</button>
</div>
)}
@@ -1601,14 +1642,15 @@ function PhaseHeader({
e.stopPropagation();
onDistributeRates();
}}
title={ratesOpen ? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto" : undefined}
className={`mt-1 w-full rounded px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
title={ratesOpen ? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto" : "Verteilt"}
className={`mt-1 w-full rounded border px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
ratesOpen
? "border border-attention bg-attention text-attention-fg hover:opacity-90"
: "border border-accent text-accent hover:bg-accent hover:text-accent-fg"
? "border-attention bg-attention text-attention-fg hover:opacity-90"
: "border-success text-success hover:bg-success hover:text-white"
}`}
>
{phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
{ratesOpen ? "" : " ✓"}
</button>
</div>
@@ -1818,15 +1860,15 @@ function AddElementDialog({
// --- Dialog: neue Lebensphase ---
function AddPhaseDialog({
maxDurationYears,
cap: capInfo,
onClose,
onCreate,
}: {
maxDurationYears: number | null;
cap: { years: number | null; reason: string | null };
onClose: () => void;
onCreate: (payload: { name?: string; durationYears?: number }) => void;
}) {
const cap = maxDurationYears;
const cap = capInfo.years;
const [name, setName] = useState("");
const [durationYears, setDurationYears] = useState(cap ?? 10);
const [saving, setSaving] = useState(false);
@@ -1834,6 +1876,25 @@ function AddPhaseDialog({
return (
<DialogShell title="Neue Lebensphase" onClose={onClose}>
<div className="grid grid-cols-1 gap-3">
{/* Warum die Dauer begrenzt ist. Die Rechnung leitet Erwerbsstatus und Bezuege am
PHASENBEGINN ab -- faellt die Pensionierung mitten in eine Phase, rutscht sie auf
die naechste Grenze und die Zahlen waeren still falsch. */}
<div className="rounded-lg bg-surface-2 px-3 py-2 text-xs leading-relaxed text-muted">
{cap == null ? (
<>
Alle Personen sind bereits pensioniert – diese Phase darf so lang sein, wie du willst. Der
Planungshorizont ergibt sich aus der Summe aller Lebensphasen.
</>
) : (
<>
Diese Phase kann <strong className="text-fg">höchstens {cap} {cap === 1 ? "Jahr" : "Jahre"}</strong>{" "}
dauern. {capInfo.reason} FPT bestimmt Erwerbsstatus und Renten immer am{" "}
<strong className="text-fg">Anfang</strong> einer Phase – fiele die Pensionierung mitten hinein,
rutschte sie auf die nächste Phasengrenze und die Zahlen wären still falsch. Danach legst du einfach
die nächste Phase an.
</>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung (optional)</label>
<input
+144 -72
View File
@@ -60,6 +60,7 @@ export function RetirementFields({
if (el.category === "PILLAR_3A")
return (
<Pillar3aBlock
plan={plan}
el={el}
rd={rd}
patch={patch}
@@ -99,9 +100,12 @@ export function Pillar({
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
<span className="text-sm font-semibold text-fg">{title}</span>
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
{/* Dieselbe Vokabel wie bei den Phasenzellen (SPEZIFIKATION 3.14.2): «angeschaut».
Es ist derselbe Gedanke -- dort auf den Werten der Vorphase, hier auf einer Vorgabe
des Tools -- und zwei Wörter für eine Sache haben nur verwirrt. */}
{confirmed ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
<Check className="h-3.5 w-3.5" /> bestätigt
<Check className="h-3.5 w-3.5" /> angeschaut
</span>
) : (
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
@@ -112,7 +116,7 @@ export function Pillar({
{open && (
<div className="border-t border-border px-3 py-3">
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-muted">
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<input
type="checkbox"
checked={confirmed}
@@ -120,8 +124,11 @@ export function Pillar({
className="mt-0.5"
/>
<span>
Ich habe das angeschaut und bestätige es. Ohne Häkchen rechnet das Tool mit der Vorgabe – sie wird als
«ungeprüft» ausgewiesen, damit sie nicht unbemerkt durchgeht.
<strong className="text-fg">Angeschaut.</strong> FPT muss hier etwas rechnen, auch bevor du entschieden
hast – deshalb gibt es eine Vorgabe (volle Rente, Bezug im Pensionsjahr). Der Haken hält fest, dass du
sie gesehen und für richtig befunden hast. <strong className="text-fg">Er ändert keine Zahl.</strong>{" "}
Ohne ihn rechnet FPT genauso weiter, weist die Vorgabe aber als «ungeprüft» aus, damit sie nicht
unbemerkt durchgeht.
</span>
</label>
</div>
@@ -232,7 +239,6 @@ export function PkBlock({
defaultOpen?: boolean;
}) {
const share = rd.capitalSharePct ?? 0;
const targets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
return (
<Pillar
@@ -261,7 +267,12 @@ export function PkBlock({
step={5}
value={share}
onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })}
className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-surface-2 accent-[var(--accent)]"
// KEIN appearance-none: Damit zeichnet der Browser die Schiene nicht mehr selbst,
// und accent-color greift nicht -- uebrig blieb der blosse Knopf. Im warmen Schema
// war die Schiene dadurch unsichtbar.
// Gleiche Schreibweise wie der Regler der Live-Simulation, der immer funktioniert
// hat: keine erzwungene Hoehe, kein appearance-none.
className="flex-1 cursor-pointer accent-[var(--accent)]"
/>
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
{share} % Kapital
@@ -273,15 +284,19 @@ export function PkBlock({
</div>
</div>
<NumberField
label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil – umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
value={rd.conversionRate ?? 0}
step={0.1}
min={0}
max={20}
onChange={(v) => patch(el.id, { conversionRate: v })}
/>
{/* Bei 100 % Kapital wird nichts verrentet -- dann ist der Satz gegenstandslos, genau wie
die Kapitalbezugssteuer bei 0 % Kapital. */}
{share < 100 && (
<NumberField
label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil – umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
value={rd.conversionRate ?? 0}
step={0.1}
min={0}
max={20}
onChange={(v) => patch(el.id, { conversionRate: v })}
/>
)}
{share > 0 && (
<NumberField
label="Kapitalbezugssteuer (%)"
@@ -309,7 +324,9 @@ export function PkBlock({
</span>
</label>
{rd.recentBuyIn && (
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention/10 px-3 py-2 text-xs text-attention-fg">
// attention-fg ist WEISS -- gedacht fuer vollflaechigen Attention-Grund. Auf
// 10 % Deckkraft war der Text im warmen und im hellen Schema unlesbar.
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den
Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen
@@ -317,7 +334,7 @@ export function PkBlock({
</p>
)}
</div>
<CapitalUse rd={rd} elementId={el.id} patch={patch} targets={targets} plan={plan} />
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</>
)}
</Pillar>
@@ -325,6 +342,7 @@ export function PkBlock({
}
export function Pillar3aBlock({
plan,
el,
rd,
patch,
@@ -332,6 +350,7 @@ export function Pillar3aBlock({
siblings,
defaultOpen,
}: {
plan: PlanInput;
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
@@ -379,90 +398,143 @@ export function Pillar3aBlock({
)}
</div>
{clash && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt – ein
anderes Bezugsjahr senkt die Progression.
</p>
)}
{/* Die Frage stellt sich hier sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER
vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag. Bis 0.40 gab
es die Maske nur bei der Pensionskasse -- das 3a-Kapital landete stumm auf dem Cash. */}
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</Pillar>
);
}
// Eine Zeile der Kapitalverwendung: Quote plus, sobald sie > 0 ist, das Ziel. Eigene Komponente
// auf Modulebene -- innerhalb von CapitalUse deklariert wuerde sie bei jedem Tastendruck neu
// erzeugt und verloere ihren Zustand (der React Compiler weist das zu Recht zurueck).
function CapitalUseRow({
title,
help,
value,
max,
onChange,
targets,
targetValue,
onTarget,
targetLabel,
missing,
label,
}: {
title: string;
help: string;
value: number;
max: number;
onChange: (v: number) => void;
targets: ElementInput[];
targetValue?: string;
onTarget: (id: string) => void;
targetLabel: string;
missing: string;
label: (e: ElementInput) => string;
}) {
return (
<div className="rounded-lg border border-border p-3">
<div className="grid gap-3 sm:grid-cols-2">
<NumberField label={title} help={help} value={value} step={5} min={0} max={max} onChange={onChange} />
{value > 0 && targets.length > 0 && (
<SelectField
label={targetLabel}
value={targetValue ?? targets[0].id}
onChange={(v: string) => onTarget(v)}
options={targets.map((t) => ({ value: t.id, label: label(t) }))}
/>
)}
{value > 0 && targets.length === 0 && (
<p className="flex items-start gap-2 self-end text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{missing}
</p>
)}
</div>
</div>
);
}
// Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier
// nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt.
//
// Drei gleichrangige Zeilen: Schuldentilgung, Investition, Cash. Cash ist rechnerisch der
// REST, wird aber wie eine Wahl dargestellt und nicht wie eine Fussnote -- «ich lasse es
// liegen und entscheide später» ist ein legitimer Entscheid. Alle drei frei eintippbar zu
// machen hiesse, dass beim Tippen still eine der anderen Zahlen wandert; das überrascht mehr,
// als es hilft.
function CapitalUse({
rd,
elementId,
patch,
targets,
plan,
}: {
rd: RetirementDecision;
elementId: string;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
targets: ElementInput[];
plan: PlanInput;
}) {
const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0));
const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0));
const cash = Math.max(0, 100 - amort - invest);
const hasMortgage = plan.elements.some((e) => e.category === "REAL_ESTATE");
// Tilgungs-Ziele: Hypotheken UND sonstige Schulden. Wer einen Konsumkredit zu 6 % neben
// einer Hypothek zu 1,5 % trägt, tilgt zuerst den Kredit.
const debtTargets = plan.elements.filter((e) => e.category === "REAL_ESTATE" || e.category === "OTHER_DEBT");
const investTargets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
const label = (e: ElementInput) => `${e.name} · ${ownerLabel(plan.persons, e.ownerRole)}`;
return (
<>
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital?
</p>
<div className="grid gap-3 sm:grid-cols-2">
<NumberField
label="… in die Hypothek (%)"
help={
hasMortgage
? "Einmalige Amortisation. Am Restsaldo gekappt – ist die Hypothek kleiner, bleibt der Rest Cash."
: "Es gibt keine Immobilie in diesem Szenario – dieser Anteil bliebe wirkungslos auf dem Cash."
}
value={amort}
step={5}
min={0}
max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
/>
<NumberField
label="… in die Anlage (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
step={5}
min={0}
max={100 - amort}
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
/>
{invest > 0 && targets.length > 0 && (
<div className="sm:col-span-2">
<SelectField
label="Ziel der Anlage-Quote"
value={rd.capitalUseTargetElementId ?? targets[0].id}
onChange={(v: string) => patch(elementId, { capitalUseTargetElementId: v })}
options={targets.map((t) => ({
value: t.id,
label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
}))}
/>
</div>
)}
{invest > 0 && targets.length === 0 && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf
dem Cash-Konto liegen.
</p>
)}
<p className="sm:col-span-2 text-xs text-faint">
Nicht zugeteilt: <strong className="text-fg">{cash} %</strong> – bleibt auf dem Cash-Konto.
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital?
</p>
<div className="flex flex-col gap-2">
<CapitalUseRow
label={label}
title="Schuldentilgung (%)"
help="Einmalige Tilgung. Am Restsaldo gekappt – ist die Schuld kleiner als der Anteil, bleibt der Rest Cash."
value={amort}
max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
targets={debtTargets}
targetValue={rd.capitalUseDebtTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseDebtTargetElementId: v })}
targetLabel="Welche Schuld?"
missing="Es gibt weder Immobilie noch Schuld in diesem Szenario – der Anteil bliebe auf dem Cash-Konto."
/>
<CapitalUseRow
label={label}
title="Investition (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
max={100 - amort}
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
targets={investTargets}
targetValue={rd.capitalUseTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseTargetElementId: v })}
targetLabel="Wohin investieren?"
missing="Es gibt kein Element «Sonstiges Vermögen» – der Anteil bliebe auf dem Cash-Konto."
/>
{/* Cash ist der Rest, aber als vollwertige Zeile dargestellt. */}
<div className="rounded-lg border border-dashed border-border bg-surface-2 p-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs font-medium text-muted">Cash (%)</span>
<span className="text-sm font-semibold tabular-nums text-fg">{cash} %</span>
</div>
<p className="mt-1 text-[11px] text-faint">
Was du nicht zuteilst, bleibt auf dem Cash-Konto – unverzinst, aber jederzeit verfügbar.
</p>
</div>
</div>
</>
</div>
);
}
+2 -2
View File
@@ -38,7 +38,7 @@ export function ReviewTile({
{done ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardList className="h-4 w-4 text-attention-fg" />
<ClipboardList className="h-4 w-4 text-attention" />
)}
<span className="text-xs font-semibold uppercase tracking-wide text-fg">Offene Punkte</span>
{!starting && (
@@ -99,7 +99,7 @@ export function ReviewTile({
>
<span className="flex items-baseline gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{g.title}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention-fg">{g.open}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention">{g.open}</span>
</span>
<span className="block truncate text-[11px] text-muted">{g.reasons.join(" · ")}</span>
</button>
+24 -7
View File
@@ -1918,16 +1918,33 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct)));
if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue;
// Schuldentilgung. Das Ziel ist seit 0.41 waehlbar und darf eine Immobilie ODER eine
// sonstige Schuld sein: Wer einen Konsumkredit zu 6 % neben einer Hypothek zu 1,5 %
// traegt, tilgt zuerst den Kredit -- vorher floss der Betrag stur in die erstbeste
// Immobilie. Ohne Wahl bleibt es beim bisherigen Verhalten (alle Immobilien der Reihe
// nach), damit bestehende Plaene unveraendert rechnen.
let amortBudget = Math.round((use.net * amortPct) / 100);
for (const e of orderedElements) {
const chosenDebt = orderedElements.find((e) => e.id === use.td.capitalUseDebtTargetElementId);
const debtOrder = chosenDebt ? [chosenDebt, ...orderedElements.filter((e) => e !== chosenDebt)] : orderedElements;
for (const e of debtOrder) {
if (amortBudget <= 0) break;
if (e.category !== "REAL_ESTATE") continue;
const c = carries.get(e.id)!;
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue;
const pay = Math.min(amortBudget, c.mortgage);
c.mortgage -= pay;
amortBudget -= pay;
txImmediateRepay += pay;
if (c.status !== "ACTIVE") continue;
if (e.category === "REAL_ESTATE") {
if (c.mortgage <= 0) continue;
const pay = Math.min(amortBudget, c.mortgage);
c.mortgage -= pay;
amortBudget -= pay;
txImmediateRepay += pay;
} else if (e.category === "OTHER_DEBT") {
// Nur das AUSDRUECKLICH gewaehlte Schuld-Element -- sonst wuerden bestehende Plaene,
// die bisher nur Hypotheken kannten, ploetzlich anders rechnen.
if (e !== chosenDebt || c.owed <= 0) continue;
const pay = Math.min(amortBudget, c.owed);
c.owed -= pay;
amortBudget -= pay;
txImmediateRepay += pay;
}
}
const investBudget = Math.round((use.net * investPct) / 100);
+92
View File
@@ -0,0 +1,92 @@
// Verwendung des bezogenen Alterskapitals: das Tilgungs-Ziel ist wählbar (0.41).
//
// Bis 0.40 floss die Tilgungs-Quote stur in die erstbeste Immobilie. Wer neben einer Hypothek
// zu 1,5 % einen Konsumkredit zu 6 % trägt, will aber zuerst den Kredit los -- und hatte keine
// Möglichkeit, das zu sagen.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
function plan(debtTarget?: string): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 64, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 1, cashTransition: { mode: "NONE" } },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 5, cashTransition: { mode: "NONE" } },
],
elements: [
{
id: "pk",
category: "PENSION_FUND" as never,
name: "PK",
ownerRole: "PERSON_A" as never,
orderIndex: 1,
baseData: { currentValue: 400000, expectedReturn: 0 },
phaseValues: { p1: {}, p2: {} },
transitionValues: {},
// Volles Kapital, keine Steuer, alles in die Tilgung: 400'000 stehen zur Verfügung.
retirementDecision: {
capitalSharePct: 100,
capitalTaxRate: 0,
conversionRate: 6,
capitalUseAmortizationPct: 100,
...(debtTarget ? { capitalUseDebtTargetElementId: debtTarget } : {}),
} as never,
},
{
id: "haus",
category: "REAL_ESTATE" as never,
name: "Haus",
ownerRole: "HOUSEHOLD" as never,
orderIndex: 2,
baseData: { purchasePrice: 900000, mortgage: 300000, interestRate: 1.5, valueGrowth: 0 },
phaseValues: { p1: {}, p2: {} },
transitionValues: { p1: { decision: "HOLD" as never } },
},
{
id: "kredit",
category: "OTHER_DEBT" as never,
name: "Konsumkredit",
ownerRole: "HOUSEHOLD" as never,
orderIndex: 3,
baseData: { startValue: 100000 },
phaseValues: { p1: {}, p2: {} },
transitionValues: {},
},
],
};
}
const restOf = (p: PlanInput, id: string) => {
const c = computePlan(p);
const ph = c.phases[1];
const ce = ph.elements.find((e) => e.elementId === id)!;
return id === "haus" ? ce.mortgageStart : Math.abs(ce.startValue);
};
describe("Schuldentilgung aus dem Alterskapital", () => {
it("tilgt ohne Wahl weiterhin die Hypothek", () => {
// Bestandsschutz: Pläne aus der Zeit vor der Zielwahl müssen unverändert rechnen.
const p = plan();
expect(restOf(p, "haus")).toBe(0);
expect(restOf(p, "kredit")).toBe(100000);
});
it("tilgt den gewählten Kredit zuerst", () => {
const p = plan("kredit");
expect(restOf(p, "kredit")).toBe(0);
});
it("lässt eine nicht gewählte Schuld unberührt, wenn die Hypothek gewählt ist", () => {
const p = plan("haus");
expect(restOf(p, "haus")).toBe(0);
expect(restOf(p, "kredit")).toBe(100000);
});
});
+5
View File
@@ -79,7 +79,11 @@ export interface RetirementDecision {
// --- gemeinsam: Kapitalbezug und seine Verwendung ---------------------------------------
capitalTaxRate?: number;
// Quote in die SCHULDENTILGUNG. Ziel ist seit 0.41 waehlbar: eine Hypothek (Immobilie)
// ODER eine sonstige Schuld -- vorher floss der Betrag stets in die erstbeste Immobilie,
// was bei einem teuren Konsumkredit daneben die falsche Wahl war.
capitalUseAmortizationPct?: number;
capitalUseDebtTargetElementId?: string;
capitalUseInvestPct?: number;
capitalUseTargetElementId?: string;
}
@@ -110,6 +114,7 @@ export const retirementDecisionSchema = z
capitalTaxRate: z.number().min(0).max(100).optional(),
capitalUseAmortizationPct: pct.optional(),
capitalUseDebtTargetElementId: z.string().max(60).optional(),
capitalUseInvestPct: pct.optional(),
capitalUseTargetElementId: z.string().max(60).optional(),
})