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