Compare commits
3 Commits
7979fed7bd
...
0f656f4070
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f656f4070 | |||
| 1865db5de7 | |||
| 9907fda6f4 |
+228
-30
@@ -4,10 +4,10 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.34 |
|
||||
| **Version** | 0.35 |
|
||||
| **Datum** | 2026-07-25 |
|
||||
| **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` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
| Version | Datum | Autor | Änderung |
|
||||
|---|---|---|---|
|
||||
| 0.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 1–5 Jahren, Teilbezug 20–80 %. 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` (0–100 %). 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** (60–70) 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.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**. |
|
||||
@@ -789,25 +790,46 @@ Referenz: `src/components/ElementDetail.tsx` Zeilen 342–462.
|
||||
|
||||
### 3.5.3 Ampel-Logik: „offene" Entscheide
|
||||
|
||||
Ein Entscheid gilt als **beantwortet** (`isTransitionAnswered`), wenn das jeweilige
|
||||
Entscheidungsfeld gesetzt ist:
|
||||
Seit 0.35 gibt es **drei** Zustände statt zwei. Der dritte ist der interessanteste.
|
||||
|
||||
| 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 |
|
||||
|---|---|
|
||||
| `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt (inkl. `PARTIAL`) |
|
||||
| `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt |
|
||||
| `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt |
|
||||
| `PENSION_FUND`, `PILLAR_3A`, `AHV` | am **Pensions-Übergang**: über `confirmed` (siehe oben); sonst: `withdrawalMode` gesetzt |
|
||||
| **Cash** | `mode` gesetzt (`isCashTransitionAnswered`) |
|
||||
| alle anderen | immer beantwortet |
|
||||
|
||||
Der Übergangs-Spaltenkopf zeigt entweder „N offen" (Akzentfarbe) oder „geprüft" (grün, Häkchen).
|
||||
Offene Zellen sind farblich hervorgehoben und zeigen „?".
|
||||
Der Übergangs-Spaltenkopf zeigt „N offen" (Akzentfarbe), „N Vorgaben" (gedeckt, weil es kein
|
||||
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
|
||||
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.
|
||||
|
||||
Referenz: `src/components/PlanView.tsx` Zeilen 200–232, `src/components/ElementDetail.tsx` Zeilen 107–120.
|
||||
Referenz: `src/lib/decisions.ts` (`openTransitionCount`, `totalOpenDecisions`, `decisionsText`).
|
||||
|
||||
### 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`,
|
||||
`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 60–70 | 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 20–80 %); dazu abgesetzt als *Grundlage der Schätzung*: `avgIncomeBefore`, `gapYearsBefore` |
|
||||
| **Pensionskasse** | `capitalSharePct` (0–100 %), `conversionRate`, `capitalTaxRate`, `recentBuyIn` (Hinweis-Flag), Kapitalverwendung `capitalUse*` |
|
||||
| **Säule 3a** | `withdrawalAge` (60–70), `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
|
||||
@@ -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. |
|
||||
|
||||
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 1–5 Jahren; dazwischen linear interpoliert | 12 bis 60 Monate |
|
||||
| **Teilbezug** | Kürzung bzw. Zuschlag wirken nur auf den bezogenen Anteil | 20–80 % |
|
||||
|
||||
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:
|
||||
alterImJahr = alterZuPhasenbeginn + t − 1
|
||||
alterImJahr ≥ 65 → Rente fliesst (Einkommen)
|
||||
sonst, wenn pensioniert → Beitrag fällt an (Ausgabe, wirkt auf die Verzehrquote)
|
||||
sonst → nichts
|
||||
alterImJahr ≥ individuellesRentenalter → Rente fliesst (Einkommen)
|
||||
nicht erwerbstätig ∧ alterImJahr < 65 → Beitrag fällt an (Ausgabe)
|
||||
```
|
||||
|
||||
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.
|
||||
Beide Bedingungen können im selben Jahr gelten. Der Verlaufspunkt des AHV-Elements zeigt die
|
||||
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
|
||||
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.
|
||||
**Für das Jahresraster wird das Rentenalter gerundet.** Die Monatsgenauigkeit steckt im
|
||||
*Faktor*, nicht im Auszahlungszeitpunkt – FPT rechnet durchgehend jahresweise.
|
||||
|
||||
**Nicht abgebildet:** die seit 1.1.2025 tieferen, einkommensabhängigen Kürzungssätze für Frauen
|
||||
der Übergangsgeneration (Jahrgänge 1961–1969). 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
|
||||
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)).
|
||||
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
|
||||
die Grössenordnung bekommt.
|
||||
|
||||
@@ -2536,13 +2703,28 @@ ownerRetiresNext = owner existiert
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
| `CAPITAL` | `txInflow += round(value × (1 − capitalTaxRate/100))`; `carry.value = 0`; `pkPensionAnnual = 0` |
|
||||
| `PENSION` | `carry.pkPensionAnnual = round(value × conversionRate / 100)`; `carry.value = 0` |
|
||||
| `COMBI` | `capital = min(value, capitalAmount)`; `txInflow += round(capital × (1 − tax/100))`; `pkPensionAnnual = round((value − capital) × conversionRate / 100)`; `carry.value = 0` |
|
||||
```
|
||||
sharePct = clamp(retirementDecision.capitalSharePct ?? 0, 0, 100)
|
||||
capital = round(value × sharePct / 100)
|
||||
rest = value − capital
|
||||
|
||||
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:
|
||||
```
|
||||
@@ -2553,10 +2735,26 @@ txInflow += round(withdrawal × (1 − capitalTaxRate/100))
|
||||
|
||||
### 4.9.2 PILLAR_3A
|
||||
|
||||
**Pensions-Übergang**: immer vollständiger Bezug –
|
||||
`txInflow += round(ec.endValue × (1 − capitalTaxRate/100))`; `carry.value = 0`.
|
||||
**Normaler Übergang (Vorbezug)**: wie PK – Bruttoentnahme, Netto-Zufluss nach
|
||||
Kapitalbezugssteuer.
|
||||
**Bezug am gewählten Alter** (seit 0.35): Der Bezug hängt nicht mehr starr am Pensions-Übergang,
|
||||
sondern am `withdrawalAge` des Pensionierungs-Entscheids (60–70, Vorgabe = Pensionsalter).
|
||||
Gezogen wird an der **ersten Phasengrenze bei oder nach** diesem Alter; liegt der Wunsch hinter
|
||||
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
|
||||
|
||||
@@ -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) |
|
||||
| `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 `"` |
|
||||
| **Total** | **292** | |
|
||||
| **Total** | **315** | |
|
||||
|
||||
## 8.2 Testfälle
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Pensionierung wird eine Eigenschaft der PERSON statt der Zeitachse (SPEZIFIKATION 3.13).
|
||||
--
|
||||
-- (1) `FinancialElement.retirementDecision` haelt den Bezugs-Entscheid von AHV, Pensionskasse
|
||||
-- und Saeule 3a. Bisher lag er in `ElementTransitionValue` -- also am Schluessel
|
||||
-- Element x Phasen-ID. Verschob sich das Pensionsalter, wanderte die Phasengrenze, und der
|
||||
-- Entscheid musste verlustbehaftet von Grenze zu Grenze gerettet werden. Ohne Phasen-ID im
|
||||
-- Schluessel ueberlebt er jede Zeitachsen-Aenderung.
|
||||
--
|
||||
-- (2) `Person.planningHorizonAge` macht das Planende explizit. Bisher ergab es sich
|
||||
-- stillschweigend als Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt
|
||||
-- verschieden weit rechnen und waren nicht vergleichbar. NULL bedeutet weiterhin
|
||||
-- "aus den Phasendauern ableiten", der Wert ist also rueckwaertskompatibel.
|
||||
--
|
||||
-- Bewusst OHNE Datenmigration: Alte Entscheide in `ElementTransitionValue` bleiben liegen und
|
||||
-- werden vom Rechenkern ignoriert. Die betroffenen Elemente erscheinen dadurch als
|
||||
-- "Vorgabe ungeprueft" -- genau der Zustand, den das neue Drei-Zustands-Modell dafuer kennt.
|
||||
ALTER TABLE "FinancialElement" ADD COLUMN "retirementDecision" JSONB;
|
||||
ALTER TABLE "Person" ADD COLUMN "planningHorizonAge" INTEGER;
|
||||
@@ -77,6 +77,10 @@ model Person {
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
role PersonRole
|
||||
retirementAge Int
|
||||
// Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus
|
||||
// der Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit
|
||||
// rechnen und waren nicht vergleichbar.
|
||||
planningHorizonAge Int?
|
||||
|
||||
@@unique([scenarioId, role])
|
||||
}
|
||||
@@ -301,6 +305,10 @@ model FinancialElement {
|
||||
// Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs.
|
||||
sourceElementId String?
|
||||
|
||||
// Pensionierungs-Entscheid (AHV/PK/3a). Bewusst OHNE Phasen-ID im Schluessel: So ueberlebt
|
||||
// er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts.
|
||||
retirementDecision Json?
|
||||
|
||||
phaseValues ElementPhaseValue[]
|
||||
transitionValues ElementTransitionValue[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { RETIREMENT_CATEGORIES, retirementDecisionSchema } from "@/lib/retirement-decision";
|
||||
|
||||
// Speichert den Pensionierungs-Entscheid eines Elements (AHV, PK, Säule 3a).
|
||||
//
|
||||
// Bewusst OHNE Phasenbezug in der Route: Der Entscheid gilt für die Pensionierung des
|
||||
// Besitzers, wo immer die auf der Zeitachse gerade liegt. Genau das unterscheidet ihn vom
|
||||
// Übergangs-Entscheid (`/transition/<fromPhaseId>`), der an einer konkreten Grenze hängt.
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ elementId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { elementId } = await params;
|
||||
|
||||
const element = await getOwnedElement(elementId, userId);
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
if (!RETIREMENT_CATEGORIES.includes(element.category)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nur AHV, Pensionskasse und Säule 3a kennen einen Pensionierungs-Entscheid." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = retirementDecisionSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.financialElement.update({
|
||||
where: { id: elementId },
|
||||
data: { retirementDecision: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -34,7 +34,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
inflationRateDefault: source.inflationRateDefault,
|
||||
initialCash: source.initialCash,
|
||||
persons: {
|
||||
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
|
||||
create: source.persons.map((p) => ({
|
||||
role: p.role,
|
||||
retirementAge: p.retirementAge,
|
||||
planningHorizonAge: p.planningHorizonAge,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -64,6 +68,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
name: el.name,
|
||||
ownerRole: el.ownerRole,
|
||||
orderIndex: el.orderIndex,
|
||||
// Ohne das waere die Kopie eines Szenarios genau fuer den Zweck unbrauchbar, fuer
|
||||
// den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario.
|
||||
retirementDecision: el.retirementDecision ?? undefined,
|
||||
sourceElementId: el.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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 { planHorizonChange } from "@/lib/retirement";
|
||||
import { MAX_PLANNING_HORIZON_AGE, MIN_PLANNING_HORIZON_AGE } from "@/lib/constants";
|
||||
|
||||
// Planungshorizont setzen: bis zu welchem Alter gerechnet wird.
|
||||
//
|
||||
// Wie beim Pensionsalter gilt: Zahl stellen, Struktur folgt. Die LETZTE Lebensphase wird so
|
||||
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Vorher ergab sich das
|
||||
// Planende stillschweigend aus der Summe der Phasendauern -- zwei Szenarien konnten dadurch
|
||||
// unbemerkt verschieden weit rechnen und waren nicht vergleichbar.
|
||||
|
||||
const bodySchema = z.object({
|
||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||
horizonAge: z.number().int().min(MIN_PLANNING_HORIZON_AGE).max(MAX_PLANNING_HORIZON_AGE),
|
||||
});
|
||||
|
||||
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, horizonAge } = parsed.data;
|
||||
|
||||
const planInput = toPlanInput(scenario);
|
||||
const person = planInput.persons.find((p) => p.role === role);
|
||||
if (!person) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
|
||||
if (horizonAge <= person.retirementAge) {
|
||||
return NextResponse.json(
|
||||
{ error: "Der Planungshorizont muss nach der Pensionierung liegen." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const change = planHorizonChange(planInput, horizonAge, role);
|
||||
if (!change) return NextResponse.json({ error: "Es gibt keine Lebensphase, die sich anpassen liesse." }, { status: 400 });
|
||||
if (change.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.person.update({ where: { id: person.id }, data: { planningHorizonAge: horizonAge } }),
|
||||
prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } }),
|
||||
]);
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ ok: true, lastPhaseDuration: change.newDuration });
|
||||
}
|
||||
@@ -29,7 +29,6 @@ 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,
|
||||
PILLAR_3A_MAX_ANNUAL,
|
||||
PILLAR_3A_MAX_SELF_EMPLOYED,
|
||||
@@ -881,11 +880,15 @@ export function ElementTransitionFields({
|
||||
context,
|
||||
td,
|
||||
setT,
|
||||
retirement,
|
||||
}: {
|
||||
element: { category: ElementCategory };
|
||||
context: CellContext;
|
||||
td: TransitionData;
|
||||
setT: (patch: Partial<TransitionData>) => void;
|
||||
// Der fertig gerenderte Pensionierungs-Baustein (AHV/PK/3a). Wird vom Aufrufer geliefert,
|
||||
// damit dieses Modul nichts über den Pensionierungs-Bildschirm wissen muss.
|
||||
retirement?: React.ReactNode;
|
||||
}) {
|
||||
switch (element.category) {
|
||||
case "INCOME":
|
||||
@@ -897,8 +900,10 @@ export function ElementTransitionFields({
|
||||
</p>
|
||||
);
|
||||
case "AHV":
|
||||
if (context.isRetirementTransition && context.ahvCareer) {
|
||||
return <AhvReviewFields career={context.ahvCareer} td={td} setT={setT} />;
|
||||
// Der Bezugs-Entscheid liegt seit 0.34 am Element und wird hier nur ANDERS ANGESCHAUT --
|
||||
// es ist dieselbe Komponente wie im Pensionierungs-Bildschirm, kein Duplikat.
|
||||
if (context.isRetirementTransition && retirement) {
|
||||
return <div className="col-span-2">{retirement}</div>;
|
||||
}
|
||||
return (
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
@@ -906,103 +911,14 @@ export function ElementTransitionFields({
|
||||
</p>
|
||||
);
|
||||
case "PENSION_FUND": {
|
||||
if (context.isRetirementTransition) {
|
||||
const mode = td.payoutMode ?? "PENSION";
|
||||
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
|
||||
// Brutto = ganzes Guthaben (Kapitalbezug) bzw. der gewählte Teil (Kombination).
|
||||
const brutto =
|
||||
mode === "COMBI"
|
||||
? Math.min(context.carriedEndValue, Math.round(num(td.capitalAmount)))
|
||||
: context.carriedEndValue;
|
||||
const netto = Math.round(brutto * (1 - taxRate / 100));
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
label="Bezugsart bei Pensionierung"
|
||||
value={mode}
|
||||
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })}
|
||||
options={[
|
||||
{ value: "PENSION", label: "Rente" },
|
||||
{ value: "CAPITAL", label: "Kapitalbezug" },
|
||||
{ value: "COMBI", label: "Kombination" },
|
||||
]}
|
||||
/>
|
||||
{(mode === "PENSION" || mode === "COMBI") && (
|
||||
<NumberField
|
||||
label="Umwandlungssatz (%)"
|
||||
help="Jährliche Rente = verrentetes Kapital x Umwandlungssatz."
|
||||
step={0.1}
|
||||
value={num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)}
|
||||
onChange={(v) => setT({ conversionRate: v })}
|
||||
/>
|
||||
)}
|
||||
{mode === "COMBI" && (
|
||||
<MoneyField
|
||||
label="Davon Kapitalbezug (CHF)"
|
||||
help={`Der Rest wird verrentet. Maximal ${formatChf(context.carriedEndValue)}.`}
|
||||
value={num(td.capitalAmount)}
|
||||
max={context.carriedEndValue}
|
||||
onChange={(v) => setT({ capitalAmount: v })}
|
||||
/>
|
||||
)}
|
||||
{/* Erst der Betrag, dann die Steuer, dann die Verwendung -- in der Reihenfolge,
|
||||
in der man die Entscheidung tatsächlich trifft. */}
|
||||
{(mode === "CAPITAL" || mode === "COMBI") && (
|
||||
<>
|
||||
<DerivedField
|
||||
label="Kapitalbezug brutto"
|
||||
value={brutto}
|
||||
help="Das Guthaben am Ende der Vorphase (bei einer Kombination der oben gewählte Anteil)."
|
||||
/>
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Pauschalsatz – die tatsächliche Steuer ist kantonal und progressiv."
|
||||
step={0.5}
|
||||
value={taxRate}
|
||||
onChange={(v) => setT({ capitalTaxRate: v })}
|
||||
/>
|
||||
<DerivedField
|
||||
label="Auszahlung netto"
|
||||
value={netto}
|
||||
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
|
||||
/>
|
||||
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (context.isRetirementTransition && retirement) {
|
||||
return <div className="col-span-2">{retirement}</div>;
|
||||
}
|
||||
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />;
|
||||
}
|
||||
case "PILLAR_3A": {
|
||||
if (context.isRetirementTransition) {
|
||||
// Die Säule 3a wird bei der Pensionierung IMMER vollständig bezogen -- entschieden
|
||||
// wird hier der Steuersatz und die Verwendung des Geldes.
|
||||
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
|
||||
const brutto = context.carriedEndValue;
|
||||
const netto = Math.round(brutto * (1 - taxRate / 100));
|
||||
return (
|
||||
<>
|
||||
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Die Säule 3a wird bei der Pensionierung <strong className="text-fg">vollständig bezogen</strong>. Zu
|
||||
entscheiden sind der Steuersatz und die Verwendung des Geldes.
|
||||
</p>
|
||||
<DerivedField label="Bezug brutto" value={brutto} help="Das Guthaben am Ende der Vorphase." />
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Pauschalsatz – die tatsächliche Steuer ist kantonal und progressiv. Gestaffelte Bezüge (PK und 3a in verschiedenen Jahren) senken sie; das bildet das Tool noch nicht ab."
|
||||
step={0.5}
|
||||
value={taxRate}
|
||||
onChange={(v) => setT({ capitalTaxRate: v })}
|
||||
/>
|
||||
<DerivedField
|
||||
label="Auszahlung netto"
|
||||
value={netto}
|
||||
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
|
||||
/>
|
||||
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
|
||||
</>
|
||||
);
|
||||
if (context.isRetirementTransition && retirement) {
|
||||
return <div className="col-span-2">{retirement}</div>;
|
||||
}
|
||||
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />;
|
||||
}
|
||||
|
||||
+156
-12
@@ -15,6 +15,7 @@ import {
|
||||
Pencil,
|
||||
Maximize2,
|
||||
PiggyBank,
|
||||
Table2,
|
||||
Plus,
|
||||
Settings2,
|
||||
ShoppingCart,
|
||||
@@ -30,9 +31,20 @@ import { capitalPot } from "@/lib/distribution";
|
||||
import {
|
||||
isRetirementTransition,
|
||||
openTransitionCount,
|
||||
decisionsText,
|
||||
type DecisionCounts,
|
||||
transitionInactive as inactiveAtTransition,
|
||||
TRANSITION_CATEGORIES,
|
||||
retirementConfirmed,
|
||||
} from "@/lib/decisions";
|
||||
import { AHV_REFERENCE_AGE } from "@/lib/constants";
|
||||
import {
|
||||
RETIREMENT_CATEGORIES,
|
||||
withRetirementDefaults,
|
||||
type RetirementDecision,
|
||||
} from "@/lib/retirement-decision";
|
||||
import { RetirementFields } from "@/components/RetirementFields";
|
||||
import { RetirementPanel } from "@/components/RetirementPanel";
|
||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||
import {
|
||||
@@ -170,6 +182,8 @@ export function PlanView({
|
||||
// weil beide mehrere Elemente auf einmal bearbeiten.
|
||||
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
|
||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||
// Matrix oder Pensionierung. Kein URL-Zustand: Es ist eine Arbeitsansicht, kein Ort.
|
||||
const [view, setView] = useState<"matrix" | "retirement">("matrix");
|
||||
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
|
||||
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
|
||||
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
|
||||
@@ -352,6 +366,18 @@ export function PlanView({
|
||||
onChanged();
|
||||
}
|
||||
|
||||
// Kurzfassung der Pensionierung fuer die Matrix-Ansicht: die eine Zahl, die zaehlt, plus
|
||||
// der Hinweis auf ungepruefte Vorgaben. Ein Klick fuehrt in den Bildschirm.
|
||||
const retirementSummaryText = (() => {
|
||||
const r = computed.retirement;
|
||||
if (r.gapAnnual === null) return null;
|
||||
const unconfirmed = plan.elements.filter(
|
||||
(e) => RETIREMENT_CATEGORIES.includes(e.category) && !retirementConfirmed(e)
|
||||
).length;
|
||||
const luecke = r.gapAnnual < 0 ? `Rentenluecke ${formatChf(r.gapAnnual)}/Jahr` : `Ueberschuss ${formatChf(r.gapAnnual)}/Jahr`;
|
||||
return unconfirmed > 0 ? `${luecke} · ${unconfirmed} Vorgaben ungeprueft` : luecke;
|
||||
})();
|
||||
|
||||
const hasPhases = computed.phases.length > 0;
|
||||
const firstPhase = computed.phases[0] ?? null;
|
||||
const lastPhase = computed.phases[computed.phases.length - 1] ?? null;
|
||||
@@ -438,9 +464,46 @@ export function PlanView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ansicht: Matrix oder Pensionierung. Die Pensionierung steht bewusst gleichrangig
|
||||
NEBEN der Matrix und nicht in ihr: Sie ist keine Zelle, sondern eine eigene Frage --
|
||||
und die einzige, die man zwischen Szenarien systematisch variiert. */}
|
||||
{hasPhases && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-sm">
|
||||
{(["matrix", "retirement"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setView(v)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1 font-medium ${
|
||||
view === v ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{v === "matrix" ? <Table2 className="h-4 w-4" /> : <PiggyBank className="h-4 w-4" />}
|
||||
{v === "matrix" ? "Matrix" : "Pensionierung"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === "matrix" && retirementSummaryText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("retirement")}
|
||||
className="truncate text-xs text-muted underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
title="Zur Pensionierung"
|
||||
>
|
||||
{retirementSummaryText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "retirement" && (
|
||||
<RetirementPanel plan={plan} computed={computed} onSaved={onChanged} />
|
||||
)}
|
||||
|
||||
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
|
||||
deren Zahlen steuert. */}
|
||||
{hasPhases && (
|
||||
{hasPhases && view === "matrix" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">Anzeige</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||
@@ -554,7 +617,7 @@ export function PlanView({
|
||||
|
||||
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
|
||||
beim Scrollen sichtbar bleiben. */}
|
||||
{hasPhases && (
|
||||
{hasPhases && view === "matrix" && (
|
||||
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
|
||||
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
|
||||
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
|
||||
@@ -589,7 +652,7 @@ export function PlanView({
|
||||
) : (
|
||||
<TransitionHeader
|
||||
key={`t-${col.fromPhase.id}`}
|
||||
openCount={transitionOpenCount(col.fromPhase, col.toPhase)}
|
||||
counts={transitionOpenCount(col.fromPhase, col.toPhase)}
|
||||
onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
|
||||
/>
|
||||
)
|
||||
@@ -880,6 +943,8 @@ export function PlanView({
|
||||
const els = transitionElements(fromPhase, toPhase);
|
||||
return (
|
||||
<TransitionReviewDialog
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
fromPhase={fromPhase}
|
||||
toPhase={toPhase}
|
||||
elements={els}
|
||||
@@ -1084,6 +1149,8 @@ export function PlanView({
|
||||
const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1];
|
||||
return (
|
||||
<TransitionCellPanel
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
key={`${panel.elementId}-${panel.fromPhaseId}`}
|
||||
element={element}
|
||||
fromPhase={fromPhase}
|
||||
@@ -1451,14 +1518,22 @@ function PhaseHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) {
|
||||
const done = openCount === 0;
|
||||
function TransitionHeader({ counts, onClick }: { counts: DecisionCounts; onClick: () => void }) {
|
||||
// Eine ungeprüfte Vorgabe ist kein vergessenes Eingabefeld -- sie wird deshalb eigens
|
||||
// benannt und in gedeckterem Ton gezeigt (SPEZIFIKATION 3.5.3).
|
||||
const done = counts.open === 0 && counts.unconfirmed === 0;
|
||||
const onlyDefaults = counts.open === 0 && counts.unconfirmed > 0;
|
||||
return (
|
||||
<th
|
||||
onClick={onClick}
|
||||
data-tour="transition"
|
||||
title={done ? undefined : decisionsText(counts)}
|
||||
className={`sticky top-0 z-30 w-24 min-w-24 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
|
||||
done ? "bg-surface-2 text-success" : "bg-attention text-attention-fg"
|
||||
done
|
||||
? "bg-surface-2 text-success"
|
||||
: onlyDefaults
|
||||
? "bg-surface-2 text-muted"
|
||||
: "bg-attention text-attention-fg"
|
||||
}`}
|
||||
>
|
||||
<div>Übergang</div>
|
||||
@@ -1467,8 +1542,17 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick:
|
||||
<CheckCircle2 className="h-3 w-3" /> geprüft
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 rounded-full bg-attention-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
{openCount} offen
|
||||
<div className="mt-1 flex flex-col gap-0.5">
|
||||
{counts.open > 0 && (
|
||||
<div className="rounded-full bg-attention-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
{counts.open} offen
|
||||
</div>
|
||||
)}
|
||||
{counts.unconfirmed > 0 && (
|
||||
<div className="rounded-full border border-border px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
{counts.unconfirmed} Vorgabe{counts.unconfirmed === 1 ? "" : "n"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
@@ -1496,7 +1580,7 @@ function NextSteps({
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
columns: Column[];
|
||||
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => number;
|
||||
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => DecisionCounts;
|
||||
onReview: (fromPhaseId: string) => void;
|
||||
onAddElement: (category: ElementCategory) => void;
|
||||
onAddPhase: () => void;
|
||||
@@ -1512,14 +1596,14 @@ function NextSteps({
|
||||
for (const col of columns) {
|
||||
if (col.kind !== "transition") continue;
|
||||
const n = openCountFor(col.fromPhase, col.toPhase);
|
||||
openTotal += n;
|
||||
if (n > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id;
|
||||
openTotal += n.open;
|
||||
if (n.open > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id;
|
||||
}
|
||||
if (openTotal > 0 && firstOpenPhaseId) {
|
||||
const target = firstOpenPhaseId;
|
||||
items.push({
|
||||
key: "transitions",
|
||||
text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen – z. B. was bei der Pensionierung mit PK und 3a passiert.`,
|
||||
text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen – z. B. ob eine Immobilie verkauft wird.`,
|
||||
action: "Jetzt durchgehen",
|
||||
run: () => onReview(target),
|
||||
});
|
||||
@@ -1869,7 +1953,14 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
|
||||
}
|
||||
|
||||
// --- Dialog: geführter Übergang ---
|
||||
// Pensionsalter des Element-Besitzers -- Bezugspunkt fuer die Vorgaben des Entscheids.
|
||||
function retirementAgeOf(plan: PlanInput, el: ElementInput): number {
|
||||
return plan.persons.find((p) => p.role === el.ownerRole)?.retirementAge ?? AHV_REFERENCE_AGE;
|
||||
}
|
||||
|
||||
function TransitionReviewDialog({
|
||||
plan,
|
||||
computed,
|
||||
fromPhase,
|
||||
toPhase,
|
||||
elements,
|
||||
@@ -1879,6 +1970,8 @@ function TransitionReviewDialog({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
fromPhase: PhaseComputed;
|
||||
toPhase: PhaseComputed | undefined;
|
||||
elements: ElementInput[];
|
||||
@@ -1894,6 +1987,14 @@ function TransitionReviewDialog({
|
||||
)
|
||||
);
|
||||
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initialCash));
|
||||
// Pensionierungs-Entscheide: eigener Entwurf, weil sie an einem anderen Ort liegen als die
|
||||
// Übergangswerte -- und beim Speichern über einen eigenen Endpunkt gehen.
|
||||
const [rds, setRds] = useState<Record<string, RetirementDecision>>({});
|
||||
const effectiveRd = (el: ElementInput) =>
|
||||
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
|
||||
...(el.retirementDecision ?? {}),
|
||||
...(rds[el.id] ?? {}),
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -1905,6 +2006,11 @@ function TransitionReviewDialog({
|
||||
for (const e of elements) {
|
||||
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
|
||||
}
|
||||
// Pensionierungs-Entscheide liegen am ELEMENT und haben deshalb einen eigenen Endpunkt.
|
||||
for (const [elementId, changes] of Object.entries(rds)) {
|
||||
const el = plan.elements.find((x) => x.id === elementId);
|
||||
await api.put(`/api/elements/${elementId}/retirement`, { ...(el?.retirementDecision ?? {}), ...changes });
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
|
||||
@@ -1974,6 +2080,16 @@ function TransitionReviewDialog({
|
||||
context={ctx}
|
||||
td={tds[el.id] ?? {}}
|
||||
setT={(patch) => setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
|
||||
retirement={
|
||||
<RetirementFields
|
||||
plan={plan}
|
||||
el={el}
|
||||
defaultOpen
|
||||
rd={effectiveRd(el)}
|
||||
patch={(id, patch) => setRds((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }))}
|
||||
summary={computed.retirement.perPerson.find((x) => x.role === el.ownerRole)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2073,6 +2189,8 @@ function CashInitialPanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose
|
||||
|
||||
// --- Panel: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) ---
|
||||
function TransitionCellPanel({
|
||||
plan,
|
||||
computed,
|
||||
element,
|
||||
fromPhase,
|
||||
toPhase,
|
||||
@@ -2080,6 +2198,8 @@ function TransitionCellPanel({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
element: ElementInput;
|
||||
fromPhase: PhaseComputed;
|
||||
toPhase: PhaseComputed | undefined;
|
||||
@@ -2090,6 +2210,12 @@ function TransitionCellPanel({
|
||||
const [td, setTd] = useState<TransitionData>(() =>
|
||||
withTransitionDefaults(element.category, context.isRetirementTransition, element.transitionValues[fromPhase.id] ?? {})
|
||||
);
|
||||
const [rd, setRd] = useState<RetirementDecision>({});
|
||||
const effectiveRd = (el: ElementInput) =>
|
||||
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
|
||||
...(el.retirementDecision ?? {}),
|
||||
...rd,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -2098,6 +2224,12 @@ function TransitionCellPanel({
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/api/elements/${element.id}/transition/${fromPhase.id}`, td);
|
||||
if (Object.keys(rd).length > 0) {
|
||||
await api.put(`/api/elements/${element.id}/retirement`, {
|
||||
...(element.retirementDecision ?? {}),
|
||||
...rd,
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
@@ -2118,6 +2250,18 @@ function TransitionCellPanel({
|
||||
context={context}
|
||||
td={td}
|
||||
setT={(patch) => setTd((prev) => ({ ...prev, ...patch }))}
|
||||
retirement={
|
||||
<RetirementFields
|
||||
plan={plan}
|
||||
el={element}
|
||||
defaultOpen
|
||||
rd={effectiveRd(element)}
|
||||
patch={(_id: string, patch: Partial<RetirementDecision>) =>
|
||||
setRd((prev) => ({ ...prev, ...patch }))
|
||||
}
|
||||
summary={computed.retirement.perPerson.find((x) => x.role === element.ownerRole)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
// Die drei Säulen-Bausteine des Pensionierungs-Entscheids.
|
||||
//
|
||||
// Eigenes Modul, weil sie an ZWEI Orten erscheinen: im Pensionierungs-Bildschirm (alle
|
||||
// beieinander) und in der Matrix-Zelle am Pensions-Übergang (je einer). Das ist kein
|
||||
// Duplikat, sondern zwei Ansichten auf dasselbe Objekt -- deshalb auch genau eine
|
||||
// Implementierung. Wären es zwei, liefen sie garantiert auseinander.
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, Info } from "lucide-react";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { ownerLabel } from "@/lib/elements";
|
||||
import {
|
||||
AHV_DEFER_MAX_MONTHS,
|
||||
AHV_EARLY_MAX_MONTHS,
|
||||
AHV_REFERENCE_AGE,
|
||||
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||
PK_BUYIN_BLOCKING_YEARS,
|
||||
} from "@/lib/constants";
|
||||
import { ahvDrawLabel, withRetirementDefaults, type AhvDraw, type RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { RetirementPersonSummary } from "@/lib/calculations";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
|
||||
export type PatchFn = (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
|
||||
// Dispatcher: rendert den passenden Baustein zur Kategorie. Wird von der Matrix-Zelle
|
||||
// genutzt, die immer genau ein Element vor sich hat.
|
||||
export function RetirementFields({
|
||||
plan,
|
||||
el,
|
||||
patch,
|
||||
summary,
|
||||
defaultOpen,
|
||||
// Der bereits mit Vorgaben aufgefüllte Entscheid. Der Pensionierungs-Bildschirm reicht hier
|
||||
// seinen Entwurf herein (ungespeicherte Änderungen); die Matrix-Zelle lässt ihn weg und
|
||||
// bekommt den gespeicherten Stand.
|
||||
rd: rdOverride,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
el: ElementInput;
|
||||
patch: PatchFn;
|
||||
summary?: RetirementPersonSummary;
|
||||
defaultOpen?: boolean;
|
||||
rd?: RetirementDecision;
|
||||
}) {
|
||||
const person = plan.persons.find((p) => p.role === el.ownerRole);
|
||||
const retirementAge = person?.retirementAge ?? AHV_REFERENCE_AGE;
|
||||
const rd = rdOverride ?? withRetirementDefaults(el.category, retirementAge, el.retirementDecision);
|
||||
const siblings = plan.elements
|
||||
.filter((x) => x.category === "PILLAR_3A" && x.ownerRole === el.ownerRole && x.id !== el.id)
|
||||
.map((x) => withRetirementDefaults("PILLAR_3A", retirementAge, x.retirementDecision));
|
||||
|
||||
if (el.category === "AHV") return <AhvBlock el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
|
||||
if (el.category === "PENSION_FUND")
|
||||
return <PkBlock plan={plan} el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
|
||||
if (el.category === "PILLAR_3A")
|
||||
return (
|
||||
<Pillar3aBlock
|
||||
el={el}
|
||||
rd={rd}
|
||||
patch={patch}
|
||||
retirementAge={retirementAge}
|
||||
siblings={siblings}
|
||||
defaultOpen={defaultOpen}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Säulen-Blöcke -------------------------------------------------------------------------
|
||||
|
||||
export function Pillar({
|
||||
title,
|
||||
subtitle,
|
||||
confirmed,
|
||||
onConfirm,
|
||||
children,
|
||||
defaultOpen,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
confirmed: boolean;
|
||||
onConfirm: (v: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||
return (
|
||||
<div className="rounded-xl border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left"
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
|
||||
<span className="text-sm font-semibold text-fg">{title}</span>
|
||||
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
|
||||
{confirmed ? (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
|
||||
<Check className="h-3.5 w-3.5" /> bestätigt
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
|
||||
<Info className="h-3.5 w-3.5" /> Vorgabe ungeprüft
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => onConfirm(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Ich habe das angeschaut und bestätige es. Ohne Häkchen rechnet das Tool mit der Vorgabe – sie wird als
|
||||
«ungeprüft» ausgewiesen, damit sie nicht unbemerkt durchgeht.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AhvBlock({
|
||||
el,
|
||||
rd,
|
||||
patch,
|
||||
summary,
|
||||
defaultOpen,
|
||||
}: {
|
||||
el: ElementInput;
|
||||
rd: RetirementDecision;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
summary?: RetirementPersonSummary;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const draw = rd.ahvDraw ?? "REFERENCE";
|
||||
const months = rd.ahvMonths ?? 0;
|
||||
const maxMonths = draw === "EARLY" ? AHV_EARLY_MAX_MONTHS : AHV_DEFER_MAX_MONTHS;
|
||||
|
||||
return (
|
||||
<Pillar
|
||||
title="AHV"
|
||||
subtitle={`${ahvDrawLabel(rd)} · ${formatChf(summary?.ahvAnnual ?? 0)} / Jahr`}
|
||||
confirmed={rd.confirmed === true}
|
||||
onConfirm={(v) => patch(el.id, { confirmed: v })}
|
||||
defaultOpen={defaultOpen}
|
||||
>
|
||||
<SelectField
|
||||
label="Bezug"
|
||||
help={`Referenzalter ist ${AHV_REFERENCE_AGE}. Ein Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie (nach 5 Jahren um 31,5 %).`}
|
||||
value={draw}
|
||||
onChange={(v: AhvDraw) =>
|
||||
patch(el.id, { ahvDraw: v, ahvMonths: v === "REFERENCE" ? 0 : months || 12 })
|
||||
}
|
||||
options={[
|
||||
{ value: "REFERENCE", label: `Ab Referenzalter (${AHV_REFERENCE_AGE})` },
|
||||
{ value: "EARLY", label: "Vorbeziehen" },
|
||||
{ value: "DEFERRED", label: "Aufschieben" },
|
||||
]}
|
||||
/>
|
||||
{draw !== "REFERENCE" && (
|
||||
<NumberField
|
||||
label={draw === "EARLY" ? "Vorbezug (Monate)" : "Aufschub (Monate)"}
|
||||
help={
|
||||
draw === "EARLY"
|
||||
? `Höchstens ${AHV_EARLY_MAX_MONTHS} Monate (3 Jahre), seit AHV 21 monatsgenau.`
|
||||
: `Mindestens 12, höchstens ${AHV_DEFER_MAX_MONTHS} Monate (5 Jahre).`
|
||||
}
|
||||
value={months}
|
||||
min={draw === "EARLY" ? 1 : 12}
|
||||
max={maxMonths}
|
||||
step={1}
|
||||
onChange={(v) => patch(el.id, { ahvMonths: Math.max(0, Math.min(maxMonths, Math.round(v))) })}
|
||||
/>
|
||||
)}
|
||||
{draw !== "REFERENCE" && summary && (
|
||||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Rente ab Alter <strong className="text-fg">{summary.ahvFromAge}</strong>:{" "}
|
||||
<strong className="text-fg">{formatChf(summary.ahvAnnual)}</strong> pro Jahr. Die Beitragspflicht endet davon
|
||||
unabhängig erst mit {AHV_REFERENCE_AGE} – wer vorher aufhört zu arbeiten, zahlt bis dahin als
|
||||
Nichterwerbstätige(r) weiter.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Grundlage der Schätzung</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<MoneyField
|
||||
label="Durchschnittseinkommen vor Planbeginn (brutto, heutige Kaufkraft)"
|
||||
help="Aus der AHV-Rentenvorausberechnung. Bleibt das Feld leer, schätzt das Tool den Wert aus dem geplanten Durchschnitt – das ist besser als 0, aber ungenauer als dein echter Auszug."
|
||||
value={rd.avgIncomeBefore ?? 0}
|
||||
onChange={(v) => patch(el.id, { avgIncomeBefore: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Beitragslücken vor Planbeginn (Jahre)"
|
||||
help="Jahre ohne AHV-Beitrag, etwa durch Auslandaufenthalt. Jedes fehlende Jahr kürzt die Rente um rund 1/44."
|
||||
value={rd.gapYearsBefore ?? 0}
|
||||
min={0}
|
||||
max={50}
|
||||
step={1}
|
||||
onChange={(v) => patch(el.id, { gapYearsBefore: Math.max(0, Math.round(v)) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Pillar>
|
||||
);
|
||||
}
|
||||
|
||||
export function PkBlock({
|
||||
plan,
|
||||
el,
|
||||
rd,
|
||||
patch,
|
||||
summary,
|
||||
defaultOpen,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
el: ElementInput;
|
||||
rd: RetirementDecision;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
summary?: RetirementPersonSummary;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const share = rd.capitalSharePct ?? 0;
|
||||
const targets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
|
||||
|
||||
return (
|
||||
<Pillar
|
||||
title={`Pensionskasse · ${el.name}`}
|
||||
subtitle={
|
||||
share === 0
|
||||
? `Volle Rente · ${formatChf(summary?.pkPensionAnnual ?? 0)} / Jahr`
|
||||
: share === 100
|
||||
? "Volles Kapital"
|
||||
: `${100 - share} % Rente / ${share} % Kapital`
|
||||
}
|
||||
confirmed={rd.confirmed === true}
|
||||
onConfirm={(v) => patch(el.id, { confirmed: v })}
|
||||
defaultOpen={defaultOpen}
|
||||
>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||
Kapitalanteil
|
||||
<InfoBubble text="0 % = volle Rente, 100 % = volles Kapital, alles dazwischen ist die Kombination. Bewusst eine Quote und kein Frankenbetrag: Verschiebst du das Pensionsalter, ändert sich das Guthaben – die Quote skaliert mit, ein fixer Betrag würde still ein anderes Verhältnis bedeuten." />
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={share}
|
||||
onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })}
|
||||
className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-surface-2 accent-[var(--accent)]"
|
||||
/>
|
||||
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
|
||||
{share} % Kapital
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[11px] text-faint">
|
||||
<span>volle Rente</span>
|
||||
<span>volles Kapital</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NumberField
|
||||
label="Umwandlungssatz (%)"
|
||||
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil – umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
|
||||
value={rd.conversionRate ?? 0}
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={20}
|
||||
onChange={(v) => patch(el.id, { conversionRate: v })}
|
||||
/>
|
||||
{share > 0 && (
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Pauschalsatz. Die tatsächliche Steuer ist kantonal und progressiv – und alle Kapitalbezüge desselben Jahres werden zusammengezählt, bei Ehepaaren auch die des Partners. Wer staffelt, zahlt weniger."
|
||||
value={rd.capitalTaxRate ?? 0}
|
||||
step={0.5}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{share > 0 && (
|
||||
<>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="flex cursor-pointer items-start gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rd.recentBuyIn === true}
|
||||
onChange={(e) => patch(el.id, { recentBuyIn: e.target.checked })}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
In den letzten {PK_BUYIN_BLOCKING_YEARS} Jahren in die Pensionskasse eingekauft
|
||||
</span>
|
||||
</label>
|
||||
{rd.recentBuyIn && (
|
||||
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention/10 px-3 py-2 text-xs text-attention-fg">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den
|
||||
Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen
|
||||
Effekt nicht – prüfe die Fristen mit deiner Kasse.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<CapitalUse rd={rd} elementId={el.id} patch={patch} targets={targets} plan={plan} />
|
||||
</>
|
||||
)}
|
||||
</Pillar>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pillar3aBlock({
|
||||
el,
|
||||
rd,
|
||||
patch,
|
||||
retirementAge,
|
||||
siblings,
|
||||
defaultOpen,
|
||||
}: {
|
||||
el: ElementInput;
|
||||
rd: RetirementDecision;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
retirementAge: number;
|
||||
siblings: RetirementDecision[];
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const age = rd.withdrawalAge ?? retirementAge;
|
||||
const clash = siblings.some((s) => (s.withdrawalAge ?? retirementAge) === age);
|
||||
|
||||
return (
|
||||
<Pillar
|
||||
title={`Säule 3a · ${el.name}`}
|
||||
subtitle={`Bezug mit ${age}`}
|
||||
confirmed={rd.confirmed === true}
|
||||
onConfirm={(v) => patch(el.id, { confirmed: v })}
|
||||
defaultOpen={defaultOpen}
|
||||
>
|
||||
<NumberField
|
||||
label="Bezugsalter"
|
||||
help={`Frühestens ${PILLAR_3A_MIN_WITHDRAWAL_AGE}, spätestens ${PILLAR_3A_MAX_WITHDRAWAL_AGE} – und nach dem Referenzalter nur, solange du erwerbstätig bleibst. Der Bezug erfolgt an der ersten Phasengrenze bei oder nach diesem Alter.`}
|
||||
value={age}
|
||||
min={PILLAR_3A_MIN_WITHDRAWAL_AGE}
|
||||
max={PILLAR_3A_MAX_WITHDRAWAL_AGE}
|
||||
step={1}
|
||||
onChange={(v) => patch(el.id, { withdrawalAge: Math.round(v) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Pauschalsatz. Alle Kapitalbezüge desselben Jahres werden zusammengezählt – deshalb lohnt sich das Staffeln über mehrere Jahre und mehrere Konten."
|
||||
value={rd.capitalTaxRate ?? 0}
|
||||
step={0.5}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
|
||||
/>
|
||||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Ein 3a-Konto lässt sich bei der Pensionierung nur <strong className="text-fg">ganz</strong> auflösen. Gestaffelt
|
||||
wird über mehrere Konten mit unterschiedlichen Bezugsjahren.
|
||||
{age > AHV_REFERENCE_AGE && (
|
||||
<>
|
||||
{" "}
|
||||
Ein Bezug nach {AHV_REFERENCE_AGE} setzt voraus, dass du weiterhin erwerbstätig bist.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{clash && (
|
||||
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt – ein
|
||||
anderes Bezugsjahr senkt die Progression.
|
||||
</p>
|
||||
)}
|
||||
</Pillar>
|
||||
);
|
||||
}
|
||||
|
||||
// Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier
|
||||
// nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt.
|
||||
function CapitalUse({
|
||||
rd,
|
||||
elementId,
|
||||
patch,
|
||||
targets,
|
||||
plan,
|
||||
}: {
|
||||
rd: RetirementDecision;
|
||||
elementId: string;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
targets: ElementInput[];
|
||||
plan: PlanInput;
|
||||
}) {
|
||||
const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0));
|
||||
const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0));
|
||||
const cash = Math.max(0, 100 - amort - invest);
|
||||
const hasMortgage = plan.elements.some((e) => e.category === "REAL_ESTATE");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Wohin fliesst das bezogene Kapital?
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField
|
||||
label="… in die Hypothek (%)"
|
||||
help={
|
||||
hasMortgage
|
||||
? "Einmalige Amortisation. Am Restsaldo gekappt – ist die Hypothek kleiner, bleibt der Rest Cash."
|
||||
: "Es gibt keine Immobilie in diesem Szenario – dieser Anteil bliebe wirkungslos auf dem Cash."
|
||||
}
|
||||
value={amort}
|
||||
step={5}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="… in die Anlage (%)"
|
||||
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
|
||||
value={invest}
|
||||
step={5}
|
||||
min={0}
|
||||
max={100 - amort}
|
||||
onChange={(v) => patch(elementId, { 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"
|
||||
value={rd.capitalUseTargetElementId ?? targets[0].id}
|
||||
onChange={(v: string) => patch(elementId, { capitalUseTargetElementId: v })}
|
||||
options={targets.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{invest > 0 && targets.length === 0 && (
|
||||
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf
|
||||
dem Cash-Konto liegen.
|
||||
</p>
|
||||
)}
|
||||
<p className="sm:col-span-2 text-xs text-faint">
|
||||
Nicht zugeteilt: <strong className="text-fg">{cash} %</strong> – bleibt auf dem Cash-Konto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
// Der Pensionierungs-Bildschirm.
|
||||
//
|
||||
// Bis 0.33 lagen die Entscheide, die inhaltlich EINE Frage sind, in drei weit auseinander
|
||||
// liegenden Matrix-Zellen (AHV, PK, 3a) am Pensions-Übergang. Hier stehen sie beieinander --
|
||||
// in der Reihenfolge, in der man tatsächlich darüber nachdenkt:
|
||||
//
|
||||
// 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 ganz oben ist die RENTENLÜCKE. Sie ist keine neue Rechnung, sondern die
|
||||
// Verzehrquote im ersten voll pensionierten Jahr -- es fehlte bisher nur der Name dafür.
|
||||
// Gerechnet wird sie im Rechenkern (`computed.retirement`), damit Bildschirm und PDF-Bericht
|
||||
// nicht auseinanderlaufen können.
|
||||
//
|
||||
// Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollständiger Vorschlag, den man
|
||||
// korrigiert. Sonst müsste man am Anfang Fragen beantworten, die man erst am Ende beantworten
|
||||
// kann -- und ohne Vorgaben wäre der Plan bis dahin gar nicht rechenbar.
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button, useToast } from "@/components/ui";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { NumberField } from "@/components/FormField";
|
||||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||||
import { RetirementFields } from "@/components/RetirementFields";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { ownerLabel } from "@/lib/elements";
|
||||
import {
|
||||
AHV_REFERENCE_AGE,
|
||||
MAX_PLANNING_HORIZON_AGE,
|
||||
MIN_PLANNING_HORIZON_AGE,
|
||||
PK_MIN_RETIREMENT_AGE,
|
||||
} from "@/lib/constants";
|
||||
import { withRetirementDefaults, type RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { PlanComputed, RetirementPersonSummary } from "@/lib/calculations";
|
||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||
|
||||
type Draft = Record<string, RetirementDecision>;
|
||||
|
||||
export function RetirementPanel({
|
||||
plan,
|
||||
computed,
|
||||
onSaved,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [draft, setDraft] = useState<Draft>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Effektiver Entscheid je Element: gespeicherter Wert, überlagert vom Entwurf, aufgefüllt
|
||||
// mit den Vorgaben. Genau das rechnet auch der Rechenkern.
|
||||
const effective = (el: ElementInput): RetirementDecision => {
|
||||
const person = plan.persons.find((p) => p.role === el.ownerRole);
|
||||
return withRetirementDefaults(el.category, person?.retirementAge ?? AHV_REFERENCE_AGE, {
|
||||
...(el.retirementDecision ?? {}),
|
||||
...(draft[el.id] ?? {}),
|
||||
});
|
||||
};
|
||||
|
||||
const patch = (elementId: string, p: Partial<RetirementDecision>) =>
|
||||
setDraft((d) => ({ ...d, [elementId]: { ...(d[elementId] ?? {}), ...p } }));
|
||||
|
||||
const dirty = Object.keys(draft).length > 0;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (const [elementId, changes] of Object.entries(draft)) {
|
||||
const el = plan.elements.find((x) => x.id === elementId);
|
||||
if (!el) continue;
|
||||
await api.put(`/api/elements/${elementId}/retirement`, {
|
||||
...(el.retirementDecision ?? {}),
|
||||
...changes,
|
||||
});
|
||||
}
|
||||
setDraft({});
|
||||
toast("success", "Pensionierung gespeichert.");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-fg">Pensionierung</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort. Was du hier festlegst, steht auch in der
|
||||
Matrix am Pensions-Übergang – es ist derselbe Entscheid, nur anders angeschaut.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{plan.persons.map((person) => {
|
||||
const summary = computed.retirement.perPerson.find((s) => s.role === person.role);
|
||||
return (
|
||||
<PersonBlock
|
||||
key={person.role}
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
role={person.role}
|
||||
summary={summary}
|
||||
effective={effective}
|
||||
patch={patch}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
{dirty && (
|
||||
<div className="sticky bottom-0 flex items-center gap-3 border-t border-border bg-surface px-1 py-3">
|
||||
<Button disabled={saving} onClick={save}>
|
||||
{saving ? "…" : "Änderungen speichern"}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={saving} onClick={() => setDraft({})}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- ein Block je Person -------------------------------------------------------------------
|
||||
|
||||
function PersonBlock({
|
||||
plan,
|
||||
computed,
|
||||
role,
|
||||
summary,
|
||||
effective,
|
||||
patch,
|
||||
onSaved,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
role: PersonRole;
|
||||
summary: RetirementPersonSummary | undefined;
|
||||
effective: (el: ElementInput) => RetirementDecision;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const person = plan.persons.find((p) => p.role === role)!;
|
||||
const label = ownerLabel(plan.persons, role);
|
||||
const own = (cat: string) => plan.elements.filter((e) => e.category === cat && e.ownerRole === role);
|
||||
const ahvEl = own("AHV")[0];
|
||||
const pkEls = own("PENSION_FUND");
|
||||
const a3Els = own("PILLAR_3A");
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-border">
|
||||
<header className="border-b border-border px-4 py-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-base font-semibold text-fg">
|
||||
{label} · Pensionierung mit {person.retirementAge}
|
||||
</h3>
|
||||
{person.retirementAge < PK_MIN_RETIREMENT_AGE && (
|
||||
<span className="flex items-center gap-1 text-xs text-attention-fg">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Vor {PK_MIN_RETIREMENT_AGE} lässt kaum eine Pensionskasse eine Pensionierung zu.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-4 px-4 py-4 md:grid-cols-2">
|
||||
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
||||
<HorizonControl plan={plan} role={role} onSaved={onSaved} />
|
||||
</div>
|
||||
|
||||
<GapBox plan={plan} computed={computed} summary={summary} />
|
||||
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
{[ahvEl, ...pkEls, ...a3Els].filter(Boolean).map((el) => (
|
||||
<RetirementFields key={el!.id} plan={plan} el={el!} rd={effective(el!)} patch={patch} summary={summary} />
|
||||
))}
|
||||
{!ahvEl && pkEls.length === 0 && a3Els.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
Für {label} sind noch keine Vorsorge-Elemente erfasst. Lege in der Matrix AHV, Pensionskasse oder Säule 3a
|
||||
an – die Entscheide dazu erscheinen dann hier.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Leitzahlen ----------------------------------------------------------------------------
|
||||
|
||||
function GapBox({
|
||||
plan,
|
||||
computed,
|
||||
summary,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
summary: RetirementPersonSummary | undefined;
|
||||
}) {
|
||||
const r = computed.retirement;
|
||||
const startYear = plan.startYear ?? null;
|
||||
const horizon = summary?.planningHorizonAge ?? null;
|
||||
const reachesEnd = computed.ruinAge === null;
|
||||
|
||||
return (
|
||||
<div className="mx-4 mb-4 rounded-xl border border-border bg-surface-2 p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<Line label="AHV-Rente" value={summary?.ahvAnnual ?? 0} note={summary?.ahvDraw} />
|
||||
<Line label="PK-Rente" value={summary?.pkPensionAnnual ?? 0} />
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Line label="Renteneinkommen (Haushalt)" value={r.pensionIncome ?? 0} strong />
|
||||
<Line label="Ausgaben (Haushalt)" value={r.expenses ?? 0} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center text-xs uppercase tracking-wide text-faint">
|
||||
Rentenlücke
|
||||
<InfoBubble text="Renteneinkommen minus Ausgaben im ersten Jahr, in dem niemand mehr arbeitet. Ein negativer Wert ist normal – er wird aus dem Vermögen gedeckt. Entscheidend ist, wie lange das trägt." />
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-semibold tabular-nums ${
|
||||
(r.gapAnnual ?? 0) < 0 ? "text-danger" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{r.gapAnnual === null ? "–" : `${formatChf(r.gapAnnual)} / Jahr`}
|
||||
</div>
|
||||
{r.firstRetirementYear !== null && startYear && (
|
||||
<div className="text-xs text-faint">
|
||||
gerechnet für {startYear + r.firstRetirementYear - 1}, das erste voll pensionierte Jahr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-faint">Vermögen reicht</div>
|
||||
<div className={`text-lg font-semibold ${reachesEnd ? "text-success" : "text-danger"}`}>
|
||||
{reachesEnd
|
||||
? horizon
|
||||
? `bis zum Horizont (Alter ${horizon})`
|
||||
: "über die ganze Planung"
|
||||
: `bis Alter ${computed.ruinAge}`}
|
||||
</div>
|
||||
</div>
|
||||
{summary && summary.capitalAtRetirement > 0 && (
|
||||
<div className="text-xs text-muted">
|
||||
Einmalig verfügbar: <strong className="text-fg">{formatChf(summary.capitalAtRetirement)}</strong> netto
|
||||
aus PK und Säule 3a
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({ label, value, note, strong }: { label: string; value: number; note?: string; strong?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className={strong ? "font-semibold text-fg" : "text-muted"}>
|
||||
{label}
|
||||
{note && <span className="ml-1 text-xs text-faint">({note})</span>}
|
||||
</span>
|
||||
<span className={`tabular-nums ${strong ? "font-semibold text-fg" : "text-fg"}`}>{formatChf(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Planungshorizont ----------------------------------------------------------------------
|
||||
|
||||
function HorizonControl({ plan, role, onSaved }: { plan: PlanInput; role: PersonRole; onSaved: () => void }) {
|
||||
const person = plan.persons.find((p) => p.role === role)!;
|
||||
const [value, setValue] = useState(person.planningHorizonAge ?? null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
// Ohne erfassten Horizont: das Alter, bei dem der Plan heute faktisch endet.
|
||||
const implied = person.age + plan.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||
|
||||
async function submit(next: number) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post(`/api/scenarios/${plan.id}/horizon`, { role, horizonAge: next });
|
||||
toast("success", `Planungshorizont auf Alter ${next} gesetzt.`);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Nicht möglich.");
|
||||
setValue(person.planningHorizonAge ?? null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<h4 className="mb-2 flex items-center text-sm font-semibold text-fg">
|
||||
Planungshorizont
|
||||
<InfoBubble text="Bis zu welchem Alter gerechnet wird. Die letzte Lebensphase passt sich an. Ohne diesen Wert ergibt sich das Planende stillschweigend aus der Summe der Phasendauern – zwei Szenarien rechnen dann womöglich unbemerkt verschieden weit und sind nicht vergleichbar." />
|
||||
</h4>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<NumberField
|
||||
label="Alter"
|
||||
value={value ?? implied}
|
||||
min={MIN_PLANNING_HORIZON_AGE}
|
||||
max={MAX_PLANNING_HORIZON_AGE}
|
||||
step={1}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={busy || value === null || value === person.planningHorizonAge}
|
||||
onClick={() => value !== null && submit(value)}
|
||||
>
|
||||
Setzen
|
||||
</Button>
|
||||
</div>
|
||||
{person.planningHorizonAge === null && (
|
||||
<p className="mt-1 text-xs text-faint">
|
||||
Noch nicht gesetzt – der Plan endet aktuell rechnerisch mit Alter {implied}.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="mt-1 text-xs text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,14 +200,14 @@ export function SensitivityDialog({
|
||||
label="tief"
|
||||
value={dr.low}
|
||||
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 })}
|
||||
/>
|
||||
<RequiredNumberField
|
||||
label="hoch"
|
||||
value={dr.high}
|
||||
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 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+32
-12
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen
|
||||
@@ -16,9 +17,20 @@ function el(
|
||||
category: ElementCategory,
|
||||
ownerRole: string | null,
|
||||
phaseValues: Record<string, PhaseData>,
|
||||
transitionValues: Record<string, TransitionData> = {}
|
||||
transitionValues: Record<string, TransitionData> = {},
|
||||
// Pensionierungs-Entscheid: haengt seit 0.34 am Element, nicht an einer Phasengrenze.
|
||||
retirementDecision: RetirementDecision = {}
|
||||
) {
|
||||
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
|
||||
return {
|
||||
id: nid(),
|
||||
category,
|
||||
name: category,
|
||||
ownerRole: ownerRole as never,
|
||||
orderIndex: idc,
|
||||
phaseValues,
|
||||
transitionValues,
|
||||
retirementDecision,
|
||||
};
|
||||
}
|
||||
|
||||
function plan(opts: {
|
||||
@@ -84,13 +96,15 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } },
|
||||
{ p1: { payoutMode: "PENSION", conversionRate: 6 } }
|
||||
{},
|
||||
{ capitalSharePct: 0, conversionRate: 6 }
|
||||
),
|
||||
el(
|
||||
"PILLAR_3A",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } },
|
||||
{ p1: { capitalTaxRate: 8 } }
|
||||
{},
|
||||
{ capitalTaxRate: 8 }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", {
|
||||
p1: { startValue: 300000, expectedReturn: 4 },
|
||||
@@ -116,7 +130,8 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } },
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 8 } }
|
||||
{},
|
||||
{ capitalSharePct: 100, capitalTaxRate: 8 }
|
||||
),
|
||||
],
|
||||
}),
|
||||
@@ -240,7 +255,8 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} },
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } }
|
||||
{},
|
||||
{ capitalSharePct: 100, capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 }
|
||||
),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
@@ -317,7 +333,7 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", ()
|
||||
...raw,
|
||||
elements: raw.elements.map((e) =>
|
||||
e.category === "PENSION_FUND"
|
||||
? { ...e, transitionValues: { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } }
|
||||
? { ...e, retirementDecision: { capitalSharePct: 100, capitalTaxRate: 5 } }
|
||||
: e
|
||||
),
|
||||
} as PlanInput);
|
||||
@@ -372,7 +388,8 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } }
|
||||
{},
|
||||
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }),
|
||||
],
|
||||
@@ -414,7 +431,8 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
|
||||
"PILLAR_3A",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} },
|
||||
{ p1: { capitalTaxRate: 0, capitalUseInvestPct: 50 } }
|
||||
{},
|
||||
{ capitalTaxRate: 0, capitalUseInvestPct: 50 }
|
||||
),
|
||||
],
|
||||
} as PlanInput;
|
||||
@@ -444,13 +462,15 @@ describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => {
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 300000, expectedReturn: 0 }, p2: {} },
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } }
|
||||
{},
|
||||
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
|
||||
),
|
||||
el(
|
||||
"PILLAR_3A",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 200000, expectedReturn: 0 }, p2: {} },
|
||||
{ p1: { capitalTaxRate: 0, capitalUseInvestPct: 100 } }
|
||||
{},
|
||||
{ capitalTaxRate: 0, capitalUseInvestPct: 100 }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: {} }),
|
||||
],
|
||||
@@ -503,6 +523,6 @@ describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => {
|
||||
: e
|
||||
),
|
||||
};
|
||||
expect(computePlan(mitRate).phases[1].endWealth).toBe(computePlan(base).phases[1].endWealth);
|
||||
expect(computePlan(mitRate).phases[1].endWealthNominal).toBe(computePlan(base).phases[1].endWealthNominal);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations";
|
||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// --- kleine Bau-Helfer ---
|
||||
@@ -11,9 +12,20 @@ function el(
|
||||
category: ElementCategory,
|
||||
ownerRole: string | null,
|
||||
phaseValues: Record<string, PhaseData>,
|
||||
transitionValues: Record<string, TransitionData> = {}
|
||||
transitionValues: Record<string, TransitionData> = {},
|
||||
// Pensionierungs-Entscheid: hängt seit 0.34 am Element, nicht an einer Phasengrenze.
|
||||
retirementDecision: RetirementDecision = {}
|
||||
) {
|
||||
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
|
||||
return {
|
||||
id: nid(),
|
||||
category,
|
||||
name: category,
|
||||
ownerRole: ownerRole as never,
|
||||
orderIndex: idc,
|
||||
phaseValues,
|
||||
transitionValues,
|
||||
retirementDecision,
|
||||
};
|
||||
}
|
||||
|
||||
function plan(opts: {
|
||||
@@ -261,12 +273,11 @@ describe("AHV einkommensabhängig", () => {
|
||||
"AHV",
|
||||
"PERSON_A",
|
||||
{ p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} },
|
||||
{},
|
||||
{
|
||||
p1: {
|
||||
reviewed: opts.reviewed ?? true,
|
||||
confirmed: opts.reviewed ?? true,
|
||||
avgIncomeBefore: opts.avgIncomeBefore ?? 0,
|
||||
gapYearsBefore: opts.gapYearsBefore ?? 0,
|
||||
},
|
||||
}
|
||||
),
|
||||
],
|
||||
@@ -356,13 +367,13 @@ describe("AHV einkommensabhängig", () => {
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
|
||||
});
|
||||
|
||||
it("bereits bei Planbeginn pensioniert: Karriere kommt aus der Phasenzelle", () => {
|
||||
it("bereits bei Planbeginn pensioniert: Karriere kommt aus dem Pensionierungs-Entscheid", () => {
|
||||
const p = plan({
|
||||
age: 66,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [{ id: "p1", durationYears: 10 }],
|
||||
elements: [el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 60000, gapYearsBefore: 0 } })],
|
||||
elements: [el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 60000, gapYearsBefore: 0 })],
|
||||
});
|
||||
const rente = computePlan(p).phases[0].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(60000) * 13));
|
||||
@@ -377,8 +388,8 @@ describe("AHV einkommensabhängig", () => {
|
||||
],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
|
||||
elements: [
|
||||
el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 100000 } }),
|
||||
el("AHV", "PERSON_B", { p1: { avgIncomeBefore: 100000 } }),
|
||||
el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
|
||||
el("AHV", "PERSON_B", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
|
||||
],
|
||||
};
|
||||
const ph = computePlan(p).phases[0];
|
||||
|
||||
+237
-80
@@ -10,11 +10,21 @@ import {
|
||||
DEFAULT_CAPITAL_TAX_RATE,
|
||||
DEFAULT_PK_CONVERSION_RATE,
|
||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||
} from "@/lib/constants";
|
||||
import { num } from "@/lib/elements";
|
||||
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
|
||||
import type { ElementCategory, TransitionData } from "@/lib/elements";
|
||||
import type { PersonRole, PlanInput } from "@/lib/types";
|
||||
import {
|
||||
ahvDrawLabel,
|
||||
ahvFactor,
|
||||
ahvShiftMonths,
|
||||
ahvStartAge,
|
||||
withRetirementDefaults,
|
||||
type RetirementDecision,
|
||||
} from "@/lib/retirement-decision";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||
|
||||
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
|
||||
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
|
||||
@@ -215,10 +225,37 @@ export interface YearPoint {
|
||||
cash: number;
|
||||
}
|
||||
|
||||
// Kennzahlen des Pensionierungs-Bildschirms. Bewusst im Rechenkern und nicht im UI: Es sind
|
||||
// die beiden Zahlen, an denen die ganze Planung haengt -- eine Nebenrechnung in der
|
||||
// Komponente wuerde im PDF-Bericht anders ausfallen.
|
||||
export interface RetirementPersonSummary {
|
||||
personId: string;
|
||||
role: PersonRole;
|
||||
retirementAge: number;
|
||||
planningHorizonAge: number | null;
|
||||
ahvAnnual: number; // inkl. Kuerzung bei Vorbezug bzw. Zuschlag bei Aufschub
|
||||
ahvFromAge: number; // individuelles Rentenalter, nicht zwingend das Referenzalter
|
||||
ahvDraw: string; // lesbare Kurzfassung ("Referenzalter", "Vorbezug 2 J. (-13.6 %)")
|
||||
pkPensionAnnual: number;
|
||||
capitalAtRetirement: number; // PK-Kapital + Saeule 3a, NETTO nach Kapitalbezugssteuer
|
||||
}
|
||||
|
||||
export interface RetirementOverview {
|
||||
// Planjahr, ab dem NIEMAND mehr erwerbstaetig ist. null, solange jemand arbeitet.
|
||||
firstRetirementYear: number | null;
|
||||
pensionIncome: number | null;
|
||||
expenses: number | null;
|
||||
// Renteneinkommen minus Ausgaben im ersten voll pensionierten Jahr. Negativ = Luecke, die
|
||||
// aus dem Vermoegen zu decken ist.
|
||||
gapAnnual: number | null;
|
||||
perPerson: RetirementPersonSummary[];
|
||||
}
|
||||
|
||||
export interface PlanComputed {
|
||||
phases: PhaseComputed[];
|
||||
yearly: YearPoint[];
|
||||
nachlass: number;
|
||||
retirement: RetirementOverview;
|
||||
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermögen (inkl. Cash) erstmals < 0 fällt
|
||||
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (für die AHV-Prüfung am Übergang)
|
||||
traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain)
|
||||
@@ -405,11 +442,32 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
|
||||
const ahvIncomeAccum = new Map<string, number>();
|
||||
const ahvYearsAccum = new Map<string, number>();
|
||||
// Karriere VOR Planbeginn -- aus der Prüfung am Pensions-Übergang bzw. (für bereits
|
||||
// Pensionierte) aus der Phasenzelle der ersten Phase.
|
||||
const ahvBeforeByPerson = new Map<string, { avg: number; gap: number }>();
|
||||
// Karriere VOR Planbeginn und der Bezugs-Entscheid, je Person. Beides steht seit 0.34 im
|
||||
// Pensionierungs-Entscheid des AHV-Elements -- vorher lag es an ZWEI Orten (Übergangszelle
|
||||
// bzw., für bei Planbeginn bereits Pensionierte, Phasenzelle der ersten Phase), was zwei
|
||||
// Codepfade für dieselbe Frage bedeutete.
|
||||
// avg === null heisst NICHT ERFASST -- dann gilt der geplante Durchschnitt als Schaetzung
|
||||
// (siehe unten). Ein Fallback auf 0 wuerde die Rente still und massiv zu tief rechnen.
|
||||
const ahvBeforeByPerson = new Map<string, { avg: number | null; gap: number }>();
|
||||
const ahvDrawByPerson = new Map<string, RetirementDecision>();
|
||||
for (const e of plan.elements) {
|
||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||
const owner = personByRole(persons, e.ownerRole);
|
||||
if (!owner) continue;
|
||||
const rd = withRetirementDefaults("AHV", retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE, e.retirementDecision);
|
||||
ahvDrawByPerson.set(owner.id, rd);
|
||||
ahvBeforeByPerson.set(owner.id, {
|
||||
avg: typeof rd.avgIncomeBefore === "number" ? rd.avgIncomeBefore : null,
|
||||
gap: Math.max(0, Math.round(num(rd.gapYearsBefore))),
|
||||
});
|
||||
}
|
||||
const carries = new Map<string, Carry>();
|
||||
for (const e of plan.elements) carries.set(e.id, emptyCarry());
|
||||
// Fuer die Pensionierungs-Uebersicht: die zuletzt gerechnete AHV-Rente je Person und das
|
||||
// netto bezogene Alterskapital (PK + 3a). Beides faellt in der Phasen- bzw. Uebergangs-
|
||||
// schleife ohnehin an -- gesammelt wird es hier, damit es am Ende zur Verfuegung steht.
|
||||
const ahvFinalByPerson = new Map<string, number>();
|
||||
const capitalNetByPerson = new Map<string, number>();
|
||||
|
||||
const result: PhaseComputed[] = [];
|
||||
const yearly: YearPoint[] = [];
|
||||
@@ -471,20 +529,6 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
|
||||
}
|
||||
|
||||
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Übergang, an dem die
|
||||
// Beitragskarriere geprüft werden könnte -- die Werte liegen dann in der Phasenzelle.
|
||||
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 (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Übergang bereits gesetzt
|
||||
const pd = e.phaseValues[phase.id] ?? {};
|
||||
ahvBeforeByPerson.set(owner.id, {
|
||||
avg: num(pd.avgIncomeBefore),
|
||||
gap: Math.max(0, Math.round(num(pd.gapYearsBefore))),
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -496,9 +540,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||
const owner = personByRole(persons, e.ownerRole);
|
||||
if (!owner) continue;
|
||||
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 };
|
||||
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 };
|
||||
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
||||
const mdJE = ahvMdje(career, before.avg, before.gap);
|
||||
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre vor
|
||||
// Planbeginn -- exakt der Wert, den das UI vorbelegt. Beide Groessen sind BRUTTO.
|
||||
const avgBefore = before.avg ?? career.plannedAvgGrossIncome;
|
||||
const mdJE = ahvMdje(career, avgBefore, before.gap);
|
||||
const totalGap = (gapYearsByPerson.get(owner.id) ?? 0) + before.gap;
|
||||
ahvUncapped.set(owner.id, ahvAnnualPension(mdJE, totalGap));
|
||||
}
|
||||
@@ -508,6 +555,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
|
||||
if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
|
||||
}
|
||||
// Vorbezug kürzt, Aufschub erhöht -- und zwar NACH dem Ehepaar-Plafond: Der Plafond gilt
|
||||
// für die ordentlichen Renten, die individuelle Kürzung setzt darauf auf.
|
||||
for (const [pid, v] of [...ahvFinal]) {
|
||||
const f = ahvFactor(ahvDrawByPerson.get(pid) ?? {});
|
||||
if (f !== 1) ahvFinal.set(pid, Math.round(v * f));
|
||||
}
|
||||
for (const [pid, v] of ahvFinal) ahvFinalByPerson.set(pid, v);
|
||||
|
||||
// --- Element-Laufzeitzustände aufbauen ---
|
||||
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
@@ -522,6 +576,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const ahvItems: {
|
||||
ownerId: string;
|
||||
ownerStartAge: number;
|
||||
// Alter, ab dem die Rente DIESER Person fliesst. Bis 0.33 war das fix das
|
||||
// Referenzalter; mit Vorbezug/Aufschub ist es individuell. Für das Jahresraster
|
||||
// gerundet -- die Monatsgenauigkeit steckt im Faktor, nicht im Auszahlungszeitpunkt.
|
||||
renteFromAge: number;
|
||||
rente: number;
|
||||
beitrag: number;
|
||||
working: boolean;
|
||||
@@ -661,29 +719,46 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
if (owner) {
|
||||
const rente = ahvFinal.get(owner.id) ?? 0;
|
||||
const beitrag = Math.round(num(pd.ahvContribution));
|
||||
const rd = ahvDrawByPerson.get(owner.id) ?? {};
|
||||
// Rentenbeginn individuell (Vorbezug/Aufschub), Beitragspflicht dagegen IMMER bis
|
||||
// zum Referenzalter: Wer die Rente vorbezieht, ist damit nicht von den Beiträgen
|
||||
// als Nichterwerbstätige(r) befreit -- die beiden Alter sind zu trennen.
|
||||
const renteFromAge = Math.round(ahvStartAge(rd));
|
||||
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 });
|
||||
ahvItems.push({
|
||||
ownerId: owner.id,
|
||||
ownerStartAge: ageStart,
|
||||
renteFromAge,
|
||||
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;
|
||||
const reachesRente = ageEnd >= renteFromAge;
|
||||
const startsWithRente = ageStart >= renteFromAge;
|
||||
const owesContribution = !ownerWorking && ageStart < AHV_REFERENCE_AGE;
|
||||
ec.startValue = startsWithRente ? rente : 0;
|
||||
ec.endValue = reachesRente ? rente : 0;
|
||||
|
||||
if (startsRetired) {
|
||||
if (startsWithRente) {
|
||||
ec.summary = `Rente ${fmt(rente)}`;
|
||||
} else if (reachesRef && !ownerWorking) {
|
||||
} else if (reachesRente && owesContribution) {
|
||||
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.note = `Rente ab Alter ${renteFromAge}; bis Alter ${AHV_REFERENCE_AGE} Beitrag als Nichterwerbstätige(r).`;
|
||||
} else if (reachesRente) {
|
||||
ec.summary = `Rente ab ${renteFromAge} ${fmt(rente)}`;
|
||||
} else if (owesContribution) {
|
||||
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";
|
||||
}
|
||||
if (ahvShiftMonths(rd) !== 0) {
|
||||
ec.note = [ec.note, `AHV: ${ahvDrawLabel(rd)}.`].filter(Boolean).join(" ");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -847,15 +922,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
interestNominal += re.mortgage * (re.interestRate / 100);
|
||||
}
|
||||
|
||||
// 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).
|
||||
// AHV jahresweise: Die Rente fliesst ab dem individuellen Rentenalter -- auch wenn die
|
||||
// Person noch arbeitet. Unabhängig davon zahlt eine nicht (mehr) erwerbstätige Person
|
||||
// bis zum REFERENZALTER Beiträge, die wie eine Ausgabe auf die Quote schlagen
|
||||
// (Kap. 4.4.6). Beide Bedingungen können gleichzeitig gelten: Wer mit 62 aufhört und
|
||||
// die Rente ab 63 vorbezieht, bezieht ab 63 und zahlt bis 65.
|
||||
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;
|
||||
if (ageThisYear >= a.renteFromAge) ahvIncome += a.rente;
|
||||
if (!a.working && ageThisYear < AHV_REFERENCE_AGE) ahvCost += a.beitrag;
|
||||
}
|
||||
incomeFlow += ahvIncome;
|
||||
|
||||
@@ -1020,8 +1097,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
// 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 });
|
||||
const rente = ageThisYear >= a.renteFromAge ? a.rente : 0;
|
||||
const beitrag = !a.working && ageThisYear < AHV_REFERENCE_AGE ? a.beitrag : 0;
|
||||
if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value: rente - beitrag });
|
||||
}
|
||||
// Verrentete PK läuft nominal fix durch die Phase.
|
||||
for (const ec of ecById.values()) {
|
||||
@@ -1229,9 +1307,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
if (e.category === "AHV") {
|
||||
const owner = e.ownerRole ? personByRole(persons, e.ownerRole) : null;
|
||||
const gap = owner ? gapYearsByPerson.get(owner.id) ?? 0 : 0;
|
||||
const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 } : { avg: 0, gap: 0 };
|
||||
const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 } : { avg: null, gap: 0 };
|
||||
const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null;
|
||||
const mdJE = career ? ahvMdje(career, before.avg, before.gap) : 0;
|
||||
const avgBefore = before.avg ?? career?.plannedAvgGrossIncome ?? 0;
|
||||
const mdJE = career ? ahvMdje(career, avgBefore, before.gap) : 0;
|
||||
ec.trace = {
|
||||
title: `AHV-Rente «${ec.name}»`,
|
||||
specAnchor: "44-ahv-rente",
|
||||
@@ -1456,7 +1535,32 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
let txImmediateRepay = 0;
|
||||
// Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND
|
||||
// der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt.
|
||||
const capitalUses: { net: number; td: TransitionData; sourceName: string }[] = [];
|
||||
const capitalUses: { net: number; td: RetirementDecision; sourceName: string }[] = [];
|
||||
|
||||
// Zieht dieses 3a-Konto an DIESER Phasengrenze? Ein Konto lässt sich nur ganz auflösen,
|
||||
// und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb
|
||||
// an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem
|
||||
// Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt.
|
||||
const drawsPillar3aHere = (
|
||||
el: ElementInput,
|
||||
ownerPerson: { id: string; age: number } | null,
|
||||
ageAtBoundary: number
|
||||
): boolean => {
|
||||
// 3a ist personengebunden -- ohne Besitzer gibt es kein Bezugsalter und keinen Bezug.
|
||||
if (!ownerPerson) return false;
|
||||
const rd = withRetirementDefaults(
|
||||
"PILLAR_3A",
|
||||
retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE,
|
||||
el.retirementDecision
|
||||
);
|
||||
const wish = Math.max(
|
||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||
Math.min(PILLAR_3A_MAX_WITHDRAWAL_AGE, Math.round(num(rd.withdrawalAge, ageAtBoundary)))
|
||||
);
|
||||
const prevBoundaryAge = ownerPerson.age + yearsBefore;
|
||||
if (prevBoundaryAge >= wish) return false; // in einer früheren Phase bereits gezogen
|
||||
return ageAtBoundary >= wish || !nextPhase;
|
||||
};
|
||||
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
|
||||
// Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind
|
||||
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
|
||||
@@ -1496,6 +1600,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const ownerRetiresNext =
|
||||
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true &&
|
||||
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration);
|
||||
// Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für
|
||||
// die Vorgaben des Pensionierungs-Entscheids und für das 3a-Bezugsalter.
|
||||
const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE;
|
||||
const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
|
||||
|
||||
// Einkommen/Ausgaben: Basiswert wurde bereits im Element-Setup fortgeschrieben.
|
||||
if (e.category === "INCOME" || e.category === "EXPENSE") {
|
||||
@@ -1503,18 +1611,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
continue;
|
||||
}
|
||||
|
||||
// AHV: beim Pensions-Übergang die geprüften Karriere-Werte vor Planbeginn übernehmen.
|
||||
// AHV: Der Bezugs-Entscheid hängt seit 0.34 am Element, nicht am Übergang -- hier ist
|
||||
// deshalb nichts mehr zu tun.
|
||||
if (e.category === "AHV") {
|
||||
if (ownerRetiresNext && owner) {
|
||||
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
||||
ahvBeforeByPerson.set(owner.id, {
|
||||
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schätzung für die Jahre
|
||||
// vor Planbeginn -- exakt der Wert, den der Prüf-Dialog vorbelegt. Ein Fallback auf
|
||||
// 0 würde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
|
||||
avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome),
|
||||
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
|
||||
});
|
||||
}
|
||||
carry.hasCarry = true;
|
||||
continue;
|
||||
}
|
||||
@@ -1530,29 +1629,26 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
switch (e.category) {
|
||||
case "PENSION_FUND": {
|
||||
if (ownerRetiresNext) {
|
||||
// EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente,
|
||||
// 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil
|
||||
// sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim
|
||||
// Verschieben des Alters still ein anderes Verhältnis bedeuten.
|
||||
const value = ec.endValue;
|
||||
const mode = td.payoutMode ?? "PENSION";
|
||||
if (mode === "CAPITAL") {
|
||||
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
txInflow += net;
|
||||
txTax += value - net;
|
||||
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) });
|
||||
carry.value = 0;
|
||||
carry.pkPensionAnnual = 0;
|
||||
} else if (mode === "PENSION") {
|
||||
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
|
||||
txPensionConversion += value;
|
||||
carry.value = 0;
|
||||
} else {
|
||||
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
|
||||
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
const rd = withRetirementDefaults("PENSION_FUND", ownerRetirementAge, e.retirementDecision);
|
||||
const sharePct = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
|
||||
const capital = Math.round((value * sharePct) / 100);
|
||||
const rest = value - capital;
|
||||
if (capital > 0) {
|
||||
const net = Math.round(capital * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
txInflow += net;
|
||||
txTax += capital - net;
|
||||
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) });
|
||||
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
|
||||
txPensionConversion += value - capital;
|
||||
carry.value = 0;
|
||||
capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) });
|
||||
if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net);
|
||||
}
|
||||
carry.pkPensionAnnual =
|
||||
rest > 0 ? Math.round((rest * num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100) : 0;
|
||||
txPensionConversion += rest;
|
||||
carry.value = 0;
|
||||
} else {
|
||||
// Vorbezug (z. B. Wohneigentum/Selbstständigkeit): ebenfalls kapitalbezugssteuerpflichtig.
|
||||
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
|
||||
@@ -1565,11 +1661,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
break;
|
||||
}
|
||||
case "PILLAR_3A": {
|
||||
if (ownerRetiresNext) {
|
||||
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
// Der Bezug hängt seit 0.34 am gewählten Alter, nicht mehr starr am Pensions-
|
||||
// Übergang: Ein 3a-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.
|
||||
if (drawsPillar3aHere(e, owner, ownerAgeAtBoundary)) {
|
||||
const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision);
|
||||
const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
txInflow += net;
|
||||
txTax += ec.endValue - net;
|
||||
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) });
|
||||
capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) });
|
||||
if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net);
|
||||
carry.value = 0;
|
||||
} else {
|
||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||
@@ -1794,6 +1896,58 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
|
||||
const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson));
|
||||
|
||||
// --- Pensionierung: die Kennzahlen des gleichnamigen Bildschirms -------------------------
|
||||
//
|
||||
// Bewusst HIER und nicht im UI: Rentenlücke und Reichweite sind die beiden Zahlen, an denen
|
||||
// die ganze Planung hängt. Eine Nebenrechnung in der Komponente hätte dieselbe Driftgefahr
|
||||
// wie bei den Rechenwegen (Kap. 4.14.3) -- und würde im PDF-Bericht anders ausfallen.
|
||||
//
|
||||
// Die Rentenlücke ist KEINE neue Grösse: Sie ist die Verzehrquote im ersten Jahr, in dem
|
||||
// niemand mehr arbeitet -- Renteneinkommen minus Ausgaben. Genau das rechnet die
|
||||
// Jahresschleife ohnehin, es fehlte nur der Name dafür.
|
||||
const retirement = ((): RetirementOverview => {
|
||||
// Startjahr je Phase (1-basiert) -- PhaseComputed fuehrt nur die Dauer.
|
||||
const startYearOf = new Map<string, number>();
|
||||
let acc = 1;
|
||||
for (const r of result) {
|
||||
startYearOf.set(r.id, acc);
|
||||
acc += r.durationYears;
|
||||
}
|
||||
const firstRetiredPhase = result.find((p) => p.persons.every((x) => !x.working));
|
||||
const perPerson = persons.map((p) => {
|
||||
// Die erste Phase, in der DIESE Person nicht mehr arbeitet -- dort stehen ihre Renten.
|
||||
const ph = result.find((r) => r.persons.find((x) => x.role === p.role && !x.working));
|
||||
const own = (cat: ElementCategory) =>
|
||||
ph?.elements.find((e) => e.category === cat && e.ownerRole === p.role);
|
||||
const rd = ahvDrawByPerson.get(p.id) ?? {};
|
||||
return {
|
||||
personId: p.id,
|
||||
role: p.role,
|
||||
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
|
||||
planningHorizonAge: p.planningHorizonAge ?? null,
|
||||
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
|
||||
ahvFromAge: Math.round(ahvStartAge(rd)),
|
||||
ahvDraw: ahvDrawLabel(rd),
|
||||
pkPensionAnnual: Math.round(own("PENSION_FUND")?.startValue ?? 0),
|
||||
// Einmalig verfügbares Alterskapital: netto, nach Kapitalbezugssteuer.
|
||||
capitalAtRetirement: Math.round(capitalNetByPerson.get(p.id) ?? 0),
|
||||
};
|
||||
});
|
||||
if (!firstRetiredPhase) {
|
||||
return { firstRetirementYear: null, pensionIncome: null, expenses: null, gapAnnual: null, perPerson };
|
||||
}
|
||||
// Erstes Jahr dieser Phase: Einkommen und Ausgaben stehen im Jahrespunkt.
|
||||
const firstYear = startYearOf.get(firstRetiredPhase.id) ?? 1;
|
||||
const yp = yearly.find((y) => y.year === firstYear);
|
||||
return {
|
||||
firstRetirementYear: firstYear,
|
||||
pensionIncome: yp ? Math.round(yp.income) : null,
|
||||
expenses: yp ? Math.round(yp.expenseNominal) : null,
|
||||
gapAnnual: yp ? Math.round(yp.income - yp.expenseNominal) : null,
|
||||
perPerson,
|
||||
};
|
||||
})();
|
||||
|
||||
// --- Plan-weite Rechenwege ---
|
||||
const planTraces: Trace[] = [];
|
||||
if (explain) {
|
||||
@@ -1810,8 +1964,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
});
|
||||
|
||||
for (const career of ahvCareer) {
|
||||
const before = ahvBeforeByPerson.get(career.personId) ?? { avg: 0, gap: 0 };
|
||||
const mdJE = ahvMdje(career, before.avg, before.gap);
|
||||
const before = ahvBeforeByPerson.get(career.personId) ?? { avg: null, gap: 0 };
|
||||
const avgBefore = before.avg ?? career.plannedAvgGrossIncome;
|
||||
const mdJE = ahvMdje(career, avgBefore, before.gap);
|
||||
planTraces.push({
|
||||
title: `AHV-Beitragskarriere – ${career.role === "PERSON_A" ? "Person A" : "Person B"}`,
|
||||
specAnchor: "442-beitragskarriere-und-mdje",
|
||||
@@ -1820,8 +1975,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
st("Durchschnittliches Bruttöinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
|
||||
"Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."),
|
||||
st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn − 21 − Ausfalljahre davor", undefined, undefined, "Jahre"),
|
||||
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined,
|
||||
before.avg === 0 && career.yearsBeforePlan > 0 ? "Noch nicht erfasst – am Pensions-Übergang zu prüfen." : "Aus der AHV-Rentenvorausberechnung übernommen."),
|
||||
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(avgBefore), undefined, undefined,
|
||||
before.avg === null && career.yearsBeforePlan > 0
|
||||
? "Nicht erfasst – geschätzt aus dem geplanten Durchschnitt. Im Pensionierungs-Bildschirm zu prüfen."
|
||||
: "Aus der AHV-Rentenvorausberechnung übernommen."),
|
||||
st("Massgebendes durchschnittliches Jahreseinkommen", Math.round(mdJE),
|
||||
"(Einkommen davor × Jahre davor + Einkommen im Plan × Jahre im Plan) / Total Jahre", undefined,
|
||||
"REAL gerechnet: die echte AHV wertet vergangene Einkommen auf UND indexiert die Schwellen – beides hebt sich real weitgehend auf."),
|
||||
@@ -1841,7 +1998,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
});
|
||||
}
|
||||
|
||||
return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
|
||||
return { phases: result, yearly, nachlass, retirement, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
|
||||
}
|
||||
|
||||
// Durchschnittliches REALES Jahreseinkommen über eine Phase. Nominal wächst der Flow mit
|
||||
|
||||
@@ -23,6 +23,59 @@ export const AHV_REFERENCE_AGE = 65;
|
||||
// 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;
|
||||
|
||||
// --- Flexibler Rentenbezug (AHV 21) --------------------------------------------------------
|
||||
//
|
||||
// Vorbezug: fruehestens 3 Jahre vor dem Referenzalter, seit AHV 21 in MONATSschritten.
|
||||
// Die Kuerzung ist lebenslang und betraegt 6.8 % pro vorbezogenem Jahr; Monate anteilig.
|
||||
// Die seit 1.1.2025 tieferen, einkommensabhaengigen Saetze fuer Frauen der
|
||||
// Uebergangsgeneration (Jahrgaenge 1961-1969) sind BEWUSST nicht abgebildet -- sie laufen
|
||||
// aus, und ihre Nachbildung erforderte eine zweite, jahrgangsabhaengige Rentenformel.
|
||||
// Quelle: Merkblatt 3.04 "Flexibler Rentenbezug", Stand 1.1.2026.
|
||||
export const AHV_EARLY_MAX_MONTHS = 36;
|
||||
export const AHV_EARLY_REDUCTION_PER_YEAR = 6.8;
|
||||
|
||||
// Aufschub: 1 bis 5 Jahre. Der Zuschlag ist NICHT linear -- er steigt ueberproportional,
|
||||
// weil sich die Bezugsdauer verkuerzt. Amtliche Stuetzwerte je volles Jahr; fuer Monate
|
||||
// dazwischen wird linear interpoliert (die amtliche Tabelle ist monatsgenau, die Abweichung
|
||||
// liegt im Zehntelprozent-Bereich und damit weit unter der Unschaerfe der Rentenschaetzung).
|
||||
export const AHV_DEFER_MIN_MONTHS = 12;
|
||||
export const AHV_DEFER_MAX_MONTHS = 60;
|
||||
export const AHV_DEFER_BONUS_BY_YEAR = [0, 5.2, 10.8, 17.1, 24.0, 31.5];
|
||||
|
||||
// Teilbezug/Teilaufschub: zwischen 20 und 80 Prozent der Rente.
|
||||
export const AHV_PARTIAL_MIN_PCT = 20;
|
||||
export const AHV_PARTIAL_MAX_PCT = 80;
|
||||
|
||||
// --- Saeule 3a: Bezugsfenster ---------------------------------------------------------------
|
||||
//
|
||||
// Fruehestens 5 Jahre vor dem Referenzalter (Art. 3 BVV3). Spaetestens beim Referenzalter --
|
||||
// aufschiebbar bis 70, solange eine Erwerbstaetigkeit besteht. Ein Konto laesst sich bei der
|
||||
// Pensionierung nur GANZ aufloesen; gestaffelt wird ueber mehrere Konten.
|
||||
export const PILLAR_3A_MIN_WITHDRAWAL_AGE = AHV_REFERENCE_AGE - 5;
|
||||
export const PILLAR_3A_MAX_WITHDRAWAL_AGE = AHV_REFERENCE_AGE + 5;
|
||||
|
||||
// --- Pensionskasse: Bezugsfenster -----------------------------------------------------------
|
||||
//
|
||||
// Gesetzliches Mindestalter fuer die vorzeitige Pensionierung (Art. 1i BVV2). Viele Reglemente
|
||||
// setzen erst bei 60 an -- deshalb Hinweis statt Sperre.
|
||||
export const PK_MIN_RETIREMENT_AGE = 58;
|
||||
export const PK_MAX_RETIREMENT_AGE = AHV_REFERENCE_AGE + 5;
|
||||
|
||||
// Sperrfrist nach einem Einkauf, innerhalb derer ein Kapitalbezug den Steuerabzug
|
||||
// nachtraeglich entfallen laesst (Art. 79b Abs. 3 BVG). Reines Hinweis-Mass: Das Tool kennt
|
||||
// keine Einkaeufe und kann die Frist deshalb nicht selbst pruefen.
|
||||
export const PK_BUYIN_BLOCKING_YEARS = 3;
|
||||
|
||||
// --- Planungshorizont -----------------------------------------------------------------------
|
||||
//
|
||||
// Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus der
|
||||
// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen
|
||||
// und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu
|
||||
// kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss.
|
||||
export const DEFAULT_PLANNING_HORIZON_AGE = 90;
|
||||
export const MIN_PLANNING_HORIZON_AGE = 70;
|
||||
export const MAX_PLANNING_HORIZON_AGE = 110;
|
||||
|
||||
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
|
||||
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
|
||||
//
|
||||
|
||||
+66
-11
@@ -8,8 +8,9 @@
|
||||
// von dort macht diese Datei serverseitig unbenutzbar ("Attempted to call ... from the
|
||||
// server but it is on the client") -- genau daran scheiterte der PDF-Bericht.
|
||||
import { isCashTransitionAnswered, isTransitionAnswered } from "@/lib/transitions";
|
||||
import { RETIREMENT_CATEGORIES } from "@/lib/retirement-decision";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
|
||||
|
||||
export const TRANSITION_CATEGORIES: ElementCategory[] = [
|
||||
@@ -56,29 +57,83 @@ export function transitionInactive(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drei Zustände statt zwei (SPEZIFIKATION 3.5.3).
|
||||
//
|
||||
// Bis 0.33 kannte das Modell nur "offen" oder "beantwortet". Seit die Pensionierungs-
|
||||
// Entscheide durchgängige Vorgaben haben, gibt es einen dritten und interessanteren Zustand:
|
||||
// Das System HAT eine Antwort -- sie ist nur nicht die des Benutzers. Diesen Fall als
|
||||
// beantwortet zu zählen hiesse, eine stillschweigend gesetzte Vorgabe wie «volle Rente statt
|
||||
// Kapitalbezug» durchrutschen zu lassen, obwohl sie das Ergebnis massiv verändert.
|
||||
//
|
||||
// Deshalb zählt eine Vorgabe MIT -- aber in einem eigenen Topf, damit sie sich nicht wie ein
|
||||
// vergessenes Eingabefeld liest.
|
||||
export interface DecisionCounts {
|
||||
// Kein Entscheid vorhanden: Verkauf/Halten, Cash-Übergang, Tilgung.
|
||||
open: number;
|
||||
// Pensionierungs-Entscheid liegt auf Vorgabe und wurde nie bestätigt.
|
||||
unconfirmed: number;
|
||||
}
|
||||
|
||||
export const NO_DECISIONS: DecisionCounts = { open: 0, unconfirmed: 0 };
|
||||
|
||||
export function addCounts(a: DecisionCounts, b: DecisionCounts): DecisionCounts {
|
||||
return { open: a.open + b.open, unconfirmed: a.unconfirmed + b.unconfirmed };
|
||||
}
|
||||
|
||||
// Ist der Pensionierungs-Entscheid dieses Elements vom Benutzer bestätigt?
|
||||
export function retirementConfirmed(el: ElementInput): boolean {
|
||||
return el.retirementDecision?.confirmed === true;
|
||||
}
|
||||
|
||||
// Offene Entscheide an EINER Phasengrenze. Der Cash-Entscheid zählt mit.
|
||||
export function openTransitionCount(
|
||||
plan: PlanInput,
|
||||
computed: PlanComputed,
|
||||
fromPhase: PhaseComputed,
|
||||
toPhase: PhaseComputed
|
||||
): number {
|
||||
): DecisionCounts {
|
||||
const cash = plan.phases.find((p) => p.id === fromPhase.id)?.cashTransition ?? {};
|
||||
let n = isCashTransitionAnswered(cash) ? 0 : 1;
|
||||
const counts: DecisionCounts = { open: isCashTransitionAnswered(cash) ? 0 : 1, unconfirmed: 0 };
|
||||
for (const el of plan.elements) {
|
||||
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
||||
if (transitionInactive(computed, el, fromPhase, toPhase)) continue;
|
||||
const retire = isRetirementTransition(el, fromPhase, toPhase);
|
||||
// An einem Pensions-Übergang zählt bei AHV/PK/3a nicht mehr die Übergangszelle, sondern
|
||||
// der Pensionierungs-Entscheid -- der liegt seit 0.34 am Element.
|
||||
if (retire && RETIREMENT_CATEGORIES.includes(el.category)) {
|
||||
if (!retirementConfirmed(el)) counts.unconfirmed++;
|
||||
continue;
|
||||
}
|
||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||
if (!isTransitionAnswered(el.category, isRetirementTransition(el, fromPhase, toPhase), td)) n++;
|
||||
if (!isTransitionAnswered(el.category, retire, td)) counts.open++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Summe über alle Phasengrenzen -- die aktionierbarste Kennzahl des ganzen Werkzeugs.
|
||||
export function totalOpenDecisions(plan: PlanInput, computed: PlanComputed): DecisionCounts {
|
||||
let n = NO_DECISIONS;
|
||||
for (let i = 0; i < computed.phases.length - 1; i++) {
|
||||
n = addCounts(n, openTransitionCount(plan, computed, computed.phases[i], computed.phases[i + 1]));
|
||||
}
|
||||
// Pensionierungs-Entscheide von Personen, deren Pensionierung GAR NICHT im Plan liegt (weil
|
||||
// sie bei Planbeginn bereits pensioniert sind), tauchen an keiner Grenze auf -- sie fehlten
|
||||
// damit in der Zählung, obwohl ihre Vorgaben genauso wirken.
|
||||
const retiredAtStart = new Set(
|
||||
(computed.phases[0]?.persons ?? []).filter((p) => !p.working).map((p) => p.role)
|
||||
);
|
||||
for (const el of plan.elements) {
|
||||
if (!RETIREMENT_CATEGORIES.includes(el.category)) continue;
|
||||
if (!el.ownerRole || !retiredAtStart.has(el.ownerRole as PersonRole)) continue;
|
||||
if (!retirementConfirmed(el)) n = addCounts(n, { open: 0, unconfirmed: 1 });
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Summe über alle Phasengrenzen -- die aktionierbarste Kennzahl des ganzen Werkzeugs.
|
||||
export function totalOpenDecisions(plan: PlanInput, computed: PlanComputed): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < computed.phases.length - 1; i++) {
|
||||
n += openTransitionCount(plan, computed, computed.phases[i], computed.phases[i + 1]);
|
||||
}
|
||||
return n;
|
||||
// Kurzfassung für Badges: «2 offene Entscheide · 3 Vorgaben ungeprüft».
|
||||
export function decisionsText(c: DecisionCounts): string {
|
||||
const parts: string[] = [];
|
||||
if (c.open > 0) parts.push(`${c.open} ${c.open === 1 ? "offener Entscheid" : "offene Entscheide"}`);
|
||||
if (c.unconfirmed > 0) parts.push(`${c.unconfirmed} ${c.unconfirmed === 1 ? "Vorgabe" : "Vorgaben"} ungeprüft`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
+9
-2
@@ -104,7 +104,14 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
|
||||
}
|
||||
usedBaseEls.add(src.id);
|
||||
|
||||
if (el.name !== src.name || el.ownerRole !== src.ownerRole) {
|
||||
// Der Pensionierungs-Entscheid haengt am Element und an keiner Phase -- eine Aenderung
|
||||
// markiert deshalb die ZEILE. Ohne das bliebe der haeufigste Szenario-Unterschied
|
||||
// ueberhaupt (Rente statt Kapital, anderes Bezugsalter) im Diff unsichtbar.
|
||||
if (
|
||||
el.name !== src.name ||
|
||||
el.ownerRole !== src.ownerRole ||
|
||||
!sameData(el.retirementDecision ?? {}, src.retirementDecision ?? {})
|
||||
) {
|
||||
d.elementRow.set(el.id, "changed");
|
||||
}
|
||||
|
||||
@@ -132,7 +139,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
|
||||
// --- Profil und Cash-Anfangswert ---
|
||||
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
|
||||
const personKey = (p: PlanInput["persons"][number]) =>
|
||||
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}`;
|
||||
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}|${p.planningHorizonAge ?? ""}`;
|
||||
d.profileChanged =
|
||||
scenario.householdType !== base.householdType ||
|
||||
scenario.inflationRateDefault !== base.inflationRateDefault ||
|
||||
|
||||
+24
-2
@@ -48,6 +48,17 @@ export interface SliderDef {
|
||||
// anders -- ein Regler vergleicht keine Treiber gegeneinander, er zeigt ein einzelnes
|
||||
// absolutes Ergebnis. Ein Standardbereich erfindet also keine Aussage, er macht den Regler
|
||||
// ü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 }> = {
|
||||
// Absolute Werte: der neutrale Punkt ist der Planwert selbst und wird zur Laufzeit gesetzt.
|
||||
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 },
|
||||
income: { min: -30, max: 20, 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.
|
||||
retirementA: { 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,
|
||||
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,
|
||||
// Absolute Treiber: neutral ist der Planwert selbst, sonst stünde der Regler beim
|
||||
// Öffnen an einer Stelle, die den Plan bereits verändert.
|
||||
neutral:
|
||||
def.id === "inflation"
|
||||
? plan.inflationRateDefault
|
||||
: def.id === "pkCapitalShare"
|
||||
? plannedCapitalShare(plan)
|
||||
: 0,
|
||||
});
|
||||
}
|
||||
if (expandReturns) {
|
||||
|
||||
@@ -68,12 +68,25 @@ describe("Datenbank-Migrationen", () => {
|
||||
expect(scenCols).not.toContain("startYear");
|
||||
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");
|
||||
expect(personCols).toContain("retirementAge");
|
||||
expect(personCols).toContain("planningHorizonAge");
|
||||
expect(personCols).not.toContain("name");
|
||||
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.
|
||||
const hh = await db.query<{ is_nullable: string }>(
|
||||
`SELECT is_nullable FROM information_schema.columns
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
||||
import { retirementDecisionSchema } from "@/lib/retirement-decision";
|
||||
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
export const planInclude = {
|
||||
@@ -29,6 +31,11 @@ function parseTransitionData(raw: unknown): TransitionData {
|
||||
return parsed.success ? parsed.data : {};
|
||||
}
|
||||
|
||||
function parseRetirementDecision(raw: unknown): RetirementDecision {
|
||||
const parsed = retirementDecisionSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : {};
|
||||
}
|
||||
|
||||
function parseCashTransition(raw: unknown): CashTransitionData {
|
||||
const parsed = cashTransitionSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : {};
|
||||
@@ -53,6 +60,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
name: hh?.name ?? null,
|
||||
age: hh?.age ?? 0,
|
||||
retirementAge: p.retirementAge,
|
||||
planningHorizonAge: p.planningHorizonAge,
|
||||
};
|
||||
}),
|
||||
phases: plan.phases.map((phase) => ({
|
||||
@@ -76,6 +84,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
orderIndex: e.orderIndex,
|
||||
phaseValues,
|
||||
transitionValues,
|
||||
retirementDecision: parseRetirementDecision(e.retirementDecision),
|
||||
sourceElementId: e.sourceElementId,
|
||||
};
|
||||
}),
|
||||
|
||||
+7
-5
@@ -15,7 +15,7 @@
|
||||
// in den Anhang, ein Haftungsausschluss ist Pflicht.
|
||||
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { totalOpenDecisions } from "@/lib/decisions";
|
||||
import { decisionsText, totalOpenDecisions } from "@/lib/decisions";
|
||||
import { num } from "@/lib/elements";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { resolveActuals, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals";
|
||||
@@ -239,11 +239,13 @@ function buildScenario(
|
||||
if (pkPension > 0) {
|
||||
figures.push({ label: "PK-Rente pro Jahr", value: formatChf(pkPension), basis: "Umwandlungssatz laut Systemparametern" });
|
||||
}
|
||||
const openTotal = open.open + open.unconfirmed;
|
||||
figures.push({
|
||||
label: "Offene Entscheide",
|
||||
value: open === 0 ? "keine" : String(open),
|
||||
tone: open === 0 ? "success" : undefined,
|
||||
basis: "noch nicht getroffene Übergangs-Entscheide zwischen den Lebensphasen",
|
||||
value: openTotal === 0 ? "keine" : decisionsText(open),
|
||||
tone: openTotal === 0 ? "success" : undefined,
|
||||
basis:
|
||||
"noch nicht getroffene Übergangs-Entscheide sowie Pensionierungs-Vorgaben, die nie bestätigt wurden",
|
||||
});
|
||||
|
||||
const phases: ReportTable = {
|
||||
@@ -273,7 +275,7 @@ function buildScenario(
|
||||
],
|
||||
},
|
||||
assumptions: assumptionsOf(plan),
|
||||
openDecisions: open,
|
||||
openDecisions: open.open + open.unconfirmed,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
// Der Pensionierungs-Entscheid je Vorsorge-Element.
|
||||
//
|
||||
// WARUM EIN EIGENER SPEICHERORT UND NICHT `transitionValues`?
|
||||
//
|
||||
// Bis 0.34 hing jeder dieser Entscheide an `transitionValues[phaseId]` -- also am Schluessel
|
||||
// Element x Phasen-ID. Das war der Grund fuer eine ganze Reihe von Aergernissen: Verschob man
|
||||
// das Pensionsalter, wanderte die Phasengrenze, und die Entscheide mussten ueber
|
||||
// `mergeTransition` von Grenze zu Grenze gerettet werden -- verlustbehaftet. Ein Szenario nur
|
||||
// wegen eines anderen Pensionsalters aufzusetzen hiess, alle Entscheide erneut zu treffen.
|
||||
// Und der Ziel-Solver (Roadmap Nr. 21) haette bei jedem Kandidatenalter die Phasenstruktur
|
||||
// umbauen und die Entscheide neu einsammeln muessen.
|
||||
//
|
||||
// Ohne Phasen-ID im Schluessel ueberlebt der Entscheid JEDE Zeitachsen-Aenderung. Die
|
||||
// Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse -- die Zeitachse ist die
|
||||
// Folge davon.
|
||||
//
|
||||
// Was hier NICHT hineingehoert: Vorbezuege (PK/3a vor der Pensionierung), Verkaeufe,
|
||||
// Tilgungen, der Cash-Uebergang. Das sind echte Ereignisse an einer bestimmten Phasengrenze
|
||||
// und bleiben in `transitionValues`.
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AHV_DEFER_BONUS_BY_YEAR,
|
||||
AHV_DEFER_MAX_MONTHS,
|
||||
AHV_EARLY_MAX_MONTHS,
|
||||
AHV_EARLY_REDUCTION_PER_YEAR,
|
||||
AHV_PARTIAL_MIN_PCT,
|
||||
AHV_REFERENCE_AGE,
|
||||
DEFAULT_CAPITAL_TAX_RATE,
|
||||
DEFAULT_PK_CONVERSION_RATE,
|
||||
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||
} from "@/lib/constants";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
|
||||
export type AhvDraw = "EARLY" | "REFERENCE" | "DEFERRED";
|
||||
|
||||
export interface RetirementDecision {
|
||||
// Hat die Person hingeschaut? Fehlt das Haekchen, rechnet das Tool mit einer Vorgabe --
|
||||
// ein dritter Zustand neben "unbeantwortet" und "beantwortet" (SPEZIFIKATION 3.5.3).
|
||||
confirmed?: boolean;
|
||||
|
||||
// --- AHV -------------------------------------------------------------------------------
|
||||
ahvDraw?: AhvDraw;
|
||||
// Monate des Vorbezugs bzw. des Aufschubs. Nur bei EARLY/DEFERRED von Bedeutung.
|
||||
ahvMonths?: number;
|
||||
// Teilbezug/Teilaufschub: Anteil der Rente, der vorbezogen bzw. aufgeschoben wird.
|
||||
ahvSharePct?: number;
|
||||
// Beitragskarriere vor Planbeginn. Keine Entscheidung, sondern Datenqualitaet -- deshalb
|
||||
// im UI abgesetzt dargestellt. avgIncomeBefore ist REAL (heutige Kaufkraft) und BRUTTO.
|
||||
avgIncomeBefore?: number;
|
||||
gapYearsBefore?: number;
|
||||
|
||||
// --- Pensionskasse ---------------------------------------------------------------------
|
||||
// Anteil des Altersguthabens, der als Kapital bezogen wird. EIN Regler statt des frueheren
|
||||
// Modus PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 = volle Rente, 100 = volles Kapital,
|
||||
// alles dazwischen ist die Kombination. Als Quote und nicht in Franken, weil sich das
|
||||
// Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch.
|
||||
capitalSharePct?: number;
|
||||
conversionRate?: number;
|
||||
// Reines Hinweis-Flag: Ein Kapitalbezug innerhalb von drei Jahren nach einem Einkauf laesst
|
||||
// den Steuerabzug nachtraeglich entfallen (Art. 79b Abs. 3 BVG). Das Tool kennt keine
|
||||
// Einkaeufe und kann das nicht selbst pruefen -- deshalb die Frage statt einer Automatik.
|
||||
recentBuyIn?: boolean;
|
||||
|
||||
// --- Saeule 3a -------------------------------------------------------------------------
|
||||
// Alter, in dem dieses Konto aufgeloest wird. Ein 3a-Konto laesst sich bei der
|
||||
// Pensionierung nur GANZ aufloesen -- gestaffelt wird ueber mehrere Konten, und genau
|
||||
// dafuer gibt es dieses Feld. Der Bezug erfolgt an der Phasengrenze bei oder nach diesem
|
||||
// Alter (siehe `effectiveWithdrawalAge`).
|
||||
withdrawalAge?: number;
|
||||
|
||||
// --- gemeinsam: Kapitalbezug und seine Verwendung ---------------------------------------
|
||||
capitalTaxRate?: number;
|
||||
capitalUseAmortizationPct?: number;
|
||||
capitalUseInvestPct?: number;
|
||||
capitalUseTargetElementId?: string;
|
||||
}
|
||||
|
||||
const pct = z.number().min(0).max(100);
|
||||
|
||||
export const retirementDecisionSchema = z
|
||||
.object({
|
||||
confirmed: z.boolean().optional(),
|
||||
|
||||
ahvDraw: z.enum(["EARLY", "REFERENCE", "DEFERRED"]).optional(),
|
||||
ahvMonths: z.number().int().min(0).max(AHV_DEFER_MAX_MONTHS).optional(),
|
||||
ahvSharePct: z.number().min(AHV_PARTIAL_MIN_PCT).max(100).optional(),
|
||||
avgIncomeBefore: z.number().min(0).optional(),
|
||||
gapYearsBefore: z.number().int().min(0).max(50).optional(),
|
||||
|
||||
capitalSharePct: pct.optional(),
|
||||
conversionRate: z.number().min(0).max(20).optional(),
|
||||
recentBuyIn: z.boolean().optional(),
|
||||
|
||||
withdrawalAge: z
|
||||
.number()
|
||||
.int()
|
||||
.min(PILLAR_3A_MIN_WITHDRAWAL_AGE)
|
||||
.max(PILLAR_3A_MAX_WITHDRAWAL_AGE)
|
||||
.optional(),
|
||||
|
||||
capitalTaxRate: z.number().min(0).max(100).optional(),
|
||||
capitalUseAmortizationPct: pct.optional(),
|
||||
capitalUseInvestPct: pct.optional(),
|
||||
capitalUseTargetElementId: z.string().max(60).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Kategorien, die ueberhaupt einen Pensionierungs-Entscheid kennen.
|
||||
export const RETIREMENT_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"];
|
||||
|
||||
// --- Vorgaben -------------------------------------------------------------------------------
|
||||
//
|
||||
// Zentrales Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollstaendiger Vorschlag,
|
||||
// den man korrigiert. Nur so ist der Plan ab der ersten Sekunde rechenbar -- und nur so muss
|
||||
// niemand am Anfang Fragen beantworten, die er erst am Ende beantworten kann.
|
||||
export function withRetirementDefaults(
|
||||
category: ElementCategory,
|
||||
retirementAge: number,
|
||||
rd: RetirementDecision | null | undefined
|
||||
): RetirementDecision {
|
||||
const d: RetirementDecision = { ...(rd ?? {}) };
|
||||
if (category === "AHV") {
|
||||
d.ahvDraw ??= "REFERENCE";
|
||||
d.ahvSharePct ??= 100;
|
||||
} else if (category === "PENSION_FUND") {
|
||||
// Volle Rente. Das ist die Vorgabe, weil sie die Regel ist -- ein Kapitalbezug ist der
|
||||
// begruendungspflichtige Fall, nicht umgekehrt.
|
||||
d.capitalSharePct ??= 0;
|
||||
d.conversionRate ??= DEFAULT_PK_CONVERSION_RATE;
|
||||
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
|
||||
} else if (category === "PILLAR_3A") {
|
||||
// Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58
|
||||
// aufhoert, kann die 3a trotzdem erst mit 60 beziehen.
|
||||
d.withdrawalAge ??= clamp(
|
||||
retirementAge,
|
||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||
PILLAR_3A_MAX_WITHDRAWAL_AGE
|
||||
);
|
||||
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
// --- AHV: Rentenbeginn und Faktor -----------------------------------------------------------
|
||||
|
||||
// Alter, ab dem die Rente tatsaechlich fliesst. Bis 0.33 war das immer das Referenzalter --
|
||||
// wer mit 62 aufhoerte, bekam die ungekuerzte Rente drei Jahre spaeter, wer bis 68 arbeitete,
|
||||
// verschenkte den Zuschlag. Beides war schlicht falsch.
|
||||
export function ahvStartAge(rd: RetirementDecision): number {
|
||||
const months = ahvShiftMonths(rd);
|
||||
return AHV_REFERENCE_AGE + months / 12;
|
||||
}
|
||||
|
||||
// Verschiebung in Monaten gegenueber dem Referenzalter: negativ = Vorbezug, positiv = Aufschub.
|
||||
export function ahvShiftMonths(rd: RetirementDecision): number {
|
||||
const raw = Math.round(Math.max(0, rd.ahvMonths ?? 0));
|
||||
if (rd.ahvDraw === "EARLY") return -Math.min(raw, AHV_EARLY_MAX_MONTHS);
|
||||
if (rd.ahvDraw === "DEFERRED") return Math.min(raw, AHV_DEFER_MAX_MONTHS);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Faktor auf die Rente. 1 = unveraendert, < 1 = gekuerzt (Vorbezug), > 1 = erhoeht (Aufschub).
|
||||
//
|
||||
// Beim Teilbezug wirkt die Kuerzung bzw. der Zuschlag NUR auf den vorbezogenen/aufgeschobenen
|
||||
// Anteil -- der Rest laeuft ungekuerzt ab dem Referenzalter. Das Tool bildet das vereinfacht
|
||||
// ab, indem es den gewichteten Mischfaktor bildet: Der Zeitversatz des Restanteils wird nicht
|
||||
// eigens modelliert, weil das eine zweite Rentenlinie mit eigenem Startjahr erforderte.
|
||||
export function ahvFactor(rd: RetirementDecision): number {
|
||||
const months = ahvShiftMonths(rd);
|
||||
if (months === 0) return 1;
|
||||
const share = clamp(rd.ahvSharePct ?? 100, AHV_PARTIAL_MIN_PCT, 100) / 100;
|
||||
const adjust = months < 0 ? earlyFactor(-months) : deferFactor(months);
|
||||
return share * adjust + (1 - share);
|
||||
}
|
||||
|
||||
function earlyFactor(months: number): number {
|
||||
// Linear in den Monaten -- die Kuerzung ist gesetzlich ein Jahressatz, monatlich anteilig.
|
||||
return 1 - (months / 12) * (AHV_EARLY_REDUCTION_PER_YEAR / 100);
|
||||
}
|
||||
|
||||
function deferFactor(months: number): number {
|
||||
// Die amtlichen Stuetzwerte gelten je volles Jahr; dazwischen wird linear interpoliert.
|
||||
const years = months / 12;
|
||||
const lo = Math.floor(years);
|
||||
const hi = Math.min(AHV_DEFER_BONUS_BY_YEAR.length - 1, lo + 1);
|
||||
const a = AHV_DEFER_BONUS_BY_YEAR[Math.min(lo, AHV_DEFER_BONUS_BY_YEAR.length - 1)];
|
||||
const b = AHV_DEFER_BONUS_BY_YEAR[hi];
|
||||
const bonus = a + (b - a) * (years - lo);
|
||||
return 1 + bonus / 100;
|
||||
}
|
||||
|
||||
// Lesbare Kurzfassung fuer Matrix-Zelle und Zusammenfassung.
|
||||
export function ahvDrawLabel(rd: RetirementDecision): string {
|
||||
const months = ahvShiftMonths(rd);
|
||||
if (months === 0) return "Referenzalter";
|
||||
const abs = Math.abs(months);
|
||||
const years = Math.floor(abs / 12);
|
||||
const rest = abs % 12;
|
||||
const dauer = [years > 0 ? `${years} J.` : "", rest > 0 ? `${rest} Mt.` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const delta = Math.round((ahvFactor(rd) - 1) * 1000) / 10;
|
||||
return `${months < 0 ? "Vorbezug" : "Aufschub"} ${dauer} (${delta > 0 ? "+" : ""}${delta} %)`;
|
||||
}
|
||||
|
||||
// --- Saeule 3a: das Bezugsalter --------------------------------------------------------------
|
||||
|
||||
// Der Bezug erfolgt an einer PHASENGRENZE -- innerhalb einer Phase kennt das Modell kein
|
||||
// Einzelereignis. Gewaehlt wird deshalb die erste Grenze bei oder nach dem gewuenschten Alter.
|
||||
// Wer exakt staffeln will, setzt eine Phasengrenze; das ist in FPT ohnehin die Art, wie man
|
||||
// Zeitpunkte modelliert.
|
||||
export function effectiveWithdrawalAge(
|
||||
rd: RetirementDecision,
|
||||
retirementAge: number,
|
||||
boundaryAges: number[]
|
||||
): number {
|
||||
const wish = clamp(
|
||||
Math.round(rd.withdrawalAge ?? retirementAge),
|
||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||
PILLAR_3A_MAX_WITHDRAWAL_AGE
|
||||
);
|
||||
const hit = boundaryAges.find((a) => a >= wish);
|
||||
// Ohne passende Grenze (Wunsch liegt hinter dem Planende) gilt die letzte verfuegbare.
|
||||
return hit ?? boundaryAges[boundaryAges.length - 1] ?? wish;
|
||||
}
|
||||
@@ -47,6 +47,58 @@ export function sortedPhases(plan: PlanInput) {
|
||||
return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
}
|
||||
|
||||
// --- Planungshorizont -----------------------------------------------------------------------
|
||||
//
|
||||
// Bis 0.33 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien
|
||||
// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar,
|
||||
// obwohl genau das ihr Zweck ist. Neu ist der Horizont eine erfasste Zahl, und die LETZTE
|
||||
// Phase folgt ihr (dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt).
|
||||
//
|
||||
// Referenzperson ist die aelteste: Sie erreicht ihren Horizont zuerst, und der Plan muss so
|
||||
// lange laufen, bis die LETZTE Person ihren erreicht hat.
|
||||
export function planEndYear(plan: PlanInput): number | null {
|
||||
const years = plan.persons
|
||||
.filter((p) => typeof p.planningHorizonAge === "number")
|
||||
.map((p) => (p.planningHorizonAge as number) - p.age);
|
||||
return years.length > 0 ? Math.max(...years) : null;
|
||||
}
|
||||
|
||||
export interface HorizonChange {
|
||||
lastPhaseId: string;
|
||||
oldDuration: number;
|
||||
newDuration: number;
|
||||
blocked: string | null;
|
||||
}
|
||||
|
||||
// Was muesste an der letzten Phase geschehen, damit der Plan bis zum Horizont laeuft?
|
||||
export function planHorizonChange(plan: PlanInput, horizonAge: number, role: PersonRole): HorizonChange | null {
|
||||
const phases = sortedPhases(plan);
|
||||
const last = phases[phases.length - 1];
|
||||
if (!last) return null;
|
||||
const person = plan.persons.find((p) => p.role === role);
|
||||
if (!person) return null;
|
||||
|
||||
// Zielgesamtdauer aus SICHT DIESER Person; die uebrigen Horizonte bleiben unberuehrt und
|
||||
// koennen laenger sein -- deshalb das Maximum ueber alle.
|
||||
const wish = Math.max(
|
||||
horizonAge - person.age,
|
||||
...plan.persons.filter((p) => p.role !== role && typeof p.planningHorizonAge === "number")
|
||||
.map((p) => (p.planningHorizonAge as number) - p.age)
|
||||
);
|
||||
const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0);
|
||||
const newDuration = wish - before;
|
||||
const change: HorizonChange = {
|
||||
lastPhaseId: last.id,
|
||||
oldDuration: last.durationYears,
|
||||
newDuration,
|
||||
blocked: null,
|
||||
};
|
||||
if (newDuration < 1) {
|
||||
change.blocked = `Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern – kürze zuerst eine frühere Phase.`;
|
||||
}
|
||||
return change;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
+62
-2
@@ -19,6 +19,7 @@ import { num } from "@/lib/elements";
|
||||
import type { ElementCategory, PhaseData } from "@/lib/elements";
|
||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||
import type { ResolvedActuals } from "@/lib/actuals";
|
||||
import type { RetirementDecision } from "@/lib/retirement-decision";
|
||||
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
|
||||
|
||||
export type DriverId =
|
||||
@@ -30,7 +31,9 @@ export type DriverId =
|
||||
| "lifespan"
|
||||
| "propertyGrowth"
|
||||
| "retirementA"
|
||||
| "retirementB";
|
||||
| "retirementB"
|
||||
| "pkCapitalShare"
|
||||
| "ahvDrawMonths";
|
||||
|
||||
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
|
||||
// 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)
|
||||
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge)
|
||||
// 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 {
|
||||
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.",
|
||||
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",
|
||||
label: "Wertsteigerung der Immobilie",
|
||||
@@ -167,6 +192,7 @@ export const UNIT_SUFFIX: Record<DriverUnit, string> = {
|
||||
delta_pp: "pp",
|
||||
rel_pct: "%",
|
||||
delta_years: "J.",
|
||||
delta_months: "Mt.",
|
||||
};
|
||||
|
||||
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 {
|
||||
switch (id) {
|
||||
case "inflation":
|
||||
@@ -215,6 +257,24 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
|
||||
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 "income": {
|
||||
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
|
||||
|
||||
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { RetirementDecision } from "@/lib/retirement-decision";
|
||||
|
||||
export type HouseholdType = "SINGLE" | "COUPLE";
|
||||
export type PersonRole = "PERSON_A" | "PERSON_B";
|
||||
@@ -14,6 +15,9 @@ export interface PersonInput {
|
||||
name: string | null;
|
||||
age: number;
|
||||
retirementAge: number;
|
||||
// Bis zu welchem Alter gerechnet wird. Fehlt der Wert, ergibt sich das Planende wie bisher
|
||||
// aus der Summe der Phasendauern -- deshalb optional und nicht mit Default belegt.
|
||||
planningHorizonAge?: number | null;
|
||||
}
|
||||
|
||||
export interface PhaseInput {
|
||||
@@ -36,6 +40,9 @@ export interface ElementInput {
|
||||
// Werte je Phase (Key = phaseId) bzw. je Übergang (Key = fromPhaseId).
|
||||
phaseValues: Record<string, PhaseData>;
|
||||
transitionValues: Record<string, TransitionData>;
|
||||
// Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug --
|
||||
// er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt.
|
||||
retirementDecision?: RetirementDecision | null;
|
||||
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
|
||||
sourceElementId?: string | null;
|
||||
}
|
||||
|
||||
@@ -177,12 +177,18 @@ export async function restoreVersion(
|
||||
},
|
||||
});
|
||||
|
||||
// Pensionsalter: an der Rolle festgemacht (je Szenario eindeutig).
|
||||
// Pensionsalter und Planungshorizont: an der Rolle festgemacht (je Szenario eindeutig).
|
||||
for (const p of snap.persons) {
|
||||
await tx.person.upsert({
|
||||
where: { scenarioId_role: { scenarioId, role: p.role } },
|
||||
create: { id: p.id, scenarioId, role: p.role, retirementAge: p.retirementAge },
|
||||
update: { retirementAge: p.retirementAge },
|
||||
create: {
|
||||
id: p.id,
|
||||
scenarioId,
|
||||
role: p.role,
|
||||
retirementAge: p.retirementAge,
|
||||
planningHorizonAge: p.planningHorizonAge ?? null,
|
||||
},
|
||||
update: { retirementAge: p.retirementAge, planningHorizonAge: p.planningHorizonAge ?? null },
|
||||
});
|
||||
}
|
||||
if (plan.deletePersonRoles.length > 0) {
|
||||
@@ -227,6 +233,9 @@ export async function restoreVersion(
|
||||
name: el.name,
|
||||
ownerRole: el.ownerRole ?? null,
|
||||
orderIndex: el.orderIndex,
|
||||
// Der Pensionierungs-Entscheid gehoert zum Inhalt des Szenarios -- ohne ihn wuerde
|
||||
// eine Wiederherstellung die Bezugsentscheide still auf die Vorgaben zuruecksetzen.
|
||||
retirementDecision: (el.retirementDecision ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
sourceElementId: el.sourceElementId ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user