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
+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;
}