AHV einkommensabhaengig + Warnhinweis bei Aenderungen in frueheren Phasen
Deploy App / deploy (push) Successful in 59s
Deploy App / deploy (push) Successful in 59s
Roadmap Nr. 3: Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) ueber das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente. - Formel aus den amtlichen Randbedingungen hergeleitet und gegen die Tabelle 318.117.1 verifiziert: 51/51 Zeilen exakt. Schwellen sind Vielfache von R0=1'260 (12/36/72 x R0 = 15'120 / 45'360 / 90'720). Stuetzstellen als Golden Tests. - Alles REAL gerechnet: die AHV wertet vergangene Einkommen auf UND indexiert die Schwellen -- real hebt sich das auf. Nominal wuerde die Rente systematisch zu hoch ausfallen (Beispiel: faelschlich Maximalrente, ~41'600 ueber 25 Rentenjahre). - Pruefung der Beitragskarriere am Pensions-Uebergang; Zusatzfelder fuer die Jahre vor Planbeginn nur, wenn der Plan nicht bis Alter 21 zurueckreicht. - Sonderfall "bei Planbeginn bereits pensioniert": Felder in der Phasenzelle. - Ohne Pruefung gilt der geplante Durchschnitt (nicht 0) -- sonst waere die Rente still viel zu tief. - 13. Altersrente: Jahresbetrag = Monatsrente x 13. Roadmap Nr. 4: Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren -- Werte schreiben sich fort und wirken bis ans Planende durch. Verhaltensaenderung fuer bestehende Plaene: siehe SPEZIFIKATION 9.10. Zwoelf Regressionstests (18 -> 30). Spezifikation auf v0.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+161
-6
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
AHV_CONTRIBUTION_START_AGE,
|
||||
AHV_COUPLE_CAP_FACTOR,
|
||||
AHV_FULL_CONTRIBUTION_YEARS,
|
||||
AHV_MAX_ANNUAL_SINGLE,
|
||||
AHV_MIN_MONTHLY_FULL,
|
||||
AHV_PENSION_MONTHS,
|
||||
DEFAULT_CAPITAL_TAX_RATE,
|
||||
DEFAULT_PK_CONVERSION_RATE,
|
||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||
@@ -95,6 +98,60 @@ export interface PlanComputed {
|
||||
yearly: YearPoint[];
|
||||
nachlass: number;
|
||||
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
|
||||
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (fuer die AHV-Pruefung am Uebergang)
|
||||
}
|
||||
|
||||
// --- AHV-Rentenformel (Skala 44) ---------------------------------------------------------
|
||||
// Amtliche Rentenformel des BSV. Alle Schwellen sind Vielfache von R0 (Mindestrente):
|
||||
// mdJE <= 12 x R0 -> Mindestrente R0
|
||||
// 12 x R0 < mdJE <= 36 x R0 -> Formel 1: R0 x (0.74 + 0.26 x mdJE/(12 R0))
|
||||
// 36 x R0 < mdJE < 72 x R0 -> Formel 2: R0 x (1.04 + 0.16 x mdJE/(12 R0))
|
||||
// mdJE >= 72 x R0 -> Maximalrente 2 x R0
|
||||
// Verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen exakt, siehe Tests).
|
||||
// Bewusst STETIG gerechnet: die amtliche Tabelle stuft das mdJE auf Vielfache von 1.2 x R0;
|
||||
// fuer eine Planung ist der stetige Wert naeher an der Wahrheit (Abweichung < 20/Monat).
|
||||
export function ahvMonthlyFullPension(mdJE: number): number {
|
||||
const r0 = AHV_MIN_MONTHLY_FULL;
|
||||
if (mdJE <= 12 * r0) return r0;
|
||||
if (mdJE >= 72 * r0) return 2 * r0;
|
||||
const x = mdJE / (12 * r0);
|
||||
return mdJE <= 36 * r0 ? r0 * (0.74 + 0.26 * x) : r0 * (1.04 + 0.16 * x);
|
||||
}
|
||||
|
||||
// Beitragskarriere einer Person fuer die AHV -- akkumuliert ueber die Erwerbsphasen des Plans.
|
||||
export interface AhvCareer {
|
||||
personId: string;
|
||||
role: PersonRole;
|
||||
plannedAvgIncome: number; // reales Durchschnittseinkommen der Beitragsjahre IM Plan
|
||||
planYears: number; // Beitragsjahre im Plan (Dauer abzueglich Ausfalljahre)
|
||||
yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn
|
||||
gapYearsInPlan: number;
|
||||
}
|
||||
|
||||
// Massgebendes durchschnittliches Jahreseinkommen: gewichteter Mittelwert der realen
|
||||
// Einkommen ueber ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
|
||||
// AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in
|
||||
// realer Betrachtung weitgehend auf.
|
||||
export function ahvMdje(career: AhvCareer, avgIncomeBefore: number, gapYearsBefore: number): number {
|
||||
const yearsBefore = Math.max(0, career.yearsBeforePlan - Math.max(0, gapYearsBefore));
|
||||
const totalYears = yearsBefore + career.planYears;
|
||||
if (totalYears <= 0) return 0;
|
||||
return (avgIncomeBefore * yearsBefore + career.plannedAvgIncome * career.planYears) / totalYears;
|
||||
}
|
||||
|
||||
// Jaehrliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekuerzt um die Ausfalljahre
|
||||
// (Rentenskala: pro fehlendes Beitragsjahr 1/44).
|
||||
export function ahvAnnualPension(mdJE: number, totalGapYears: number): number {
|
||||
const factor = Math.max(
|
||||
0,
|
||||
(AHV_FULL_CONTRIBUTION_YEARS - Math.max(0, totalGapYears)) / AHV_FULL_CONTRIBUTION_YEARS
|
||||
);
|
||||
return Math.round(ahvMonthlyFullPension(mdJE) * AHV_PENSION_MONTHS * factor);
|
||||
}
|
||||
|
||||
// Jahre zwischen dem AHV-Beitragsbeginn (21) und dem Planbeginn.
|
||||
export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
|
||||
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
|
||||
}
|
||||
|
||||
// Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt).
|
||||
@@ -130,7 +187,8 @@ function fmt(v: number): string {
|
||||
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||
}
|
||||
|
||||
function personByRole(persons: { id: string; role: PersonRole }[], role: string) {
|
||||
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behaelt.
|
||||
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
|
||||
return persons.find((p) => p.role === role) ?? null;
|
||||
}
|
||||
|
||||
@@ -143,6 +201,12 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
|
||||
|
||||
const gapYearsByPerson = new Map<string, number>();
|
||||
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
|
||||
const ahvIncomeAccum = new Map<string, number>();
|
||||
const ahvYearsAccum = new Map<string, number>();
|
||||
// Karriere VOR Planbeginn -- aus der Pruefung am Pensions-Uebergang bzw. (fuer bereits
|
||||
// Pensionierte) aus der Phasenzelle der ersten Phase.
|
||||
const ahvBeforeByPerson = new Map<string, { avg: number; gap: number }>();
|
||||
const carries = new Map<string, Carry>();
|
||||
for (const e of plan.elements) carries.set(e.id, emptyCarry());
|
||||
|
||||
@@ -191,22 +255,42 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
|
||||
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
|
||||
|
||||
// Ausfalljahre kumulieren + AHV-Renten (mit Plafonierung).
|
||||
// Ausfalljahre kumulieren (nur waehrend der Erwerbstaetigkeit).
|
||||
const gapThisPhase = new Map<string, number>();
|
||||
for (const e of plan.elements) {
|
||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||
const owner = personByRole(persons, e.ownerRole);
|
||||
if (!owner || !workingByPerson.get(owner.id)) continue;
|
||||
const gy = Math.max(0, Math.round(num(e.phaseValues[phase.id]?.gapYears)));
|
||||
gapThisPhase.set(owner.id, (gapThisPhase.get(owner.id) ?? 0) + gy);
|
||||
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
|
||||
}
|
||||
|
||||
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Uebergang, an dem die
|
||||
// Beitragskarriere geprueft werden koennte -- die Werte liegen dann in der Phasenzelle.
|
||||
for (const e of plan.elements) {
|
||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||
const owner = personByRole(persons, e.ownerRole);
|
||||
if (!owner || workingByPerson.get(owner.id)) continue;
|
||||
if (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Uebergang bereits gesetzt
|
||||
const pd = e.phaseValues[phase.id] ?? {};
|
||||
ahvBeforeByPerson.set(owner.id, {
|
||||
avg: num(pd.avgIncomeBefore),
|
||||
gap: Math.max(0, Math.round(num(pd.gapYearsBefore))),
|
||||
});
|
||||
}
|
||||
|
||||
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekuerzt um die Ausfalljahre.
|
||||
const ahvUncapped = new Map<string, number>();
|
||||
for (const e of plan.elements) {
|
||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||
const owner = personByRole(persons, e.ownerRole);
|
||||
if (!owner || workingByPerson.get(owner.id)) continue;
|
||||
const gap = gapYearsByPerson.get(owner.id) ?? 0;
|
||||
const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS);
|
||||
ahvUncapped.set(owner.id, Math.round(AHV_MAX_ANNUAL_SINGLE * factor));
|
||||
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 };
|
||||
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
||||
const mdJE = ahvMdje(career, before.avg, before.gap);
|
||||
const totalGap = (gapYearsByPerson.get(owner.id) ?? 0) + before.gap;
|
||||
ahvUncapped.set(owner.id, ahvAnnualPension(mdJE, totalGap));
|
||||
}
|
||||
const ahvFinal = new Map(ahvUncapped);
|
||||
if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) {
|
||||
@@ -218,6 +302,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
// --- Element-Laufzeitzustaende aufbauen ---
|
||||
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
const ecById = new Map<string, ElementPhaseComputed>();
|
||||
// Reales Durchschnittseinkommen dieser Phase je Person (fuer die AHV-Karriere).
|
||||
const phaseRealIncomeByPerson = new Map<string, number>();
|
||||
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
||||
@@ -280,6 +366,21 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
? Math.round(pd.amount)
|
||||
: ec.baseValue;
|
||||
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
|
||||
|
||||
// AHV: reales Erwerbseinkommen der Person mitfuehren. Nur Einkommen, die einer
|
||||
// Person zugeordnet sind -- bei einem Einzelplan zaehlt "Gemeinsam" zur Person A.
|
||||
if (e.category === "INCOME" && ownerWorking) {
|
||||
const attributed =
|
||||
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
|
||||
if (attributed) {
|
||||
const avgReal = avgRealFlow(basis, idx, infl, duration, cumInflStart);
|
||||
phaseRealIncomeByPerson.set(
|
||||
attributed.id,
|
||||
(phaseRealIncomeByPerson.get(attributed.id) ?? 0) + avgReal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Basiswert der Folgephase fortschreiben (nominal fuer Einkommen, real fuer Ausgaben).
|
||||
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
|
||||
break;
|
||||
@@ -376,6 +477,17 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
}
|
||||
}
|
||||
|
||||
// AHV-Karriere fortschreiben: Beitragsjahre EINMAL je Person und Phase (nicht je
|
||||
// Einkommens-Element), gewichtet mit dem realen Durchschnittseinkommen der Phase.
|
||||
for (const p of persons) {
|
||||
if (!workingByPerson.get(p.id)) continue;
|
||||
const contribYears = Math.max(0, duration - (gapThisPhase.get(p.id) ?? 0));
|
||||
if (contribYears <= 0) continue;
|
||||
const avgReal = phaseRealIncomeByPerson.get(p.id) ?? 0;
|
||||
ahvIncomeAccum.set(p.id, (ahvIncomeAccum.get(p.id) ?? 0) + avgReal * contribYears);
|
||||
ahvYearsAccum.set(p.id, (ahvYearsAccum.get(p.id) ?? 0) + contribYears);
|
||||
}
|
||||
|
||||
// --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
|
||||
// Investitionen dieser Phase werden am Phasenanfang abgezogen -> der Cash-Startwert
|
||||
// zeigt den Bestand NACH den Investitionen (kein Doppelzaehlen mit dem Vermoegen).
|
||||
@@ -571,6 +683,22 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
continue;
|
||||
}
|
||||
|
||||
// AHV: beim Pensions-Uebergang die geprueften Karriere-Werte vor Planbeginn uebernehmen.
|
||||
if (e.category === "AHV") {
|
||||
if (ownerRetiresNext && owner) {
|
||||
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
||||
ahvBeforeByPerson.set(owner.id, {
|
||||
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre
|
||||
// vor Planbeginn -- exakt der Wert, den der Pruef-Dialog vorbelegt. Ein Fallback auf
|
||||
// 0 wuerde die Rente still und massiv zu tief rechnen.
|
||||
avg: num(td.avgIncomeBefore, career.plannedAvgIncome),
|
||||
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
|
||||
});
|
||||
}
|
||||
carry.hasCarry = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (carry.status !== "ACTIVE") {
|
||||
carry.hasCarry = true;
|
||||
continue;
|
||||
@@ -666,7 +794,34 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
}
|
||||
|
||||
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
|
||||
return { phases: result, yearly, nachlass, ruinAge };
|
||||
const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson));
|
||||
return { phases: result, yearly, nachlass, ruinAge, ahvCareer };
|
||||
}
|
||||
|
||||
// Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit
|
||||
// idx, real wird er mit der Inflation deflationiert -> geometrische Reihe mit q.
|
||||
function avgRealFlow(basis: number, idx: number, infl: number, duration: number, cumInflStart: number): number {
|
||||
const q = (1 + idx / 100) / (1 + infl / 100);
|
||||
const sum = Math.abs(q - 1) < 1e-12 ? duration : (1 - Math.pow(q, duration)) / (1 - q);
|
||||
return (basis / (cumInflStart || 1)) * (sum / duration);
|
||||
}
|
||||
|
||||
function buildCareer(
|
||||
owner: { id: string; role: PersonRole; age: number },
|
||||
incomeAccum: Map<string, number>,
|
||||
yearsAccum: Map<string, number>,
|
||||
gapAccum: Map<string, number>
|
||||
): AhvCareer {
|
||||
const planYears = yearsAccum.get(owner.id) ?? 0;
|
||||
const incomeSum = incomeAccum.get(owner.id) ?? 0;
|
||||
return {
|
||||
personId: owner.id,
|
||||
role: owner.role,
|
||||
plannedAvgIncome: planYears > 0 ? incomeSum / planYears : 0,
|
||||
planYears,
|
||||
yearsBeforePlan: ahvYearsBeforePlan(owner.age),
|
||||
gapYearsInPlan: gapAccum.get(owner.id) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function retiresInPhase(
|
||||
|
||||
Reference in New Issue
Block a user