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
+164 -33
View File
@@ -4,6 +4,7 @@ import {
AHV_FULL_CONTRIBUTION_YEARS,
AHV_GROSS_FROM_NET_FACTOR,
AHV_MAX_ANNUAL_SINGLE,
AHV_REFERENCE_AGE,
AHV_MIN_MONTHLY_FULL,
AHV_PENSION_MONTHS,
DEFAULT_CAPITAL_TAX_RATE,
@@ -292,6 +293,9 @@ interface Carry {
owed: number; // Schulden: Restschuld (positiv)
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
// Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge,
// Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase.
rates: Record<string, number>;
hasCarry: boolean;
}
@@ -305,6 +309,7 @@ function emptyCarry(): Carry {
owed: 0,
pkPensionAnnual: 0,
flowBasis: 0,
rates: {},
hasCarry: false,
};
}
@@ -448,12 +453,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
});
}
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
// AHV-Renten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
//
// Seit Roadmap Nr. 44 wird die Rente für JEDE Person mit AHV-Element gerechnet, nicht nur
// für bereits pensionierte: Sie fliesst ab dem REFERENZALTER -- auch wenn jemand darüber
// hinaus arbeitet. Ob sie in einem Jahr tatsächlich fliesst, entscheidet die Jahres-
// schleife anhand des Alters (Kap. 4.4.6).
const ahvUncapped = new Map<string, number>();
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole);
if (!owner || workingByPerson.get(owner.id)) continue;
if (!owner) continue;
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 };
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
const mdJE = ahvMdje(career, before.avg, before.gap);
@@ -472,9 +482,19 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const ecById = new Map<string, ElementPhaseComputed>();
// Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere).
const phaseRealIncomeByPerson = new Map<string, number>();
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = [];
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = [];
let renteTotal = 0; // PK-Renten (nominal fix ueber die ganze Phase)
// AHV je Person: Rente und Beitrag stehen fest, WANN sie greifen entscheidet das Alter
// im jeweiligen Jahr -- deshalb eine eigene Liste statt eines Phasenbetrags.
const ahvItems: {
ownerId: string;
ownerStartAge: number;
rente: number;
beitrag: number;
working: boolean;
ec: ElementPhaseComputed;
}[] = [];
// `isPk` für die Vermögens-Brücke: PK-Beiträge verlassen das Cash NICHT (sie sind im
// Nettolohn bereits abgezogen), erhöhen aber das Vermögen -- sie sind also ein echter
// Zugang und keine Umbuchung.
@@ -512,6 +532,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
// Punkt A (Roadmap Nr. 44): Ein nicht erfasster Wiederkehr-Parameter wird aus der
// Vorphase ÜBERNOMMEN, statt stillschweigend auf 0 zu fallen. Im UI ist das das
// angehakte «Aus Vorphase übernehmen»; ein eigener Wert hakt es ab. Der jeweils
// verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt.
const inherited = (key: string, fallback = 0): number => {
const own = (pd as Record<string, unknown>)[key];
const v = typeof own === "number" ? own : carry.rates[key] ?? fallback;
carry.rates[key] = v;
return v;
};
const ec: ElementPhaseComputed = {
elementId: e.id,
category: e.category,
@@ -549,14 +580,24 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu.
// Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase
// (nominal für Einkommen, real für Ausgaben), ausser bewusst geändert (pd.amount).
const idx = num(pd.teuerungsausgleich, 0);
const idx = inherited("teuerungsausgleich");
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
const basis = !carry.hasCarry
let basis = !carry.hasCarry
? Math.round(num(pd.amount))
: typeof pd.amount === "number"
? Math.round(pd.amount)
: ec.baseValue;
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
// Ist die besitzende Person pensioniert, fällt ihr Erwerbseinkommen weg -- sonst liefe
// der Lohn stillschweigend in die Pension weiter (Kap. 4.4.7). Ein ausdrücklich
// erfasster Betrag gewinnt, damit ein Teilzeitpensum oder eine Erwerbsersatz-Zahlung
// modellierbar bleibt. Gemeinsame Einkommen (Mieterträge o. Ä.) sind NICHT betroffen,
// weil sie nicht an der Erwerbstätigkeit einer Person hängen.
if (e.category === "INCOME" && owner && !ownerWorking && typeof pd.amount !== "number") {
basis = 0;
ec.baseValue = 0;
ec.note = "Wegen Pensionierung auf 0 gesetzt. Für ein Teilzeitpensum trage hier einen Betrag ein.";
}
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec, carry });
// AHV: reales Erwerbseinkommen der Person mitführen. Nur Einkommen, die einer
// Person zugeordnet sind -- bei einem Einzelplan zählt "Gemeinsam" zur Person A.
@@ -576,20 +617,41 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
}
// Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben).
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
// Die Fortschreibung in die Folgephase passiert NACH der Jahresschleife: Dort kann ein
// effektiver Wert `basis` noch neu setzen (`rebaseFlow`), und genau der soll weiter-
// getragen werden -- nicht der ursprünglich geplante.
break;
}
case "AHV": {
if (owner && !ownerWorking) {
// Die AHV wird JAHRESWEISE gerechnet (Kap. 4.4.6): Bis zum Referenzalter zahlt eine
// frühpensionierte Person Beiträge, ab dem Referenzalter fliesst die Rente -- beides
// kann INNERHALB derselben Phase kippen, deshalb nicht über `renteTotal`.
if (owner) {
const rente = ahvFinal.get(owner.id) ?? 0;
renteTotal += rente;
ec.startValue = rente;
ec.endValue = rente;
ec.summary = `Rente ${fmt(rente)}`;
} else {
const gap = Math.max(0, Math.round(num(pd.gapYears)));
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
const beitrag = Math.round(num(pd.ahvContribution));
const ageStart = owner.age + yearsBefore; // Alter im ersten Jahr der Phase
const ageEnd = ageStart + duration - 1; // Alter im letzten Jahr der Phase
ahvItems.push({ ownerId: owner.id, ownerStartAge: ageStart, rente, beitrag, working: ownerWorking, ec });
const reachesRef = ageEnd >= AHV_REFERENCE_AGE;
const startsRetired = ageStart >= AHV_REFERENCE_AGE;
ec.startValue = startsRetired ? rente : 0;
ec.endValue = reachesRef ? rente : 0;
if (startsRetired) {
ec.summary = `Rente ${fmt(rente)}`;
} else if (reachesRef && !ownerWorking) {
ec.summary = `Beitrag ${fmt(beitrag)} → Rente ${fmt(rente)}`;
ec.note = `Rente ab Alter ${AHV_REFERENCE_AGE}; bis dahin Beitrag als Nichterwerbstätige(r).`;
} else if (reachesRef) {
ec.summary = `Rente ab ${AHV_REFERENCE_AGE} ${fmt(rente)}`;
} else if (!ownerWorking) {
ec.summary = `Beitrag ${fmt(beitrag)}`;
ec.note = `Frühpensioniert: beitragspflichtig bis Alter ${AHV_REFERENCE_AGE}.`;
} else {
const gap = Math.max(0, Math.round(num(pd.gapYears)));
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
}
}
break;
}
@@ -606,12 +668,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zählt NICHT zur Quote
const rate = Math.round(inherited("annualContribution")); // PK-Beitrag zählt NICHT zur Quote
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: true, ec });
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal: 0, isPk: true, ec });
}
break;
}
@@ -623,13 +685,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const rate = Math.round(num(pd.annualContribution));
const rate = Math.round(inherited("annualContribution"));
fixedRatesTotal += rate;
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: false, ec });
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal: 0, isPk: false, ec });
}
break;
}
@@ -637,15 +699,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const rate = Math.round(num(pd.annualContribution));
const withdrawal = Math.round(num(pd.annualWithdrawal));
const rate = Math.round(inherited("annualContribution"));
const withdrawal = Math.round(inherited("annualWithdrawal"));
fixedRatesTotal += rate;
plannedWithdrawTotal += withdrawal;
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, isPk: false, ec });
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal, isPk: false, ec });
break;
}
case "REAL_ESTATE": {
@@ -654,7 +716,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice));
const valueStart = carry.hasCarry ? carry.propertyValue : Math.round(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
const amort = Math.round(num(pd.amortization));
const amort = Math.round(inherited("amortization"));
const equity = valueStart - mortgageStart;
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
ec.baseValue = equity;
@@ -666,8 +728,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
purchase,
mortgage: mortgageStart,
amort,
growth: num(pd.valueGrowth),
interestRate: num(pd.interestRate),
growth: inherited("valueGrowth"),
interestRate: inherited("interestRate"),
// Default INCLUDED: bestehende Pläne haben die Zinsen in den Ausgaben -> nicht
// nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu.
addInterest: pd.interestHandling === "ADD",
@@ -677,7 +739,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
case "OTHER_DEBT": {
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
const repay = Math.round(num(pd.annualRepayment));
const repay = Math.round(inherited("annualRepayment"));
ec.baseValue = -owedStart;
ec.startValue = -owedStart;
wealthStart += -owedStart;
@@ -742,8 +804,20 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
interestNominal += re.mortgage * (re.interestRate / 100);
}
const expenseNominal = expenseRealBase * inflFactor + interestNominal;
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
// AHV jahresweise: Die Rente fliesst ab dem Referenzalter -- auch wenn die Person noch
// arbeitet. Vorher zahlt eine bereits pensionierte Person Beiträge, die wie eine
// Ausgabe auf die Quote schlagen (Kap. 4.4.6).
let ahvIncome = 0;
let ahvCost = 0;
for (const a of ahvItems) {
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
if (ageThisYear >= AHV_REFERENCE_AGE) ahvIncome += a.rente;
else if (!a.working) ahvCost += a.beitrag;
}
incomeFlow += ahvIncome;
const expenseNominal = expenseRealBase * inflFactor + interestNominal + ahvCost;
const expenseReal = expenseRealBase + (interestNominal + ahvCost) / (inflFactor || 1);
const quote = incomeFlow - expenseNominal;
// Der Vermögenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
@@ -899,9 +973,16 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
});
}
for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) });
// Renten (AHV, verrentete PK) laufen nominal fix durch die Phase.
// AHV jahresweise: vor dem Referenzalter der Beitrag als Nichterwerbstätige(r) (negativ,
// also als Belastung sichtbar), ab dem Referenzalter die Rente.
for (const a of ahvItems) {
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
const value = ageThisYear >= AHV_REFERENCE_AGE ? a.rente : a.working ? 0 : -a.beitrag;
if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value });
}
// Verrentete PK läuft nominal fix durch die Phase.
for (const ec of ecById.values()) {
if ((ec.category === "AHV" || ec.category === "PENSION_FUND") && ec.startValue > 0 && ec.yearly.length < t) {
if (ec.category === "PENSION_FUND" && ec.startValue > 0 && ec.yearly.length < t) {
ec.yearly.push({ year: yr, age, value: ec.startValue });
}
}
@@ -932,6 +1013,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1) * flowDeflatorEnd);
exp.ec.summary = fmt(exp.ec.startValue);
}
// Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben). Erst
// hier, weil `basis` in der Jahresschleife durch effektive Werte neu gesetzt worden sein kann.
for (const f of [...incomes, ...expenses]) {
f.carry.flowBasis = f.basis * Math.pow(1 + f.idx / 100, duration);
}
for (const a of assets) {
a.ec.endValue = Math.round(a.value);
a.ec.summary = fmt(a.ec.endValue);
@@ -1571,6 +1657,51 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
carry.hasCarry = true;
}
// --- Punkt C (Roadmap Nr. 44): Verwendung des Kapitalzuflusses -----------------------
//
// Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, 3a,
// Immobilienverkauf). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht.
// Die Verwendung wird deshalb als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert
// sich der Betrag -- die Aufteilung skaliert mit, statt still falsch zu werden.
//
// Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die
// Anlage-Quote wie eine Zusatzinvestition. Beide sind schon heute Cash-Abflüsse an der
// Grenze und laufen damit korrekt durch beide Brücken.
if (txInflow > 0) {
const ct = phase.cashTransition ?? {};
const amortPct = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
const investPct = Math.max(0, Math.min(100 - amortPct, num(ct.capitalUseInvestPct)));
if (amortPct > 0 || investPct > 0) {
let amortBudget = Math.round((txInflow * amortPct) / 100);
for (const e of orderedElements) {
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;
}
const investBudget = Math.round((txInflow * investPct) / 100);
if (investBudget > 0) {
const target =
orderedElements.find(
(e) =>
e.id === ct.capitalUseTargetElementId &&
e.category === "OTHER_ASSET" &&
carries.get(e.id)!.status === "ACTIVE"
) ??
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
if (target) {
carries.get(target.id)!.value += investBudget;
txImmediateRepay += investBudget;
}
}
}
}
cashCarryIn = cashEnd + txInflow + txOneOffInflow - txImmediateRepay - txOneOffOutflow;
incomingInflow = txInflow;
incomingImmediateRepay = txImmediateRepay;