Fundament: Element-Stammdaten, Fixpunkte, Horizont in Jahren, PK-Bezugsalter

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:06:04 +02:00
parent cc61a120ee
commit 2f762175d2
17 changed files with 515 additions and 146 deletions
+98 -43
View File
@@ -12,9 +12,12 @@ import {
DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_MAX_RETIREMENT_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants";
import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import { planFixpoints, type Fixpoint, type FixpointPerson } from "@/lib/phaseplan";
import {
ahvDrawLabel,
ahvFactor,
@@ -23,7 +26,7 @@ import {
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import type { ElementCategory } from "@/lib/elements";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
@@ -317,16 +320,49 @@ export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
}
// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt).
// Alle Fixpunkte eines Plans als Personen-Sicht für `phaseplan`. Bündelt an EINER Stelle,
// welche Ereignisse eine Phasengrenze erzwingen -- Erwerbsende plus die drei Bezugsbeginne.
export function fixpointPersonsOf(plan: PlanInput): FixpointPerson[] {
return plan.persons.map((p) => {
const own = (cat: ElementCategory) =>
plan.elements.filter((e) => e.category === cat && e.ownerRole === p.role);
const ahvEl = own("AHV")[0];
const pkEl = own("PENSION_FUND")[0];
const rdAhv = ahvEl ? withRetirementDefaults("AHV", p.retirementAge, ahvEl.retirementDecision) : null;
const rdPk = pkEl ? withRetirementDefaults("PENSION_FUND", p.retirementAge, pkEl.retirementDecision) : null;
return {
role: p.role,
name: p.name,
age: p.age,
retirementAge: p.retirementAge,
ahvStartAge: rdAhv ? Math.round(ahvStartAge(rdAhv)) : undefined,
pkWithdrawalAge: rdPk?.pkWithdrawalAge,
pillar3aAges: own("PILLAR_3A")
.map((e) => withRetirementDefaults("PILLAR_3A", p.retirementAge, e.retirementDecision).withdrawalAge)
.filter((x): x is number => typeof x === "number"),
};
});
}
// Maximale Dauer einer neuen Phase bis zum nächsten FIXPUNKT (null = unbegrenzt).
//
// Bis 0.35 zählte nur das Erwerbsende. Seit auch die Bezugsbeginne von AHV, PK und 3a eigene
// Zeitpunkte haben, muss die Kappung sie mitzählen: Fiele ein Bezug mitten in eine Phase,
// würde er auf die nächste Grenze rutschen -- unter Umständen Jahre später.
export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[],
yearsBefore: number
yearsBefore: number,
extraFixpoints: Fixpoint[] = []
): number | null {
const caps: number[] = [];
for (const p of persons) {
const startAge = p.age + yearsBefore;
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
}
for (const f of extraFixpoints) {
const d = f.year - yearsBefore;
if (d > 0) caps.push(d);
}
return caps.length > 0 ? Math.min(...caps) : null;
}
@@ -390,6 +426,11 @@ function sourceLabelOf(
return `${e.name} (${who})`;
}
// Alter innerhalb eines gesetzlichen Fensters halten.
function clampAge(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, Math.round(v)));
}
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
return persons.find((p) => p.role === role) ?? null;
}
@@ -514,7 +555,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const capsFromWorking = personInfos
.filter((p) => p.working)
.map((p) => retirementAge.get(p.personId)! - p.startAge)
.filter((d) => d > 0);
.filter((d) => d > 0)
// Dazu jeder noch bevorstehende Bezugsbeginn: Auch er erzwingt eine Phasengrenze.
.concat(planFixpoints(fixpointPersonsOf(plan)).map((f) => f.year - yearsBefore).filter((d) => d > 0));
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
@@ -618,7 +661,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
for (const e of orderedElements) {
const carry = carries.get(e.id)!;
const pd = e.phaseValues[phase.id] ?? {};
// Stammdaten (Bestand bei Planbeginn, Ausgangs-Annahmen) und Phasenwerte. In der ERSTEN
// Phase gilt der Phasenwert, wo einer erfasst ist, sonst die Stammdaten -- damit ist
// Phase 1 nichts Besonderes mehr, sondern erbt schlicht von der Wurzel.
const bd = e.baseData ?? {};
const raw = e.phaseValues[phase.id] ?? {};
const pd: PhaseData = isFirstPhase ? { ...bd, ...raw } : raw;
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
@@ -628,7 +676,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// 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;
// Reihenfolge: eigener Wert -> aus der Vorphase geerbt -> Stammdaten -> Fallback.
// Die Stammdaten sind damit die WURZEL der Kette; vor 0.36 hatte Phase 1 nichts, von
// dem sie hätte erben können, und fiel auf 0.
const root = (bd as Record<string, unknown>)[key];
const v =
typeof own === "number"
? own
: carry.rates[key] ?? (typeof root === "number" ? root : fallback);
carry.rates[key] = v;
return v;
};
@@ -1541,24 +1596,30 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// 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 = (
// Zieht dieses Vorsorge-Guthaben an DIESER Phasengrenze? Gilt gleichermassen für die
// Pensionskasse und die Säule 3a: Beide haben seit 0.36 ein eigenes Bezugsalter, und
// innerhalb einer Phase kennt das Modell kein Einzelereignis. Gezogen wird an der ERSTEN
// Grenze bei oder nach dem Wunschalter; liegt der Wunsch hinter dem Planende, greift die
// letzte Grenze, damit kein Guthaben unbezogen liegen bleibt.
const drawsHere = (
el: ElementInput,
ownerPerson: { id: string; age: number } | null,
ageAtBoundary: number
): boolean => {
// 3a ist personengebunden -- ohne Besitzer gibt es kein Bezugsalter und keinen Bezug.
// Beide Kategorien sind personengebunden -- ohne Besitzer gibt es kein Bezugsalter.
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 ra = retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE;
const rd = withRetirementDefaults(el.category, ra, el.retirementDecision);
const wish =
el.category === "PENSION_FUND"
? clampAge(num(rd.pkWithdrawalAge, ra), PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE)
: clampAge(
num(rd.withdrawalAge, ageAtBoundary),
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE
);
const prevBoundaryAge = ownerPerson.age + yearsBefore;
if (prevBoundaryAge >= wish) return false; // in einer früheren Phase bereits gezogen
if (prevBoundaryAge >= wish) return false; // an einer früheren Grenze bereits gezogen
return ageAtBoundary >= wish || !nextPhase;
};
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
@@ -1597,11 +1658,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const ec = ecById.get(e.id)!;
const td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
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.
// die Vorgaben des Pensionierungs-Entscheids und für die Bezugsalter von PK und 3a.
// Ob ein Guthaben HIER gezogen wird, entscheidet seit 0.36 `drawsHere` anhand des
// jeweiligen Bezugsalters, nicht mehr das Erwerbsende.
const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE;
const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
@@ -1628,7 +1688,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
switch (e.category) {
case "PENSION_FUND": {
if (ownerRetiresNext) {
if (drawsHere(e, owner, ownerAgeAtBoundary)) {
// 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
@@ -1665,7 +1725,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// Ü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)) {
if (drawsHere(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;
@@ -1746,17 +1806,22 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE";
title = `Übergang «${ec.name}» ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`;
if (ownerRetiresNext && e.category === "PENSION_FUND") {
steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, ""));
if (mode === "PENSION" || mode === "COMBI") {
steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
// Der Bezugs-Entscheid liegt seit 0.34 am Element; der ZEITPUNKT hat seit 0.36 ein
// eigenes Alter und muss nicht mehr mit dem Erwerbsende zusammenfallen.
const drawnHere = drawsHere(e, owner, ownerAgeAtBoundary);
const rd = withRetirementDefaults(e.category, ownerRetirementAge, e.retirementDecision);
title = `Übergang «${ec.name}» ${drawnHere ? "Bezug" : "Vorbezug"}`;
if (drawnHere && e.category === "PENSION_FUND") {
const share = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
steps.push(st("Anteil Kapitalbezug", share, undefined, undefined, "0 % = volle Rente, 100 % = volles Kapital.", "%"));
if (share < 100) {
steps.push(st("Umwandlungssatz", num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
}
}
if (taxHere > 0) {
steps.push(st("Kapitalbezugssteuer", num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE), undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
const satz = drawnHere ? num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) : num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
steps.push(st("Kapitalbezugssteuer", satz, undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
steps.push(st("Steuerbetrag", -taxHere));
}
if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug Kapitalbezugssteuer"));
@@ -1924,7 +1989,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
personId: p.id,
role: p.role,
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
planningHorizonAge: p.planningHorizonAge ?? null,
// Alter am Planende -- abgeleitet aus dem Horizont, nicht erfasst.
planningHorizonAge: typeof plan.planningHorizonYears === "number" ? p.age + plan.planningHorizonYears : null,
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
ahvFromAge: Math.round(ahvStartAge(rd)),
ahvDraw: ahvDrawLabel(rd),
@@ -2027,15 +2093,4 @@ function buildCareer(
};
}
function retiresInPhase(
personId: string,
persons: { id: string; role: PersonRole; age: number }[],
retirementAge: Map<string, number>,
yearsBeforeNext: number
): boolean {
const p = persons.find((x) => x.id === personId);
if (!p) return false;
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
}
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).