Pensionierung als Eigenschaft der Person: Treiber, Tests, Spezifikation 0.35
Deploy App / deploy (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user