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
+32 -12
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types";
// Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen
@@ -16,9 +17,20 @@ function el(
category: ElementCategory,
ownerRole: string | null,
phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {}
transitionValues: Record<string, TransitionData> = {},
// Pensionierungs-Entscheid: haengt seit 0.34 am Element, nicht an einer Phasengrenze.
retirementDecision: RetirementDecision = {}
) {
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
return {
id: nid(),
category,
name: category,
ownerRole: ownerRole as never,
orderIndex: idc,
phaseValues,
transitionValues,
retirementDecision,
};
}
function plan(opts: {
@@ -84,13 +96,15 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } },
{ p1: { payoutMode: "PENSION", conversionRate: 6 } }
{},
{ capitalSharePct: 0, conversionRate: 6 }
),
el(
"PILLAR_3A",
"PERSON_A",
{ p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } },
{ p1: { capitalTaxRate: 8 } }
{},
{ capitalTaxRate: 8 }
),
el("OTHER_ASSET", "HOUSEHOLD", {
p1: { startValue: 300000, expectedReturn: 4 },
@@ -116,7 +130,8 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 8 } }
{},
{ capitalSharePct: 100, capitalTaxRate: 8 }
),
],
}),
@@ -240,7 +255,8 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } }
{},
{ capitalSharePct: 100, capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 }
),
el(
"REAL_ESTATE",
@@ -317,7 +333,7 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", ()
...raw,
elements: raw.elements.map((e) =>
e.category === "PENSION_FUND"
? { ...e, transitionValues: { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } }
? { ...e, retirementDecision: { capitalSharePct: 100, capitalTaxRate: 5 } }
: e
),
} as PlanInput);
@@ -372,7 +388,8 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } }
{},
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }),
],
@@ -414,7 +431,8 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
"PILLAR_3A",
"PERSON_A",
{ p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} },
{ p1: { capitalTaxRate: 0, capitalUseInvestPct: 50 } }
{},
{ capitalTaxRate: 0, capitalUseInvestPct: 50 }
),
],
} as PlanInput;
@@ -444,13 +462,15 @@ describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => {
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 300000, expectedReturn: 0 }, p2: {} },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } }
{},
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
),
el(
"PILLAR_3A",
"PERSON_A",
{ p1: { currentValue: 200000, expectedReturn: 0 }, p2: {} },
{ p1: { capitalTaxRate: 0, capitalUseInvestPct: 100 } }
{},
{ capitalTaxRate: 0, capitalUseInvestPct: 100 }
),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: {} }),
],
@@ -503,6 +523,6 @@ describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => {
: e
),
};
expect(computePlan(mitRate).phases[1].endWealth).toBe(computePlan(base).phases[1].endWealth);
expect(computePlan(mitRate).phases[1].endWealthNominal).toBe(computePlan(base).phases[1].endWealthNominal);
});
});
+22 -11
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types";
// --- kleine Bau-Helfer ---
@@ -11,9 +12,20 @@ function el(
category: ElementCategory,
ownerRole: string | null,
phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {}
transitionValues: Record<string, TransitionData> = {},
// Pensionierungs-Entscheid: hängt seit 0.34 am Element, nicht an einer Phasengrenze.
retirementDecision: RetirementDecision = {}
) {
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
return {
id: nid(),
category,
name: category,
ownerRole: ownerRole as never,
orderIndex: idc,
phaseValues,
transitionValues,
retirementDecision,
};
}
function plan(opts: {
@@ -261,12 +273,11 @@ describe("AHV einkommensabhängig", () => {
"AHV",
"PERSON_A",
{ p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} },
{},
{
p1: {
reviewed: opts.reviewed ?? true,
avgIncomeBefore: opts.avgIncomeBefore ?? 0,
gapYearsBefore: opts.gapYearsBefore ?? 0,
},
confirmed: opts.reviewed ?? true,
avgIncomeBefore: opts.avgIncomeBefore ?? 0,
gapYearsBefore: opts.gapYearsBefore ?? 0,
}
),
],
@@ -356,13 +367,13 @@ describe("AHV einkommensabhängig", () => {
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
});
it("bereits bei Planbeginn pensioniert: Karriere kommt aus der Phasenzelle", () => {
it("bereits bei Planbeginn pensioniert: Karriere kommt aus dem Pensionierungs-Entscheid", () => {
const p = plan({
age: 66,
retirementAge: 65,
inflation: 0,
phases: [{ id: "p1", durationYears: 10 }],
elements: [el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 60000, gapYearsBefore: 0 } })],
elements: [el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 60000, gapYearsBefore: 0 })],
});
const rente = computePlan(p).phases[0].elements.find((e) => e.category === "AHV")!.startValue;
expect(rente).toBe(Math.round(ahvMonthlyFullPension(60000) * 13));
@@ -377,8 +388,8 @@ describe("AHV einkommensabhängig", () => {
],
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
elements: [
el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 100000 } }),
el("AHV", "PERSON_B", { p1: { avgIncomeBefore: 100000 } }),
el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
el("AHV", "PERSON_B", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
],
};
const ph = computePlan(p).phases[0];
+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
+53
View File
@@ -23,6 +23,59 @@ export const AHV_REFERENCE_AGE = 65;
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
// --- Flexibler Rentenbezug (AHV 21) --------------------------------------------------------
//
// Vorbezug: fruehestens 3 Jahre vor dem Referenzalter, seit AHV 21 in MONATSschritten.
// Die Kuerzung ist lebenslang und betraegt 6.8 % pro vorbezogenem Jahr; Monate anteilig.
// Die seit 1.1.2025 tieferen, einkommensabhaengigen Saetze fuer Frauen der
// Uebergangsgeneration (Jahrgaenge 1961-1969) sind BEWUSST nicht abgebildet -- sie laufen
// aus, und ihre Nachbildung erforderte eine zweite, jahrgangsabhaengige Rentenformel.
// Quelle: Merkblatt 3.04 "Flexibler Rentenbezug", Stand 1.1.2026.
export const AHV_EARLY_MAX_MONTHS = 36;
export const AHV_EARLY_REDUCTION_PER_YEAR = 6.8;
// Aufschub: 1 bis 5 Jahre. Der Zuschlag ist NICHT linear -- er steigt ueberproportional,
// weil sich die Bezugsdauer verkuerzt. Amtliche Stuetzwerte je volles Jahr; fuer Monate
// dazwischen wird linear interpoliert (die amtliche Tabelle ist monatsgenau, die Abweichung
// liegt im Zehntelprozent-Bereich und damit weit unter der Unschaerfe der Rentenschaetzung).
export const AHV_DEFER_MIN_MONTHS = 12;
export const AHV_DEFER_MAX_MONTHS = 60;
export const AHV_DEFER_BONUS_BY_YEAR = [0, 5.2, 10.8, 17.1, 24.0, 31.5];
// Teilbezug/Teilaufschub: zwischen 20 und 80 Prozent der Rente.
export const AHV_PARTIAL_MIN_PCT = 20;
export const AHV_PARTIAL_MAX_PCT = 80;
// --- Saeule 3a: Bezugsfenster ---------------------------------------------------------------
//
// Fruehestens 5 Jahre vor dem Referenzalter (Art. 3 BVV3). Spaetestens beim Referenzalter --
// aufschiebbar bis 70, solange eine Erwerbstaetigkeit besteht. Ein Konto laesst sich bei der
// Pensionierung nur GANZ aufloesen; gestaffelt wird ueber mehrere Konten.
export const PILLAR_3A_MIN_WITHDRAWAL_AGE = AHV_REFERENCE_AGE - 5;
export const PILLAR_3A_MAX_WITHDRAWAL_AGE = AHV_REFERENCE_AGE + 5;
// --- Pensionskasse: Bezugsfenster -----------------------------------------------------------
//
// Gesetzliches Mindestalter fuer die vorzeitige Pensionierung (Art. 1i BVV2). Viele Reglemente
// setzen erst bei 60 an -- deshalb Hinweis statt Sperre.
export const PK_MIN_RETIREMENT_AGE = 58;
export const PK_MAX_RETIREMENT_AGE = AHV_REFERENCE_AGE + 5;
// Sperrfrist nach einem Einkauf, innerhalb derer ein Kapitalbezug den Steuerabzug
// nachtraeglich entfallen laesst (Art. 79b Abs. 3 BVG). Reines Hinweis-Mass: Das Tool kennt
// keine Einkaeufe und kann die Frist deshalb nicht selbst pruefen.
export const PK_BUYIN_BLOCKING_YEARS = 3;
// --- Planungshorizont -----------------------------------------------------------------------
//
// Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus der
// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen
// und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu
// kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss.
export const DEFAULT_PLANNING_HORIZON_AGE = 90;
export const MIN_PLANNING_HORIZON_AGE = 70;
export const MAX_PLANNING_HORIZON_AGE = 110;
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
//
+231
View File
@@ -0,0 +1,231 @@
// Der Pensionierungs-Entscheid je Vorsorge-Element.
//
// WARUM EIN EIGENER SPEICHERORT UND NICHT `transitionValues`?
//
// Bis 0.34 hing jeder dieser Entscheide an `transitionValues[phaseId]` -- also am Schluessel
// Element x Phasen-ID. Das war der Grund fuer eine ganze Reihe von Aergernissen: Verschob man
// das Pensionsalter, wanderte die Phasengrenze, und die Entscheide mussten ueber
// `mergeTransition` von Grenze zu Grenze gerettet werden -- verlustbehaftet. Ein Szenario nur
// wegen eines anderen Pensionsalters aufzusetzen hiess, alle Entscheide erneut zu treffen.
// Und der Ziel-Solver (Roadmap Nr. 21) haette bei jedem Kandidatenalter die Phasenstruktur
// umbauen und die Entscheide neu einsammeln muessen.
//
// Ohne Phasen-ID im Schluessel ueberlebt der Entscheid JEDE Zeitachsen-Aenderung. Die
// Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse -- die Zeitachse ist die
// Folge davon.
//
// Was hier NICHT hineingehoert: Vorbezuege (PK/3a vor der Pensionierung), Verkaeufe,
// Tilgungen, der Cash-Uebergang. Das sind echte Ereignisse an einer bestimmten Phasengrenze
// und bleiben in `transitionValues`.
import { z } from "zod";
import {
AHV_DEFER_BONUS_BY_YEAR,
AHV_DEFER_MAX_MONTHS,
AHV_EARLY_MAX_MONTHS,
AHV_EARLY_REDUCTION_PER_YEAR,
AHV_PARTIAL_MIN_PCT,
AHV_REFERENCE_AGE,
DEFAULT_CAPITAL_TAX_RATE,
DEFAULT_PK_CONVERSION_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
} from "@/lib/constants";
import type { ElementCategory } from "@/lib/elements";
export type AhvDraw = "EARLY" | "REFERENCE" | "DEFERRED";
export interface RetirementDecision {
// Hat die Person hingeschaut? Fehlt das Haekchen, rechnet das Tool mit einer Vorgabe --
// ein dritter Zustand neben "unbeantwortet" und "beantwortet" (SPEZIFIKATION 3.5.3).
confirmed?: boolean;
// --- AHV -------------------------------------------------------------------------------
ahvDraw?: AhvDraw;
// Monate des Vorbezugs bzw. des Aufschubs. Nur bei EARLY/DEFERRED von Bedeutung.
ahvMonths?: number;
// Teilbezug/Teilaufschub: Anteil der Rente, der vorbezogen bzw. aufgeschoben wird.
ahvSharePct?: number;
// Beitragskarriere vor Planbeginn. Keine Entscheidung, sondern Datenqualitaet -- deshalb
// im UI abgesetzt dargestellt. avgIncomeBefore ist REAL (heutige Kaufkraft) und BRUTTO.
avgIncomeBefore?: number;
gapYearsBefore?: number;
// --- Pensionskasse ---------------------------------------------------------------------
// Anteil des Altersguthabens, der als Kapital bezogen wird. EIN Regler statt des frueheren
// Modus PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 = volle Rente, 100 = volles Kapital,
// alles dazwischen ist die Kombination. Als Quote und nicht in Franken, weil sich das
// Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch.
capitalSharePct?: number;
conversionRate?: number;
// Reines Hinweis-Flag: Ein Kapitalbezug innerhalb von drei Jahren nach einem Einkauf laesst
// den Steuerabzug nachtraeglich entfallen (Art. 79b Abs. 3 BVG). Das Tool kennt keine
// Einkaeufe und kann das nicht selbst pruefen -- deshalb die Frage statt einer Automatik.
recentBuyIn?: boolean;
// --- Saeule 3a -------------------------------------------------------------------------
// Alter, in dem dieses Konto aufgeloest wird. Ein 3a-Konto laesst sich bei der
// Pensionierung nur GANZ aufloesen -- gestaffelt wird ueber mehrere Konten, und genau
// dafuer gibt es dieses Feld. Der Bezug erfolgt an der Phasengrenze bei oder nach diesem
// Alter (siehe `effectiveWithdrawalAge`).
withdrawalAge?: number;
// --- gemeinsam: Kapitalbezug und seine Verwendung ---------------------------------------
capitalTaxRate?: number;
capitalUseAmortizationPct?: number;
capitalUseInvestPct?: number;
capitalUseTargetElementId?: string;
}
const pct = z.number().min(0).max(100);
export const retirementDecisionSchema = z
.object({
confirmed: z.boolean().optional(),
ahvDraw: z.enum(["EARLY", "REFERENCE", "DEFERRED"]).optional(),
ahvMonths: z.number().int().min(0).max(AHV_DEFER_MAX_MONTHS).optional(),
ahvSharePct: z.number().min(AHV_PARTIAL_MIN_PCT).max(100).optional(),
avgIncomeBefore: z.number().min(0).optional(),
gapYearsBefore: z.number().int().min(0).max(50).optional(),
capitalSharePct: pct.optional(),
conversionRate: z.number().min(0).max(20).optional(),
recentBuyIn: z.boolean().optional(),
withdrawalAge: z
.number()
.int()
.min(PILLAR_3A_MIN_WITHDRAWAL_AGE)
.max(PILLAR_3A_MAX_WITHDRAWAL_AGE)
.optional(),
capitalTaxRate: z.number().min(0).max(100).optional(),
capitalUseAmortizationPct: pct.optional(),
capitalUseInvestPct: pct.optional(),
capitalUseTargetElementId: z.string().max(60).optional(),
})
.strict();
// Kategorien, die ueberhaupt einen Pensionierungs-Entscheid kennen.
export const RETIREMENT_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"];
// --- Vorgaben -------------------------------------------------------------------------------
//
// Zentrales Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollstaendiger Vorschlag,
// den man korrigiert. Nur so ist der Plan ab der ersten Sekunde rechenbar -- und nur so muss
// niemand am Anfang Fragen beantworten, die er erst am Ende beantworten kann.
export function withRetirementDefaults(
category: ElementCategory,
retirementAge: number,
rd: RetirementDecision | null | undefined
): RetirementDecision {
const d: RetirementDecision = { ...(rd ?? {}) };
if (category === "AHV") {
d.ahvDraw ??= "REFERENCE";
d.ahvSharePct ??= 100;
} else if (category === "PENSION_FUND") {
// Volle Rente. Das ist die Vorgabe, weil sie die Regel ist -- ein Kapitalbezug ist der
// begruendungspflichtige Fall, nicht umgekehrt.
d.capitalSharePct ??= 0;
d.conversionRate ??= DEFAULT_PK_CONVERSION_RATE;
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
} else if (category === "PILLAR_3A") {
// Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58
// aufhoert, kann die 3a trotzdem erst mit 60 beziehen.
d.withdrawalAge ??= clamp(
retirementAge,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE
);
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
}
return d;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// --- AHV: Rentenbeginn und Faktor -----------------------------------------------------------
// Alter, ab dem die Rente tatsaechlich fliesst. Bis 0.33 war das immer das Referenzalter --
// wer mit 62 aufhoerte, bekam die ungekuerzte Rente drei Jahre spaeter, wer bis 68 arbeitete,
// verschenkte den Zuschlag. Beides war schlicht falsch.
export function ahvStartAge(rd: RetirementDecision): number {
const months = ahvShiftMonths(rd);
return AHV_REFERENCE_AGE + months / 12;
}
// Verschiebung in Monaten gegenueber dem Referenzalter: negativ = Vorbezug, positiv = Aufschub.
export function ahvShiftMonths(rd: RetirementDecision): number {
const raw = Math.round(Math.max(0, rd.ahvMonths ?? 0));
if (rd.ahvDraw === "EARLY") return -Math.min(raw, AHV_EARLY_MAX_MONTHS);
if (rd.ahvDraw === "DEFERRED") return Math.min(raw, AHV_DEFER_MAX_MONTHS);
return 0;
}
// Faktor auf die Rente. 1 = unveraendert, < 1 = gekuerzt (Vorbezug), > 1 = erhoeht (Aufschub).
//
// Beim Teilbezug wirkt die Kuerzung bzw. der Zuschlag NUR auf den vorbezogenen/aufgeschobenen
// Anteil -- der Rest laeuft ungekuerzt ab dem Referenzalter. Das Tool bildet das vereinfacht
// ab, indem es den gewichteten Mischfaktor bildet: Der Zeitversatz des Restanteils wird nicht
// eigens modelliert, weil das eine zweite Rentenlinie mit eigenem Startjahr erforderte.
export function ahvFactor(rd: RetirementDecision): number {
const months = ahvShiftMonths(rd);
if (months === 0) return 1;
const share = clamp(rd.ahvSharePct ?? 100, AHV_PARTIAL_MIN_PCT, 100) / 100;
const adjust = months < 0 ? earlyFactor(-months) : deferFactor(months);
return share * adjust + (1 - share);
}
function earlyFactor(months: number): number {
// Linear in den Monaten -- die Kuerzung ist gesetzlich ein Jahressatz, monatlich anteilig.
return 1 - (months / 12) * (AHV_EARLY_REDUCTION_PER_YEAR / 100);
}
function deferFactor(months: number): number {
// Die amtlichen Stuetzwerte gelten je volles Jahr; dazwischen wird linear interpoliert.
const years = months / 12;
const lo = Math.floor(years);
const hi = Math.min(AHV_DEFER_BONUS_BY_YEAR.length - 1, lo + 1);
const a = AHV_DEFER_BONUS_BY_YEAR[Math.min(lo, AHV_DEFER_BONUS_BY_YEAR.length - 1)];
const b = AHV_DEFER_BONUS_BY_YEAR[hi];
const bonus = a + (b - a) * (years - lo);
return 1 + bonus / 100;
}
// Lesbare Kurzfassung fuer Matrix-Zelle und Zusammenfassung.
export function ahvDrawLabel(rd: RetirementDecision): string {
const months = ahvShiftMonths(rd);
if (months === 0) return "Referenzalter";
const abs = Math.abs(months);
const years = Math.floor(abs / 12);
const rest = abs % 12;
const dauer = [years > 0 ? `${years} J.` : "", rest > 0 ? `${rest} Mt.` : ""]
.filter(Boolean)
.join(" ");
const delta = Math.round((ahvFactor(rd) - 1) * 1000) / 10;
return `${months < 0 ? "Vorbezug" : "Aufschub"} ${dauer} (${delta > 0 ? "+" : ""}${delta} %)`;
}
// --- Saeule 3a: das Bezugsalter --------------------------------------------------------------
// Der Bezug erfolgt an einer PHASENGRENZE -- innerhalb einer Phase kennt das Modell kein
// Einzelereignis. Gewaehlt wird deshalb die erste Grenze bei oder nach dem gewuenschten Alter.
// Wer exakt staffeln will, setzt eine Phasengrenze; das ist in FPT ohnehin die Art, wie man
// Zeitpunkte modelliert.
export function effectiveWithdrawalAge(
rd: RetirementDecision,
retirementAge: number,
boundaryAges: number[]
): number {
const wish = clamp(
Math.round(rd.withdrawalAge ?? retirementAge),
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE
);
const hit = boundaryAges.find((a) => a >= wish);
// Ohne passende Grenze (Wunsch liegt hinter dem Planende) gilt die letzte verfuegbare.
return hit ?? boundaryAges[boundaryAges.length - 1] ?? wish;
}
+52
View File
@@ -47,6 +47,58 @@ export function sortedPhases(plan: PlanInput) {
return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
}
// --- Planungshorizont -----------------------------------------------------------------------
//
// Bis 0.33 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien
// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar,
// obwohl genau das ihr Zweck ist. Neu ist der Horizont eine erfasste Zahl, und die LETZTE
// Phase folgt ihr (dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt).
//
// Referenzperson ist die aelteste: Sie erreicht ihren Horizont zuerst, und der Plan muss so
// lange laufen, bis die LETZTE Person ihren erreicht hat.
export function planEndYear(plan: PlanInput): number | null {
const years = plan.persons
.filter((p) => typeof p.planningHorizonAge === "number")
.map((p) => (p.planningHorizonAge as number) - p.age);
return years.length > 0 ? Math.max(...years) : null;
}
export interface HorizonChange {
lastPhaseId: string;
oldDuration: number;
newDuration: number;
blocked: string | null;
}
// Was muesste an der letzten Phase geschehen, damit der Plan bis zum Horizont laeuft?
export function planHorizonChange(plan: PlanInput, horizonAge: number, role: PersonRole): HorizonChange | null {
const phases = sortedPhases(plan);
const last = phases[phases.length - 1];
if (!last) return null;
const person = plan.persons.find((p) => p.role === role);
if (!person) return null;
// Zielgesamtdauer aus SICHT DIESER Person; die uebrigen Horizonte bleiben unberuehrt und
// koennen laenger sein -- deshalb das Maximum ueber alle.
const wish = Math.max(
horizonAge - person.age,
...plan.persons.filter((p) => p.role !== role && typeof p.planningHorizonAge === "number")
.map((p) => (p.planningHorizonAge as number) - p.age)
);
const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0);
const newDuration = wish - before;
const change: HorizonChange = {
lastPhaseId: last.id,
oldDuration: last.durationYears,
newDuration,
blocked: null,
};
if (newDuration < 1) {
change.blocked = `Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern kürze zuerst eine frühere Phase.`;
}
return change;
}
// Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt.
export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] {
const phases = sortedPhases(plan);
+7
View File
@@ -4,6 +4,7 @@
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B";
@@ -14,6 +15,9 @@ export interface PersonInput {
name: string | null;
age: number;
retirementAge: number;
// Bis zu welchem Alter gerechnet wird. Fehlt der Wert, ergibt sich das Planende wie bisher
// aus der Summe der Phasendauern -- deshalb optional und nicht mit Default belegt.
planningHorizonAge?: number | null;
}
export interface PhaseInput {
@@ -36,6 +40,9 @@ export interface ElementInput {
// Werte je Phase (Key = phaseId) bzw. je Übergang (Key = fromPhaseId).
phaseValues: Record<string, PhaseData>;
transitionValues: Record<string, TransitionData>;
// Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug --
// er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt.
retirementDecision?: RetirementDecision | null;
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourceElementId?: string | null;
}