Pensionierung: Entscheid ans Element, AHV-Vorbezug/Aufschub, PK-Kapitalquote
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user