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({
+161 -6
View File
@@ -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(
+16 -3
View File
@@ -1,9 +1,22 @@
// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch
// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt.
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente (2'520/Monat × 13 = 32'760).
// Quelle: BSV / AHV-IV 2026.
export const AHV_MAX_ANNUAL_SINGLE = 32760;
// R0 = Mindestbetrag der vollen monatlichen Altersrente (Skala 44). Alle Schwellen der
// Rentenformel sind Vielfache von R0 -- aendert sich R0, verschiebt sich die ganze Skala.
// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gueltig ab 1.1.2026;
// amtliche Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44".
export const AHV_MIN_MONTHLY_FULL = 1260;
// Anzahl Rentenzahlungen pro Jahr. Ab 1.1.2026 gibt es die 13. Altersrente (Art. 34bis AHVG),
// deshalb 13 statt 12.
export const AHV_PENSION_MONTHS = 13;
// Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag -- faktisch ab Alter 21.
export const AHV_CONTRIBUTION_START_AGE = 21;
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente: 2 x R0 x 13 = 32'760.
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
+16
View File
@@ -58,6 +58,11 @@ export interface PhaseData {
teuerungsausgleich?: number;
// AHV
gapYears?: number;
// AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen
// Pensions-Uebergang, an dem die Karriere geprueft werden koennte): Beitragskarriere.
// avgIncomeBefore ist REAL (heutige Kaufkraft).
avgIncomeBefore?: number;
gapYearsBefore?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET
currentValue?: number;
startValue?: number;
@@ -80,6 +85,12 @@ export type TransitionDecision = "HOLD" | "SELL";
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
export interface TransitionData {
// AHV (Pensions-Uebergang): Pruefung der Beitragskarriere. Das geplante Durchschnitts-
// einkommen kommt aus dem Plan; reicht der Plan nicht bis zum Beitragsbeginn (Alter 21)
// zurueck, ergaenzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL.
reviewed?: boolean;
avgIncomeBefore?: number;
gapYearsBefore?: number;
// PENSION_FUND / PILLAR_3A (normaler Uebergang): expliziter Bezugs-Entscheid.
withdrawalMode?: "NONE" | "AMOUNT";
withdrawal?: number;
@@ -135,6 +146,8 @@ export const phaseDataSchema = z
amount: nonNeg.optional(),
teuerungsausgleich: z.number().min(-20).max(50).optional(),
gapYears: z.number().int().min(0).optional(),
avgIncomeBefore: nonNeg.optional(),
gapYearsBefore: z.number().int().min(0).max(50).optional(),
currentValue: nonNeg.optional(),
startValue: nonNeg.optional(),
expectedReturn: z.number().min(-50).max(100).optional(),
@@ -150,6 +163,9 @@ export const phaseDataSchema = z
export const transitionDataSchema = z
.object({
reviewed: z.boolean().optional(),
avgIncomeBefore: nonNeg.optional(),
gapYearsBefore: z.number().int().min(0).max(50).optional(),
withdrawalMode: z.enum(["NONE", "AMOUNT"]).optional(),
withdrawal: nonNeg.optional(),
payoutMode: z.enum(["CAPITAL", "PENSION", "COMBI"]).optional(),