AHV einkommensabhaengig + Warnhinweis bei Aenderungen in frueheren Phasen
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:
2026-07-17 09:02:42 +02:00
parent 9f5bd754eb
commit e901d23970
8 changed files with 771 additions and 53 deletions
+166 -1
View File
@@ -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({