Pensionierung als Eigenschaft der Person: Treiber, Tests, Spezifikation 0.35
Deploy App / deploy (push) Successful in 1m57s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:52:40 +02:00
parent 1865db5de7
commit 0f656f4070
6 changed files with 675 additions and 37 deletions
+228 -30
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.34 | | **Version** | 0.35 |
| **Datum** | 2026-07-25 | | **Datum** | 2026-07-25 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `0953880` inkl. Modul-Review 4 (Nachbesserungen) (Branch `main`) | | **Codestand** | Arbeitsstand nach `1865db5` inkl. Pensionierung als Eigenschaft der Person (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **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` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.35 | 2026-07-26 | Claude (Opus 5) | **Die Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse** (neues Kapitel 3.13). Der grösste Eingriff seit V7. Bisher hing jeder Bezugs-Entscheid an `transitionValues[phaseId]` am Schlüssel Element × Phasen-ID. Daraus folgte fast alles, was an der Pensionsplanung störte: Entscheide, die inhaltlich **eine** Frage sind, lagen in drei weit auseinander liegenden Matrix-Zellen; das Alter zu ändern war ein struktureller Eingriff, bei dem Entscheide über `mergeTransition` verlustbehaftet von Grenze zu Grenze gerettet werden mussten; ein Szenario nur für ein anderes Pensionsalter hiess, alles neu zu entscheiden; und der Ziel-Solver (Roadmap Nr. 21) hätte nichts zum Anfassen gehabt. **Neu liegt der Entscheid am ELEMENT** (`FinancialElement.retirementDecision`, ohne Phasenbezug) und überlebt damit jede Verschiebung der Zeitachse. (1) **Neuer Pensionierungs-Bildschirm** gleichrangig neben der Matrix, mit der **Rentenlücke** als Leitzahl keine neue Rechnung, sondern die Verzehrquote im ersten voll pensionierten Jahr; sie fehlte bisher nur als Begriff. Gerechnet im Rechenkern (`PlanComputed.retirement`), damit Bildschirm und PDF-Bericht nicht auseinanderlaufen. Die Matrix-Zellen am Pensions-Übergang bleiben bedienbar und nutzen **dieselbe Komponente** (`RetirementFields`) zwei Ansichten auf ein Objekt, kein Duplikat. (2) **AHV-Vorbezug und -Aufschub** werden gerechnet (Kap. 4.4.7 neu geschrieben): Kürzung 6,8 %/Jahr, Zuschlag +5,2/10,8/17,1/24,0/31,5 % nach 15 Jahren, Teilbezug 2080 %. Bis 0.34 startete die Rente **immer** mit 65 wer mit 62 aufhörte, bekam die ungekürzte Rente drei Jahre später, wer bis 68 arbeitete, verschenkte den Zuschlag. Dabei wurde eine fachliche Trennung eingeführt, die es vorher gar nicht gab: **Rentenbeginn und Beitragspflicht sind zwei verschiedene Alter.** Wer mit 62 aufhört und ab 63 vorbezieht, bezieht ab 63 **und** zahlt bis 65 weiter als Nichterwerbstätige(r). (3) **Pensionskasse: ein Regler statt eines Modus.** `payoutMode` (`PENSION`/`CAPITAL`/`COMBI`) und der absolute `capitalAmount` entfallen zugunsten von `capitalSharePct` (0100 %). Als Quote, weil sich das Guthaben mit dem Pensionsalter ändert ein fixer Betrag bedeutete beim Verschieben still ein anderes Verhältnis. (4) **Säule 3a: wählbares Bezugsalter** (6070) statt starr am Pensions-Übergang. Ein Konto lässt sich nur ganz auflösen, und alle Bezüge desselben Jahres werden steuerlich zusammengezählt gestaffelt wird deshalb über Konten und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter. (5) **Planungshorizont** (`Person.planningHorizonAge`): Bisher ergab sich das Planende stillschweigend aus der Summe der Phasendauern zwei Szenarien konnten unbemerkt verschieden weit rechnen und waren nicht vergleichbar. Neue Funktion `planHorizonChange`, neuer Endpunkt `POST /api/scenarios/<id>/horizon`. (6) **Ampel mit drei Zuständen** (Kap. 3.5.3 neu): `unbeantwortet` · `auf Vorgabe` · `bestätigt`. Mit durchgängigen Vorgaben bewusst, damit niemand am Anfang Fragen beantworten muss, die er erst am Ende beantworten kann entstand ein Zustand, den das Modell nicht kannte: Das System **hat** eine Antwort, nur nicht die des Benutzers. Eine Vorgabe wie «volle Rente statt Kapitalbezug» als beantwortet zu zählen hiesse, sie unbemerkt durchgehen zu lassen. Sie zählt deshalb mit, aber getrennt benannt: «2 offene Entscheide · 3 Vorgaben ungeprüft», bestätigt wird je Säule. (7) **Drei neue Treiber** in Tornado und Live-Simulation: PK-Kapitalanteil, AHV-Vorbezug/Aufschub (in Monaten, neue Einheit `delta_months`) und das bestehende Pensionsalter wird endlich **korrekt**, weil der AHV-Beginn jetzt mitzieht. (8) Nebenbei zwei Vereinfachungen: Die Beitragskarriere vor Planbeginn lag an **zwei** Orten (Übergangszelle bzw. Phasenzelle für bereits Pensionierte) mit zwei Codepfaden jetzt an einem. Und Szenario-Kopie, Versionierung und Diff tragen den Entscheid mit; ohne das wäre die Kopie genau für den Zweck unbrauchbar, für den man sie am häufigsten anlegt. **Keine Datenmigration** (Testdaten wurden vorgängig gelöscht); alte Werte in `transitionValues` werden ignoriert, betroffene Elemente erscheinen als «Vorgabe ungeprüft». Neues Modul `retirement-decision.ts`, neue Komponenten `RetirementPanel` und `RetirementFields`. 23 Tests ergänzt (292 → 315). |
| 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). | | 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). |
| 0.33 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4 (Matrix: Phasen und Elemente).** (1) **Kapitalverwendung neu am Vorsorge-Element** (Punkt C aus Roadmap Nr. 44): Die Prozent-Aufteilung des bezogenen Alterskapitals hing am **Cash-Übergang** dem falschen Ort, denn mit zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. Sie steht jetzt beim **Bezugs-Entscheid** der Pensionskasse (nur bei Kapitalbezug) bzw. der **Säule 3a**. Beide Dialoge führen neu **brutto → Steuersatz → netto** und darunter die Verteilung. Der zugeteilte Betrag fliesst über den regulären Weg (`Carry.capitalIn` → Zusatzeinlage der Folgephase) und ist damit **überall sichtbar**: am Ziel-Element, in der Cash-Brücke als Investition und im «Kapital verteilen»-Dialog. Vorher erhöhte er still den Bestand, weshalb Element und Dialog eine **0** zeigten. Die **Säule 3a** ist am Pensions-Übergang neu ein **offener Entscheid** (Steuersatz und Verwendung); vorher galt sie als automatisch beantwortet. (2) **Phasendauer: die Folgephase gleicht aus** (Kap. 3.3.2). Bis 0.32 prüfte die Kappung nur die **bearbeitete** Phase wurde Phase 1 von 10 auf 12 Jahre verlängert, überspannte danach Phase 2 die Pensionierung, und die tragende Invariante aus Roadmap Nr. 44 kippte. Neu trägt die Folgephase die Differenz (Gesamtdauer bleibt gleich, wie beim Verschieben des Pensionsalters); passt sie nicht, wird blockiert; vorher erscheint eine Rückfrage. Neue reine Funktion `planDurationChange`. (3) **Element und Phase direkt bedienbar:** In der Matrix tragen Element-Zeile und Phasenkopf neu **Stift** (umbenennen, beim Element inkl. **Zuordnung**) und **Papierkorb**; das Expand-Symbol ist **immer** sichtbar statt nur bei Mouseover. `PATCH /api/elements/<id>` nimmt dafür neu auch `ownerRole` (bleibt für AHV/PK/3a personengebunden). (4) **Hilfetexte** werden über ein **Portal** gezeichnet in scrollenden Dialogen schnitt der Container sie vorher ab; sie klappen nach oben, wenn unten kein Platz ist. (5) **Verteil-Dialoge:** Zeilen zeigen die **Zuordnung** (Person A/B/Gemeinsam) und sind nach **«vom Cash»/«ins Cash»** gruppiert; die Vorbelegung nutzt neu den **effektiven** Wert inklusive Vererbung aus der Vorphase ein geerbter 3a-Beitrag erschien vorher als 0. (6) **Matrix:** alle Phasenspalten **gleich breit**, bei vielen Phasen wird horizontal gescrollt; **«Alle auf-/zuklappen»**; eine zugeklappte Kategorie zeigt je Phase die **Summe** ihrer Elemente. (7) **Phasen-Detailansicht** nutzt die neue Aufteilungs-Grafik (Fläche + Ring) statt der alten Balken. (8) **Übersicht:** «Leer starten» steht neu auch im leeren Zustand zur Wahl. (9) Nebenbei: dritte vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3äd`) repariert, das Phasen-Panel nutzt den eigenen Bestätigungs-Dialog statt `window.confirm`. 10 Tests ergänzt (278 → 288). | | 0.33 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4 (Matrix: Phasen und Elemente).** (1) **Kapitalverwendung neu am Vorsorge-Element** (Punkt C aus Roadmap Nr. 44): Die Prozent-Aufteilung des bezogenen Alterskapitals hing am **Cash-Übergang** dem falschen Ort, denn mit zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. Sie steht jetzt beim **Bezugs-Entscheid** der Pensionskasse (nur bei Kapitalbezug) bzw. der **Säule 3a**. Beide Dialoge führen neu **brutto → Steuersatz → netto** und darunter die Verteilung. Der zugeteilte Betrag fliesst über den regulären Weg (`Carry.capitalIn` → Zusatzeinlage der Folgephase) und ist damit **überall sichtbar**: am Ziel-Element, in der Cash-Brücke als Investition und im «Kapital verteilen»-Dialog. Vorher erhöhte er still den Bestand, weshalb Element und Dialog eine **0** zeigten. Die **Säule 3a** ist am Pensions-Übergang neu ein **offener Entscheid** (Steuersatz und Verwendung); vorher galt sie als automatisch beantwortet. (2) **Phasendauer: die Folgephase gleicht aus** (Kap. 3.3.2). Bis 0.32 prüfte die Kappung nur die **bearbeitete** Phase wurde Phase 1 von 10 auf 12 Jahre verlängert, überspannte danach Phase 2 die Pensionierung, und die tragende Invariante aus Roadmap Nr. 44 kippte. Neu trägt die Folgephase die Differenz (Gesamtdauer bleibt gleich, wie beim Verschieben des Pensionsalters); passt sie nicht, wird blockiert; vorher erscheint eine Rückfrage. Neue reine Funktion `planDurationChange`. (3) **Element und Phase direkt bedienbar:** In der Matrix tragen Element-Zeile und Phasenkopf neu **Stift** (umbenennen, beim Element inkl. **Zuordnung**) und **Papierkorb**; das Expand-Symbol ist **immer** sichtbar statt nur bei Mouseover. `PATCH /api/elements/<id>` nimmt dafür neu auch `ownerRole` (bleibt für AHV/PK/3a personengebunden). (4) **Hilfetexte** werden über ein **Portal** gezeichnet in scrollenden Dialogen schnitt der Container sie vorher ab; sie klappen nach oben, wenn unten kein Platz ist. (5) **Verteil-Dialoge:** Zeilen zeigen die **Zuordnung** (Person A/B/Gemeinsam) und sind nach **«vom Cash»/«ins Cash»** gruppiert; die Vorbelegung nutzt neu den **effektiven** Wert inklusive Vererbung aus der Vorphase ein geerbter 3a-Beitrag erschien vorher als 0. (6) **Matrix:** alle Phasenspalten **gleich breit**, bei vielen Phasen wird horizontal gescrollt; **«Alle auf-/zuklappen»**; eine zugeklappte Kategorie zeigt je Phase die **Summe** ihrer Elemente. (7) **Phasen-Detailansicht** nutzt die neue Aufteilungs-Grafik (Fläche + Ring) statt der alten Balken. (8) **Übersicht:** «Leer starten» steht neu auch im leeren Zustand zur Wahl. (9) Nebenbei: dritte vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3äd`) repariert, das Phasen-Panel nutzt den eigenen Bestätigungs-Dialog statt `window.confirm`. 10 Tests ergänzt (278 → 288). |
| 0.32 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3, Nachbesserungen darunter ein gravierender Rechenfehler bei den effektiven Werten.** (1) **Immobilien-Bugfix (Kap. 3.9):** Der Ist-Wizard belegte den Immobilienwert mit dem **Eigenkapital** vor (`ElementYearPoint.value`), während Erfassung und Rechenkern den **Verkehrswert** erwarten. Der Rechenkern setzte den vorbelegten Wert als Verkehrswert ein, liess die Hypothek aber stehen das Eigenkapital brach im Ist-Jahr schlagartig ein, typischerweise ins Negative. Sichtbar wurde das als **negative Gesamt-Abweichung, obwohl nur ein Lohn erhöht** wurde, und als «wegbrechendes» Wohneigentum in der Vermögensaufteilung. Neu wird `propertyValue` vorbelegt; das Feld ist als «Verkehrswert + Restschuld» beschriftet. Drei Regressionstests. (2) **Ist-Datensätze bearbeitbar:** Ein Klick auf die Zeile (oder «Bearbeiten») öffnet den erfassten Satz erneut; neuer Endpunkt `PUT /api/plans/<id>/actuals/<setId>`. Beim Bearbeiten überschreiben die Planwerte die erfassten Zahlen nicht mehr. (3) **Ring-Klick in der Vermögensaufteilung repariert:** Recharts 3 reicht im Klick-Parameter **kein `activePayload`** mehr durch (nur noch `activeIndex`) der Handler feuerte nie, der Ring zeigte immer das Planende. (4) **Seitenleiste sauber dreistufig:** Ebene 1 Pläne, Ebene 2 die vier Bereiche (Szenarien, Effektive Werte, Analysen, Berichte) mit **bündigen Symbolen**, Ebene 3 nur die Szenarien verschachtelt nach Herkunft. (5) Die **Szenario-Liste** zeigt neben der Version deren **Kommentar**. | | 0.32 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3, Nachbesserungen darunter ein gravierender Rechenfehler bei den effektiven Werten.** (1) **Immobilien-Bugfix (Kap. 3.9):** Der Ist-Wizard belegte den Immobilienwert mit dem **Eigenkapital** vor (`ElementYearPoint.value`), während Erfassung und Rechenkern den **Verkehrswert** erwarten. Der Rechenkern setzte den vorbelegten Wert als Verkehrswert ein, liess die Hypothek aber stehen das Eigenkapital brach im Ist-Jahr schlagartig ein, typischerweise ins Negative. Sichtbar wurde das als **negative Gesamt-Abweichung, obwohl nur ein Lohn erhöht** wurde, und als «wegbrechendes» Wohneigentum in der Vermögensaufteilung. Neu wird `propertyValue` vorbelegt; das Feld ist als «Verkehrswert + Restschuld» beschriftet. Drei Regressionstests. (2) **Ist-Datensätze bearbeitbar:** Ein Klick auf die Zeile (oder «Bearbeiten») öffnet den erfassten Satz erneut; neuer Endpunkt `PUT /api/plans/<id>/actuals/<setId>`. Beim Bearbeiten überschreiben die Planwerte die erfassten Zahlen nicht mehr. (3) **Ring-Klick in der Vermögensaufteilung repariert:** Recharts 3 reicht im Klick-Parameter **kein `activePayload`** mehr durch (nur noch `activeIndex`) der Handler feuerte nie, der Ring zeigte immer das Planende. (4) **Seitenleiste sauber dreistufig:** Ebene 1 Pläne, Ebene 2 die vier Bereiche (Szenarien, Effektive Werte, Analysen, Berichte) mit **bündigen Symbolen**, Ebene 3 nur die Szenarien verschachtelt nach Herkunft. (5) Die **Szenario-Liste** zeigt neben der Version deren **Kommentar**. |
@@ -789,25 +790,46 @@ Referenz: `src/components/ElementDetail.tsx` Zeilen 342462.
### 3.5.3 Ampel-Logik: „offene" Entscheide ### 3.5.3 Ampel-Logik: „offene" Entscheide
Ein Entscheid gilt als **beantwortet** (`isTransitionAnswered`), wenn das jeweilige Seit 0.35 gibt es **drei** Zustände statt zwei. Der dritte ist der interessanteste.
Entscheidungsfeld gesetzt ist:
| Zustand | Bedeutung |
|---|---|
| **unbeantwortet** | Das System weiss nichts. Verkauf/Halten, Tilgung, Cash-Übergang. |
| **auf Vorgabe** | Das System **hat** eine Antwort nur nicht die des Benutzers. Gilt für die Pensionierungs-Entscheide (AHV/PK/3a), die seit 0.35 durchgängige Vorgaben haben. |
| **bestätigt** | Der Benutzer hat hingeschaut (`retirementDecision.confirmed`), je Säule. |
Warum der dritte Zustand nötig wurde: Die Vorgaben sind Absicht ohne sie müsste man am
Anfang Fragen beantworten, die man erst am Ende beantworten kann, und der Plan wäre bis dahin
nicht rechenbar ([3.13.2](#3132-kein-leeres-formular)). Eine Vorgabe aber als *beantwortet* zu
zählen hiesse, eine stillschweigend gesetzte Annahme wie «volle Rente statt Kapitalbezug»
durchgehen zu lassen, obwohl sie das Ergebnis massiv verändert. Sie zählt deshalb **mit**
aber in einem eigenen Topf, damit sie sich nicht wie ein vergessenes Eingabefeld liest:
> **2 offene Entscheide · 3 Vorgaben ungeprüft**
`openTransitionCount` und `totalOpenDecisions` liefern deshalb ein Paar `DecisionCounts`
(`{ open, unconfirmed }`) statt einer Zahl. `decisionsText` formuliert es. Ein Entscheid gilt
weiterhin als beantwortet (`isTransitionAnswered`), wenn das jeweilige Feld gesetzt ist:
| Kategorie | Beantwortet, wenn | | Kategorie | Beantwortet, wenn |
|---|---| |---|---|
| `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt (inkl. `PARTIAL`) | | `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt (inkl. `PARTIAL`) |
| `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt | | `PENSION_FUND`, `PILLAR_3A`, `AHV` | am **Pensions-Übergang**: über `confirmed` (siehe oben); sonst: `withdrawalMode` gesetzt |
| `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt |
| **Cash** | `mode` gesetzt (`isCashTransitionAnswered`) | | **Cash** | `mode` gesetzt (`isCashTransitionAnswered`) |
| alle anderen | immer beantwortet | | alle anderen | immer beantwortet |
Der Übergangs-Spaltenkopf zeigt entweder „N offen" (Akzentfarbe) oder „geprüft" (grün, Häkchen). Der Übergangs-Spaltenkopf zeigt „N offen" (Akzentfarbe), „N Vorgaben" (gedeckt, weil es kein
Offene Zellen sind farblich hervorgehoben und zeigen „?". Handlungsdefizit ist) oder „geprüft" (grün, Häkchen). Offene Zellen sind hervorgehoben und
zeigen „?".
**Auswirkung auf den PDF-Bericht:** Die Kennzahl «Offene Entscheide» weist beide Töpfe
zusammen aus. Berichte von vor 0.35 sind deshalb nicht direkt vergleichbar.
**Ein Element ist am Übergang inaktiv** (`transitionInactive`), wenn es bereits verkauft/getilgt **Ein Element ist am Übergang inaktiv** (`transitionInactive`), wenn es bereits verkauft/getilgt
ist **oder** wenn es eine PK/3a ist, deren Besitzer schon zu Beginn der Von-Phase pensioniert war ist **oder** wenn es eine PK/3a ist, deren Besitzer schon zu Beginn der Von-Phase pensioniert war
(dann ist bereits bezogen/verrentet). Inaktive Zellen zeigen „–" und sind nicht anklickbar. (dann ist bereits bezogen/verrentet). Inaktive Zellen zeigen „–" und sind nicht anklickbar.
Referenz: `src/components/PlanView.tsx` Zeilen 200232, `src/components/ElementDetail.tsx` Zeilen 107120. Referenz: `src/lib/decisions.ts` (`openTransitionCount`, `totalOpenDecisions`, `decisionsText`).
### 3.5.4 Geführter Übergang (Review-Dialog) ### 3.5.4 Geführter Übergang (Review-Dialog)
@@ -2014,6 +2036,127 @@ frühere Filter auf `status === "ACTIVE"` griff nicht: Der Status bleibt nach de
Referenz: `src/lib/retirement.ts`, `src/components/RetirementAdjuster.tsx`, Referenz: `src/lib/retirement.ts`, `src/components/RetirementAdjuster.tsx`,
`src/components/FormField.tsx` (`InheritableField`). `src/components/FormField.tsx` (`InheritableField`).
## 3.13 Pensionierung
### 3.13.1 Warum sie eine Eigenschaft der Person ist
Bis 0.34 war die Pensionierung als Eigenschaft der **Zeitachse** modelliert: ein Phasenübergang,
an dem verstreut in drei Matrix-Zellen (AHV, PK, 3a) je ein Entscheid hing gespeichert unter
`transitionValues[phaseId]`, also am Schlüssel Element × Phasen-ID. Daraus folgte fast alles,
was an der Pensionsplanung störte:
- Entscheide, die inhaltlich **eine** Frage sind, lagen räumlich weit auseinander.
- Das Alter zu ändern war ein **struktureller** Eingriff. Beim Zusammenlegen zweier Übergänge
mussten Entscheide über `mergeTransition` gerettet werden verlustbehaftet.
- Ein Szenario nur für ein anderes Pensionsalter hiess: alle Entscheide erneut treffen. Genau
dafür legt man aber Szenarien an.
- Ziel-Solver (Roadmap Nr. 21) und Live-Simulation hatten **nichts zum Anfassen**.
Der Denkfehler: Die Pensionierung ist keine Eigenschaft der Zeitachse. **Sie ist eine
Eigenschaft der Person** die Zeitachse ist die Folge davon.
Seit 0.35 liegt der Entscheid deshalb am **Element** (`FinancialElement.retirementDecision`,
ohne Phasenbezug) und gilt für die Pensionierung des Besitzers, wo immer die gerade liegt.
Ohne Phasen-ID im Schlüssel überlebt er **jede** Zeitachsen-Änderung: Alter verschieben,
Phase zusammenlegen, Szenario kopieren.
Was **nicht** dort hineingehört: Vorbezüge (PK/3a vor der Pensionierung), Verkäufe, Tilgungen,
der Cash-Übergang. Das sind echte Ereignisse an einer bestimmten Grenze und bleiben in
`transitionValues`.
### 3.13.2 Kein leeres Formular
Ein Widerspruch steckt in der Sache: Der **Zeitpunkt** muss früh feststehen (er definiert die
Phasengrenze), die **Bezugsentscheide** lassen sich aber erst beurteilen, wenn bis dahin geplant
ist. Ein Formular, das am Anfang leer dasteht, verlangt also Antworten, die noch niemand geben
kann und der Plan wäre bis dahin nicht einmal rechenbar.
Auflösung: **ein vollständiger Vorschlag, den man korrigiert.** `withRetirementDefaults` füllt
jeden Entscheid mit einer Vorgabe, die für die meisten Fälle richtig ist:
| Säule | Vorgabe | warum diese |
|---|---|---|
| AHV | Bezug ab Referenzalter | der gesetzliche Normalfall |
| Pensionskasse | `capitalSharePct = 0` (volle Rente) | die Rente ist die Regel; ein Kapitalbezug ist der begründungspflichtige Fall |
| Säule 3a | `withdrawalAge` = Pensionsalter, gekappt auf 6070 | wer mit 58 aufhört, kann die 3a trotzdem erst mit 60 beziehen |
Die Rentenlücke steht damit ab der ersten Sekunde da und wird mit jeder geplanten Phase genauer.
Dass eine Vorgabe nicht dasselbe ist wie ein Entscheid, hält die **dreistufige Ampel** fest
([3.5.3](#353-ampel-logik-offene-entscheide)).
### 3.13.3 Der Bildschirm
Eigene Ansicht, gleichrangig **neben** der Matrix (Umschalter darüber), plus eine Kurzfassung in
der Matrix-Ansicht, die dorthin führt. Aufbau in der Reihenfolge, in der man tatsächlich denkt:
*Wann höre ich auf? → Was kommt dann rein? → Was habe ich auf einen Schlag? → Reicht das?
→ Was mache ich mit dem Haufen?*
Die Leitzahl ist die **Rentenlücke**. Sie ist keine neue Rechnung, sondern die Verzehrquote im
ersten Jahr, in dem niemand mehr arbeitet Renteneinkommen minus Ausgaben. Genau das rechnet
die Jahresschleife ohnehin; es fehlte nur der Begriff. Gerechnet wird sie im **Rechenkern**
(`PlanComputed.retirement`), nicht im UI: Eine Nebenrechnung in der Komponente hätte dieselbe
Driftgefahr wie bei den Rechenwegen ([4.14.3](#4143-rechenweg-protokoll)) und fiele im
PDF-Bericht anders aus.
Daneben steht die zweite Zahl, **wie lange das Vermögen trägt** (`ruinAge`, bzw. «über die ganze
Planung»).
**Die Matrix-Zellen bleiben bedienbar.** AHV, PK und 3a am Pensions-Übergang zeigen exakt
dieselbe Komponente (`RetirementFields`) zwei Ansichten auf dasselbe Objekt, kein Duplikat.
Wären es zwei Implementierungen, liefen sie auseinander.
### 3.13.4 Der Feldsatz
| Säule | Felder |
|---|---|
| **AHV** | `ahvDraw` (`EARLY`/`REFERENCE`/`DEFERRED`), `ahvMonths`, `ahvSharePct` (Teilbezug 2080 %); dazu abgesetzt als *Grundlage der Schätzung*: `avgIncomeBefore`, `gapYearsBefore` |
| **Pensionskasse** | `capitalSharePct` (0100 %), `conversionRate`, `capitalTaxRate`, `recentBuyIn` (Hinweis-Flag), Kapitalverwendung `capitalUse*` |
| **Säule 3a** | `withdrawalAge` (6070), `capitalTaxRate`, Kapitalverwendung `capitalUse*` |
| **alle** | `confirmed` |
Kürzung, Zuschlag und die Aufteilung Kapital/Rente sind **gerechnet, nicht erfasst** ihre
Formeln stehen in [4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)
und [4.9.1](#491-pension_fund).
Die Beitragskarriere vor Planbeginn lag bis 0.34 an **zwei** Orten in der Übergangszelle und,
für bei Planbeginn bereits Pensionierte, in der Phasenzelle der ersten Phase mit zwei
Codepfaden für dieselbe Frage. Jetzt an einem.
### 3.13.5 Planungshorizont
`Person.planningHorizonAge` macht das Planende **explizit**. Bisher ergab es sich stillschweigend
aus der Summe der Phasendauern: Zwei Szenarien konnten dadurch unbemerkt verschieden weit
rechnen und waren nicht vergleichbar obwohl das ihr Zweck ist.
Die Mechanik ist dieselbe wie beim Pensionsalter: **Zahl stellen, Struktur folgt.** Die letzte
Lebensphase wird so angepasst, dass der Plan bis zum Horizont läuft (`planHorizonChange`,
`POST /api/scenarios/<id>/horizon`). Fiele sie dabei unter ein Jahr, wird blockiert. Bei zwei
Personen läuft der Plan bis zum **spätesten** Horizont.
Bewusst neutral formuliert («Planungshorizont», nicht «Sterbealter»). Der Default liegt über der
Lebenserwartung: Eine zu kurze Planung sieht tragfähig aus, obwohl das Geld nur nicht lange
genug reichen muss.
### 3.13.6 Was bewusst nicht abgebildet wird
- **Teilpensionierung in Schritten.** Gesetzlich seit AHV 21 in bis zu drei Schritten möglich
(Art. 13a BVG). Voll modelliert hiesse, dass eine Person teilweise erwerbstätig ist das
bricht die Invariante, dass jede Pensionierung auf einer Phasengrenze liegt
([4.16.1](#4161-die-tragende-invariante)). Wer stufenweise aufhört, bildet das heute über
einen Teilzeit-Lohn und eine Kapitalquote ab.
- **Progressive Kapitalbezugssteuer.** Alle Bezüge desselben Jahres werden zusammengezählt, bei
Ehepaaren auch die des Partners. Das Tool rechnet mit einem Pauschalsatz und weist auf die
Staffelung hin (Roadmap Nr. 14, siehe [9.14](#914-keine-steuerschätzung)).
- **Die 3-Jahres-Sperrfrist nach einem PK-Einkauf** (Art. 79b Abs. 3 BVG). Das Tool kennt keine
Einkäufe und kann die Frist deshalb nicht prüfen statt einer Automatik gibt es die Frage
«in den letzten drei Jahren eingekauft?» und einen Warnhinweis.
- **Reglementarische Grenzen** (Mindestalter 58 vs. 60, Kapitalquote 25 % vs. 100 %,
Aufschubmöglichkeit) werden als Hinweis gezeigt, nicht als Sperre: Ein Planungstool, das den
Fall verbietet, den die eigene Kasse erlaubt, wäre falsch.
Referenz: `src/lib/retirement-decision.ts`, `src/components/RetirementPanel.tsx`,
`src/components/RetirementFields.tsx`, `src/lib/retirement.ts` (`planHorizonChange`).
--- ---
# 4. Berechnungsmodell # 4. Berechnungsmodell
@@ -2241,27 +2384,51 @@ Daraus folgen drei Fälle:
| 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. | | 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 Beide Wechsel können **innerhalb derselben Lebensphase** stattfinden. Die AHV wird deshalb
**jahresweise** ausgewertet statt als Phasenkonstante: **jahresweise** ausgewertet statt als Phasenkonstante.
### Vorbezug und Aufschub (seit 0.35)
Bis 0.34 begann die Rente **immer** mit 65. Das war schlicht falsch: Wer mit 62 aufhörte, bekam
die ungekürzte Rente erst drei Jahre später; wer bis 68 arbeitete, verschenkte den Zuschlag.
Der Bezugszeitpunkt ist seither Teil des Pensionierungs-Entscheids
([3.13](#313-pensionierung)) und wird gerechnet:
| | Regel | Grenzen |
|---|---|---|
| **Vorbezug** | **6,8 % pro Jahr**, lebenslang, monatlich anteilig | höchstens 36 Monate (ab 62) |
| **Aufschub** | **+5,2 / 10,8 / 17,1 / 24,0 / 31,5 %** nach 15 Jahren; dazwischen linear interpoliert | 12 bis 60 Monate |
| **Teilbezug** | Kürzung bzw. Zuschlag wirken nur auf den bezogenen Anteil | 2080 % |
Der Faktor greift **nach** der Ehepaar-Plafonierung: Der Plafond gilt für die ordentlichen
Renten, die individuelle Kürzung setzt darauf auf.
**Rentenbeginn und Beitragspflicht sind zwei verschiedene Alter** eine Trennung, die es vor
0.35 gar nicht gab. Der Vorbezug zieht nur den *Rentenbeginn* vor; die Beitragspflicht als
Nichterwerbstätige(r) endet unabhängig davon erst mit dem **Referenzalter**. Wer mit 62 aufhört
und ab 63 vorbezieht, bezieht ab 63 **und** zahlt bis 65 weiter:
``` ```
für jedes Jahr t der Phase: für jedes Jahr t der Phase:
alterImJahr = alterZuPhasenbeginn + t 1 alterImJahr = alterZuPhasenbeginn + t 1
alterImJahr ≥ 65 → Rente fliesst (Einkommen) alterImJahr ≥ individuellesRentenalter → Rente fliesst (Einkommen)
sonst, wenn pensioniert → Beitrag fällt an (Ausgabe, wirkt auf die Verzehrquote) nicht erwerbstätig ∧ alterImJahr < 65 → Beitrag fällt an (Ausgabe)
sonst → nichts
``` ```
Der Verlaufspunkt des AHV-Elements zeigt den Beitrag als **negativen** Wert so ist in der Beide Bedingungen können im selben Jahr gelten. Der Verlaufspunkt des AHV-Elements zeigt die
Grafik zu sehen, dass die AHV in diesen Jahren Geld kostet, statt welches zu bringen. Differenz; ein reiner Beitrag erscheint **negativ**, damit in der Grafik sichtbar ist, 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 **Für das Jahresraster wird das Rentenalter gerundet.** Die Monatsgenauigkeit steckt im
aufschieben und erhielte dafür einen Zuschlag. Das Tool lässt die Rente stattdessen fliessen *Faktor*, nicht im Auszahlungszeitpunkt FPT rechnet durchgehend jahresweise.
die vorsichtigere Annahme, und eine, die keinen zusätzlichen Entscheid verlangt.
**Nicht abgebildet:** die seit 1.1.2025 tieferen, einkommensabhängigen Kürzungssätze für Frauen
der Übergangsgeneration (Jahrgänge 19611969). Sie laufen aus, und ihre Nachbildung erforderte
eine zweite, jahrgangsabhängige Rentenformel.
**Der Beitrag als Nichterwerbstätige(r)** bemisst sich am Vermögen und am Renteneinkommen, nicht **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 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 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)). stiller Vorschlag würde nicht hinterfragt (vgl. [9.15](#915-monte-carlo-misst-risiko-um-die-annahmen-nicht-deren-richtigkeit)).
Der Hilfetext nennt die Bandbreite ausdrücklich, damit auch jemand ohne Vorwissen ein Gefühl für Der Hilfetext nennt die Bandbreite ausdrücklich, damit auch jemand ohne Vorwissen ein Gefühl für
die Grössenordnung bekommt. die Grössenordnung bekommt.
@@ -2536,13 +2703,28 @@ ownerRetiresNext = owner existiert
### 4.9.1 PENSION_FUND ### 4.9.1 PENSION_FUND
**Pensions-Übergang** (`ownerRetiresNext`), `value = ec.endValue`, Default-Modus `PENSION`: **Pensions-Übergang** (`ownerRetiresNext`), `value = ec.endValue`. Gelesen wird der
**Pensionierungs-Entscheid des Elements** ([3.13](#313-pensionierung)), nicht die Übergangszelle:
| `payoutMode` | Wirkung | ```
|---|---| sharePct = clamp(retirementDecision.capitalSharePct ?? 0, 0, 100)
| `CAPITAL` | `txInflow += round(value × (1 capitalTaxRate/100))`; `carry.value = 0`; `pkPensionAnnual = 0` | capital = round(value × sharePct / 100)
| `PENSION` | `carry.pkPensionAnnual = round(value × conversionRate / 100)`; `carry.value = 0` | rest = value capital
| `COMBI` | `capital = min(value, capitalAmount)`; `txInflow += round(capital × (1 tax/100))`; `pkPensionAnnual = round((value capital) × conversionRate / 100)`; `carry.value = 0` |
falls capital > 0:
netto = round(capital × (1 capitalTaxRate/100))
txInflow += netto
txTax += capital netto
-> geht in die Kapitalverwendung (Punkt C, Kap. 3.12.5)
carry.pkPensionAnnual = rest > 0 ? round(rest × conversionRate / 100) : 0
carry.value = 0
```
Eine **Quote** statt des früheren Modus (`PENSION`/`CAPITAL`/`COMBI`) plus Frankenbetrag:
0 % ist die volle Rente, 100 % der volle Kapitalbezug, alles dazwischen die Kombination.
Der Grund ist derselbe wie bei Punkt C verschiebt man das Pensionsalter, ändert sich das
Guthaben. Ein fixer Betrag bedeutete dann still ein anderes Verhältnis, eine Quote skaliert mit.
**Normaler Übergang (Vorbezug)** brutto entnommen, netto ins Cash: **Normaler Übergang (Vorbezug)** brutto entnommen, netto ins Cash:
``` ```
@@ -2553,10 +2735,26 @@ txInflow += round(withdrawal × (1 capitalTaxRate/100))
### 4.9.2 PILLAR_3A ### 4.9.2 PILLAR_3A
**Pensions-Übergang**: immer vollständiger Bezug **Bezug am gewählten Alter** (seit 0.35): Der Bezug hängt nicht mehr starr am Pensions-Übergang,
`txInflow += round(ec.endValue × (1 capitalTaxRate/100))`; `carry.value = 0`. sondern am `withdrawalAge` des Pensionierungs-Entscheids (6070, Vorgabe = Pensionsalter).
**Normaler Übergang (Vorbezug)**: wie PK Bruttoentnahme, Netto-Zufluss nach Gezogen wird an der **ersten Phasengrenze bei oder nach** diesem Alter; liegt der Wunsch hinter
Kapitalbezugssteuer. dem Planende, greift die letzte Grenze, damit kein Guthaben unbezogen liegen bleibt.
```
netto = round(ec.endValue × (1 capitalTaxRate/100))
txInflow += netto
carry.value = 0
```
Warum das Alter und nicht ein Ja/Nein: Ein 3a-Konto lässt sich bei der Pensionierung nur
**ganz** auflösen, und alle Kapitalbezüge desselben Jahres werden steuerlich zusammengezählt.
Gestaffelt wird deshalb über mehrere Konten mit unterschiedlichen Bezugsjahren dieses Feld
ist die einzige Stellschraube dafür. Innerhalb einer Phase kennt das Modell kein
Einzelereignis; wer exakt staffeln will, setzt eine Phasengrenze.
**Normaler Übergang (Vorbezug)**: unverändert in der Übergangszelle Bruttoentnahme,
Netto-Zufluss nach Kapitalbezugssteuer. Ein Vorbezug ist ein Ereignis an einer bestimmten
Grenze und gehört deshalb nicht zum Pensionierungs-Entscheid.
### 4.9.3 OTHER_ASSET ### 4.9.3 OTHER_ASSET
@@ -4031,7 +4229,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) | | `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP | | `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP |
| `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` | | `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` |
| **Total** | **292** | | | **Total** | **315** | |
## 8.2 Testfälle ## 8.2 Testfälle
+2 -2
View File
@@ -200,14 +200,14 @@ export function SensitivityDialog({
label="tief" label="tief"
value={dr.low} value={dr.low}
suffix={suffix} suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1} step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { low: v })} onChange={(v) => setDraft(d.id, { low: v })}
/> />
<RequiredNumberField <RequiredNumberField
label="hoch" label="hoch"
value={dr.high} value={dr.high}
suffix={suffix} suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1} step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { high: v })} onChange={(v) => setDraft(d.id, { high: v })}
/> />
</div> </div>
+24 -2
View File
@@ -48,6 +48,17 @@ export interface SliderDef {
// anders -- ein Regler vergleicht keine Treiber gegeneinander, er zeigt ein einzelnes // anders -- ein Regler vergleicht keine Treiber gegeneinander, er zeigt ein einzelnes
// absolutes Ergebnis. Ein Standardbereich erfindet also keine Aussage, er macht den Regler // absolutes Ergebnis. Ein Standardbereich erfindet also keine Aussage, er macht den Regler
// überhaupt erst bedienbar. Beide Enden bleiben editierbar. Siehe 9.27. // überhaupt erst bedienbar. Beide Enden bleiben editierbar. Siehe 9.27.
// Der geplante PK-Kapitalanteil. Bei mehreren PK-Elementen der Durchschnitt -- der Regler
// setzt ohnehin einen gemeinsamen Wert, und ein Regler, der beim Oeffnen schon vom Plan
// abweicht, waere irrefuehrend.
function plannedCapitalShare(plan: PlanInput): number {
const shares = plan.elements
.filter((e) => e.category === "PENSION_FUND")
.map((e) => e.retirementDecision?.capitalSharePct ?? 0);
if (shares.length === 0) return 0;
return Math.round(shares.reduce((a, b) => a + b, 0) / shares.length);
}
const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral: number }> = { const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral: number }> = {
// Absolute Werte: der neutrale Punkt ist der Planwert selbst und wird zur Laufzeit gesetzt. // Absolute Werte: der neutrale Punkt ist der Planwert selbst und wird zur Laufzeit gesetzt.
inflation: { min: 0, max: 4, step: 0.1, neutral: 0 }, inflation: { min: 0, max: 4, step: 0.1, neutral: 0 },
@@ -58,6 +69,11 @@ const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral
expenses: { min: -20, max: 30, step: 1, neutral: 0 }, expenses: { min: -20, max: 30, step: 1, neutral: 0 },
income: { min: -30, max: 20, step: 1, neutral: 0 }, income: { min: -30, max: 20, step: 1, neutral: 0 },
lifespan: { min: -5, max: 10, step: 1, neutral: 0 }, lifespan: { min: -5, max: 10, step: 1, neutral: 0 },
// Absolutwert in Prozent: 0 = volle Rente, 100 = volles Kapital. Neutral wird zur Laufzeit
// auf den geplanten Anteil gesetzt.
pkCapitalShare: { min: 0, max: 100, step: 5, neutral: 0 },
// Monate gegenueber dem Referenzalter: negativ = Vorbezug, positiv = Aufschub.
ahvDrawMonths: { min: -36, max: 60, step: 1, neutral: 0 },
// Der wirkliche Spielraum hängt an den Phasendauern und wird in `buildSliders` gesetzt. // Der wirkliche Spielraum hängt an den Phasendauern und wird in `buildSliders` gesetzt.
retirementA: { min: -3, max: 3, step: 1, neutral: 0 }, retirementA: { min: -3, max: 3, step: 1, neutral: 0 },
retirementB: { min: -3, max: 3, step: 1, neutral: 0 }, retirementB: { min: -3, max: 3, step: 1, neutral: 0 },
@@ -92,8 +108,14 @@ export function buildSliders(plan: PlanInput, expandReturns: boolean): SliderDef
min: hard ? Math.max(r.min, hard.min) : r.min, min: hard ? Math.max(r.min, hard.min) : r.min,
max: hard ? Math.min(r.max, hard.max) : r.max, max: hard ? Math.min(r.max, hard.max) : r.max,
step: r.step, step: r.step,
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan. // Absolute Treiber: neutral ist der Planwert selbst, sonst stünde der Regler beim
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0, // Öffnen an einer Stelle, die den Plan bereits verändert.
neutral:
def.id === "inflation"
? plan.inflationRateDefault
: def.id === "pkCapitalShare"
? plannedCapitalShare(plan)
: 0,
}); });
} }
if (expandReturns) { if (expandReturns) {
+14 -1
View File
@@ -68,12 +68,25 @@ describe("Datenbank-Migrationen", () => {
expect(scenCols).not.toContain("startYear"); expect(scenCols).not.toContain("startYear");
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
// Person trägt nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt. // Person trägt Pensionsalter und Planungshorizont -- Name und Alter beschreiben den
// Haushalt und liegen am Plan.
const personCols = await cols("Person"); const personCols = await cols("Person");
expect(personCols).toContain("retirementAge"); expect(personCols).toContain("retirementAge");
expect(personCols).toContain("planningHorizonAge");
expect(personCols).not.toContain("name"); expect(personCols).not.toContain("name");
expect(personCols).not.toContain("age"); expect(personCols).not.toContain("age");
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
// nur so überlebt er eine Verschiebung der Zeitachse.
expect(await cols("FinancialElement")).toContain("retirementDecision");
// Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben.
const rd = await db.query<{ is_nullable: string }>(
`SELECT is_nullable FROM information_schema.columns
WHERE table_name='FinancialElement' AND column_name='retirementDecision'`
);
expect(rd.rows[0]?.is_nullable).toBe("YES");
// Ein Plan ohne Haushaltsform wäre nicht rechenbar. // Ein Plan ohne Haushaltsform wäre nicht rechenbar.
const hh = await db.query<{ is_nullable: string }>( const hh = await db.query<{ is_nullable: string }>(
`SELECT is_nullable FROM information_schema.columns `SELECT is_nullable FROM information_schema.columns
+345
View File
@@ -0,0 +1,345 @@
// Der Pensionierungs-Entscheid (0.34).
//
// Zwei Dinge werden hier festgenagelt: die gesetzlichen Faktoren des flexiblen AHV-Bezugs
// (die sind nachschlagbar und muessen exakt stimmen) und die Eigenschaft, um derentwillen
// der ganze Umbau stattfand -- dass ein Entscheid eine Verschiebung der Zeitachse ueberlebt.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import {
ahvDrawLabel,
ahvFactor,
ahvStartAge,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import { shiftRetirement } from "@/lib/retirement";
import { decisionsText, totalOpenDecisions } from "@/lib/decisions";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
let idc = 0;
const nid = () => `r${idc++}`;
function el(
category: ElementCategory,
ownerRole: string | null,
phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {},
retirementDecision: RetirementDecision = {}
) {
return {
id: nid(),
category,
name: category,
ownerRole: ownerRole as never,
orderIndex: idc,
phaseValues,
transitionValues,
retirementDecision,
};
}
function plan(opts: {
age: number;
retirementAge: number;
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
elements: ReturnType<typeof el>[];
horizon?: number;
}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
persons: [
{
id: "A",
role: "PERSON_A",
name: null,
age: opts.age,
retirementAge: opts.retirementAge,
planningHorizonAge: opts.horizon ?? null,
},
],
phases: opts.phases.map((p, i) => ({
id: p.id,
sequenceNumber: i + 1,
name: p.id,
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
elements: opts.elements,
};
}
describe("AHV: flexibler Rentenbezug", () => {
it("kürzt den Vorbezug mit 6,8 % pro Jahr", () => {
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 12 })).toBeCloseTo(0.932, 5);
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 24 })).toBeCloseTo(0.864, 5);
// Monatsgenau, nicht nur in ganzen Jahren (seit AHV 21).
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 6 })).toBeCloseTo(0.966, 5);
});
it("trifft die amtlichen Zuschlagssätze beim Aufschub", () => {
const bonus = (jahre: number) =>
Math.round((ahvFactor({ ahvDraw: "DEFERRED", ahvMonths: jahre * 12 }) - 1) * 1000) / 10;
expect(bonus(1)).toBe(5.2);
expect(bonus(2)).toBe(10.8);
expect(bonus(3)).toBe(17.1);
expect(bonus(4)).toBe(24.0);
expect(bonus(5)).toBe(31.5);
});
it("lässt den Referenzbezug unverändert und kappt am gesetzlichen Fenster", () => {
expect(ahvFactor({})).toBe(1);
expect(ahvFactor({ ahvDraw: "REFERENCE", ahvMonths: 99 })).toBe(1);
// Vorbezug höchstens 3 Jahre, Aufschub höchstens 5.
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 999 })).toBeCloseTo(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 36 }), 9);
expect(ahvFactor({ ahvDraw: "DEFERRED", ahvMonths: 999 })).toBeCloseTo(1.315, 5);
});
it("verschiebt den Rentenbeginn entsprechend", () => {
expect(ahvStartAge({})).toBe(AHV_REFERENCE_AGE);
expect(ahvStartAge({ ahvDraw: "EARLY", ahvMonths: 24 })).toBe(AHV_REFERENCE_AGE - 2);
expect(ahvStartAge({ ahvDraw: "DEFERRED", ahvMonths: 36 })).toBe(AHV_REFERENCE_AGE + 3);
});
it("wirkt beim Teilbezug nur auf den bezogenen Anteil", () => {
// 50 % zwei Jahre vorbezogen (-13,6 %), der Rest ungekürzt -> halbe Wirkung.
const voll = ahvFactor({ ahvDraw: "EARLY", ahvMonths: 24, ahvSharePct: 100 });
const halb = ahvFactor({ ahvDraw: "EARLY", ahvMonths: 24, ahvSharePct: 50 });
expect(halb).toBeCloseTo(1 - (1 - voll) / 2, 9);
});
it("beschriftet den Entscheid lesbar", () => {
expect(ahvDrawLabel({})).toBe("Referenzalter");
expect(ahvDrawLabel({ ahvDraw: "EARLY", ahvMonths: 24 })).toContain("Vorbezug");
expect(ahvDrawLabel({ ahvDraw: "EARLY", ahvMonths: 24 })).toContain("-13.6");
expect(ahvDrawLabel({ ahvDraw: "DEFERRED", ahvMonths: 60 })).toContain("+31.5");
});
});
describe("AHV im Rechenkern: Rentenbeginn und Beitragspflicht sind zwei Alter", () => {
function p(rd: RetirementDecision, retirementAge = 62) {
return plan({
age: 60,
retirementAge,
phases: [
{ id: "p1", durationYears: retirementAge - 60 },
{ id: "p2", durationYears: 15 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000 }, p2: { amount: 50000 } }),
el("AHV", "PERSON_A", { p1: {}, p2: { ahvContribution: 1000 } }, {}, { avgIncomeBefore: 90000, ...rd }),
],
});
}
const ahvYearly = (pl: PlanInput) => {
const c = computePlan(pl);
const ec = c.phases[1].elements.find((e) => e.category === "AHV")!;
return ec.yearly;
};
// Hinweis zur Konvention: `age` im Verlauf ist das Alter am JAHRESENDE, die Rentenprüfung
// nutzt das Alter am Jahresanfang. Deshalb wird hier nicht auf absolute Alter geprüft,
// sondern auf das erste Jahr mit Rente -- das ist die Aussage, um die es geht.
const erstesRentenjahr = (pl: PlanInput) => ahvYearly(pl).find((x) => x.value > 0)?.age ?? null;
it("zahlt die Rente erst ab dem Referenzalter, wenn nicht vorbezogen wird", () => {
const y = ahvYearly(p({}));
expect(y[0].value).toBeLessThan(0); // vor dem Referenzalter nur der Beitrag
expect(erstesRentenjahr(p({}))).not.toBeNull();
});
it("zieht den Rentenbeginn beim Vorbezug um genau die Vorbezugsdauer vor", () => {
const ohne = erstesRentenjahr(p({}))!;
const zweiJahre = erstesRentenjahr(p({ ahvDraw: "EARLY", ahvMonths: 24 }))!;
expect(ohne - zweiJahre).toBe(2);
});
it("schiebt ihn beim Aufschub entsprechend nach hinten", () => {
const ohne = erstesRentenjahr(p({}))!;
const spaeter = erstesRentenjahr(p({ ahvDraw: "DEFERRED", ahvMonths: 36 }))!;
expect(spaeter - ohne).toBe(3);
});
it("beendet die Beitragspflicht unabhängig davon erst mit dem Referenzalter", () => {
// Der fachliche Kern: Wer mit 62 aufhört und ab 63 vorbezieht, bezieht ab 63 UND zahlt
// bis 65 weiter. Im Überlappungsjahr ist der Wert deshalb um den Beitrag gemindert.
const y = ahvYearly(p({ ahvDraw: "EARLY", ahvMonths: 24 }));
const mitRente = y.filter((x) => x.value > 0).map((x) => x.value);
const kleinster = Math.min(...mitRente);
const groesster = Math.max(...mitRente);
expect(groesster - kleinster).toBe(1000);
});
it("kürzt die Rente dauerhaft um den Vorbezugsfaktor", () => {
const ohne = computePlan(p({})).retirement.perPerson[0].ahvAnnual;
const mit = computePlan(p({ ahvDraw: "EARLY", ahvMonths: 24 })).retirement.perPerson[0].ahvAnnual;
expect(mit).toBe(Math.round(ohne * 0.864));
});
it("erhöht sie beim Aufschub", () => {
const ohne = computePlan(p({})).retirement.perPerson[0].ahvAnnual;
const mit = computePlan(p({ ahvDraw: "DEFERRED", ahvMonths: 36 })).retirement.perPerson[0].ahvAnnual;
expect(mit).toBe(Math.round(ohne * 1.171));
});
});
describe("Pensionskasse: eine Quote statt Modus und Frankenbetrag", () => {
function p(sharePct: number) {
return plan({
age: 62,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 3 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000 }, p2: { amount: 50000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 500000, expectedReturn: 0 }, p2: {} },
{},
{ capitalSharePct: sharePct, conversionRate: 6, capitalTaxRate: 0 }
),
],
});
}
const pk = (share: number) => computePlan(p(share)).retirement.perPerson[0];
it("0 % ist die volle Rente", () => {
expect(pk(0).pkPensionAnnual).toBe(30000); // 500'000 x 6 %
expect(pk(0).capitalAtRetirement).toBe(0);
});
it("100 % ist der volle Kapitalbezug", () => {
expect(pk(100).pkPensionAnnual).toBe(0);
expect(pk(100).capitalAtRetirement).toBe(500000);
});
it("dazwischen wird sauber geteilt", () => {
expect(pk(40).capitalAtRetirement).toBe(200000);
expect(pk(40).pkPensionAnnual).toBe(18000); // 300'000 x 6 %
});
it("skaliert mit dem Pensionsalter -- genau darum eine Quote und kein Betrag", () => {
// Ein Jahr laenger arbeiten heisst mehr Guthaben; die Aufteilung 40/60 bleibt.
const base = p(40);
const laenger: PlanInput = {
...base,
persons: base.persons.map((x) => ({ ...x, retirementAge: 66 })),
phases: base.phases.map((ph, i) => (i === 0 ? { ...ph, durationYears: 4 } : { ...ph, durationYears: 9 })),
};
const r = computePlan(laenger).retirement.perPerson[0];
// Ohne Rendite waechst das Guthaben hier nicht -- entscheidend ist, dass das VERHAELTNIS
// gleich bleibt, nicht der Betrag.
expect(r.capitalAtRetirement / (r.capitalAtRetirement + (r.pkPensionAnnual / 6) * 100)).toBeCloseTo(0.4, 6);
});
});
describe("Der Entscheid überlebt eine Verschiebung der Zeitachse", () => {
// Der eigentliche Grund fuer den ganzen Umbau: Bis 0.33 hing der Entscheid an
// transitionValues[phaseId]. Verschob man das Pensionsalter, wanderte die Phasengrenze --
// und der Entscheid musste verlustbehaftet mitgezogen werden.
it("bleibt nach shiftRetirement unverändert am Element", () => {
const base = plan({
age: 60,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 15 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 60000 }, p2: { amount: 60000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
{},
{ capitalSharePct: 75, capitalTaxRate: 5, confirmed: true }
),
],
});
const shifted = shiftRetirement(base, "PERSON_A", -2);
expect(shifted).not.toBeNull();
const pkVorher = base.elements.find((e) => e.category === "PENSION_FUND")!.retirementDecision;
const pkNachher = shifted!.plan.elements.find((e) => e.category === "PENSION_FUND")!.retirementDecision;
expect(pkNachher).toEqual(pkVorher);
expect(pkNachher?.capitalSharePct).toBe(75);
expect(pkNachher?.confirmed).toBe(true);
});
});
describe("Vorgaben: der Plan ist ab der ersten Sekunde rechenbar", () => {
it("setzt sinnvolle Vorgaben je Kategorie", () => {
expect(withRetirementDefaults("AHV", 65, {}).ahvDraw).toBe("REFERENCE");
// Volle Rente ist die Regel -- ein Kapitalbezug ist der begruendungspflichtige Fall.
expect(withRetirementDefaults("PENSION_FUND", 65, {}).capitalSharePct).toBe(0);
expect(withRetirementDefaults("PILLAR_3A", 63, {}).withdrawalAge).toBe(63);
});
it("hält die 3a im gesetzlichen Bezugsfenster, auch bei früher Pensionierung", () => {
// Wer mit 58 aufhoert, kann die Saeule 3a trotzdem erst mit 60 beziehen.
expect(withRetirementDefaults("PILLAR_3A", 58, {}).withdrawalAge).toBe(60);
expect(withRetirementDefaults("PILLAR_3A", 75, {}).withdrawalAge).toBe(70);
});
it("überschreibt einen erfassten Wert nicht", () => {
expect(withRetirementDefaults("PENSION_FUND", 65, { capitalSharePct: 100 }).capitalSharePct).toBe(100);
});
});
describe("Ampel: drei Zustände statt zwei", () => {
// Bis 0.33 kannte das Modell nur "offen" oder "beantwortet". Eine durchgängige Vorgabe wäre
// damit als beantwortet durchgegangen -- und "volle Rente statt Kapitalbezug" hätte das
// Ergebnis massiv verändert, ohne dass es je jemandem aufgefallen wäre.
function p(confirmed: boolean) {
return plan({
age: 62,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 3, cashTransition: { mode: "NONE" } },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000 }, p2: { amount: 50000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 400000 }, p2: {} },
{},
{ capitalSharePct: 0, confirmed }
),
el("PILLAR_3A", "PERSON_A", { p1: { currentValue: 100000 }, p2: {} }, {}, { confirmed }),
],
});
}
it("zählt eine ungeprüfte Vorgabe eigens -- nicht als offenen Entscheid", () => {
const c = totalOpenDecisions(p(false), computePlan(p(false)));
expect(c.unconfirmed).toBe(2); // PK und 3a
expect(c.open).toBe(0); // der Cash-Übergang ist beantwortet
});
it("verschwindet, sobald bestätigt wurde", () => {
const c = totalOpenDecisions(p(true), computePlan(p(true)));
expect(c.unconfirmed).toBe(0);
expect(c.open).toBe(0);
});
it("benennt beides getrennt", () => {
expect(decisionsText({ open: 2, unconfirmed: 3 })).toBe("2 offene Entscheide · 3 Vorgaben ungeprüft");
expect(decisionsText({ open: 1, unconfirmed: 0 })).toBe("1 offener Entscheid");
expect(decisionsText({ open: 0, unconfirmed: 1 })).toBe("1 Vorgabe ungeprüft");
expect(decisionsText({ open: 0, unconfirmed: 0 })).toBe("");
});
});
+62 -2
View File
@@ -19,6 +19,7 @@ import { num } from "@/lib/elements";
import type { ElementCategory, PhaseData } from "@/lib/elements"; import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types"; import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
import type { ResolvedActuals } from "@/lib/actuals"; import type { ResolvedActuals } from "@/lib/actuals";
import type { RetirementDecision } from "@/lib/retirement-decision";
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement"; import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
export type DriverId = export type DriverId =
@@ -30,7 +31,9 @@ export type DriverId =
| "lifespan" | "lifespan"
| "propertyGrowth" | "propertyGrowth"
| "retirementA" | "retirementA"
| "retirementB"; | "retirementB"
| "pkCapitalShare"
| "ahvDrawMonths";
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden // Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden: // und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
@@ -39,7 +42,7 @@ export type DriverId =
// absoluter Wert würde die PK auf ETF-Rendite plätten) // absoluter Wert würde die PK auf ETF-Rendite plätten)
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge) // rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge)
// delta_years Verschiebung in Jahren // delta_years Verschiebung in Jahren
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years"; export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years" | "delta_months";
export interface DriverDef { export interface DriverDef {
id: DriverId; id: DriverId;
@@ -150,6 +153,28 @@ export const DRIVERS: DriverDef[] = [
"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.", "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"), applies: (p) => retirementDriverApplies(p, "PERSON_B"),
}, },
// Pensionierungs-Entscheide als Treiber (0.34). Sie waren bisher gar nicht abbildbar: Der
// Bezugs-Entscheid hing an einer Phasengrenze, und der Tornado verschiebt Grenzen -- die
// Entscheide wären mitgewandert oder verloren gegangen. Seit sie am Element haengen, laesst
// sich beides sauber trennen.
{
id: "pkCapitalShare",
label: "PK: Anteil Kapitalbezug",
shortLabel: "PK Kapitalanteil",
unit: "abs_pct",
help:
"Absoluter Anteil des PK-Guthabens, der als Kapital bezogen wird (0 % = volle Rente, 100 % = volles Kapital). Sinnvolle Bandbreite: 0 bis 100 %. Die Rente ist ein sicherer, lebenslanger Fluss, das Kapital ist marktabhängig und vererbbar das ist der eigentliche Zielkonflikt dieser Frage.",
applies: (p) => hasCategory(p, ["PENSION_FUND"]),
},
{
id: "ahvDrawMonths",
label: "AHV: Vorbezug / Aufschub (Monate)",
shortLabel: "AHV-Bezug",
unit: "delta_months",
help:
"Verschiebung des AHV-Rentenbeginns in MONATEN gegenüber dem Referenzalter: negativ = Vorbezug (kürzt 6,8 % pro Jahr, lebenslang), positiv = Aufschub (erhöht bis +31,5 % nach 5 Jahren). Sinnvolle Bandbreite: 36 bis +60 Monate.",
applies: (p) => hasCategory(p, ["AHV"]),
},
{ {
id: "propertyGrowth", id: "propertyGrowth",
label: "Wertsteigerung der Immobilie", label: "Wertsteigerung der Immobilie",
@@ -167,6 +192,7 @@ export const UNIT_SUFFIX: Record<DriverUnit, string> = {
delta_pp: "pp", delta_pp: "pp",
rel_pct: "%", rel_pct: "%",
delta_years: "J.", delta_years: "J.",
delta_months: "Mt.",
}; };
export function driverById(id: DriverId): DriverDef { export function driverById(id: DriverId): DriverDef {
@@ -196,6 +222,22 @@ function mapElements(
}; };
} }
// Wie `mapElements`, nur auf dem Pensionierungs-Entscheid statt auf den Phasenwerten.
function mapRetirement(
plan: PlanInput,
categories: ElementCategory[],
fn: (rd: RetirementDecision) => Partial<RetirementDecision>
): PlanInput {
return {
...plan,
elements: plan.elements.map((e) =>
categories.includes(e.category)
? { ...e, retirementDecision: { ...(e.retirementDecision ?? {}), ...fn(e.retirementDecision ?? {}) } }
: e
),
};
}
export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanInput { export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanInput {
switch (id) { switch (id) {
case "inflation": case "inflation":
@@ -215,6 +257,24 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
valueGrowth: num(pd.valueGrowth) + value, valueGrowth: num(pd.valueGrowth) + value,
})); }));
case "pkCapitalShare":
// Absolutwert, keine Verschiebung: Die Frage lautet "wie viel Kapital?", nicht
// "wie viel mehr als geplant?".
return mapRetirement(plan, ["PENSION_FUND"], () => ({
capitalSharePct: Math.max(0, Math.min(100, value)),
}));
case "ahvDrawMonths": {
// Ein einziger Regler für beide Richtungen: negativ = Vorbezug, positiv = Aufschub.
// Zwei getrennte Treiber hätten sich gegenseitig ausgeschlossen und den Tornado mit
// einer Zeile belastet, die je nach Vorzeichen der anderen wirkungslos ist.
const months = Math.round(value);
return mapRetirement(plan, ["AHV"], () => ({
ahvDraw: months === 0 ? "REFERENCE" : months < 0 ? "EARLY" : "DEFERRED",
ahvMonths: Math.abs(months),
}));
}
case "expenses": case "expenses":
case "income": { case "income": {
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist: // Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist: