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 |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.25 |
|
| **Version** | 0.26 |
|
||||||
| **Datum** | 2026-07-18 |
|
| **Datum** | 2026-07-21 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **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 |
|
| **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` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| 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.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.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). |
|
| 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.
|
4. **Ergebnis** – Basisfall, Tornado-Chart und Tabelle.
|
||||||
|
|
||||||
Der Dialog ist bewusst **nicht** Teil des Grafiken-Bereichs: Dieser ist reine Ausgabe, während der
|
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
|
Tornado Pflichteingaben braucht. Seit 0.26 gehört auch das **Pensionsalter** je Person zu den
|
||||||
**Pensionsalter** nicht enthalten ist (siehe [9.18](#918-tornado-was-der-chart-nicht-leistet)).
|
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`.
|
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`,
|
Referenz: `src/lib/report.ts`, `src/lib/report-pdf.ts`, `src/lib/decisions.ts`,
|
||||||
`src/components/ReportsView.tsx`.
|
`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
|
# 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
|
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)).
|
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`),
|
Referenz: `src/lib/calculations.ts` (`ahvMonthlyFullPension`, `ahvMdje`, `ahvAnnualPension`),
|
||||||
`src/lib/constants.ts`.
|
`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 Nominal, real und die Deflatoren
|
||||||
|
|
||||||
### 4.5.1 Das V5-Modell
|
### 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
|
### 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)
|
baseValue = hasCarry ? round(carry.flowBasis) : round(phaseData.amount)
|
||||||
basis = !hasCarry → round(phaseData.amount)
|
basis = !hasCarry → round(phaseData.amount)
|
||||||
phaseData.amount ist Zahl → round(phaseData.amount) // bewusster Override
|
phaseData.amount ist Zahl → round(phaseData.amount) // bewusster Override
|
||||||
sonst → baseValue // live vererbt
|
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
|
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`
|
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).
|
(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
|
### 4.6.2 AHV
|
||||||
|
|
||||||
Erwerbstätig → nur Zusammenfassung („N Ausfalljahre" / „Keine Ausfalljahre"), kein Wert.
|
Seit 0.26 **jahresweise** statt phasenweise ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)):
|
||||||
Pensioniert → `startValue = endValue = rente`, Summand in `renteTotal`.
|
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
|
### 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_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_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente |
|
||||||
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
|
| `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) |
|
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
|
||||||
| `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz |
|
| `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz |
|
||||||
| `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer |
|
| `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer |
|
||||||
@@ -2526,6 +2689,7 @@ falsch zu werden:
|
|||||||
| Einkommen | **relativ %** | skaliert `amount` aller `INCOME`-Elemente |
|
| Einkommen | **relativ %** | skaliert `amount` aller `INCOME`-Elemente |
|
||||||
| Lohnentwicklung | **Δ Prozentpunkte** | verschiebt `teuerungsausgleich` der `INCOME`-Elemente |
|
| Lohnentwicklung | **Δ Prozentpunkte** | verschiebt `teuerungsausgleich` der `INCOME`-Elemente |
|
||||||
| Wertsteigerung der Immobilie | **Δ Prozentpunkte** | verschiebt `valueGrowth` |
|
| 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:
|
Die Begründungen im Einzelnen:
|
||||||
- **Absolut** nur bei der Inflation – es gibt genau einen plan-weiten Wert.
|
- **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,
|
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.
|
deren Einzelregler das Panel fluten würden, ohne eine bessere Frage zu ermöglichen.
|
||||||
|
|
||||||
**Das Pensionsalter fehlt weiterhin** – aus demselben Grund wie beim Tornado
|
**Das Pensionsalter ist seit 0.26 als Regler dabei** (je Person einer, sofern überhaupt
|
||||||
([9.18](#918-tornado-was-der-chart-nicht-leistet)): Es liesse sich nicht verschieben,
|
Spielraum besteht). Sein Bereich ist **plan-abhängig**: Er endet dort, wo eine angrenzende
|
||||||
ohne die Phasengrenzen mitzuziehen. Ersatzweise gibt es **Lebensdauer** (letzte Phase
|
Lebensphase unter ein Jahr fiele – ein Regler, der stumm an seiner Grenze klebt, wäre schlechter
|
||||||
verlängern/verkürzen), was das Langlebigkeitsrisiko abdeckt, nicht aber die Frühpensionierung.
|
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
|
### 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`,
|
Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
|
||||||
`tunableElements`), `src/components/LiveSimDialog.tsx`, `src/components/AllocationChart.tsx`.
|
`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
|
# 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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `amount` | INCOME, EXPENSE | ≥ 0 |
|
||||||
| `teuerungsausgleich` | INCOME, EXPENSE | −20 bis 50 |
|
| `teuerungsausgleich` | INCOME, EXPENSE | −20 bis 50 |
|
||||||
| `gapYears` | AHV | Integer ≥ 0 |
|
| `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** |
|
| `avgIncomeBefore` | AHV – nur wenn bei Planbeginn **bereits pensioniert** | ≥ 0, **real** |
|
||||||
| `gapYearsBefore` | AHV – dito | Integer 0–50 |
|
| `gapYearsBefore` | AHV – dito | Integer 0–50 |
|
||||||
| `currentValue` | PENSION_FUND, PILLAR_3A | ≥ 0 |
|
| `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 |
|
| `valueGrowth` | REAL_ESTATE – Wertsteigerung %/Jahr auf die Liegenschaft | −20 bis 20 |
|
||||||
| `annualRepayment` | OTHER_DEBT | ≥ 0 |
|
| `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`
|
### 5.4.4 JSON-Payload `TransitionData`
|
||||||
|
|
||||||
| Feld | Kategorien | Zod-Regel |
|
| Feld | Kategorien | Zod-Regel |
|
||||||
@@ -3089,6 +3352,9 @@ Liegt in `Phase.cashTransition`. Validierung über `cashTransitionSchema`.
|
|||||||
| `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0–100 |
|
| `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0–100 |
|
||||||
| `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen |
|
| `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen |
|
||||||
| `outflowAmount` | Betrag **real** (heutige Kaufkraft) | ≥ 0 |
|
| `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
|
Pro Übergang ist **genau ein** Zufluss und **eine** Kostenposition möglich – siehe
|
||||||
[9.7](#97-nur-ein-zufluss-und-eine-kostenposition-pro-übergang).
|
[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.
|
`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.
|
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>`
|
### `PATCH /api/scenarios/<scenarioId>`
|
||||||
Akzeptiert eine **Union** von zwei Formen:
|
Akzeptiert eine **Union** von zwei Formen:
|
||||||
1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` – ersetzt
|
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 |
|
| Datei | Tests | Schwerpunkt |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" |
|
| `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` | 15 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber |
|
| `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 |
|
| `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 % |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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
|
## 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
|
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.
|
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
|
**Das Pensionsalter ist seit 0.26 enthalten** – zuvor bewusst nicht, und die Begründung von
|
||||||
`retirementAge` lässt sich im aktuellen Datenmodell nicht isoliert variieren, ohne die
|
damals erklärt, warum die heutige Umsetzung so aussieht, wie sie aussieht.
|
||||||
Phasengrenzen mitzuverschieben – und das Ergebnis wäre nicht ungenau, sondern **irreführend**.
|
|
||||||
|
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.
|
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
|
- **Auf 62 gesetzt:** Phase 1 beginnt mit 45, also `45 < 62` → die Phase bliebe vollständig
|
||||||
Erwerbsphase. Die Person arbeitet im Modell weiterhin bis 65, der Pensions-Übergang liegt an
|
Erwerbsphase. Die Person arbeitete im Modell weiterhin bis 65. Wirkung: **praktisch null.**
|
||||||
derselben Grenze. Wirkung auf das Ergebnis: **praktisch null.**
|
- **Auf 68 gesetzt:** Phase 2 beginnt mit 65, also `65 < 68` → Phase 2 würde zur Erwerbsphase.
|
||||||
- **Pensionsalter auf 68:** Phase 2 beginnt mit 65, also `65 < 68` → Phase 2 wird zur
|
Zugleich wäre `ownerRetiresNext` an der Grenze nach Phase 1 falsch, womit der
|
||||||
**Erwerbsphase**, das Einkommen läuft weiter. Zugleich wird `ownerRetiresNext` an der Grenze
|
**Pensions-Übergang komplett entfiele**: keine PK-Verrentung, kein 3a-Bezug. Der Balken wäre
|
||||||
nach Phase 1 falsch (65 ≥ 68 trifft nicht zu), womit der **Pensions-Übergang komplett entfällt**:
|
riesig – er misst aber den Ausfall der Vorsorgelogik, nicht „drei Jahre länger arbeiten".
|
||||||
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".
|
|
||||||
|
|
||||||
Fachlich korrekt wäre nur, `retirementAge` **und** die Phasengrenze gemeinsam zu verschieben
|
Der Treiber verschiebt deshalb `retirementAge` **und** die Phasengrenze gemeinsam
|
||||||
(Erwerbsphase kürzer, Pensionsphase länger). Das hat eigene Sonderfälle – Paare mit
|
([4.16](#416-pensionsalter-verschieben)). Zwei Eigenheiten bleiben und sind im Dialog benannt:
|
||||||
unterschiedlichem Pensionsalter, Grenzen abseits des Pensionsereignisses, Verschiebung grösser als
|
Der Spielraum endet bei den angrenzenden Phasendauern (eine **Zusammenlegung** findet im Tornado
|
||||||
die Phasendauer – und ist als eigener Arbeitsschritt offen. Der verwandte Treiber **Lebensdauer**
|
bewusst nicht statt), und eine zu weite Bandbreite wird auf das Mögliche gekürzt statt verworfen.
|
||||||
(Dauer der letzten Phase) ist dagegen sauber abgebildet und deckt einen Teil des Bedürfnisses ab.
|
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
|
## 9.19 Simulationsparameter werden nicht gespeichert
|
||||||
|
|
||||||
|
|||||||
@@ -79,33 +79,28 @@ export async function POST(
|
|||||||
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vorbelegung für eine neue Phase: nur die editierbaren Felder werden übernommen. Start-
|
// Vorbelegung für eine neue Phase.
|
||||||
// bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden in der Berechnung live aus der
|
//
|
||||||
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
|
// 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 {
|
function buildCarryData(category: string, prev: PhaseData): PhaseData {
|
||||||
switch (category) {
|
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":
|
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 };
|
return { gapYears: 0 };
|
||||||
case "PENSION_FUND":
|
|
||||||
case "PILLAR_3A":
|
|
||||||
case "OTHER_ASSET":
|
|
||||||
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
|
|
||||||
case "REAL_ESTATE":
|
case "REAL_ESTATE":
|
||||||
// purchasePrice + amortization bleiben; Resthypothek und Verkehrswert werden live
|
// Der Kaufpreis ist eine Tatsache, keine Annahme (Basis der Grundstückgewinnsteuer);
|
||||||
// fortgeschrieben. Zinssatz, Zins-Behandlung und Wertsteigerung gelten weiter.
|
// die Zins-Behandlung ist ein Schalter ohne Zahlenwert -- beide werden weiterhin kopiert.
|
||||||
return {
|
return {
|
||||||
purchasePrice: num(prev.purchasePrice),
|
purchasePrice: num(prev.purchasePrice),
|
||||||
amortization: num(prev.amortization),
|
|
||||||
interestRate: num(prev.interestRate),
|
|
||||||
interestHandling: prev.interestHandling ?? "INCLUDED",
|
interestHandling: prev.interestHandling ?? "INCLUDED",
|
||||||
valueGrowth: num(prev.valueGrowth),
|
|
||||||
};
|
};
|
||||||
case "OTHER_DEBT":
|
|
||||||
return { annualRepayment: num(prev.annualRepayment) };
|
|
||||||
default:
|
default:
|
||||||
return {};
|
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 { useState } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { AlertTriangle } 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 { formatChf } from "@/lib/format";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
|
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
|
||||||
@@ -24,8 +24,9 @@ import {
|
|||||||
type RateScope,
|
type RateScope,
|
||||||
} from "@/lib/ratefields";
|
} from "@/lib/ratefields";
|
||||||
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
|
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 {
|
import {
|
||||||
|
AHV_REFERENCE_AGE,
|
||||||
DEFAULT_CAPITAL_TAX_RATE,
|
DEFAULT_CAPITAL_TAX_RATE,
|
||||||
DEFAULT_PK_CONVERSION_RATE,
|
DEFAULT_PK_CONVERSION_RATE,
|
||||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||||
@@ -59,6 +60,12 @@ export interface CellContext {
|
|||||||
laterPhaseCount: number;
|
laterPhaseCount: number;
|
||||||
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
|
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
|
||||||
ahvCareer: AhvCareer | null;
|
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 {
|
interface Props {
|
||||||
@@ -244,10 +251,18 @@ export function CashTransitionFields({
|
|||||||
ct,
|
ct,
|
||||||
setC,
|
setC,
|
||||||
deflatorEnd,
|
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;
|
ct: CashTransitionData;
|
||||||
setC: (patch: Partial<CashTransitionData>) => void;
|
setC: (patch: Partial<CashTransitionData>) => void;
|
||||||
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
|
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
|
||||||
|
showCapitalUse?: boolean;
|
||||||
|
capitalInflow?: number;
|
||||||
|
investTargets?: { id: string; name: string }[];
|
||||||
}) {
|
}) {
|
||||||
const mode = ct.mode ?? "NONE";
|
const mode = ct.mode ?? "NONE";
|
||||||
const showIn = mode === "INFLOW" || mode === "BOTH";
|
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;
|
setP: (patch: Partial<PhaseData>) => void;
|
||||||
}) {
|
}) {
|
||||||
const carried = context.carried;
|
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) {
|
switch (element.category) {
|
||||||
case "INCOME":
|
case "INCOME":
|
||||||
case "EXPENSE": {
|
case "EXPENSE": {
|
||||||
@@ -372,7 +485,6 @@ export function ElementPhaseFields({
|
|||||||
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
|
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
|
||||||
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
|
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
|
||||||
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
|
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
|
||||||
const rate = num(pd.teuerungsausgleich, 0);
|
|
||||||
const d = context.deflatorStart || 1;
|
const d = context.deflatorStart || 1;
|
||||||
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
|
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
|
||||||
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
|
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
|
||||||
@@ -394,21 +506,36 @@ export function ElementPhaseFields({
|
|||||||
value={otherValue}
|
value={otherValue}
|
||||||
help="Nur zur Info, wird automatisch berechnet."
|
help="Nur zur Info, wird automatisch berechnet."
|
||||||
/>
|
/>
|
||||||
<NumberField
|
{inh(
|
||||||
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
"teuerungsausgleich",
|
||||||
help={
|
isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)",
|
||||||
isIncome
|
(v, set) => (
|
||||||
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
<NumberField
|
||||||
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||||
}
|
step={0.1}
|
||||||
step={0.1}
|
value={v}
|
||||||
value={rate}
|
onChange={set}
|
||||||
onChange={(v) => setP({ teuerungsausgleich: v })}
|
/>
|
||||||
/>
|
),
|
||||||
|
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) {
|
if (!context.ownerWorking) {
|
||||||
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang,
|
// 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).
|
// an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase).
|
||||||
@@ -442,23 +569,61 @@ export function ElementPhaseFields({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-muted">
|
<>
|
||||||
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
|
<p className="col-span-2 text-sm text-muted">
|
||||||
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
|
||||||
Plafonierung auf 150% der Maximalrente.
|
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
||||||
</p>
|
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 (
|
return (
|
||||||
<NumberField
|
<>
|
||||||
label="Geplante Ausfalljahre"
|
<NumberField
|
||||||
help="Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44."
|
label="Geplante Ausfalljahre"
|
||||||
value={num(pd.gapYears)}
|
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.`}
|
||||||
min={0}
|
value={num(pd.gapYears)}
|
||||||
max={context.durationYears}
|
min={0}
|
||||||
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
|
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":
|
case "PENSION_FUND":
|
||||||
if (!context.ownerWorking) {
|
if (!context.ownerWorking) {
|
||||||
return (
|
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="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||||
)}
|
)}
|
||||||
<MoneyField
|
{inh(
|
||||||
label="Jährliche Einzahlung (CHF)"
|
"annualContribution",
|
||||||
help="Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
|
"Jährliche Einzahlung (CHF)",
|
||||||
value={num(pd.annualContribution)}
|
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={set} />,
|
||||||
onChange={(v) => setP({ annualContribution: v })}
|
asChf,
|
||||||
/>
|
"Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
|
||||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
)}
|
||||||
|
{inh(
|
||||||
|
"expectedReturn",
|
||||||
|
"Erwartete Rendite (%/Jahr)",
|
||||||
|
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||||
|
asPct
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "PILLAR_3A":
|
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="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||||
)}
|
)}
|
||||||
<MoneyField
|
{inh(
|
||||||
label="Jährliche Einzahlung (CHF)"
|
"annualContribution",
|
||||||
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`}
|
"Jährliche Einzahlung (CHF)",
|
||||||
value={num(pd.annualContribution)}
|
(v, set) => (
|
||||||
max={PILLAR_3A_MAX_ANNUAL}
|
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={PILLAR_3A_MAX_ANNUAL} onChange={set} />
|
||||||
onChange={(v) => setP({ annualContribution: v })}
|
),
|
||||||
/>
|
asChf,
|
||||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
`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": {
|
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="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<MoneyField
|
{inh(
|
||||||
label="Amortisation (CHF/Jahr)"
|
"amortization",
|
||||||
help="Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
"Amortisation (CHF/Jahr)",
|
||||||
value={num(pd.amortization)}
|
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={set} />,
|
||||||
onChange={(v) => setP({ amortization: v })}
|
asChf,
|
||||||
/>
|
"Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||||
<NumberField
|
)}
|
||||||
label="Hypothekarzins (%/Jahr)"
|
{inh(
|
||||||
help="Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
"interestRate",
|
||||||
step={0.1}
|
"Hypothekarzins (%/Jahr)",
|
||||||
value={num(pd.interestRate)}
|
(v, set) => <NumberField label="Hypothekarzins (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||||
onChange={(v) => setP({ interestRate: v })}
|
asPct,
|
||||||
/>
|
"Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel
|
<FieldLabel
|
||||||
label="Hypothekarzins-Betrag (Beginn → Ende)"
|
label="Hypothekarzins-Betrag (Beginn → Ende)"
|
||||||
@@ -570,13 +749,13 @@ export function ElementPhaseFields({
|
|||||||
{formatChf(zinsStart)} <span className="text-faint">→</span> {formatChf(zinsEnde)}
|
{formatChf(zinsStart)} <span className="text-faint">→</span> {formatChf(zinsEnde)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NumberField
|
{inh(
|
||||||
label="Geschätzte Wertsteigerung (%/Jahr)"
|
"valueGrowth",
|
||||||
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)."
|
"Geschätzte Wertsteigerung (%/Jahr)",
|
||||||
step={0.1}
|
(v, set) => <NumberField label="Geschätzte Wertsteigerung (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||||
value={num(pd.valueGrowth)}
|
asPct,
|
||||||
onChange={(v) => setP({ valueGrowth: v })}
|
"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">
|
<div className="sm:col-span-2">
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
|
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 })} />
|
<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 })} />
|
{inh(
|
||||||
<MoneyField
|
"expectedReturn",
|
||||||
label="Jährlicher Sparbeitrag (CHF)"
|
"Erwartete Rendite (%/Jahr)",
|
||||||
help="Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||||
value={num(pd.annualContribution)}
|
asPct
|
||||||
onChange={(v) => setP({ annualContribution: v })}
|
)}
|
||||||
/>
|
{inh(
|
||||||
<MoneyField
|
"annualContribution",
|
||||||
label="Jährliche Bezugsrate (CHF)"
|
"Jährlicher Sparbeitrag (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)."
|
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
|
||||||
value={num(pd.annualWithdrawal)}
|
asChf,
|
||||||
onChange={(v) => setP({ annualWithdrawal: v })}
|
"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":
|
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="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||||
)}
|
)}
|
||||||
<MoneyField
|
{inh(
|
||||||
label="Jährliche Tilgung (CHF)"
|
"annualRepayment",
|
||||||
help="Zählt gegen die Sparquote (max. die Sparquote)."
|
"Jährliche Tilgung (CHF)",
|
||||||
value={num(pd.annualRepayment)}
|
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />,
|
||||||
|
asChf,
|
||||||
onChange={(v) => setP({ annualRepayment: v })}
|
"Zählt gegen die Sparquote (max. die Sparquote)."
|
||||||
/>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,3 +320,54 @@ export function SelectField<T extends string>({
|
|||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { NumberField, SelectField, TextField } from "@/components/FormField";
|
import { FieldLabel, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||||
import type { HouseholdType, PersonRole } from "@/lib/types";
|
import type { HouseholdType, PersonRole } from "@/lib/types";
|
||||||
|
|
||||||
export interface ProfileDraft {
|
export interface ProfileDraft {
|
||||||
@@ -24,9 +24,14 @@ export function emptyProfileDraft(): ProfileDraft {
|
|||||||
export function PlanProfileFields({
|
export function PlanProfileFields({
|
||||||
draft,
|
draft,
|
||||||
onChange,
|
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;
|
draft: ProfileDraft;
|
||||||
onChange: (next: ProfileDraft) => void;
|
onChange: (next: ProfileDraft) => void;
|
||||||
|
lockRetirement?: boolean;
|
||||||
}) {
|
}) {
|
||||||
function setType(type: HouseholdType) {
|
function setType(type: HouseholdType) {
|
||||||
if (type === "SINGLE") {
|
if (type === "SINGLE") {
|
||||||
@@ -89,14 +94,26 @@ export function PlanProfileFields({
|
|||||||
max={120}
|
max={120}
|
||||||
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
{lockRetirement ? (
|
||||||
label="Pensionierungsalter (nur dieses Szenario)"
|
<div>
|
||||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
|
<FieldLabel
|
||||||
value={person.retirementAge}
|
label="Pensionierungsalter"
|
||||||
min={30}
|
help="Liegt immer auf einer Phasengrenze und wird deshalb unten unter «Pensionsalter anpassen» verschoben -- dabei wandern die angrenzenden Phasendauern mit."
|
||||||
max={100}
|
/>
|
||||||
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
} from "@/components/ElementDetail";
|
} from "@/components/ElementDetail";
|
||||||
import { PhaseDetail } from "@/components/PhaseDetail";
|
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||||
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
|
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||||||
import { MoneyField } from "@/components/FormField";
|
import { MoneyField } from "@/components/FormField";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
@@ -58,6 +59,7 @@ import {
|
|||||||
CATEGORY_LABELS,
|
CATEGORY_LABELS,
|
||||||
CATEGORY_ORDER,
|
CATEGORY_ORDER,
|
||||||
PERSON_ONLY_CATEGORIES,
|
PERSON_ONLY_CATEGORIES,
|
||||||
|
inheritedPhaseValues,
|
||||||
num,
|
num,
|
||||||
type CashTransitionData,
|
type CashTransitionData,
|
||||||
type ElementCategory,
|
type ElementCategory,
|
||||||
@@ -256,6 +258,16 @@ export function PlanView({
|
|||||||
return computed.phases.length - phase.sequenceNumber;
|
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.
|
// Baut den Kontext für eine Phasenzelle.
|
||||||
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
||||||
const ce = computedElement(phase.id, element.id);
|
const ce = computedElement(phase.id, element.id);
|
||||||
@@ -280,6 +292,8 @@ export function PlanView({
|
|||||||
deflatorStart: phase.cumulativeInflationStart,
|
deflatorStart: phase.cumulativeInflationStart,
|
||||||
laterPhaseCount: laterPhaseCount(phase),
|
laterPhaseCount: laterPhaseCount(phase),
|
||||||
ahvCareer: careerFor(element),
|
ahvCareer: careerFor(element),
|
||||||
|
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
|
||||||
|
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +316,8 @@ export function PlanView({
|
|||||||
deflatorStart: fromPhase.cumulativeInflationStart,
|
deflatorStart: fromPhase.cumulativeInflationStart,
|
||||||
laterPhaseCount: laterPhaseCount(fromPhase),
|
laterPhaseCount: laterPhaseCount(fromPhase),
|
||||||
ahvCareer: careerFor(element),
|
ahvCareer: careerFor(element),
|
||||||
|
inheritedValues: {},
|
||||||
|
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1468,6 +1484,9 @@ function AddElementDialog({
|
|||||||
deflatorStart: firstPhase.cumulativeInflationStart,
|
deflatorStart: firstPhase.cumulativeInflationStart,
|
||||||
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
|
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
|
||||||
ahvCareer: null,
|
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() {
|
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) ---
|
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---
|
||||||
function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
||||||
const [draft, setDraft] = useState<ProfileDraft>({
|
const [draft, setDraft] = useState<ProfileDraft>({
|
||||||
@@ -1642,12 +1669,21 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
|
|||||||
return (
|
return (
|
||||||
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
|
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
|
||||||
<div className="flex flex-col gap-3">
|
<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>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
|
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
|
||||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</InspectorShell>
|
</InspectorShell>
|
||||||
);
|
);
|
||||||
@@ -1723,6 +1759,9 @@ function TransitionReviewDialog({
|
|||||||
ct={ct}
|
ct={ct}
|
||||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||||
|
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||||
|
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||||
|
investTargets={investTargetsOf(toPhase)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1814,6 +1853,9 @@ function CashTransitionPanel({
|
|||||||
ct={ct}
|
ct={ct}
|
||||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||||
|
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||||
|
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||||
|
investTargets={investTargetsOf(toPhase)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
{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);
|
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", () => {
|
describe("Wasserfall-Brücken", () => {
|
||||||
@@ -272,3 +304,44 @@ describe("Wasserfall-Brücken", () => {
|
|||||||
expect(verkauft.saleGainLoss).not.toBe(0);
|
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);
|
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_FULL_CONTRIBUTION_YEARS,
|
||||||
AHV_GROSS_FROM_NET_FACTOR,
|
AHV_GROSS_FROM_NET_FACTOR,
|
||||||
AHV_MAX_ANNUAL_SINGLE,
|
AHV_MAX_ANNUAL_SINGLE,
|
||||||
|
AHV_REFERENCE_AGE,
|
||||||
AHV_MIN_MONTHLY_FULL,
|
AHV_MIN_MONTHLY_FULL,
|
||||||
AHV_PENSION_MONTHS,
|
AHV_PENSION_MONTHS,
|
||||||
DEFAULT_CAPITAL_TAX_RATE,
|
DEFAULT_CAPITAL_TAX_RATE,
|
||||||
@@ -292,6 +293,9 @@ interface Carry {
|
|||||||
owed: number; // Schulden: Restschuld (positiv)
|
owed: number; // Schulden: Restschuld (positiv)
|
||||||
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
|
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
|
||||||
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
|
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;
|
hasCarry: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +309,7 @@ function emptyCarry(): Carry {
|
|||||||
owed: 0,
|
owed: 0,
|
||||||
pkPensionAnnual: 0,
|
pkPensionAnnual: 0,
|
||||||
flowBasis: 0,
|
flowBasis: 0,
|
||||||
|
rates: {},
|
||||||
hasCarry: false,
|
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>();
|
const ahvUncapped = new Map<string, number>();
|
||||||
for (const e of plan.elements) {
|
for (const e of plan.elements) {
|
||||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||||
const owner = personByRole(persons, e.ownerRole);
|
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 before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 };
|
||||||
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
||||||
const mdJE = ahvMdje(career, before.avg, before.gap);
|
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>();
|
const ecById = new Map<string, ElementPhaseComputed>();
|
||||||
// Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere).
|
// Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere).
|
||||||
const phaseRealIncomeByPerson = new Map<string, number>();
|
const phaseRealIncomeByPerson = new Map<string, number>();
|
||||||
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = [];
|
||||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = [];
|
||||||
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
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
|
// `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
|
// Nettolohn bereits abgezogen), erhöhen aber das Vermögen -- sie sind also ein echter
|
||||||
// Zugang und keine Umbuchung.
|
// 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 owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
||||||
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
|
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 = {
|
const ec: ElementPhaseComputed = {
|
||||||
elementId: e.id,
|
elementId: e.id,
|
||||||
category: e.category,
|
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.
|
// REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu.
|
||||||
// Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase
|
// 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).
|
// (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));
|
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))
|
? Math.round(num(pd.amount))
|
||||||
: typeof pd.amount === "number"
|
: typeof pd.amount === "number"
|
||||||
? Math.round(pd.amount)
|
? Math.round(pd.amount)
|
||||||
: ec.baseValue;
|
: 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
|
// AHV: reales Erwerbseinkommen der Person mitführen. Nur Einkommen, die einer
|
||||||
// Person zugeordnet sind -- bei einem Einzelplan zählt "Gemeinsam" zur Person A.
|
// 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).
|
// Die Fortschreibung in die Folgephase passiert NACH der Jahresschleife: Dort kann ein
|
||||||
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
|
// effektiver Wert `basis` noch neu setzen (`rebaseFlow`), und genau der soll weiter-
|
||||||
|
// getragen werden -- nicht der ursprünglich geplante.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "AHV": {
|
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;
|
const rente = ahvFinal.get(owner.id) ?? 0;
|
||||||
renteTotal += rente;
|
const beitrag = Math.round(num(pd.ahvContribution));
|
||||||
ec.startValue = rente;
|
const ageStart = owner.age + yearsBefore; // Alter im ersten Jahr der Phase
|
||||||
ec.endValue = rente;
|
const ageEnd = ageStart + duration - 1; // Alter im letzten Jahr der Phase
|
||||||
ec.summary = `Rente ${fmt(rente)}`;
|
ahvItems.push({ ownerId: owner.id, ownerStartAge: ageStart, rente, beitrag, working: ownerWorking, ec });
|
||||||
} else {
|
|
||||||
const gap = Math.max(0, Math.round(num(pd.gapYears)));
|
const reachesRef = ageEnd >= AHV_REFERENCE_AGE;
|
||||||
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
|
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;
|
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 base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
const start = base + topUp;
|
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;
|
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||||
ec.baseValue = base;
|
ec.baseValue = base;
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
wealthStart += 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;
|
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 base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
const start = base + topUp;
|
const start = base + topUp;
|
||||||
const rate = Math.round(num(pd.annualContribution));
|
const rate = Math.round(inherited("annualContribution"));
|
||||||
fixedRatesTotal += rate;
|
fixedRatesTotal += rate;
|
||||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||||
ec.baseValue = base;
|
ec.baseValue = base;
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
wealthStart += 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;
|
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 base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
|
||||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
const start = base + topUp;
|
const start = base + topUp;
|
||||||
const rate = Math.round(num(pd.annualContribution));
|
const rate = Math.round(inherited("annualContribution"));
|
||||||
const withdrawal = Math.round(num(pd.annualWithdrawal));
|
const withdrawal = Math.round(inherited("annualWithdrawal"));
|
||||||
fixedRatesTotal += rate;
|
fixedRatesTotal += rate;
|
||||||
plannedWithdrawTotal += withdrawal;
|
plannedWithdrawTotal += withdrawal;
|
||||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||||
ec.baseValue = base;
|
ec.baseValue = base;
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
wealthStart += 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;
|
break;
|
||||||
}
|
}
|
||||||
case "REAL_ESTATE": {
|
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 purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice));
|
||||||
const valueStart = carry.hasCarry ? carry.propertyValue : 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 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;
|
const equity = valueStart - mortgageStart;
|
||||||
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
|
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
|
||||||
ec.baseValue = equity;
|
ec.baseValue = equity;
|
||||||
@@ -666,8 +728,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
purchase,
|
purchase,
|
||||||
mortgage: mortgageStart,
|
mortgage: mortgageStart,
|
||||||
amort,
|
amort,
|
||||||
growth: num(pd.valueGrowth),
|
growth: inherited("valueGrowth"),
|
||||||
interestRate: num(pd.interestRate),
|
interestRate: inherited("interestRate"),
|
||||||
// Default INCLUDED: bestehende Pläne haben die Zinsen in den Ausgaben -> nicht
|
// Default INCLUDED: bestehende Pläne haben die Zinsen in den Ausgaben -> nicht
|
||||||
// nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu.
|
// nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu.
|
||||||
addInterest: pd.interestHandling === "ADD",
|
addInterest: pd.interestHandling === "ADD",
|
||||||
@@ -677,7 +739,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
}
|
}
|
||||||
case "OTHER_DEBT": {
|
case "OTHER_DEBT": {
|
||||||
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
|
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.baseValue = -owedStart;
|
||||||
ec.startValue = -owedStart;
|
ec.startValue = -owedStart;
|
||||||
wealthStart += -owedStart;
|
wealthStart += -owedStart;
|
||||||
@@ -742,8 +804,20 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
interestNominal += re.mortgage * (re.interestRate / 100);
|
interestNominal += re.mortgage * (re.interestRate / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expenseNominal = expenseRealBase * inflFactor + interestNominal;
|
// AHV jahresweise: Die Rente fliesst ab dem Referenzalter -- auch wenn die Person noch
|
||||||
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
|
// 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;
|
const quote = incomeFlow - expenseNominal;
|
||||||
|
|
||||||
// Der Vermögenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
|
// 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) });
|
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()) {
|
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 });
|
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.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1) * flowDeflatorEnd);
|
||||||
exp.ec.summary = fmt(exp.ec.startValue);
|
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) {
|
for (const a of assets) {
|
||||||
a.ec.endValue = Math.round(a.value);
|
a.ec.endValue = Math.round(a.value);
|
||||||
a.ec.summary = fmt(a.ec.endValue);
|
a.ec.summary = fmt(a.ec.endValue);
|
||||||
@@ -1571,6 +1657,51 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
carry.hasCarry = true;
|
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;
|
cashCarryIn = cashEnd + txInflow + txOneOffInflow - txImmediateRepay - txOneOffOutflow;
|
||||||
incomingInflow = txInflow;
|
incomingInflow = txInflow;
|
||||||
incomingImmediateRepay = txImmediateRepay;
|
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.
|
// Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag -- faktisch ab Alter 21.
|
||||||
export const AHV_CONTRIBUTION_START_AGE = 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.
|
// 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.
|
// 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;
|
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",
|
validFrom: "1.1.2026",
|
||||||
group: "AHV",
|
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",
|
key: "AHV_COUPLE_CAP_FACTOR",
|
||||||
label: "Ehepaar-Plafonierung",
|
label: "Ehepaar-Plafonierung",
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export interface PhaseData {
|
|||||||
teuerungsausgleich?: number;
|
teuerungsausgleich?: number;
|
||||||
// AHV
|
// AHV
|
||||||
gapYears?: number;
|
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
|
// 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.
|
// Pensions-Übergang, an dem die Karriere geprüft werden könnte): Beitragskarriere.
|
||||||
// avgIncomeBefore ist REAL (heutige Kaufkraft).
|
// avgIncomeBefore ist REAL (heutige Kaufkraft).
|
||||||
@@ -135,6 +138,14 @@ export interface CashTransitionData {
|
|||||||
// Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft).
|
// Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft).
|
||||||
outflowLabel?: string;
|
outflowLabel?: string;
|
||||||
outflowAmount?: number;
|
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) ---
|
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
|
||||||
@@ -149,6 +160,9 @@ export const cashTransitionSchema = z
|
|||||||
inflowTaxRate: z.number().min(0).max(100).optional(),
|
inflowTaxRate: z.number().min(0).max(100).optional(),
|
||||||
outflowLabel: z.string().max(120).optional(),
|
outflowLabel: z.string().max(120).optional(),
|
||||||
outflowAmount: nonNeg.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();
|
.strip();
|
||||||
|
|
||||||
@@ -157,6 +171,7 @@ export const phaseDataSchema = z
|
|||||||
amount: nonNeg.optional(),
|
amount: nonNeg.optional(),
|
||||||
teuerungsausgleich: z.number().min(-20).max(50).optional(),
|
teuerungsausgleich: z.number().min(-20).max(50).optional(),
|
||||||
gapYears: z.number().int().min(0).optional(),
|
gapYears: z.number().int().min(0).optional(),
|
||||||
|
ahvContribution: nonNeg.optional(),
|
||||||
avgIncomeBefore: nonNeg.optional(),
|
avgIncomeBefore: nonNeg.optional(),
|
||||||
gapYearsBefore: z.number().int().min(0).max(50).optional(),
|
gapYearsBefore: z.number().int().min(0).max(50).optional(),
|
||||||
currentValue: nonNeg.optional(),
|
currentValue: nonNeg.optional(),
|
||||||
@@ -198,3 +213,41 @@ export const transitionDataSchema = z
|
|||||||
export function num(value: number | undefined | null, fallback = 0): number {
|
export function num(value: number | undefined | null, fallback = 0): number {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
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.
|
// keine Ladeanzeige. Der Engpass ist das Neuzeichnen der Grafik, nicht die Mathematik.
|
||||||
|
|
||||||
import { computePlan } from "@/lib/calculations";
|
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 { DriverId, DriverUnit } from "@/lib/sensitivity";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
import type { PlanInput } from "@/lib/types";
|
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 },
|
expenses: { min: -20, max: 30, step: 1, neutral: 0 },
|
||||||
income: { min: -30, max: 20, step: 1, neutral: 0 },
|
income: { min: -30, max: 20, step: 1, neutral: 0 },
|
||||||
lifespan: { min: -5, max: 10, 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,
|
// 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.
|
// Aufgeschlüsselt übernehmen die Element-Regler die Rolle der Sammelregler.
|
||||||
if (expandReturns && (def.id === "returns" || def.id === "propertyGrowth")) continue;
|
if (expandReturns && (def.id === "returns" || def.id === "propertyGrowth")) continue;
|
||||||
const r = RANGES[def.id];
|
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({
|
out.push({
|
||||||
key: `d:${def.id}`,
|
key: `d:${def.id}`,
|
||||||
ref: { kind: "driver", id: def.id },
|
ref: { kind: "driver", id: def.id },
|
||||||
label: def.shortLabel,
|
label: def.shortLabel,
|
||||||
unit: def.unit,
|
unit: def.unit,
|
||||||
help: def.help,
|
help: def.help,
|
||||||
min: r.min,
|
min: hard ? Math.max(r.min, hard.min) : r.min,
|
||||||
max: r.max,
|
max: hard ? Math.min(r.max, hard.max) : r.max,
|
||||||
step: r.step,
|
step: r.step,
|
||||||
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan.
|
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan.
|
||||||
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0,
|
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0,
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ export function buildRateWrites(
|
|||||||
if (scope === "THIS" || fields.length === 0) return [];
|
if (scope === "THIS" || fields.length === 0) return [];
|
||||||
|
|
||||||
const patch: Partial<PhaseData> = {};
|
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)
|
return targetPhases(phases, currentPhaseId, scope)
|
||||||
.filter((p) => p.id !== currentPhaseId)
|
.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));
|
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 { computePlan } from "@/lib/calculations";
|
||||||
import { num } from "@/lib/elements";
|
import { num } from "@/lib/elements";
|
||||||
import type { ElementCategory, PhaseData } 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 type { ResolvedActuals } from "@/lib/actuals";
|
||||||
|
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
|
||||||
|
|
||||||
export type DriverId =
|
export type DriverId =
|
||||||
| "inflation"
|
| "inflation"
|
||||||
@@ -27,7 +28,9 @@ export type DriverId =
|
|||||||
| "income"
|
| "income"
|
||||||
| "salaryGrowth"
|
| "salaryGrowth"
|
||||||
| "lifespan"
|
| "lifespan"
|
||||||
| "propertyGrowth";
|
| "propertyGrowth"
|
||||||
|
| "retirementA"
|
||||||
|
| "retirementB";
|
||||||
|
|
||||||
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
|
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
|
||||||
// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
|
// 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));
|
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[] = [
|
export const DRIVERS: DriverDef[] = [
|
||||||
{
|
{
|
||||||
id: "expenses",
|
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.",
|
"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"]),
|
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",
|
id: "propertyGrowth",
|
||||||
label: "Wertsteigerung der Immobilie",
|
label: "Wertsteigerung der Immobilie",
|
||||||
@@ -191,6 +233,18 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
|
|||||||
teuerungsausgleich: num(pd.teuerungsausgleich, 0) + value,
|
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": {
|
case "lifespan": {
|
||||||
// Verlängert/verkürzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das lässt
|
// 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).
|
// 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.";
|
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.";
|
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user