9907fda6f4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
// 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;
|
|
}
|