From 9907fda6f40ab63abe97f0b9baf2457acbffa80a Mon Sep 17 00:00:00 2001 From: kelle Date: Sun, 26 Jul 2026 13:18:38 +0200 Subject: [PATCH] Pensionierung: Entscheid ans Element, AHV-Vorbezug/Aufschub, PK-Kapitalquote Co-Authored-By: Claude Opus 5 --- .../migration.sql | 18 + prisma/schema.prisma | 8 + src/lib/bridges.test.ts | 44 ++- src/lib/calculations.test.ts | 33 +- src/lib/calculations.ts | 317 +++++++++++++----- src/lib/constants.ts | 53 +++ src/lib/retirement-decision.ts | 231 +++++++++++++ src/lib/retirement.ts | 52 +++ src/lib/types.ts | 7 + 9 files changed, 660 insertions(+), 103 deletions(-) create mode 100644 prisma/migrations/20260726090000_retirement_decision/migration.sql create mode 100644 src/lib/retirement-decision.ts diff --git a/prisma/migrations/20260726090000_retirement_decision/migration.sql b/prisma/migrations/20260726090000_retirement_decision/migration.sql new file mode 100644 index 0000000..cb191cd --- /dev/null +++ b/prisma/migrations/20260726090000_retirement_decision/migration.sql @@ -0,0 +1,18 @@ +-- Pensionierung wird eine Eigenschaft der PERSON statt der Zeitachse (SPEZIFIKATION 3.13). +-- +-- (1) `FinancialElement.retirementDecision` haelt den Bezugs-Entscheid von AHV, Pensionskasse +-- und Saeule 3a. Bisher lag er in `ElementTransitionValue` -- also am Schluessel +-- Element x Phasen-ID. Verschob sich das Pensionsalter, wanderte die Phasengrenze, und der +-- Entscheid musste verlustbehaftet von Grenze zu Grenze gerettet werden. Ohne Phasen-ID im +-- Schluessel ueberlebt er jede Zeitachsen-Aenderung. +-- +-- (2) `Person.planningHorizonAge` macht das Planende explizit. Bisher ergab es sich +-- stillschweigend als Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt +-- verschieden weit rechnen und waren nicht vergleichbar. NULL bedeutet weiterhin +-- "aus den Phasendauern ableiten", der Wert ist also rueckwaertskompatibel. +-- +-- Bewusst OHNE Datenmigration: Alte Entscheide in `ElementTransitionValue` bleiben liegen und +-- werden vom Rechenkern ignoriert. Die betroffenen Elemente erscheinen dadurch als +-- "Vorgabe ungeprueft" -- genau der Zustand, den das neue Drei-Zustands-Modell dafuer kennt. +ALTER TABLE "FinancialElement" ADD COLUMN "retirementDecision" JSONB; +ALTER TABLE "Person" ADD COLUMN "planningHorizonAge" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index befb32e..a70fe01 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -77,6 +77,10 @@ model Person { scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) role PersonRole retirementAge Int + // Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus + // der Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit + // rechnen und waren nicht vergleichbar. + planningHorizonAge Int? @@unique([scenarioId, role]) } @@ -301,6 +305,10 @@ model FinancialElement { // Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs. sourceElementId String? + // Pensionierungs-Entscheid (AHV/PK/3a). Bewusst OHNE Phasen-ID im Schluessel: So ueberlebt + // er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts. + retirementDecision Json? + phaseValues ElementPhaseValue[] transitionValues ElementTransitionValue[] } diff --git a/src/lib/bridges.test.ts b/src/lib/bridges.test.ts index 2095d7b..aeea2a6 100644 --- a/src/lib/bridges.test.ts +++ b/src/lib/bridges.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { computePlan } from "@/lib/calculations"; import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; +import type { RetirementDecision } from "@/lib/retirement-decision"; import type { PlanInput } from "@/lib/types"; // Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen @@ -16,9 +17,20 @@ function el( category: ElementCategory, ownerRole: string | null, phaseValues: Record, - transitionValues: Record = {} + transitionValues: Record = {}, + // Pensionierungs-Entscheid: haengt seit 0.34 am Element, nicht an einer Phasengrenze. + retirementDecision: RetirementDecision = {} ) { - return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues }; + return { + id: nid(), + category, + name: category, + ownerRole: ownerRole as never, + orderIndex: idc, + phaseValues, + transitionValues, + retirementDecision, + }; } function plan(opts: { @@ -84,13 +96,15 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ "PENSION_FUND", "PERSON_A", { p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } }, - { p1: { payoutMode: "PENSION", conversionRate: 6 } } + {}, + { capitalSharePct: 0, conversionRate: 6 } ), el( "PILLAR_3A", "PERSON_A", { p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } }, - { p1: { capitalTaxRate: 8 } } + {}, + { capitalTaxRate: 8 } ), el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 300000, expectedReturn: 4 }, @@ -116,7 +130,8 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ "PENSION_FUND", "PERSON_A", { p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } }, - { p1: { payoutMode: "CAPITAL", capitalTaxRate: 8 } } + {}, + { capitalSharePct: 100, capitalTaxRate: 8 } ), ], }), @@ -240,7 +255,8 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ "PENSION_FUND", "PERSON_A", { p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} }, - { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } } + {}, + { capitalSharePct: 100, capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } ), el( "REAL_ESTATE", @@ -317,7 +333,7 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", () ...raw, elements: raw.elements.map((e) => e.category === "PENSION_FUND" - ? { ...e, transitionValues: { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } } + ? { ...e, retirementDecision: { capitalSharePct: 100, capitalTaxRate: 5 } } : e ), } as PlanInput); @@ -372,7 +388,8 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => { "PENSION_FUND", "PERSON_A", { p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} }, - { p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } } + {}, + { capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 } ), el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }), ], @@ -414,7 +431,8 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => { "PILLAR_3A", "PERSON_A", { p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} }, - { p1: { capitalTaxRate: 0, capitalUseInvestPct: 50 } } + {}, + { capitalTaxRate: 0, capitalUseInvestPct: 50 } ), ], } as PlanInput; @@ -444,13 +462,15 @@ describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => { "PENSION_FUND", "PERSON_A", { p1: { currentValue: 300000, expectedReturn: 0 }, p2: {} }, - { p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } } + {}, + { capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 } ), el( "PILLAR_3A", "PERSON_A", { p1: { currentValue: 200000, expectedReturn: 0 }, p2: {} }, - { p1: { capitalTaxRate: 0, capitalUseInvestPct: 100 } } + {}, + { capitalTaxRate: 0, capitalUseInvestPct: 100 } ), el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: {} }), ], @@ -503,6 +523,6 @@ describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => { : e ), }; - expect(computePlan(mitRate).phases[1].endWealth).toBe(computePlan(base).phases[1].endWealth); + expect(computePlan(mitRate).phases[1].endWealthNominal).toBe(computePlan(base).phases[1].endWealthNominal); }); }); diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts index f653e05..7499220 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations"; import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; +import type { RetirementDecision } from "@/lib/retirement-decision"; import type { PlanInput } from "@/lib/types"; // --- kleine Bau-Helfer --- @@ -11,9 +12,20 @@ function el( category: ElementCategory, ownerRole: string | null, phaseValues: Record, - transitionValues: Record = {} + transitionValues: Record = {}, + // Pensionierungs-Entscheid: hängt seit 0.34 am Element, nicht an einer Phasengrenze. + retirementDecision: RetirementDecision = {} ) { - return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues }; + return { + id: nid(), + category, + name: category, + ownerRole: ownerRole as never, + orderIndex: idc, + phaseValues, + transitionValues, + retirementDecision, + }; } function plan(opts: { @@ -261,12 +273,11 @@ describe("AHV einkommensabhängig", () => { "AHV", "PERSON_A", { p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} }, + {}, { - p1: { - reviewed: opts.reviewed ?? true, - avgIncomeBefore: opts.avgIncomeBefore ?? 0, - gapYearsBefore: opts.gapYearsBefore ?? 0, - }, + confirmed: opts.reviewed ?? true, + avgIncomeBefore: opts.avgIncomeBefore ?? 0, + gapYearsBefore: opts.gapYearsBefore ?? 0, } ), ], @@ -356,13 +367,13 @@ describe("AHV einkommensabhängig", () => { expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13)); }); - it("bereits bei Planbeginn pensioniert: Karriere kommt aus der Phasenzelle", () => { + it("bereits bei Planbeginn pensioniert: Karriere kommt aus dem Pensionierungs-Entscheid", () => { const p = plan({ age: 66, retirementAge: 65, inflation: 0, phases: [{ id: "p1", durationYears: 10 }], - elements: [el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 60000, gapYearsBefore: 0 } })], + 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)); @@ -377,8 +388,8 @@ describe("AHV einkommensabhängig", () => { ], 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 } }), + el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 100000 }), + el("AHV", "PERSON_B", { p1: {} }, {}, { avgIncomeBefore: 100000 }), ], }; const ph = computePlan(p).phases[0]; diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index e8ae668..cb40d43 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -10,11 +10,21 @@ import { DEFAULT_CAPITAL_TAX_RATE, DEFAULT_PK_CONVERSION_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE, + PILLAR_3A_MAX_WITHDRAWAL_AGE, + PILLAR_3A_MIN_WITHDRAWAL_AGE, } from "@/lib/constants"; import { num } from "@/lib/elements"; import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals"; -import type { ElementCategory, TransitionData } from "@/lib/elements"; -import type { PersonRole, PlanInput } from "@/lib/types"; +import { + ahvDrawLabel, + ahvFactor, + ahvShiftMonths, + ahvStartAge, + withRetirementDefaults, + type RetirementDecision, +} from "@/lib/retirement-decision"; +import type { ElementCategory } from "@/lib/elements"; +import type { ElementInput, PersonRole, PlanInput } from "@/lib/types"; export type PhaseType = "ERWERB" | "PENSION" | "MIXED"; export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED"; @@ -215,10 +225,37 @@ export interface YearPoint { cash: number; } +// Kennzahlen des Pensionierungs-Bildschirms. Bewusst im Rechenkern und nicht im UI: Es sind +// die beiden Zahlen, an denen die ganze Planung haengt -- eine Nebenrechnung in der +// Komponente wuerde im PDF-Bericht anders ausfallen. +export interface RetirementPersonSummary { + personId: string; + role: PersonRole; + retirementAge: number; + planningHorizonAge: number | null; + ahvAnnual: number; // inkl. Kuerzung bei Vorbezug bzw. Zuschlag bei Aufschub + ahvFromAge: number; // individuelles Rentenalter, nicht zwingend das Referenzalter + ahvDraw: string; // lesbare Kurzfassung ("Referenzalter", "Vorbezug 2 J. (-13.6 %)") + pkPensionAnnual: number; + capitalAtRetirement: number; // PK-Kapital + Saeule 3a, NETTO nach Kapitalbezugssteuer +} + +export interface RetirementOverview { + // Planjahr, ab dem NIEMAND mehr erwerbstaetig ist. null, solange jemand arbeitet. + firstRetirementYear: number | null; + pensionIncome: number | null; + expenses: number | null; + // Renteneinkommen minus Ausgaben im ersten voll pensionierten Jahr. Negativ = Luecke, die + // aus dem Vermoegen zu decken ist. + gapAnnual: number | null; + perPerson: RetirementPersonSummary[]; +} + export interface PlanComputed { phases: PhaseComputed[]; yearly: YearPoint[]; nachlass: number; + retirement: RetirementOverview; ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermögen (inkl. Cash) erstmals < 0 fällt ahvCareer: AhvCareer[]; // Beitragskarriere je Person (für die AHV-Prüfung am Übergang) traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain) @@ -405,11 +442,32 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp // AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre. const ahvIncomeAccum = new Map(); const ahvYearsAccum = new Map(); - // Karriere VOR Planbeginn -- aus der Prüfung am Pensions-Übergang bzw. (für bereits - // Pensionierte) aus der Phasenzelle der ersten Phase. - const ahvBeforeByPerson = new Map(); + // Karriere VOR Planbeginn und der Bezugs-Entscheid, je Person. Beides steht seit 0.34 im + // Pensionierungs-Entscheid des AHV-Elements -- vorher lag es an ZWEI Orten (Übergangszelle + // bzw., für bei Planbeginn bereits Pensionierte, Phasenzelle der ersten Phase), was zwei + // Codepfade für dieselbe Frage bedeutete. + // avg === null heisst NICHT ERFASST -- dann gilt der geplante Durchschnitt als Schaetzung + // (siehe unten). Ein Fallback auf 0 wuerde die Rente still und massiv zu tief rechnen. + const ahvBeforeByPerson = new Map(); + const ahvDrawByPerson = new Map(); + for (const e of plan.elements) { + if (e.category !== "AHV" || !e.ownerRole) continue; + const owner = personByRole(persons, e.ownerRole); + if (!owner) continue; + const rd = withRetirementDefaults("AHV", retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE, e.retirementDecision); + ahvDrawByPerson.set(owner.id, rd); + ahvBeforeByPerson.set(owner.id, { + avg: typeof rd.avgIncomeBefore === "number" ? rd.avgIncomeBefore : null, + gap: Math.max(0, Math.round(num(rd.gapYearsBefore))), + }); + } const carries = new Map(); for (const e of plan.elements) carries.set(e.id, emptyCarry()); + // Fuer die Pensionierungs-Uebersicht: die zuletzt gerechnete AHV-Rente je Person und das + // netto bezogene Alterskapital (PK + 3a). Beides faellt in der Phasen- bzw. Uebergangs- + // schleife ohnehin an -- gesammelt wird es hier, damit es am Ende zur Verfuegung steht. + const ahvFinalByPerson = new Map(); + const capitalNetByPerson = new Map(); const result: PhaseComputed[] = []; const yearly: YearPoint[] = []; @@ -471,20 +529,6 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy); } - // Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Übergang, an dem die - // Beitragskarriere geprüft werden könnte -- 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 Übergang 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: Vollrente zum mdJE, gekürzt um die Ausfalljahre. // // Seit Roadmap Nr. 44 wird die Rente für JEDE Person mit AHV-Element gerechnet, nicht nur @@ -496,9 +540,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp if (e.category !== "AHV" || !e.ownerRole) continue; const owner = personByRole(persons, e.ownerRole); if (!owner) continue; - const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 }; + const before = ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 }; const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson); - const mdJE = ahvMdje(career, before.avg, before.gap); + // Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre vor + // Planbeginn -- exakt der Wert, den das UI vorbelegt. Beide Groessen sind BRUTTO. + const avgBefore = before.avg ?? career.plannedAvgGrossIncome; + const mdJE = ahvMdje(career, avgBefore, before.gap); const totalGap = (gapYearsByPerson.get(owner.id) ?? 0) + before.gap; ahvUncapped.set(owner.id, ahvAnnualPension(mdJE, totalGap)); } @@ -508,6 +555,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR; if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum))); } + // Vorbezug kürzt, Aufschub erhöht -- und zwar NACH dem Ehepaar-Plafond: Der Plafond gilt + // für die ordentlichen Renten, die individuelle Kürzung setzt darauf auf. + for (const [pid, v] of [...ahvFinal]) { + const f = ahvFactor(ahvDrawByPerson.get(pid) ?? {}); + if (f !== 1) ahvFinal.set(pid, Math.round(v * f)); + } + for (const [pid, v] of ahvFinal) ahvFinalByPerson.set(pid, v); // --- Element-Laufzeitzustände aufbauen --- const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex); @@ -522,6 +576,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const ahvItems: { ownerId: string; ownerStartAge: number; + // Alter, ab dem die Rente DIESER Person fliesst. Bis 0.33 war das fix das + // Referenzalter; mit Vorbezug/Aufschub ist es individuell. Für das Jahresraster + // gerundet -- die Monatsgenauigkeit steckt im Faktor, nicht im Auszahlungszeitpunkt. + renteFromAge: number; rente: number; beitrag: number; working: boolean; @@ -661,29 +719,46 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp if (owner) { const rente = ahvFinal.get(owner.id) ?? 0; const beitrag = Math.round(num(pd.ahvContribution)); + const rd = ahvDrawByPerson.get(owner.id) ?? {}; + // Rentenbeginn individuell (Vorbezug/Aufschub), Beitragspflicht dagegen IMMER bis + // zum Referenzalter: Wer die Rente vorbezieht, ist damit nicht von den Beiträgen + // als Nichterwerbstätige(r) befreit -- die beiden Alter sind zu trennen. + const renteFromAge = Math.round(ahvStartAge(rd)); const ageStart = owner.age + yearsBefore; // Alter im ersten Jahr der Phase const ageEnd = ageStart + duration - 1; // Alter im letzten Jahr der Phase - ahvItems.push({ ownerId: owner.id, ownerStartAge: ageStart, rente, beitrag, working: ownerWorking, ec }); + ahvItems.push({ + ownerId: owner.id, + ownerStartAge: ageStart, + renteFromAge, + rente, + beitrag, + working: ownerWorking, + ec, + }); - const reachesRef = ageEnd >= AHV_REFERENCE_AGE; - const startsRetired = ageStart >= AHV_REFERENCE_AGE; - ec.startValue = startsRetired ? rente : 0; - ec.endValue = reachesRef ? rente : 0; + const reachesRente = ageEnd >= renteFromAge; + const startsWithRente = ageStart >= renteFromAge; + const owesContribution = !ownerWorking && ageStart < AHV_REFERENCE_AGE; + ec.startValue = startsWithRente ? rente : 0; + ec.endValue = reachesRente ? rente : 0; - if (startsRetired) { + if (startsWithRente) { ec.summary = `Rente ${fmt(rente)}`; - } else if (reachesRef && !ownerWorking) { + } else if (reachesRente && owesContribution) { ec.summary = `Beitrag ${fmt(beitrag)} → Rente ${fmt(rente)}`; - ec.note = `Rente ab Alter ${AHV_REFERENCE_AGE}; bis dahin Beitrag als Nichterwerbstätige(r).`; - } else if (reachesRef) { - ec.summary = `Rente ab ${AHV_REFERENCE_AGE} ${fmt(rente)}`; - } else if (!ownerWorking) { + ec.note = `Rente ab Alter ${renteFromAge}; bis Alter ${AHV_REFERENCE_AGE} Beitrag als Nichterwerbstätige(r).`; + } else if (reachesRente) { + ec.summary = `Rente ab ${renteFromAge} ${fmt(rente)}`; + } else if (owesContribution) { ec.summary = `Beitrag ${fmt(beitrag)}`; ec.note = `Frühpensioniert: beitragspflichtig bis Alter ${AHV_REFERENCE_AGE}.`; } else { const gap = Math.max(0, Math.round(num(pd.gapYears))); ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre"; } + if (ahvShiftMonths(rd) !== 0) { + ec.note = [ec.note, `AHV: ${ahvDrawLabel(rd)}.`].filter(Boolean).join(" "); + } } break; } @@ -847,15 +922,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp interestNominal += re.mortgage * (re.interestRate / 100); } - // AHV jahresweise: Die Rente fliesst ab dem Referenzalter -- auch wenn die Person noch - // arbeitet. Vorher zahlt eine bereits pensionierte Person Beiträge, die wie eine - // Ausgabe auf die Quote schlagen (Kap. 4.4.6). + // AHV jahresweise: Die Rente fliesst ab dem individuellen Rentenalter -- auch wenn die + // Person noch arbeitet. Unabhängig davon zahlt eine nicht (mehr) erwerbstätige Person + // bis zum REFERENZALTER Beiträge, die wie eine Ausgabe auf die Quote schlagen + // (Kap. 4.4.6). Beide Bedingungen können gleichzeitig gelten: Wer mit 62 aufhört und + // die Rente ab 63 vorbezieht, bezieht ab 63 und zahlt bis 65. let ahvIncome = 0; let ahvCost = 0; for (const a of ahvItems) { const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn - if (ageThisYear >= AHV_REFERENCE_AGE) ahvIncome += a.rente; - else if (!a.working) ahvCost += a.beitrag; + if (ageThisYear >= a.renteFromAge) ahvIncome += a.rente; + if (!a.working && ageThisYear < AHV_REFERENCE_AGE) ahvCost += a.beitrag; } incomeFlow += ahvIncome; @@ -1020,8 +1097,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp // also als Belastung sichtbar), ab dem Referenzalter die Rente. for (const a of ahvItems) { const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn - const value = ageThisYear >= AHV_REFERENCE_AGE ? a.rente : a.working ? 0 : -a.beitrag; - if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value }); + const rente = ageThisYear >= a.renteFromAge ? a.rente : 0; + const beitrag = !a.working && ageThisYear < AHV_REFERENCE_AGE ? a.beitrag : 0; + if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value: rente - beitrag }); } // Verrentete PK läuft nominal fix durch die Phase. for (const ec of ecById.values()) { @@ -1229,9 +1307,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp if (e.category === "AHV") { const owner = e.ownerRole ? personByRole(persons, e.ownerRole) : null; const gap = owner ? gapYearsByPerson.get(owner.id) ?? 0 : 0; - const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 } : { avg: 0, gap: 0 }; + const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 } : { avg: null, gap: 0 }; const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null; - const mdJE = career ? ahvMdje(career, before.avg, before.gap) : 0; + const avgBefore = before.avg ?? career?.plannedAvgGrossIncome ?? 0; + const mdJE = career ? ahvMdje(career, avgBefore, before.gap) : 0; ec.trace = { title: `AHV-Rente «${ec.name}»`, specAnchor: "44-ahv-rente", @@ -1456,7 +1535,32 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp let txImmediateRepay = 0; // Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND // der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt. - const capitalUses: { net: number; td: TransitionData; sourceName: string }[] = []; + const capitalUses: { net: number; td: RetirementDecision; sourceName: string }[] = []; + + // Zieht dieses 3a-Konto an DIESER Phasengrenze? Ein Konto lässt sich nur ganz auflösen, + // und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb + // an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem + // Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt. + const drawsPillar3aHere = ( + el: ElementInput, + ownerPerson: { id: string; age: number } | null, + ageAtBoundary: number + ): boolean => { + // 3a ist personengebunden -- ohne Besitzer gibt es kein Bezugsalter und keinen Bezug. + if (!ownerPerson) return false; + const rd = withRetirementDefaults( + "PILLAR_3A", + retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE, + el.retirementDecision + ); + const wish = Math.max( + PILLAR_3A_MIN_WITHDRAWAL_AGE, + Math.min(PILLAR_3A_MAX_WITHDRAWAL_AGE, Math.round(num(rd.withdrawalAge, ageAtBoundary))) + ); + const prevBoundaryAge = ownerPerson.age + yearsBefore; + if (prevBoundaryAge >= wish) return false; // in einer früheren Phase bereits gezogen + return ageAtBoundary >= wish || !nextPhase; + }; // Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase). // Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind // nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz @@ -1496,6 +1600,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const ownerRetiresNext = !!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration); + // Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für + // die Vorgaben des Pensionierungs-Entscheids und für das 3a-Bezugsalter. + const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE; + const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0; // Einkommen/Ausgaben: Basiswert wurde bereits im Element-Setup fortgeschrieben. if (e.category === "INCOME" || e.category === "EXPENSE") { @@ -1503,18 +1611,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp continue; } - // AHV: beim Pensions-Übergang die geprüften Karriere-Werte vor Planbeginn übernehmen. + // AHV: Der Bezugs-Entscheid hängt seit 0.34 am Element, nicht am Übergang -- hier ist + // deshalb nichts mehr zu tun. 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 Schätzung für die Jahre - // vor Planbeginn -- exakt der Wert, den der Prüf-Dialog vorbelegt. Ein Fallback auf - // 0 würde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO. - avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome), - gap: Math.max(0, Math.round(num(td.gapYearsBefore))), - }); - } carry.hasCarry = true; continue; } @@ -1530,29 +1629,26 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp switch (e.category) { case "PENSION_FUND": { if (ownerRetiresNext) { + // EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente, + // 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil + // sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim + // Verschieben des Alters still ein anderes Verhältnis bedeuten. const value = ec.endValue; - const mode = td.payoutMode ?? "PENSION"; - if (mode === "CAPITAL") { - const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); - txInflow += net; - txTax += value - net; - capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) }); - carry.value = 0; - carry.pkPensionAnnual = 0; - } else if (mode === "PENSION") { - carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); - txPensionConversion += value; - carry.value = 0; - } else { - const capital = Math.min(value, Math.round(num(td.capitalAmount))); - const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + const rd = withRetirementDefaults("PENSION_FUND", ownerRetirementAge, e.retirementDecision); + const sharePct = Math.max(0, Math.min(100, num(rd.capitalSharePct))); + const capital = Math.round((value * sharePct) / 100); + const rest = value - capital; + if (capital > 0) { + const net = Math.round(capital * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); txInflow += net; txTax += capital - net; - capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) }); - carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); - txPensionConversion += value - capital; - carry.value = 0; + capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) }); + if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net); } + carry.pkPensionAnnual = + rest > 0 ? Math.round((rest * num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100) : 0; + txPensionConversion += rest; + carry.value = 0; } else { // Vorbezug (z. B. Wohneigentum/Selbstständigkeit): ebenfalls kapitalbezugssteuerpflichtig. // Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash. @@ -1565,11 +1661,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp break; } case "PILLAR_3A": { - if (ownerRetiresNext) { - const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + // Der Bezug hängt seit 0.34 am gewählten Alter, nicht mehr starr am Pensions- + // Übergang: Ein 3a-Konto lässt sich nur GANZ auflösen, und alle Bezüge desselben + // Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten + // und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter. + if (drawsPillar3aHere(e, owner, ownerAgeAtBoundary)) { + const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision); + const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); txInflow += net; txTax += ec.endValue - net; - capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) }); + capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) }); + if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net); carry.value = 0; } else { const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); @@ -1794,6 +1896,58 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0; const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson)); + // --- Pensionierung: die Kennzahlen des gleichnamigen Bildschirms ------------------------- + // + // Bewusst HIER und nicht im UI: Rentenlücke und Reichweite sind die beiden Zahlen, an denen + // die ganze Planung hängt. Eine Nebenrechnung in der Komponente hätte dieselbe Driftgefahr + // wie bei den Rechenwegen (Kap. 4.14.3) -- und würde im PDF-Bericht anders ausfallen. + // + // Die Rentenlücke ist KEINE neue Grösse: Sie ist die Verzehrquote im ersten Jahr, in dem + // niemand mehr arbeitet -- Renteneinkommen minus Ausgaben. Genau das rechnet die + // Jahresschleife ohnehin, es fehlte nur der Name dafür. + const retirement = ((): RetirementOverview => { + // Startjahr je Phase (1-basiert) -- PhaseComputed fuehrt nur die Dauer. + const startYearOf = new Map(); + let acc = 1; + for (const r of result) { + startYearOf.set(r.id, acc); + acc += r.durationYears; + } + const firstRetiredPhase = result.find((p) => p.persons.every((x) => !x.working)); + const perPerson = persons.map((p) => { + // Die erste Phase, in der DIESE Person nicht mehr arbeitet -- dort stehen ihre Renten. + const ph = result.find((r) => r.persons.find((x) => x.role === p.role && !x.working)); + const own = (cat: ElementCategory) => + ph?.elements.find((e) => e.category === cat && e.ownerRole === p.role); + const rd = ahvDrawByPerson.get(p.id) ?? {}; + return { + personId: p.id, + role: p.role, + retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE, + planningHorizonAge: p.planningHorizonAge ?? null, + ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0), + ahvFromAge: Math.round(ahvStartAge(rd)), + ahvDraw: ahvDrawLabel(rd), + pkPensionAnnual: Math.round(own("PENSION_FUND")?.startValue ?? 0), + // Einmalig verfügbares Alterskapital: netto, nach Kapitalbezugssteuer. + capitalAtRetirement: Math.round(capitalNetByPerson.get(p.id) ?? 0), + }; + }); + if (!firstRetiredPhase) { + return { firstRetirementYear: null, pensionIncome: null, expenses: null, gapAnnual: null, perPerson }; + } + // Erstes Jahr dieser Phase: Einkommen und Ausgaben stehen im Jahrespunkt. + const firstYear = startYearOf.get(firstRetiredPhase.id) ?? 1; + const yp = yearly.find((y) => y.year === firstYear); + return { + firstRetirementYear: firstYear, + pensionIncome: yp ? Math.round(yp.income) : null, + expenses: yp ? Math.round(yp.expenseNominal) : null, + gapAnnual: yp ? Math.round(yp.income - yp.expenseNominal) : null, + perPerson, + }; + })(); + // --- Plan-weite Rechenwege --- const planTraces: Trace[] = []; if (explain) { @@ -1810,8 +1964,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp }); for (const career of ahvCareer) { - const before = ahvBeforeByPerson.get(career.personId) ?? { avg: 0, gap: 0 }; - const mdJE = ahvMdje(career, before.avg, before.gap); + const before = ahvBeforeByPerson.get(career.personId) ?? { avg: null, gap: 0 }; + const avgBefore = before.avg ?? career.plannedAvgGrossIncome; + const mdJE = ahvMdje(career, avgBefore, before.gap); planTraces.push({ title: `AHV-Beitragskarriere – ${career.role === "PERSON_A" ? "Person A" : "Person B"}`, specAnchor: "442-beitragskarriere-und-mdje", @@ -1820,8 +1975,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp st("Durchschnittliches Bruttöinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined, "Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."), st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn − 21 − Ausfalljahre davor", undefined, undefined, "Jahre"), - st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined, - before.avg === 0 && career.yearsBeforePlan > 0 ? "Noch nicht erfasst – am Pensions-Übergang zu prüfen." : "Aus der AHV-Rentenvorausberechnung übernommen."), + st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(avgBefore), undefined, undefined, + before.avg === null && career.yearsBeforePlan > 0 + ? "Nicht erfasst – geschätzt aus dem geplanten Durchschnitt. Im Pensionierungs-Bildschirm zu prüfen." + : "Aus der AHV-Rentenvorausberechnung übernommen."), st("Massgebendes durchschnittliches Jahreseinkommen", Math.round(mdJE), "(Einkommen davor × Jahre davor + Einkommen im Plan × Jahre im Plan) / Total Jahre", undefined, "REAL gerechnet: die echte AHV wertet vergangene Einkommen auf UND indexiert die Schwellen – beides hebt sich real weitgehend auf."), @@ -1841,7 +1998,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp }); } - return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined }; + return { phases: result, yearly, nachlass, retirement, ruinAge, ahvCareer, traces: explain ? planTraces : undefined }; } // Durchschnittliches REALES Jahreseinkommen über eine Phase. Nominal wächst der Flow mit diff --git a/src/lib/constants.ts b/src/lib/constants.ts index d81ac09..6376661 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -23,6 +23,59 @@ export const AHV_REFERENCE_AGE = 65; // 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; +// --- Flexibler Rentenbezug (AHV 21) -------------------------------------------------------- +// +// Vorbezug: fruehestens 3 Jahre vor dem Referenzalter, seit AHV 21 in MONATSschritten. +// Die Kuerzung ist lebenslang und betraegt 6.8 % pro vorbezogenem Jahr; Monate anteilig. +// Die seit 1.1.2025 tieferen, einkommensabhaengigen Saetze fuer Frauen der +// Uebergangsgeneration (Jahrgaenge 1961-1969) sind BEWUSST nicht abgebildet -- sie laufen +// aus, und ihre Nachbildung erforderte eine zweite, jahrgangsabhaengige Rentenformel. +// Quelle: Merkblatt 3.04 "Flexibler Rentenbezug", Stand 1.1.2026. +export const AHV_EARLY_MAX_MONTHS = 36; +export const AHV_EARLY_REDUCTION_PER_YEAR = 6.8; + +// Aufschub: 1 bis 5 Jahre. Der Zuschlag ist NICHT linear -- er steigt ueberproportional, +// weil sich die Bezugsdauer verkuerzt. Amtliche Stuetzwerte je volles Jahr; fuer Monate +// dazwischen wird linear interpoliert (die amtliche Tabelle ist monatsgenau, die Abweichung +// liegt im Zehntelprozent-Bereich und damit weit unter der Unschaerfe der Rentenschaetzung). +export const AHV_DEFER_MIN_MONTHS = 12; +export const AHV_DEFER_MAX_MONTHS = 60; +export const AHV_DEFER_BONUS_BY_YEAR = [0, 5.2, 10.8, 17.1, 24.0, 31.5]; + +// Teilbezug/Teilaufschub: zwischen 20 und 80 Prozent der Rente. +export const AHV_PARTIAL_MIN_PCT = 20; +export const AHV_PARTIAL_MAX_PCT = 80; + +// --- Saeule 3a: Bezugsfenster --------------------------------------------------------------- +// +// Fruehestens 5 Jahre vor dem Referenzalter (Art. 3 BVV3). Spaetestens beim Referenzalter -- +// aufschiebbar bis 70, solange eine Erwerbstaetigkeit besteht. Ein Konto laesst sich bei der +// Pensionierung nur GANZ aufloesen; gestaffelt wird ueber mehrere Konten. +export const PILLAR_3A_MIN_WITHDRAWAL_AGE = AHV_REFERENCE_AGE - 5; +export const PILLAR_3A_MAX_WITHDRAWAL_AGE = AHV_REFERENCE_AGE + 5; + +// --- Pensionskasse: Bezugsfenster ----------------------------------------------------------- +// +// Gesetzliches Mindestalter fuer die vorzeitige Pensionierung (Art. 1i BVV2). Viele Reglemente +// setzen erst bei 60 an -- deshalb Hinweis statt Sperre. +export const PK_MIN_RETIREMENT_AGE = 58; +export const PK_MAX_RETIREMENT_AGE = AHV_REFERENCE_AGE + 5; + +// Sperrfrist nach einem Einkauf, innerhalb derer ein Kapitalbezug den Steuerabzug +// nachtraeglich entfallen laesst (Art. 79b Abs. 3 BVG). Reines Hinweis-Mass: Das Tool kennt +// keine Einkaeufe und kann die Frist deshalb nicht selbst pruefen. +export const PK_BUYIN_BLOCKING_YEARS = 3; + +// --- Planungshorizont ----------------------------------------------------------------------- +// +// Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus der +// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen +// und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu +// kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss. +export const DEFAULT_PLANNING_HORIZON_AGE = 90; +export const MIN_PLANNING_HORIZON_AGE = 70; +export const MAX_PLANNING_HORIZON_AGE = 110; + // Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so // denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn. // diff --git a/src/lib/retirement-decision.ts b/src/lib/retirement-decision.ts new file mode 100644 index 0000000..dc3f264 --- /dev/null +++ b/src/lib/retirement-decision.ts @@ -0,0 +1,231 @@ +// Der Pensionierungs-Entscheid je Vorsorge-Element. +// +// WARUM EIN EIGENER SPEICHERORT UND NICHT `transitionValues`? +// +// Bis 0.34 hing jeder dieser Entscheide an `transitionValues[phaseId]` -- also am Schluessel +// Element x Phasen-ID. Das war der Grund fuer eine ganze Reihe von Aergernissen: Verschob man +// das Pensionsalter, wanderte die Phasengrenze, und die Entscheide mussten ueber +// `mergeTransition` von Grenze zu Grenze gerettet werden -- verlustbehaftet. Ein Szenario nur +// wegen eines anderen Pensionsalters aufzusetzen hiess, alle Entscheide erneut zu treffen. +// Und der Ziel-Solver (Roadmap Nr. 21) haette bei jedem Kandidatenalter die Phasenstruktur +// umbauen und die Entscheide neu einsammeln muessen. +// +// Ohne Phasen-ID im Schluessel ueberlebt der Entscheid JEDE Zeitachsen-Aenderung. Die +// Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse -- die Zeitachse ist die +// Folge davon. +// +// Was hier NICHT hineingehoert: Vorbezuege (PK/3a vor der Pensionierung), Verkaeufe, +// Tilgungen, der Cash-Uebergang. Das sind echte Ereignisse an einer bestimmten Phasengrenze +// und bleiben in `transitionValues`. + +import { z } from "zod"; +import { + AHV_DEFER_BONUS_BY_YEAR, + AHV_DEFER_MAX_MONTHS, + AHV_EARLY_MAX_MONTHS, + AHV_EARLY_REDUCTION_PER_YEAR, + AHV_PARTIAL_MIN_PCT, + AHV_REFERENCE_AGE, + DEFAULT_CAPITAL_TAX_RATE, + DEFAULT_PK_CONVERSION_RATE, + PILLAR_3A_MAX_WITHDRAWAL_AGE, + PILLAR_3A_MIN_WITHDRAWAL_AGE, +} from "@/lib/constants"; +import type { ElementCategory } from "@/lib/elements"; + +export type AhvDraw = "EARLY" | "REFERENCE" | "DEFERRED"; + +export interface RetirementDecision { + // Hat die Person hingeschaut? Fehlt das Haekchen, rechnet das Tool mit einer Vorgabe -- + // ein dritter Zustand neben "unbeantwortet" und "beantwortet" (SPEZIFIKATION 3.5.3). + confirmed?: boolean; + + // --- AHV ------------------------------------------------------------------------------- + ahvDraw?: AhvDraw; + // Monate des Vorbezugs bzw. des Aufschubs. Nur bei EARLY/DEFERRED von Bedeutung. + ahvMonths?: number; + // Teilbezug/Teilaufschub: Anteil der Rente, der vorbezogen bzw. aufgeschoben wird. + ahvSharePct?: number; + // Beitragskarriere vor Planbeginn. Keine Entscheidung, sondern Datenqualitaet -- deshalb + // im UI abgesetzt dargestellt. avgIncomeBefore ist REAL (heutige Kaufkraft) und BRUTTO. + avgIncomeBefore?: number; + gapYearsBefore?: number; + + // --- Pensionskasse --------------------------------------------------------------------- + // Anteil des Altersguthabens, der als Kapital bezogen wird. EIN Regler statt des frueheren + // Modus PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 = volle Rente, 100 = volles Kapital, + // alles dazwischen ist die Kombination. Als Quote und nicht in Franken, weil sich das + // Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch. + capitalSharePct?: number; + conversionRate?: number; + // Reines Hinweis-Flag: Ein Kapitalbezug innerhalb von drei Jahren nach einem Einkauf laesst + // den Steuerabzug nachtraeglich entfallen (Art. 79b Abs. 3 BVG). Das Tool kennt keine + // Einkaeufe und kann das nicht selbst pruefen -- deshalb die Frage statt einer Automatik. + recentBuyIn?: boolean; + + // --- Saeule 3a ------------------------------------------------------------------------- + // Alter, in dem dieses Konto aufgeloest wird. Ein 3a-Konto laesst sich bei der + // Pensionierung nur GANZ aufloesen -- gestaffelt wird ueber mehrere Konten, und genau + // dafuer gibt es dieses Feld. Der Bezug erfolgt an der Phasengrenze bei oder nach diesem + // Alter (siehe `effectiveWithdrawalAge`). + withdrawalAge?: number; + + // --- gemeinsam: Kapitalbezug und seine Verwendung --------------------------------------- + capitalTaxRate?: number; + capitalUseAmortizationPct?: number; + capitalUseInvestPct?: number; + capitalUseTargetElementId?: string; +} + +const pct = z.number().min(0).max(100); + +export const retirementDecisionSchema = z + .object({ + confirmed: z.boolean().optional(), + + ahvDraw: z.enum(["EARLY", "REFERENCE", "DEFERRED"]).optional(), + ahvMonths: z.number().int().min(0).max(AHV_DEFER_MAX_MONTHS).optional(), + ahvSharePct: z.number().min(AHV_PARTIAL_MIN_PCT).max(100).optional(), + avgIncomeBefore: z.number().min(0).optional(), + gapYearsBefore: z.number().int().min(0).max(50).optional(), + + capitalSharePct: pct.optional(), + conversionRate: z.number().min(0).max(20).optional(), + recentBuyIn: z.boolean().optional(), + + withdrawalAge: z + .number() + .int() + .min(PILLAR_3A_MIN_WITHDRAWAL_AGE) + .max(PILLAR_3A_MAX_WITHDRAWAL_AGE) + .optional(), + + capitalTaxRate: z.number().min(0).max(100).optional(), + capitalUseAmortizationPct: pct.optional(), + capitalUseInvestPct: pct.optional(), + capitalUseTargetElementId: z.string().max(60).optional(), + }) + .strict(); + +// Kategorien, die ueberhaupt einen Pensionierungs-Entscheid kennen. +export const RETIREMENT_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"]; + +// --- Vorgaben ------------------------------------------------------------------------------- +// +// Zentrales Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollstaendiger Vorschlag, +// den man korrigiert. Nur so ist der Plan ab der ersten Sekunde rechenbar -- und nur so muss +// niemand am Anfang Fragen beantworten, die er erst am Ende beantworten kann. +export function withRetirementDefaults( + category: ElementCategory, + retirementAge: number, + rd: RetirementDecision | null | undefined +): RetirementDecision { + const d: RetirementDecision = { ...(rd ?? {}) }; + if (category === "AHV") { + d.ahvDraw ??= "REFERENCE"; + d.ahvSharePct ??= 100; + } else if (category === "PENSION_FUND") { + // Volle Rente. Das ist die Vorgabe, weil sie die Regel ist -- ein Kapitalbezug ist der + // begruendungspflichtige Fall, nicht umgekehrt. + d.capitalSharePct ??= 0; + d.conversionRate ??= DEFAULT_PK_CONVERSION_RATE; + d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE; + } else if (category === "PILLAR_3A") { + // Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58 + // aufhoert, kann die 3a trotzdem erst mit 60 beziehen. + d.withdrawalAge ??= clamp( + retirementAge, + PILLAR_3A_MIN_WITHDRAWAL_AGE, + PILLAR_3A_MAX_WITHDRAWAL_AGE + ); + d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE; + } + return d; +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +// --- AHV: Rentenbeginn und Faktor ----------------------------------------------------------- + +// Alter, ab dem die Rente tatsaechlich fliesst. Bis 0.33 war das immer das Referenzalter -- +// wer mit 62 aufhoerte, bekam die ungekuerzte Rente drei Jahre spaeter, wer bis 68 arbeitete, +// verschenkte den Zuschlag. Beides war schlicht falsch. +export function ahvStartAge(rd: RetirementDecision): number { + const months = ahvShiftMonths(rd); + return AHV_REFERENCE_AGE + months / 12; +} + +// Verschiebung in Monaten gegenueber dem Referenzalter: negativ = Vorbezug, positiv = Aufschub. +export function ahvShiftMonths(rd: RetirementDecision): number { + const raw = Math.round(Math.max(0, rd.ahvMonths ?? 0)); + if (rd.ahvDraw === "EARLY") return -Math.min(raw, AHV_EARLY_MAX_MONTHS); + if (rd.ahvDraw === "DEFERRED") return Math.min(raw, AHV_DEFER_MAX_MONTHS); + return 0; +} + +// Faktor auf die Rente. 1 = unveraendert, < 1 = gekuerzt (Vorbezug), > 1 = erhoeht (Aufschub). +// +// Beim Teilbezug wirkt die Kuerzung bzw. der Zuschlag NUR auf den vorbezogenen/aufgeschobenen +// Anteil -- der Rest laeuft ungekuerzt ab dem Referenzalter. Das Tool bildet das vereinfacht +// ab, indem es den gewichteten Mischfaktor bildet: Der Zeitversatz des Restanteils wird nicht +// eigens modelliert, weil das eine zweite Rentenlinie mit eigenem Startjahr erforderte. +export function ahvFactor(rd: RetirementDecision): number { + const months = ahvShiftMonths(rd); + if (months === 0) return 1; + const share = clamp(rd.ahvSharePct ?? 100, AHV_PARTIAL_MIN_PCT, 100) / 100; + const adjust = months < 0 ? earlyFactor(-months) : deferFactor(months); + return share * adjust + (1 - share); +} + +function earlyFactor(months: number): number { + // Linear in den Monaten -- die Kuerzung ist gesetzlich ein Jahressatz, monatlich anteilig. + return 1 - (months / 12) * (AHV_EARLY_REDUCTION_PER_YEAR / 100); +} + +function deferFactor(months: number): number { + // Die amtlichen Stuetzwerte gelten je volles Jahr; dazwischen wird linear interpoliert. + const years = months / 12; + const lo = Math.floor(years); + const hi = Math.min(AHV_DEFER_BONUS_BY_YEAR.length - 1, lo + 1); + const a = AHV_DEFER_BONUS_BY_YEAR[Math.min(lo, AHV_DEFER_BONUS_BY_YEAR.length - 1)]; + const b = AHV_DEFER_BONUS_BY_YEAR[hi]; + const bonus = a + (b - a) * (years - lo); + return 1 + bonus / 100; +} + +// Lesbare Kurzfassung fuer Matrix-Zelle und Zusammenfassung. +export function ahvDrawLabel(rd: RetirementDecision): string { + const months = ahvShiftMonths(rd); + if (months === 0) return "Referenzalter"; + const abs = Math.abs(months); + const years = Math.floor(abs / 12); + const rest = abs % 12; + const dauer = [years > 0 ? `${years} J.` : "", rest > 0 ? `${rest} Mt.` : ""] + .filter(Boolean) + .join(" "); + const delta = Math.round((ahvFactor(rd) - 1) * 1000) / 10; + return `${months < 0 ? "Vorbezug" : "Aufschub"} ${dauer} (${delta > 0 ? "+" : ""}${delta} %)`; +} + +// --- Saeule 3a: das Bezugsalter -------------------------------------------------------------- + +// Der Bezug erfolgt an einer PHASENGRENZE -- innerhalb einer Phase kennt das Modell kein +// Einzelereignis. Gewaehlt wird deshalb die erste Grenze bei oder nach dem gewuenschten Alter. +// Wer exakt staffeln will, setzt eine Phasengrenze; das ist in FPT ohnehin die Art, wie man +// Zeitpunkte modelliert. +export function effectiveWithdrawalAge( + rd: RetirementDecision, + retirementAge: number, + boundaryAges: number[] +): number { + const wish = clamp( + Math.round(rd.withdrawalAge ?? retirementAge), + PILLAR_3A_MIN_WITHDRAWAL_AGE, + PILLAR_3A_MAX_WITHDRAWAL_AGE + ); + const hit = boundaryAges.find((a) => a >= wish); + // Ohne passende Grenze (Wunsch liegt hinter dem Planende) gilt die letzte verfuegbare. + return hit ?? boundaryAges[boundaryAges.length - 1] ?? wish; +} diff --git a/src/lib/retirement.ts b/src/lib/retirement.ts index e4dfd26..dc76711 100644 --- a/src/lib/retirement.ts +++ b/src/lib/retirement.ts @@ -47,6 +47,58 @@ export function sortedPhases(plan: PlanInput) { return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); } +// --- Planungshorizont ----------------------------------------------------------------------- +// +// Bis 0.33 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien +// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar, +// obwohl genau das ihr Zweck ist. Neu ist der Horizont eine erfasste Zahl, und die LETZTE +// Phase folgt ihr (dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt). +// +// Referenzperson ist die aelteste: Sie erreicht ihren Horizont zuerst, und der Plan muss so +// lange laufen, bis die LETZTE Person ihren erreicht hat. +export function planEndYear(plan: PlanInput): number | null { + const years = plan.persons + .filter((p) => typeof p.planningHorizonAge === "number") + .map((p) => (p.planningHorizonAge as number) - p.age); + return years.length > 0 ? Math.max(...years) : null; +} + +export interface HorizonChange { + lastPhaseId: string; + oldDuration: number; + newDuration: number; + blocked: string | null; +} + +// Was muesste an der letzten Phase geschehen, damit der Plan bis zum Horizont laeuft? +export function planHorizonChange(plan: PlanInput, horizonAge: number, role: PersonRole): HorizonChange | null { + const phases = sortedPhases(plan); + const last = phases[phases.length - 1]; + if (!last) return null; + const person = plan.persons.find((p) => p.role === role); + if (!person) return null; + + // Zielgesamtdauer aus SICHT DIESER Person; die uebrigen Horizonte bleiben unberuehrt und + // koennen laenger sein -- deshalb das Maximum ueber alle. + const wish = Math.max( + horizonAge - person.age, + ...plan.persons.filter((p) => p.role !== role && typeof p.planningHorizonAge === "number") + .map((p) => (p.planningHorizonAge as number) - p.age) + ); + const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0); + const newDuration = wish - before; + const change: HorizonChange = { + lastPhaseId: last.id, + oldDuration: last.durationYears, + newDuration, + blocked: null, + }; + if (newDuration < 1) { + change.blocked = `Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern – kürze zuerst eine frühere Phase.`; + } + return change; +} + // Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt. export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] { const phases = sortedPhases(plan); diff --git a/src/lib/types.ts b/src/lib/types.ts index dffbbb4..0ea3f73 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -4,6 +4,7 @@ // V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan. import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements"; +import type { RetirementDecision } from "@/lib/retirement-decision"; export type HouseholdType = "SINGLE" | "COUPLE"; export type PersonRole = "PERSON_A" | "PERSON_B"; @@ -14,6 +15,9 @@ export interface PersonInput { name: string | null; age: number; retirementAge: number; + // Bis zu welchem Alter gerechnet wird. Fehlt der Wert, ergibt sich das Planende wie bisher + // aus der Summe der Phasendauern -- deshalb optional und nicht mit Default belegt. + planningHorizonAge?: number | null; } export interface PhaseInput { @@ -36,6 +40,9 @@ export interface ElementInput { // Werte je Phase (Key = phaseId) bzw. je Übergang (Key = fromPhaseId). phaseValues: Record; transitionValues: Record; + // Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug -- + // er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt. + retirementDecision?: RetirementDecision | null; // Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario. sourceElementId?: string | null; }