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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
+316
-34
@@ -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/<id>/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/<id>/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/<id>/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/<scenarioId>/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/<scenarioId>`
|
||||
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
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
// 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<CashTransitionData>) => 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 && <CapitalUseFields ct={ct} setC={setC} inflow={capitalInflow} targets={investTargets} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<CashTransitionData>) => 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 (
|
||||
<>
|
||||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
<strong className="text-fg">Verwendung des Kapitalzuflusses</strong> (PK-Kapital, Säule 3a,
|
||||
Verkaufserlös). Bei diesem Übergang fliessen{" "}
|
||||
<strong className="text-fg">{formatChf(inflow)}</strong> 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.
|
||||
</div>
|
||||
<NumberField
|
||||
label="… in die Hypothek (Amortisation, %)"
|
||||
help="Reduziert die Resthypothek der Immobilie einmalig. Am Restsaldo gekappt – ist die Hypothek kleiner, bleibt der Rest Cash."
|
||||
step={5}
|
||||
min={0}
|
||||
max={100}
|
||||
value={amort}
|
||||
onChange={(v) => setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="… in die Anlage (%)"
|
||||
help="Fliesst als Zusatzinvestition in das gewählte Vermögens-Element und wächst dort mit dessen Rendite weiter."
|
||||
step={5}
|
||||
min={0}
|
||||
max={100 - amort}
|
||||
value={invest}
|
||||
onChange={(v) => setC({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
||||
/>
|
||||
{invest > 0 && targets.length > 0 && (
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Ziel der Anlage-Quote"
|
||||
help="Welches Vermögens-Element wird aufgestockt?"
|
||||
value={ct.capitalUseTargetElementId ?? targets[0].id}
|
||||
onChange={(v: string) => setC({ capitalUseTargetElementId: v })}
|
||||
options={targets.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DerivedField
|
||||
label={`… bleibt Cash (${cash} %)`}
|
||||
value={Math.round((inflow * cash) / 100)}
|
||||
help="Der nicht zugeteilte Rest. Wird automatisch berechnet."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -365,6 +449,35 @@ export function ElementPhaseFields({
|
||||
setP: (patch: Partial<PhaseData>) => 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 (
|
||||
<InheritableField
|
||||
label={label}
|
||||
help={help}
|
||||
canInherit={carried}
|
||||
isInheriting={isInheriting}
|
||||
inheritedDisplay={format(fallback)}
|
||||
onToggle={(inherit) => setP({ [key]: inherit ? undefined : fallback } as Partial<PhaseData>)}
|
||||
>
|
||||
{render(typeof own === "number" ? own : fallback, (v) => setP({ [key]: v } as Partial<PhaseData>))}
|
||||
</InheritableField>
|
||||
);
|
||||
};
|
||||
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."
|
||||
/>
|
||||
<NumberField
|
||||
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||
help={
|
||||
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."
|
||||
}
|
||||
step={0.1}
|
||||
value={rate}
|
||||
onChange={(v) => setP({ teuerungsausgleich: v })}
|
||||
/>
|
||||
{inh(
|
||||
"teuerungsausgleich",
|
||||
isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)",
|
||||
(v, set) => (
|
||||
<NumberField
|
||||
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||
step={0.1}
|
||||
value={v}
|
||||
onChange={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 (
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
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.
|
||||
</p>
|
||||
<>
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
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}).`
|
||||
: "."}
|
||||
</p>
|
||||
{startsBeforeReference && (
|
||||
<>
|
||||
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
<strong>Frühpensionierung:</strong> 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.
|
||||
</p>
|
||||
<MoneyField
|
||||
label="AHV-Beitrag als Nichterwerbstätige(r) (CHF/Jahr)"
|
||||
help={
|
||||
"Der Beitrag bemisst sich am Vermögen und am Renteneinkommen, nicht am Lohn – die Bandbreite ist deshalb enorm: " +
|
||||
"vom Mindestbeitrag von rund 530 CHF pro Jahr bis zum Höchstbeitrag von rund 26'500 CHF pro Jahr. " +
|
||||
"Faustregel: Bis etwa 300'000 CHF Vermögen liegt man nahe am Minimum; ab rund 8.7 Mio. am Maximum. " +
|
||||
"Verheiratete zahlen den Beitrag je einzeln, wenn beide nicht erwerbstätig sind; ist der Partner erwerbstätig " +
|
||||
"und zahlt genug, entfällt er. Die AHV-Ausgleichskasse rechnet den Betrag auf Anfrage aus."
|
||||
}
|
||||
value={num(pd.ahvContribution)}
|
||||
onChange={(v) => setP({ ahvContribution: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NumberField
|
||||
label="Geplante Ausfalljahre"
|
||||
help="Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44."
|
||||
value={num(pd.gapYears)}
|
||||
min={0}
|
||||
max={context.durationYears}
|
||||
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
|
||||
/>
|
||||
<>
|
||||
<NumberField
|
||||
label="Geplante Ausfalljahre"
|
||||
help={`Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44. Nur Jahre VOR dem Referenzalter ${AHV_REFERENCE_AGE} zählen.`}
|
||||
value={num(pd.gapYears)}
|
||||
min={0}
|
||||
max={context.durationYears}
|
||||
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
|
||||
/>
|
||||
{reachesReference && (
|
||||
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
<strong>Weiterarbeiten über {AHV_REFERENCE_AGE} hinaus:</strong> 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.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "PENSION_FUND":
|
||||
if (!context.ownerWorking) {
|
||||
return (
|
||||
@@ -484,13 +649,19 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jährliche Einzahlung (CHF)"
|
||||
help="Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
|
||||
value={num(pd.annualContribution)}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
{inh(
|
||||
"annualContribution",
|
||||
"Jährliche Einzahlung (CHF)",
|
||||
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={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) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "PILLAR_3A":
|
||||
@@ -513,14 +684,21 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jährliche Einzahlung (CHF)"
|
||||
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`}
|
||||
value={num(pd.annualContribution)}
|
||||
max={PILLAR_3A_MAX_ANNUAL}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
{inh(
|
||||
"annualContribution",
|
||||
"Jährliche Einzahlung (CHF)",
|
||||
(v, set) => (
|
||||
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={PILLAR_3A_MAX_ANNUAL} onChange={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) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "REAL_ESTATE": {
|
||||
@@ -548,19 +726,20 @@ export function ElementPhaseFields({
|
||||
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
|
||||
</>
|
||||
)}
|
||||
<MoneyField
|
||||
label="Amortisation (CHF/Jahr)"
|
||||
help="Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||
value={num(pd.amortization)}
|
||||
onChange={(v) => setP({ amortization: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Hypothekarzins (%/Jahr)"
|
||||
help="Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
||||
step={0.1}
|
||||
value={num(pd.interestRate)}
|
||||
onChange={(v) => setP({ interestRate: v })}
|
||||
/>
|
||||
{inh(
|
||||
"amortization",
|
||||
"Amortisation (CHF/Jahr)",
|
||||
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={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) => <NumberField label="Hypothekarzins (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct,
|
||||
"Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
||||
)}
|
||||
<div>
|
||||
<FieldLabel
|
||||
label="Hypothekarzins-Betrag (Beginn → Ende)"
|
||||
@@ -570,13 +749,13 @@ export function ElementPhaseFields({
|
||||
{formatChf(zinsStart)} <span className="text-faint">→</span> {formatChf(zinsEnde)}
|
||||
</div>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Geschätzte Wertsteigerung (%/Jahr)"
|
||||
help="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)."
|
||||
step={0.1}
|
||||
value={num(pd.valueGrowth)}
|
||||
onChange={(v) => setP({ valueGrowth: v })}
|
||||
/>
|
||||
{inh(
|
||||
"valueGrowth",
|
||||
"Geschätzte Wertsteigerung (%/Jahr)",
|
||||
(v, set) => <NumberField label="Geschätzte Wertsteigerung (%/Jahr)" step={0.1} value={v} onChange={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)."
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
|
||||
@@ -609,19 +788,26 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||
)}
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
<MoneyField
|
||||
label="Jährlicher Sparbeitrag (CHF)"
|
||||
help="Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||||
value={num(pd.annualContribution)}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Jährliche Bezugsrate (CHF)"
|
||||
help="Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
|
||||
value={num(pd.annualWithdrawal)}
|
||||
onChange={(v) => setP({ annualWithdrawal: v })}
|
||||
/>
|
||||
{inh(
|
||||
"expectedReturn",
|
||||
"Erwartete Rendite (%/Jahr)",
|
||||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct
|
||||
)}
|
||||
{inh(
|
||||
"annualContribution",
|
||||
"Jährlicher Sparbeitrag (CHF)",
|
||||
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||||
)}
|
||||
{inh(
|
||||
"annualWithdrawal",
|
||||
"Jährliche Bezugsrate (CHF)",
|
||||
(v, set) => <MoneyField label="Jährliche Bezugsrate (CHF)" value={v} onChange={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({
|
||||
) : (
|
||||
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jährliche Tilgung (CHF)"
|
||||
help="Zählt gegen die Sparquote (max. die Sparquote)."
|
||||
value={num(pd.annualRepayment)}
|
||||
|
||||
onChange={(v) => setP({ annualRepayment: v })}
|
||||
/>
|
||||
{inh(
|
||||
"annualRepayment",
|
||||
"Jährliche Tilgung (CHF)",
|
||||
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Zählt gegen die Sparquote (max. die Sparquote)."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,3 +320,54 @@ export function SelectField<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- "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 (
|
||||
<div>
|
||||
{isInheriting ? (
|
||||
<>
|
||||
<FieldLabel label={label} help={help} />
|
||||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||
{inheritedDisplay} <span className="text-xs text-faint">(aus der Vorphase)</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{canInherit && (
|
||||
<label className="mt-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isInheriting}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-accent"
|
||||
/>
|
||||
Aus Vorphase übernehmen
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Pensionierungsalter (nur dieses Szenario)"
|
||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
|
||||
value={person.retirementAge}
|
||||
min={30}
|
||||
max={100}
|
||||
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
||||
/>
|
||||
{lockRetirement ? (
|
||||
<div>
|
||||
<FieldLabel
|
||||
label="Pensionierungsalter"
|
||||
help="Liegt immer auf einer Phasengrenze und wird deshalb unten unter «Pensionsalter anpassen» verschoben -- dabei wandern die angrenzenden Phasendauern mit."
|
||||
/>
|
||||
<div className="mt-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-muted">
|
||||
{person.retirementAge} Jahre
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NumberField
|
||||
label="Pensionierungsalter (nur dieses Szenario)"
|
||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
|
||||
value={person.retirementAge}
|
||||
min={30}
|
||||
max={100}
|
||||
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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<ProfileDraft>({
|
||||
@@ -1642,12 +1669,21 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
|
||||
return (
|
||||
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||||
<PlanProfileFields draft={draft} onChange={setDraft} lockRetirement={plan.phases.length > 0} />
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
|
||||
{plan.phases.length > 0 && (
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<h3 className="mb-2 text-sm font-semibold text-fg">Pensionsalter anpassen</h3>
|
||||
{/* Wirkt SOFORT (eigener Endpunkt), weil dabei Phasendauern verschoben werden --
|
||||
das lässt sich nicht sinnvoll mit dem Profil-Formular zusammen abspeichern. */}
|
||||
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InspectorShell>
|
||||
);
|
||||
@@ -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)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{boundaries.map((b) => {
|
||||
const label = personLabel(plan, b.role);
|
||||
const canMergeDown = b.mergeDeltaDown !== null;
|
||||
const canMergeUp = b.mergeDeltaUp !== null;
|
||||
return (
|
||||
<div key={b.role} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{label}</div>
|
||||
<div className="text-lg font-semibold text-fg">
|
||||
Pension mit {b.retirementAge}
|
||||
{b.blocked ? "" : <span className="ml-2 text-xs font-normal text-muted">Planjahr {b.planYear}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!b.blocked && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy !== null || (b.minDelta === 0 && !canMergeDown)}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", -1)}
|
||||
>
|
||||
−1 Jahr
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy !== null || (b.maxDelta === 0 && !canMergeUp)}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", 1)}
|
||||
>
|
||||
+1 Jahr
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hilfebox: Was ist möglich -- und warum nicht mehr. Ohne diese Erklärung wirkt
|
||||
ein deaktivierter Knopf wie ein Fehler. */}
|
||||
<p className="mt-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
{limitsText(b, label)}
|
||||
</p>
|
||||
|
||||
{!b.blocked && (b.minDelta < -1 || b.maxDelta > 1) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{[-5, -3, -2, 2, 3, 5]
|
||||
.filter((d) => d >= (b.mergeDeltaDown ?? b.minDelta) && d <= (b.mergeDeltaUp ?? b.maxDelta))
|
||||
.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", d)}
|
||||
className="rounded-full border border-border px-2 py-0.5 text-xs text-muted hover:border-accent hover:text-fg disabled:opacity-50"
|
||||
>
|
||||
{d > 0 ? "+" : ""}
|
||||
{d} → {b.retirementAge + d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+164
-33
@@ -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<string, number>;
|
||||
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<string, number>();
|
||||
for (const e of plan.elements) {
|
||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||
const owner = personByRole(persons, e.ownerRole);
|
||||
if (!owner || workingByPerson.get(owner.id)) continue;
|
||||
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<string, ElementPhaseComputed>();
|
||||
// Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere).
|
||||
const phaseRealIncomeByPerson = new Map<string, number>();
|
||||
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
||||
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<string, unknown>)[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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: <Wert>" 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<string, PhaseData>,
|
||||
orderedPhaseIds: string[],
|
||||
phaseId: string
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
+17
-3
@@ -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<DriverId, { min: number; max: number; step: number; neutral
|
||||
expenses: { min: -20, max: 30, step: 1, neutral: 0 },
|
||||
income: { min: -30, max: 20, step: 1, neutral: 0 },
|
||||
lifespan: { min: -5, max: 10, step: 1, neutral: 0 },
|
||||
// Der wirkliche Spielraum hängt an den Phasendauern und wird in `buildSliders` gesetzt.
|
||||
retirementA: { min: -3, max: 3, step: 1, neutral: 0 },
|
||||
retirementB: { min: -3, max: 3, step: 1, neutral: 0 },
|
||||
};
|
||||
|
||||
// Bereich für die einzeln aufgeschlüsselten Element-Renditen: derselbe wie der Sammelregler,
|
||||
@@ -69,14 +79,18 @@ export function buildSliders(plan: PlanInput, expandReturns: boolean): SliderDef
|
||||
// Aufgeschlüsselt übernehmen die Element-Regler die Rolle der Sammelregler.
|
||||
if (expandReturns && (def.id === "returns" || def.id === "propertyGrowth")) continue;
|
||||
const r = RANGES[def.id];
|
||||
// Beim Pensionsalter darf der Regler nur so weit gehen, wie die angrenzenden Phasen es
|
||||
// zulassen -- sonst würde er stumm an seiner Grenze kleben (Roadmap Nr. 44).
|
||||
const hard =
|
||||
def.id === "retirementA" || def.id === "retirementB" ? retirementDriverRange(plan, def.id) : null;
|
||||
out.push({
|
||||
key: `d:${def.id}`,
|
||||
ref: { kind: "driver", id: def.id },
|
||||
label: def.shortLabel,
|
||||
unit: def.unit,
|
||||
help: def.help,
|
||||
min: r.min,
|
||||
max: r.max,
|
||||
min: hard ? Math.max(r.min, hard.min) : r.min,
|
||||
max: hard ? Math.min(r.max, hard.max) : r.max,
|
||||
step: r.step,
|
||||
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan.
|
||||
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0,
|
||||
|
||||
@@ -106,7 +106,9 @@ export function buildRateWrites(
|
||||
if (scope === "THIS" || fields.length === 0) return [];
|
||||
|
||||
const patch: Partial<PhaseData> = {};
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>)[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(" ");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+67
-2
@@ -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.";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user