Pensionsalter anpassen (Roadmap 44)
Deploy App / deploy (push) Successful in 1m1s

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:
2026-07-21 14:24:40 +02:00
parent c429624635
commit d6855ef1a0
20 changed files with 2057 additions and 184 deletions
+316 -34
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.25 |
| **Datum** | 2026-07-18 |
| **Version** | 0.26 |
| **Datum** | 2026-07-21 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
| **Codestand** | Arbeitsstand nach `c429624` inkl. anpassbarem Pensionsalter (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.26 | 2026-07-21 | Claude (Opus 4.8) | **Pensionsalter anpassen** (Roadmap Nr. 44, neue Kapitel 3.12, 4.4.7 und 4.16). Bisher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Lebensphase endet ein frei geändertes Alter hätte die Phasengrenze zerrissen. Neu wird nicht das Alter geändert, sondern **die Grenze verschoben**: Die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt gleich. Der Spielraum endet dort, wo eine angrenzende Phase unter ein Jahr fiele; ein Schritt weiter **entfällt sie ganz**, was vorher bestätigt wird, weil dabei zwei Übergänge **zusammengelegt** werden (bereits getroffene Entscheide bleiben, leere Felder werden aus dem entfallenden Übergang ergänzt, einmalige Cash-Beträge werden addiert der Steuersatz betragsgewichtet). Das Pensionsalter ist damit auch **Treiber im Tornado** und **Regler in der Live-Simulation**. **AHV-Referenzalter (4.4.7):** Die Rente beginnt neu **immer mit 65**, unabhängig vom Pensionsalter wer länger arbeitet, erhält sie zusätzlich zum Lohn; wer früher aufhört, zahlt bis 65 einen **Beitrag als Nichterwerbstätige(r)**, der als laufende Ausgabe auf die Verzehrquote schlägt und mit 65 wegfällt (neues Feld `ahvContribution`, ohne Default, Hilfetext nennt die reale Bandbreite von rund 530 bis 26'500 CHF pro Jahr). Beide Wechsel können **innerhalb** einer Phase liegen, die AHV wird deshalb **jahresweise** statt phasenweise gerechnet. **Punkt B:** Ein Einkommen, das einer **Person** zugeordnet ist, fällt bei deren Pensionierung auf 0 bisher lief der Lohn stillschweigend in die Pension weiter. Gemeinsame Einkommen (Mieterträge o. Ä.) bleiben; ein ausdrücklich erfasster Betrag gewinnt, damit ein Teilzeitpensum modellierbar bleibt. **Punkt A:** Wiederkehr-Parameter (Raten, Beiträge, Amortisation, Wertsteigerung, Zinssatz) werden neu **live aus der Vorphase geerbt** statt beim Anlegen der Phase kopiert sichtbar als angehaktes **«Aus Vorphase übernehmen»** je Feld. Vorher blieb die Kopie stehen, wenn man die Vorphase später änderte. **Punkt C:** Der Kapitalzufluss am Pensions-Übergang (PK, 3a, Verkaufserlös) lässt sich in **Prozent** auf Amortisation, Anlage und Cash aufteilen bewusst nicht in Franken, weil sich der Betrag mit dem Pensionsalter ändert und eine Quote mitskaliert. **Nebenbei ein echter Fehler behoben:** Der fortgeschriebene Basiswert für Einkommen und Ausgaben wurde **vor** der Jahresschleife berechnet effektive Werte kamen dadurch nie in der Folgephase an. Neues Modul `retirement.ts`, neuer Endpunkt `POST /api/scenarios/<id>/retirement`; 40 Tests ergänzt (221 → 261). |
| 0.25 | 2026-07-21 | Claude (Opus 4.8) | **PDF-Berichte** (Roadmap Nr. 11, neues Kapitel 3.11). Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: Liste der erzeugten Berichte plus Assistent zum Anlegen (Titel, Notiz, nominal **oder** real, Plan- oder effektive Daten, bis zu **drei** Szenarien, beliebige gespeicherte Analysen). Das **Layout ist immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen: Deckblatt mit Zusammenfassung und drei Kernaussagen, dann je Szenario Kennzahlen, Vermögensverlauf, Lebensphasen und Annahmen, danach Vergleich, Plan/Ist, Analysen und die Hinweise. **Die PDF-Datei wird als Datei abgelegt** (BYTEA in Postgres, nicht im Container-Dateisystem, das jeder Deploy neu baut): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein eine Neuerzeugung könnte das nach Änderungen an Plan, Rechenkern oder Layout nicht garantieren. **Kennzahlen je Szenario:** Endvermögen, Kapitalreichweite, Vermögen und Vorsorgekapital bei Pensionierung, AHV- und PK-Rente sowie die **offenen Entscheide** die einzige unmittelbar handlungsleitende Zahl. Damit Bericht und Matrix nie verschiedene Zahlen nennen, liegt deren Zählung neu als reine Funktion in `decisions.ts`, die beide benutzen. **Zu jeder Kennzahl steht ihre Grundlage** als kurzer Verweis; die vollständigen Annahmen (Startwerte, Renditen, Raten je Element) stehen **einmal** je Szenario, statt bei jeder Kennzahl wiederholt zu werden. Ein **Haftungsausschluss** ist verpflichtend und durch einen Test gesichert ein formal gesetztes PDF wird sonst als Beratung gelesen. **Technik:** `pdfkit` in der Node-Runtime statt Headless-Browser (kein Chromium im Image); `@react-pdf/renderer` schied aus, weil es mit React 19 / Next 16 bricht. Diagramme entstehen als **echte Vektoren** aus den gespeicherten Zahlen genau dafür wurden die Analysen in 0.24 als Zahlen und nicht als Bilder abgelegt. `pdfkit` ist als externes Paket deklariert, weil es Font-Metriken über Dateipfade lädt und gebündelt erst in der Produktion bräche. Neue Tabelle `Report`, Endpunkte unter `/api/plans/<id>/reports`, neue Module `report.ts`, `report-pdf.ts`, `decisions.ts`; 9 Tests ergänzt (212 → 221). |
| 0.24 | 2026-07-20 | Claude (Opus 4.8) | **Navigation auf Plan-Ebene und gespeicherte Analysen** (neues Kapitel 3.10). Die Seitenleiste ist neu zweistufig: Unter jedem Plan liegen die Unterpunkte **Szenarien**, **Effektive Werte** und **Analysen**; ein Klick auf den Plan-Namen öffnet ein **Plan-Dashboard** (Kennzahlen Haushaltsdaten direkt, gerechnete Werte ausdrücklich «laut Basisszenario», dazu die Ist-Abweichung, falls erfasst). Die **Szenario-Liste** zeigt je Szenario Version, Elementzahl, Endvermögen und Ruinalter mit den Aktionen Historie und Matrix; das Basisszenario ist hervorgehoben, der Szenario-Baum in der Seitenleiste bleibt daneben erhalten. Die **Analysen-Ansicht** bietet vier umklappende Kacheln (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren Umklappen auch per Antippen für Touch). **Grafiken** öffnen neu nicht mehr alle drei Diagramme, sondern lassen zuerst **eines** wählen; der Szenario-Vergleich zieht mit zu den Grafiken, der CSV-Export auf die Matrix. **Gespeicherte Analysen:** Jede Grafik, MC-Simulation und Einflussfaktoren-Berechnung kann festgehalten werden als **Zahlen, nicht als Bild** (read-only, es wird nichts neu gerechnet). Das hält den Datensatz klein und macht ihn druckfähig: Der spätere PDF-Bericht (Roadmap Nr. 11) zeichnet daraus vektoriell in Druckauflösung, was ein Screenshot nicht könnte. Bewusst **nicht** gespeichert wird `finalWealthSorted` (megabyteweise). Jedes Werkzeug schreibt sein Ergebnis in dieselbe generische Form, sodass eine Nur-Lese-Ansicht genügt. Neue Tabelle `SavedAnalysis`, neue Endpunkte unter `/api/plans/<id>/analyses` und `/dashboard`; neue Komponenten `PlanViews`, `SavedAnalysisView`, `SaveAnalysisButton`, neues Modul `analyses.ts`. Kein Eingriff in den Rechenkern; Testbestand 212 (Migration um `SavedAnalysis` erweitert). |
| 0.23 | 2026-07-20 | Claude (Opus 4.8) | **V7: Der Haushalt liegt am Plan, die Annahmen am Szenario.** **Haushaltsform**, **Personen** (Name, Alter) und **Planstartjahr** wandern vom Szenario auf den **Plan**; das **Pensionsalter** bleibt szenario-eigen (es ist der Kern jedes Früh-/Spätpensionierungs-Szenarios), ebenso Inflation und Cash-Anfangswert. Begründung: Diese Angaben beschreiben den Haushalt, nicht eine Planungsvariante unterscheiden sie sich, ist es ein anderer **Plan**, kein anderes Szenario. Neue Tabelle `PlanPerson` (Rolle, Name, Alter je Plan); `Person` behält nur noch Rolle und Pensionsalter; `Plan` bekommt `householdType` und `startYear`. **Der Rechenkern bleibt unberührt:** `toPlanInput()` fügt Plan- und Szenario-Ebene wieder zu einem unveränderten `PlanInput` zusammen, die 43 Golden Tests laufen durch. **Nebeneffekt, der ein reales Problem löst:** Weil das Startjahr nun plan-weit ist, landet ein erfasster Ist-Satz für 2031 in **allen** Szenarien zwingend auf demselben Planjahr vorher war das nicht garantiert. Das **Wiederherstellen einer Szenario-Version** setzt folgerichtig nur noch das Szenario-Eigene zurück; plan-weite Angaben über eine Version *eines* Szenarios zu überschreiben, hätte die übrigen stillschweigend mitverändert. Der Profil-Dialog kennzeichnet neu je Feld, ob es **plan-weit** oder **nur dieses Szenario** gilt. Die Migration übernimmt die Werte aus dem **Basisszenario**; zwei neue Tests spielen dafür echte V6-Daten ein und prüfen die Übernahme inkl. abweichender Nebenszenarien (210 → 212). |
@@ -970,8 +971,9 @@ analog zur Monte-Carlo-Simulation:
4. **Ergebnis** Basisfall, Tornado-Chart und Tabelle.
Der Dialog ist bewusst **nicht** Teil des Grafiken-Bereichs: Dieser ist reine Ausgabe, während der
Tornado Pflichteingaben braucht. Ein Hinweis am Ende der Parameterliste benennt, warum das
**Pensionsalter** nicht enthalten ist (siehe [9.18](#918-tornado-was-der-chart-nicht-leistet)).
Tornado Pflichteingaben braucht. Seit 0.26 gehört auch das **Pensionsalter** je Person zu den
Treibern es erscheint nur, wenn die Phasengrenze überhaupt Spielraum hat
(siehe [9.18](#918-tornado-was-der-chart-nicht-leistet) und [4.16](#416-pensionsalter-verschieben)).
Referenz: `src/components/SensitivityDialog.tsx`, `src/components/AppShell.tsx`.
@@ -1651,6 +1653,107 @@ das, was eingefroren wird, dasselbe, was geprüft wird.
Referenz: `src/lib/report.ts`, `src/lib/report-pdf.ts`, `src/lib/decisions.ts`,
`src/components/ReportsView.tsx`.
## 3.12 Pensionsalter anpassen
> Roadmap Nr. 44. Bedienung im Szenario-Profil, Abschnitt «Pensionsalter anpassen».
### 3.12.1 Warum das kein Zahlenfeld ist
Das Pensionsalter bestimmt, **wo eine Lebensphase endet**: `maxPhaseDuration` kappt jede
Phasendauer beim nächsten Pensionierungsereignis, jede Pensionierung liegt deshalb zwangsläufig
auf einer Phasengrenze. Ein frei änderbares Alter hätte diese Zusammengehörigkeit zerrissen
die Phasen blieben, wo sie sind, und der Phasentyp (Erwerb/Misch/Pension) käme mitten in einer
Phase ins Rutschen.
Deshalb ändert die Bedienung nicht das Alter, sondern verschiebt **die Grenze**:
| | vorher | nachher (4 Jahre) |
|---|---|---|
| Phase 3 «Misch» | 10 Jahre | **6 Jahre** |
| Phase 4 «Pension» | 15 Jahre | **19 Jahre** |
| Gesamtdauer | 60 Jahre | 60 Jahre |
Die Gesamtdauer des Plans bleibt immer gleich es wird Zeit umverteilt, nicht hinzugefügt.
### 3.12.2 Spielraum und Sperren
Angezeigt wird je Person das aktuelle Pensionsalter, Knöpfe für ±1 Jahr, Kurzwahl-Chips für
grössere Schritte und eine **Hilfebox**, die den Spielraum benennt. Ein deaktivierter Knopf
ohne Erklärung wirkt wie ein Fehler; die Box sagt deshalb immer, was möglich ist **und warum
nicht mehr**.
Der Spielraum ist `(Dauer der Vorphase 1)` bis `+(Dauer der Folgephase 1)`: Eine
Lebensphase muss mindestens ein Jahr dauern.
Gesperrt wird ganz, wenn:
| Situation | Meldung |
|---|---|
| Person ist bei Planbeginn schon pensioniert | es gibt keine Grenze zu verschieben |
| Pensionierung liegt am oder nach dem Planende | zuerst eine Lebensphase anhängen |
| Pensionierung liegt nicht auf einer Phasengrenze | zuerst die Lebensphasen anpassen (Altdaten) |
| **Beide Personen teilen dieselbe Grenze** | zuerst eine Lebensphase einfügen, um sie zu trennen |
Der letzte Fall ist der Kehrseite der Zusammenlegung: Sind zwei Pensionierungen auf demselben
Zeitpunkt, liesse sich die eine nicht bewegen, ohne die andere mitzunehmen.
### 3.12.3 Zusammenlegung: wenn eine Lebensphase entfällt
Genau ein Jahr über den Spielraum hinaus fällt die angrenzende Phase auf 0 und **verschwindet**.
Das ist erlaubt es ist der Fall «beide werden gleichzeitig pensioniert, die Mischphase gibt es
nicht mehr» , wird aber **vorher bestätigt**, weil dabei zwei Übergänge zu einem werden.
Regel für die Zusammenführung (überlebende Grenze ist die des **Vorgängers** der entfallenden
Phase):
| | Regel | Begründung |
|---|---|---|
| Element-Entscheide | Was am überlebenden Übergang schon entschieden ist, **bleibt**. Nur leere Felder werden aus dem entfallenden ergänzt. | Ein bewusster Entscheid darf nie von einem anderen überschrieben werden. |
| Einmalige Cash-Beträge | werden **addiert** | Beide Ereignisse finden weiterhin statt, nur zum selben Zeitpunkt. |
| Steuersatz auf dem Zufluss | **betragsgewichteter** Mischsatz | Nur so bleibt der Netto-Zufluss derselbe wie vorher. |
| Bezeichnungen | mit « + » verbunden | damit nachvollziehbar bleibt, woraus die Summe entstand |
Die Verschiebung ist **nicht** über die Undo-Funktion rückgängig zu machen sie erzeugt wie
jede Änderung eine neue Nebenversion, aus der sich der alte Stand wiederherstellen lässt
([3.8](#38-versionierung-und-änderungshistorie)).
### 3.12.4 Punkt A: «Aus Vorphase übernehmen»
Wiederkehr-Parameter Teuerungsausgleich, erwartete Rendite, jährliche Einzahlung, Bezugsrate,
Amortisation, Wertsteigerung, Hypothekarzins, Tilgung galten bisher als **Kopie**: Beim
Anlegen einer Phase wurde der Wert der Vorphase hineingeschrieben. Änderte man die Vorphase
später, blieb die Kopie stehen.
Neu ist das Feld in Folgephasen standardmässig **leer** und wird live geerbt; darunter steht ein
angehaktes Kästchen **«Aus Vorphase übernehmen»** mit dem geerbten Wert daneben. Ein Häkchen
weg macht das Feld editierbar und den Wert phasen-eigen.
Nicht vererbt werden bewusst: **Ausfalljahre** (sie gelten für genau eine Phase ein geerbter
Wert würde eine Lücke erfinden), der **Kaufpreis** einer Immobilie (eine Tatsache, keine
Annahme) und die **Zins-Behandlung** (ein Schalter ohne Zahlenwert).
Bestehende Pläne verhalten sich unverändert: Dort sind die Werte gespeichert und gewinnen daher
gegen die Vererbung, bis man das Häkchen aktiv setzt.
### 3.12.5 Punkt C: Verwendung des Kapitalzuflusses
Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a,
Verkaufserlös). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Im
Cash-Übergang lässt sich deshalb erfassen, wie viel **in Prozent** in die Amortisation der
Hypothek und in eine Anlage fliesst; der Rest bleibt Cash.
**Warum Prozent und nicht Franken:** Verschiebt man das Pensionsalter, ändert sich das bezogene
Kapital. Ein Frankenbetrag müsste von Hand nachgezogen werden und würde bis dahin still eine
falsche Aufteilung rechnen. Eine Quote skaliert mit.
Die Amortisations-Quote ist am Restsaldo der Hypothek gekappt; ist sie grösser, bleibt der Rest
Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive).
Beide sind mechanisch nichts Neues die eine wirkt wie eine Sonderamortisation, die andere wie
eine Zusatzinvestition, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken.
Referenz: `src/lib/retirement.ts`, `src/components/RetirementAdjuster.tsx`,
`src/components/FormField.tsx` (`InheritableField`).
---
# 4. Berechnungsmodell
@@ -1857,11 +1960,58 @@ falls (renteA + renteB) > cap:
Die Rente ist danach **nominal fix** sie wird über die Phasen hinweg nicht indexiert und
verliert damit real an Kaufkraft (siehe [9.11](#911-ahv-rente-wird-nach-der-pensionierung-nicht-indexiert)).
Sie fliesst in `renteTotal` und wird im Einkommen mitgeführt.
**Wann** sie fliesst, entscheidet seit 0.26 nicht mehr der Phasentyp, sondern das Alter im
jeweiligen Jahr siehe 4.4.7.
Referenz: `src/lib/calculations.ts` (`ahvMonthlyFullPension`, `ahvMdje`, `ahvAnnualPension`),
`src/lib/constants.ts`.
### 4.4.7 Referenzalter: Die Rente beginnt mit 65, nicht mit der Pensionierung
Bis Version 0.25 galt implizit «pensioniert = Rente». Sobald sich das Pensionsalter verschieben
lässt ([4.16](#416-pensionsalter-verschieben)), ist das falsch: Die AHV-Rente hängt am
**Referenzalter** (`AHV_REFERENCE_AGE = 65`), nicht daran, wann jemand aufhört zu arbeiten.
Daraus folgen drei Fälle:
| Fall | Was passiert |
|---|---|
| Pensionierung **mit 65** | unverändert die Rente fliesst ab Beginn der Pensionsphase |
| Pensionierung **nach 65** | Die Erwerbsphase deckt das Referenzalter ab. Die Rente fliesst ab 65 **zusätzlich zum Lohn**. Erfasst werden nur noch die Ausfalljahre bis 65. |
| Pensionierung **vor 65** | Die Pensionsphase beginnt vor 65. Bis dahin ist die Person **beitragspflichtig als Nichterwerbstätige(r)**; der Beitrag (`ahvContribution`) ist eine laufende Ausgabe. Mit 65 fällt er weg und die Rente setzt ein. |
Beide Wechsel können **innerhalb derselben Lebensphase** stattfinden. Die AHV wird deshalb
**jahresweise** ausgewertet statt als Phasenkonstante:
```
für jedes Jahr t der Phase:
alterImJahr = alterZuPhasenbeginn + t 1
alterImJahr ≥ 65 → Rente fliesst (Einkommen)
sonst, wenn pensioniert → Beitrag fällt an (Ausgabe, wirkt auf die Verzehrquote)
sonst → nichts
```
Der Verlaufspunkt des AHV-Elements zeigt den Beitrag als **negativen** Wert so ist in der
Grafik zu sehen, dass die AHV in diesen Jahren Geld kostet, statt welches zu bringen.
**Ein Aufschub der Rente wird nicht abgebildet.** Wer über 65 hinaus arbeitet, könnte den Bezug
aufschieben und erhielte dafür einen Zuschlag. Das Tool lässt die Rente stattdessen fliessen
die vorsichtigere Annahme, und eine, die keinen zusätzlichen Entscheid verlangt.
**Der Beitrag als Nichterwerbstätige(r)** bemisst sich am Vermögen und am Renteneinkommen, nicht
am Lohn. Die Bandbreite ist entsprechend enorm: vom Mindestbeitrag von rund **530 CHF/Jahr** bis
zum Höchstbeitrag von rund **26'500 CHF/Jahr**. Deshalb gibt es hier **keinen Default** ein
stiller Vorschlag würde nicht hinterfragt (vgl. [9.15](#915-defaults-für-annahmen-sind-gefährlich)).
Der Hilfetext nennt die Bandbreite ausdrücklich, damit auch jemand ohne Vorwissen ein Gefühl für
die Grössenordnung bekommt.
**Beitragsjahre:** Wer den Beitrag zahlt, hat keine Ausfalljahre die Rentenskala bleibt
unberührt. Das Modell kürzt die Rente bei Frühpensionierung deshalb nicht; eine Kürzung entsteht
nur, wenn man die Jahre ausdrücklich als Ausfalljahre erfasst.
Referenz: `src/lib/calculations.ts` (`ahvItems` in der Jahresschleife), `AHV_REFERENCE_AGE` in
`src/lib/constants.ts`.
## 4.5 Nominal, real und die Deflatoren
### 4.5.1 Das V5-Modell
@@ -1905,11 +2055,16 @@ Vorab-Abbruch: Ist der Carry-Status `SOLD` → Notiz „Verkauft"; ist er `SETTL
### 4.6.1 INCOME / EXPENSE
```
idx = phaseData.teuerungsausgleich ?? 0
idx = phaseData.teuerungsausgleich ?? carry.rates.teuerungsausgleich ?? 0 // Punkt A
baseValue = hasCarry ? round(carry.flowBasis) : round(phaseData.amount)
basis = !hasCarry → round(phaseData.amount)
phaseData.amount ist Zahl → round(phaseData.amount) // bewusster Override
sonst → baseValue // live vererbt
// Punkt B: Besitzer ist eine PERSON und in dieser Phase pensioniert,
// und es ist kein Betrag erfasst → basis = 0
// NACH der Jahresschleife (siehe 4.16.6):
carry.flowBasis = basis × (1 + idx/100)^duration // für die Folgephase
```
@@ -1921,10 +2076,17 @@ durch. Nur ein bewusst abweichender Wert wird fix gespeichert.
Man beachte den Exponenten-Unterschied: der **Endwert** der Phase nutzt `duration 1`
(letztes Jahr), der **Carry** für die Folgephase nutzt `duration` (ein Jahr weiter).
Die Fortschreibung passiert seit 0.26 **nach** der Jahresschleife sonst kämen effektive Werte
nie in der Folgephase an ([4.16.6](#4166-fehlerbehebung-fortgeschriebener-basiswert-und-effektive-werte)).
### 4.6.2 AHV
Erwerbstätig → nur Zusammenfassung („N Ausfalljahre" / „Keine Ausfalljahre"), kein Wert.
Pensioniert → `startValue = endValue = rente`, Summand in `renteTotal`.
Seit 0.26 **jahresweise** statt phasenweise ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)):
Das Element meldet Rente, Beitrag und Startalter des Besitzers an die Jahresschleife an, die je
Jahr entscheidet, was fliesst. `startValue` / `endValue` zeigen den Stand im ersten bzw. letzten
Jahr der Phase; die Zusammenfassung nennt beide Zustände, wenn das Referenzalter mitten in der
Phase liegt («Beitrag … → Rente …»). Die Rente läuft **nicht** mehr über `renteTotal`, weil sie
innerhalb einer Phase einsetzen kann.
### 4.6.3 PENSION_FUND
@@ -2258,6 +2420,7 @@ Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bunde
| `AHV_GROSS_FROM_NET_FACTOR` | 1.12 | Netto → Brutto für die AHV; Herleitung siehe [4.4.5](#445-netto-brutto-umrechnung-für-die-ahv) |
| `AHV_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente |
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
| `AHV_REFERENCE_AGE` | 65 | Referenzalter ab hier fliesst die Rente, **unabhängig vom Pensionsalter** ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) |
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
| `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz |
| `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer |
@@ -2526,6 +2689,7 @@ falsch zu werden:
| Einkommen | **relativ %** | skaliert `amount` aller `INCOME`-Elemente |
| Lohnentwicklung | **Δ Prozentpunkte** | verschiebt `teuerungsausgleich` der `INCOME`-Elemente |
| Wertsteigerung der Immobilie | **Δ Prozentpunkte** | verschiebt `valueGrowth` |
| Pensionsalter Person A / B | **Δ Jahre** | verschiebt die Phasengrenze der Pensionierung ([4.16](#416-pensionsalter-verschieben)) |
Die Begründungen im Einzelnen:
- **Absolut** nur bei der Inflation es gibt genau einen plan-weiten Wert.
@@ -2777,10 +2941,11 @@ Elemente einzeln um +1 pp zu heben ergibt exakt dasselbe Endvermögen wie der Sa
Frage, die man tatsächlich stellt; Ausgaben-Elemente sind typischerweise viele kleine Posten,
deren Einzelregler das Panel fluten würden, ohne eine bessere Frage zu ermöglichen.
**Das Pensionsalter fehlt weiterhin** aus demselben Grund wie beim Tornado
([9.18](#918-tornado-was-der-chart-nicht-leistet)): Es liesse sich nicht verschieben,
ohne die Phasengrenzen mitzuziehen. Ersatzweise gibt es **Lebensdauer** (letzte Phase
verlängern/verkürzen), was das Langlebigkeitsrisiko abdeckt, nicht aber die Frühpensionierung.
**Das Pensionsalter ist seit 0.26 als Regler dabei** (je Person einer, sofern überhaupt
Spielraum besteht). Sein Bereich ist **plan-abhängig**: Er endet dort, wo eine angrenzende
Lebensphase unter ein Jahr fiele ein Regler, der stumm an seiner Grenze klebt, wäre schlechter
als keiner. Der Treiber **Lebensdauer** bleibt daneben bestehen; er beantwortet die andere Frage
(wie lange muss es reichen, statt wann höre ich auf).
### 4.15.3 Referenz und Kennzahlen
@@ -2821,6 +2986,96 @@ echtes Szenario übertragen lässt.
Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
`tunableElements`), `src/components/LiveSimDialog.tsx`, `src/components/AllocationChart.tsx`.
## 4.16 Pensionsalter verschieben
> Modul `src/lib/retirement.ts`. Rein: Es entscheidet nur, **was** geschehen soll; das
> Schreiben übernimmt der Aufrufer (Panel: API-Aufrufe; Tornado/Live-Simulation: reine
> Plan-Kopie).
### 4.16.1 Die tragende Invariante
`maxPhaseDuration(persons, yearsBefore)` kappt jede Phasendauer beim nächsten
Pensionierungsereignis. Daraus folgt: **Jede Pensionierung liegt auf einer Phasengrenze.** Genau
das macht die Anpassung überhaupt erst möglich «Pensionsalter ändern» ist gleichbedeutend mit
«diese eine Grenze verschieben».
`retirementBoundaries(plan)` liefert je Person:
| Feld | Bedeutung |
|---|---|
| `planYear` | Planjahr der Pensionierung (`retirementAge age`) |
| `phaseIndex` | Index der Phase **vor** der Grenze |
| `minDelta` / `maxDelta` | Spielraum, ohne dass eine Nachbarphase unter 1 Jahr fällt |
| `mergeDeltaDown` / `mergeDeltaUp` | genau der Wert, bei dem eine Phase entfällt |
| `blocked` | Erklärtext, wenn gar keine Anpassung möglich ist (siehe 3.12.2) |
### 4.16.2 `shiftRetirement(plan, role, delta)`
```
Phase[i].dauer += delta // vor der Grenze
Phase[i+1].dauer = delta // nach der Grenze
person.retirementAge += delta
```
Fällt eine der beiden auf 0, wird sie entfernt und die `sequenceNumber` lückenlos neu vergeben.
Die Übergangsdaten der entfallenden Phase werden nach der Regel aus 3.12.3 in den Vorgänger
gezogen (`mergeTransition`, `mergeCashTransition`). Ausserhalb des erlaubten Bereichs liefert
die Funktion `null` sie klemmt nicht still.
### 4.16.3 Als Treiber und Regler
`applyDriver(plan, "retirementA" | "retirementB", jahre)` benutzt dieselbe Funktion. Zwei
bewusste Abweichungen gegenüber der Bedienung:
* Der Bereich endet bei `minDelta`/`maxDelta`, **ohne** Zusammenlegung. Eine Zusammenlegung
verändert den Plan inhaltlich (zwei Übergänge werden einer) dafür ist eine
Was-wäre-wenn-Betrachtung der falsche Ort.
* Eine zu weite Eingabe wird auf das Mögliche **gekürzt** statt verworfen: Eine Bandbreite von
±5 Jahren soll auch dann etwas zeigen, wenn nur ±2 möglich sind. Bleibt gar kein Spielraum,
erklärt `ineffectiveReason` den Nullbalken.
Der Hebel ist doppelt länger Einkommen **und** kürzer Verzehr und deshalb meist einer der
grössten im Tornado.
### 4.16.4 Punkt B: Einkommen endet mit der Pensionierung
Bis 0.25 lief ein Erwerbseinkommen stillschweigend in die Pensionsphase weiter (der Basiswert
wird ja fortgeschrieben). Bei fixem Pensionsalter fiel das kaum auf; sobald sich die Grenze
verschieben lässt, ist es ein handfester Fehler «drei Jahre früher aufhören» hätte sonst gar
keine Wirkung gehabt.
```
Kategorie INCOME, Element ist EINER PERSON zugeordnet,
Person in dieser Phase pensioniert, kein ausdrücklicher Betrag erfasst
→ Basiswert = 0
```
Drei bewusste Einschränkungen:
* **Nur personenzugeordnete Einkommen.** «Gemeinsam» (Mieterträge, Ausschüttungen) hängt nicht
an der Erwerbstätigkeit einer Person und läuft weiter.
* **Ein ausdrücklich erfasster Betrag gewinnt.** Sonst liesse sich ein Teilzeitpensum oder eine
Überbrückungsrente nach der Pensionierung nicht abbilden.
* Das Element zeigt in dieser Phase den Hinweis, dass es wegen der Pensionierung auf 0 steht.
### 4.16.5 Vererbte Wiederkehr-Parameter (Punkt A)
Der `Carry` führt neu eine Tabelle `rates` mit den zuletzt verwendeten Werten. Ist ein Feld in
einer Phase nicht gesetzt, gilt der Wert aus der Vorphase; der verwendete Wert wird wieder
abgelegt, sodass die Kette über beliebig viele Phasen trägt. Betroffen sind
`teuerungsausgleich`, `expectedReturn`, `annualContribution`, `annualWithdrawal`,
`amortization`, `valueGrowth`, `interestRate` und `annualRepayment`.
### 4.16.6 Fehlerbehebung: fortgeschriebener Basiswert und effektive Werte
Der Basiswert der Folgephase (`carry.flowBasis`) wurde beim **Element-Setup** berechnet, also
**vor** der Jahresschleife. Ein effektiver Wert setzt den Basiswert aber erst **in** der
Jahresschleife neu (`rebaseFlow`). Folge: Ein für 2031 erfasster Lohn wirkte bis zum Ende seiner
Phase und fiel an der Phasengrenze stillschweigend auf den geplanten Wert zurück.
Die Fortschreibung passiert neu **nach** der Jahresschleife, gemeinsam mit den Endwerten. Zwei
Regressionstests in `actuals.test.ts` halten den Fall fest.
---
# 5. Technische Spezifikation
@@ -2905,6 +3160,8 @@ PlanComputed ← an den Client geliefert
| `versioning-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. |
| `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. |
| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. |
| `retirement.ts` | Pensionsalter verschieben: Spielraum je Person, Verschiebung der Phasengrenze, Zusammenlegung zweier Übergänge (Kap. 4.16). Rein, ohne I/O. |
| `transitions.ts` | Reine Übergangs-Regeln (Vorbelegung, «beantwortet?», Cash-Zusammenfassung). Liegt hier und nicht in einer Komponente, weil auch der Server sie braucht -- ein Import aus `src/components` bricht erst in der Produktion. |
| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. |
| `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand Grundlage der Systemparameter-Ansicht |
| `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) |
@@ -3041,6 +3298,7 @@ Referenz: `prisma/schema.prisma` Zeilen 46, `src/lib/elements.ts` Zeilen 48
| `amount` | INCOME, EXPENSE | ≥ 0 |
| `teuerungsausgleich` | INCOME, EXPENSE | 20 bis 50 |
| `gapYears` | AHV | Integer ≥ 0 |
| `ahvContribution` | AHV | ≥ 0 Beitrag als Nichterwerbstätige(r) bis zum Referenzalter ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) |
| `avgIncomeBefore` | AHV nur wenn bei Planbeginn **bereits pensioniert** | ≥ 0, **real** |
| `gapYearsBefore` | AHV dito | Integer 050 |
| `currentValue` | PENSION_FUND, PILLAR_3A | ≥ 0 |
@@ -3057,6 +3315,11 @@ Referenz: `prisma/schema.prisma` Zeilen 46, `src/lib/elements.ts` Zeilen 48
| `valueGrowth` | REAL_ESTATE Wertsteigerung %/Jahr auf die Liegenschaft | 20 bis 20 |
| `annualRepayment` | OTHER_DEBT | ≥ 0 |
**Vererbbare Felder (Punkt A, Kap. 3.12.4):** `teuerungsausgleich`, `expectedReturn`,
`annualContribution`, `annualWithdrawal`, `amortization`, `valueGrowth`, `interestRate` und
`annualRepayment` sind ab Phase 2 in der Regel **nicht gesetzt** die Berechnung übernimmt dann
den Wert der Vorphase. Alle übrigen Felder bedeuten «nicht gesetzt = 0» wie bisher.
### 5.4.4 JSON-Payload `TransitionData`
| Feld | Kategorien | Zod-Regel |
@@ -3089,6 +3352,9 @@ Liegt in `Phase.cashTransition`. Validierung über `cashTransitionSchema`.
| `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0100 |
| `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen |
| `outflowAmount` | Betrag **real** (heutige Kaufkraft) | ≥ 0 |
| `capitalUseAmortizationPct` | Anteil des Kapitalzuflusses in die Amortisation (Kap. 3.12.5) | 0100 |
| `capitalUseInvestPct` | Anteil des Kapitalzuflusses in eine Anlage | 0100 |
| `capitalUseTargetElementId` | Ziel der Anlage-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen |
Pro Übergang ist **genau ein** Zufluss und **eine** Kostenposition möglich siehe
[9.7](#97-nur-ein-zufluss-und-eine-kostenposition-pro-übergang).
@@ -3318,6 +3584,20 @@ Liefert Eingabe, Berechnung **und die Vergleichsbasis** in einem Zug:
`base` ist das Eltern-Szenario (null beim Basisszenario) daraus rechnet der Client den Diff.
Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd.
### `POST /api/scenarios/<scenarioId>/retirement`
Verschiebt das Pensionsalter einer Person und damit die zugehörige Phasengrenze
([3.12](#312-pensionsalter-anpassen)):
```json
{ "role": "PERSON_A", "delta": -4, "confirmMerge": false }
```
Bewusst kein `PATCH` auf `retirementAge`: Die Änderung betrifft immer **zwei** Phasendauern
gleichzeitig und kann eine Phase entfallen lassen.
→ 200 `{ ok, removedPhaseId, retirementAge }` ·
→ 400 mit Erklärtext, wenn gesperrt oder ausserhalb des Spielraums ·
**409** `{ needsMergeConfirmation: true, removedPhaseId, removedPhaseName, mergedIntoPhaseId }`,
wenn dabei eine Lebensphase entfiele und `confirmMerge` nicht gesetzt ist. Der Client fragt
vorher selbst (er kennt den Plan), der 409 ist die serverseitige Absicherung.
### `PATCH /api/scenarios/<scenarioId>`
Akzeptiert eine **Union** von zwei Formen:
1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` ersetzt
@@ -3467,12 +3747,12 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| Datei | Tests | Schwerpunkt |
|---|---|---|
| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" |
| `sensitivity.test.ts` | 15 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber |
| `calculations.test.ts` | 53 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, AHV-Referenzalter (Beitrag vor 65, Rente ab 65), Einkommen endet mit der Pensionierung, Vererbung der Wiederkehr-Parameter, „V5 Golden Tests" |
| `sensitivity.test.ts` | 20 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber, Pensionsalter als Treiber |
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
| `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % |
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
| `actuals.test.ts` | 22 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung |
| `actuals.test.ts` | 24 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung, Wirkung über die Phasengrenze hinaus |
| `dataview.test.ts` | 7 | beide Sichten in einem Zug, Rückfall ohne Ist-Werte, Abweichungsmessung |
| `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen |
| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
@@ -3480,10 +3760,12 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei |
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen |
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
| `bridges.test.ts` | 13 | Vermögens- und Cash-Brücke gehen über acht Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus; Kapitalverwendung nach Quote (Punkt C) |
| `retirement.test.ts` | 16 | Spielraum und Sperren je Person, Verschiebung ohne Änderung der Gesamtdauer, Wegfall einer Phase, Zusammenführung der Übergangs-Entscheide |
| `server-boundary.test.ts` | 1 | statischer Wächter: kein Modul unter `src/lib` importiert aus `src/components` |
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
| **Total** | **221** | |
| **Total** | **261** | |
## 8.2 Testfälle
@@ -3768,25 +4050,25 @@ Aussagekräftig ist die **Reihenfolge**, nicht der absolute Betrag.
härter als die Summe der Einzelbalken weil in der Folge Kapital verzehrt wird, das später zur
Verzinsung fehlt. Für Kombinationen ist die Monte-Carlo-Simulation zuständig.
**Das Pensionsalter ist bewusst nicht enthalten.** Die Roadmap nennt es als Top-Hebel, aber
`retirementAge` lässt sich im aktuellen Datenmodell nicht isoliert variieren, ohne die
Phasengrenzen mitzuverschieben und das Ergebnis wäre nicht ungenau, sondern **irreführend**.
**Das Pensionsalter ist seit 0.26 enthalten** zuvor bewusst nicht, und die Begründung von
damals erklärt, warum die heutige Umsetzung so aussieht, wie sie aussieht.
Ein isoliert verändertes `retirementAge` wäre nicht ungenau gewesen, sondern **irreführend**.
Beispiel: Person 45, Pension mit 65, Phase 1 = 20 Jahre (45→65), Phase 2 = 20 Jahre.
- **Pensionsalter auf 62:** Phase 1 beginnt mit 45, also `45 < 62` → die Phase bleibt vollständig
Erwerbsphase. Die Person arbeitet im Modell weiterhin bis 65, der Pensions-Übergang liegt an
derselben Grenze. Wirkung auf das Ergebnis: **praktisch null.**
- **Pensionsalter auf 68:** Phase 2 beginnt mit 65, also `65 < 68` → Phase 2 wird zur
**Erwerbsphase**, das Einkommen läuft weiter. Zugleich wird `ownerRetiresNext` an der Grenze
nach Phase 1 falsch (65 ≥ 68 trifft nicht zu), womit der **Pensions-Übergang komplett entfällt**:
keine PK-Verrentung, kein 3a-Bezug, keine AHV-Rente. Der Balken wäre riesig er misst aber den
Ausfall der Vorsorgelogik, nicht „drei Jahre länger arbeiten".
- **Auf 62 gesetzt:** Phase 1 beginnt mit 45, also `45 < 62` → die Phase bliebe vollständig
Erwerbsphase. Die Person arbeitete im Modell weiterhin bis 65. Wirkung: **praktisch null.**
- **Auf 68 gesetzt:** Phase 2 beginnt mit 65, also `65 < 68` → Phase 2 würde zur Erwerbsphase.
Zugleich wäre `ownerRetiresNext` an der Grenze nach Phase 1 falsch, womit der
**Pensions-Übergang komplett entfiele**: keine PK-Verrentung, kein 3a-Bezug. Der Balken wäre
riesig er misst aber den Ausfall der Vorsorgelogik, nicht „drei Jahre länger arbeiten".
Fachlich korrekt wäre nur, `retirementAge` **und** die Phasengrenze gemeinsam zu verschieben
(Erwerbsphase kürzer, Pensionsphase länger). Das hat eigene Sonderfälle Paare mit
unterschiedlichem Pensionsalter, Grenzen abseits des Pensionsereignisses, Verschiebung grösser als
die Phasendauer und ist als eigener Arbeitsschritt offen. Der verwandte Treiber **Lebensdauer**
(Dauer der letzten Phase) ist dagegen sauber abgebildet und deckt einen Teil des Bedürfnisses ab.
Der Treiber verschiebt deshalb `retirementAge` **und** die Phasengrenze gemeinsam
([4.16](#416-pensionsalter-verschieben)). Zwei Eigenheiten bleiben und sind im Dialog benannt:
Der Spielraum endet bei den angrenzenden Phasendauern (eine **Zusammenlegung** findet im Tornado
bewusst nicht statt), und eine zu weite Bandbreite wird auf das Mögliche gekürzt statt verworfen.
Der verwandte Treiber **Lebensdauer** (Dauer der letzten Phase) bleibt daneben bestehen er
beantwortet die andere Frage: nicht „wann höre ich auf", sondern „wie lange muss es reichen".
## 9.19 Simulationsparameter werden nicht gespeichert
@@ -79,33 +79,28 @@ export async function POST(
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
}
// Vorbelegung für eine neue Phase: nur die editierbaren Felder werden übernommen. Start-
// bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden in der Berechnung live aus der
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
// Vorbelegung für eine neue Phase.
//
// Seit Roadmap Nr. 44 (Punkt A) werden die WIEDERKEHR-Parameter (Raten, Beiträge, Amortisation,
// Wertsteigerung, Zinssatz) NICHT mehr als Kopie gespeichert, sondern bewusst leer gelassen:
// Die Berechnung übernimmt sie live aus der Vorphase, und im UI steht dafür sichtbar
// «Aus Vorphase übernehmen» -- angehakt. Der Unterschied zeigt sich, sobald man die Vorphase
// später ändert: Eine Kopie bliebe stehen, die Vererbung zieht mit.
//
// Start- bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden ohnehin live fortgeschrieben.
function buildCarryData(category: string, prev: PhaseData): PhaseData {
switch (category) {
case "INCOME":
case "EXPENSE":
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird übernommen.
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
case "AHV":
// Ausfalljahre sind KEIN Wiederkehr-Parameter: Sie gelten für genau diese Phase, und
// ein geerbter Wert würde eine Lücke erfinden, die es nicht gibt.
return { gapYears: 0 };
case "PENSION_FUND":
case "PILLAR_3A":
case "OTHER_ASSET":
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
case "REAL_ESTATE":
// purchasePrice + amortization bleiben; Resthypothek und Verkehrswert werden live
// fortgeschrieben. Zinssatz, Zins-Behandlung und Wertsteigerung gelten weiter.
// Der Kaufpreis ist eine Tatsache, keine Annahme (Basis der Grundstückgewinnsteuer);
// die Zins-Behandlung ist ein Schalter ohne Zahlenwert -- beide werden weiterhin kopiert.
return {
purchasePrice: num(prev.purchasePrice),
amortization: num(prev.amortization),
interestRate: num(prev.interestRate),
interestHandling: prev.interestHandling ?? "INCLUDED",
valueGrowth: num(prev.valueGrowth),
};
case "OTHER_DEBT":
return { annualRepayment: num(prev.annualRepayment) };
default:
return {};
}
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { Prisma } from "@/generated/prisma/client";
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
// Pensionsalter anpassen (Roadmap Nr. 44).
//
// Das ist bewusst KEIN einfaches PATCH auf `retirementAge`: Eine Pensionierung liegt immer
// auf einer Phasengrenze, also verschiebt jede Änderung zwei Phasendauern gleichzeitig --
// und kann eine Phase ganz entfallen lassen. Die Entscheidung darüber trifft `lib/retirement`
// (rein und getestet), hier wird sie nur noch geschrieben.
const bodySchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]),
delta: z.number().int().min(-80).max(80),
// Muss der Aufrufer ausdrücklich setzen, wenn dabei eine Lebensphase wegfällt.
confirmMerge: z.boolean().optional(),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { role, delta, confirmMerge } = parsed.data;
const planInput = toPlanInput(scenario);
const boundary = retirementBoundaries(planInput).find((b) => b.role === role);
if (!boundary) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
if (boundary.blocked) return NextResponse.json({ error: boundary.blocked }, { status: 400 });
if (delta < (boundary.mergeDeltaDown ?? boundary.minDelta) || delta > (boundary.mergeDeltaUp ?? boundary.maxDelta)) {
return NextResponse.json(
{ error: "So weit lässt sich das Pensionsalter hier nicht verschieben. Passe zuerst die Lebensphasen an." },
{ status: 400 }
);
}
const result = shiftRetirement(planInput, role, delta);
if (!result) return NextResponse.json({ error: "Die Verschiebung ist hier nicht möglich." }, { status: 400 });
// Eine Zusammenlegung verändert den Plan inhaltlich (zwei Übergänge werden einer) und muss
// deshalb vom Aufrufer bestätigt sein. 409 = "verstanden, aber so nicht ohne Zustimmung".
if (result.removedPhaseId && !confirmMerge) {
const removed = planInput.phases.find((p) => p.id === result.removedPhaseId);
return NextResponse.json(
{
error: "Bestätigung erforderlich.",
needsMergeConfirmation: true,
removedPhaseId: result.removedPhaseId,
removedPhaseName: removed?.name ?? "",
mergedIntoPhaseId: result.mergedIntoPhaseId,
},
{ status: 409 }
);
}
await prisma.$transaction(async (tx) => {
await tx.person.updateMany({
where: { scenarioId: scenario.id, role },
data: { retirementAge: boundary.retirementAge + delta },
});
if (result.removedPhaseId) {
// Die Übergangsdaten der entfallenden Phase wurden in den Vorgänger gezogen; hier wird
// nur noch der zusammengeführte Stand geschrieben, bevor die Phase verschwindet.
if (result.mergedIntoPhaseId) {
const target = result.plan.phases.find((p) => p.id === result.mergedIntoPhaseId);
if (target) {
await tx.phase.update({
where: { id: target.id },
data: { cashTransition: (target.cashTransition ?? {}) as Prisma.InputJsonValue },
});
}
for (const e of result.plan.elements) {
const td = e.transitionValues?.[result.mergedIntoPhaseId];
if (!td) continue;
await tx.elementTransitionValue.upsert({
where: { elementId_fromPhaseId: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId } },
create: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId, data: td as Prisma.InputJsonValue },
update: { data: td as Prisma.InputJsonValue },
});
}
}
// Löscht die Phase samt ihrer Phasen- und Übergangswerte (Kaskade im Schema).
await tx.phase.delete({ where: { id: result.removedPhaseId } });
}
for (const p of result.plan.phases) {
await tx.phase.update({
where: { id: p.id },
data: { durationYears: p.durationYears, sequenceNumber: p.sequenceNumber },
});
}
});
await touchScenario(scenario.id, userId);
return NextResponse.json({
ok: true,
removedPhaseId: result.removedPhaseId,
retirementAge: boundary.retirementAge + delta,
});
}
+253 -67
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client";
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
@@ -24,8 +24,9 @@ import {
type RateScope,
} from "@/lib/ratefields";
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
import { CATEGORY_LABELS, num } from "@/lib/elements";
import { CATEGORY_LABELS, num, type InheritableKey } from "@/lib/elements";
import {
AHV_REFERENCE_AGE,
DEFAULT_CAPITAL_TAX_RATE,
DEFAULT_PK_CONVERSION_RATE,
DEFAULT_PROPERTY_GAINS_TAX_RATE,
@@ -59,6 +60,12 @@ export interface CellContext {
laterPhaseCount: number;
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
ahvCareer: AhvCareer | null;
// Werte, die in dieser Phase gelten würden, wenn das jeweilige Feld leer bleibt
// (Roadmap Nr. 44, Punkt A). Leer in der ersten Phase.
inheritedValues: Record<string, number>;
// Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob
// das Referenzalter in diese Phase fällt (Kap. 4.4.6).
ownerAgeStart: number;
}
interface Props {
@@ -244,10 +251,18 @@ export function CashTransitionFields({
ct,
setC,
deflatorEnd,
// Roadmap Nr. 44, Punkt C: Nur am Pensions-Übergang gefragt -- dort kommt das Kapital
// (PK, 3a, Immobilienverkauf) auf einmal herein und will verwendet werden.
showCapitalUse = false,
capitalInflow = 0,
investTargets = [],
}: {
ct: CashTransitionData;
setC: (patch: Partial<CashTransitionData>) => void;
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
showCapitalUse?: boolean;
capitalInflow?: number;
investTargets?: { id: string; name: string }[];
}) {
const mode = ct.mode ?? "NONE";
const showIn = mode === "INFLOW" || mode === "BOTH";
@@ -347,6 +362,75 @@ export function CashTransitionFields({
/>
</>
)}
{showCapitalUse && <CapitalUseFields ct={ct} setC={setC} inflow={capitalInflow} targets={investTargets} />}
</>
);
}
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
//
// In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene
// Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin
// still eine falsche Aufteilung rechnen. Eine Quote skaliert mit.
function CapitalUseFields({
ct,
setC,
inflow,
targets,
}: {
ct: CashTransitionData;
setC: (patch: Partial<CashTransitionData>) => void;
inflow: number;
targets: { id: string; name: string }[];
}) {
const amort = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
const invest = Math.max(0, Math.min(100 - amort, num(ct.capitalUseInvestPct)));
const cash = Math.max(0, 100 - amort - invest);
return (
<>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<strong className="text-fg">Verwendung des Kapitalzuflusses</strong> (PK-Kapital, Säule 3a,
Verkaufserlös). Bei diesem Übergang fliessen{" "}
<strong className="text-fg">{formatChf(inflow)}</strong> netto herein. Erfasst wird die
Aufteilung in Prozent so bleibt sie richtig, wenn du das Pensionsalter verschiebst und
sich der Betrag dadurch ändert. Was nicht zugeteilt ist, bleibt Cash.
</div>
<NumberField
label="… in die Hypothek (Amortisation, %)"
help="Reduziert die Resthypothek der Immobilie einmalig. Am Restsaldo gekappt ist die Hypothek kleiner, bleibt der Rest Cash."
step={5}
min={0}
max={100}
value={amort}
onChange={(v) => setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
/>
<NumberField
label="… in die Anlage (%)"
help="Fliesst als Zusatzinvestition in das gewählte Vermögens-Element und wächst dort mit dessen Rendite weiter."
step={5}
min={0}
max={100 - amort}
value={invest}
onChange={(v) => setC({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
/>
{invest > 0 && targets.length > 0 && (
<div className="sm:col-span-2">
<SelectField
label="Ziel der Anlage-Quote"
help="Welches Vermögens-Element wird aufgestockt?"
value={ct.capitalUseTargetElementId ?? targets[0].id}
onChange={(v: string) => setC({ capitalUseTargetElementId: v })}
options={targets.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
)}
<DerivedField
label={`… bleibt Cash (${cash} %)`}
value={Math.round((inflow * cash) / 100)}
help="Der nicht zugeteilte Rest. Wird automatisch berechnet."
/>
</>
);
}
@@ -365,6 +449,35 @@ export function ElementPhaseFields({
setP: (patch: Partial<PhaseData>) => void;
}) {
const carried = context.carried;
// Punkt A (Roadmap Nr. 44): Wiederkehr-Parameter werden aus der Vorphase übernommen,
// solange das Feld leer bleibt. `inh` baut die Hülle mit dem sichtbaren Häkchen.
const inh = (
key: InheritableKey,
label: string,
render: (value: number, set: (v: number) => void) => React.ReactNode,
format: (v: number) => string,
help?: string
) => {
const own = pd[key];
const fallback = context.inheritedValues[key] ?? 0;
const isInheriting = carried && typeof own !== "number";
return (
<InheritableField
label={label}
help={help}
canInherit={carried}
isInheriting={isInheriting}
inheritedDisplay={format(fallback)}
onToggle={(inherit) => setP({ [key]: inherit ? undefined : fallback } as Partial<PhaseData>)}
>
{render(typeof own === "number" ? own : fallback, (v) => setP({ [key]: v } as Partial<PhaseData>))}
</InheritableField>
);
};
const asPct = (v: number) => `${v} %`;
const asChf = (v: number) => formatChf(v);
switch (element.category) {
case "INCOME":
case "EXPENSE": {
@@ -372,7 +485,6 @@ export function ElementPhaseFields({
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
const rate = num(pd.teuerungsausgleich, 0);
const d = context.deflatorStart || 1;
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
@@ -394,21 +506,36 @@ export function ElementPhaseFields({
value={otherValue}
help="Nur zur Info, wird automatisch berechnet."
/>
{inh(
"teuerungsausgleich",
isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)",
(v, set) => (
<NumberField
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
help={
step={0.1}
value={v}
onChange={set}
/>
),
asPct,
isIncome
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
}
step={0.1}
value={rate}
onChange={(v) => setP({ teuerungsausgleich: v })}
/>
)}
</>
);
}
case "AHV":
case "AHV": {
// Seit Roadmap Nr. 44 beginnt die AHV-Rente IMMER mit dem Referenzalter -- unabhängig
// davon, wann die Person aufhört zu arbeiten (Kap. 4.4.6). Daraus folgen zwei Fälle,
// die es vorher nicht gab und die hier sichtbar gemacht werden müssen:
// * Pensionierung VOR 65 -> beitragspflichtig als Nichterwerbstätige(r) bis 65.
// * Erwerbstätig ÜBER 65 -> die Rente fliesst trotzdem ab 65.
const ageStart = context.ownerAgeStart;
const ageEnd = ageStart + context.durationYears - 1;
const reachesReference = ageEnd >= AHV_REFERENCE_AGE;
const startsBeforeReference = ageStart < AHV_REFERENCE_AGE;
if (!context.ownerWorking) {
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang,
// an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase).
@@ -442,23 +569,61 @@ export function ElementPhaseFields({
);
}
return (
<>
<p className="col-span-2 text-sm text-muted">
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
Plafonierung auf 150% der Maximalrente.
Plafonierung auf 150% der Maximalrente. Sie fliesst ab Alter {AHV_REFERENCE_AGE}
{reachesReference && startsBeforeReference
? ` also erst im Verlauf dieser Lebensphase (Alter ${ageStart} bis ${ageEnd}).`
: "."}
</p>
{startsBeforeReference && (
<>
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<strong>Frühpensionierung:</strong> Wer vor Alter {AHV_REFERENCE_AGE} aufhört zu
arbeiten, bleibt bis dahin AHV-beitragspflichtig als Nichterwerbstätige(r). Der
Beitrag ist eine laufende Ausgabe und fällt mit {AHV_REFERENCE_AGE} weg, wenn die
Rente einsetzt.
</p>
<MoneyField
label="AHV-Beitrag als Nichterwerbstätige(r) (CHF/Jahr)"
help={
"Der Beitrag bemisst sich am Vermögen und am Renteneinkommen, nicht am Lohn die Bandbreite ist deshalb enorm: " +
"vom Mindestbeitrag von rund 530 CHF pro Jahr bis zum Höchstbeitrag von rund 26'500 CHF pro Jahr. " +
"Faustregel: Bis etwa 300'000 CHF Vermögen liegt man nahe am Minimum; ab rund 8.7 Mio. am Maximum. " +
"Verheiratete zahlen den Beitrag je einzeln, wenn beide nicht erwerbstätig sind; ist der Partner erwerbstätig " +
"und zahlt genug, entfällt er. Die AHV-Ausgleichskasse rechnet den Betrag auf Anfrage aus."
}
value={num(pd.ahvContribution)}
onChange={(v) => setP({ ahvContribution: v })}
/>
</>
)}
</>
);
}
return (
<>
<NumberField
label="Geplante Ausfalljahre"
help="Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44."
help={`Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44. Nur Jahre VOR dem Referenzalter ${AHV_REFERENCE_AGE} zählen.`}
value={num(pd.gapYears)}
min={0}
max={context.durationYears}
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
/>
{reachesReference && (
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<strong>Weiterarbeiten über {AHV_REFERENCE_AGE} hinaus:</strong> Die AHV-Rente
beginnt trotzdem mit {AHV_REFERENCE_AGE} und fliesst neben dem Lohn. Ein Aufschub
der Rente wird nicht abgebildet. Erfasse deshalb nur die Ausfalljahre bis zum
Referenzalter.
</p>
)}
</>
);
}
case "PENSION_FUND":
if (!context.ownerWorking) {
return (
@@ -484,13 +649,19 @@ export function ElementPhaseFields({
) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jährliche Einzahlung (CHF)"
help="Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
{inh(
"annualContribution",
"Jährliche Einzahlung (CHF)",
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={set} />,
asChf,
"Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
)}
{inh(
"expectedReturn",
"Erwartete Rendite (%/Jahr)",
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct
)}
</>
);
case "PILLAR_3A":
@@ -513,14 +684,21 @@ export function ElementPhaseFields({
) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jährliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`}
value={num(pd.annualContribution)}
max={PILLAR_3A_MAX_ANNUAL}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
{inh(
"annualContribution",
"Jährliche Einzahlung (CHF)",
(v, set) => (
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={PILLAR_3A_MAX_ANNUAL} onChange={set} />
),
asChf,
`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
)}
{inh(
"expectedReturn",
"Erwartete Rendite (%/Jahr)",
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct
)}
</>
);
case "REAL_ESTATE": {
@@ -548,19 +726,20 @@ export function ElementPhaseFields({
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
</>
)}
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
value={num(pd.amortization)}
onChange={(v) => setP({ amortization: v })}
/>
<NumberField
label="Hypothekarzins (%/Jahr)"
help="Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
step={0.1}
value={num(pd.interestRate)}
onChange={(v) => setP({ interestRate: v })}
/>
{inh(
"amortization",
"Amortisation (CHF/Jahr)",
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={set} />,
asChf,
"Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
)}
{inh(
"interestRate",
"Hypothekarzins (%/Jahr)",
(v, set) => <NumberField label="Hypothekarzins (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct,
"Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
)}
<div>
<FieldLabel
label="Hypothekarzins-Betrag (Beginn → Ende)"
@@ -570,13 +749,13 @@ export function ElementPhaseFields({
{formatChf(zinsStart)} <span className="text-faint"></span> {formatChf(zinsEnde)}
</div>
</div>
<NumberField
label="Geschätzte Wertsteigerung (%/Jahr)"
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr bei 100'000 Eigenkapital also 10% darauf (Hebel)."
step={0.1}
value={num(pd.valueGrowth)}
onChange={(v) => setP({ valueGrowth: v })}
/>
{inh(
"valueGrowth",
"Geschätzte Wertsteigerung (%/Jahr)",
(v, set) => <NumberField label="Geschätzte Wertsteigerung (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct,
"Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr bei 100'000 Eigenkapital also 10% darauf (Hebel)."
)}
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
@@ -609,19 +788,26 @@ export function ElementPhaseFields({
) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField
label="Jährlicher Sparbeitrag (CHF)"
help="Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<MoneyField
label="Jährliche Bezugsrate (CHF)"
help="Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
value={num(pd.annualWithdrawal)}
onChange={(v) => setP({ annualWithdrawal: v })}
/>
{inh(
"expectedReturn",
"Erwartete Rendite (%/Jahr)",
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct
)}
{inh(
"annualContribution",
"Jährlicher Sparbeitrag (CHF)",
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
asChf,
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
)}
{inh(
"annualWithdrawal",
"Jährliche Bezugsrate (CHF)",
(v, set) => <MoneyField label="Jährliche Bezugsrate (CHF)" value={v} onChange={set} />,
asChf,
"Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
)}
</>
);
case "OTHER_DEBT":
@@ -632,13 +818,13 @@ export function ElementPhaseFields({
) : (
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
<MoneyField
label="Jährliche Tilgung (CHF)"
help="Zählt gegen die Sparquote (max. die Sparquote)."
value={num(pd.annualRepayment)}
onChange={(v) => setP({ annualRepayment: v })}
/>
{inh(
"annualRepayment",
"Jährliche Tilgung (CHF)",
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />,
asChf,
"Zählt gegen die Sparquote (max. die Sparquote)."
)}
</>
);
}
+51
View File
@@ -320,3 +320,54 @@ export function SelectField<T extends string>({
</div>
);
}
// --- "Aus Vorphase uebernehmen" (Roadmap Nr. 44, Punkt A) --------------------------------
//
// Wiederkehr-Parameter (Raten, Beitraege, Amortisation) gelten weiter, bis man sie bewusst
// aendert. Bisher war das unsichtbar: Beim Anlegen einer Phase wurde der Wert KOPIERT, und
// spaetere Aenderungen an der Vorphase kamen deshalb nie an. Jetzt entscheidet ein sichtbares
// Haekchen: angehakt = Feld leer lassen und live erben; abgehakt = eigener Wert fuer diese
// Phase. In der ersten Phase gibt es nichts zu erben -- dort erscheint das Haekchen nicht.
export function InheritableField({
label,
help,
canInherit,
isInheriting,
inheritedDisplay,
onToggle,
children,
}: {
label: string;
help?: string;
canInherit: boolean;
isInheriting: boolean;
inheritedDisplay: string;
onToggle: (inherit: boolean) => void;
children: React.ReactNode;
}) {
return (
<div>
{isInheriting ? (
<>
<FieldLabel label={label} help={help} />
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
{inheritedDisplay} <span className="text-xs text-faint">(aus der Vorphase)</span>
</div>
</>
) : (
children
)}
{canInherit && (
<label className="mt-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted">
<input
type="checkbox"
checked={isInheriting}
onChange={(e) => onToggle(e.target.checked)}
className="h-3.5 w-3.5 accent-accent"
/>
Aus Vorphase übernehmen
</label>
)}
</div>
);
}
+18 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { NumberField, SelectField, TextField } from "@/components/FormField";
import { FieldLabel, NumberField, SelectField, TextField } from "@/components/FormField";
import type { HouseholdType, PersonRole } from "@/lib/types";
export interface ProfileDraft {
@@ -24,9 +24,14 @@ export function emptyProfileDraft(): ProfileDraft {
export function PlanProfileFields({
draft,
onChange,
// Sobald Lebensphasen bestehen, liegt jede Pensionierung auf einer Phasengrenze. Ein frei
// änderbares Alter würde diese Grenze zerreissen -- deshalb übernimmt dort die eigene
// Bedienung «Pensionsalter anpassen» (Roadmap Nr. 44), und das Feld ist nur noch Anzeige.
lockRetirement = false,
}: {
draft: ProfileDraft;
onChange: (next: ProfileDraft) => void;
lockRetirement?: boolean;
}) {
function setType(type: HouseholdType) {
if (type === "SINGLE") {
@@ -89,6 +94,17 @@ export function PlanProfileFields({
max={120}
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
/>
{lockRetirement ? (
<div>
<FieldLabel
label="Pensionierungsalter"
help="Liegt immer auf einer Phasengrenze und wird deshalb unten unter «Pensionsalter anpassen» verschoben -- dabei wandern die angrenzenden Phasendauern mit."
/>
<div className="mt-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-muted">
{person.retirementAge} Jahre
</div>
</div>
) : (
<NumberField
label="Pensionierungsalter (nur dieses Szenario)"
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
@@ -97,6 +113,7 @@ export function PlanProfileFields({
max={100}
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
/>
)}
</div>
))}
+43 -1
View File
@@ -50,6 +50,7 @@ import {
} from "@/components/ElementDetail";
import { PhaseDetail } from "@/components/PhaseDetail";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
import { MoneyField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
@@ -58,6 +59,7 @@ import {
CATEGORY_LABELS,
CATEGORY_ORDER,
PERSON_ONLY_CATEGORIES,
inheritedPhaseValues,
num,
type CashTransitionData,
type ElementCategory,
@@ -256,6 +258,16 @@ export function PlanView({
return computed.phases.length - phase.sequenceNumber;
}
// Phasen in ihrer Reihenfolge -- Grundlage der Vererbung (Roadmap Nr. 44, Punkt A).
const orderedPhaseIds = computed.phases.map((p) => p.id);
// Alter des Element-Besitzers im ersten Jahr einer Phase. Ohne Personenzuordnung gilt
// Person A, weil die Zeitachse ohnehin an ihr hängt.
function ownerAgeAtPhaseStart(phase: PhaseComputed, element: ElementInput): number {
const role = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? element.ownerRole : "PERSON_A";
return phase.persons.find((p) => p.role === role)?.startAge ?? 0;
}
// Baut den Kontext für eine Phasenzelle.
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
@@ -280,6 +292,8 @@ export function PlanView({
deflatorStart: phase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(phase),
ahvCareer: careerFor(element),
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
};
}
@@ -302,6 +316,8 @@ export function PlanView({
deflatorStart: fromPhase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(fromPhase),
ahvCareer: careerFor(element),
inheritedValues: {},
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
};
}
@@ -1468,6 +1484,9 @@ function AddElementDialog({
deflatorStart: firstPhase.cumulativeInflationStart,
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
ahvCareer: null,
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
inheritedValues: {},
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
};
async function create() {
@@ -1615,6 +1634,14 @@ function AddPhaseDialog({
);
}
// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle
// Vermögens-Elemente, die in der Folgephase noch aktiv sind.
function investTargetsOf(toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
return (toPhase?.elements ?? [])
.filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE")
.map((e) => ({ id: e.elementId, name: e.name }));
}
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---
function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
const [draft, setDraft] = useState<ProfileDraft>({
@@ -1642,12 +1669,21 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
return (
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
<div className="flex flex-col gap-3">
<PlanProfileFields draft={draft} onChange={setDraft} />
<PlanProfileFields draft={draft} onChange={setDraft} lockRetirement={plan.phases.length > 0} />
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 pt-1">
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
</div>
{plan.phases.length > 0 && (
<div className="mt-3 border-t border-border pt-3">
<h3 className="mb-2 text-sm font-semibold text-fg">Pensionsalter anpassen</h3>
{/* Wirkt SOFORT (eigener Endpunkt), weil dabei Phasendauern verschoben werden --
das lässt sich nicht sinnvoll mit dem Profil-Formular zusammen abspeichern. */}
<RetirementAdjuster plan={plan} onSaved={onSaved} />
</div>
)}
</div>
</InspectorShell>
);
@@ -1723,6 +1759,9 @@ function TransitionReviewDialog({
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
capitalInflow={toPhase?.capitalInflow ?? 0}
investTargets={investTargetsOf(toPhase)}
/>
</div>
</div>
@@ -1814,6 +1853,9 @@ function CashTransitionPanel({
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
capitalInflow={toPhase?.capitalInflow ?? 0}
investTargets={investTargetsOf(toPhase)}
/>
</div>
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
+136
View File
@@ -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>
);
}
+47
View File
@@ -295,3 +295,50 @@ describe("rebaseFlow", () => {
expect(rebaseFlow(1000, -100, 3)).toBe(1000);
});
});
describe("Effektive Flusswerte wirken in die Folgephase (Roadmap Nr. 44)", () => {
// Zwei Phasen a 5 Jahre, Lohn 100'000 ohne Teuerung. Im Planjahr 3 wird der Lohn
// effektiv mit 200'000 erfasst -- das muss auch in Phase 2 ankommen, sonst faellt der
// Wert am Phasenwechsel stillschweigend auf den geplanten zurueck.
function lohnPlan(): PlanInput {
return {
id: "s",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2020,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "E1", durationYears: 5, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "E2", durationYears: 5, cashTransition: {} },
],
elements: [
{
id: "lohn",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} },
transitionValues: {},
sourceElementId: null,
},
],
} as unknown as PlanInput;
}
it("traegt den effektiven Wert ueber die Phasengrenze", () => {
const p = lohnPlan();
const actuals = resolveActuals([setAt(2022, { lohn: { value: 200000 } })], p, p.elements);
const computed = computePlan(p, undefined, { actuals });
expect(valueAt(computed, "lohn", 3)).toBe(200000);
// Phase 2 beginnt im Planjahr 6 -- ohne den Fix stuende hier wieder 100'000.
expect(valueAt(computed, "lohn", 6)).toBe(200000);
});
it("bleibt ohne Ist-Werte auf der Planlinie", () => {
const computed = computePlan(lohnPlan());
expect(valueAt(computed, "lohn", 6)).toBe(100000);
});
});
+73
View File
@@ -220,6 +220,38 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
],
}),
},
{
// Roadmap Nr. 44, Punkt C: Das PK-Kapital wird nicht als Cash liegen gelassen, sondern
// per Quote auf Hypothek und Anlagen verteilt. Beide Brücken müssen weiterhin aufgehen.
name: "Kapitalverwendung am Pensions-Übergang (Quoten)",
build: () =>
plan({
age: 60,
retirementAge: 65,
initialCash: 50000,
phases: [
{ id: "p1", durationYears: 5, cashTransition: { capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 120000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } }
),
el(
"REAL_ESTATE",
"HOUSEHOLD",
{ p1: { purchasePrice: 900000, mortgage: 600000, amortization: 5000, valueGrowth: 1 }, p2: {} },
{ p1: { decision: "HOLD" } }
),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 4 }, p2: {} }),
],
}),
},
];
describe("Wasserfall-Brücken", () => {
@@ -272,3 +304,44 @@ describe("Wasserfall-Brücken", () => {
expect(verkauft.saleGainLoss).not.toBe(0);
});
});
describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", () => {
const withQuoten = konstellationen.find((k) => k.name.startsWith("Kapitalverwendung"))!;
it("leitet den Zufluss gemaess Quote in Hypothek und Anlage statt ins Cash", () => {
const mit = computePlan(withQuoten.build());
const ohne = computePlan({
...withQuoten.build(),
phases: withQuoten.build().phases.map((p) => ({ ...p, cashTransition: {} })),
});
const p2Mit = mit.phases[1];
const p2Ohne = ohne.phases[1];
// 90 % des Kapitals werden verwendet -> deutlich weniger Cash zu Beginn von Phase 2.
expect(p2Mit.cashStart).toBeLessThan(p2Ohne.cashStart);
// Die Hypothek ist tiefer, das Vermoegen dadurch hoeher.
const immoMit = p2Mit.elements.find((e) => e.category === "REAL_ESTATE")!;
const immoOhne = p2Ohne.elements.find((e) => e.category === "REAL_ESTATE")!;
expect(immoMit.mortgageStart).toBeLessThan(immoOhne.mortgageStart);
// Das Sonstige Vermoegen startet hoeher.
const assetMit = p2Mit.elements.find((e) => e.category === "OTHER_ASSET")!;
const assetOhne = p2Ohne.elements.find((e) => e.category === "OTHER_ASSET")!;
expect(assetMit.startValue).toBeGreaterThan(assetOhne.startValue);
});
it("skaliert mit dem Pensionsalter, statt einen Frankenbetrag stehen zu lassen", () => {
// Genau der Grund fuer Quoten statt Betraege: Ein Jahr laenger arbeiten heisst mehr
// Kapital -- und damit automatisch mehr Amortisation und mehr Anlage.
const base = withQuoten.build();
const laenger: PlanInput = {
...base,
persons: base.persons.map((p) => ({ ...p, retirementAge: 66 })),
phases: base.phases.map((p, i) =>
i === 0 ? { ...p, durationYears: 6 } : { ...p, durationYears: 9 }
),
};
const a = computePlan(base).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!;
const b = computePlan(laenger).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!;
expect(b.mortgageStart).toBeLessThan(a.mortgageStart);
});
});
+158
View File
@@ -733,3 +733,161 @@ describe("V5 Golden Tests", () => {
expect(r.phases[1].cashStart).toBe(r.phases[0].cashEnd);
});
});
// --- Roadmap Nr. 44: Referenzalter, Beitragspflicht, Einkommen bei Pensionierung ---
describe("AHV-Referenzalter (Kap. 4.4.6)", () => {
// Person wird mit 60 pensioniert, die AHV-Rente beginnt trotzdem erst mit 65.
function fruehPlan(beitrag: number) {
return plan({
age: 55,
retirementAge: 60,
inflation: 0,
initialCash: 500000,
phases: [
{ id: "p1", durationYears: 5 }, // Alter 55-60, erwerbstaetig
{ id: "p2", durationYears: 15 }, // Alter 60-75, pensioniert
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 60000, teuerungsausgleich: 0 }, p2: {} }),
el(
"AHV",
"PERSON_A",
{ p1: {}, p2: { ahvContribution: beitrag } },
{ p1: { reviewed: true, avgIncomeBefore: 100000, gapYearsBefore: 0 } }
),
],
});
}
const ahvYearly = (p: PlanInput, phaseIdx: number) =>
computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.yearly.map((y) => y.value);
it("zahlt vor 65 den Beitrag und ab 65 die Rente", () => {
const werte = ahvYearly(fruehPlan(2000), 1);
// Phase 2 laeuft von Alter 60 bis 75: 5 Jahre Beitrag (60-64), dann 10 Jahre Rente.
expect(werte.slice(0, 5)).toEqual([-2000, -2000, -2000, -2000, -2000]);
expect(new Set(werte.slice(5))).toEqual(new Set([32760]));
});
it("belastet den Beitrag als Kosten -- ohne Beitrag bleibt mehr Vermoegen", () => {
const mit = computePlan(fruehPlan(20000));
const ohne = computePlan(fruehPlan(0));
const endeMit = mit.phases[1].endWealthNominal;
const endeOhne = ohne.phases[1].endWealthNominal;
expect(endeOhne).toBeGreaterThan(endeMit);
// 5 Beitragsjahre x 20'000 = 100'000 Unterschied (zzgl. entgangener Rendite darauf).
expect(endeOhne - endeMit).toBeGreaterThanOrEqual(100000);
});
it("laesst die Rente auch waehrend der Erwerbstaetigkeit ab 65 fliessen", () => {
// Pension erst mit 70 -- die Rente muss trotzdem ab 65 kommen.
const p = plan({
age: 60,
retirementAge: 70,
inflation: 0,
phases: [
{ id: "p1", durationYears: 10 }, // Alter 60-70, erwerbstaetig (deckt 65 ab)
{ id: "p2", durationYears: 5 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }),
],
});
const werte = ahvYearly(p, 0);
// Jahre 1-5 (Alter 60-64): nichts, danach die Rente.
expect(werte.slice(0, 5)).toEqual([0, 0, 0, 0, 0]);
expect(werte[5]).toBeGreaterThan(0);
});
it("Pensionierung mit genau 65 bleibt unveraendert", () => {
const p = plan({
age: 60,
retirementAge: 65,
inflation: 0,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }),
],
});
const werte = ahvYearly(p, 1);
expect(werte).toHaveLength(10);
expect(new Set(werte)).toEqual(new Set([32760]));
});
});
describe("Einkommen bei Pensionierung (Roadmap Nr. 44, Punkt B)", () => {
function lohnPlan(p2: PhaseData, owner: "PERSON_A" | "HOUSEHOLD") {
return plan({
age: 60,
retirementAge: 65,
inflation: 0,
household: "COUPLE", // sonst zaehlt "Gemeinsam" zur Person A
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 5 },
],
elements: [el("INCOME", owner, { p1: { amount: 100000, teuerungsausgleich: 0 }, p2 })],
});
}
const lohn = (p: PlanInput) => computePlan(p).phases[1].elements[0].startValue;
it("faellt ein personenbezogener Lohn ohne eigenen Wert auf 0", () => {
expect(lohn(lohnPlan({}, "PERSON_A"))).toBe(0);
});
it("bleibt bestehen, wenn ausdruecklich ein Betrag erfasst ist (Teilzeit)", () => {
expect(lohn(lohnPlan({ amount: 30000, teuerungsausgleich: 0 }, "PERSON_A"))).toBe(30000);
});
it("laesst gemeinsame Einkommen unberuehrt", () => {
expect(lohn(lohnPlan({}, "HOUSEHOLD"))).toBe(100000);
});
});
describe("Wiederkehr-Parameter aus der Vorphase (Roadmap Nr. 44, Punkt A)", () => {
function assetPlan(p2: PhaseData) {
return plan({
age: 40,
retirementAge: 65,
inflation: 0,
phases: [
{ id: "p1", durationYears: 10 },
{ id: "p2", durationYears: 10 },
],
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2 })],
});
}
const endwert = (p: PlanInput) => computePlan(p).phases[1].elements[0].endValue;
it("uebernimmt eine nicht erfasste Rendite aus der Vorphase", () => {
// Ohne Vererbung waere Phase 2 zinslos -- der Endwert bliebe auf dem Stand von Phase 1.
// Rundung je Phase -> auf den Franken genau vergleichen waere zu streng.
expect(endwert(assetPlan({}))).toBeCloseTo(100000 * Math.pow(1.05, 20), -1);
});
it("laesst einen ausdruecklich erfassten Wert gewinnen", () => {
expect(endwert(assetPlan({ expectedReturn: 0 }))).toBe(Math.round(100000 * Math.pow(1.05, 10)));
});
it("traegt die Vererbung ueber mehrere Phasen", () => {
const p = plan({
age: 40,
retirementAge: 75,
inflation: 0,
phases: [
{ id: "p1", durationYears: 10 },
{ id: "p2", durationYears: 10 },
{ id: "p3", durationYears: 10 },
],
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2: {}, p3: {} })],
});
expect(computePlan(p).phases[2].elements[0].endValue).toBeCloseTo(100000 * Math.pow(1.05, 30), -1);
});
});
+160 -29
View File
@@ -4,6 +4,7 @@ import {
AHV_FULL_CONTRIBUTION_YEARS,
AHV_GROSS_FROM_NET_FACTOR,
AHV_MAX_ANNUAL_SINGLE,
AHV_REFERENCE_AGE,
AHV_MIN_MONTHLY_FULL,
AHV_PENSION_MONTHS,
DEFAULT_CAPITAL_TAX_RATE,
@@ -292,6 +293,9 @@ interface Carry {
owed: number; // Schulden: Restschuld (positiv)
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
// Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge,
// Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase.
rates: Record<string, number>;
hasCarry: boolean;
}
@@ -305,6 +309,7 @@ function emptyCarry(): Carry {
owed: 0,
pkPensionAnnual: 0,
flowBasis: 0,
rates: {},
hasCarry: false,
};
}
@@ -448,12 +453,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
});
}
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
// AHV-Renten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
//
// Seit Roadmap Nr. 44 wird die Rente für JEDE Person mit AHV-Element gerechnet, nicht nur
// für bereits pensionierte: Sie fliesst ab dem REFERENZALTER -- auch wenn jemand darüber
// hinaus arbeitet. Ob sie in einem Jahr tatsächlich fliesst, entscheidet die Jahres-
// schleife anhand des Alters (Kap. 4.4.6).
const ahvUncapped = new Map<string, number>();
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole);
if (!owner || workingByPerson.get(owner.id)) continue;
if (!owner) continue;
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 };
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
const mdJE = ahvMdje(career, before.avg, before.gap);
@@ -472,9 +482,19 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const ecById = new Map<string, ElementPhaseComputed>();
// Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere).
const phaseRealIncomeByPerson = new Map<string, number>();
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = [];
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = [];
let renteTotal = 0; // PK-Renten (nominal fix ueber die ganze Phase)
// AHV je Person: Rente und Beitrag stehen fest, WANN sie greifen entscheidet das Alter
// im jeweiligen Jahr -- deshalb eine eigene Liste statt eines Phasenbetrags.
const ahvItems: {
ownerId: string;
ownerStartAge: number;
rente: number;
beitrag: number;
working: boolean;
ec: ElementPhaseComputed;
}[] = [];
// `isPk` für die Vermögens-Brücke: PK-Beiträge verlassen das Cash NICHT (sie sind im
// Nettolohn bereits abgezogen), erhöhen aber das Vermögen -- sie sind also ein echter
// Zugang und keine Umbuchung.
@@ -512,6 +532,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
// Punkt A (Roadmap Nr. 44): Ein nicht erfasster Wiederkehr-Parameter wird aus der
// Vorphase ÜBERNOMMEN, statt stillschweigend auf 0 zu fallen. Im UI ist das das
// angehakte «Aus Vorphase übernehmen»; ein eigener Wert hakt es ab. Der jeweils
// verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt.
const inherited = (key: string, fallback = 0): number => {
const own = (pd as Record<string, unknown>)[key];
const v = typeof own === "number" ? own : carry.rates[key] ?? fallback;
carry.rates[key] = v;
return v;
};
const ec: ElementPhaseComputed = {
elementId: e.id,
category: e.category,
@@ -549,14 +580,24 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu.
// Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase
// (nominal für Einkommen, real für Ausgaben), ausser bewusst geändert (pd.amount).
const idx = num(pd.teuerungsausgleich, 0);
const idx = inherited("teuerungsausgleich");
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
const basis = !carry.hasCarry
let basis = !carry.hasCarry
? Math.round(num(pd.amount))
: typeof pd.amount === "number"
? Math.round(pd.amount)
: ec.baseValue;
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
// Ist die besitzende Person pensioniert, fällt ihr Erwerbseinkommen weg -- sonst liefe
// der Lohn stillschweigend in die Pension weiter (Kap. 4.4.7). Ein ausdrücklich
// erfasster Betrag gewinnt, damit ein Teilzeitpensum oder eine Erwerbsersatz-Zahlung
// modellierbar bleibt. Gemeinsame Einkommen (Mieterträge o. Ä.) sind NICHT betroffen,
// weil sie nicht an der Erwerbstätigkeit einer Person hängen.
if (e.category === "INCOME" && owner && !ownerWorking && typeof pd.amount !== "number") {
basis = 0;
ec.baseValue = 0;
ec.note = "Wegen Pensionierung auf 0 gesetzt. Für ein Teilzeitpensum trage hier einen Betrag ein.";
}
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec, carry });
// AHV: reales Erwerbseinkommen der Person mitführen. Nur Einkommen, die einer
// Person zugeordnet sind -- bei einem Einzelplan zählt "Gemeinsam" zur Person A.
@@ -576,21 +617,42 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
}
// Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben).
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
// Die Fortschreibung in die Folgephase passiert NACH der Jahresschleife: Dort kann ein
// effektiver Wert `basis` noch neu setzen (`rebaseFlow`), und genau der soll weiter-
// getragen werden -- nicht der ursprünglich geplante.
break;
}
case "AHV": {
if (owner && !ownerWorking) {
// Die AHV wird JAHRESWEISE gerechnet (Kap. 4.4.6): Bis zum Referenzalter zahlt eine
// frühpensionierte Person Beiträge, ab dem Referenzalter fliesst die Rente -- beides
// kann INNERHALB derselben Phase kippen, deshalb nicht über `renteTotal`.
if (owner) {
const rente = ahvFinal.get(owner.id) ?? 0;
renteTotal += rente;
ec.startValue = rente;
ec.endValue = rente;
const beitrag = Math.round(num(pd.ahvContribution));
const ageStart = owner.age + yearsBefore; // Alter im ersten Jahr der Phase
const ageEnd = ageStart + duration - 1; // Alter im letzten Jahr der Phase
ahvItems.push({ ownerId: owner.id, ownerStartAge: ageStart, rente, beitrag, working: ownerWorking, ec });
const reachesRef = ageEnd >= AHV_REFERENCE_AGE;
const startsRetired = ageStart >= AHV_REFERENCE_AGE;
ec.startValue = startsRetired ? rente : 0;
ec.endValue = reachesRef ? rente : 0;
if (startsRetired) {
ec.summary = `Rente ${fmt(rente)}`;
} else if (reachesRef && !ownerWorking) {
ec.summary = `Beitrag ${fmt(beitrag)} → Rente ${fmt(rente)}`;
ec.note = `Rente ab Alter ${AHV_REFERENCE_AGE}; bis dahin Beitrag als Nichterwerbstätige(r).`;
} else if (reachesRef) {
ec.summary = `Rente ab ${AHV_REFERENCE_AGE} ${fmt(rente)}`;
} else if (!ownerWorking) {
ec.summary = `Beitrag ${fmt(beitrag)}`;
ec.note = `Frühpensioniert: beitragspflichtig bis Alter ${AHV_REFERENCE_AGE}.`;
} else {
const gap = Math.max(0, Math.round(num(pd.gapYears)));
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
}
}
break;
}
case "PENSION_FUND": {
@@ -606,12 +668,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zählt NICHT zur Quote
const rate = Math.round(inherited("annualContribution")); // PK-Beitrag zählt NICHT zur Quote
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: true, ec });
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal: 0, isPk: true, ec });
}
break;
}
@@ -623,13 +685,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const rate = Math.round(num(pd.annualContribution));
const rate = Math.round(inherited("annualContribution"));
fixedRatesTotal += rate;
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: false, ec });
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal: 0, isPk: false, ec });
}
break;
}
@@ -637,15 +699,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const rate = Math.round(num(pd.annualContribution));
const withdrawal = Math.round(num(pd.annualWithdrawal));
const rate = Math.round(inherited("annualContribution"));
const withdrawal = Math.round(inherited("annualWithdrawal"));
fixedRatesTotal += rate;
plannedWithdrawTotal += withdrawal;
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, isPk: false, ec });
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal, isPk: false, ec });
break;
}
case "REAL_ESTATE": {
@@ -654,7 +716,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice));
const valueStart = carry.hasCarry ? carry.propertyValue : Math.round(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
const amort = Math.round(num(pd.amortization));
const amort = Math.round(inherited("amortization"));
const equity = valueStart - mortgageStart;
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
ec.baseValue = equity;
@@ -666,8 +728,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
purchase,
mortgage: mortgageStart,
amort,
growth: num(pd.valueGrowth),
interestRate: num(pd.interestRate),
growth: inherited("valueGrowth"),
interestRate: inherited("interestRate"),
// Default INCLUDED: bestehende Pläne haben die Zinsen in den Ausgaben -> nicht
// nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu.
addInterest: pd.interestHandling === "ADD",
@@ -677,7 +739,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
case "OTHER_DEBT": {
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
const repay = Math.round(num(pd.annualRepayment));
const repay = Math.round(inherited("annualRepayment"));
ec.baseValue = -owedStart;
ec.startValue = -owedStart;
wealthStart += -owedStart;
@@ -742,8 +804,20 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
interestNominal += re.mortgage * (re.interestRate / 100);
}
const expenseNominal = expenseRealBase * inflFactor + interestNominal;
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
// AHV jahresweise: Die Rente fliesst ab dem Referenzalter -- auch wenn die Person noch
// arbeitet. Vorher zahlt eine bereits pensionierte Person Beiträge, die wie eine
// Ausgabe auf die Quote schlagen (Kap. 4.4.6).
let ahvIncome = 0;
let ahvCost = 0;
for (const a of ahvItems) {
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
if (ageThisYear >= AHV_REFERENCE_AGE) ahvIncome += a.rente;
else if (!a.working) ahvCost += a.beitrag;
}
incomeFlow += ahvIncome;
const expenseNominal = expenseRealBase * inflFactor + interestNominal + ahvCost;
const expenseReal = expenseRealBase + (interestNominal + ahvCost) / (inflFactor || 1);
const quote = incomeFlow - expenseNominal;
// Der Vermögenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
@@ -899,9 +973,16 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
});
}
for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) });
// Renten (AHV, verrentete PK) laufen nominal fix durch die Phase.
// AHV jahresweise: vor dem Referenzalter der Beitrag als Nichterwerbstätige(r) (negativ,
// also als Belastung sichtbar), ab dem Referenzalter die Rente.
for (const a of ahvItems) {
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
const value = ageThisYear >= AHV_REFERENCE_AGE ? a.rente : a.working ? 0 : -a.beitrag;
if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value });
}
// Verrentete PK läuft nominal fix durch die Phase.
for (const ec of ecById.values()) {
if ((ec.category === "AHV" || ec.category === "PENSION_FUND") && ec.startValue > 0 && ec.yearly.length < t) {
if (ec.category === "PENSION_FUND" && ec.startValue > 0 && ec.yearly.length < t) {
ec.yearly.push({ year: yr, age, value: ec.startValue });
}
}
@@ -932,6 +1013,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1) * flowDeflatorEnd);
exp.ec.summary = fmt(exp.ec.startValue);
}
// Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben). Erst
// hier, weil `basis` in der Jahresschleife durch effektive Werte neu gesetzt worden sein kann.
for (const f of [...incomes, ...expenses]) {
f.carry.flowBasis = f.basis * Math.pow(1 + f.idx / 100, duration);
}
for (const a of assets) {
a.ec.endValue = Math.round(a.value);
a.ec.summary = fmt(a.ec.endValue);
@@ -1571,6 +1657,51 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
carry.hasCarry = true;
}
// --- Punkt C (Roadmap Nr. 44): Verwendung des Kapitalzuflusses -----------------------
//
// Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, 3a,
// Immobilienverkauf). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht.
// Die Verwendung wird deshalb als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert
// sich der Betrag -- die Aufteilung skaliert mit, statt still falsch zu werden.
//
// Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die
// Anlage-Quote wie eine Zusatzinvestition. Beide sind schon heute Cash-Abflüsse an der
// Grenze und laufen damit korrekt durch beide Brücken.
if (txInflow > 0) {
const ct = phase.cashTransition ?? {};
const amortPct = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
const investPct = Math.max(0, Math.min(100 - amortPct, num(ct.capitalUseInvestPct)));
if (amortPct > 0 || investPct > 0) {
let amortBudget = Math.round((txInflow * amortPct) / 100);
for (const e of orderedElements) {
if (amortBudget <= 0) break;
if (e.category !== "REAL_ESTATE") continue;
const c = carries.get(e.id)!;
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue;
const pay = Math.min(amortBudget, c.mortgage);
c.mortgage -= pay;
amortBudget -= pay;
txImmediateRepay += pay;
}
const investBudget = Math.round((txInflow * investPct) / 100);
if (investBudget > 0) {
const target =
orderedElements.find(
(e) =>
e.id === ct.capitalUseTargetElementId &&
e.category === "OTHER_ASSET" &&
carries.get(e.id)!.status === "ACTIVE"
) ??
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
if (target) {
carries.get(target.id)!.value += investBudget;
txImmediateRepay += investBudget;
}
}
}
}
cashCarryIn = cashEnd + txInflow + txOneOffInflow - txImmediateRepay - txOneOffOutflow;
incomingInflow = txInflow;
incomingImmediateRepay = txImmediateRepay;
+16
View File
@@ -14,6 +14,11 @@ export const AHV_PENSION_MONTHS = 13;
// Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag -- faktisch ab Alter 21.
export const AHV_CONTRIBUTION_START_AGE = 21;
// Referenzalter: Ab hier fliesst die AHV-Rente -- UNABHAENGIG vom gewaehlten Pensionsalter.
// Wer frueher aufhoert, bleibt bis dahin beitragspflichtig (als Nichterwerbstaetige(r)); wer
// laenger arbeitet, erhaelt die Rente trotzdem ab diesem Alter (siehe SPEZIFIKATION 4.4.6).
export const AHV_REFERENCE_AGE = 65;
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente: 2 x R0 x 13 = 32'760.
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
@@ -132,6 +137,17 @@ export const SYSTEM_PARAMETERS: SystemParameter[] = [
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_REFERENCE_AGE",
label: "Referenzalter (Rentenbeginn)",
value: AHV_REFERENCE_AGE,
unit: "Jahre",
meaning:
"Ab diesem Alter fliesst die AHV-Rente unabhängig vom gewählten Pensionsalter. Wer früher aufhört, bleibt bis dahin beitragspflichtig; wer länger arbeitet, erhält die Rente trotzdem ab hier. Ein Aufschub der Rente ist nicht modelliert.",
source: "AHVG (Referenzalter 65 nach AHV 21)",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_COUPLE_CAP_FACTOR",
label: "Ehepaar-Plafonierung",
+53
View File
@@ -58,6 +58,9 @@ export interface PhaseData {
teuerungsausgleich?: number;
// AHV
gapYears?: number;
// Nur AHV, nur in Phasen NACH einer Fruehpensionierung: jaehrlicher Beitrag als
// Nichterwerbstaetige(r) bis zum Referenzalter. Faellt als Kosten an (Kap. 4.4.6).
ahvContribution?: number;
// AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen
// Pensions-Übergang, an dem die Karriere geprüft werden könnte): Beitragskarriere.
// avgIncomeBefore ist REAL (heutige Kaufkraft).
@@ -135,6 +138,14 @@ export interface CashTransitionData {
// Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft).
outflowLabel?: string;
outflowAmount?: number;
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
// Bewusst in PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich
// das bezogene Kapital -- eine Franken-Angabe müsste man dann von Hand nachziehen, eine
// Quote skaliert mit. Der nicht zugeteilte Rest bleibt Cash.
capitalUseAmortizationPct?: number;
capitalUseInvestPct?: number;
// Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen».
capitalUseTargetElementId?: string;
}
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
@@ -149,6 +160,9 @@ export const cashTransitionSchema = z
inflowTaxRate: z.number().min(0).max(100).optional(),
outflowLabel: z.string().max(120).optional(),
outflowAmount: nonNeg.optional(),
capitalUseAmortizationPct: z.number().min(0).max(100).optional(),
capitalUseInvestPct: z.number().min(0).max(100).optional(),
capitalUseTargetElementId: z.string().max(60).optional(),
})
.strip();
@@ -157,6 +171,7 @@ export const phaseDataSchema = z
amount: nonNeg.optional(),
teuerungsausgleich: z.number().min(-20).max(50).optional(),
gapYears: z.number().int().min(0).optional(),
ahvContribution: nonNeg.optional(),
avgIncomeBefore: nonNeg.optional(),
gapYearsBefore: z.number().int().min(0).max(50).optional(),
currentValue: nonNeg.optional(),
@@ -198,3 +213,41 @@ export const transitionDataSchema = z
export function num(value: number | undefined | null, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
// --- Vererbung der Wiederkehr-Parameter (Roadmap Nr. 44, Punkt A) -----------------------
//
// Raten, Beitraege, Amortisation, Wertsteigerung und Zinssatz gelten weiter, bis man sie
// bewusst aendert: Fehlt der Wert in einer Phase, gilt der aus der Vorphase. Genau diese
// Regel wendet auch `computePlan` an -- hier steht sie fuer das UI, damit die Anzeige
// "Aus Vorphase uebernehmen: <Wert>" nicht raten muss.
export const INHERITABLE_KEYS = [
"teuerungsausgleich",
"expectedReturn",
"annualContribution",
"annualWithdrawal",
"amortization",
"valueGrowth",
"interestRate",
"annualRepayment",
] as const;
export type InheritableKey = (typeof INHERITABLE_KEYS)[number];
// Wert, der in `phaseId` gelten wuerde, wenn das Feld dort leer bleibt. `orderedPhaseIds`
// muss in Phasenreihenfolge vorliegen.
export function inheritedPhaseValues(
phaseValues: Record<string, PhaseData>,
orderedPhaseIds: string[],
phaseId: string
): Record<string, number> {
const out: Record<string, number> = {};
for (const id of orderedPhaseIds) {
if (id === phaseId) break;
const pd = phaseValues[id] ?? {};
for (const key of INHERITABLE_KEYS) {
const v = pd[key];
if (typeof v === "number") out[key] = v;
}
}
return out;
}
+17 -3
View File
@@ -13,7 +13,14 @@
// keine Ladeanzeige. Der Engpass ist das Neuzeichnen der Grafik, nicht die Mathematik.
import { computePlan } from "@/lib/calculations";
import { applyDriver, applyElementDriver, driverById, DRIVERS, tunableElements } from "@/lib/sensitivity";
import {
applyDriver,
applyElementDriver,
driverById,
DRIVERS,
retirementDriverRange,
tunableElements,
} from "@/lib/sensitivity";
import type { DriverId, DriverUnit } from "@/lib/sensitivity";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
@@ -51,6 +58,9 @@ const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral
expenses: { min: -20, max: 30, step: 1, neutral: 0 },
income: { min: -30, max: 20, step: 1, neutral: 0 },
lifespan: { min: -5, max: 10, step: 1, neutral: 0 },
// Der wirkliche Spielraum hängt an den Phasendauern und wird in `buildSliders` gesetzt.
retirementA: { min: -3, max: 3, step: 1, neutral: 0 },
retirementB: { min: -3, max: 3, step: 1, neutral: 0 },
};
// Bereich für die einzeln aufgeschlüsselten Element-Renditen: derselbe wie der Sammelregler,
@@ -69,14 +79,18 @@ export function buildSliders(plan: PlanInput, expandReturns: boolean): SliderDef
// Aufgeschlüsselt übernehmen die Element-Regler die Rolle der Sammelregler.
if (expandReturns && (def.id === "returns" || def.id === "propertyGrowth")) continue;
const r = RANGES[def.id];
// Beim Pensionsalter darf der Regler nur so weit gehen, wie die angrenzenden Phasen es
// zulassen -- sonst würde er stumm an seiner Grenze kleben (Roadmap Nr. 44).
const hard =
def.id === "retirementA" || def.id === "retirementB" ? retirementDriverRange(plan, def.id) : null;
out.push({
key: `d:${def.id}`,
ref: { kind: "driver", id: def.id },
label: def.shortLabel,
unit: def.unit,
help: def.help,
min: r.min,
max: r.max,
min: hard ? Math.max(r.min, hard.min) : r.min,
max: hard ? Math.min(r.max, hard.max) : r.max,
step: r.step,
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan.
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0,
+3 -1
View File
@@ -106,7 +106,9 @@ export function buildRateWrites(
if (scope === "THIS" || fields.length === 0) return [];
const patch: Partial<PhaseData> = {};
for (const f of fields) patch[f] = draft[f] ?? 0;
// Bewusst OHNE `?? 0`: Ein leeres Feld bedeutet seit Roadmap Nr. 44 «aus der Vorphase
// übernehmen». Ein 0 an dieser Stelle würde daraus eine harte Null machen.
for (const f of fields) patch[f] = draft[f];
return targetPhases(phases, currentPhaseId, scope)
.filter((p) => p.id !== currentPhaseId)
+181
View File
@@ -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();
});
});
+274
View File
@@ -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(" ");
}
+38
View File
@@ -210,3 +210,41 @@ describe("Sensitivität: Tornado", () => {
expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd));
});
});
describe("Treiber Pensionsalter (Roadmap Nr. 44)", () => {
it("erscheint nur fuer vorhandene Personen mit Spielraum", () => {
const p = basePlan();
expect(driverById("retirementA").applies(p)).toBe(true);
expect(driverById("retirementB").applies(p)).toBe(false); // Einzelhaushalt
});
it("verschiebt die Phasengrenze, ohne die Gesamtdauer zu aendern", () => {
const shifted = applyDriver(basePlan(), "retirementA", 3);
expect(shifted.phases.map((x) => x.durationYears)).toEqual([23, 17]);
expect(shifted.persons[0].retirementAge).toBe(68);
});
it("kuerzt auf den moeglichen Spielraum statt gar nichts zu tun", () => {
// Phase 2 hat 20 Jahre -> maximal +19; eine Eingabe von +50 landet dort.
const shifted = applyDriver(basePlan(), "retirementA", 50);
expect(shifted.phases.map((x) => x.durationYears)).toEqual([39, 1]);
});
it("laenger arbeiten bringt mehr Endvermoegen", () => {
// Der Lohn muss der PERSON gehoeren -- nur dann faellt er bei der Pensionierung weg
// (Roadmap Nr. 44, Punkt B) und die Verschiebung der Grenze wirkt ueberhaupt.
const p = basePlan();
p.elements[0].ownerRole = "PERSON_A";
const base = planMetric(p, "real");
const spaeter = planMetric(applyDriver(p, "retirementA", 3), "real");
const frueher = planMetric(applyDriver(p, "retirementA", -3), "real");
expect(spaeter).toBeGreaterThan(base);
expect(frueher).toBeLessThan(base);
});
it("liefert im Tornado einen Balken mit Spannweite", () => {
const t = computeTornado(basePlan(), "real", [{ id: "retirementA", low: -3, high: 3 }]);
expect(t.bars[0].swing).toBeGreaterThan(0);
expect(t.bars[0].note).toBeUndefined();
});
});
+67 -2
View File
@@ -17,8 +17,9 @@
import { computePlan } from "@/lib/calculations";
import { num } from "@/lib/elements";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
import type { ResolvedActuals } from "@/lib/actuals";
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
export type DriverId =
| "inflation"
@@ -27,7 +28,9 @@ export type DriverId =
| "income"
| "salaryGrowth"
| "lifespan"
| "propertyGrowth";
| "propertyGrowth"
| "retirementA"
| "retirementB";
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
@@ -53,6 +56,24 @@ function hasCategory(plan: PlanInput, categories: ElementCategory[]): boolean {
return plan.elements.some((e) => categories.includes(e.category));
}
// Der Pensionsalter-Regler erscheint nur, wenn sich die Grenze überhaupt verschieben lässt --
// ein Regler ohne Spielraum wäre schlimmer als keiner (siehe `retirementBoundaries`).
function retirementDriverApplies(plan: PlanInput, role: PersonRole): boolean {
const b = retirementBoundaries(plan).find((x) => x.role === role);
return !!b && !b.blocked && (b.minDelta !== 0 || b.maxDelta !== 0);
}
// Erlaubter Bereich des Reglers: bis zur Zusammenlegung, aber nicht darüber hinaus. Der
// Tornado/die Live-Simulation legen bewusst NICHT zusammen -- das würde Übergangs-Entscheide
// zusammenführen, also den Plan inhaltlich verändern, und dafür ist eine Was-wäre-wenn-
// Betrachtung der falsche Ort. Deshalb endet der Spielraum bei minDelta/maxDelta.
export function retirementDriverRange(plan: PlanInput, id: "retirementA" | "retirementB"): { min: number; max: number } {
const role: PersonRole = id === "retirementA" ? "PERSON_A" : "PERSON_B";
const b = retirementBoundaries(plan).find((x) => x.role === role);
if (!b || b.blocked) return { min: 0, max: 0 };
return { min: b.minDelta, max: b.maxDelta };
}
export const DRIVERS: DriverDef[] = [
{
id: "expenses",
@@ -108,6 +129,27 @@ export const DRIVERS: DriverDef[] = [
"Verschiebung der jährlichen nominalen Lohnerhöhung in Prozentpunkten. Sinnvolle Bandbreite: 1 bis +1 Prozentpunkt. Wirkt nur über die verbleibenden Erwerbsjahre und ist deshalb meist ein schwacher Hebel.",
applies: (p) => hasCategory(p, ["INCOME"]),
},
// Pensionsalter (Roadmap Nr. 44). Verschiebt die Phasengrenze, an der die Person in
// Pension geht -- die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt.
// Der Hebel ist doppelt (länger Einkommen UND kürzer Verzehr) und deshalb meist gross.
{
id: "retirementA",
label: "Pensionsalter Person A",
shortLabel: "Pensionsalter A",
unit: "delta_years",
help:
"Verschiebt das Pensionsalter der Person A. Sinnvolle Bandbreite: 3 bis +3 Jahre. Der Spielraum endet dort, wo eine angrenzende Lebensphase verschwinden würde darüber hinaus eingegebene Jahre werden auf das Mögliche gekürzt.",
applies: (p) => retirementDriverApplies(p, "PERSON_A"),
},
{
id: "retirementB",
label: "Pensionsalter Person B",
shortLabel: "Pensionsalter B",
unit: "delta_years",
help:
"Verschiebt das Pensionsalter der Person B. Sinnvolle Bandbreite: 3 bis +3 Jahre. Der Spielraum endet dort, wo eine angrenzende Lebensphase verschwinden würde darüber hinaus eingegebene Jahre werden auf das Mögliche gekürzt.",
applies: (p) => retirementDriverApplies(p, "PERSON_B"),
},
{
id: "propertyGrowth",
label: "Wertsteigerung der Immobilie",
@@ -191,6 +233,18 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
teuerungsausgleich: num(pd.teuerungsausgleich, 0) + value,
}));
case "retirementA":
case "retirementB": {
// Auf den möglichen Spielraum kürzen statt den Plan unverändert zu lassen: Eine
// eingegebene Bandbreite von ±5 Jahren soll auch dann etwas zeigen, wenn nur ±2
// möglich sind. Wie weit tatsächlich verschoben wurde, steht in der Ergebnistabelle.
const range = retirementDriverRange(plan, id);
const delta = Math.max(range.min, Math.min(range.max, Math.round(value)));
if (delta === 0) return plan;
const role: PersonRole = id === "retirementA" ? "PERSON_A" : "PERSON_B";
return shiftRetirement(plan, role, delta)?.plan ?? plan;
}
case "lifespan": {
// Verlängert/verkürzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das lässt
// sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18).
@@ -289,6 +343,17 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
return "Wirkungslos, weil die Immobilie vor Planende verkauft wird: Der Verkaufserlös ergibt sich aus dem erfassten Verkaufspreis, nicht aus dem modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim Verkauf verworfen.";
}
}
if (id === "retirementA" || id === "retirementB") {
const range = retirementDriverRange(plan, id);
if (range.min === 0 && range.max === 0) {
const role: PersonRole = id === "retirementA" ? "PERSON_A" : "PERSON_B";
const b = retirementBoundaries(plan).find((x) => x.role === role);
return (
b?.blocked ??
"Die angrenzenden Lebensphasen dauern bereits nur ein Jahr es bleibt kein Spielraum. Passe zuerst die Phasendauern an."
);
}
}
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
}