Pensionsalter anpassen (Roadmap 44)
Deploy App / deploy (push) Successful in 1m1s

Das Pensionsalter laesst sich neu veraendern: Nicht das Alter wird gesetzt,
sondern die Phasengrenze verschoben -- Vorphase laenger, Folgephase kuerzer,
Gesamtdauer gleich. Faellt eine Phase dabei weg, werden die beiden Uebergaenge
nach Bestaetigung zusammengelegt.

- neues reines Modul lib/retirement.ts + POST /api/scenarios/<id>/retirement
- Pensionsalter als Tornado-Treiber und Live-Simulations-Regler
- AHV-Referenzalter 65: Rente ab 65 unabhaengig vom Pensionsalter; vor 65
  Beitrag als Nichterwerbstaetige(r) (neues Feld ahvContribution).
  AHV wird dafuer jahresweise statt phasenweise gerechnet.
- Punkt B: personenzugeordnetes Einkommen faellt bei Pensionierung auf 0
- Punkt A: Wiederkehr-Parameter werden live aus der Vorphase geerbt,
  sichtbar als "Aus Vorphase uebernehmen"
- Punkt C: Kapitalzufluss am Pensions-Uebergang per Quote auf Amortisation,
  Anlage und Cash verteilbar
- Fix: carry.flowBasis wurde vor der Jahresschleife berechnet, effektive
  Werte kamen deshalb nie in der Folgephase an

SPEZIFIKATION 0.26 (neue Kapitel 3.12, 4.4.7, 4.16). 221 -> 261 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 14:24:40 +02:00
parent c429624635
commit d6855ef1a0
20 changed files with 2057 additions and 184 deletions
+269 -83
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client";
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
@@ -24,8 +24,9 @@ import {
type RateScope,
} from "@/lib/ratefields";
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
import { CATEGORY_LABELS, num } from "@/lib/elements";
import { CATEGORY_LABELS, num, type InheritableKey } from "@/lib/elements";
import {
AHV_REFERENCE_AGE,
DEFAULT_CAPITAL_TAX_RATE,
DEFAULT_PK_CONVERSION_RATE,
DEFAULT_PROPERTY_GAINS_TAX_RATE,
@@ -59,6 +60,12 @@ export interface CellContext {
laterPhaseCount: number;
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
ahvCareer: AhvCareer | null;
// Werte, die in dieser Phase gelten würden, wenn das jeweilige Feld leer bleibt
// (Roadmap Nr. 44, Punkt A). Leer in der ersten Phase.
inheritedValues: Record<string, number>;
// Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob
// das Referenzalter in diese Phase fällt (Kap. 4.4.6).
ownerAgeStart: number;
}
interface Props {
@@ -244,10 +251,18 @@ export function CashTransitionFields({
ct,
setC,
deflatorEnd,
// Roadmap Nr. 44, Punkt C: Nur am Pensions-Übergang gefragt -- dort kommt das Kapital
// (PK, 3a, Immobilienverkauf) auf einmal herein und will verwendet werden.
showCapitalUse = false,
capitalInflow = 0,
investTargets = [],
}: {
ct: CashTransitionData;
setC: (patch: Partial<CashTransitionData>) => void;
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
showCapitalUse?: boolean;
capitalInflow?: number;
investTargets?: { id: string; name: string }[];
}) {
const mode = ct.mode ?? "NONE";
const showIn = mode === "INFLOW" || mode === "BOTH";
@@ -347,6 +362,75 @@ export function CashTransitionFields({
/>
</>
)}
{showCapitalUse && <CapitalUseFields ct={ct} setC={setC} inflow={capitalInflow} targets={investTargets} />}
</>
);
}
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
//
// In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene
// Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin
// still eine falsche Aufteilung rechnen. Eine Quote skaliert mit.
function CapitalUseFields({
ct,
setC,
inflow,
targets,
}: {
ct: CashTransitionData;
setC: (patch: Partial<CashTransitionData>) => void;
inflow: number;
targets: { id: string; name: string }[];
}) {
const amort = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
const invest = Math.max(0, Math.min(100 - amort, num(ct.capitalUseInvestPct)));
const cash = Math.max(0, 100 - amort - invest);
return (
<>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<strong className="text-fg">Verwendung des Kapitalzuflusses</strong> (PK-Kapital, Säule 3a,
Verkaufserlös). Bei diesem Übergang fliessen{" "}
<strong className="text-fg">{formatChf(inflow)}</strong> netto herein. Erfasst wird die
Aufteilung in Prozent so bleibt sie richtig, wenn du das Pensionsalter verschiebst und
sich der Betrag dadurch ändert. Was nicht zugeteilt ist, bleibt Cash.
</div>
<NumberField
label="… in die Hypothek (Amortisation, %)"
help="Reduziert die Resthypothek der Immobilie einmalig. Am Restsaldo gekappt ist die Hypothek kleiner, bleibt der Rest Cash."
step={5}
min={0}
max={100}
value={amort}
onChange={(v) => setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
/>
<NumberField
label="… in die Anlage (%)"
help="Fliesst als Zusatzinvestition in das gewählte Vermögens-Element und wächst dort mit dessen Rendite weiter."
step={5}
min={0}
max={100 - amort}
value={invest}
onChange={(v) => setC({ 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"
help="Welches Vermögens-Element wird aufgestockt?"
value={ct.capitalUseTargetElementId ?? targets[0].id}
onChange={(v: string) => setC({ capitalUseTargetElementId: v })}
options={targets.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
)}
<DerivedField
label={`… bleibt Cash (${cash} %)`}
value={Math.round((inflow * cash) / 100)}
help="Der nicht zugeteilte Rest. Wird automatisch berechnet."
/>
</>
);
}
@@ -365,6 +449,35 @@ export function ElementPhaseFields({
setP: (patch: Partial<PhaseData>) => void;
}) {
const carried = context.carried;
// Punkt A (Roadmap Nr. 44): Wiederkehr-Parameter werden aus der Vorphase übernommen,
// solange das Feld leer bleibt. `inh` baut die Hülle mit dem sichtbaren Häkchen.
const inh = (
key: InheritableKey,
label: string,
render: (value: number, set: (v: number) => void) => React.ReactNode,
format: (v: number) => string,
help?: string
) => {
const own = pd[key];
const fallback = context.inheritedValues[key] ?? 0;
const isInheriting = carried && typeof own !== "number";
return (
<InheritableField
label={label}
help={help}
canInherit={carried}
isInheriting={isInheriting}
inheritedDisplay={format(fallback)}
onToggle={(inherit) => setP({ [key]: inherit ? undefined : fallback } as Partial<PhaseData>)}
>
{render(typeof own === "number" ? own : fallback, (v) => setP({ [key]: v } as Partial<PhaseData>))}
</InheritableField>
);
};
const asPct = (v: number) => `${v} %`;
const asChf = (v: number) => formatChf(v);
switch (element.category) {
case "INCOME":
case "EXPENSE": {
@@ -372,7 +485,6 @@ export function ElementPhaseFields({
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
const rate = num(pd.teuerungsausgleich, 0);
const d = context.deflatorStart || 1;
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
@@ -394,21 +506,36 @@ export function ElementPhaseFields({
value={otherValue}
help="Nur zur Info, wird automatisch berechnet."
/>
<NumberField
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
help={
isIncome
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
}
step={0.1}
value={rate}
onChange={(v) => setP({ teuerungsausgleich: v })}
/>
{inh(
"teuerungsausgleich",
isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)",
(v, set) => (
<NumberField
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
step={0.1}
value={v}
onChange={set}
/>
),
asPct,
isIncome
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
)}
</>
);
}
case "AHV":
case "AHV": {
// Seit Roadmap Nr. 44 beginnt die AHV-Rente IMMER mit dem Referenzalter -- unabhängig
// davon, wann die Person aufhört zu arbeiten (Kap. 4.4.6). Daraus folgen zwei Fälle,
// die es vorher nicht gab und die hier sichtbar gemacht werden müssen:
// * Pensionierung VOR 65 -> beitragspflichtig als Nichterwerbstätige(r) bis 65.
// * Erwerbstätig ÜBER 65 -> die Rente fliesst trotzdem ab 65.
const ageStart = context.ownerAgeStart;
const ageEnd = ageStart + context.durationYears - 1;
const reachesReference = ageEnd >= AHV_REFERENCE_AGE;
const startsBeforeReference = ageStart < AHV_REFERENCE_AGE;
if (!context.ownerWorking) {
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang,
// an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase).
@@ -442,23 +569,61 @@ export function ElementPhaseFields({
);
}
return (
<p className="col-span-2 text-sm text-muted">
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
Plafonierung auf 150% der Maximalrente.
</p>
<>
<p className="col-span-2 text-sm text-muted">
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
Plafonierung auf 150% der Maximalrente. Sie fliesst ab Alter {AHV_REFERENCE_AGE}
{reachesReference && startsBeforeReference
? ` also erst im Verlauf dieser Lebensphase (Alter ${ageStart} bis ${ageEnd}).`
: "."}
</p>
{startsBeforeReference && (
<>
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<strong>Frühpensionierung:</strong> Wer vor Alter {AHV_REFERENCE_AGE} aufhört zu
arbeiten, bleibt bis dahin AHV-beitragspflichtig als Nichterwerbstätige(r). Der
Beitrag ist eine laufende Ausgabe und fällt mit {AHV_REFERENCE_AGE} weg, wenn die
Rente einsetzt.
</p>
<MoneyField
label="AHV-Beitrag als Nichterwerbstätige(r) (CHF/Jahr)"
help={
"Der Beitrag bemisst sich am Vermögen und am Renteneinkommen, nicht am Lohn die Bandbreite ist deshalb enorm: " +
"vom Mindestbeitrag von rund 530 CHF pro Jahr bis zum Höchstbeitrag von rund 26'500 CHF pro Jahr. " +
"Faustregel: Bis etwa 300'000 CHF Vermögen liegt man nahe am Minimum; ab rund 8.7 Mio. am Maximum. " +
"Verheiratete zahlen den Beitrag je einzeln, wenn beide nicht erwerbstätig sind; ist der Partner erwerbstätig " +
"und zahlt genug, entfällt er. Die AHV-Ausgleichskasse rechnet den Betrag auf Anfrage aus."
}
value={num(pd.ahvContribution)}
onChange={(v) => setP({ ahvContribution: v })}
/>
</>
)}
</>
);
}
return (
<NumberField
label="Geplante Ausfalljahre"
help="Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44."
value={num(pd.gapYears)}
min={0}
max={context.durationYears}
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
/>
<>
<NumberField
label="Geplante Ausfalljahre"
help={`Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44. Nur Jahre VOR dem Referenzalter ${AHV_REFERENCE_AGE} zählen.`}
value={num(pd.gapYears)}
min={0}
max={context.durationYears}
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
/>
{reachesReference && (
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<strong>Weiterarbeiten über {AHV_REFERENCE_AGE} hinaus:</strong> Die AHV-Rente
beginnt trotzdem mit {AHV_REFERENCE_AGE} und fliesst neben dem Lohn. Ein Aufschub
der Rente wird nicht abgebildet. Erfasse deshalb nur die Ausfalljahre bis zum
Referenzalter.
</p>
)}
</>
);
}
case "PENSION_FUND":
if (!context.ownerWorking) {
return (
@@ -484,13 +649,19 @@ export function ElementPhaseFields({
) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jährliche Einzahlung (CHF)"
help="Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
{inh(
"annualContribution",
"Jährliche Einzahlung (CHF)",
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={set} />,
asChf,
"Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
)}
{inh(
"expectedReturn",
"Erwartete Rendite (%/Jahr)",
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct
)}
</>
);
case "PILLAR_3A":
@@ -513,14 +684,21 @@ export function ElementPhaseFields({
) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jährliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`}
value={num(pd.annualContribution)}
max={PILLAR_3A_MAX_ANNUAL}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
{inh(
"annualContribution",
"Jährliche Einzahlung (CHF)",
(v, set) => (
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={PILLAR_3A_MAX_ANNUAL} onChange={set} />
),
asChf,
`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
)}
{inh(
"expectedReturn",
"Erwartete Rendite (%/Jahr)",
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct
)}
</>
);
case "REAL_ESTATE": {
@@ -548,19 +726,20 @@ export function ElementPhaseFields({
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
</>
)}
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
value={num(pd.amortization)}
onChange={(v) => setP({ amortization: v })}
/>
<NumberField
label="Hypothekarzins (%/Jahr)"
help="Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
step={0.1}
value={num(pd.interestRate)}
onChange={(v) => setP({ interestRate: v })}
/>
{inh(
"amortization",
"Amortisation (CHF/Jahr)",
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={set} />,
asChf,
"Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
)}
{inh(
"interestRate",
"Hypothekarzins (%/Jahr)",
(v, set) => <NumberField label="Hypothekarzins (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct,
"Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
)}
<div>
<FieldLabel
label="Hypothekarzins-Betrag (Beginn → Ende)"
@@ -570,13 +749,13 @@ export function ElementPhaseFields({
{formatChf(zinsStart)} <span className="text-faint"></span> {formatChf(zinsEnde)}
</div>
</div>
<NumberField
label="Geschätzte Wertsteigerung (%/Jahr)"
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr bei 100'000 Eigenkapital also 10% darauf (Hebel)."
step={0.1}
value={num(pd.valueGrowth)}
onChange={(v) => setP({ valueGrowth: v })}
/>
{inh(
"valueGrowth",
"Geschätzte Wertsteigerung (%/Jahr)",
(v, set) => <NumberField label="Geschätzte Wertsteigerung (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct,
"Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr bei 100'000 Eigenkapital also 10% darauf (Hebel)."
)}
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
@@ -609,19 +788,26 @@ export function ElementPhaseFields({
) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField
label="Jährlicher Sparbeitrag (CHF)"
help="Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<MoneyField
label="Jährliche Bezugsrate (CHF)"
help="Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
value={num(pd.annualWithdrawal)}
onChange={(v) => setP({ annualWithdrawal: v })}
/>
{inh(
"expectedReturn",
"Erwartete Rendite (%/Jahr)",
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct
)}
{inh(
"annualContribution",
"Jährlicher Sparbeitrag (CHF)",
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
asChf,
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
)}
{inh(
"annualWithdrawal",
"Jährliche Bezugsrate (CHF)",
(v, set) => <MoneyField label="Jährliche Bezugsrate (CHF)" value={v} onChange={set} />,
asChf,
"Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
)}
</>
);
case "OTHER_DEBT":
@@ -632,13 +818,13 @@ export function ElementPhaseFields({
) : (
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
<MoneyField
label="Jährliche Tilgung (CHF)"
help="Zählt gegen die Sparquote (max. die Sparquote)."
value={num(pd.annualRepayment)}
onChange={(v) => setP({ annualRepayment: v })}
/>
{inh(
"annualRepayment",
"Jährliche Tilgung (CHF)",
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />,
asChf,
"Zählt gegen die Sparquote (max. die Sparquote)."
)}
</>
);
}