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:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations";
|
||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
@@ -45,8 +45,173 @@ function plan(opts: {
|
||||
|
||||
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
||||
|
||||
// Stuetzstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
|
||||
// (BSV, gueltig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
|
||||
// Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab.
|
||||
const AHV_AMTLICHE_TABELLE: [number, number][] = [
|
||||
[15120, 1260], // Mindestrente (12 x R0)
|
||||
[16632, 1293],
|
||||
[22680, 1424],
|
||||
[30240, 1588],
|
||||
[43848, 1882],
|
||||
[45360, 1915], // Wendepunkt (36 x R0)
|
||||
[46872, 1935],
|
||||
[60480, 2117],
|
||||
[75600, 2318],
|
||||
[89208, 2500],
|
||||
[90720, 2520], // Maximalrente (72 x R0)
|
||||
];
|
||||
|
||||
describe("AHV-Rentenformel (Skala 44)", () => {
|
||||
it("reproduziert die amtliche Tabelle 318.117.1 exakt", () => {
|
||||
for (const [mdJE, erwartet] of AHV_AMTLICHE_TABELLE) {
|
||||
expect(Math.round(ahvMonthlyFullPension(mdJE)), `mdJE ${mdJE}`).toBe(erwartet);
|
||||
}
|
||||
});
|
||||
|
||||
it("kappt ausserhalb der Schwellen", () => {
|
||||
expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente
|
||||
expect(ahvMonthlyFullPension(10000)).toBe(1260);
|
||||
expect(ahvMonthlyFullPension(150000)).toBe(2520); // ueber 72 x R0 -> Maximalrente
|
||||
});
|
||||
|
||||
it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => {
|
||||
const links = ahvMonthlyFullPension(45360 - 0.01);
|
||||
const rechts = ahvMonthlyFullPension(45360 + 0.01);
|
||||
expect(Math.abs(links - rechts)).toBeLessThan(0.01);
|
||||
});
|
||||
});
|
||||
|
||||
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
|
||||
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
|
||||
describe("AHV einkommensabhaengig", () => {
|
||||
// 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
|
||||
function ahvPlan(opts: {
|
||||
age?: number;
|
||||
income: number;
|
||||
avgIncomeBefore?: number;
|
||||
gapYearsBefore?: number;
|
||||
gapYearsInPlan?: number;
|
||||
reviewed?: boolean;
|
||||
}) {
|
||||
const age = opts.age ?? 60;
|
||||
return plan({
|
||||
age,
|
||||
retirementAge: 65,
|
||||
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand pruefbar bleiben
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 65 - age },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: opts.income, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el(
|
||||
"AHV",
|
||||
"PERSON_A",
|
||||
{ p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} },
|
||||
{
|
||||
p1: {
|
||||
reviewed: opts.reviewed ?? true,
|
||||
avgIncomeBefore: opts.avgIncomeBefore ?? 0,
|
||||
gapYearsBefore: opts.gapYearsBefore ?? 0,
|
||||
},
|
||||
}
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const renteIn = (p: PlanInput, phaseIdx: number) =>
|
||||
computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
|
||||
it("volle Karriere auf Maximalniveau -> Maximalrente 32'760", () => {
|
||||
const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
|
||||
expect(renteIn(p, 1)).toBe(32760); // 2'520 x 13
|
||||
});
|
||||
|
||||
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => {
|
||||
// mdJE = 60'000 -> 1'260 x (1.04 + 0.16 x 60'000/15'120) = 2'118.1/Monat x 13 = 27'535
|
||||
const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
|
||||
const erwartet = Math.round(ahvMonthlyFullPension(60000) * 13);
|
||||
expect(renteIn(p, 1)).toBe(erwartet);
|
||||
expect(renteIn(p, 1)).toBeLessThan(32760);
|
||||
});
|
||||
|
||||
it("tiefes Einkommen -> Mindestrente 1'260 x 13", () => {
|
||||
const p = ahvPlan({ income: 10000, avgIncomeBefore: 10000 });
|
||||
expect(renteIn(p, 1)).toBe(1260 * 13);
|
||||
});
|
||||
|
||||
it("Einkommen vor Planbeginn dominiert bei kurzer Restlaufzeit", () => {
|
||||
// 39 Jahre vor Planbeginn zu 40'000, nur 5 Jahre im Plan zu 200'000.
|
||||
const p = ahvPlan({ income: 200000, avgIncomeBefore: 40000 });
|
||||
const mdJE = (40000 * 39 + 200000 * 5) / 44; // = 58'181.8
|
||||
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
|
||||
});
|
||||
|
||||
it("Ausfalljahre kuerzen die Rente ueber die Skala 44", () => {
|
||||
const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
|
||||
const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 });
|
||||
expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger
|
||||
expect(renteIn(mit, 1)).toBeLessThan(renteIn(ohne, 1));
|
||||
});
|
||||
|
||||
it("Ausfalljahre im Plan senken die Beitragsjahre, nicht das Durchschnittseinkommen", () => {
|
||||
// Gleiches Einkommen, aber 2 Ausfalljahre im Plan -> nur die Skala sinkt.
|
||||
const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsInPlan: 2 });
|
||||
expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44)));
|
||||
});
|
||||
|
||||
it("ohne Pruefung gilt der geplante Durchschnitt auch fuer die Jahre vor Planbeginn", () => {
|
||||
// Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%).
|
||||
const p = plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Uebergangsdaten
|
||||
],
|
||||
});
|
||||
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000) * 13)); // mdJE = 80'000, nicht 9'091
|
||||
});
|
||||
|
||||
it("bereits bei Planbeginn pensioniert: Karriere kommt aus der Phasenzelle", () => {
|
||||
const p = plan({
|
||||
age: 66,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [{ id: "p1", durationYears: 10 }],
|
||||
elements: [el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 60000, gapYearsBefore: 0 } })],
|
||||
});
|
||||
const rente = computePlan(p).phases[0].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(60000) * 13));
|
||||
});
|
||||
|
||||
it("Ehepaar-Plafonierung greift weiterhin (150% der Maximalrente)", () => {
|
||||
const p: PlanInput = {
|
||||
id: "plan", name: "T", householdType: "COUPLE", inflationRateDefault: 0, initialCash: 0,
|
||||
persons: [
|
||||
{ id: "A", role: "PERSON_A", name: null, age: 66, retirementAge: 65 },
|
||||
{ id: "B", role: "PERSON_B", name: null, age: 66, retirementAge: 65 },
|
||||
],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
|
||||
elements: [
|
||||
el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 100000 } }),
|
||||
el("AHV", "PERSON_B", { p1: { avgIncomeBefore: 100000 } }),
|
||||
],
|
||||
};
|
||||
const ph = computePlan(p).phases[0];
|
||||
const summe = ph.elements.filter((e) => e.category === "AHV").reduce((s, e) => s + e.startValue, 0);
|
||||
expect(summe).toBe(Math.round(32760 * 1.5)); // plafoniert, nicht 2 x 32'760
|
||||
});
|
||||
});
|
||||
|
||||
describe("V5 Golden Tests", () => {
|
||||
it("Test 1 – Ansparen (Einkommen +2% nominal, Ausgaben real flach -> nominal +2%)", () => {
|
||||
const p = plan({
|
||||
|
||||
Reference in New Issue
Block a user