Pensionierung: Entscheid ans Element, AHV-Vorbezug/Aufschub, PK-Kapitalquote

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:18:38 +02:00
parent 7979fed7bd
commit 9907fda6f4
9 changed files with 660 additions and 103 deletions
+237 -80
View File
@@ -10,11 +10,21 @@ import {
DEFAULT_CAPITAL_TAX_RATE,
DEFAULT_PK_CONVERSION_RATE,
DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
} from "@/lib/constants";
import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import type { ElementCategory, TransitionData } from "@/lib/elements";
import type { PersonRole, PlanInput } from "@/lib/types";
import {
ahvDrawLabel,
ahvFactor,
ahvShiftMonths,
ahvStartAge,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import type { ElementCategory } from "@/lib/elements";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
@@ -215,10 +225,37 @@ export interface YearPoint {
cash: number;
}
// Kennzahlen des Pensionierungs-Bildschirms. Bewusst im Rechenkern und nicht im UI: Es sind
// die beiden Zahlen, an denen die ganze Planung haengt -- eine Nebenrechnung in der
// Komponente wuerde im PDF-Bericht anders ausfallen.
export interface RetirementPersonSummary {
personId: string;
role: PersonRole;
retirementAge: number;
planningHorizonAge: number | null;
ahvAnnual: number; // inkl. Kuerzung bei Vorbezug bzw. Zuschlag bei Aufschub
ahvFromAge: number; // individuelles Rentenalter, nicht zwingend das Referenzalter
ahvDraw: string; // lesbare Kurzfassung ("Referenzalter", "Vorbezug 2 J. (-13.6 %)")
pkPensionAnnual: number;
capitalAtRetirement: number; // PK-Kapital + Saeule 3a, NETTO nach Kapitalbezugssteuer
}
export interface RetirementOverview {
// Planjahr, ab dem NIEMAND mehr erwerbstaetig ist. null, solange jemand arbeitet.
firstRetirementYear: number | null;
pensionIncome: number | null;
expenses: number | null;
// Renteneinkommen minus Ausgaben im ersten voll pensionierten Jahr. Negativ = Luecke, die
// aus dem Vermoegen zu decken ist.
gapAnnual: number | null;
perPerson: RetirementPersonSummary[];
}
export interface PlanComputed {
phases: PhaseComputed[];
yearly: YearPoint[];
nachlass: number;
retirement: RetirementOverview;
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermögen (inkl. Cash) erstmals < 0 fällt
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (für die AHV-Prüfung am Übergang)
traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain)
@@ -405,11 +442,32 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
const ahvIncomeAccum = new Map<string, number>();
const ahvYearsAccum = new Map<string, number>();
// Karriere VOR Planbeginn -- aus der Prüfung am Pensions-Übergang bzw. (für bereits
// Pensionierte) aus der Phasenzelle der ersten Phase.
const ahvBeforeByPerson = new Map<string, { avg: number; gap: number }>();
// Karriere VOR Planbeginn und der Bezugs-Entscheid, je Person. Beides steht seit 0.34 im
// Pensionierungs-Entscheid des AHV-Elements -- vorher lag es an ZWEI Orten (Übergangszelle
// bzw., für bei Planbeginn bereits Pensionierte, Phasenzelle der ersten Phase), was zwei
// Codepfade für dieselbe Frage bedeutete.
// avg === null heisst NICHT ERFASST -- dann gilt der geplante Durchschnitt als Schaetzung
// (siehe unten). Ein Fallback auf 0 wuerde die Rente still und massiv zu tief rechnen.
const ahvBeforeByPerson = new Map<string, { avg: number | null; gap: number }>();
const ahvDrawByPerson = new Map<string, RetirementDecision>();
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole);
if (!owner) continue;
const rd = withRetirementDefaults("AHV", retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE, e.retirementDecision);
ahvDrawByPerson.set(owner.id, rd);
ahvBeforeByPerson.set(owner.id, {
avg: typeof rd.avgIncomeBefore === "number" ? rd.avgIncomeBefore : null,
gap: Math.max(0, Math.round(num(rd.gapYearsBefore))),
});
}
const carries = new Map<string, Carry>();
for (const e of plan.elements) carries.set(e.id, emptyCarry());
// Fuer die Pensionierungs-Uebersicht: die zuletzt gerechnete AHV-Rente je Person und das
// netto bezogene Alterskapital (PK + 3a). Beides faellt in der Phasen- bzw. Uebergangs-
// schleife ohnehin an -- gesammelt wird es hier, damit es am Ende zur Verfuegung steht.
const ahvFinalByPerson = new Map<string, number>();
const capitalNetByPerson = new Map<string, number>();
const result: PhaseComputed[] = [];
const yearly: YearPoint[] = [];
@@ -471,20 +529,6 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
}
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Übergang, an dem die
// Beitragskarriere geprüft werden könnte -- die Werte liegen dann in der Phasenzelle.
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 (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Übergang bereits gesetzt
const pd = e.phaseValues[phase.id] ?? {};
ahvBeforeByPerson.set(owner.id, {
avg: num(pd.avgIncomeBefore),
gap: Math.max(0, Math.round(num(pd.gapYearsBefore))),
});
}
// 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
@@ -496,9 +540,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole);
if (!owner) continue;
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 };
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 };
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
const mdJE = ahvMdje(career, before.avg, before.gap);
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre vor
// Planbeginn -- exakt der Wert, den das UI vorbelegt. Beide Groessen sind BRUTTO.
const avgBefore = before.avg ?? career.plannedAvgGrossIncome;
const mdJE = ahvMdje(career, avgBefore, before.gap);
const totalGap = (gapYearsByPerson.get(owner.id) ?? 0) + before.gap;
ahvUncapped.set(owner.id, ahvAnnualPension(mdJE, totalGap));
}
@@ -508,6 +555,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
}
// Vorbezug kürzt, Aufschub erhöht -- und zwar NACH dem Ehepaar-Plafond: Der Plafond gilt
// für die ordentlichen Renten, die individuelle Kürzung setzt darauf auf.
for (const [pid, v] of [...ahvFinal]) {
const f = ahvFactor(ahvDrawByPerson.get(pid) ?? {});
if (f !== 1) ahvFinal.set(pid, Math.round(v * f));
}
for (const [pid, v] of ahvFinal) ahvFinalByPerson.set(pid, v);
// --- Element-Laufzeitzustände aufbauen ---
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
@@ -522,6 +576,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const ahvItems: {
ownerId: string;
ownerStartAge: number;
// Alter, ab dem die Rente DIESER Person fliesst. Bis 0.33 war das fix das
// Referenzalter; mit Vorbezug/Aufschub ist es individuell. Für das Jahresraster
// gerundet -- die Monatsgenauigkeit steckt im Faktor, nicht im Auszahlungszeitpunkt.
renteFromAge: number;
rente: number;
beitrag: number;
working: boolean;
@@ -661,29 +719,46 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (owner) {
const rente = ahvFinal.get(owner.id) ?? 0;
const beitrag = Math.round(num(pd.ahvContribution));
const rd = ahvDrawByPerson.get(owner.id) ?? {};
// Rentenbeginn individuell (Vorbezug/Aufschub), Beitragspflicht dagegen IMMER bis
// zum Referenzalter: Wer die Rente vorbezieht, ist damit nicht von den Beiträgen
// als Nichterwerbstätige(r) befreit -- die beiden Alter sind zu trennen.
const renteFromAge = Math.round(ahvStartAge(rd));
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 });
ahvItems.push({
ownerId: owner.id,
ownerStartAge: ageStart,
renteFromAge,
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;
const reachesRente = ageEnd >= renteFromAge;
const startsWithRente = ageStart >= renteFromAge;
const owesContribution = !ownerWorking && ageStart < AHV_REFERENCE_AGE;
ec.startValue = startsWithRente ? rente : 0;
ec.endValue = reachesRente ? rente : 0;
if (startsRetired) {
if (startsWithRente) {
ec.summary = `Rente ${fmt(rente)}`;
} else if (reachesRef && !ownerWorking) {
} else if (reachesRente && owesContribution) {
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.note = `Rente ab Alter ${renteFromAge}; bis Alter ${AHV_REFERENCE_AGE} Beitrag als Nichterwerbstätige(r).`;
} else if (reachesRente) {
ec.summary = `Rente ab ${renteFromAge} ${fmt(rente)}`;
} else if (owesContribution) {
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";
}
if (ahvShiftMonths(rd) !== 0) {
ec.note = [ec.note, `AHV: ${ahvDrawLabel(rd)}.`].filter(Boolean).join(" ");
}
}
break;
}
@@ -847,15 +922,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
interestNominal += re.mortgage * (re.interestRate / 100);
}
// 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).
// AHV jahresweise: Die Rente fliesst ab dem individuellen Rentenalter -- auch wenn die
// Person noch arbeitet. Unabhängig davon zahlt eine nicht (mehr) erwerbstätige Person
// bis zum REFERENZALTER Beiträge, die wie eine Ausgabe auf die Quote schlagen
// (Kap. 4.4.6). Beide Bedingungen können gleichzeitig gelten: Wer mit 62 aufhört und
// die Rente ab 63 vorbezieht, bezieht ab 63 und zahlt bis 65.
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;
if (ageThisYear >= a.renteFromAge) ahvIncome += a.rente;
if (!a.working && ageThisYear < AHV_REFERENCE_AGE) ahvCost += a.beitrag;
}
incomeFlow += ahvIncome;
@@ -1020,8 +1097,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// 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 });
const rente = ageThisYear >= a.renteFromAge ? a.rente : 0;
const beitrag = !a.working && ageThisYear < AHV_REFERENCE_AGE ? a.beitrag : 0;
if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value: rente - beitrag });
}
// Verrentete PK läuft nominal fix durch die Phase.
for (const ec of ecById.values()) {
@@ -1229,9 +1307,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (e.category === "AHV") {
const owner = e.ownerRole ? personByRole(persons, e.ownerRole) : null;
const gap = owner ? gapYearsByPerson.get(owner.id) ?? 0 : 0;
const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 } : { avg: 0, gap: 0 };
const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 } : { avg: null, gap: 0 };
const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null;
const mdJE = career ? ahvMdje(career, before.avg, before.gap) : 0;
const avgBefore = before.avg ?? career?.plannedAvgGrossIncome ?? 0;
const mdJE = career ? ahvMdje(career, avgBefore, before.gap) : 0;
ec.trace = {
title: `AHV-Rente «${ec.name}»`,
specAnchor: "44-ahv-rente",
@@ -1456,7 +1535,32 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let txImmediateRepay = 0;
// Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND
// der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt.
const capitalUses: { net: number; td: TransitionData; sourceName: string }[] = [];
const capitalUses: { net: number; td: RetirementDecision; sourceName: string }[] = [];
// Zieht dieses 3a-Konto an DIESER Phasengrenze? Ein Konto lässt sich nur ganz auflösen,
// und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb
// an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem
// Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt.
const drawsPillar3aHere = (
el: ElementInput,
ownerPerson: { id: string; age: number } | null,
ageAtBoundary: number
): boolean => {
// 3a ist personengebunden -- ohne Besitzer gibt es kein Bezugsalter und keinen Bezug.
if (!ownerPerson) return false;
const rd = withRetirementDefaults(
"PILLAR_3A",
retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE,
el.retirementDecision
);
const wish = Math.max(
PILLAR_3A_MIN_WITHDRAWAL_AGE,
Math.min(PILLAR_3A_MAX_WITHDRAWAL_AGE, Math.round(num(rd.withdrawalAge, ageAtBoundary)))
);
const prevBoundaryAge = ownerPerson.age + yearsBefore;
if (prevBoundaryAge >= wish) return false; // in einer früheren Phase bereits gezogen
return ageAtBoundary >= wish || !nextPhase;
};
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
// Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
@@ -1496,6 +1600,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const ownerRetiresNext =
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true &&
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration);
// Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für
// die Vorgaben des Pensionierungs-Entscheids und für das 3a-Bezugsalter.
const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE;
const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
// Einkommen/Ausgaben: Basiswert wurde bereits im Element-Setup fortgeschrieben.
if (e.category === "INCOME" || e.category === "EXPENSE") {
@@ -1503,18 +1611,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
continue;
}
// AHV: beim Pensions-Übergang die geprüften Karriere-Werte vor Planbeginn übernehmen.
// AHV: Der Bezugs-Entscheid hängt seit 0.34 am Element, nicht am Übergang -- hier ist
// deshalb nichts mehr zu tun.
if (e.category === "AHV") {
if (ownerRetiresNext && owner) {
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
ahvBeforeByPerson.set(owner.id, {
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schätzung für die Jahre
// vor Planbeginn -- exakt der Wert, den der Prüf-Dialog vorbelegt. Ein Fallback auf
// 0 würde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome),
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
});
}
carry.hasCarry = true;
continue;
}
@@ -1530,29 +1629,26 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
switch (e.category) {
case "PENSION_FUND": {
if (ownerRetiresNext) {
// EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente,
// 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil
// sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim
// Verschieben des Alters still ein anderes Verhältnis bedeuten.
const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") {
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += value - net;
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) });
carry.value = 0;
carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") {
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value;
carry.value = 0;
} else {
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const rd = withRetirementDefaults("PENSION_FUND", ownerRetirementAge, e.retirementDecision);
const sharePct = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
const capital = Math.round((value * sharePct) / 100);
const rest = value - capital;
if (capital > 0) {
const net = Math.round(capital * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += capital - net;
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) });
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value - capital;
carry.value = 0;
capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) });
if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net);
}
carry.pkPensionAnnual =
rest > 0 ? Math.round((rest * num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100) : 0;
txPensionConversion += rest;
carry.value = 0;
} else {
// Vorbezug (z. B. Wohneigentum/Selbstständigkeit): ebenfalls kapitalbezugssteuerpflichtig.
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
@@ -1565,11 +1661,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
break;
}
case "PILLAR_3A": {
if (ownerRetiresNext) {
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
// Der Bezug hängt seit 0.34 am gewählten Alter, nicht mehr starr am Pensions-
// Übergang: Ein 3a-Konto lässt sich nur GANZ auflösen, und alle Bezüge desselben
// Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten
// und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter.
if (drawsPillar3aHere(e, owner, ownerAgeAtBoundary)) {
const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision);
const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += ec.endValue - net;
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) });
capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) });
if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net);
carry.value = 0;
} else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
@@ -1794,6 +1896,58 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson));
// --- Pensionierung: die Kennzahlen des gleichnamigen Bildschirms -------------------------
//
// Bewusst HIER und nicht im UI: Rentenlücke und Reichweite sind die beiden Zahlen, an denen
// die ganze Planung hängt. Eine Nebenrechnung in der Komponente hätte dieselbe Driftgefahr
// wie bei den Rechenwegen (Kap. 4.14.3) -- und würde im PDF-Bericht anders ausfallen.
//
// Die Rentenlücke ist KEINE neue Grösse: Sie ist die Verzehrquote im ersten Jahr, in dem
// niemand mehr arbeitet -- Renteneinkommen minus Ausgaben. Genau das rechnet die
// Jahresschleife ohnehin, es fehlte nur der Name dafür.
const retirement = ((): RetirementOverview => {
// Startjahr je Phase (1-basiert) -- PhaseComputed fuehrt nur die Dauer.
const startYearOf = new Map<string, number>();
let acc = 1;
for (const r of result) {
startYearOf.set(r.id, acc);
acc += r.durationYears;
}
const firstRetiredPhase = result.find((p) => p.persons.every((x) => !x.working));
const perPerson = persons.map((p) => {
// Die erste Phase, in der DIESE Person nicht mehr arbeitet -- dort stehen ihre Renten.
const ph = result.find((r) => r.persons.find((x) => x.role === p.role && !x.working));
const own = (cat: ElementCategory) =>
ph?.elements.find((e) => e.category === cat && e.ownerRole === p.role);
const rd = ahvDrawByPerson.get(p.id) ?? {};
return {
personId: p.id,
role: p.role,
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
planningHorizonAge: p.planningHorizonAge ?? null,
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
ahvFromAge: Math.round(ahvStartAge(rd)),
ahvDraw: ahvDrawLabel(rd),
pkPensionAnnual: Math.round(own("PENSION_FUND")?.startValue ?? 0),
// Einmalig verfügbares Alterskapital: netto, nach Kapitalbezugssteuer.
capitalAtRetirement: Math.round(capitalNetByPerson.get(p.id) ?? 0),
};
});
if (!firstRetiredPhase) {
return { firstRetirementYear: null, pensionIncome: null, expenses: null, gapAnnual: null, perPerson };
}
// Erstes Jahr dieser Phase: Einkommen und Ausgaben stehen im Jahrespunkt.
const firstYear = startYearOf.get(firstRetiredPhase.id) ?? 1;
const yp = yearly.find((y) => y.year === firstYear);
return {
firstRetirementYear: firstYear,
pensionIncome: yp ? Math.round(yp.income) : null,
expenses: yp ? Math.round(yp.expenseNominal) : null,
gapAnnual: yp ? Math.round(yp.income - yp.expenseNominal) : null,
perPerson,
};
})();
// --- Plan-weite Rechenwege ---
const planTraces: Trace[] = [];
if (explain) {
@@ -1810,8 +1964,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
});
for (const career of ahvCareer) {
const before = ahvBeforeByPerson.get(career.personId) ?? { avg: 0, gap: 0 };
const mdJE = ahvMdje(career, before.avg, before.gap);
const before = ahvBeforeByPerson.get(career.personId) ?? { avg: null, gap: 0 };
const avgBefore = before.avg ?? career.plannedAvgGrossIncome;
const mdJE = ahvMdje(career, avgBefore, before.gap);
planTraces.push({
title: `AHV-Beitragskarriere ${career.role === "PERSON_A" ? "Person A" : "Person B"}`,
specAnchor: "442-beitragskarriere-und-mdje",
@@ -1820,8 +1975,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
st("Durchschnittliches Bruttöinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
"Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."),
st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn 21 Ausfalljahre davor", undefined, undefined, "Jahre"),
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined,
before.avg === 0 && career.yearsBeforePlan > 0 ? "Noch nicht erfasst am Pensions-Übergang zu prüfen." : "Aus der AHV-Rentenvorausberechnung übernommen."),
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(avgBefore), undefined, undefined,
before.avg === null && career.yearsBeforePlan > 0
? "Nicht erfasst geschätzt aus dem geplanten Durchschnitt. Im Pensionierungs-Bildschirm zu prüfen."
: "Aus der AHV-Rentenvorausberechnung übernommen."),
st("Massgebendes durchschnittliches Jahreseinkommen", Math.round(mdJE),
"(Einkommen davor × Jahre davor + Einkommen im Plan × Jahre im Plan) / Total Jahre", undefined,
"REAL gerechnet: die echte AHV wertet vergangene Einkommen auf UND indexiert die Schwellen beides hebt sich real weitgehend auf."),
@@ -1841,7 +1998,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
});
}
return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
return { phases: result, yearly, nachlass, retirement, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
}
// Durchschnittliches REALES Jahreseinkommen über eine Phase. Nominal wächst der Flow mit