From d6855ef1a0d5af2c3ef0075988976c8cbddca6a7 Mon Sep 17 00:00:00 2001 From: kelle Date: Tue, 21 Jul 2026 14:24:40 +0200 Subject: [PATCH] Pensionsalter anpassen (Roadmap 44) Das Pensionsalter laesst sich neu veraendern: Nicht das Alter wird gesetzt, sondern die Phasengrenze verschoben -- Vorphase laenger, Folgephase kuerzer, Gesamtdauer gleich. Faellt eine Phase dabei weg, werden die beiden Uebergaenge nach Bestaetigung zusammengelegt. - neues reines Modul lib/retirement.ts + POST /api/scenarios//retirement - Pensionsalter als Tornado-Treiber und Live-Simulations-Regler - AHV-Referenzalter 65: Rente ab 65 unabhaengig vom Pensionsalter; vor 65 Beitrag als Nichterwerbstaetige(r) (neues Feld ahvContribution). AHV wird dafuer jahresweise statt phasenweise gerechnet. - Punkt B: personenzugeordnetes Einkommen faellt bei Pensionierung auf 0 - Punkt A: Wiederkehr-Parameter werden live aus der Vorphase geerbt, sichtbar als "Aus Vorphase uebernehmen" - Punkt C: Kapitalzufluss am Pensions-Uebergang per Quote auf Amortisation, Anlage und Cash verteilbar - Fix: carry.flowBasis wurde vor der Jahresschleife berechnet, effektive Werte kamen deshalb nie in der Folgephase an SPEZIFIKATION 0.26 (neue Kapitel 3.12, 4.4.7, 4.16). 221 -> 261 Tests. Co-Authored-By: Claude Opus 4.8 --- SPEZIFIKATION.md | 350 +++++++++++++++-- .../scenarios/[scenarioId]/phases/route.ts | 31 +- .../[scenarioId]/retirement/route.ts | 112 ++++++ src/components/ElementDetail.tsx | 352 +++++++++++++----- src/components/FormField.tsx | 51 +++ src/components/PlanProfileFields.tsx | 35 +- src/components/PlanView.tsx | 44 ++- src/components/RetirementAdjuster.tsx | 136 +++++++ src/lib/actuals.test.ts | 47 +++ src/lib/bridges.test.ts | 73 ++++ src/lib/calculations.test.ts | 158 ++++++++ src/lib/calculations.ts | 197 ++++++++-- src/lib/constants.ts | 16 + src/lib/elements.ts | 53 +++ src/lib/livesim.ts | 20 +- src/lib/ratefields.ts | 4 +- src/lib/retirement.test.ts | 181 +++++++++ src/lib/retirement.ts | 274 ++++++++++++++ src/lib/sensitivity.test.ts | 38 ++ src/lib/sensitivity.ts | 69 +++- 20 files changed, 2057 insertions(+), 184 deletions(-) create mode 100644 src/app/api/scenarios/[scenarioId]/retirement/route.ts create mode 100644 src/components/RetirementAdjuster.tsx create mode 100644 src/lib/retirement.test.ts create mode 100644 src/lib/retirement.ts diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 36835d6..73801a1 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.25 | -| **Datum** | 2026-07-18 | +| **Version** | 0.26 | +| **Datum** | 2026-07-21 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) | +| **Codestand** | Arbeitsstand nach `c429624` inkl. anpassbarem Pensionsalter (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.26 | 2026-07-21 | Claude (Opus 4.8) | **Pensionsalter anpassen** (Roadmap Nr. 44, neue Kapitel 3.12, 4.4.7 und 4.16). Bisher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Lebensphase endet – ein frei geändertes Alter hätte die Phasengrenze zerrissen. Neu wird nicht das Alter geändert, sondern **die Grenze verschoben**: Die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt gleich. Der Spielraum endet dort, wo eine angrenzende Phase unter ein Jahr fiele; ein Schritt weiter **entfällt sie ganz**, was vorher bestätigt wird, weil dabei zwei Übergänge **zusammengelegt** werden (bereits getroffene Entscheide bleiben, leere Felder werden aus dem entfallenden Übergang ergänzt, einmalige Cash-Beträge werden addiert – der Steuersatz betragsgewichtet). Das Pensionsalter ist damit auch **Treiber im Tornado** und **Regler in der Live-Simulation**. **AHV-Referenzalter (4.4.7):** Die Rente beginnt neu **immer mit 65**, unabhängig vom Pensionsalter – wer länger arbeitet, erhält sie zusätzlich zum Lohn; wer früher aufhört, zahlt bis 65 einen **Beitrag als Nichterwerbstätige(r)**, der als laufende Ausgabe auf die Verzehrquote schlägt und mit 65 wegfällt (neues Feld `ahvContribution`, ohne Default, Hilfetext nennt die reale Bandbreite von rund 530 bis 26'500 CHF pro Jahr). Beide Wechsel können **innerhalb** einer Phase liegen, die AHV wird deshalb **jahresweise** statt phasenweise gerechnet. **Punkt B:** Ein Einkommen, das einer **Person** zugeordnet ist, fällt bei deren Pensionierung auf 0 – bisher lief der Lohn stillschweigend in die Pension weiter. Gemeinsame Einkommen (Mieterträge o. Ä.) bleiben; ein ausdrücklich erfasster Betrag gewinnt, damit ein Teilzeitpensum modellierbar bleibt. **Punkt A:** Wiederkehr-Parameter (Raten, Beiträge, Amortisation, Wertsteigerung, Zinssatz) werden neu **live aus der Vorphase geerbt** statt beim Anlegen der Phase kopiert – sichtbar als angehaktes **«Aus Vorphase übernehmen»** je Feld. Vorher blieb die Kopie stehen, wenn man die Vorphase später änderte. **Punkt C:** Der Kapitalzufluss am Pensions-Übergang (PK, 3a, Verkaufserlös) lässt sich in **Prozent** auf Amortisation, Anlage und Cash aufteilen – bewusst nicht in Franken, weil sich der Betrag mit dem Pensionsalter ändert und eine Quote mitskaliert. **Nebenbei ein echter Fehler behoben:** Der fortgeschriebene Basiswert für Einkommen und Ausgaben wurde **vor** der Jahresschleife berechnet – effektive Werte kamen dadurch nie in der Folgephase an. Neues Modul `retirement.ts`, neuer Endpunkt `POST /api/scenarios//retirement`; 40 Tests ergänzt (221 → 261). | | 0.25 | 2026-07-21 | Claude (Opus 4.8) | **PDF-Berichte** (Roadmap Nr. 11, neues Kapitel 3.11). Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: Liste der erzeugten Berichte plus Assistent zum Anlegen (Titel, Notiz, nominal **oder** real, Plan- oder effektive Daten, bis zu **drei** Szenarien, beliebige gespeicherte Analysen). Das **Layout ist immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen: Deckblatt mit Zusammenfassung und drei Kernaussagen, dann je Szenario Kennzahlen, Vermögensverlauf, Lebensphasen und Annahmen, danach Vergleich, Plan/Ist, Analysen und die Hinweise. **Die PDF-Datei wird als Datei abgelegt** (BYTEA in Postgres, nicht im Container-Dateisystem, das jeder Deploy neu baut): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein – eine Neuerzeugung könnte das nach Änderungen an Plan, Rechenkern oder Layout nicht garantieren. **Kennzahlen je Szenario:** Endvermögen, Kapitalreichweite, Vermögen und Vorsorgekapital bei Pensionierung, AHV- und PK-Rente sowie die **offenen Entscheide** – die einzige unmittelbar handlungsleitende Zahl. Damit Bericht und Matrix nie verschiedene Zahlen nennen, liegt deren Zählung neu als reine Funktion in `decisions.ts`, die beide benutzen. **Zu jeder Kennzahl steht ihre Grundlage** als kurzer Verweis; die vollständigen Annahmen (Startwerte, Renditen, Raten je Element) stehen **einmal** je Szenario, statt bei jeder Kennzahl wiederholt zu werden. Ein **Haftungsausschluss** ist verpflichtend und durch einen Test gesichert – ein formal gesetztes PDF wird sonst als Beratung gelesen. **Technik:** `pdfkit` in der Node-Runtime statt Headless-Browser (kein Chromium im Image); `@react-pdf/renderer` schied aus, weil es mit React 19 / Next 16 bricht. Diagramme entstehen als **echte Vektoren** aus den gespeicherten Zahlen – genau dafür wurden die Analysen in 0.24 als Zahlen und nicht als Bilder abgelegt. `pdfkit` ist als externes Paket deklariert, weil es Font-Metriken über Dateipfade lädt und gebündelt erst in der Produktion bräche. Neue Tabelle `Report`, Endpunkte unter `/api/plans//reports`, neue Module `report.ts`, `report-pdf.ts`, `decisions.ts`; 9 Tests ergänzt (212 → 221). | | 0.24 | 2026-07-20 | Claude (Opus 4.8) | **Navigation auf Plan-Ebene und gespeicherte Analysen** (neues Kapitel 3.10). Die Seitenleiste ist neu zweistufig: Unter jedem Plan liegen die Unterpunkte **Szenarien**, **Effektive Werte** und **Analysen**; ein Klick auf den Plan-Namen öffnet ein **Plan-Dashboard** (Kennzahlen – Haushaltsdaten direkt, gerechnete Werte ausdrücklich «laut Basisszenario», dazu die Ist-Abweichung, falls erfasst). Die **Szenario-Liste** zeigt je Szenario Version, Elementzahl, Endvermögen und Ruinalter mit den Aktionen Historie und Matrix; das Basisszenario ist hervorgehoben, der Szenario-Baum in der Seitenleiste bleibt daneben erhalten. Die **Analysen-Ansicht** bietet vier umklappende Kacheln (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren – Umklappen auch per Antippen für Touch). **Grafiken** öffnen neu nicht mehr alle drei Diagramme, sondern lassen zuerst **eines** wählen; der Szenario-Vergleich zieht mit zu den Grafiken, der CSV-Export auf die Matrix. **Gespeicherte Analysen:** Jede Grafik, MC-Simulation und Einflussfaktoren-Berechnung kann festgehalten werden – als **Zahlen, nicht als Bild** (read-only, es wird nichts neu gerechnet). Das hält den Datensatz klein und macht ihn druckfähig: Der spätere PDF-Bericht (Roadmap Nr. 11) zeichnet daraus vektoriell in Druckauflösung, was ein Screenshot nicht könnte. Bewusst **nicht** gespeichert wird `finalWealthSorted` (megabyteweise). Jedes Werkzeug schreibt sein Ergebnis in dieselbe generische Form, sodass eine Nur-Lese-Ansicht genügt. Neue Tabelle `SavedAnalysis`, neue Endpunkte unter `/api/plans//analyses` und `/dashboard`; neue Komponenten `PlanViews`, `SavedAnalysisView`, `SaveAnalysisButton`, neues Modul `analyses.ts`. Kein Eingriff in den Rechenkern; Testbestand 212 (Migration um `SavedAnalysis` erweitert). | | 0.23 | 2026-07-20 | Claude (Opus 4.8) | **V7: Der Haushalt liegt am Plan, die Annahmen am Szenario.** **Haushaltsform**, **Personen** (Name, Alter) und **Planstartjahr** wandern vom Szenario auf den **Plan**; das **Pensionsalter** bleibt szenario-eigen (es ist der Kern jedes Früh-/Spätpensionierungs-Szenarios), ebenso Inflation und Cash-Anfangswert. Begründung: Diese Angaben beschreiben den Haushalt, nicht eine Planungsvariante – unterscheiden sie sich, ist es ein anderer **Plan**, kein anderes Szenario. Neue Tabelle `PlanPerson` (Rolle, Name, Alter je Plan); `Person` behält nur noch Rolle und Pensionsalter; `Plan` bekommt `householdType` und `startYear`. **Der Rechenkern bleibt unberührt:** `toPlanInput()` fügt Plan- und Szenario-Ebene wieder zu einem unveränderten `PlanInput` zusammen, die 43 Golden Tests laufen durch. **Nebeneffekt, der ein reales Problem löst:** Weil das Startjahr nun plan-weit ist, landet ein erfasster Ist-Satz für 2031 in **allen** Szenarien zwingend auf demselben Planjahr – vorher war das nicht garantiert. Das **Wiederherstellen einer Szenario-Version** setzt folgerichtig nur noch das Szenario-Eigene zurück; plan-weite Angaben über eine Version *eines* Szenarios zu überschreiben, hätte die übrigen stillschweigend mitverändert. Der Profil-Dialog kennzeichnet neu je Feld, ob es **plan-weit** oder **nur dieses Szenario** gilt. Die Migration übernimmt die Werte aus dem **Basisszenario**; zwei neue Tests spielen dafür echte V6-Daten ein und prüfen die Übernahme inkl. abweichender Nebenszenarien (210 → 212). | @@ -970,8 +971,9 @@ analog zur Monte-Carlo-Simulation: 4. **Ergebnis** – Basisfall, Tornado-Chart und Tabelle. Der Dialog ist bewusst **nicht** Teil des Grafiken-Bereichs: Dieser ist reine Ausgabe, während der -Tornado Pflichteingaben braucht. Ein Hinweis am Ende der Parameterliste benennt, warum das -**Pensionsalter** nicht enthalten ist (siehe [9.18](#918-tornado-was-der-chart-nicht-leistet)). +Tornado Pflichteingaben braucht. Seit 0.26 gehört auch das **Pensionsalter** je Person zu den +Treibern – es erscheint nur, wenn die Phasengrenze überhaupt Spielraum hat +(siehe [9.18](#918-tornado-was-der-chart-nicht-leistet) und [4.16](#416-pensionsalter-verschieben)). Referenz: `src/components/SensitivityDialog.tsx`, `src/components/AppShell.tsx`. @@ -1651,6 +1653,107 @@ das, was eingefroren wird, dasselbe, was geprüft wird. Referenz: `src/lib/report.ts`, `src/lib/report-pdf.ts`, `src/lib/decisions.ts`, `src/components/ReportsView.tsx`. +## 3.12 Pensionsalter anpassen + +> Roadmap Nr. 44. Bedienung im Szenario-Profil, Abschnitt «Pensionsalter anpassen». + +### 3.12.1 Warum das kein Zahlenfeld ist + +Das Pensionsalter bestimmt, **wo eine Lebensphase endet**: `maxPhaseDuration` kappt jede +Phasendauer beim nächsten Pensionierungsereignis, jede Pensionierung liegt deshalb zwangsläufig +auf einer Phasengrenze. Ein frei änderbares Alter hätte diese Zusammengehörigkeit zerrissen – +die Phasen blieben, wo sie sind, und der Phasentyp (Erwerb/Misch/Pension) käme mitten in einer +Phase ins Rutschen. + +Deshalb ändert die Bedienung nicht das Alter, sondern verschiebt **die Grenze**: + +| | vorher | nachher (−4 Jahre) | +|---|---|---| +| Phase 3 «Misch» | 10 Jahre | **6 Jahre** | +| Phase 4 «Pension» | 15 Jahre | **19 Jahre** | +| Gesamtdauer | 60 Jahre | 60 Jahre | + +Die Gesamtdauer des Plans bleibt immer gleich – es wird Zeit umverteilt, nicht hinzugefügt. + +### 3.12.2 Spielraum und Sperren + +Angezeigt wird je Person das aktuelle Pensionsalter, Knöpfe für ±1 Jahr, Kurzwahl-Chips für +grössere Schritte und eine **Hilfebox**, die den Spielraum benennt. Ein deaktivierter Knopf +ohne Erklärung wirkt wie ein Fehler; die Box sagt deshalb immer, was möglich ist **und warum +nicht mehr**. + +Der Spielraum ist `−(Dauer der Vorphase − 1)` bis `+(Dauer der Folgephase − 1)`: Eine +Lebensphase muss mindestens ein Jahr dauern. + +Gesperrt wird ganz, wenn: + +| Situation | Meldung | +|---|---| +| Person ist bei Planbeginn schon pensioniert | es gibt keine Grenze zu verschieben | +| Pensionierung liegt am oder nach dem Planende | zuerst eine Lebensphase anhängen | +| Pensionierung liegt nicht auf einer Phasengrenze | zuerst die Lebensphasen anpassen (Altdaten) | +| **Beide Personen teilen dieselbe Grenze** | zuerst eine Lebensphase einfügen, um sie zu trennen | + +Der letzte Fall ist der Kehrseite der Zusammenlegung: Sind zwei Pensionierungen auf demselben +Zeitpunkt, liesse sich die eine nicht bewegen, ohne die andere mitzunehmen. + +### 3.12.3 Zusammenlegung: wenn eine Lebensphase entfällt + +Genau ein Jahr über den Spielraum hinaus fällt die angrenzende Phase auf 0 und **verschwindet**. +Das ist erlaubt – es ist der Fall «beide werden gleichzeitig pensioniert, die Mischphase gibt es +nicht mehr» –, wird aber **vorher bestätigt**, weil dabei zwei Übergänge zu einem werden. + +Regel für die Zusammenführung (überlebende Grenze ist die des **Vorgängers** der entfallenden +Phase): + +| | Regel | Begründung | +|---|---|---| +| Element-Entscheide | Was am überlebenden Übergang schon entschieden ist, **bleibt**. Nur leere Felder werden aus dem entfallenden ergänzt. | Ein bewusster Entscheid darf nie von einem anderen überschrieben werden. | +| Einmalige Cash-Beträge | werden **addiert** | Beide Ereignisse finden weiterhin statt, nur zum selben Zeitpunkt. | +| Steuersatz auf dem Zufluss | **betragsgewichteter** Mischsatz | Nur so bleibt der Netto-Zufluss derselbe wie vorher. | +| Bezeichnungen | mit « + » verbunden | damit nachvollziehbar bleibt, woraus die Summe entstand | + +Die Verschiebung ist **nicht** über die Undo-Funktion rückgängig zu machen – sie erzeugt wie +jede Änderung eine neue Nebenversion, aus der sich der alte Stand wiederherstellen lässt +([3.8](#38-versionierung-und-änderungshistorie)). + +### 3.12.4 Punkt A: «Aus Vorphase übernehmen» + +Wiederkehr-Parameter – Teuerungsausgleich, erwartete Rendite, jährliche Einzahlung, Bezugsrate, +Amortisation, Wertsteigerung, Hypothekarzins, Tilgung – galten bisher als **Kopie**: Beim +Anlegen einer Phase wurde der Wert der Vorphase hineingeschrieben. Änderte man die Vorphase +später, blieb die Kopie stehen. + +Neu ist das Feld in Folgephasen standardmässig **leer** und wird live geerbt; darunter steht ein +angehaktes Kästchen **«Aus Vorphase übernehmen»** mit dem geerbten Wert daneben. Ein Häkchen +weg macht das Feld editierbar und den Wert phasen-eigen. + +Nicht vererbt werden bewusst: **Ausfalljahre** (sie gelten für genau eine Phase – ein geerbter +Wert würde eine Lücke erfinden), der **Kaufpreis** einer Immobilie (eine Tatsache, keine +Annahme) und die **Zins-Behandlung** (ein Schalter ohne Zahlenwert). + +Bestehende Pläne verhalten sich unverändert: Dort sind die Werte gespeichert und gewinnen daher +gegen die Vererbung, bis man das Häkchen aktiv setzt. + +### 3.12.5 Punkt C: Verwendung des Kapitalzuflusses + +Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a, +Verkaufserlös). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Im +Cash-Übergang lässt sich deshalb erfassen, wie viel **in Prozent** in die Amortisation der +Hypothek und in eine Anlage fliesst; der Rest bleibt Cash. + +**Warum Prozent und nicht Franken:** Verschiebt man das Pensionsalter, ändert sich das bezogene +Kapital. Ein Frankenbetrag müsste von Hand nachgezogen werden – und würde bis dahin still eine +falsche Aufteilung rechnen. Eine Quote skaliert mit. + +Die Amortisations-Quote ist am Restsaldo der Hypothek gekappt; ist sie grösser, bleibt der Rest +Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive). +Beide sind mechanisch nichts Neues – die eine wirkt wie eine Sonderamortisation, die andere wie +eine Zusatzinvestition, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken. + +Referenz: `src/lib/retirement.ts`, `src/components/RetirementAdjuster.tsx`, +`src/components/FormField.tsx` (`InheritableField`). + --- # 4. Berechnungsmodell @@ -1857,11 +1960,58 @@ falls (renteA + renteB) > cap: Die Rente ist danach **nominal fix** – sie wird über die Phasen hinweg nicht indexiert und verliert damit real an Kaufkraft (siehe [9.11](#911-ahv-rente-wird-nach-der-pensionierung-nicht-indexiert)). -Sie fliesst in `renteTotal` und wird im Einkommen mitgeführt. +**Wann** sie fliesst, entscheidet seit 0.26 nicht mehr der Phasentyp, sondern das Alter im +jeweiligen Jahr – siehe 4.4.7. Referenz: `src/lib/calculations.ts` (`ahvMonthlyFullPension`, `ahvMdje`, `ahvAnnualPension`), `src/lib/constants.ts`. +### 4.4.7 Referenzalter: Die Rente beginnt mit 65, nicht mit der Pensionierung + +Bis Version 0.25 galt implizit «pensioniert = Rente». Sobald sich das Pensionsalter verschieben +lässt ([4.16](#416-pensionsalter-verschieben)), ist das falsch: Die AHV-Rente hängt am +**Referenzalter** (`AHV_REFERENCE_AGE = 65`), nicht daran, wann jemand aufhört zu arbeiten. + +Daraus folgen drei Fälle: + +| Fall | Was passiert | +|---|---| +| Pensionierung **mit 65** | unverändert – die Rente fliesst ab Beginn der Pensionsphase | +| Pensionierung **nach 65** | Die Erwerbsphase deckt das Referenzalter ab. Die Rente fliesst ab 65 **zusätzlich zum Lohn**. Erfasst werden nur noch die Ausfalljahre bis 65. | +| Pensionierung **vor 65** | Die Pensionsphase beginnt vor 65. Bis dahin ist die Person **beitragspflichtig als Nichterwerbstätige(r)**; der Beitrag (`ahvContribution`) ist eine laufende Ausgabe. Mit 65 fällt er weg und die Rente setzt ein. | + +Beide Wechsel können **innerhalb derselben Lebensphase** stattfinden. Die AHV wird deshalb +**jahresweise** ausgewertet statt als Phasenkonstante: + +``` +für jedes Jahr t der Phase: + alterImJahr = alterZuPhasenbeginn + t − 1 + alterImJahr ≥ 65 → Rente fliesst (Einkommen) + sonst, wenn pensioniert → Beitrag fällt an (Ausgabe, wirkt auf die Verzehrquote) + sonst → nichts +``` + +Der Verlaufspunkt des AHV-Elements zeigt den Beitrag als **negativen** Wert – so ist in der +Grafik zu sehen, dass die AHV in diesen Jahren Geld kostet, statt welches zu bringen. + +**Ein Aufschub der Rente wird nicht abgebildet.** Wer über 65 hinaus arbeitet, könnte den Bezug +aufschieben und erhielte dafür einen Zuschlag. Das Tool lässt die Rente stattdessen fliessen – +die vorsichtigere Annahme, und eine, die keinen zusätzlichen Entscheid verlangt. + +**Der Beitrag als Nichterwerbstätige(r)** bemisst sich am Vermögen und am Renteneinkommen, nicht +am Lohn. Die Bandbreite ist entsprechend enorm: vom Mindestbeitrag von rund **530 CHF/Jahr** bis +zum Höchstbeitrag von rund **26'500 CHF/Jahr**. Deshalb gibt es hier **keinen Default** – ein +stiller Vorschlag würde nicht hinterfragt (vgl. [9.15](#915-defaults-für-annahmen-sind-gefährlich)). +Der Hilfetext nennt die Bandbreite ausdrücklich, damit auch jemand ohne Vorwissen ein Gefühl für +die Grössenordnung bekommt. + +**Beitragsjahre:** Wer den Beitrag zahlt, hat keine Ausfalljahre – die Rentenskala bleibt +unberührt. Das Modell kürzt die Rente bei Frühpensionierung deshalb nicht; eine Kürzung entsteht +nur, wenn man die Jahre ausdrücklich als Ausfalljahre erfasst. + +Referenz: `src/lib/calculations.ts` (`ahvItems` in der Jahresschleife), `AHV_REFERENCE_AGE` in +`src/lib/constants.ts`. + ## 4.5 Nominal, real und die Deflatoren ### 4.5.1 Das V5-Modell @@ -1905,11 +2055,16 @@ Vorab-Abbruch: Ist der Carry-Status `SOLD` → Notiz „Verkauft"; ist er `SETTL ### 4.6.1 INCOME / EXPENSE ``` -idx = phaseData.teuerungsausgleich ?? 0 +idx = phaseData.teuerungsausgleich ?? carry.rates.teuerungsausgleich ?? 0 // Punkt A baseValue = hasCarry ? round(carry.flowBasis) : round(phaseData.amount) basis = !hasCarry → round(phaseData.amount) phaseData.amount ist Zahl → round(phaseData.amount) // bewusster Override sonst → baseValue // live vererbt + +// Punkt B: Besitzer ist eine PERSON und in dieser Phase pensioniert, +// und es ist kein Betrag erfasst → basis = 0 + +// NACH der Jahresschleife (siehe 4.16.6): carry.flowBasis = basis × (1 + idx/100)^duration // für die Folgephase ``` @@ -1921,10 +2076,17 @@ durch. Nur ein bewusst abweichender Wert wird fix gespeichert. Man beachte den Exponenten-Unterschied: der **Endwert** der Phase nutzt `duration − 1` (letztes Jahr), der **Carry** für die Folgephase nutzt `duration` (ein Jahr weiter). +Die Fortschreibung passiert seit 0.26 **nach** der Jahresschleife – sonst kämen effektive Werte +nie in der Folgephase an ([4.16.6](#4166-fehlerbehebung-fortgeschriebener-basiswert-und-effektive-werte)). + ### 4.6.2 AHV -Erwerbstätig → nur Zusammenfassung („N Ausfalljahre" / „Keine Ausfalljahre"), kein Wert. -Pensioniert → `startValue = endValue = rente`, Summand in `renteTotal`. +Seit 0.26 **jahresweise** statt phasenweise ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)): +Das Element meldet Rente, Beitrag und Startalter des Besitzers an die Jahresschleife an, die je +Jahr entscheidet, was fliesst. `startValue` / `endValue` zeigen den Stand im ersten bzw. letzten +Jahr der Phase; die Zusammenfassung nennt beide Zustände, wenn das Referenzalter mitten in der +Phase liegt («Beitrag … → Rente …»). Die Rente läuft **nicht** mehr über `renteTotal`, weil sie +innerhalb einer Phase einsetzen kann. ### 4.6.3 PENSION_FUND @@ -2258,6 +2420,7 @@ Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bunde | `AHV_GROSS_FROM_NET_FACTOR` | 1.12 | Netto → Brutto für die AHV; Herleitung siehe [4.4.5](#445-netto-brutto-umrechnung-für-die-ahv) | | `AHV_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente | | `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) | +| `AHV_REFERENCE_AGE` | 65 | Referenzalter – ab hier fliesst die Rente, **unabhängig vom Pensionsalter** ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) | | `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) | | `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz | | `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer | @@ -2526,6 +2689,7 @@ falsch zu werden: | Einkommen | **relativ %** | skaliert `amount` aller `INCOME`-Elemente | | Lohnentwicklung | **Δ Prozentpunkte** | verschiebt `teuerungsausgleich` der `INCOME`-Elemente | | Wertsteigerung der Immobilie | **Δ Prozentpunkte** | verschiebt `valueGrowth` | +| Pensionsalter Person A / B | **Δ Jahre** | verschiebt die Phasengrenze der Pensionierung ([4.16](#416-pensionsalter-verschieben)) | Die Begründungen im Einzelnen: - **Absolut** nur bei der Inflation – es gibt genau einen plan-weiten Wert. @@ -2777,10 +2941,11 @@ Elemente einzeln um +1 pp zu heben ergibt exakt dasselbe Endvermögen wie der Sa Frage, die man tatsächlich stellt; Ausgaben-Elemente sind typischerweise viele kleine Posten, deren Einzelregler das Panel fluten würden, ohne eine bessere Frage zu ermöglichen. -**Das Pensionsalter fehlt weiterhin** – aus demselben Grund wie beim Tornado -([9.18](#918-tornado-was-der-chart-nicht-leistet)): Es liesse sich nicht verschieben, -ohne die Phasengrenzen mitzuziehen. Ersatzweise gibt es **Lebensdauer** (letzte Phase -verlängern/verkürzen), was das Langlebigkeitsrisiko abdeckt, nicht aber die Frühpensionierung. +**Das Pensionsalter ist seit 0.26 als Regler dabei** (je Person einer, sofern überhaupt +Spielraum besteht). Sein Bereich ist **plan-abhängig**: Er endet dort, wo eine angrenzende +Lebensphase unter ein Jahr fiele – ein Regler, der stumm an seiner Grenze klebt, wäre schlechter +als keiner. Der Treiber **Lebensdauer** bleibt daneben bestehen; er beantwortet die andere Frage +(wie lange muss es reichen, statt wann höre ich auf). ### 4.15.3 Referenz und Kennzahlen @@ -2821,6 +2986,96 @@ echtes Szenario übertragen lässt. Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`, `tunableElements`), `src/components/LiveSimDialog.tsx`, `src/components/AllocationChart.tsx`. +## 4.16 Pensionsalter verschieben + +> Modul `src/lib/retirement.ts`. Rein: Es entscheidet nur, **was** geschehen soll; das +> Schreiben übernimmt der Aufrufer (Panel: API-Aufrufe; Tornado/Live-Simulation: reine +> Plan-Kopie). + +### 4.16.1 Die tragende Invariante + +`maxPhaseDuration(persons, yearsBefore)` kappt jede Phasendauer beim nächsten +Pensionierungsereignis. Daraus folgt: **Jede Pensionierung liegt auf einer Phasengrenze.** Genau +das macht die Anpassung überhaupt erst möglich – «Pensionsalter ändern» ist gleichbedeutend mit +«diese eine Grenze verschieben». + +`retirementBoundaries(plan)` liefert je Person: + +| Feld | Bedeutung | +|---|---| +| `planYear` | Planjahr der Pensionierung (`retirementAge − age`) | +| `phaseIndex` | Index der Phase **vor** der Grenze | +| `minDelta` / `maxDelta` | Spielraum, ohne dass eine Nachbarphase unter 1 Jahr fällt | +| `mergeDeltaDown` / `mergeDeltaUp` | genau der Wert, bei dem eine Phase entfällt | +| `blocked` | Erklärtext, wenn gar keine Anpassung möglich ist (siehe 3.12.2) | + +### 4.16.2 `shiftRetirement(plan, role, delta)` + +``` +Phase[i].dauer += delta // vor der Grenze +Phase[i+1].dauer −= delta // nach der Grenze +person.retirementAge += delta +``` + +Fällt eine der beiden auf 0, wird sie entfernt und die `sequenceNumber` lückenlos neu vergeben. +Die Übergangsdaten der entfallenden Phase werden nach der Regel aus 3.12.3 in den Vorgänger +gezogen (`mergeTransition`, `mergeCashTransition`). Ausserhalb des erlaubten Bereichs liefert +die Funktion `null` – sie klemmt nicht still. + +### 4.16.3 Als Treiber und Regler + +`applyDriver(plan, "retirementA" | "retirementB", jahre)` benutzt dieselbe Funktion. Zwei +bewusste Abweichungen gegenüber der Bedienung: + +* Der Bereich endet bei `minDelta`/`maxDelta`, **ohne** Zusammenlegung. Eine Zusammenlegung + verändert den Plan inhaltlich (zwei Übergänge werden einer) – dafür ist eine + Was-wäre-wenn-Betrachtung der falsche Ort. +* Eine zu weite Eingabe wird auf das Mögliche **gekürzt** statt verworfen: Eine Bandbreite von + ±5 Jahren soll auch dann etwas zeigen, wenn nur ±2 möglich sind. Bleibt gar kein Spielraum, + erklärt `ineffectiveReason` den Nullbalken. + +Der Hebel ist doppelt – länger Einkommen **und** kürzer Verzehr – und deshalb meist einer der +grössten im Tornado. + +### 4.16.4 Punkt B: Einkommen endet mit der Pensionierung + +Bis 0.25 lief ein Erwerbseinkommen stillschweigend in die Pensionsphase weiter (der Basiswert +wird ja fortgeschrieben). Bei fixem Pensionsalter fiel das kaum auf; sobald sich die Grenze +verschieben lässt, ist es ein handfester Fehler – «drei Jahre früher aufhören» hätte sonst gar +keine Wirkung gehabt. + +``` +Kategorie INCOME, Element ist EINER PERSON zugeordnet, +Person in dieser Phase pensioniert, kein ausdrücklicher Betrag erfasst + → Basiswert = 0 +``` + +Drei bewusste Einschränkungen: + +* **Nur personenzugeordnete Einkommen.** «Gemeinsam» (Mieterträge, Ausschüttungen) hängt nicht + an der Erwerbstätigkeit einer Person und läuft weiter. +* **Ein ausdrücklich erfasster Betrag gewinnt.** Sonst liesse sich ein Teilzeitpensum oder eine + Überbrückungsrente nach der Pensionierung nicht abbilden. +* Das Element zeigt in dieser Phase den Hinweis, dass es wegen der Pensionierung auf 0 steht. + +### 4.16.5 Vererbte Wiederkehr-Parameter (Punkt A) + +Der `Carry` führt neu eine Tabelle `rates` mit den zuletzt verwendeten Werten. Ist ein Feld in +einer Phase nicht gesetzt, gilt der Wert aus der Vorphase; der verwendete Wert wird wieder +abgelegt, sodass die Kette über beliebig viele Phasen trägt. Betroffen sind +`teuerungsausgleich`, `expectedReturn`, `annualContribution`, `annualWithdrawal`, +`amortization`, `valueGrowth`, `interestRate` und `annualRepayment`. + +### 4.16.6 Fehlerbehebung: fortgeschriebener Basiswert und effektive Werte + +Der Basiswert der Folgephase (`carry.flowBasis`) wurde beim **Element-Setup** berechnet, also +**vor** der Jahresschleife. Ein effektiver Wert setzt den Basiswert aber erst **in** der +Jahresschleife neu (`rebaseFlow`). Folge: Ein für 2031 erfasster Lohn wirkte bis zum Ende seiner +Phase – und fiel an der Phasengrenze stillschweigend auf den geplanten Wert zurück. + +Die Fortschreibung passiert neu **nach** der Jahresschleife, gemeinsam mit den Endwerten. Zwei +Regressionstests in `actuals.test.ts` halten den Fall fest. + --- # 5. Technische Spezifikation @@ -2905,6 +3160,8 @@ PlanComputed ← an den Client geliefert | `versioning-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. | | `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. | | `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. | +| `retirement.ts` | Pensionsalter verschieben: Spielraum je Person, Verschiebung der Phasengrenze, Zusammenlegung zweier Übergänge (Kap. 4.16). Rein, ohne I/O. | +| `transitions.ts` | Reine Übergangs-Regeln (Vorbelegung, «beantwortet?», Cash-Zusammenfassung). Liegt hier und nicht in einer Komponente, weil auch der Server sie braucht -- ein Import aus `src/components` bricht erst in der Produktion. | | `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. | | `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand – Grundlage der Systemparameter-Ansicht | | `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) | @@ -3041,6 +3298,7 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48 | `amount` | INCOME, EXPENSE | ≥ 0 | | `teuerungsausgleich` | INCOME, EXPENSE | −20 bis 50 | | `gapYears` | AHV | Integer ≥ 0 | +| `ahvContribution` | AHV | ≥ 0 – Beitrag als Nichterwerbstätige(r) bis zum Referenzalter ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) | | `avgIncomeBefore` | AHV – nur wenn bei Planbeginn **bereits pensioniert** | ≥ 0, **real** | | `gapYearsBefore` | AHV – dito | Integer 0–50 | | `currentValue` | PENSION_FUND, PILLAR_3A | ≥ 0 | @@ -3057,6 +3315,11 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48 | `valueGrowth` | REAL_ESTATE – Wertsteigerung %/Jahr auf die Liegenschaft | −20 bis 20 | | `annualRepayment` | OTHER_DEBT | ≥ 0 | +**Vererbbare Felder (Punkt A, Kap. 3.12.4):** `teuerungsausgleich`, `expectedReturn`, +`annualContribution`, `annualWithdrawal`, `amortization`, `valueGrowth`, `interestRate` und +`annualRepayment` sind ab Phase 2 in der Regel **nicht gesetzt** – die Berechnung übernimmt dann +den Wert der Vorphase. Alle übrigen Felder bedeuten «nicht gesetzt = 0» wie bisher. + ### 5.4.4 JSON-Payload `TransitionData` | Feld | Kategorien | Zod-Regel | @@ -3089,6 +3352,9 @@ Liegt in `Phase.cashTransition`. Validierung über `cashTransitionSchema`. | `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0–100 | | `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen | | `outflowAmount` | Betrag **real** (heutige Kaufkraft) | ≥ 0 | +| `capitalUseAmortizationPct` | Anteil des Kapitalzuflusses in die Amortisation (Kap. 3.12.5) | 0–100 | +| `capitalUseInvestPct` | Anteil des Kapitalzuflusses in eine Anlage | 0–100 | +| `capitalUseTargetElementId` | Ziel der Anlage-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen | Pro Übergang ist **genau ein** Zufluss und **eine** Kostenposition möglich – siehe [9.7](#97-nur-ein-zufluss-und-eine-kostenposition-pro-übergang). @@ -3318,6 +3584,20 @@ Liefert Eingabe, Berechnung **und die Vergleichsbasis** in einem Zug: `base` ist das Eltern-Szenario (null beim Basisszenario) – daraus rechnet der Client den Diff. Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd. +### `POST /api/scenarios//retirement` +Verschiebt das Pensionsalter einer Person und damit die zugehörige Phasengrenze +([3.12](#312-pensionsalter-anpassen)): +```json +{ "role": "PERSON_A", "delta": -4, "confirmMerge": false } +``` +Bewusst kein `PATCH` auf `retirementAge`: Die Änderung betrifft immer **zwei** Phasendauern +gleichzeitig und kann eine Phase entfallen lassen. +→ 200 `{ ok, removedPhaseId, retirementAge }` · +→ 400 mit Erklärtext, wenn gesperrt oder ausserhalb des Spielraums · +→ **409** `{ needsMergeConfirmation: true, removedPhaseId, removedPhaseName, mergedIntoPhaseId }`, +wenn dabei eine Lebensphase entfiele und `confirmMerge` nicht gesetzt ist. Der Client fragt +vorher selbst (er kennt den Plan), der 409 ist die serverseitige Absicherung. + ### `PATCH /api/scenarios/` Akzeptiert eine **Union** von zwei Formen: 1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` – ersetzt @@ -3467,12 +3747,12 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | Datei | Tests | Schwerpunkt | |---|---|---| -| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" | -| `sensitivity.test.ts` | 15 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber | +| `calculations.test.ts` | 53 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, AHV-Referenzalter (Beitrag vor 65, Rente ab 65), Einkommen endet mit der Pensionierung, Vererbung der Wiederkehr-Parameter, „V5 Golden Tests" | +| `sensitivity.test.ts` | 20 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber, Pensionsalter als Treiber | | `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise | | `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % | | `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder | -| `actuals.test.ts` | 22 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung | +| `actuals.test.ts` | 24 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung, Wirkung über die Phasengrenze hinaus | | `dataview.test.ts` | 7 | beide Sichten in einem Zug, Rückfall ohne Ist-Werte, Abweichungsmessung | | `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen | | `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien | @@ -3480,10 +3760,12 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei | | `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung | | `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen | -| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus | +| `bridges.test.ts` | 13 | Vermögens- und Cash-Brücke gehen über acht Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus; Kapitalverwendung nach Quote (Punkt C) | +| `retirement.test.ts` | 16 | Spielraum und Sperren je Person, Verschiebung ohne Änderung der Gesamtdauer, Wegfall einer Phase, Zusammenführung der Übergangs-Entscheide | +| `server-boundary.test.ts` | 1 | statischer Wächter: kein Modul unter `src/lib` importiert aus `src/components` | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) | -| **Total** | **221** | | +| **Total** | **261** | | ## 8.2 Testfälle @@ -3768,25 +4050,25 @@ Aussagekräftig ist die **Reihenfolge**, nicht der absolute Betrag. härter als die Summe der Einzelbalken – weil in der Folge Kapital verzehrt wird, das später zur Verzinsung fehlt. Für Kombinationen ist die Monte-Carlo-Simulation zuständig. -**Das Pensionsalter ist bewusst nicht enthalten.** Die Roadmap nennt es als Top-Hebel, aber -`retirementAge` lässt sich im aktuellen Datenmodell nicht isoliert variieren, ohne die -Phasengrenzen mitzuverschieben – und das Ergebnis wäre nicht ungenau, sondern **irreführend**. +**Das Pensionsalter ist seit 0.26 enthalten** – zuvor bewusst nicht, und die Begründung von +damals erklärt, warum die heutige Umsetzung so aussieht, wie sie aussieht. + +Ein isoliert verändertes `retirementAge` wäre nicht ungenau gewesen, sondern **irreführend**. Beispiel: Person 45, Pension mit 65, Phase 1 = 20 Jahre (45→65), Phase 2 = 20 Jahre. -- **Pensionsalter auf 62:** Phase 1 beginnt mit 45, also `45 < 62` → die Phase bleibt vollständig - Erwerbsphase. Die Person arbeitet im Modell weiterhin bis 65, der Pensions-Übergang liegt an - derselben Grenze. Wirkung auf das Ergebnis: **praktisch null.** -- **Pensionsalter auf 68:** Phase 2 beginnt mit 65, also `65 < 68` → Phase 2 wird zur - **Erwerbsphase**, das Einkommen läuft weiter. Zugleich wird `ownerRetiresNext` an der Grenze - nach Phase 1 falsch (65 ≥ 68 trifft nicht zu), womit der **Pensions-Übergang komplett entfällt**: - keine PK-Verrentung, kein 3a-Bezug, keine AHV-Rente. Der Balken wäre riesig – er misst aber den - Ausfall der Vorsorgelogik, nicht „drei Jahre länger arbeiten". +- **Auf 62 gesetzt:** Phase 1 beginnt mit 45, also `45 < 62` → die Phase bliebe vollständig + Erwerbsphase. Die Person arbeitete im Modell weiterhin bis 65. Wirkung: **praktisch null.** +- **Auf 68 gesetzt:** Phase 2 beginnt mit 65, also `65 < 68` → Phase 2 würde zur Erwerbsphase. + Zugleich wäre `ownerRetiresNext` an der Grenze nach Phase 1 falsch, womit der + **Pensions-Übergang komplett entfiele**: keine PK-Verrentung, kein 3a-Bezug. Der Balken wäre + riesig – er misst aber den Ausfall der Vorsorgelogik, nicht „drei Jahre länger arbeiten". -Fachlich korrekt wäre nur, `retirementAge` **und** die Phasengrenze gemeinsam zu verschieben -(Erwerbsphase kürzer, Pensionsphase länger). Das hat eigene Sonderfälle – Paare mit -unterschiedlichem Pensionsalter, Grenzen abseits des Pensionsereignisses, Verschiebung grösser als -die Phasendauer – und ist als eigener Arbeitsschritt offen. Der verwandte Treiber **Lebensdauer** -(Dauer der letzten Phase) ist dagegen sauber abgebildet und deckt einen Teil des Bedürfnisses ab. +Der Treiber verschiebt deshalb `retirementAge` **und** die Phasengrenze gemeinsam +([4.16](#416-pensionsalter-verschieben)). Zwei Eigenheiten bleiben und sind im Dialog benannt: +Der Spielraum endet bei den angrenzenden Phasendauern (eine **Zusammenlegung** findet im Tornado +bewusst nicht statt), und eine zu weite Bandbreite wird auf das Mögliche gekürzt statt verworfen. +Der verwandte Treiber **Lebensdauer** (Dauer der letzten Phase) bleibt daneben bestehen – er +beantwortet die andere Frage: nicht „wann höre ich auf", sondern „wie lange muss es reichen". ## 9.19 Simulationsparameter werden nicht gespeichert diff --git a/src/app/api/scenarios/[scenarioId]/phases/route.ts b/src/app/api/scenarios/[scenarioId]/phases/route.ts index 0f6bd48..52e600b 100644 --- a/src/app/api/scenarios/[scenarioId]/phases/route.ts +++ b/src/app/api/scenarios/[scenarioId]/phases/route.ts @@ -79,33 +79,28 @@ export async function POST( return NextResponse.json({ phase: { id: phase.id } }, { status: 201 }); } -// Vorbelegung für eine neue Phase: nur die editierbaren Felder werden übernommen. Start- -// bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden in der Berechnung live aus der -// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert. +// Vorbelegung für eine neue Phase. +// +// Seit Roadmap Nr. 44 (Punkt A) werden die WIEDERKEHR-Parameter (Raten, Beiträge, Amortisation, +// Wertsteigerung, Zinssatz) NICHT mehr als Kopie gespeichert, sondern bewusst leer gelassen: +// Die Berechnung übernimmt sie live aus der Vorphase, und im UI steht dafür sichtbar +// «Aus Vorphase übernehmen» -- angehakt. Der Unterschied zeigt sich, sobald man die Vorphase +// später ändert: Eine Kopie bliebe stehen, die Vererbung zieht mit. +// +// Start- bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden ohnehin live fortgeschrieben. function buildCarryData(category: string, prev: PhaseData): PhaseData { switch (category) { - case "INCOME": - case "EXPENSE": - // Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird übernommen. - return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {}; case "AHV": + // Ausfalljahre sind KEIN Wiederkehr-Parameter: Sie gelten für genau diese Phase, und + // ein geerbter Wert würde eine Lücke erfinden, die es nicht gibt. return { gapYears: 0 }; - case "PENSION_FUND": - case "PILLAR_3A": - case "OTHER_ASSET": - return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) }; case "REAL_ESTATE": - // purchasePrice + amortization bleiben; Resthypothek und Verkehrswert werden live - // fortgeschrieben. Zinssatz, Zins-Behandlung und Wertsteigerung gelten weiter. + // Der Kaufpreis ist eine Tatsache, keine Annahme (Basis der Grundstückgewinnsteuer); + // die Zins-Behandlung ist ein Schalter ohne Zahlenwert -- beide werden weiterhin kopiert. return { purchasePrice: num(prev.purchasePrice), - amortization: num(prev.amortization), - interestRate: num(prev.interestRate), interestHandling: prev.interestHandling ?? "INCLUDED", - valueGrowth: num(prev.valueGrowth), }; - case "OTHER_DEBT": - return { annualRepayment: num(prev.annualRepayment) }; default: return {}; } diff --git a/src/app/api/scenarios/[scenarioId]/retirement/route.ts b/src/app/api/scenarios/[scenarioId]/retirement/route.ts new file mode 100644 index 0000000..033f27b --- /dev/null +++ b/src/app/api/scenarios/[scenarioId]/retirement/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { getOwnedScenario, toPlanInput } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; +import { touchScenario } from "@/lib/versioning-db"; +import { Prisma } from "@/generated/prisma/client"; +import { retirementBoundaries, shiftRetirement } from "@/lib/retirement"; + +// Pensionsalter anpassen (Roadmap Nr. 44). +// +// Das ist bewusst KEIN einfaches PATCH auf `retirementAge`: Eine Pensionierung liegt immer +// auf einer Phasengrenze, also verschiebt jede Änderung zwei Phasendauern gleichzeitig -- +// und kann eine Phase ganz entfallen lassen. Die Entscheidung darüber trifft `lib/retirement` +// (rein und getestet), hier wird sie nur noch geschrieben. + +const bodySchema = z.object({ + role: z.enum(["PERSON_A", "PERSON_B"]), + delta: z.number().int().min(-80).max(80), + // Muss der Aufrufer ausdrücklich setzen, wenn dabei eine Lebensphase wegfällt. + confirmMerge: z.boolean().optional(), +}); + +export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { scenarioId } = await params; + + const scenario = await getOwnedScenario(scenarioId, userId); + if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); + + const parsed = bodySchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); + const { role, delta, confirmMerge } = parsed.data; + + const planInput = toPlanInput(scenario); + const boundary = retirementBoundaries(planInput).find((b) => b.role === role); + if (!boundary) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 }); + if (boundary.blocked) return NextResponse.json({ error: boundary.blocked }, { status: 400 }); + + if (delta < (boundary.mergeDeltaDown ?? boundary.minDelta) || delta > (boundary.mergeDeltaUp ?? boundary.maxDelta)) { + return NextResponse.json( + { error: "So weit lässt sich das Pensionsalter hier nicht verschieben. Passe zuerst die Lebensphasen an." }, + { status: 400 } + ); + } + + const result = shiftRetirement(planInput, role, delta); + if (!result) return NextResponse.json({ error: "Die Verschiebung ist hier nicht möglich." }, { status: 400 }); + + // Eine Zusammenlegung verändert den Plan inhaltlich (zwei Übergänge werden einer) und muss + // deshalb vom Aufrufer bestätigt sein. 409 = "verstanden, aber so nicht ohne Zustimmung". + if (result.removedPhaseId && !confirmMerge) { + const removed = planInput.phases.find((p) => p.id === result.removedPhaseId); + return NextResponse.json( + { + error: "Bestätigung erforderlich.", + needsMergeConfirmation: true, + removedPhaseId: result.removedPhaseId, + removedPhaseName: removed?.name ?? "", + mergedIntoPhaseId: result.mergedIntoPhaseId, + }, + { status: 409 } + ); + } + + await prisma.$transaction(async (tx) => { + await tx.person.updateMany({ + where: { scenarioId: scenario.id, role }, + data: { retirementAge: boundary.retirementAge + delta }, + }); + + if (result.removedPhaseId) { + // Die Übergangsdaten der entfallenden Phase wurden in den Vorgänger gezogen; hier wird + // nur noch der zusammengeführte Stand geschrieben, bevor die Phase verschwindet. + if (result.mergedIntoPhaseId) { + const target = result.plan.phases.find((p) => p.id === result.mergedIntoPhaseId); + if (target) { + await tx.phase.update({ + where: { id: target.id }, + data: { cashTransition: (target.cashTransition ?? {}) as Prisma.InputJsonValue }, + }); + } + for (const e of result.plan.elements) { + const td = e.transitionValues?.[result.mergedIntoPhaseId]; + if (!td) continue; + await tx.elementTransitionValue.upsert({ + where: { elementId_fromPhaseId: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId } }, + create: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId, data: td as Prisma.InputJsonValue }, + update: { data: td as Prisma.InputJsonValue }, + }); + } + } + // Löscht die Phase samt ihrer Phasen- und Übergangswerte (Kaskade im Schema). + await tx.phase.delete({ where: { id: result.removedPhaseId } }); + } + + for (const p of result.plan.phases) { + await tx.phase.update({ + where: { id: p.id }, + data: { durationYears: p.durationYears, sequenceNumber: p.sequenceNumber }, + }); + } + }); + + await touchScenario(scenario.id, userId); + return NextResponse.json({ + ok: true, + removedPhaseId: result.removedPhaseId, + retirementAge: boundary.retirementAge + delta, + }); +} diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index b5b7808..88687d0 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { AlertTriangle } from "lucide-react"; -import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; +import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; import { formatChf } from "@/lib/format"; import { api } from "@/lib/api-client"; // Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und @@ -24,8 +24,9 @@ import { type RateScope, } from "@/lib/ratefields"; import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations"; -import { CATEGORY_LABELS, num } from "@/lib/elements"; +import { CATEGORY_LABELS, num, type InheritableKey } from "@/lib/elements"; import { + AHV_REFERENCE_AGE, DEFAULT_CAPITAL_TAX_RATE, DEFAULT_PK_CONVERSION_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE, @@ -59,6 +60,12 @@ export interface CellContext { laterPhaseCount: number; // AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang). ahvCareer: AhvCareer | null; + // Werte, die in dieser Phase gelten würden, wenn das jeweilige Feld leer bleibt + // (Roadmap Nr. 44, Punkt A). Leer in der ersten Phase. + inheritedValues: Record; + // Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob + // das Referenzalter in diese Phase fällt (Kap. 4.4.6). + ownerAgeStart: number; } interface Props { @@ -244,10 +251,18 @@ export function CashTransitionFields({ ct, setC, deflatorEnd, + // Roadmap Nr. 44, Punkt C: Nur am Pensions-Übergang gefragt -- dort kommt das Kapital + // (PK, 3a, Immobilienverkauf) auf einmal herein und will verwendet werden. + showCapitalUse = false, + capitalInflow = 0, + investTargets = [], }: { ct: CashTransitionData; setC: (patch: Partial) => void; deflatorEnd: number; // Bestands-Deflator an der Phasengrenze + showCapitalUse?: boolean; + capitalInflow?: number; + investTargets?: { id: string; name: string }[]; }) { const mode = ct.mode ?? "NONE"; const showIn = mode === "INFLOW" || mode === "BOTH"; @@ -347,6 +362,75 @@ export function CashTransitionFields({ /> )} + + {showCapitalUse && } + + ); +} + +// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C). +// +// In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene +// Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin +// still eine falsche Aufteilung rechnen. Eine Quote skaliert mit. +function CapitalUseFields({ + ct, + setC, + inflow, + targets, +}: { + ct: CashTransitionData; + setC: (patch: Partial) => void; + inflow: number; + targets: { id: string; name: string }[]; +}) { + const amort = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct))); + const invest = Math.max(0, Math.min(100 - amort, num(ct.capitalUseInvestPct))); + const cash = Math.max(0, 100 - amort - invest); + + return ( + <> +
+ Verwendung des Kapitalzuflusses (PK-Kapital, Säule 3a, + Verkaufserlös). Bei diesem Übergang fliessen{" "} + {formatChf(inflow)} netto herein. Erfasst wird die + Aufteilung in Prozent – so bleibt sie richtig, wenn du das Pensionsalter verschiebst und + sich der Betrag dadurch ändert. Was nicht zugeteilt ist, bleibt Cash. +
+ setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })} + /> + setC({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })} + /> + {invest > 0 && targets.length > 0 && ( +
+ setC({ capitalUseTargetElementId: v })} + options={targets.map((t) => ({ value: t.id, label: t.name }))} + /> +
+ )} + ); } @@ -365,6 +449,35 @@ export function ElementPhaseFields({ setP: (patch: Partial) => void; }) { const carried = context.carried; + + // Punkt A (Roadmap Nr. 44): Wiederkehr-Parameter werden aus der Vorphase übernommen, + // solange das Feld leer bleibt. `inh` baut die Hülle mit dem sichtbaren Häkchen. + const inh = ( + key: InheritableKey, + label: string, + render: (value: number, set: (v: number) => void) => React.ReactNode, + format: (v: number) => string, + help?: string + ) => { + const own = pd[key]; + const fallback = context.inheritedValues[key] ?? 0; + const isInheriting = carried && typeof own !== "number"; + return ( + setP({ [key]: inherit ? undefined : fallback } as Partial)} + > + {render(typeof own === "number" ? own : fallback, (v) => setP({ [key]: v } as Partial))} + + ); + }; + const asPct = (v: number) => `${v} %`; + const asChf = (v: number) => formatChf(v); + switch (element.category) { case "INCOME": case "EXPENSE": { @@ -372,7 +485,6 @@ export function ElementPhaseFields({ // Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase // vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …). const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0; - const rate = num(pd.teuerungsausgleich, 0); const d = context.deflatorStart || 1; // Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal. const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d); @@ -394,21 +506,36 @@ export function ElementPhaseFields({ value={otherValue} help="Nur zur Info, wird automatisch berechnet." /> - setP({ teuerungsausgleich: v })} - /> + {inh( + "teuerungsausgleich", + isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)", + (v, set) => ( + + ), + asPct, + isIncome + ? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft." + : "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard." + )} ); } - case "AHV": + case "AHV": { + // Seit Roadmap Nr. 44 beginnt die AHV-Rente IMMER mit dem Referenzalter -- unabhängig + // davon, wann die Person aufhört zu arbeiten (Kap. 4.4.6). Daraus folgen zwei Fälle, + // die es vorher nicht gab und die hier sichtbar gemacht werden müssen: + // * Pensionierung VOR 65 -> beitragspflichtig als Nichterwerbstätige(r) bis 65. + // * Erwerbstätig ÜBER 65 -> die Rente fliesst trotzdem ab 65. + const ageStart = context.ownerAgeStart; + const ageEnd = ageStart + context.durationYears - 1; + const reachesReference = ageEnd >= AHV_REFERENCE_AGE; + const startsBeforeReference = ageStart < AHV_REFERENCE_AGE; + if (!context.ownerWorking) { // Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang, // an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase). @@ -442,23 +569,61 @@ export function ElementPhaseFields({ ); } return ( -

- Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet - (massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die - Plafonierung auf 150% der Maximalrente. -

+ <> +

+ Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet + (massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die + Plafonierung auf 150% der Maximalrente. Sie fliesst ab Alter {AHV_REFERENCE_AGE} + {reachesReference && startsBeforeReference + ? ` – also erst im Verlauf dieser Lebensphase (Alter ${ageStart} bis ${ageEnd}).` + : "."} +

+ {startsBeforeReference && ( + <> +

+ Frühpensionierung: Wer vor Alter {AHV_REFERENCE_AGE} aufhört zu + arbeiten, bleibt bis dahin AHV-beitragspflichtig – als Nichterwerbstätige(r). Der + Beitrag ist eine laufende Ausgabe und fällt mit {AHV_REFERENCE_AGE} weg, wenn die + Rente einsetzt. +

+ setP({ ahvContribution: v })} + /> + + )} + ); } return ( - setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })} - /> + <> + setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })} + /> + {reachesReference && ( +

+ Weiterarbeiten über {AHV_REFERENCE_AGE} hinaus: Die AHV-Rente + beginnt trotzdem mit {AHV_REFERENCE_AGE} und fliesst neben dem Lohn. Ein Aufschub + der Rente wird nicht abgebildet. Erfasse deshalb nur die Ausfalljahre bis zum + Referenzalter. +

+ )} + ); + } case "PENSION_FUND": if (!context.ownerWorking) { return ( @@ -484,13 +649,19 @@ export function ElementPhaseFields({ ) : ( setP({ currentValue: v })} /> )} - setP({ annualContribution: v })} - /> - setP({ expectedReturn: v })} /> + {inh( + "annualContribution", + "Jährliche Einzahlung (CHF)", + (v, set) => , + asChf, + "Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)." + )} + {inh( + "expectedReturn", + "Erwartete Rendite (%/Jahr)", + (v, set) => , + asPct + )} ); case "PILLAR_3A": @@ -513,14 +684,21 @@ export function ElementPhaseFields({ ) : ( setP({ currentValue: v })} /> )} - setP({ annualContribution: v })} - /> - setP({ expectedReturn: v })} /> + {inh( + "annualContribution", + "Jährliche Einzahlung (CHF)", + (v, set) => ( + + ), + asChf, + `Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.` + )} + {inh( + "expectedReturn", + "Erwartete Rendite (%/Jahr)", + (v, set) => , + asPct + )} ); case "REAL_ESTATE": { @@ -548,19 +726,20 @@ export function ElementPhaseFields({ setP({ mortgage: v })} /> )} - setP({ amortization: v })} - /> - setP({ interestRate: v })} - /> + {inh( + "amortization", + "Amortisation (CHF/Jahr)", + (v, set) => , + asChf, + "Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist." + )} + {inh( + "interestRate", + "Hypothekarzins (%/Jahr)", + (v, set) => , + asPct, + "Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation." + )}
→ {formatChf(zinsEnde)}
- setP({ valueGrowth: v })} - /> + {inh( + "valueGrowth", + "Geschätzte Wertsteigerung (%/Jahr)", + (v, set) => , + asPct, + "Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)." + )}
setP({ startValue: v })} /> )} - setP({ expectedReturn: v })} /> - setP({ annualContribution: v })} - /> - setP({ annualWithdrawal: v })} - /> + {inh( + "expectedReturn", + "Erwartete Rendite (%/Jahr)", + (v, set) => , + asPct + )} + {inh( + "annualContribution", + "Jährlicher Sparbeitrag (CHF)", + (v, set) => , + asChf, + "Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)." + )} + {inh( + "annualWithdrawal", + "Jährliche Bezugsrate (CHF)", + (v, set) => , + asChf, + "Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)." + )} ); case "OTHER_DEBT": @@ -632,13 +818,13 @@ export function ElementPhaseFields({ ) : ( setP({ startValue: v })} /> )} - setP({ annualRepayment: v })} - /> + {inh( + "annualRepayment", + "Jährliche Tilgung (CHF)", + (v, set) => , + asChf, + "Zählt gegen die Sparquote (max. die Sparquote)." + )} ); } diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index e17ff90..1e030ad 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -320,3 +320,54 @@ export function SelectField({
); } + +// --- "Aus Vorphase uebernehmen" (Roadmap Nr. 44, Punkt A) -------------------------------- +// +// Wiederkehr-Parameter (Raten, Beitraege, Amortisation) gelten weiter, bis man sie bewusst +// aendert. Bisher war das unsichtbar: Beim Anlegen einer Phase wurde der Wert KOPIERT, und +// spaetere Aenderungen an der Vorphase kamen deshalb nie an. Jetzt entscheidet ein sichtbares +// Haekchen: angehakt = Feld leer lassen und live erben; abgehakt = eigener Wert fuer diese +// Phase. In der ersten Phase gibt es nichts zu erben -- dort erscheint das Haekchen nicht. +export function InheritableField({ + label, + help, + canInherit, + isInheriting, + inheritedDisplay, + onToggle, + children, +}: { + label: string; + help?: string; + canInherit: boolean; + isInheriting: boolean; + inheritedDisplay: string; + onToggle: (inherit: boolean) => void; + children: React.ReactNode; +}) { + return ( +
+ {isInheriting ? ( + <> + +
+ {inheritedDisplay} (aus der Vorphase) +
+ + ) : ( + children + )} + {canInherit && ( + + )} +
+ ); +} diff --git a/src/components/PlanProfileFields.tsx b/src/components/PlanProfileFields.tsx index 91ac000..fed1eda 100644 --- a/src/components/PlanProfileFields.tsx +++ b/src/components/PlanProfileFields.tsx @@ -1,6 +1,6 @@ "use client"; -import { NumberField, SelectField, TextField } from "@/components/FormField"; +import { FieldLabel, NumberField, SelectField, TextField } from "@/components/FormField"; import type { HouseholdType, PersonRole } from "@/lib/types"; export interface ProfileDraft { @@ -24,9 +24,14 @@ export function emptyProfileDraft(): ProfileDraft { export function PlanProfileFields({ draft, onChange, + // Sobald Lebensphasen bestehen, liegt jede Pensionierung auf einer Phasengrenze. Ein frei + // änderbares Alter würde diese Grenze zerreissen -- deshalb übernimmt dort die eigene + // Bedienung «Pensionsalter anpassen» (Roadmap Nr. 44), und das Feld ist nur noch Anzeige. + lockRetirement = false, }: { draft: ProfileDraft; onChange: (next: ProfileDraft) => void; + lockRetirement?: boolean; }) { function setType(type: HouseholdType) { if (type === "SINGLE") { @@ -89,14 +94,26 @@ export function PlanProfileFields({ max={120} onChange={(v) => updatePerson(index, { age: Math.round(v) })} /> - updatePerson(index, { retirementAge: Math.round(v) })} - /> + {lockRetirement ? ( +
+ +
+ {person.retirementAge} Jahre +
+
+ ) : ( + updatePerson(index, { retirementAge: Math.round(v) })} + /> + )} ))} diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index ce4a301..a097313 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -50,6 +50,7 @@ import { } from "@/components/ElementDetail"; import { PhaseDetail } from "@/components/PhaseDetail"; import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields"; +import { RetirementAdjuster } from "@/components/RetirementAdjuster"; import { MoneyField } from "@/components/FormField"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; @@ -58,6 +59,7 @@ import { CATEGORY_LABELS, CATEGORY_ORDER, PERSON_ONLY_CATEGORIES, + inheritedPhaseValues, num, type CashTransitionData, type ElementCategory, @@ -256,6 +258,16 @@ export function PlanView({ return computed.phases.length - phase.sequenceNumber; } + // Phasen in ihrer Reihenfolge -- Grundlage der Vererbung (Roadmap Nr. 44, Punkt A). + const orderedPhaseIds = computed.phases.map((p) => p.id); + + // Alter des Element-Besitzers im ersten Jahr einer Phase. Ohne Personenzuordnung gilt + // Person A, weil die Zeitachse ohnehin an ihr hängt. + function ownerAgeAtPhaseStart(phase: PhaseComputed, element: ElementInput): number { + const role = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? element.ownerRole : "PERSON_A"; + return phase.persons.find((p) => p.role === role)?.startAge ?? 0; + } + // Baut den Kontext für eine Phasenzelle. function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext { const ce = computedElement(phase.id, element.id); @@ -280,6 +292,8 @@ export function PlanView({ deflatorStart: phase.cumulativeInflationStart, laterPhaseCount: laterPhaseCount(phase), ahvCareer: careerFor(element), + inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id), + ownerAgeStart: ownerAgeAtPhaseStart(phase, element), }; } @@ -302,6 +316,8 @@ export function PlanView({ deflatorStart: fromPhase.cumulativeInflationStart, laterPhaseCount: laterPhaseCount(fromPhase), ahvCareer: careerFor(element), + inheritedValues: {}, + ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element), }; } @@ -1468,6 +1484,9 @@ function AddElementDialog({ deflatorStart: firstPhase.cumulativeInflationStart, laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis ahvCareer: null, + // Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere. + inheritedValues: {}, + ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0, }; async function create() { @@ -1615,6 +1634,14 @@ function AddPhaseDialog({ ); } +// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle +// Vermögens-Elemente, die in der Folgephase noch aktiv sind. +function investTargetsOf(toPhase: PhaseComputed | undefined): { id: string; name: string }[] { + return (toPhase?.elements ?? []) + .filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE") + .map((e) => ({ id: e.elementId, name: e.name })); +} + // --- Panel: Szenario-Profil (Grundprofil bearbeiten) --- function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { const [draft, setDraft] = useState({ @@ -1642,12 +1669,21 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () return (
- + 0} /> {error &&

{error}

}
+ + {plan.phases.length > 0 && ( +
+

Pensionsalter anpassen

+ {/* Wirkt SOFORT (eigener Endpunkt), weil dabei Phasendauern verschoben werden -- + das lässt sich nicht sinnvoll mit dem Profil-Formular zusammen abspeichern. */} + +
+ )}
); @@ -1723,6 +1759,9 @@ function TransitionReviewDialog({ ct={ct} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={fromPhase.cumulativeInflationEnd} + showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0} + capitalInflow={toPhase?.capitalInflow ?? 0} + investTargets={investTargetsOf(toPhase)} /> @@ -1814,6 +1853,9 @@ function CashTransitionPanel({ ct={ct} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={fromPhase.cumulativeInflationEnd} + showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0} + capitalInflow={toPhase?.capitalInflow ?? 0} + investTargets={investTargetsOf(toPhase)} /> {error &&

{error}

} diff --git a/src/components/RetirementAdjuster.tsx b/src/components/RetirementAdjuster.tsx new file mode 100644 index 0000000..8f72ea3 --- /dev/null +++ b/src/components/RetirementAdjuster.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { api } from "@/lib/api-client"; +import { limitsText, retirementBoundaries, shiftRetirement, sortedPhases } from "@/lib/retirement"; +import { Button, useConfirm } from "@/components/ui"; +import type { PlanInput } from "@/lib/types"; + +// Pensionsalter anpassen (Roadmap Nr. 44). +// +// Warum eine eigene Bedienung statt eines Zahlenfeldes im Profil: Eine Pensionierung liegt +// immer auf einer Phasengrenze. Ein frei änderbares Alter würde diese Grenze zerreissen -- +// die Phasen blieben, wo sie sind, und der Phasentyp (Erwerb/Pension) käme mitten in einer +// Phase ins Rutschen. Hier wird stattdessen die GRENZE verschoben: Die Phase davor wird +// länger, die danach kürzer, die Gesamtdauer des Plans bleibt gleich. + +function personLabel(plan: PlanInput, role: string): string { + const p = plan.persons.find((x) => x.role === role); + if (p?.name) return p.name; + if (plan.householdType === "SINGLE") return "Du"; + return role === "PERSON_A" ? "Person A" : "Person B"; +} + +export function RetirementAdjuster({ plan, onSaved }: { plan: PlanInput; onSaved: () => void }) { + const confirm = useConfirm(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const boundaries = useMemo(() => retirementBoundaries(plan), [plan]); + + async function shift(role: "PERSON_A" | "PERSON_B", delta: number) { + setError(null); + const preview = shiftRetirement(plan, role, delta); + if (!preview) { + setError("So weit lässt sich das Pensionsalter hier nicht verschieben."); + return; + } + + // Zusammenlegung: Die entfallende Phase nimmt einen ganzen Übergang mit. Das muss VORHER + // klar sein -- nachher liesse es sich nur durch erneutes Erfassen rückgängig machen. + if (preview.removedPhaseId) { + const removed = sortedPhases(plan).find((p) => p.id === preview.removedPhaseId); + const target = sortedPhases(plan).find((p) => p.id === preview.mergedIntoPhaseId); + const ok = await confirm({ + title: "Lebensphase fällt weg", + message: + `Bei dieser Verschiebung entfällt die Lebensphase «${removed?.name ?? ""}» vollständig. ` + + (target + ? `Ihre Übergangs-Entscheide werden in den Übergang nach «${target.name}» gezogen: Bereits ` + + `getroffene Entscheide bleiben, leere Felder werden von dort ergänzt, und einmalige ` + + `Cash-Beträge werden addiert. ` + : "") + + "Fortfahren?", + confirmLabel: "Phase zusammenlegen", + danger: true, + }); + if (!ok) return; + } + + setBusy(role); + try { + await api.post(`/api/scenarios/${plan.id}/retirement`, { role, delta, confirmMerge: true }); + onSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : "Anpassung fehlgeschlagen."); + } finally { + setBusy(null); + } + } + + return ( +
+ {boundaries.map((b) => { + const label = personLabel(plan, b.role); + const canMergeDown = b.mergeDeltaDown !== null; + const canMergeUp = b.mergeDeltaUp !== null; + return ( +
+
+
+
{label}
+
+ Pension mit {b.retirementAge} + {b.blocked ? "" : Planjahr {b.planYear}} +
+
+ {!b.blocked && ( +
+ + +
+ )} +
+ + {/* Hilfebox: Was ist möglich -- und warum nicht mehr. Ohne diese Erklärung wirkt + ein deaktivierter Knopf wie ein Fehler. */} +

+ {limitsText(b, label)} +

+ + {!b.blocked && (b.minDelta < -1 || b.maxDelta > 1) && ( +
+ {[-5, -3, -2, 2, 3, 5] + .filter((d) => d >= (b.mergeDeltaDown ?? b.minDelta) && d <= (b.mergeDeltaUp ?? b.maxDelta)) + .map((d) => ( + + ))} +
+ )} +
+ ); + })} + {error &&

{error}

} +
+ ); +} diff --git a/src/lib/actuals.test.ts b/src/lib/actuals.test.ts index 55de2d6..9a8032c 100644 --- a/src/lib/actuals.test.ts +++ b/src/lib/actuals.test.ts @@ -295,3 +295,50 @@ describe("rebaseFlow", () => { expect(rebaseFlow(1000, -100, 3)).toBe(1000); }); }); + +describe("Effektive Flusswerte wirken in die Folgephase (Roadmap Nr. 44)", () => { + // Zwei Phasen a 5 Jahre, Lohn 100'000 ohne Teuerung. Im Planjahr 3 wird der Lohn + // effektiv mit 200'000 erfasst -- das muss auch in Phase 2 ankommen, sonst faellt der + // Wert am Phasenwechsel stillschweigend auf den geplanten zurueck. + function lohnPlan(): PlanInput { + return { + id: "s", + name: "T", + householdType: "SINGLE", + inflationRateDefault: 0, + initialCash: 0, + startYear: 2020, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }], + phases: [ + { id: "p1", sequenceNumber: 1, name: "E1", durationYears: 5, cashTransition: {} }, + { id: "p2", sequenceNumber: 2, name: "E2", durationYears: 5, cashTransition: {} }, + ], + elements: [ + { + id: "lohn", + category: "INCOME", + name: "Lohn", + ownerRole: "HOUSEHOLD", + orderIndex: 1, + phaseValues: { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }, + transitionValues: {}, + sourceElementId: null, + }, + ], + } as unknown as PlanInput; + } + + it("traegt den effektiven Wert ueber die Phasengrenze", () => { + const p = lohnPlan(); + const actuals = resolveActuals([setAt(2022, { lohn: { value: 200000 } })], p, p.elements); + const computed = computePlan(p, undefined, { actuals }); + expect(valueAt(computed, "lohn", 3)).toBe(200000); + // Phase 2 beginnt im Planjahr 6 -- ohne den Fix stuende hier wieder 100'000. + expect(valueAt(computed, "lohn", 6)).toBe(200000); + }); + + it("bleibt ohne Ist-Werte auf der Planlinie", () => { + const computed = computePlan(lohnPlan()); + expect(valueAt(computed, "lohn", 6)).toBe(100000); + }); +}); diff --git a/src/lib/bridges.test.ts b/src/lib/bridges.test.ts index 9c2d94c..34a3209 100644 --- a/src/lib/bridges.test.ts +++ b/src/lib/bridges.test.ts @@ -220,6 +220,38 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ ], }), }, + { + // Roadmap Nr. 44, Punkt C: Das PK-Kapital wird nicht als Cash liegen gelassen, sondern + // per Quote auf Hypothek und Anlagen verteilt. Beide Brücken müssen weiterhin aufgehen. + name: "Kapitalverwendung am Pensions-Übergang (Quoten)", + build: () => + plan({ + age: 60, + retirementAge: 65, + initialCash: 50000, + phases: [ + { id: "p1", durationYears: 5, cashTransition: { capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } }, + { id: "p2", durationYears: 10 }, + ], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 120000 } }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }), + el( + "PENSION_FUND", + "PERSON_A", + { p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} }, + { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } + ), + el( + "REAL_ESTATE", + "HOUSEHOLD", + { p1: { purchasePrice: 900000, mortgage: 600000, amortization: 5000, valueGrowth: 1 }, p2: {} }, + { p1: { decision: "HOLD" } } + ), + el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 4 }, p2: {} }), + ], + }), + }, ]; describe("Wasserfall-Brücken", () => { @@ -272,3 +304,44 @@ describe("Wasserfall-Brücken", () => { expect(verkauft.saleGainLoss).not.toBe(0); }); }); + +describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", () => { + const withQuoten = konstellationen.find((k) => k.name.startsWith("Kapitalverwendung"))!; + + it("leitet den Zufluss gemaess Quote in Hypothek und Anlage statt ins Cash", () => { + const mit = computePlan(withQuoten.build()); + const ohne = computePlan({ + ...withQuoten.build(), + phases: withQuoten.build().phases.map((p) => ({ ...p, cashTransition: {} })), + }); + const p2Mit = mit.phases[1]; + const p2Ohne = ohne.phases[1]; + + // 90 % des Kapitals werden verwendet -> deutlich weniger Cash zu Beginn von Phase 2. + expect(p2Mit.cashStart).toBeLessThan(p2Ohne.cashStart); + // Die Hypothek ist tiefer, das Vermoegen dadurch hoeher. + const immoMit = p2Mit.elements.find((e) => e.category === "REAL_ESTATE")!; + const immoOhne = p2Ohne.elements.find((e) => e.category === "REAL_ESTATE")!; + expect(immoMit.mortgageStart).toBeLessThan(immoOhne.mortgageStart); + // Das Sonstige Vermoegen startet hoeher. + const assetMit = p2Mit.elements.find((e) => e.category === "OTHER_ASSET")!; + const assetOhne = p2Ohne.elements.find((e) => e.category === "OTHER_ASSET")!; + expect(assetMit.startValue).toBeGreaterThan(assetOhne.startValue); + }); + + it("skaliert mit dem Pensionsalter, statt einen Frankenbetrag stehen zu lassen", () => { + // Genau der Grund fuer Quoten statt Betraege: Ein Jahr laenger arbeiten heisst mehr + // Kapital -- und damit automatisch mehr Amortisation und mehr Anlage. + const base = withQuoten.build(); + const laenger: PlanInput = { + ...base, + persons: base.persons.map((p) => ({ ...p, retirementAge: 66 })), + phases: base.phases.map((p, i) => + i === 0 ? { ...p, durationYears: 6 } : { ...p, durationYears: 9 } + ), + }; + const a = computePlan(base).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!; + const b = computePlan(laenger).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!; + expect(b.mortgageStart).toBeLessThan(a.mortgageStart); + }); +}); diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts index 3bd2767..f653e05 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -733,3 +733,161 @@ describe("V5 Golden Tests", () => { expect(r.phases[1].cashStart).toBe(r.phases[0].cashEnd); }); }); + +// --- Roadmap Nr. 44: Referenzalter, Beitragspflicht, Einkommen bei Pensionierung --- + +describe("AHV-Referenzalter (Kap. 4.4.6)", () => { + // Person wird mit 60 pensioniert, die AHV-Rente beginnt trotzdem erst mit 65. + function fruehPlan(beitrag: number) { + return plan({ + age: 55, + retirementAge: 60, + inflation: 0, + initialCash: 500000, + phases: [ + { id: "p1", durationYears: 5 }, // Alter 55-60, erwerbstaetig + { id: "p2", durationYears: 15 }, // Alter 60-75, pensioniert + ], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 60000, teuerungsausgleich: 0 }, p2: {} }), + el( + "AHV", + "PERSON_A", + { p1: {}, p2: { ahvContribution: beitrag } }, + { p1: { reviewed: true, avgIncomeBefore: 100000, gapYearsBefore: 0 } } + ), + ], + }); + } + + const ahvYearly = (p: PlanInput, phaseIdx: number) => + computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.yearly.map((y) => y.value); + + it("zahlt vor 65 den Beitrag und ab 65 die Rente", () => { + const werte = ahvYearly(fruehPlan(2000), 1); + // Phase 2 laeuft von Alter 60 bis 75: 5 Jahre Beitrag (60-64), dann 10 Jahre Rente. + expect(werte.slice(0, 5)).toEqual([-2000, -2000, -2000, -2000, -2000]); + expect(new Set(werte.slice(5))).toEqual(new Set([32760])); + }); + + it("belastet den Beitrag als Kosten -- ohne Beitrag bleibt mehr Vermoegen", () => { + const mit = computePlan(fruehPlan(20000)); + const ohne = computePlan(fruehPlan(0)); + const endeMit = mit.phases[1].endWealthNominal; + const endeOhne = ohne.phases[1].endWealthNominal; + expect(endeOhne).toBeGreaterThan(endeMit); + // 5 Beitragsjahre x 20'000 = 100'000 Unterschied (zzgl. entgangener Rendite darauf). + expect(endeOhne - endeMit).toBeGreaterThanOrEqual(100000); + }); + + it("laesst die Rente auch waehrend der Erwerbstaetigkeit ab 65 fliessen", () => { + // Pension erst mit 70 -- die Rente muss trotzdem ab 65 kommen. + const p = plan({ + age: 60, + retirementAge: 70, + inflation: 0, + phases: [ + { id: "p1", durationYears: 10 }, // Alter 60-70, erwerbstaetig (deckt 65 ab) + { id: "p2", durationYears: 5 }, + ], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }), + el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }), + ], + }); + const werte = ahvYearly(p, 0); + // Jahre 1-5 (Alter 60-64): nichts, danach die Rente. + expect(werte.slice(0, 5)).toEqual([0, 0, 0, 0, 0]); + expect(werte[5]).toBeGreaterThan(0); + }); + + it("Pensionierung mit genau 65 bleibt unveraendert", () => { + const p = plan({ + age: 60, + retirementAge: 65, + inflation: 0, + phases: [ + { id: "p1", durationYears: 5 }, + { id: "p2", durationYears: 10 }, + ], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }), + el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }), + ], + }); + const werte = ahvYearly(p, 1); + expect(werte).toHaveLength(10); + expect(new Set(werte)).toEqual(new Set([32760])); + }); +}); + +describe("Einkommen bei Pensionierung (Roadmap Nr. 44, Punkt B)", () => { + function lohnPlan(p2: PhaseData, owner: "PERSON_A" | "HOUSEHOLD") { + return plan({ + age: 60, + retirementAge: 65, + inflation: 0, + household: "COUPLE", // sonst zaehlt "Gemeinsam" zur Person A + phases: [ + { id: "p1", durationYears: 5 }, + { id: "p2", durationYears: 5 }, + ], + elements: [el("INCOME", owner, { p1: { amount: 100000, teuerungsausgleich: 0 }, p2 })], + }); + } + const lohn = (p: PlanInput) => computePlan(p).phases[1].elements[0].startValue; + + it("faellt ein personenbezogener Lohn ohne eigenen Wert auf 0", () => { + expect(lohn(lohnPlan({}, "PERSON_A"))).toBe(0); + }); + + it("bleibt bestehen, wenn ausdruecklich ein Betrag erfasst ist (Teilzeit)", () => { + expect(lohn(lohnPlan({ amount: 30000, teuerungsausgleich: 0 }, "PERSON_A"))).toBe(30000); + }); + + it("laesst gemeinsame Einkommen unberuehrt", () => { + expect(lohn(lohnPlan({}, "HOUSEHOLD"))).toBe(100000); + }); +}); + +describe("Wiederkehr-Parameter aus der Vorphase (Roadmap Nr. 44, Punkt A)", () => { + function assetPlan(p2: PhaseData) { + return plan({ + age: 40, + retirementAge: 65, + inflation: 0, + phases: [ + { id: "p1", durationYears: 10 }, + { id: "p2", durationYears: 10 }, + ], + elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2 })], + }); + } + const endwert = (p: PlanInput) => computePlan(p).phases[1].elements[0].endValue; + + it("uebernimmt eine nicht erfasste Rendite aus der Vorphase", () => { + // Ohne Vererbung waere Phase 2 zinslos -- der Endwert bliebe auf dem Stand von Phase 1. + // Rundung je Phase -> auf den Franken genau vergleichen waere zu streng. + expect(endwert(assetPlan({}))).toBeCloseTo(100000 * Math.pow(1.05, 20), -1); + }); + + it("laesst einen ausdruecklich erfassten Wert gewinnen", () => { + expect(endwert(assetPlan({ expectedReturn: 0 }))).toBe(Math.round(100000 * Math.pow(1.05, 10))); + }); + + it("traegt die Vererbung ueber mehrere Phasen", () => { + const p = plan({ + age: 40, + retirementAge: 75, + inflation: 0, + phases: [ + { id: "p1", durationYears: 10 }, + { id: "p2", durationYears: 10 }, + { id: "p3", durationYears: 10 }, + ], + elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2: {}, p3: {} })], + }); + expect(computePlan(p).phases[2].elements[0].endValue).toBeCloseTo(100000 * Math.pow(1.05, 30), -1); + }); +}); diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index d4949c1..a08735f 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -4,6 +4,7 @@ import { AHV_FULL_CONTRIBUTION_YEARS, AHV_GROSS_FROM_NET_FACTOR, AHV_MAX_ANNUAL_SINGLE, + AHV_REFERENCE_AGE, AHV_MIN_MONTHLY_FULL, AHV_PENSION_MONTHS, DEFAULT_CAPITAL_TAX_RATE, @@ -292,6 +293,9 @@ interface Carry { owed: number; // Schulden: Restschuld (positiv) pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase + // Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge, + // Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase. + rates: Record; hasCarry: boolean; } @@ -305,6 +309,7 @@ function emptyCarry(): Carry { owed: 0, pkPensionAnnual: 0, flowBasis: 0, + rates: {}, hasCarry: false, }; } @@ -448,12 +453,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp }); } - // AHV-Renten der Pensionierten: Vollrente zum mdJE, gekürzt um die Ausfalljahre. + // 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 + // für bereits pensionierte: Sie fliesst ab dem REFERENZALTER -- auch wenn jemand darüber + // hinaus arbeitet. Ob sie in einem Jahr tatsächlich fliesst, entscheidet die Jahres- + // schleife anhand des Alters (Kap. 4.4.6). const ahvUncapped = new Map(); 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 (!owner) continue; const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 }; const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson); const mdJE = ahvMdje(career, before.avg, before.gap); @@ -472,9 +482,19 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const ecById = new Map(); // Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere). const phaseRealIncomeByPerson = new Map(); - const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; - const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; - let renteTotal = 0; // AHV + PK-Renten (nominal fix) + const incomes: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = []; + const expenses: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = []; + let renteTotal = 0; // PK-Renten (nominal fix ueber die ganze Phase) + // AHV je Person: Rente und Beitrag stehen fest, WANN sie greifen entscheidet das Alter + // im jeweiligen Jahr -- deshalb eine eigene Liste statt eines Phasenbetrags. + const ahvItems: { + ownerId: string; + ownerStartAge: number; + rente: number; + beitrag: number; + working: boolean; + ec: ElementPhaseComputed; + }[] = []; // `isPk` für die Vermögens-Brücke: PK-Beiträge verlassen das Cash NICHT (sie sind im // Nettolohn bereits abgezogen), erhöhen aber das Vermögen -- sie sind also ein echter // Zugang und keine Umbuchung. @@ -512,6 +532,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking; + // Punkt A (Roadmap Nr. 44): Ein nicht erfasster Wiederkehr-Parameter wird aus der + // Vorphase ÜBERNOMMEN, statt stillschweigend auf 0 zu fallen. Im UI ist das das + // angehakte «Aus Vorphase übernehmen»; ein eigener Wert hakt es ab. Der jeweils + // verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt. + const inherited = (key: string, fallback = 0): number => { + const own = (pd as Record)[key]; + const v = typeof own === "number" ? own : carry.rates[key] ?? fallback; + carry.rates[key] = v; + return v; + }; + const ec: ElementPhaseComputed = { elementId: e.id, category: e.category, @@ -549,14 +580,24 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp // REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu. // Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase // (nominal für Einkommen, real für Ausgaben), ausser bewusst geändert (pd.amount). - const idx = num(pd.teuerungsausgleich, 0); + const idx = inherited("teuerungsausgleich"); ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount)); - const basis = !carry.hasCarry + let basis = !carry.hasCarry ? Math.round(num(pd.amount)) : typeof pd.amount === "number" ? Math.round(pd.amount) : ec.baseValue; - (e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec }); + // Ist die besitzende Person pensioniert, fällt ihr Erwerbseinkommen weg -- sonst liefe + // der Lohn stillschweigend in die Pension weiter (Kap. 4.4.7). Ein ausdrücklich + // erfasster Betrag gewinnt, damit ein Teilzeitpensum oder eine Erwerbsersatz-Zahlung + // modellierbar bleibt. Gemeinsame Einkommen (Mieterträge o. Ä.) sind NICHT betroffen, + // weil sie nicht an der Erwerbstätigkeit einer Person hängen. + if (e.category === "INCOME" && owner && !ownerWorking && typeof pd.amount !== "number") { + basis = 0; + ec.baseValue = 0; + ec.note = "Wegen Pensionierung auf 0 gesetzt. Für ein Teilzeitpensum trage hier einen Betrag ein."; + } + (e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec, carry }); // AHV: reales Erwerbseinkommen der Person mitführen. Nur Einkommen, die einer // Person zugeordnet sind -- bei einem Einzelplan zählt "Gemeinsam" zur Person A. @@ -576,20 +617,41 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp } } - // Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben). - carry.flowBasis = basis * Math.pow(1 + idx / 100, duration); + // Die Fortschreibung in die Folgephase passiert NACH der Jahresschleife: Dort kann ein + // effektiver Wert `basis` noch neu setzen (`rebaseFlow`), und genau der soll weiter- + // getragen werden -- nicht der ursprünglich geplante. break; } case "AHV": { - if (owner && !ownerWorking) { + // Die AHV wird JAHRESWEISE gerechnet (Kap. 4.4.6): Bis zum Referenzalter zahlt eine + // frühpensionierte Person Beiträge, ab dem Referenzalter fliesst die Rente -- beides + // kann INNERHALB derselben Phase kippen, deshalb nicht über `renteTotal`. + if (owner) { const rente = ahvFinal.get(owner.id) ?? 0; - renteTotal += rente; - ec.startValue = rente; - ec.endValue = rente; - ec.summary = `Rente ${fmt(rente)}`; - } else { - const gap = Math.max(0, Math.round(num(pd.gapYears))); - ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre"; + const beitrag = Math.round(num(pd.ahvContribution)); + 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 }); + + const reachesRef = ageEnd >= AHV_REFERENCE_AGE; + const startsRetired = ageStart >= AHV_REFERENCE_AGE; + ec.startValue = startsRetired ? rente : 0; + ec.endValue = reachesRef ? rente : 0; + + if (startsRetired) { + ec.summary = `Rente ${fmt(rente)}`; + } else if (reachesRef && !ownerWorking) { + 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.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"; + } } break; } @@ -606,12 +668,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; - const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zählt NICHT zur Quote + const rate = Math.round(inherited("annualContribution")); // PK-Beitrag zählt NICHT zur Quote if (!isFirstPhase) investmentsFromCash += topUp; ec.baseValue = base; ec.startValue = start; wealthStart += start; - assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: true, ec }); + assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal: 0, isPk: true, ec }); } break; } @@ -623,13 +685,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; - const rate = Math.round(num(pd.annualContribution)); + const rate = Math.round(inherited("annualContribution")); fixedRatesTotal += rate; if (!isFirstPhase) investmentsFromCash += topUp; ec.baseValue = base; ec.startValue = start; wealthStart += start; - assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: false, ec }); + assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal: 0, isPk: false, ec }); } break; } @@ -637,15 +699,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; - const rate = Math.round(num(pd.annualContribution)); - const withdrawal = Math.round(num(pd.annualWithdrawal)); + const rate = Math.round(inherited("annualContribution")); + const withdrawal = Math.round(inherited("annualWithdrawal")); fixedRatesTotal += rate; plannedWithdrawTotal += withdrawal; if (!isFirstPhase) investmentsFromCash += topUp; ec.baseValue = base; ec.startValue = start; wealthStart += start; - assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, isPk: false, ec }); + assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal, isPk: false, ec }); break; } case "REAL_ESTATE": { @@ -654,7 +716,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice)); const valueStart = carry.hasCarry ? carry.propertyValue : Math.round(num(pd.purchasePrice)); const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage)); - const amort = Math.round(num(pd.amortization)); + const amort = Math.round(inherited("amortization")); const equity = valueStart - mortgageStart; if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity); ec.baseValue = equity; @@ -666,8 +728,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp purchase, mortgage: mortgageStart, amort, - growth: num(pd.valueGrowth), - interestRate: num(pd.interestRate), + growth: inherited("valueGrowth"), + interestRate: inherited("interestRate"), // Default INCLUDED: bestehende Pläne haben die Zinsen in den Ausgaben -> nicht // nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu. addInterest: pd.interestHandling === "ADD", @@ -677,7 +739,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp } case "OTHER_DEBT": { const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue)); - const repay = Math.round(num(pd.annualRepayment)); + const repay = Math.round(inherited("annualRepayment")); ec.baseValue = -owedStart; ec.startValue = -owedStart; wealthStart += -owedStart; @@ -742,8 +804,20 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp interestNominal += re.mortgage * (re.interestRate / 100); } - const expenseNominal = expenseRealBase * inflFactor + interestNominal; - const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1); + // 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). + 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; + } + incomeFlow += ahvIncome; + + const expenseNominal = expenseRealBase * inflFactor + interestNominal + ahvCost; + const expenseReal = expenseRealBase + (interestNominal + ahvCost) / (inflFactor || 1); const quote = incomeFlow - expenseNominal; // Der Vermögenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter @@ -899,9 +973,16 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp }); } for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) }); - // Renten (AHV, verrentete PK) laufen nominal fix durch die Phase. + // AHV jahresweise: vor dem Referenzalter der Beitrag als Nichterwerbstätige(r) (negativ, + // 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 }); + } + // Verrentete PK läuft nominal fix durch die Phase. for (const ec of ecById.values()) { - if ((ec.category === "AHV" || ec.category === "PENSION_FUND") && ec.startValue > 0 && ec.yearly.length < t) { + if (ec.category === "PENSION_FUND" && ec.startValue > 0 && ec.yearly.length < t) { ec.yearly.push({ year: yr, age, value: ec.startValue }); } } @@ -932,6 +1013,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1) * flowDeflatorEnd); exp.ec.summary = fmt(exp.ec.startValue); } + // Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben). Erst + // hier, weil `basis` in der Jahresschleife durch effektive Werte neu gesetzt worden sein kann. + for (const f of [...incomes, ...expenses]) { + f.carry.flowBasis = f.basis * Math.pow(1 + f.idx / 100, duration); + } for (const a of assets) { a.ec.endValue = Math.round(a.value); a.ec.summary = fmt(a.ec.endValue); @@ -1571,6 +1657,51 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp carry.hasCarry = true; } + // --- Punkt C (Roadmap Nr. 44): Verwendung des Kapitalzuflusses ----------------------- + // + // Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, 3a, + // Immobilienverkauf). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. + // Die Verwendung wird deshalb als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert + // sich der Betrag -- die Aufteilung skaliert mit, statt still falsch zu werden. + // + // Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die + // Anlage-Quote wie eine Zusatzinvestition. Beide sind schon heute Cash-Abflüsse an der + // Grenze und laufen damit korrekt durch beide Brücken. + if (txInflow > 0) { + const ct = phase.cashTransition ?? {}; + const amortPct = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct))); + const investPct = Math.max(0, Math.min(100 - amortPct, num(ct.capitalUseInvestPct))); + if (amortPct > 0 || investPct > 0) { + let amortBudget = Math.round((txInflow * amortPct) / 100); + for (const e of orderedElements) { + if (amortBudget <= 0) break; + if (e.category !== "REAL_ESTATE") continue; + const c = carries.get(e.id)!; + if (c.status !== "ACTIVE" || c.mortgage <= 0) continue; + const pay = Math.min(amortBudget, c.mortgage); + c.mortgage -= pay; + amortBudget -= pay; + txImmediateRepay += pay; + } + + const investBudget = Math.round((txInflow * investPct) / 100); + if (investBudget > 0) { + const target = + orderedElements.find( + (e) => + e.id === ct.capitalUseTargetElementId && + e.category === "OTHER_ASSET" && + carries.get(e.id)!.status === "ACTIVE" + ) ?? + orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE"); + if (target) { + carries.get(target.id)!.value += investBudget; + txImmediateRepay += investBudget; + } + } + } + } + cashCarryIn = cashEnd + txInflow + txOneOffInflow - txImmediateRepay - txOneOffOutflow; incomingInflow = txInflow; incomingImmediateRepay = txImmediateRepay; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4013a7d..aa97a86 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -14,6 +14,11 @@ export const AHV_PENSION_MONTHS = 13; // Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag -- faktisch ab Alter 21. export const AHV_CONTRIBUTION_START_AGE = 21; +// Referenzalter: Ab hier fliesst die AHV-Rente -- UNABHAENGIG vom gewaehlten Pensionsalter. +// Wer frueher aufhoert, bleibt bis dahin beitragspflichtig (als Nichterwerbstaetige(r)); wer +// laenger arbeitet, erhaelt die Rente trotzdem ab diesem Alter (siehe SPEZIFIKATION 4.4.6). +export const AHV_REFERENCE_AGE = 65; + // Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente: 2 x R0 x 13 = 32'760. // Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss. export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS; @@ -132,6 +137,17 @@ export const SYSTEM_PARAMETERS: SystemParameter[] = [ validFrom: "1.1.2026", group: "AHV", }, + { + key: "AHV_REFERENCE_AGE", + label: "Referenzalter (Rentenbeginn)", + value: AHV_REFERENCE_AGE, + unit: "Jahre", + meaning: + "Ab diesem Alter fliesst die AHV-Rente – unabhängig vom gewählten Pensionsalter. Wer früher aufhört, bleibt bis dahin beitragspflichtig; wer länger arbeitet, erhält die Rente trotzdem ab hier. Ein Aufschub der Rente ist nicht modelliert.", + source: "AHVG (Referenzalter 65 nach AHV 21)", + validFrom: "1.1.2026", + group: "AHV", + }, { key: "AHV_COUPLE_CAP_FACTOR", label: "Ehepaar-Plafonierung", diff --git a/src/lib/elements.ts b/src/lib/elements.ts index 9402d1d..74685d9 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -58,6 +58,9 @@ export interface PhaseData { teuerungsausgleich?: number; // AHV gapYears?: number; + // Nur AHV, nur in Phasen NACH einer Fruehpensionierung: jaehrlicher Beitrag als + // Nichterwerbstaetige(r) bis zum Referenzalter. Faellt als Kosten an (Kap. 4.4.6). + ahvContribution?: number; // AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen // Pensions-Übergang, an dem die Karriere geprüft werden könnte): Beitragskarriere. // avgIncomeBefore ist REAL (heutige Kaufkraft). @@ -135,6 +138,14 @@ export interface CashTransitionData { // Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft). outflowLabel?: string; outflowAmount?: number; + // Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C). + // Bewusst in PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich + // das bezogene Kapital -- eine Franken-Angabe müsste man dann von Hand nachziehen, eine + // Quote skaliert mit. Der nicht zugeteilte Rest bleibt Cash. + capitalUseAmortizationPct?: number; + capitalUseInvestPct?: number; + // Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen». + capitalUseTargetElementId?: string; } // --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) --- @@ -149,6 +160,9 @@ export const cashTransitionSchema = z inflowTaxRate: z.number().min(0).max(100).optional(), outflowLabel: z.string().max(120).optional(), outflowAmount: nonNeg.optional(), + capitalUseAmortizationPct: z.number().min(0).max(100).optional(), + capitalUseInvestPct: z.number().min(0).max(100).optional(), + capitalUseTargetElementId: z.string().max(60).optional(), }) .strip(); @@ -157,6 +171,7 @@ export const phaseDataSchema = z amount: nonNeg.optional(), teuerungsausgleich: z.number().min(-20).max(50).optional(), gapYears: z.number().int().min(0).optional(), + ahvContribution: nonNeg.optional(), avgIncomeBefore: nonNeg.optional(), gapYearsBefore: z.number().int().min(0).max(50).optional(), currentValue: nonNeg.optional(), @@ -198,3 +213,41 @@ export const transitionDataSchema = z export function num(value: number | undefined | null, fallback = 0): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } + +// --- Vererbung der Wiederkehr-Parameter (Roadmap Nr. 44, Punkt A) ----------------------- +// +// Raten, Beitraege, Amortisation, Wertsteigerung und Zinssatz gelten weiter, bis man sie +// bewusst aendert: Fehlt der Wert in einer Phase, gilt der aus der Vorphase. Genau diese +// Regel wendet auch `computePlan` an -- hier steht sie fuer das UI, damit die Anzeige +// "Aus Vorphase uebernehmen: " nicht raten muss. +export const INHERITABLE_KEYS = [ + "teuerungsausgleich", + "expectedReturn", + "annualContribution", + "annualWithdrawal", + "amortization", + "valueGrowth", + "interestRate", + "annualRepayment", +] as const; + +export type InheritableKey = (typeof INHERITABLE_KEYS)[number]; + +// Wert, der in `phaseId` gelten wuerde, wenn das Feld dort leer bleibt. `orderedPhaseIds` +// muss in Phasenreihenfolge vorliegen. +export function inheritedPhaseValues( + phaseValues: Record, + orderedPhaseIds: string[], + phaseId: string +): Record { + const out: Record = {}; + for (const id of orderedPhaseIds) { + if (id === phaseId) break; + const pd = phaseValues[id] ?? {}; + for (const key of INHERITABLE_KEYS) { + const v = pd[key]; + if (typeof v === "number") out[key] = v; + } + } + return out; +} diff --git a/src/lib/livesim.ts b/src/lib/livesim.ts index 15750dd..18d0e2e 100644 --- a/src/lib/livesim.ts +++ b/src/lib/livesim.ts @@ -13,7 +13,14 @@ // keine Ladeanzeige. Der Engpass ist das Neuzeichnen der Grafik, nicht die Mathematik. import { computePlan } from "@/lib/calculations"; -import { applyDriver, applyElementDriver, driverById, DRIVERS, tunableElements } from "@/lib/sensitivity"; +import { + applyDriver, + applyElementDriver, + driverById, + DRIVERS, + retirementDriverRange, + tunableElements, +} from "@/lib/sensitivity"; import type { DriverId, DriverUnit } from "@/lib/sensitivity"; import type { PlanComputed } from "@/lib/calculations"; import type { PlanInput } from "@/lib/types"; @@ -51,6 +58,9 @@ const RANGES: Record = {}; - for (const f of fields) patch[f] = draft[f] ?? 0; + // Bewusst OHNE `?? 0`: Ein leeres Feld bedeutet seit Roadmap Nr. 44 «aus der Vorphase + // übernehmen». Ein 0 an dieser Stelle würde daraus eine harte Null machen. + for (const f of fields) patch[f] = draft[f]; return targetPhases(phases, currentPhaseId, scope) .filter((p) => p.id !== currentPhaseId) diff --git a/src/lib/retirement.test.ts b/src/lib/retirement.test.ts new file mode 100644 index 0000000..449b242 --- /dev/null +++ b/src/lib/retirement.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import { retirementBoundaries, shiftRetirement, sortedPhases } from "@/lib/retirement"; +import type { PlanInput } from "@/lib/types"; + +// Genau das Beispiel aus der Anforderung: +// Person A: Start 20, Pension 65 -> Planjahr 45 +// Person B: Start 30, Pension 65 -> Planjahr 35 +// Phase 1 Erwerb 20 J. (Planjahr 0-20) +// Phase 2 Erwerb 15 J. (Planjahr 20-35) <- B wird hier pensioniert (Grenze 35) +// Phase 3 Misch 10 J. (Planjahr 35-45) <- A wird hier pensioniert (Grenze 45) +// Phase 4 Pension 15 J. (Planjahr 45-60) +function plan(): PlanInput { + return { + id: "s", name: "T", householdType: "COUPLE", inflationRateDefault: 1.5, initialCash: 0, startYear: 2026, + persons: [ + { id: "A", role: "PERSON_A", name: "Anna", age: 20, retirementAge: 65 }, + { id: "B", role: "PERSON_B", name: "Beat", age: 30, retirementAge: 65 }, + ], + phases: [ + { id: "p1", sequenceNumber: 1, name: "Erwerb 1", durationYears: 20, cashTransition: {} }, + { id: "p2", sequenceNumber: 2, name: "Erwerb 2", durationYears: 15, cashTransition: {} }, + { id: "p3", sequenceNumber: 3, name: "Misch", durationYears: 10, cashTransition: {} }, + { id: "p4", sequenceNumber: 4, name: "Pension", durationYears: 15, cashTransition: {} }, + ], + elements: [], + } as unknown as PlanInput; +} + +const durations = (p: PlanInput) => sortedPhases(p).map((x) => x.durationYears); +const boundaryOf = (p: PlanInput, role: "PERSON_A" | "PERSON_B") => + retirementBoundaries(p).find((b) => b.role === role)!; + +describe("retirementBoundaries", () => { + it("findet die Grenze jeder Person", () => { + const a = boundaryOf(plan(), "PERSON_A"); + const b = boundaryOf(plan(), "PERSON_B"); + // A wird am Ende von Phase 3 (Index 2) pensioniert, B am Ende von Phase 2 (Index 1). + expect(a.planYear).toBe(45); + expect(a.phaseIndex).toBe(2); + expect(b.planYear).toBe(35); + expect(b.phaseIndex).toBe(1); + expect(a.blocked).toBeNull(); + }); + + it("nennt die Spielräume aus den angrenzenden Phasen", () => { + const a = boundaryOf(plan(), "PERSON_A"); + // Phase 3 (10 J.) darf auf 1 schrumpfen -> 9 Jahre früher; + // Phase 4 (15 J.) darf auf 1 schrumpfen -> 14 Jahre später. + expect(a.minDelta).toBe(-9); + expect(a.maxDelta).toBe(14); + // Ein Jahr weiter faellt die jeweilige Phase weg. + expect(a.mergeDeltaDown).toBe(-10); + expect(a.mergeDeltaUp).toBe(15); + }); + + it("sperrt, wenn die Person bei Planbeginn schon pensioniert ist", () => { + const p = plan(); + p.persons[0].retirementAge = 20; // = Startalter + expect(boundaryOf(p, "PERSON_A").blocked).toContain("bereits pensioniert"); + }); + + it("sperrt, wenn beide Personen dieselbe Grenze teilen", () => { + // Nach einer Zusammenlegung ist genau das der Fall -- eine Trennung bräuchte eine neue Phase. + const p = plan(); + p.persons[0].retirementAge = 55; // A -> Planjahr 35, wie B + expect(boundaryOf(p, "PERSON_A").blocked).toContain("selben Zeitpunkt"); + }); + + it("sperrt, wenn die Pensionierung nicht auf einer Grenze liegt", () => { + const p = plan(); + p.persons[0].retirementAge = 62; // Planjahr 42 -- mitten in Phase 3 + expect(boundaryOf(p, "PERSON_A").blocked).toContain("nicht auf einer Phasengrenze"); + }); +}); + +describe("shiftRetirement", () => { + it("senkt das Pensionsalter von A um 4 Jahre (Beispiel aus der Anforderung)", () => { + const r = shiftRetirement(plan(), "PERSON_A", -4)!; + // Phase 3 wird von 10 auf 6 gekürzt, Phase 4 wächst von 15 auf 19. + expect(durations(r.plan)).toEqual([20, 15, 6, 19]); + expect(r.plan.persons.find((p) => p.role === "PERSON_A")!.retirementAge).toBe(61); + // Die Gesamtdauer des Plans bleibt gleich. + expect(durations(r.plan).reduce((a, b) => a + b, 0)).toBe(60); + expect(r.removedPhaseId).toBeNull(); + }); + + it("erhöht das Pensionsalter von A um 4 Jahre", () => { + const r = shiftRetirement(plan(), "PERSON_A", 4)!; + expect(durations(r.plan)).toEqual([20, 15, 14, 11]); + expect(r.plan.persons.find((p) => p.role === "PERSON_A")!.retirementAge).toBe(69); + }); + + it("verschiebt bei Person B die Grenze zwischen Phase 2 und 3", () => { + const r = shiftRetirement(plan(), "PERSON_B", -3)!; + expect(durations(r.plan)).toEqual([20, 12, 13, 15]); + expect(r.plan.persons.find((p) => p.role === "PERSON_B")!.retirementAge).toBe(62); + // Das Pensionsalter von A bleibt unberührt. + expect(r.plan.persons.find((p) => p.role === "PERSON_A")!.retirementAge).toBe(65); + }); + + it("lässt die Mischphase verschwinden, wenn beide gleichzeitig pensioniert werden", () => { + // Spezialfall 2: A genau 10 Jahre früher -> A und B werden zeitgleich pensioniert. + const r = shiftRetirement(plan(), "PERSON_A", -10)!; + expect(r.removedPhaseId).toBe("p3"); + expect(durations(r.plan)).toEqual([20, 15, 25]); + // Sequenznummern bleiben lückenlos. + expect(sortedPhases(r.plan).map((p) => p.sequenceNumber)).toEqual([1, 2, 3]); + expect(durations(r.plan).reduce((a, b) => a + b, 0)).toBe(60); + }); + + it("lässt auch die FOLGEphase verschwinden, wenn die Grenze ans Planende rückt", () => { + const r = shiftRetirement(plan(), "PERSON_A", 15)!; + expect(r.removedPhaseId).toBe("p4"); + expect(durations(r.plan)).toEqual([20, 15, 25]); + }); + + it("verweigert alles jenseits der Grenzen", () => { + // Eine Phase darf nicht negativ werden. + expect(shiftRetirement(plan(), "PERSON_A", -11)).toBeNull(); + expect(shiftRetirement(plan(), "PERSON_A", 16)).toBeNull(); + }); + + it("verweigert die Verschiebung bei geteilter Grenze", () => { + const p = plan(); + p.persons[0].retirementAge = 55; + expect(shiftRetirement(p, "PERSON_A", -1)).toBeNull(); + }); + + it("lässt den Plan bei delta 0 unverändert", () => { + const p = plan(); + expect(shiftRetirement(p, "PERSON_A", 0)!.plan).toBe(p); + }); +}); + +describe("Zusammenlegung der Uebergaenge", () => { + // Plan wie oben, aber mit Entscheiden an beiden betroffenen Grenzen. + function planMitEntscheiden(): PlanInput { + const p = plan(); + p.phases[1].cashTransition = { mode: "INFLOW", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10 }; + p.phases[2].cashTransition = { mode: "BOTH", inflowLabel: "Bonus", inflowAmount: 100000, inflowTaxRate: 30, outflowLabel: "Umbau", outflowAmount: 50000 }; + p.elements = [ + { + id: "pk", category: "PENSION_FUND", name: "PK", ownerRole: "PERSON_A", orderIndex: 1, + phaseValues: {}, + transitionValues: { + p2: { withdrawalMode: "NONE" }, + p3: { payoutMode: "CAPITAL", capitalTaxRate: 5, conversionRate: 6 }, + }, + }, + ] as unknown as PlanInput["elements"]; + return p; + } + + it("zieht Element-Entscheide in den ueberlebenden Uebergang", () => { + const r = shiftRetirement(planMitEntscheiden(), "PERSON_A", -10)!; + expect(r.removedPhaseId).toBe("p3"); + expect(r.mergedIntoPhaseId).toBe("p2"); + const tv = r.plan.elements[0].transitionValues; + expect(tv.p3).toBeUndefined(); + // Der bestehende Entscheid bleibt, die leeren Felder werden aufgefuellt. + expect(tv.p2).toEqual({ withdrawalMode: "NONE", payoutMode: "CAPITAL", capitalTaxRate: 5, conversionRate: 6 }); + }); + + it("addiert die einmaligen Cash-Betraege", () => { + const r = shiftRetirement(planMitEntscheiden(), "PERSON_A", -10)!; + const ct = sortedPhases(r.plan).find((x) => x.id === "p2")!.cashTransition; + expect(ct.mode).toBe("BOTH"); + expect(ct.inflowAmount).toBe(200000); + expect(ct.inflowLabel).toBe("Erbschaft + Bonus"); + // Betragsgewichteter Mischsatz: (10 % x 100'000 + 30 % x 100'000) / 200'000 = 20 %. + expect(ct.inflowTaxRate).toBe(20); + expect(ct.outflowAmount).toBe(50000); + expect(ct.outflowLabel).toBe("Umbau"); + }); + + it("laesst die Uebergaenge unangetastet, wenn keine Phase entfaellt", () => { + const r = shiftRetirement(planMitEntscheiden(), "PERSON_A", -4)!; + expect(r.mergedIntoPhaseId).toBeNull(); + expect(r.plan.elements[0].transitionValues.p3).toBeDefined(); + }); +}); diff --git a/src/lib/retirement.ts b/src/lib/retirement.ts new file mode 100644 index 0000000..e4dfd26 --- /dev/null +++ b/src/lib/retirement.ts @@ -0,0 +1,274 @@ +// Pensionsalter anpassen (Roadmap Nr. 44). +// +// Bis hierher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Phase endet +// (`maxPhaseDuration` kappt jede Phasendauer beim nächsten Pensionierungsereignis), also +// liegt JEDE Pensionierung zwangsläufig auf einer Phasengrenze. Genau das macht die +// Anpassung überhaupt erst möglich: «Pensionsalter ändern» heisst «diese eine Grenze +// verschieben» -- die Phase davor wird länger, die danach kürzer, die Gesamtdauer des Plans +// bleibt gleich. +// +// Reines Modul ohne I/O: Es entscheidet nur, WAS geschehen soll. Das Schreiben übernimmt der +// Aufrufer (Panel: API-Aufrufe; Tornado/Live-Simulation: reine Plan-Kopie). + +import type { CashTransitionData, TransitionData } from "@/lib/elements"; +import type { PersonRole, PlanInput } from "@/lib/types"; + +export interface RetirementBoundary { + role: PersonRole; + personId: string; + retirementAge: number; + // Planjahr, in dem die Pensionierung liegt (1-basiert: Ende von Phase `phaseIndex`). + planYear: number; + phaseIndex: number; // Phase VOR der Grenze + // Um so viele Jahre lässt sich das Pensionsalter senken bzw. erhöhen, ohne dass eine + // angrenzende Phase unter 1 Jahr fällt. + minDelta: number; + maxDelta: number; + // Bei genau diesen Werten verschwindet eine Phase (Zusammenlegung, siehe unten). + mergeDeltaDown: number | null; + mergeDeltaUp: number | null; + // Warum ist keine Anpassung möglich? + blocked: string | null; +} + +// Kumulierte Jahresgrenzen: Index i = Planjahr, an dem Phase i endet. +function boundaries(plan: PlanInput): number[] { + const sorted = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); + const out: number[] = []; + let acc = 0; + for (const p of sorted) { + acc += p.durationYears; + out.push(acc); + } + return out; +} + +export function sortedPhases(plan: PlanInput) { + return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); +} + +// 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); + const bounds = boundaries(plan); + const total = bounds[bounds.length - 1] ?? 0; + + return plan.persons.map((p) => { + const planYear = p.retirementAge - p.age; + const base: RetirementBoundary = { + role: p.role, + personId: p.id, + retirementAge: p.retirementAge, + planYear, + phaseIndex: -1, + minDelta: 0, + maxDelta: 0, + mergeDeltaDown: null, + mergeDeltaUp: null, + blocked: null, + }; + + if (planYear <= 0) { + return { ...base, blocked: "Diese Person ist bei Planbeginn bereits pensioniert – es gibt keine Grenze zu verschieben." }; + } + if (planYear >= total) { + return { ...base, blocked: "Die Pensionierung liegt am oder nach dem Planende – dafür müsstest du zuerst eine Lebensphase anhängen." }; + } + + const idx = bounds.indexOf(planYear); + if (idx < 0 || idx >= phases.length - 1) { + return { + ...base, + blocked: "Die Pensionierung liegt nicht auf einer Phasengrenze. Passe zuerst die Lebensphasen an.", + }; + } + + // Teilen sich zwei Personen dieselbe Grenze, liesse sich eine davon nicht verschieben, + // ohne die andere mitzunehmen -- dafür müsste eine Phase eingefügt werden. + const shared = plan.persons.some((q) => q.id !== p.id && q.retirementAge - q.age === planYear); + if (shared) { + return { + ...base, + phaseIndex: idx, + blocked: + "Beide Personen werden zum selben Zeitpunkt pensioniert. Um sie zu trennen, füge zuerst eine Lebensphase ein.", + }; + } + + const before = phases[idx].durationYears; + const after = phases[idx + 1].durationYears; + + return { + ...base, + phaseIndex: idx, + // Die Phase davor darf auf 1 schrumpfen, die danach ebenso. + minDelta: -(before - 1), + maxDelta: after - 1, + // Ein Schritt weiter lässt die jeweilige Phase ganz verschwinden. + mergeDeltaDown: -before, + mergeDeltaUp: after, + }; + }); +} + +export interface ShiftResult { + plan: PlanInput; + // Index der Phase, die dabei entfällt (Zusammenlegung) -- sonst null. + removedPhaseIndex: number | null; + removedPhaseId: string | null; + // Phase, in deren Übergang die Entscheide der entfallenden Phase gezogen wurden. + mergedIntoPhaseId: string | null; +} + +// Verschiebt die Pensionierungs-Grenze einer Person um `delta` Jahre. +// +// `delta > 0` = später in Pension: die Phase davor wird länger, die danach kürzer. +// Fällt eine der beiden auf 0, verschwindet sie (Zusammenlegung) -- das muss der Aufrufer +// vorher bestätigen lassen, weil dabei Übergangs-Entscheide zusammengeführt werden. +export function shiftRetirement(plan: PlanInput, role: PersonRole, delta: number): ShiftResult | null { + const info = retirementBoundaries(plan).find((b) => b.role === role); + if (!info || info.blocked || info.phaseIndex < 0) return null; + if (delta === 0) return { plan, removedPhaseIndex: null, removedPhaseId: null, mergedIntoPhaseId: null }; + + const allowedDown = info.mergeDeltaDown ?? info.minDelta; + const allowedUp = info.mergeDeltaUp ?? info.maxDelta; + if (delta < allowedDown || delta > allowedUp) return null; + + const phases = sortedPhases(plan); + const i = info.phaseIndex; + const beforeNew = phases[i].durationYears + delta; + const afterNew = phases[i + 1].durationYears - delta; + if (beforeNew < 0 || afterNew < 0) return null; + + const nextPhases = phases.map((p, k) => + k === i ? { ...p, durationYears: beforeNew } : k === i + 1 ? { ...p, durationYears: afterNew } : { ...p } + ); + + // Genau eine der beiden kann 0 werden -- diese Phase entfällt. + let removedIndex: number | null = null; + if (beforeNew === 0) removedIndex = i; + else if (afterNew === 0) removedIndex = i + 1; + + const removedPhaseId = removedIndex === null ? null : nextPhases[removedIndex].id; + // Die überlebende Grenze ist die des Vorgängers -- dorthin wandern die Entscheide. + const mergedIntoPhaseId = + removedIndex === null || removedIndex === 0 ? null : nextPhases[removedIndex - 1].id; + + let kept = removedIndex === null ? nextPhases : nextPhases.filter((_, k) => k !== removedIndex); + let elements = plan.elements; + + if (removedPhaseId && mergedIntoPhaseId) { + kept = kept.map((p) => + p.id === mergedIntoPhaseId + ? { + ...p, + cashTransition: mergeCashTransition( + p.cashTransition ?? {}, + nextPhases[removedIndex!].cashTransition ?? {} + ), + } + : p + ); + elements = plan.elements.map((e) => { + const removedTd = e.transitionValues?.[removedPhaseId]; + if (!removedTd) return e; + const rest = { ...e.transitionValues }; + delete rest[removedPhaseId]; + return { + ...e, + transitionValues: { + ...rest, + [mergedIntoPhaseId]: mergeTransition(e.transitionValues[mergedIntoPhaseId] ?? {}, removedTd), + }, + }; + }); + } + + return { + plan: { + ...plan, + persons: plan.persons.map((p) => (p.role === role ? { ...p, retirementAge: p.retirementAge + delta } : p)), + // Sequenznummern lückenlos neu vergeben, damit die Kette intakt bleibt. + phases: kept.map((p, k) => ({ ...p, sequenceNumber: k + 1 })), + elements, + }, + removedPhaseIndex: removedIndex, + removedPhaseId, + mergedIntoPhaseId, + }; +} + +// --- Zusammenlegung zweier Übergänge ---------------------------------------------------- +// +// Fällt eine Phase weg, verschwindet auch einer der beiden Übergänge -- die dort getroffenen +// Entscheide dürfen aber nicht stillschweigend untergehen. Die überlebende Grenze ist immer +// die des VORGÄNGERS der entfallenden Phase; deren Daten werden in ihn hineingezogen. +// +// Regel (mit dem Nutzer abgestimmt): +// * Element-Entscheide: Was am überlebenden Übergang schon entschieden ist, bleibt. Nur +// leere Felder werden aus dem entfallenden Übergang aufgefüllt -- ein bewusster +// Entscheid soll nie von einem anderen überschrieben werden. +// * Einmalige Cash-Beträge: werden ADDIERT. Beide Ereignisse finden ja weiterhin statt, +// nur zum selben Zeitpunkt. + +// Feldweises Auffüllen: `keep` gewinnt, `removed` füllt die Lücken. +export function mergeTransition(keep: TransitionData, removed: TransitionData): TransitionData { + const out: TransitionData = { ...keep }; + for (const [k, v] of Object.entries(removed) as [keyof TransitionData, unknown][]) { + if (v === undefined || v === null) continue; + if (out[k] === undefined || out[k] === null) (out as Record)[k] = v; + } + return out; +} + +export function mergeCashTransition(keep: CashTransitionData, removed: CashTransitionData): CashTransitionData { + // "NONE" bedeutet: bewusst nichts. Beträge daraus zählen nicht mit. + const active = (c: CashTransitionData) => c.mode && c.mode !== "NONE"; + const inflow = (c: CashTransitionData) => + active(c) && (c.mode === "INFLOW" || c.mode === "BOTH") ? c.inflowAmount ?? 0 : 0; + const outflow = (c: CashTransitionData) => + active(c) && (c.mode === "OUTFLOW" || c.mode === "BOTH") ? c.outflowAmount ?? 0 : 0; + + const inA = inflow(keep); + const inB = inflow(removed); + const outA = outflow(keep); + const outB = outflow(removed); + const inSum = inA + inB; + const outSum = outA + outB; + + const join = (a: string | undefined, b: string | undefined) => + [a, b].filter((x) => x && x.trim()).join(" + ") || undefined; + + return { + mode: inSum > 0 && outSum > 0 ? "BOTH" : inSum > 0 ? "INFLOW" : outSum > 0 ? "OUTFLOW" : "NONE", + inflowLabel: inSum > 0 ? join(inA > 0 ? keep.inflowLabel : undefined, inB > 0 ? removed.inflowLabel : undefined) : undefined, + inflowAmount: inSum > 0 ? inSum : undefined, + // Zwei Zuflüsse mit verschiedenen Steuersätzen ergeben zusammen einen betragsgewichteten + // Mischsatz -- nur so bleibt der Netto-Zufluss derselbe wie vor der Zusammenlegung. + inflowTaxRate: + inSum > 0 + ? Math.round((((keep.inflowTaxRate ?? 0) * inA + (removed.inflowTaxRate ?? 0) * inB) / inSum) * 100) / 100 + : undefined, + outflowLabel: outSum > 0 ? join(outA > 0 ? keep.outflowLabel : undefined, outB > 0 ? removed.outflowLabel : undefined) : undefined, + outflowAmount: outSum > 0 ? outSum : undefined, + }; +} + +// Erklärtext für die Hilfebox: was ist möglich, und warum nicht mehr. +export function limitsText(b: RetirementBoundary, personLabel: string): string { + if (b.blocked) return b.blocked; + const down = Math.abs(b.minDelta); + const up = b.maxDelta; + const parts = [ + `Das Pensionsalter von ${personLabel} lässt sich um maximal ${down} Jahr${down === 1 ? "" : "e"} senken und ` + + `${up} Jahr${up === 1 ? "" : "e"} erhöhen, weil die angrenzenden Lebensphasen mindestens 1 Jahr dauern müssen.`, + ]; + if (b.mergeDeltaDown !== null || b.mergeDeltaUp !== null) { + parts.push( + "Ein Jahr darüber hinaus fällt die angrenzende Lebensphase ganz weg – das ist möglich, wird aber vorher " + + "bestätigt, weil dabei zwei Übergänge zusammengelegt werden." + ); + } + parts.push("Brauchst du mehr Spielraum, passe zuerst die Dauer der Lebensphasen an."); + return parts.join(" "); +} diff --git a/src/lib/sensitivity.test.ts b/src/lib/sensitivity.test.ts index 82cae2f..745b2fd 100644 --- a/src/lib/sensitivity.test.ts +++ b/src/lib/sensitivity.test.ts @@ -210,3 +210,41 @@ describe("Sensitivität: Tornado", () => { expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd)); }); }); + +describe("Treiber Pensionsalter (Roadmap Nr. 44)", () => { + it("erscheint nur fuer vorhandene Personen mit Spielraum", () => { + const p = basePlan(); + expect(driverById("retirementA").applies(p)).toBe(true); + expect(driverById("retirementB").applies(p)).toBe(false); // Einzelhaushalt + }); + + it("verschiebt die Phasengrenze, ohne die Gesamtdauer zu aendern", () => { + const shifted = applyDriver(basePlan(), "retirementA", 3); + expect(shifted.phases.map((x) => x.durationYears)).toEqual([23, 17]); + expect(shifted.persons[0].retirementAge).toBe(68); + }); + + it("kuerzt auf den moeglichen Spielraum statt gar nichts zu tun", () => { + // Phase 2 hat 20 Jahre -> maximal +19; eine Eingabe von +50 landet dort. + const shifted = applyDriver(basePlan(), "retirementA", 50); + expect(shifted.phases.map((x) => x.durationYears)).toEqual([39, 1]); + }); + + it("laenger arbeiten bringt mehr Endvermoegen", () => { + // Der Lohn muss der PERSON gehoeren -- nur dann faellt er bei der Pensionierung weg + // (Roadmap Nr. 44, Punkt B) und die Verschiebung der Grenze wirkt ueberhaupt. + const p = basePlan(); + p.elements[0].ownerRole = "PERSON_A"; + const base = planMetric(p, "real"); + const spaeter = planMetric(applyDriver(p, "retirementA", 3), "real"); + const frueher = planMetric(applyDriver(p, "retirementA", -3), "real"); + expect(spaeter).toBeGreaterThan(base); + expect(frueher).toBeLessThan(base); + }); + + it("liefert im Tornado einen Balken mit Spannweite", () => { + const t = computeTornado(basePlan(), "real", [{ id: "retirementA", low: -3, high: 3 }]); + expect(t.bars[0].swing).toBeGreaterThan(0); + expect(t.bars[0].note).toBeUndefined(); + }); +}); diff --git a/src/lib/sensitivity.ts b/src/lib/sensitivity.ts index 04f2e82..655db2d 100644 --- a/src/lib/sensitivity.ts +++ b/src/lib/sensitivity.ts @@ -17,8 +17,9 @@ import { computePlan } from "@/lib/calculations"; import { num } from "@/lib/elements"; import type { ElementCategory, PhaseData } from "@/lib/elements"; -import type { ElementInput, PlanInput } from "@/lib/types"; +import type { ElementInput, PersonRole, PlanInput } from "@/lib/types"; import type { ResolvedActuals } from "@/lib/actuals"; +import { retirementBoundaries, shiftRetirement } from "@/lib/retirement"; export type DriverId = | "inflation" @@ -27,7 +28,9 @@ export type DriverId = | "income" | "salaryGrowth" | "lifespan" - | "propertyGrowth"; + | "propertyGrowth" + | "retirementA" + | "retirementB"; // Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden // und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden: @@ -53,6 +56,24 @@ function hasCategory(plan: PlanInput, categories: ElementCategory[]): boolean { return plan.elements.some((e) => categories.includes(e.category)); } +// Der Pensionsalter-Regler erscheint nur, wenn sich die Grenze überhaupt verschieben lässt -- +// ein Regler ohne Spielraum wäre schlimmer als keiner (siehe `retirementBoundaries`). +function retirementDriverApplies(plan: PlanInput, role: PersonRole): boolean { + const b = retirementBoundaries(plan).find((x) => x.role === role); + return !!b && !b.blocked && (b.minDelta !== 0 || b.maxDelta !== 0); +} + +// Erlaubter Bereich des Reglers: bis zur Zusammenlegung, aber nicht darüber hinaus. Der +// Tornado/die Live-Simulation legen bewusst NICHT zusammen -- das würde Übergangs-Entscheide +// zusammenführen, also den Plan inhaltlich verändern, und dafür ist eine Was-wäre-wenn- +// Betrachtung der falsche Ort. Deshalb endet der Spielraum bei minDelta/maxDelta. +export function retirementDriverRange(plan: PlanInput, id: "retirementA" | "retirementB"): { min: number; max: number } { + const role: PersonRole = id === "retirementA" ? "PERSON_A" : "PERSON_B"; + const b = retirementBoundaries(plan).find((x) => x.role === role); + if (!b || b.blocked) return { min: 0, max: 0 }; + return { min: b.minDelta, max: b.maxDelta }; +} + export const DRIVERS: DriverDef[] = [ { id: "expenses", @@ -108,6 +129,27 @@ export const DRIVERS: DriverDef[] = [ "Verschiebung der jährlichen nominalen Lohnerhöhung in Prozentpunkten. Sinnvolle Bandbreite: −1 bis +1 Prozentpunkt. Wirkt nur über die verbleibenden Erwerbsjahre und ist deshalb meist ein schwacher Hebel.", applies: (p) => hasCategory(p, ["INCOME"]), }, + // Pensionsalter (Roadmap Nr. 44). Verschiebt die Phasengrenze, an der die Person in + // Pension geht -- die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt. + // Der Hebel ist doppelt (länger Einkommen UND kürzer Verzehr) und deshalb meist gross. + { + id: "retirementA", + label: "Pensionsalter Person A", + shortLabel: "Pensionsalter A", + unit: "delta_years", + help: + "Verschiebt das Pensionsalter der Person A. 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_A"), + }, + { + id: "retirementB", + label: "Pensionsalter Person B", + shortLabel: "Pensionsalter B", + unit: "delta_years", + help: + "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"), + }, { id: "propertyGrowth", label: "Wertsteigerung der Immobilie", @@ -191,6 +233,18 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI teuerungsausgleich: num(pd.teuerungsausgleich, 0) + value, })); + case "retirementA": + case "retirementB": { + // Auf den möglichen Spielraum kürzen statt den Plan unverändert zu lassen: Eine + // eingegebene Bandbreite von ±5 Jahren soll auch dann etwas zeigen, wenn nur ±2 + // möglich sind. Wie weit tatsächlich verschoben wurde, steht in der Ergebnistabelle. + const range = retirementDriverRange(plan, id); + const delta = Math.max(range.min, Math.min(range.max, Math.round(value))); + if (delta === 0) return plan; + const role: PersonRole = id === "retirementA" ? "PERSON_A" : "PERSON_B"; + return shiftRetirement(plan, role, delta)?.plan ?? plan; + } + case "lifespan": { // Verlängert/verkürzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das lässt // sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18). @@ -289,6 +343,17 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string { return "Wirkungslos, weil die Immobilie vor Planende verkauft wird: Der Verkaufserlös ergibt sich aus dem erfassten Verkaufspreis, nicht aus dem modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim Verkauf verworfen."; } } + if (id === "retirementA" || id === "retirementB") { + const range = retirementDriverRange(plan, id); + if (range.min === 0 && range.max === 0) { + const role: PersonRole = id === "retirementA" ? "PERSON_A" : "PERSON_B"; + const b = retirementBoundaries(plan).find((x) => x.role === role); + return ( + b?.blocked ?? + "Die angrenzenden Lebensphasen dauern bereits nur ein Jahr – es bleibt kein Spielraum. Passe zuerst die Phasendauern an." + ); + } + } return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht."; }