Pensionierung als Eigenschaft der Person: Treiber, Tests, Spezifikation 0.35
Deploy App / deploy (push) Successful in 1m57s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:52:40 +02:00
parent 1865db5de7
commit 0f656f4070
6 changed files with 675 additions and 37 deletions
+2 -2
View File
@@ -200,14 +200,14 @@ export function SensitivityDialog({
label="tief"
value={dr.low}
suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1}
step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { low: v })}
/>
<RequiredNumberField
label="hoch"
value={dr.high}
suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1}
step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { high: v })}
/>
</div>
+24 -2
View File
@@ -48,6 +48,17 @@ export interface SliderDef {
// anders -- ein Regler vergleicht keine Treiber gegeneinander, er zeigt ein einzelnes
// absolutes Ergebnis. Ein Standardbereich erfindet also keine Aussage, er macht den Regler
// überhaupt erst bedienbar. Beide Enden bleiben editierbar. Siehe 9.27.
// Der geplante PK-Kapitalanteil. Bei mehreren PK-Elementen der Durchschnitt -- der Regler
// setzt ohnehin einen gemeinsamen Wert, und ein Regler, der beim Oeffnen schon vom Plan
// abweicht, waere irrefuehrend.
function plannedCapitalShare(plan: PlanInput): number {
const shares = plan.elements
.filter((e) => e.category === "PENSION_FUND")
.map((e) => e.retirementDecision?.capitalSharePct ?? 0);
if (shares.length === 0) return 0;
return Math.round(shares.reduce((a, b) => a + b, 0) / shares.length);
}
const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral: number }> = {
// Absolute Werte: der neutrale Punkt ist der Planwert selbst und wird zur Laufzeit gesetzt.
inflation: { min: 0, max: 4, step: 0.1, neutral: 0 },
@@ -58,6 +69,11 @@ const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral
expenses: { min: -20, max: 30, step: 1, neutral: 0 },
income: { min: -30, max: 20, step: 1, neutral: 0 },
lifespan: { min: -5, max: 10, step: 1, neutral: 0 },
// Absolutwert in Prozent: 0 = volle Rente, 100 = volles Kapital. Neutral wird zur Laufzeit
// auf den geplanten Anteil gesetzt.
pkCapitalShare: { min: 0, max: 100, step: 5, neutral: 0 },
// Monate gegenueber dem Referenzalter: negativ = Vorbezug, positiv = Aufschub.
ahvDrawMonths: { min: -36, max: 60, step: 1, neutral: 0 },
// Der wirkliche Spielraum hängt an den Phasendauern und wird in `buildSliders` gesetzt.
retirementA: { min: -3, max: 3, step: 1, neutral: 0 },
retirementB: { min: -3, max: 3, step: 1, neutral: 0 },
@@ -92,8 +108,14 @@ export function buildSliders(plan: PlanInput, expandReturns: boolean): SliderDef
min: hard ? Math.max(r.min, hard.min) : r.min,
max: hard ? Math.min(r.max, hard.max) : r.max,
step: r.step,
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan.
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0,
// Absolute Treiber: neutral ist der Planwert selbst, sonst stünde der Regler beim
// Öffnen an einer Stelle, die den Plan bereits verändert.
neutral:
def.id === "inflation"
? plan.inflationRateDefault
: def.id === "pkCapitalShare"
? plannedCapitalShare(plan)
: 0,
});
}
if (expandReturns) {
+14 -1
View File
@@ -68,12 +68,25 @@ describe("Datenbank-Migrationen", () => {
expect(scenCols).not.toContain("startYear");
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
// Person trägt nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt.
// Person trägt Pensionsalter und Planungshorizont -- Name und Alter beschreiben den
// Haushalt und liegen am Plan.
const personCols = await cols("Person");
expect(personCols).toContain("retirementAge");
expect(personCols).toContain("planningHorizonAge");
expect(personCols).not.toContain("name");
expect(personCols).not.toContain("age");
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
// nur so überlebt er eine Verschiebung der Zeitachse.
expect(await cols("FinancialElement")).toContain("retirementDecision");
// Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben.
const rd = await db.query<{ is_nullable: string }>(
`SELECT is_nullable FROM information_schema.columns
WHERE table_name='FinancialElement' AND column_name='retirementDecision'`
);
expect(rd.rows[0]?.is_nullable).toBe("YES");
// Ein Plan ohne Haushaltsform wäre nicht rechenbar.
const hh = await db.query<{ is_nullable: string }>(
`SELECT is_nullable FROM information_schema.columns
+345
View File
@@ -0,0 +1,345 @@
// Der Pensionierungs-Entscheid (0.34).
//
// Zwei Dinge werden hier festgenagelt: die gesetzlichen Faktoren des flexiblen AHV-Bezugs
// (die sind nachschlagbar und muessen exakt stimmen) und die Eigenschaft, um derentwillen
// der ganze Umbau stattfand -- dass ein Entscheid eine Verschiebung der Zeitachse ueberlebt.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import {
ahvDrawLabel,
ahvFactor,
ahvStartAge,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import { shiftRetirement } from "@/lib/retirement";
import { decisionsText, totalOpenDecisions } from "@/lib/decisions";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
let idc = 0;
const nid = () => `r${idc++}`;
function el(
category: ElementCategory,
ownerRole: string | null,
phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {},
retirementDecision: RetirementDecision = {}
) {
return {
id: nid(),
category,
name: category,
ownerRole: ownerRole as never,
orderIndex: idc,
phaseValues,
transitionValues,
retirementDecision,
};
}
function plan(opts: {
age: number;
retirementAge: number;
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
elements: ReturnType<typeof el>[];
horizon?: number;
}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
persons: [
{
id: "A",
role: "PERSON_A",
name: null,
age: opts.age,
retirementAge: opts.retirementAge,
planningHorizonAge: opts.horizon ?? null,
},
],
phases: opts.phases.map((p, i) => ({
id: p.id,
sequenceNumber: i + 1,
name: p.id,
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
elements: opts.elements,
};
}
describe("AHV: flexibler Rentenbezug", () => {
it("kürzt den Vorbezug mit 6,8 % pro Jahr", () => {
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 12 })).toBeCloseTo(0.932, 5);
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 24 })).toBeCloseTo(0.864, 5);
// Monatsgenau, nicht nur in ganzen Jahren (seit AHV 21).
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 6 })).toBeCloseTo(0.966, 5);
});
it("trifft die amtlichen Zuschlagssätze beim Aufschub", () => {
const bonus = (jahre: number) =>
Math.round((ahvFactor({ ahvDraw: "DEFERRED", ahvMonths: jahre * 12 }) - 1) * 1000) / 10;
expect(bonus(1)).toBe(5.2);
expect(bonus(2)).toBe(10.8);
expect(bonus(3)).toBe(17.1);
expect(bonus(4)).toBe(24.0);
expect(bonus(5)).toBe(31.5);
});
it("lässt den Referenzbezug unverändert und kappt am gesetzlichen Fenster", () => {
expect(ahvFactor({})).toBe(1);
expect(ahvFactor({ ahvDraw: "REFERENCE", ahvMonths: 99 })).toBe(1);
// Vorbezug höchstens 3 Jahre, Aufschub höchstens 5.
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 999 })).toBeCloseTo(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 36 }), 9);
expect(ahvFactor({ ahvDraw: "DEFERRED", ahvMonths: 999 })).toBeCloseTo(1.315, 5);
});
it("verschiebt den Rentenbeginn entsprechend", () => {
expect(ahvStartAge({})).toBe(AHV_REFERENCE_AGE);
expect(ahvStartAge({ ahvDraw: "EARLY", ahvMonths: 24 })).toBe(AHV_REFERENCE_AGE - 2);
expect(ahvStartAge({ ahvDraw: "DEFERRED", ahvMonths: 36 })).toBe(AHV_REFERENCE_AGE + 3);
});
it("wirkt beim Teilbezug nur auf den bezogenen Anteil", () => {
// 50 % zwei Jahre vorbezogen (-13,6 %), der Rest ungekürzt -> halbe Wirkung.
const voll = ahvFactor({ ahvDraw: "EARLY", ahvMonths: 24, ahvSharePct: 100 });
const halb = ahvFactor({ ahvDraw: "EARLY", ahvMonths: 24, ahvSharePct: 50 });
expect(halb).toBeCloseTo(1 - (1 - voll) / 2, 9);
});
it("beschriftet den Entscheid lesbar", () => {
expect(ahvDrawLabel({})).toBe("Referenzalter");
expect(ahvDrawLabel({ ahvDraw: "EARLY", ahvMonths: 24 })).toContain("Vorbezug");
expect(ahvDrawLabel({ ahvDraw: "EARLY", ahvMonths: 24 })).toContain("-13.6");
expect(ahvDrawLabel({ ahvDraw: "DEFERRED", ahvMonths: 60 })).toContain("+31.5");
});
});
describe("AHV im Rechenkern: Rentenbeginn und Beitragspflicht sind zwei Alter", () => {
function p(rd: RetirementDecision, retirementAge = 62) {
return plan({
age: 60,
retirementAge,
phases: [
{ id: "p1", durationYears: retirementAge - 60 },
{ id: "p2", durationYears: 15 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000 }, p2: { amount: 50000 } }),
el("AHV", "PERSON_A", { p1: {}, p2: { ahvContribution: 1000 } }, {}, { avgIncomeBefore: 90000, ...rd }),
],
});
}
const ahvYearly = (pl: PlanInput) => {
const c = computePlan(pl);
const ec = c.phases[1].elements.find((e) => e.category === "AHV")!;
return ec.yearly;
};
// Hinweis zur Konvention: `age` im Verlauf ist das Alter am JAHRESENDE, die Rentenprüfung
// nutzt das Alter am Jahresanfang. Deshalb wird hier nicht auf absolute Alter geprüft,
// sondern auf das erste Jahr mit Rente -- das ist die Aussage, um die es geht.
const erstesRentenjahr = (pl: PlanInput) => ahvYearly(pl).find((x) => x.value > 0)?.age ?? null;
it("zahlt die Rente erst ab dem Referenzalter, wenn nicht vorbezogen wird", () => {
const y = ahvYearly(p({}));
expect(y[0].value).toBeLessThan(0); // vor dem Referenzalter nur der Beitrag
expect(erstesRentenjahr(p({}))).not.toBeNull();
});
it("zieht den Rentenbeginn beim Vorbezug um genau die Vorbezugsdauer vor", () => {
const ohne = erstesRentenjahr(p({}))!;
const zweiJahre = erstesRentenjahr(p({ ahvDraw: "EARLY", ahvMonths: 24 }))!;
expect(ohne - zweiJahre).toBe(2);
});
it("schiebt ihn beim Aufschub entsprechend nach hinten", () => {
const ohne = erstesRentenjahr(p({}))!;
const spaeter = erstesRentenjahr(p({ ahvDraw: "DEFERRED", ahvMonths: 36 }))!;
expect(spaeter - ohne).toBe(3);
});
it("beendet die Beitragspflicht unabhängig davon erst mit dem Referenzalter", () => {
// Der fachliche Kern: Wer mit 62 aufhört und ab 63 vorbezieht, bezieht ab 63 UND zahlt
// bis 65 weiter. Im Überlappungsjahr ist der Wert deshalb um den Beitrag gemindert.
const y = ahvYearly(p({ ahvDraw: "EARLY", ahvMonths: 24 }));
const mitRente = y.filter((x) => x.value > 0).map((x) => x.value);
const kleinster = Math.min(...mitRente);
const groesster = Math.max(...mitRente);
expect(groesster - kleinster).toBe(1000);
});
it("kürzt die Rente dauerhaft um den Vorbezugsfaktor", () => {
const ohne = computePlan(p({})).retirement.perPerson[0].ahvAnnual;
const mit = computePlan(p({ ahvDraw: "EARLY", ahvMonths: 24 })).retirement.perPerson[0].ahvAnnual;
expect(mit).toBe(Math.round(ohne * 0.864));
});
it("erhöht sie beim Aufschub", () => {
const ohne = computePlan(p({})).retirement.perPerson[0].ahvAnnual;
const mit = computePlan(p({ ahvDraw: "DEFERRED", ahvMonths: 36 })).retirement.perPerson[0].ahvAnnual;
expect(mit).toBe(Math.round(ohne * 1.171));
});
});
describe("Pensionskasse: eine Quote statt Modus und Frankenbetrag", () => {
function p(sharePct: number) {
return plan({
age: 62,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 3 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000 }, p2: { amount: 50000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 500000, expectedReturn: 0 }, p2: {} },
{},
{ capitalSharePct: sharePct, conversionRate: 6, capitalTaxRate: 0 }
),
],
});
}
const pk = (share: number) => computePlan(p(share)).retirement.perPerson[0];
it("0 % ist die volle Rente", () => {
expect(pk(0).pkPensionAnnual).toBe(30000); // 500'000 x 6 %
expect(pk(0).capitalAtRetirement).toBe(0);
});
it("100 % ist der volle Kapitalbezug", () => {
expect(pk(100).pkPensionAnnual).toBe(0);
expect(pk(100).capitalAtRetirement).toBe(500000);
});
it("dazwischen wird sauber geteilt", () => {
expect(pk(40).capitalAtRetirement).toBe(200000);
expect(pk(40).pkPensionAnnual).toBe(18000); // 300'000 x 6 %
});
it("skaliert mit dem Pensionsalter -- genau darum eine Quote und kein Betrag", () => {
// Ein Jahr laenger arbeiten heisst mehr Guthaben; die Aufteilung 40/60 bleibt.
const base = p(40);
const laenger: PlanInput = {
...base,
persons: base.persons.map((x) => ({ ...x, retirementAge: 66 })),
phases: base.phases.map((ph, i) => (i === 0 ? { ...ph, durationYears: 4 } : { ...ph, durationYears: 9 })),
};
const r = computePlan(laenger).retirement.perPerson[0];
// Ohne Rendite waechst das Guthaben hier nicht -- entscheidend ist, dass das VERHAELTNIS
// gleich bleibt, nicht der Betrag.
expect(r.capitalAtRetirement / (r.capitalAtRetirement + (r.pkPensionAnnual / 6) * 100)).toBeCloseTo(0.4, 6);
});
});
describe("Der Entscheid überlebt eine Verschiebung der Zeitachse", () => {
// Der eigentliche Grund fuer den ganzen Umbau: Bis 0.33 hing der Entscheid an
// transitionValues[phaseId]. Verschob man das Pensionsalter, wanderte die Phasengrenze --
// und der Entscheid musste verlustbehaftet mitgezogen werden.
it("bleibt nach shiftRetirement unverändert am Element", () => {
const base = plan({
age: 60,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 15 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 60000 }, p2: { amount: 60000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
{},
{ capitalSharePct: 75, capitalTaxRate: 5, confirmed: true }
),
],
});
const shifted = shiftRetirement(base, "PERSON_A", -2);
expect(shifted).not.toBeNull();
const pkVorher = base.elements.find((e) => e.category === "PENSION_FUND")!.retirementDecision;
const pkNachher = shifted!.plan.elements.find((e) => e.category === "PENSION_FUND")!.retirementDecision;
expect(pkNachher).toEqual(pkVorher);
expect(pkNachher?.capitalSharePct).toBe(75);
expect(pkNachher?.confirmed).toBe(true);
});
});
describe("Vorgaben: der Plan ist ab der ersten Sekunde rechenbar", () => {
it("setzt sinnvolle Vorgaben je Kategorie", () => {
expect(withRetirementDefaults("AHV", 65, {}).ahvDraw).toBe("REFERENCE");
// Volle Rente ist die Regel -- ein Kapitalbezug ist der begruendungspflichtige Fall.
expect(withRetirementDefaults("PENSION_FUND", 65, {}).capitalSharePct).toBe(0);
expect(withRetirementDefaults("PILLAR_3A", 63, {}).withdrawalAge).toBe(63);
});
it("hält die 3a im gesetzlichen Bezugsfenster, auch bei früher Pensionierung", () => {
// Wer mit 58 aufhoert, kann die Saeule 3a trotzdem erst mit 60 beziehen.
expect(withRetirementDefaults("PILLAR_3A", 58, {}).withdrawalAge).toBe(60);
expect(withRetirementDefaults("PILLAR_3A", 75, {}).withdrawalAge).toBe(70);
});
it("überschreibt einen erfassten Wert nicht", () => {
expect(withRetirementDefaults("PENSION_FUND", 65, { capitalSharePct: 100 }).capitalSharePct).toBe(100);
});
});
describe("Ampel: drei Zustände statt zwei", () => {
// Bis 0.33 kannte das Modell nur "offen" oder "beantwortet". Eine durchgängige Vorgabe wäre
// damit als beantwortet durchgegangen -- und "volle Rente statt Kapitalbezug" hätte das
// Ergebnis massiv verändert, ohne dass es je jemandem aufgefallen wäre.
function p(confirmed: boolean) {
return plan({
age: 62,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 3, cashTransition: { mode: "NONE" } },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000 }, p2: { amount: 50000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 400000 }, p2: {} },
{},
{ capitalSharePct: 0, confirmed }
),
el("PILLAR_3A", "PERSON_A", { p1: { currentValue: 100000 }, p2: {} }, {}, { confirmed }),
],
});
}
it("zählt eine ungeprüfte Vorgabe eigens -- nicht als offenen Entscheid", () => {
const c = totalOpenDecisions(p(false), computePlan(p(false)));
expect(c.unconfirmed).toBe(2); // PK und 3a
expect(c.open).toBe(0); // der Cash-Übergang ist beantwortet
});
it("verschwindet, sobald bestätigt wurde", () => {
const c = totalOpenDecisions(p(true), computePlan(p(true)));
expect(c.unconfirmed).toBe(0);
expect(c.open).toBe(0);
});
it("benennt beides getrennt", () => {
expect(decisionsText({ open: 2, unconfirmed: 3 })).toBe("2 offene Entscheide · 3 Vorgaben ungeprüft");
expect(decisionsText({ open: 1, unconfirmed: 0 })).toBe("1 offener Entscheid");
expect(decisionsText({ open: 0, unconfirmed: 1 })).toBe("1 Vorgabe ungeprüft");
expect(decisionsText({ open: 0, unconfirmed: 0 })).toBe("");
});
});
+62 -2
View File
@@ -19,6 +19,7 @@ import { num } from "@/lib/elements";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
import type { ResolvedActuals } from "@/lib/actuals";
import type { RetirementDecision } from "@/lib/retirement-decision";
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
export type DriverId =
@@ -30,7 +31,9 @@ export type DriverId =
| "lifespan"
| "propertyGrowth"
| "retirementA"
| "retirementB";
| "retirementB"
| "pkCapitalShare"
| "ahvDrawMonths";
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
@@ -39,7 +42,7 @@ export type DriverId =
// absoluter Wert würde die PK auf ETF-Rendite plätten)
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge)
// delta_years Verschiebung in Jahren
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years";
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years" | "delta_months";
export interface DriverDef {
id: DriverId;
@@ -150,6 +153,28 @@ export const DRIVERS: DriverDef[] = [
"Verschiebt das Pensionsalter der Person B. Sinnvolle Bandbreite: 3 bis +3 Jahre. Der Spielraum endet dort, wo eine angrenzende Lebensphase verschwinden würde darüber hinaus eingegebene Jahre werden auf das Mögliche gekürzt.",
applies: (p) => retirementDriverApplies(p, "PERSON_B"),
},
// Pensionierungs-Entscheide als Treiber (0.34). Sie waren bisher gar nicht abbildbar: Der
// Bezugs-Entscheid hing an einer Phasengrenze, und der Tornado verschiebt Grenzen -- die
// Entscheide wären mitgewandert oder verloren gegangen. Seit sie am Element haengen, laesst
// sich beides sauber trennen.
{
id: "pkCapitalShare",
label: "PK: Anteil Kapitalbezug",
shortLabel: "PK Kapitalanteil",
unit: "abs_pct",
help:
"Absoluter Anteil des PK-Guthabens, der als Kapital bezogen wird (0 % = volle Rente, 100 % = volles Kapital). Sinnvolle Bandbreite: 0 bis 100 %. Die Rente ist ein sicherer, lebenslanger Fluss, das Kapital ist marktabhängig und vererbbar das ist der eigentliche Zielkonflikt dieser Frage.",
applies: (p) => hasCategory(p, ["PENSION_FUND"]),
},
{
id: "ahvDrawMonths",
label: "AHV: Vorbezug / Aufschub (Monate)",
shortLabel: "AHV-Bezug",
unit: "delta_months",
help:
"Verschiebung des AHV-Rentenbeginns in MONATEN gegenüber dem Referenzalter: negativ = Vorbezug (kürzt 6,8 % pro Jahr, lebenslang), positiv = Aufschub (erhöht bis +31,5 % nach 5 Jahren). Sinnvolle Bandbreite: 36 bis +60 Monate.",
applies: (p) => hasCategory(p, ["AHV"]),
},
{
id: "propertyGrowth",
label: "Wertsteigerung der Immobilie",
@@ -167,6 +192,7 @@ export const UNIT_SUFFIX: Record<DriverUnit, string> = {
delta_pp: "pp",
rel_pct: "%",
delta_years: "J.",
delta_months: "Mt.",
};
export function driverById(id: DriverId): DriverDef {
@@ -196,6 +222,22 @@ function mapElements(
};
}
// Wie `mapElements`, nur auf dem Pensionierungs-Entscheid statt auf den Phasenwerten.
function mapRetirement(
plan: PlanInput,
categories: ElementCategory[],
fn: (rd: RetirementDecision) => Partial<RetirementDecision>
): PlanInput {
return {
...plan,
elements: plan.elements.map((e) =>
categories.includes(e.category)
? { ...e, retirementDecision: { ...(e.retirementDecision ?? {}), ...fn(e.retirementDecision ?? {}) } }
: e
),
};
}
export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanInput {
switch (id) {
case "inflation":
@@ -215,6 +257,24 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
valueGrowth: num(pd.valueGrowth) + value,
}));
case "pkCapitalShare":
// Absolutwert, keine Verschiebung: Die Frage lautet "wie viel Kapital?", nicht
// "wie viel mehr als geplant?".
return mapRetirement(plan, ["PENSION_FUND"], () => ({
capitalSharePct: Math.max(0, Math.min(100, value)),
}));
case "ahvDrawMonths": {
// Ein einziger Regler für beide Richtungen: negativ = Vorbezug, positiv = Aufschub.
// Zwei getrennte Treiber hätten sich gegenseitig ausgeschlossen und den Tornado mit
// einer Zeile belastet, die je nach Vorzeichen der anderen wirkungslos ist.
const months = Math.round(value);
return mapRetirement(plan, ["AHV"], () => ({
ahvDraw: months === 0 ? "REFERENCE" : months < 0 ? "EARLY" : "DEFERRED",
ahvMonths: Math.abs(months),
}));
}
case "expenses":
case "income": {
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist: