AHV einkommensabhaengig + Warnhinweis bei Aenderungen in frueheren Phasen
Deploy App / deploy (push) Successful in 59s
Deploy App / deploy (push) Successful in 59s
Roadmap Nr. 3: Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) ueber das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente. - Formel aus den amtlichen Randbedingungen hergeleitet und gegen die Tabelle 318.117.1 verifiziert: 51/51 Zeilen exakt. Schwellen sind Vielfache von R0=1'260 (12/36/72 x R0 = 15'120 / 45'360 / 90'720). Stuetzstellen als Golden Tests. - Alles REAL gerechnet: die AHV wertet vergangene Einkommen auf UND indexiert die Schwellen -- real hebt sich das auf. Nominal wuerde die Rente systematisch zu hoch ausfallen (Beispiel: faelschlich Maximalrente, ~41'600 ueber 25 Rentenjahre). - Pruefung der Beitragskarriere am Pensions-Uebergang; Zusatzfelder fuer die Jahre vor Planbeginn nur, wenn der Plan nicht bis Alter 21 zurueckreicht. - Sonderfall "bei Planbeginn bereits pensioniert": Felder in der Phasenzelle. - Ohne Pruefung gilt der geplante Durchschnitt (nicht 0) -- sonst waere die Rente still viel zu tief. - 13. Altersrente: Jahresbetrag = Monatsrente x 13. Roadmap Nr. 4: Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren -- Werte schreiben sich fort und wirken bis ans Planende durch. Verhaltensaenderung fuer bestehende Plaene: siehe SPEZIFIKATION 9.10. Zwoelf Regressionstests (18 -> 30). Spezifikation auf v0.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+212
-21
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.3 |
|
| **Version** | 0.4 |
|
||||||
| **Datum** | 2026-07-16 |
|
| **Datum** | 2026-07-17 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `87e6a5f` inkl. einmaliger Sonderein-/ausgaben am Cash-Übergang (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `9f5bd75` inkl. einkommensabhängiger AHV und Fortschreibungs-Warnhinweis (Branch `main`) |
|
||||||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| Version | Datum | Autor | Änderung |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 0.4 | 2026-07-17 | Claude (Opus 4.8) | **AHV-Rente einkommensabhängig** (Roadmap Nr. 3) und **Fortschreibungs-Warnhinweis** (Roadmap Nr. 4). Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) über das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente; verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen). Prüfung der Beitragskarriere am Pensions-Übergang, mit Zusatzfeldern für die Jahre vor Planbeginn (ab Alter 21). Alles **real** gerechnet. Neue Konstanten `AHV_MIN_MONTHLY_FULL`, `AHV_PENSION_MONTHS`, `AHV_CONTRIBUTION_START_AGE`; `AHV_MAX_ANNUAL_SINGLE` neu abgeleitet. Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren. Neue Kapitel 3.5.6, 4.4; Abschnitt 9 um zwei Punkte ergänzt. Zwölf Regressionstests (18 → 30). **Verhaltensänderung:** siehe 9.10. |
|
||||||
| 0.3 | 2026-07-16 | Claude (Opus 4.8) | **Einmalige Sonderein-/ausgaben** umgesetzt (Roadmap Nr. 1). Der Cash-Übergang zwischen zwei Phasen ist neu ein eigener Entscheid: 1:1 übernehmen · einmaliger Zufluss · einmalige Kosten · beides. Beträge gehen direkt aufs Cash-Konto. Zufluss wird nominal erfasst (real angezeigt) mit optionalem Steuersatz (Default 0 %), Kosten werden real erfasst (nominal angezeigt); beide bleiben aus der Sparquote heraus. Der Entscheid startet unbeantwortet und zählt im „offen"-Badge mit. Neue Spalte `Phase.cashTransition` (JSON) + Migration, neue Route `PUT /api/phases/<id>/cash-transition`, eigene Kennzahlen im Phasenkopf. Fünf Regressionstests ergänzt (13 → 18). Neue Kapitel 3.5.5, 4.9.6; Abschnitt 9 um zwei Grenzen ergänzt. |
|
| 0.3 | 2026-07-16 | Claude (Opus 4.8) | **Einmalige Sonderein-/ausgaben** umgesetzt (Roadmap Nr. 1). Der Cash-Übergang zwischen zwei Phasen ist neu ein eigener Entscheid: 1:1 übernehmen · einmaliger Zufluss · einmalige Kosten · beides. Beträge gehen direkt aufs Cash-Konto. Zufluss wird nominal erfasst (real angezeigt) mit optionalem Steuersatz (Default 0 %), Kosten werden real erfasst (nominal angezeigt); beide bleiben aus der Sparquote heraus. Der Entscheid startet unbeantwortet und zählt im „offen"-Badge mit. Neue Spalte `Phase.cashTransition` (JSON) + Migration, neue Route `PUT /api/phases/<id>/cash-transition`, eigene Kennzahlen im Phasenkopf. Fünf Regressionstests ergänzt (13 → 18). Neue Kapitel 3.5.5, 4.9.6; Abschnitt 9 um zwei Grenzen ergänzt. |
|
||||||
| 0.2 | 2026-07-16 | Claude (Opus 4.8) | Drei Fixes umgesetzt und dokumentiert: **(1)** `Phase.inflationRate` ersatzlos entfernt (DB-Migration, API, Typen, UI) – die Inflation liegt seit V5 plan-weit; das Feld war wirkungslos. **(2)** Amortisation und Tilgung stoppen neu, sobald Hypothek bzw. Schuld abbezahlt sind – sie belasten danach weder Cash noch Sparquote (Kap. 4.6.5, 4.6.7, 4.7, 4.8). **(3)** Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezügen vor der Pensionierung, inkl. Steuerfeld und Netto-Vorschau im UI (Kap. 3.5.2, 4.9.1, 4.9.2). Kennzahl `plannedSaveRate` ist neu die Rate des **ersten** Phasenjahres. Drei Regressionstests ergänzt (10 → 13). Abschnitt 9 neu nummeriert (erledigte Punkte entfernt, „Sparraten werden nicht indexiert" ergänzt). |
|
| 0.2 | 2026-07-16 | Claude (Opus 4.8) | Drei Fixes umgesetzt und dokumentiert: **(1)** `Phase.inflationRate` ersatzlos entfernt (DB-Migration, API, Typen, UI) – die Inflation liegt seit V5 plan-weit; das Feld war wirkungslos. **(2)** Amortisation und Tilgung stoppen neu, sobald Hypothek bzw. Schuld abbezahlt sind – sie belasten danach weder Cash noch Sparquote (Kap. 4.6.5, 4.6.7, 4.7, 4.8). **(3)** Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezügen vor der Pensionierung, inkl. Steuerfeld und Netto-Vorschau im UI (Kap. 3.5.2, 4.9.1, 4.9.2). Kennzahl `plannedSaveRate` ist neu die Rate des **ersten** Phasenjahres. Drei Regressionstests ergänzt (10 → 13). Abschnitt 9 neu nummeriert (erledigte Punkte entfernt, „Sparraten werden nicht indexiert" ergänzt). |
|
||||||
| 0.1 | 2026-07-16 | Claude (Opus 4.8) | Erstfassung. Vollständige Neuerstellung aus dem Code (Stand `f768e01`). Ersetzt die bisherigen FDD/TDD-Dokumente v1–v5 vollständig. |
|
| 0.1 | 2026-07-16 | Claude (Opus 4.8) | Erstfassung. Vollständige Neuerstellung aus dem Code (Stand `f768e01`). Ersetzt die bisherigen FDD/TDD-Dokumente v1–v5 vollständig. |
|
||||||
@@ -575,6 +576,59 @@ neu angelegte Phase erzeugt damit automatisch einen offenen Cash-Entscheid am ne
|
|||||||
Referenz: `src/components/ElementDetail.tsx` (`CashTransitionFields`), `src/components/PlanView.tsx`
|
Referenz: `src/components/ElementDetail.tsx` (`CashTransitionFields`), `src/components/PlanView.tsx`
|
||||||
(`CashTransitionDialog`).
|
(`CashTransitionDialog`).
|
||||||
|
|
||||||
|
### 3.5.6 AHV-Prüfung am Pensions-Übergang
|
||||||
|
|
||||||
|
Die AHV-Rente hängt vom **massgebenden durchschnittlichen Jahreseinkommen (mdJE)** über die
|
||||||
|
ganze Beitragsdauer ab. Diese Grösse kann das Tool nicht allein aus dem Plan bestimmen: Die
|
||||||
|
Beitragspflicht beginnt mit 21, der Plan aber erst beim heutigen Alter. Bei einer 45-jährigen
|
||||||
|
Person liegen 24 Beitragsjahre vor dem Planbeginn.
|
||||||
|
|
||||||
|
Deshalb ist die Beitragskarriere **am Pensions-Übergang** zu prüfen – dort, wo bereits die
|
||||||
|
PK-Bezugsart und der 3a-Bezug entschieden werden. Der Dialog zeigt:
|
||||||
|
|
||||||
|
| Feld | Art |
|
||||||
|
|---|---|
|
||||||
|
| Geplantes Durchschnittseinkommen (aus dem Plan) | read-only, real |
|
||||||
|
| Beitragsjahre im Plan | read-only |
|
||||||
|
| Durchschnittseinkommen vor Planbeginn (real) | Eingabe – **nur wenn Alter bei Planbeginn > 21** |
|
||||||
|
| Ausfalljahre vor Planbeginn | Eingabe – **nur wenn Alter bei Planbeginn > 21** |
|
||||||
|
| Massgebendes durchschnittliches Jahreseinkommen | read-only, live berechnet |
|
||||||
|
| Resultierende AHV-Rente pro Jahr | read-only, live berechnet |
|
||||||
|
|
||||||
|
Die beiden Eingabefelder erscheinen also nur, wenn sie fachlich gebraucht werden. Die Zelle
|
||||||
|
zeigt `Geprueft` bzw. `?`; der Entscheid zählt im „offen"-Badge mit. An allen **anderen**
|
||||||
|
Übergängen ist die AHV-Zelle inaktiv (`–`).
|
||||||
|
|
||||||
|
**Wichtig zum Feld „vor Planbeginn":** Der Wert ist **real** (heutige Kaufkraft). Der
|
||||||
|
AHV-Kontoauszug listet Einkommen historisch-nominal – ein Lohn von 2008 steht dort mit dem
|
||||||
|
Betrag von 2008 und wäre zu tief. Die Zahl stammt idealerweise aus der **Rentenvorausberechnung**,
|
||||||
|
dort ist die Aufwertung bereits enthalten. Der Hilfetext im Feld sagt das.
|
||||||
|
|
||||||
|
**Sonderfall „bei Planbeginn bereits pensioniert":** Dann gibt es keinen Pensions-Übergang. Die
|
||||||
|
gleichen Felder erscheinen stattdessen in der **AHV-Phasenzelle der ersten Phase**, zusammen mit
|
||||||
|
der resultierenden Rente als Live-Vorschau.
|
||||||
|
|
||||||
|
Referenz: `src/components/ElementDetail.tsx` (`AhvReviewFields`), Formeln in
|
||||||
|
`src/lib/calculations.ts` (`ahvMdje`, `ahvAnnualPension`).
|
||||||
|
|
||||||
|
### 3.5.7 Warnhinweis bei Änderungen in früheren Phasen
|
||||||
|
|
||||||
|
Seit dem V3-Rework werden Werte **live fortgeschrieben** (Endwert = Startwert der Folgephase).
|
||||||
|
Eine Änderung in einer frühen Phase wirkt damit bis ans Planende durch – oft unbemerkt. Seit
|
||||||
|
Roadmap Nr. 3 gilt das verschärft: Ein geändertes Einkommen in Phase 1 verschiebt über das mdJE
|
||||||
|
auch die AHV-Rente in Phase 5.
|
||||||
|
|
||||||
|
Beim Bearbeiten einer Phase, der noch Phasen folgen, erscheint deshalb ein rot abgesetzter
|
||||||
|
Warnhinweis mit der Anzahl betroffener Folgephasen. Er erscheint in:
|
||||||
|
|
||||||
|
- **Phasenzellen** (Werte eines Elements in einer Phase)
|
||||||
|
- **Phasen-Detail** (Bezeichnung/Dauer – eine geänderte Dauer verschiebt alle Folgephasen)
|
||||||
|
|
||||||
|
Nicht in Übergangs-Dialogen (dort ist die Wirkung auf die Folgephase offensichtlich) und nicht
|
||||||
|
beim Anlegen eines Elements (dort gibt es noch nichts zu überschreiben).
|
||||||
|
|
||||||
|
Referenz: `src/components/ElementDetail.tsx` (`CarryWarning`).
|
||||||
|
|
||||||
## 3.6 Auswertung und Visualisierung
|
## 3.6 Auswertung und Visualisierung
|
||||||
|
|
||||||
### 3.6.1 Anzeigemodus nominal / beide / real
|
### 3.6.1 Anzeigemodus nominal / beide / real
|
||||||
@@ -780,29 +834,104 @@ gezählt** – es gibt keine Kalenderdaten im Modell, nur „Jahre ab Planbeginn
|
|||||||
|
|
||||||
## 4.4 AHV-Rente
|
## 4.4 AHV-Rente
|
||||||
|
|
||||||
Zwei Durchgänge pro Phase über alle AHV-Elemente:
|
Die Rente hängt an zwei Achsen: der **Beitragsdauer** (Rentenskala 44) und dem **massgebenden
|
||||||
|
durchschnittlichen Jahreseinkommen** (mdJE).
|
||||||
|
|
||||||
**1. Ausfalljahre kumulieren** – für Elemente, deren Besitzer in dieser Phase **erwerbstätig** ist:
|
### 4.4.1 Die amtliche Rentenformel (Skala 44)
|
||||||
|
|
||||||
|
Alle Schwellen sind Vielfache von `R0`, dem Mindestbetrag der vollen Monatsrente
|
||||||
|
(`AHV_MIN_MONTHLY_FULL = 1'260`). Mit `x = mdJE / (12 × R0)`:
|
||||||
|
|
||||||
```
|
```
|
||||||
gapYearsByPerson[owner] += max(0, round(phaseData.gapYears))
|
mdJE ≤ 12 × R0 (= 15'120) → Rente = R0 (Mindestrente 1'260)
|
||||||
|
12 × R0 < mdJE ≤ 36 × R0 → Rente = R0 × (0.74 + 0.26 × x) Formel 1
|
||||||
|
36 × R0 < mdJE < 72 × R0 → Rente = R0 × (1.04 + 0.16 × x) Formel 2
|
||||||
|
mdJE ≥ 72 × R0 (= 90'720) → Rente = 2 × R0 (Maximalrente 2'520)
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Ausfalljahre akkumulieren also über alle Erwerbsphasen hinweg.
|
Der Wendepunkt liegt bei `36 × R0 = 45'360` → 1'915/Monat; beide Formelteile sind dort stetig.
|
||||||
|
|
||||||
**2. Rente berechnen** – für Elemente, deren Besitzer in dieser Phase **pensioniert** ist:
|
**Quelle und Verifikation:** BSV/MAS „Berechnungsvorschriften der AHV/IV-Renten", gültig ab
|
||||||
|
1.1.2026 (liefert die Schwellenstruktur `12/36/72 × R0`), und die amtliche Tabelle
|
||||||
|
`318.117.1 – Monatliche Vollrenten, Skala 44`. Die implementierte Formel reproduziert **alle 51
|
||||||
|
Zeilen dieser Tabelle exakt**; Stützstellen sind als Golden Tests hinterlegt (Kap. 8.2).
|
||||||
|
|
||||||
|
Die Funktion `ahvMonthlyFullPension(mdJE)` rechnet bewusst **stetig**. Amtlich wird das mdJE auf
|
||||||
|
Vielfache von `1.2 × R0` gestuft (daher die 51 Tabellenzeilen); für eine Planung ist der stetige
|
||||||
|
Wert näher an der Wahrheit, die Abweichung liegt unter 20/Monat.
|
||||||
|
|
||||||
|
### 4.4.2 Beitragskarriere und mdJE
|
||||||
|
|
||||||
|
Pro Person wird über die Phasen hinweg akkumuliert (`AhvCareer`):
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `plannedAvgIncome` | reales Durchschnittseinkommen der Beitragsjahre **im Plan** |
|
||||||
|
| `planYears` | Beitragsjahre im Plan = Σ (Phasendauer − Ausfalljahre der Phase) |
|
||||||
|
| `yearsBeforePlan` | `max(0, Alter bei Planbeginn − 21)` |
|
||||||
|
| `gapYearsInPlan` | Summe der Ausfalljahre im Plan |
|
||||||
|
|
||||||
|
Das mdJE ist der **gewichtete Mittelwert über alle Beitragsjahre**:
|
||||||
|
|
||||||
```
|
```
|
||||||
factor = max(0, (44 − gapYears) / 44)
|
yearsBefore = max(0, yearsBeforePlan − gapYearsBefore)
|
||||||
rente = round(32'760 × factor)
|
mdJE = (avgIncomeBefore × yearsBefore + plannedAvgIncome × planYears)
|
||||||
|
/ (yearsBefore + planYears)
|
||||||
```
|
```
|
||||||
|
|
||||||
- `AHV_MAX_ANNUAL_SINGLE = 32'760` – maximale einfache Altersrente pro Jahr inkl. 13. Rente
|
Ausfalljahre reduzieren die **Gewichtung** (und die Skala), nicht das Durchschnittseinkommen –
|
||||||
(2'520/Monat × 13), Stand 2026, Quelle BSV.
|
genau wie in der echten AHV: Wer zwei Jahre aussetzt, hat deswegen kein tieferes
|
||||||
- `AHV_FULL_CONTRIBUTION_YEARS = 44` – volle Beitragsdauer (Rentenskala 44).
|
Durchschnittseinkommen, aber weniger Beitragsjahre.
|
||||||
|
|
||||||
**3. Ehepaar-Plafonierung** – nur bei `householdType = COUPLE` **und** wenn für **beide**
|
### 4.4.3 Warum real gerechnet wird
|
||||||
Personen eine Rente vorliegt:
|
|
||||||
|
Sämtliche Einkommen gehen **real** (Kaufkraft bei Planbeginn) in das mdJE ein, und die
|
||||||
|
Schwellen sind heutige Werte. Das ist kein Vereinfachungs-, sondern ein Genauigkeitsentscheid:
|
||||||
|
Die echte AHV **wertet vergangene Einkommen auf** (Lohnindex) **und indexiert die Schwellen**
|
||||||
|
(Mischindex). Beide Bewegungen heben sich in realer Betrachtung weitgehend auf – wer nominal
|
||||||
|
mittelt und gegen heutige Schwellen hält, vergleicht Franken von 2046 mit Schwellen von 2026 und
|
||||||
|
überschätzt die Rente systematisch.
|
||||||
|
|
||||||
|
> **Grössenordnung:** 45-jährig, 85'000 Lohn, +1.5 %/Jahr bei 2 % Inflation, 20 Erwerbsjahre.
|
||||||
|
> Nominal gemittelt: mdJE 98'276 → Maximalrente 32'760. Real gemittelt: mdJE 81'156 → 31'096.
|
||||||
|
> Differenz 1'664/Jahr, über 25 Rentenjahre rund 41'600 – und der Fehler geht immer nach oben.
|
||||||
|
|
||||||
|
Die reale Berechnung eines Phasen-Durchschnitts erfolgt analytisch (`avgRealFlow`) als
|
||||||
|
geometrische Reihe mit `q = (1 + Lohnerhöhung) / (1 + Inflation)`.
|
||||||
|
|
||||||
|
**Bekannte Unschärfe:** Die Schwellen folgen dem Mischindex, die Aufwertung dem Lohnindex. Da
|
||||||
|
Löhne langfristig schneller steigen als Preise, ist die Deflationierung mit der Preisinflation
|
||||||
|
leicht **konservativ**. Bewusst in Kauf genommen, statt eine dritte Indexannahme einzuführen.
|
||||||
|
|
||||||
|
### 4.4.4 Woher die Karriere-Werte kommen
|
||||||
|
|
||||||
|
| Situation | Quelle |
|
||||||
|
|---|---|
|
||||||
|
| Person retires innerhalb des Plans | `transitionValues` des AHV-Elements am Pensions-Übergang |
|
||||||
|
| Person bei Planbeginn bereits pensioniert | `phaseValues` des AHV-Elements in der ersten Phase |
|
||||||
|
| Prüfung noch nicht erfolgt | Fallback: `avgIncomeBefore = plannedAvgIncome` |
|
||||||
|
|
||||||
|
Der Fallback ist bewusst gewählt: Ohne erfassten Wert gilt der geplante Durchschnitt als
|
||||||
|
Schätzung für die Jahre davor – exakt der Wert, den der Dialog vorbelegt. Ein Fallback auf 0
|
||||||
|
würde die Rente still und massiv zu tief rechnen (bei einer 45-jährigen Person auf rund 45 %).
|
||||||
|
|
||||||
|
Einkommen wird einer Person nur zugerechnet, wenn das `INCOME`-Element ihr zugeordnet ist. Bei
|
||||||
|
einem **Einzelplan** zählt „Gemeinsam" (`HOUSEHOLD`) zur Person A; bei einem **Paar-Plan** nicht
|
||||||
|
(siehe [9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv)).
|
||||||
|
|
||||||
|
### 4.4.5 Jahresrente, Skala und Plafonierung
|
||||||
|
|
||||||
|
```
|
||||||
|
factor = max(0, (44 − Ausfalljahre total) / 44) // Rentenskala 44
|
||||||
|
rente = round(ahvMonthlyFullPension(mdJE) × 13 × factor)
|
||||||
|
```
|
||||||
|
|
||||||
|
`AHV_PENSION_MONTHS = 13` – seit 1.1.2026 gibt es die **13. Altersrente** (Art. 34bis AHVG).
|
||||||
|
Die Formel liefert Monatsrenten; der Jahresbetrag ist deshalb `× 13`, nicht `× 12`.
|
||||||
|
`AHV_MAX_ANNUAL_SINGLE` ist neu abgeleitet: `2 × R0 × 13 = 32'760`.
|
||||||
|
|
||||||
|
**Ehepaar-Plafonierung** – nur bei `householdType = COUPLE` **und** wenn für **beide** Personen
|
||||||
|
eine Rente vorliegt:
|
||||||
|
|
||||||
```
|
```
|
||||||
cap = 32'760 × 1.5 = 49'140
|
cap = 32'760 × 1.5 = 49'140
|
||||||
@@ -811,9 +940,11 @@ falls (renteA + renteB) > cap:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Die Rente ist danach **nominal fix** – sie wird über die Phasen hinweg nicht indexiert und
|
Die Rente ist danach **nominal fix** – sie wird über die Phasen hinweg nicht indexiert und
|
||||||
verliert damit real an Kaufkraft. Sie fliesst in `renteTotal` und wird im Einkommen mitgeführt.
|
verliert damit real an Kaufkraft (siehe [9.11](#911-ahv-rente-wird-nach-der-pensionierung-nicht-indexiert)).
|
||||||
|
Sie fliesst in `renteTotal` und wird im Einkommen mitgeführt.
|
||||||
|
|
||||||
Referenz: `src/lib/calculations.ts` Zeilen 180–202, `src/lib/constants.ts`.
|
Referenz: `src/lib/calculations.ts` (`ahvMonthlyFullPension`, `ahvMdje`, `ahvAnnualPension`),
|
||||||
|
`src/lib/constants.ts`.
|
||||||
|
|
||||||
## 4.5 Nominal, real und die Deflatoren
|
## 4.5 Nominal, real und die Deflatoren
|
||||||
|
|
||||||
@@ -1158,7 +1289,10 @@ Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bunde
|
|||||||
|
|
||||||
| Konstante | Wert | Bedeutung |
|
| Konstante | Wert | Bedeutung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `AHV_MAX_ANNUAL_SINGLE` | 32'760 | Max. einfache AHV-Altersrente/Jahr inkl. 13. Rente (2026) |
|
| `AHV_MIN_MONTHLY_FULL` | 1'260 | **R0** – Mindestbetrag der vollen Monatsrente (Skala 44). Alle Schwellen der Rentenformel sind Vielfache davon |
|
||||||
|
| `AHV_PENSION_MONTHS` | 13 | Rentenzahlungen pro Jahr – 13. Altersrente ab 1.1.2026 |
|
||||||
|
| `AHV_CONTRIBUTION_START_AGE` | 21 | Beitragspflicht ab 1. Januar nach dem 20. Geburtstag |
|
||||||
|
| `AHV_MAX_ANNUAL_SINGLE` | 32'760 | **abgeleitet**: `2 × R0 × 13` |
|
||||||
| `AHV_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente |
|
| `AHV_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente |
|
||||||
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
|
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
|
||||||
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
|
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
|
||||||
@@ -1358,6 +1492,8 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48
|
|||||||
| `amount` | INCOME, EXPENSE | ≥ 0 |
|
| `amount` | INCOME, EXPENSE | ≥ 0 |
|
||||||
| `teuerungsausgleich` | INCOME, EXPENSE | −20 bis 50 |
|
| `teuerungsausgleich` | INCOME, EXPENSE | −20 bis 50 |
|
||||||
| `gapYears` | AHV | Integer ≥ 0 |
|
| `gapYears` | AHV | Integer ≥ 0 |
|
||||||
|
| `avgIncomeBefore` | AHV – nur wenn bei Planbeginn **bereits pensioniert** | ≥ 0, **real** |
|
||||||
|
| `gapYearsBefore` | AHV – dito | Integer 0–50 |
|
||||||
| `currentValue` | PENSION_FUND, PILLAR_3A | ≥ 0 |
|
| `currentValue` | PENSION_FUND, PILLAR_3A | ≥ 0 |
|
||||||
| `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 |
|
| `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 |
|
||||||
| `expectedReturn` | PK, 3a, OTHER_ASSET | −50 bis 100 |
|
| `expectedReturn` | PK, 3a, OTHER_ASSET | −50 bis 100 |
|
||||||
@@ -1373,6 +1509,9 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48
|
|||||||
|
|
||||||
| Feld | Kategorien | Zod-Regel |
|
| Feld | Kategorien | Zod-Regel |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `reviewed` | AHV (Pensions-Übergang) – Beitragskarriere geprüft | Boolean |
|
||||||
|
| `avgIncomeBefore` | AHV (Pensions-Übergang) | ≥ 0, **real** |
|
||||||
|
| `gapYearsBefore` | AHV (Pensions-Übergang) | Integer 0–50 |
|
||||||
| `withdrawalMode` | PK, 3a (normal) | `NONE` \| `AMOUNT` |
|
| `withdrawalMode` | PK, 3a (normal) | `NONE` \| `AMOUNT` |
|
||||||
| `withdrawal` | PK, 3a (normal) | ≥ 0, **brutto** |
|
| `withdrawal` | PK, 3a (normal) | ≥ 0, **brutto** |
|
||||||
| `payoutMode` | PK (Pensionierung) | `CAPITAL` \| `PENSION` \| `COMBI` |
|
| `payoutMode` | PK (Pensionierung) | `CAPITAL` \| `PENSION` \| `COMBI` |
|
||||||
@@ -1661,7 +1800,8 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi
|
|||||||
## 8.1 Teststrategie
|
## 8.1 Teststrategie
|
||||||
|
|
||||||
Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das
|
Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das
|
||||||
Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 18 Tests („V5 Golden Tests"),
|
Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 30 Tests (AHV-Rentenformel,
|
||||||
|
AHV einkommensabhängig, „V5 Golden Tests"),
|
||||||
ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`).
|
ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`).
|
||||||
Es gibt **keine** Komponenten-, API- oder E2E-Tests.
|
Es gibt **keine** Komponenten-, API- oder E2E-Tests.
|
||||||
|
|
||||||
@@ -1669,6 +1809,15 @@ Es gibt **keine** Komponenten-, API- oder E2E-Tests.
|
|||||||
|
|
||||||
| Test | Prüft |
|
| Test | Prüft |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| **Amtliche Tabelle 318.117.1** | `ahvMonthlyFullPension` reproduziert 11 Stützstellen der amtlichen Rentenskala exakt (Mindestrente, Formel 1, Wendepunkt 45'360 → 1'915, Formel 2, Maximalrente) |
|
||||||
|
| **Kappung / Stetigkeit** | unter 15'120 → 1'260, über 90'720 → 2'520; kein Sprung am Wendepunkt |
|
||||||
|
| **AHV volle Karriere** | mdJE 100'000 → Maximalrente 32'760 |
|
||||||
|
| **AHV abgestuft / Mindestrente** | mdJE 60'000 → Formel 2; mdJE 10'000 → 1'260 × 13 |
|
||||||
|
| **AHV Vorgeschichte dominiert** | 39 Jahre à 40'000 + 5 Jahre à 200'000 → mdJE 58'182 |
|
||||||
|
| **AHV Ausfalljahre** | 4 Ausfalljahre → `32'760 × 40/44`; Ausfalljahre im Plan senken nur die Skala, nicht das mdJE |
|
||||||
|
| **AHV ohne Prüfung** | ohne erfassten Wert gilt der geplante Durchschnitt (nicht 0) |
|
||||||
|
| **AHV bereits pensioniert** | Karriere aus der Phasenzelle der ersten Phase |
|
||||||
|
| **AHV Plafonierung** | zwei Maximalrenten im Paar-Plan → gekappt auf `32'760 × 1.5` |
|
||||||
| Test 1 – Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
|
| Test 1 – Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
|
||||||
| Test 2 – Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` |
|
| Test 2 – Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` |
|
||||||
| Test 3 – Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ |
|
| Test 3 – Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ |
|
||||||
@@ -1766,12 +1915,51 @@ Ein Ereignis kann nur an einem Phasenübergang liegen. Ein Poolbau in Jahr 3 ein
|
|||||||
Phase ist nur abbildbar, wenn dort eine Phasengrenze gezogen wird. Nach der letzten Phase gibt
|
Phase ist nur abbildbar, wenn dort eine Phasengrenze gezogen wird. Nach der letzten Phase gibt
|
||||||
es keinen Übergang – ein dort erfasster Betrag bleibt wirkungslos (durch Test abgedeckt).
|
es keinen Übergang – ein dort erfasster Betrag bleibt wirkungslos (durch Test abgedeckt).
|
||||||
|
|
||||||
## 9.9 `PILLAR_3A_MAX_ANNUAL` wird nur im UI erzwungen
|
## 9.9 Gemeinsames Einkommen zählt bei Paaren nicht für die AHV
|
||||||
|
|
||||||
|
Das mdJE ist eine **personenbezogene** Grösse. Einkommen wird deshalb nur einer Person
|
||||||
|
zugerechnet, wenn das `INCOME`-Element ihr zugeordnet ist (`PERSON_A`/`PERSON_B`). Bei einem
|
||||||
|
**Einzelplan** zählt `HOUSEHOLD` zur Person A – es gibt ja nur eine. Bei einem **Paar-Plan**
|
||||||
|
bleibt `HOUSEHOLD`-Einkommen für die AHV unberücksichtigt.
|
||||||
|
|
||||||
|
Wer in einem Paar-Plan den Lohn als „Gemeinsam" erfasst, sieht deshalb im Prüf-Dialog ein
|
||||||
|
geplantes Durchschnittseinkommen von 0 und bekäme die Mindestrente. Der Dialog zeigt den Wert
|
||||||
|
prominent an, sodass der Fehler auffällt – aber es gibt keine aktive Warnung. Erwerbseinkommen
|
||||||
|
sollte in Paar-Plänen immer personenscharf erfasst werden.
|
||||||
|
|
||||||
|
Ebenfalls nicht modelliert: das **Einkommenssplitting** verheirateter Paare (während der Ehe
|
||||||
|
werden die Einkommen hälftig geteilt) sowie Erziehungs- und Betreuungsgutschriften. Beides würde
|
||||||
|
das mdJE real beeinflussen und wäre der nächste Ausbauschritt.
|
||||||
|
|
||||||
|
## 9.10 Verhaltensänderung: AHV-Rente bestehender Pläne
|
||||||
|
|
||||||
|
Bis Version 0.3 erhielt jede AHV-Position pauschal die **Maximalrente** (32'760), gekürzt nur um
|
||||||
|
Ausfalljahre – unabhängig vom Einkommen. Seit 0.4 folgt sie der Rentenformel. Bestehende Pläne
|
||||||
|
zeigen dadurch eine **andere, in der Regel tiefere** AHV-Rente, sobald das geplante
|
||||||
|
Durchschnittseinkommen unter 90'720 liegt. Das ist keine Regression, sondern die Korrektur einer
|
||||||
|
zu optimistischen Pauschale.
|
||||||
|
|
||||||
|
Zwei Fälle brauchen Aufmerksamkeit:
|
||||||
|
|
||||||
|
- **Bereits pensionierte Personen** (bei Planbeginn): Ohne erfasstes Durchschnittseinkommen
|
||||||
|
ergibt das mdJE 0 → **Mindestrente**. Vorher war es die Maximalrente. Die Felder stehen in der
|
||||||
|
AHV-Phasenzelle der ersten Phase; solange sie leer sind, ist die Rente bewusst konservativ.
|
||||||
|
- **Paar-Pläne mit `HOUSEHOLD`-Einkommen**: siehe 9.9.
|
||||||
|
|
||||||
|
## 9.11 AHV-Rente wird nach der Pensionierung nicht indexiert
|
||||||
|
|
||||||
|
Die Rente wird zum Pensionierungszeitpunkt in heutigem Geld berechnet und danach **nominal
|
||||||
|
eingefroren**. Die echte AHV wird alle zwei Jahre an den Mischindex angepasst. Über 25
|
||||||
|
Rentenjahre verliert die modellierte Rente damit real spürbar an Wert – das Modell ist an dieser
|
||||||
|
Stelle deutlich konservativ. Bewusster Alt-Entscheid, unabhängig von der Rentenformel; der
|
||||||
|
grösste verbliebene Hebel im AHV-Modell.
|
||||||
|
|
||||||
|
## 9.12 `PILLAR_3A_MAX_ANNUAL` wird nur im UI erzwungen
|
||||||
|
|
||||||
Das Feld ist per `max`-Prop hart geklammert. Das Zod-Schema kennt für `annualContribution` nur
|
Das Feld ist per `max`-Prop hart geklammert. Das Zod-Schema kennt für `annualContribution` nur
|
||||||
`≥ 0` – ein direkter API-Aufruf kann die Obergrenze überschreiten.
|
`≥ 0` – ein direkter API-Aufruf kann die Obergrenze überschreiten.
|
||||||
|
|
||||||
## 9.10 Kleinere Beobachtungen
|
## 9.13 Kleinere Beobachtungen
|
||||||
|
|
||||||
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht.
|
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht.
|
||||||
- Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) – ein Rest der
|
- Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) – ein Rest der
|
||||||
@@ -1810,6 +1998,9 @@ Das Feld ist per `max`-Prop hart geklammert. Das Zod-Schema kennt für `annualCo
|
|||||||
| **Real** | Kaufkraftbereinigt auf den Planbeginn (`nominal / Deflator`) |
|
| **Real** | Kaufkraftbereinigt auf den Planbeginn (`nominal / Deflator`) |
|
||||||
| **Deflator** | Kumulierte Inflation seit Planbeginn |
|
| **Deflator** | Kumulierte Inflation seit Planbeginn |
|
||||||
| **Ausfalljahr** | Jahr ohne AHV-Beiträge; kürzt die Rente um 1/44 |
|
| **Ausfalljahr** | Jahr ohne AHV-Beiträge; kürzt die Rente um 1/44 |
|
||||||
|
| **mdJE** | Massgebendes durchschnittliches Jahreseinkommen – Mittel der Beitragsjahre, bestimmt die Rentenhöhe |
|
||||||
|
| **R0** | Mindestbetrag der vollen AHV-Monatsrente (1'260); alle Schwellen sind Vielfache davon |
|
||||||
|
| **Wendepunkt** | mdJE = 36 × R0 = 45'360; dort wechselt die Rentenformel von Teil 1 auf Teil 2 |
|
||||||
| **Plafonierung** | Deckelung der Ehepaar-AHV auf 150 % der Einzel-Maximalrente |
|
| **Plafonierung** | Deckelung der Ehepaar-AHV auf 150 % der Einzel-Maximalrente |
|
||||||
| **Umwandlungssatz** | Prozentsatz zur Verrentung des PK-Kapitals |
|
| **Umwandlungssatz** | Prozentsatz zur Verrentung des PK-Kapitals |
|
||||||
| **Ruin(alter)** | Alter von Person A, in dem das Gesamtvermögen erstmals unter 0 fällt |
|
| **Ruin(alter)** | Alter von Person A, in dem das Gesamtvermögen erstmals unter 0 fällt |
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
|
||||||
import { CATEGORY_LABELS, num } from "@/lib/elements";
|
import { CATEGORY_LABELS, num } from "@/lib/elements";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CAPITAL_TAX_RATE,
|
DEFAULT_CAPITAL_TAX_RATE,
|
||||||
@@ -31,6 +33,10 @@ export interface CellContext {
|
|||||||
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
|
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
|
||||||
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
|
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
|
||||||
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
||||||
|
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
|
||||||
|
laterPhaseCount: number;
|
||||||
|
// AHV-Beitragskarriere des Element-Besitzers (fuer die Pruefung am Pensions-Uebergang).
|
||||||
|
ahvCareer: AhvCareer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -42,6 +48,24 @@ interface Props {
|
|||||||
onDeleteElement: () => void;
|
onDeleteElement: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
|
||||||
|
// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Aenderung hier wirkt also bis
|
||||||
|
// ans Planende durch, was ohne Hinweis leicht uebersehen wird.
|
||||||
|
export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
|
||||||
|
if (laterPhaseCount <= 0) return null;
|
||||||
|
const phasen = laterPhaseCount === 1 ? "die nachfolgende Lebensphase" : `die ${laterPhaseCount} nachfolgenden Lebensphasen`;
|
||||||
|
return (
|
||||||
|
<div className="col-span-full flex items-start gap-2 rounded-lg border border-danger bg-danger-soft px-3 py-2 text-xs text-danger">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>Achtung: Diese Lebensphase ist nicht die letzte.</strong> Werte werden fortgeschrieben
|
||||||
|
(Endwert = Startwert der Folgephase). Eine Aenderung hier wirkt sich auf {phasen} aus und
|
||||||
|
veraendert deren Startwerte, Kennzahlen sowie Endvermoegen, Cash-Verlauf und Ruinalter.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Read-only Anzeige eines abgeleiteten (fortgeschriebenen) Wertes.
|
// Read-only Anzeige eines abgeleiteten (fortgeschriebenen) Wertes.
|
||||||
function DerivedField({ label, value, help }: { label: string; value: number; help?: string }) {
|
function DerivedField({ label, value, help }: { label: string; value: number; help?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -111,9 +135,86 @@ function WithdrawalDecision({
|
|||||||
|
|
||||||
// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default
|
// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default
|
||||||
// (Halten / Kein Bezug / Rente) auch tatsaechlich persistiert.
|
// (Halten / Kein Bezug / Rente) auch tatsaechlich persistiert.
|
||||||
|
// AHV-Pruefung am Pensions-Uebergang: geplantes Durchschnittseinkommen aus dem Plan plus --
|
||||||
|
// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurueckreicht -- die Jahre davor.
|
||||||
|
export function AhvReviewFields({
|
||||||
|
career,
|
||||||
|
td,
|
||||||
|
setT,
|
||||||
|
}: {
|
||||||
|
career: AhvCareer;
|
||||||
|
td: TransitionData;
|
||||||
|
setT: (patch: Partial<TransitionData>) => void;
|
||||||
|
}) {
|
||||||
|
const brauchtVorgeschichte = career.yearsBeforePlan > 0;
|
||||||
|
// Vorbelegung mit dem geplanten Durchschnitt: besser als 0, wenn der Nutzer nichts weiss.
|
||||||
|
const avgBefore = typeof td.avgIncomeBefore === "number" ? td.avgIncomeBefore : Math.round(career.plannedAvgIncome);
|
||||||
|
const gapBefore = Math.max(0, Math.round(num(td.gapYearsBefore)));
|
||||||
|
const mdJE = ahvMdje(career, brauchtVorgeschichte ? avgBefore : 0, gapBefore);
|
||||||
|
const rente = ahvAnnualPension(mdJE, career.gapYearsInPlan + gapBefore);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
|
Die AHV-Rente haengt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
|
||||||
|
ueber die ganze Beitragsdauer (ab Alter 21) ab. Alle Betraege sind REAL (heutige Kaufkraft) –
|
||||||
|
die AHV wertet vergangene Einkommen auf und indexiert die Schwellen, was sich real weitgehend aufhebt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DerivedField
|
||||||
|
label="Geplantes Durchschnittseinkommen (aus dem Plan)"
|
||||||
|
value={Math.round(career.plannedAvgIncome)}
|
||||||
|
help={`Reales Mittel ueber ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans. Nur Einkommen, die dieser Person zugeordnet sind.`}
|
||||||
|
/>
|
||||||
|
<DerivedField
|
||||||
|
label="Beitragsjahre im Plan"
|
||||||
|
value={career.planYears}
|
||||||
|
help="Dauer der Erwerbsphasen abzueglich der dort erfassten Ausfalljahre."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{brauchtVorgeschichte && (
|
||||||
|
<>
|
||||||
|
<p className="col-span-full rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted">
|
||||||
|
Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre
|
||||||
|
davor kennt das Tool nicht – bitte ergaenzen.
|
||||||
|
</p>
|
||||||
|
<MoneyField
|
||||||
|
label="Durchschnittseinkommen vor Planbeginn (real)"
|
||||||
|
help="In HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung uebernehmen – dort ist die Aufwertung bereits enthalten. Die Rohbetraege im Kontoauszug sind historisch-nominal und daher zu tief."
|
||||||
|
value={avgBefore}
|
||||||
|
onChange={(v) => setT({ avgIncomeBefore: v })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Ausfalljahre vor Planbeginn"
|
||||||
|
help="Jahre ohne AHV-Beitraege zwischen Alter 21 und Planbeginn (z. B. Ausland, Studium). Jedes kuerzt die Rente um 1/44."
|
||||||
|
value={gapBefore}
|
||||||
|
min={0}
|
||||||
|
max={career.yearsBeforePlan}
|
||||||
|
onChange={(v) => setT({ gapYearsBefore: Math.max(0, Math.min(career.yearsBeforePlan, Math.round(v))) })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DerivedField
|
||||||
|
label="Massgebendes durchschnittliches Jahreseinkommen"
|
||||||
|
value={Math.round(mdJE)}
|
||||||
|
help="Gewichtetes Mittel ueber alle Beitragsjahre (vor Planbeginn und im Plan). Ab 90'720 gibt es die Maximalrente."
|
||||||
|
/>
|
||||||
|
<DerivedField
|
||||||
|
label="Resultierende AHV-Rente pro Jahr"
|
||||||
|
value={rente}
|
||||||
|
help="Vollrente zum mdJE (Skala 44), inkl. 13. Altersrente, gekuerzt um die Ausfalljahre. Bei Ehepaaren zusaetzlich plafoniert."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData {
|
export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData {
|
||||||
const out = { ...td };
|
const out = { ...td };
|
||||||
if (category === "REAL_ESTATE" || category === "OTHER_ASSET") {
|
if (category === "AHV") {
|
||||||
|
// Ein Speichern der Pruefung markiert sie als erledigt (gleiches Muster wie bei PK/3a).
|
||||||
|
if (isRetirement && out.reviewed === undefined) out.reviewed = true;
|
||||||
|
} else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") {
|
||||||
if (out.decision === undefined) out.decision = "HOLD";
|
if (out.decision === undefined) out.decision = "HOLD";
|
||||||
} else if (category === "PENSION_FUND") {
|
} else if (category === "PENSION_FUND") {
|
||||||
if (isRetirement) {
|
if (isRetirement) {
|
||||||
@@ -271,6 +372,9 @@ export function CashTransitionFields({
|
|||||||
// "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen).
|
// "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen).
|
||||||
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
|
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
|
case "AHV":
|
||||||
|
// Nur bei der Pensionierung ist eine Pruefung der Beitragskarriere noetig.
|
||||||
|
return isRetirement ? td.reviewed === true : true;
|
||||||
case "REAL_ESTATE":
|
case "REAL_ESTATE":
|
||||||
case "OTHER_ASSET":
|
case "OTHER_ASSET":
|
||||||
return td.decision !== undefined;
|
return td.decision !== undefined;
|
||||||
@@ -342,10 +446,42 @@ export function ElementPhaseFields({
|
|||||||
}
|
}
|
||||||
case "AHV":
|
case "AHV":
|
||||||
if (!context.ownerWorking) {
|
if (!context.ownerWorking) {
|
||||||
|
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Uebergang,
|
||||||
|
// an dem die Karriere geprueft werden koennte. Dann hier erfassen (nur erste Phase).
|
||||||
|
if (!carried && context.ahvCareer && context.ahvCareer.planYears === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
|
Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente haengt vom
|
||||||
|
massgebenden durchschnittlichen Jahreseinkommen ueber die ganze Beitragsdauer ab –
|
||||||
|
bitte hier erfassen (REAL, heutige Kaufkraft).
|
||||||
|
</p>
|
||||||
|
<MoneyField
|
||||||
|
label="Durchschnittseinkommen der Beitragsjahre (real)"
|
||||||
|
help="Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfuegung. Ab 90'720 gibt es die Maximalrente."
|
||||||
|
value={num(pd.avgIncomeBefore)}
|
||||||
|
onChange={(v) => setP({ avgIncomeBefore: v })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Ausfalljahre total"
|
||||||
|
help="Jahre ohne AHV-Beitraege. Jedes kuerzt die Rente um 1/44."
|
||||||
|
value={num(pd.gapYearsBefore)}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => setP({ gapYearsBefore: Math.max(0, Math.round(v)) })}
|
||||||
|
/>
|
||||||
|
<DerivedField
|
||||||
|
label="Resultierende AHV-Rente pro Jahr"
|
||||||
|
value={ahvAnnualPension(num(pd.avgIncomeBefore), num(pd.gapYearsBefore))}
|
||||||
|
help="Inkl. 13. Altersrente. Bei Ehepaaren zusaetzlich plafoniert."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-muted">
|
<p className="col-span-2 text-sm text-muted">
|
||||||
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der
|
Die AHV-Rente wird aus der beim Pensions-Uebergang geprueften Beitragskarriere berechnet
|
||||||
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.
|
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
||||||
|
Plafonierung auf 150% der Maximalrente.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -517,13 +653,21 @@ export function ElementTransitionFields({
|
|||||||
switch (element.category) {
|
switch (element.category) {
|
||||||
case "INCOME":
|
case "INCOME":
|
||||||
case "EXPENSE":
|
case "EXPENSE":
|
||||||
case "AHV":
|
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-muted">
|
<p className="col-span-2 text-sm text-muted">
|
||||||
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
|
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
|
||||||
Lebensphase uebernommen und koennen dort angepasst werden.
|
Lebensphase uebernommen und koennen dort angepasst werden.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
case "AHV":
|
||||||
|
if (context.isRetirementTransition && context.ahvCareer) {
|
||||||
|
return <AhvReviewFields career={context.ahvCareer} td={td} setT={setT} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="col-span-2 text-sm text-muted">
|
||||||
|
Die Beitragskarriere wird erst beim Uebergang in die Pensionierung geprueft.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
case "PENSION_FUND":
|
case "PENSION_FUND":
|
||||||
if (context.isRetirementTransition) {
|
if (context.isRetirementTransition) {
|
||||||
const mode = td.payoutMode ?? "PENSION";
|
const mode = td.payoutMode ?? "PENSION";
|
||||||
@@ -689,6 +833,8 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{/* Punkt 4: Warnung, dass Aenderungen sich in die Folgephasen fortschreiben. */}
|
||||||
|
{!isTransition && <CarryWarning laterPhaseCount={context.laterPhaseCount} />}
|
||||||
{isTransition ? (
|
{isTransition ? (
|
||||||
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
|
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { NumberField, TextField } from "@/components/FormField";
|
import { NumberField, TextField } from "@/components/FormField";
|
||||||
|
import { CarryWarning } from "@/components/ElementDetail";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { PhaseInput } from "@/lib/types";
|
import type { PhaseInput } from "@/lib/types";
|
||||||
|
|
||||||
@@ -10,12 +11,14 @@ export function PhaseDetail({
|
|||||||
phase,
|
phase,
|
||||||
maxDurationYears,
|
maxDurationYears,
|
||||||
isLast,
|
isLast,
|
||||||
|
laterPhaseCount,
|
||||||
onSaved,
|
onSaved,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
phase: PhaseInput;
|
phase: PhaseInput;
|
||||||
maxDurationYears: number | null;
|
maxDurationYears: number | null;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
|
laterPhaseCount: number;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -67,6 +70,8 @@ export function PhaseDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{/* Eine geaenderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermoegen). */}
|
||||||
|
<CarryWarning laterPhaseCount={laterPhaseCount} />
|
||||||
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
||||||
<NumberField
|
<NumberField
|
||||||
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
|
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
|
||||||
|
|||||||
+45
-18
@@ -60,7 +60,10 @@ const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
|||||||
OTHER_DEBT: <CreditCard className="h-4 w-4" />,
|
OTHER_DEBT: <CreditCard className="h-4 w-4" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Kategorien mit einem Uebergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim
|
||||||
|
// Pensions-Uebergang ist die Beitragskarriere zu pruefen (siehe transitionInactive).
|
||||||
const TRANSITION_CATEGORIES: ElementCategory[] = [
|
const TRANSITION_CATEGORIES: ElementCategory[] = [
|
||||||
|
"AHV",
|
||||||
"PENSION_FUND",
|
"PENSION_FUND",
|
||||||
"PILLAR_3A",
|
"PILLAR_3A",
|
||||||
"REAL_ESTATE",
|
"REAL_ESTATE",
|
||||||
@@ -161,6 +164,18 @@ export function PlanView({
|
|||||||
return !!before?.working && !!after && !after.working;
|
return !!before?.working && !!after && !after.working;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Beitragskarriere des Element-Besitzers (nur fuer AHV relevant).
|
||||||
|
function careerFor(element: ElementInput) {
|
||||||
|
if (element.category !== "AHV" || !element.ownerRole || element.ownerRole === "HOUSEHOLD") return null;
|
||||||
|
const person = plan.persons.find((p) => p.role === element.ownerRole);
|
||||||
|
return computed.ahvCareer.find((c) => c.personId === person?.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anzahl Phasen NACH dieser (fuer den Fortschreibungs-Warnhinweis).
|
||||||
|
function laterPhaseCount(phase: PhaseComputed): number {
|
||||||
|
return computed.phases.length - phase.sequenceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
// Baut den Kontext fuer eine Phasenzelle.
|
// Baut den Kontext fuer eine Phasenzelle.
|
||||||
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
||||||
const ce = computedElement(phase.id, element.id);
|
const ce = computedElement(phase.id, element.id);
|
||||||
@@ -179,6 +194,8 @@ export function PlanView({
|
|||||||
carried: ce?.carried ?? false,
|
carried: ce?.carried ?? false,
|
||||||
derivedStart: ce?.baseValue ?? 0,
|
derivedStart: ce?.baseValue ?? 0,
|
||||||
deflatorStart: phase.cumulativeInflationStart,
|
deflatorStart: phase.cumulativeInflationStart,
|
||||||
|
laterPhaseCount: laterPhaseCount(phase),
|
||||||
|
ahvCareer: careerFor(element),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,14 +212,20 @@ export function PlanView({
|
|||||||
carried: ce?.carried ?? false,
|
carried: ce?.carried ?? false,
|
||||||
derivedStart: 0,
|
derivedStart: 0,
|
||||||
deflatorStart: fromPhase.cumulativeInflationStart,
|
deflatorStart: fromPhase.cumulativeInflationStart,
|
||||||
|
laterPhaseCount: laterPhaseCount(fromPhase),
|
||||||
|
ahvCareer: careerFor(element),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Am Uebergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung
|
// Am Uebergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung
|
||||||
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet).
|
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet)
|
||||||
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed): boolean {
|
// ODER AHV ausserhalb des Pensions-Uebergangs.
|
||||||
|
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean {
|
||||||
const ce = computedElement(fromPhase.id, el.id);
|
const ce = computedElement(fromPhase.id, el.id);
|
||||||
if (ce && ce.status !== "ACTIVE") return true;
|
if (ce && ce.status !== "ACTIVE") return true;
|
||||||
|
if (el.category === "AHV") {
|
||||||
|
return !(toPhase && isRetirementTransition(el, fromPhase, toPhase));
|
||||||
|
}
|
||||||
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
|
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
|
||||||
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
|
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
|
||||||
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
|
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
|
||||||
@@ -222,7 +245,7 @@ export function PlanView({
|
|||||||
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
|
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
|
||||||
for (const el of plan.elements) {
|
for (const el of plan.elements) {
|
||||||
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
||||||
if (transitionInactive(el, fromPhase)) continue;
|
if (transitionInactive(el, fromPhase, toPhase)) continue;
|
||||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||||
const retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false;
|
const retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false;
|
||||||
if (!isTransitionAnswered(el.category, retire, td)) n++;
|
if (!isTransitionAnswered(el.category, retire, td)) n++;
|
||||||
@@ -232,18 +255,15 @@ export function PlanView({
|
|||||||
|
|
||||||
// Ist der Uebergangs-Entscheid dieses Elements noch offen?
|
// Ist der Uebergangs-Entscheid dieses Elements noch offen?
|
||||||
function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
|
function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
|
||||||
if (transitionInactive(el, fromPhase)) return false;
|
if (transitionInactive(el, fromPhase, toPhase)) return false;
|
||||||
const retire = isRetirementTransition(el, fromPhase, toPhase);
|
const retire = isRetirementTransition(el, fromPhase, toPhase);
|
||||||
return !isTransitionAnswered(el.category, retire, el.transitionValues[fromPhase.id] ?? {});
|
return !isTransitionAnswered(el.category, retire, el.transitionValues[fromPhase.id] ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function transitionElements(fromPhase: PhaseComputed): ElementInput[] {
|
function transitionElements(fromPhase: PhaseComputed, toPhase?: PhaseComputed): ElementInput[] {
|
||||||
return plan.elements
|
return plan.elements
|
||||||
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
|
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
|
||||||
.filter((el) => {
|
.filter((el) => !transitionInactive(el, fromPhase, toPhase))
|
||||||
const ce = computedElement(fromPhase.id, el.id);
|
|
||||||
return !ce || ce.status === "ACTIVE";
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.orderIndex - b.orderIndex);
|
.sort((a, b) => a.orderIndex - b.orderIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +481,7 @@ export function PlanView({
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const locked = TRANSITION_CATEGORIES.includes(el.category) && transitionInactive(el, col.fromPhase);
|
const locked = TRANSITION_CATEGORIES.includes(el.category) && transitionInactive(el, col.fromPhase, col.toPhase);
|
||||||
const canTransition = TRANSITION_CATEGORIES.includes(el.category) && !locked;
|
const canTransition = TRANSITION_CATEGORIES.includes(el.category) && !locked;
|
||||||
const open = canTransition && transitionUnanswered(el, col.fromPhase, col.toPhase);
|
const open = canTransition && transitionUnanswered(el, col.fromPhase, col.toPhase);
|
||||||
return (
|
return (
|
||||||
@@ -571,7 +591,7 @@ export function PlanView({
|
|||||||
if (!fromPhase) return null;
|
if (!fromPhase) return null;
|
||||||
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
||||||
const toPhase = computed.phases[toIndex];
|
const toPhase = computed.phases[toIndex];
|
||||||
const els = transitionElements(fromPhase);
|
const els = transitionElements(fromPhase, toPhase);
|
||||||
return (
|
return (
|
||||||
<TransitionReviewDialog
|
<TransitionReviewDialog
|
||||||
fromPhase={fromPhase}
|
fromPhase={fromPhase}
|
||||||
@@ -660,6 +680,7 @@ export function PlanView({
|
|||||||
phase={phaseInput}
|
phase={phaseInput}
|
||||||
maxDurationYears={phase.maxDurationYears}
|
maxDurationYears={phase.maxDurationYears}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
laterPhaseCount={laterPhaseCount(phase)}
|
||||||
onSaved={onChanged}
|
onSaved={onChanged}
|
||||||
onDeleted={() => {
|
onDeleted={() => {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@@ -679,6 +700,8 @@ export function PlanView({
|
|||||||
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
|
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
|
||||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||||
switch (el.category) {
|
switch (el.category) {
|
||||||
|
case "AHV":
|
||||||
|
return td.reviewed === true ? "Geprueft" : "?";
|
||||||
case "REAL_ESTATE":
|
case "REAL_ESTATE":
|
||||||
case "OTHER_ASSET":
|
case "OTHER_ASSET":
|
||||||
return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?";
|
return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?";
|
||||||
@@ -913,6 +936,8 @@ function AddElementDialog({
|
|||||||
carried: false,
|
carried: false,
|
||||||
derivedStart: 0,
|
derivedStart: 0,
|
||||||
deflatorStart: firstPhase.cumulativeInflationStart,
|
deflatorStart: firstPhase.cumulativeInflationStart,
|
||||||
|
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
|
||||||
|
ahvCareer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
@@ -1175,13 +1200,15 @@ function TransitionReviewDialog({
|
|||||||
const ctx = buildContext(el);
|
const ctx = buildContext(el);
|
||||||
const retire = isRetirement(el);
|
const retire = isRetirement(el);
|
||||||
const hint =
|
const hint =
|
||||||
(el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
|
el.category === "AHV" && retire
|
||||||
? "Hier könnten Sie optional Kapital beziehen."
|
? "Pensionierung: Beitragskarriere prüfen – die Rente hängt vom Durchschnittseinkommen ab."
|
||||||
: el.category === "PENSION_FUND" && retire
|
: (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
|
||||||
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
|
? "Hier könnten Sie optional Kapital beziehen."
|
||||||
: el.category === "PILLAR_3A" && retire
|
: el.category === "PENSION_FUND" && retire
|
||||||
? "Wird bei Pensionierung vollständig bezogen."
|
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
|
||||||
: null;
|
: el.category === "PILLAR_3A" && retire
|
||||||
|
? "Wird bei Pensionierung vollständig bezogen."
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
|
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations";
|
||||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
@@ -45,8 +45,173 @@ function plan(opts: {
|
|||||||
|
|
||||||
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
||||||
|
|
||||||
|
// Stuetzstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
|
||||||
|
// (BSV, gueltig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
|
||||||
|
// Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab.
|
||||||
|
const AHV_AMTLICHE_TABELLE: [number, number][] = [
|
||||||
|
[15120, 1260], // Mindestrente (12 x R0)
|
||||||
|
[16632, 1293],
|
||||||
|
[22680, 1424],
|
||||||
|
[30240, 1588],
|
||||||
|
[43848, 1882],
|
||||||
|
[45360, 1915], // Wendepunkt (36 x R0)
|
||||||
|
[46872, 1935],
|
||||||
|
[60480, 2117],
|
||||||
|
[75600, 2318],
|
||||||
|
[89208, 2500],
|
||||||
|
[90720, 2520], // Maximalrente (72 x R0)
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("AHV-Rentenformel (Skala 44)", () => {
|
||||||
|
it("reproduziert die amtliche Tabelle 318.117.1 exakt", () => {
|
||||||
|
for (const [mdJE, erwartet] of AHV_AMTLICHE_TABELLE) {
|
||||||
|
expect(Math.round(ahvMonthlyFullPension(mdJE)), `mdJE ${mdJE}`).toBe(erwartet);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kappt ausserhalb der Schwellen", () => {
|
||||||
|
expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente
|
||||||
|
expect(ahvMonthlyFullPension(10000)).toBe(1260);
|
||||||
|
expect(ahvMonthlyFullPension(150000)).toBe(2520); // ueber 72 x R0 -> Maximalrente
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => {
|
||||||
|
const links = ahvMonthlyFullPension(45360 - 0.01);
|
||||||
|
const rechts = ahvMonthlyFullPension(45360 + 0.01);
|
||||||
|
expect(Math.abs(links - rechts)).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
|
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
|
||||||
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
|
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
|
||||||
|
describe("AHV einkommensabhaengig", () => {
|
||||||
|
// 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
|
||||||
|
function ahvPlan(opts: {
|
||||||
|
age?: number;
|
||||||
|
income: number;
|
||||||
|
avgIncomeBefore?: number;
|
||||||
|
gapYearsBefore?: number;
|
||||||
|
gapYearsInPlan?: number;
|
||||||
|
reviewed?: boolean;
|
||||||
|
}) {
|
||||||
|
const age = opts.age ?? 60;
|
||||||
|
return plan({
|
||||||
|
age,
|
||||||
|
retirementAge: 65,
|
||||||
|
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand pruefbar bleiben
|
||||||
|
phases: [
|
||||||
|
{ id: "p1", durationYears: 65 - age },
|
||||||
|
{ id: "p2", durationYears: 10 },
|
||||||
|
],
|
||||||
|
elements: [
|
||||||
|
el("INCOME", "PERSON_A", { p1: { amount: opts.income, teuerungsausgleich: 0 }, p2: {} }),
|
||||||
|
el(
|
||||||
|
"AHV",
|
||||||
|
"PERSON_A",
|
||||||
|
{ p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} },
|
||||||
|
{
|
||||||
|
p1: {
|
||||||
|
reviewed: opts.reviewed ?? true,
|
||||||
|
avgIncomeBefore: opts.avgIncomeBefore ?? 0,
|
||||||
|
gapYearsBefore: opts.gapYearsBefore ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const renteIn = (p: PlanInput, phaseIdx: number) =>
|
||||||
|
computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.startValue;
|
||||||
|
|
||||||
|
it("volle Karriere auf Maximalniveau -> Maximalrente 32'760", () => {
|
||||||
|
const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
|
||||||
|
expect(renteIn(p, 1)).toBe(32760); // 2'520 x 13
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => {
|
||||||
|
// mdJE = 60'000 -> 1'260 x (1.04 + 0.16 x 60'000/15'120) = 2'118.1/Monat x 13 = 27'535
|
||||||
|
const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
|
||||||
|
const erwartet = Math.round(ahvMonthlyFullPension(60000) * 13);
|
||||||
|
expect(renteIn(p, 1)).toBe(erwartet);
|
||||||
|
expect(renteIn(p, 1)).toBeLessThan(32760);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tiefes Einkommen -> Mindestrente 1'260 x 13", () => {
|
||||||
|
const p = ahvPlan({ income: 10000, avgIncomeBefore: 10000 });
|
||||||
|
expect(renteIn(p, 1)).toBe(1260 * 13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Einkommen vor Planbeginn dominiert bei kurzer Restlaufzeit", () => {
|
||||||
|
// 39 Jahre vor Planbeginn zu 40'000, nur 5 Jahre im Plan zu 200'000.
|
||||||
|
const p = ahvPlan({ income: 200000, avgIncomeBefore: 40000 });
|
||||||
|
const mdJE = (40000 * 39 + 200000 * 5) / 44; // = 58'181.8
|
||||||
|
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ausfalljahre kuerzen die Rente ueber die Skala 44", () => {
|
||||||
|
const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
|
||||||
|
const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 });
|
||||||
|
expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger
|
||||||
|
expect(renteIn(mit, 1)).toBeLessThan(renteIn(ohne, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ausfalljahre im Plan senken die Beitragsjahre, nicht das Durchschnittseinkommen", () => {
|
||||||
|
// Gleiches Einkommen, aber 2 Ausfalljahre im Plan -> nur die Skala sinkt.
|
||||||
|
const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsInPlan: 2 });
|
||||||
|
expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ohne Pruefung gilt der geplante Durchschnitt auch fuer die Jahre vor Planbeginn", () => {
|
||||||
|
// Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%).
|
||||||
|
const p = plan({
|
||||||
|
age: 60,
|
||||||
|
retirementAge: 65,
|
||||||
|
inflation: 0,
|
||||||
|
phases: [
|
||||||
|
{ id: "p1", durationYears: 5 },
|
||||||
|
{ id: "p2", durationYears: 5 },
|
||||||
|
],
|
||||||
|
elements: [
|
||||||
|
el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
|
||||||
|
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Uebergangsdaten
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
|
||||||
|
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000) * 13)); // mdJE = 80'000, nicht 9'091
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bereits bei Planbeginn pensioniert: Karriere kommt aus der Phasenzelle", () => {
|
||||||
|
const p = plan({
|
||||||
|
age: 66,
|
||||||
|
retirementAge: 65,
|
||||||
|
inflation: 0,
|
||||||
|
phases: [{ id: "p1", durationYears: 10 }],
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ehepaar-Plafonierung greift weiterhin (150% der Maximalrente)", () => {
|
||||||
|
const p: PlanInput = {
|
||||||
|
id: "plan", name: "T", householdType: "COUPLE", inflationRateDefault: 0, initialCash: 0,
|
||||||
|
persons: [
|
||||||
|
{ id: "A", role: "PERSON_A", name: null, age: 66, retirementAge: 65 },
|
||||||
|
{ id: "B", role: "PERSON_B", name: null, age: 66, retirementAge: 65 },
|
||||||
|
],
|
||||||
|
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 } }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const ph = computePlan(p).phases[0];
|
||||||
|
const summe = ph.elements.filter((e) => e.category === "AHV").reduce((s, e) => s + e.startValue, 0);
|
||||||
|
expect(summe).toBe(Math.round(32760 * 1.5)); // plafoniert, nicht 2 x 32'760
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("V5 Golden Tests", () => {
|
describe("V5 Golden Tests", () => {
|
||||||
it("Test 1 – Ansparen (Einkommen +2% nominal, Ausgaben real flach -> nominal +2%)", () => {
|
it("Test 1 – Ansparen (Einkommen +2% nominal, Ausgaben real flach -> nominal +2%)", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
|
|||||||
+161
-6
@@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
AHV_CONTRIBUTION_START_AGE,
|
||||||
AHV_COUPLE_CAP_FACTOR,
|
AHV_COUPLE_CAP_FACTOR,
|
||||||
AHV_FULL_CONTRIBUTION_YEARS,
|
AHV_FULL_CONTRIBUTION_YEARS,
|
||||||
AHV_MAX_ANNUAL_SINGLE,
|
AHV_MAX_ANNUAL_SINGLE,
|
||||||
|
AHV_MIN_MONTHLY_FULL,
|
||||||
|
AHV_PENSION_MONTHS,
|
||||||
DEFAULT_CAPITAL_TAX_RATE,
|
DEFAULT_CAPITAL_TAX_RATE,
|
||||||
DEFAULT_PK_CONVERSION_RATE,
|
DEFAULT_PK_CONVERSION_RATE,
|
||||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||||
@@ -95,6 +98,60 @@ export interface PlanComputed {
|
|||||||
yearly: YearPoint[];
|
yearly: YearPoint[];
|
||||||
nachlass: number;
|
nachlass: number;
|
||||||
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
|
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
|
||||||
|
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (fuer die AHV-Pruefung am Uebergang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AHV-Rentenformel (Skala 44) ---------------------------------------------------------
|
||||||
|
// Amtliche Rentenformel des BSV. Alle Schwellen sind Vielfache von R0 (Mindestrente):
|
||||||
|
// mdJE <= 12 x R0 -> Mindestrente R0
|
||||||
|
// 12 x R0 < mdJE <= 36 x R0 -> Formel 1: R0 x (0.74 + 0.26 x mdJE/(12 R0))
|
||||||
|
// 36 x R0 < mdJE < 72 x R0 -> Formel 2: R0 x (1.04 + 0.16 x mdJE/(12 R0))
|
||||||
|
// mdJE >= 72 x R0 -> Maximalrente 2 x R0
|
||||||
|
// Verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen exakt, siehe Tests).
|
||||||
|
// Bewusst STETIG gerechnet: die amtliche Tabelle stuft das mdJE auf Vielfache von 1.2 x R0;
|
||||||
|
// fuer eine Planung ist der stetige Wert naeher an der Wahrheit (Abweichung < 20/Monat).
|
||||||
|
export function ahvMonthlyFullPension(mdJE: number): number {
|
||||||
|
const r0 = AHV_MIN_MONTHLY_FULL;
|
||||||
|
if (mdJE <= 12 * r0) return r0;
|
||||||
|
if (mdJE >= 72 * r0) return 2 * r0;
|
||||||
|
const x = mdJE / (12 * r0);
|
||||||
|
return mdJE <= 36 * r0 ? r0 * (0.74 + 0.26 * x) : r0 * (1.04 + 0.16 * x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beitragskarriere einer Person fuer die AHV -- akkumuliert ueber die Erwerbsphasen des Plans.
|
||||||
|
export interface AhvCareer {
|
||||||
|
personId: string;
|
||||||
|
role: PersonRole;
|
||||||
|
plannedAvgIncome: number; // reales Durchschnittseinkommen der Beitragsjahre IM Plan
|
||||||
|
planYears: number; // Beitragsjahre im Plan (Dauer abzueglich Ausfalljahre)
|
||||||
|
yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn
|
||||||
|
gapYearsInPlan: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Massgebendes durchschnittliches Jahreseinkommen: gewichteter Mittelwert der realen
|
||||||
|
// Einkommen ueber ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
|
||||||
|
// AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in
|
||||||
|
// realer Betrachtung weitgehend auf.
|
||||||
|
export function ahvMdje(career: AhvCareer, avgIncomeBefore: number, gapYearsBefore: number): number {
|
||||||
|
const yearsBefore = Math.max(0, career.yearsBeforePlan - Math.max(0, gapYearsBefore));
|
||||||
|
const totalYears = yearsBefore + career.planYears;
|
||||||
|
if (totalYears <= 0) return 0;
|
||||||
|
return (avgIncomeBefore * yearsBefore + career.plannedAvgIncome * career.planYears) / totalYears;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jaehrliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekuerzt um die Ausfalljahre
|
||||||
|
// (Rentenskala: pro fehlendes Beitragsjahr 1/44).
|
||||||
|
export function ahvAnnualPension(mdJE: number, totalGapYears: number): number {
|
||||||
|
const factor = Math.max(
|
||||||
|
0,
|
||||||
|
(AHV_FULL_CONTRIBUTION_YEARS - Math.max(0, totalGapYears)) / AHV_FULL_CONTRIBUTION_YEARS
|
||||||
|
);
|
||||||
|
return Math.round(ahvMonthlyFullPension(mdJE) * AHV_PENSION_MONTHS * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jahre zwischen dem AHV-Beitragsbeginn (21) und dem Planbeginn.
|
||||||
|
export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
|
||||||
|
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt).
|
// Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt).
|
||||||
@@ -130,7 +187,8 @@ function fmt(v: number): string {
|
|||||||
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function personByRole(persons: { id: string; role: PersonRole }[], role: string) {
|
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behaelt.
|
||||||
|
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
|
||||||
return persons.find((p) => p.role === role) ?? null;
|
return persons.find((p) => p.role === role) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +201,12 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
|
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
|
||||||
|
|
||||||
const gapYearsByPerson = new Map<string, number>();
|
const gapYearsByPerson = new Map<string, number>();
|
||||||
|
// 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 Pruefung am Pensions-Uebergang bzw. (fuer bereits
|
||||||
|
// Pensionierte) aus der Phasenzelle der ersten Phase.
|
||||||
|
const ahvBeforeByPerson = new Map<string, { avg: number; gap: number }>();
|
||||||
const carries = new Map<string, Carry>();
|
const carries = new Map<string, Carry>();
|
||||||
for (const e of plan.elements) carries.set(e.id, emptyCarry());
|
for (const e of plan.elements) carries.set(e.id, emptyCarry());
|
||||||
|
|
||||||
@@ -191,22 +255,42 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
|
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
|
||||||
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
|
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
|
||||||
|
|
||||||
// Ausfalljahre kumulieren + AHV-Renten (mit Plafonierung).
|
// Ausfalljahre kumulieren (nur waehrend der Erwerbstaetigkeit).
|
||||||
|
const gapThisPhase = new Map<string, number>();
|
||||||
for (const e of plan.elements) {
|
for (const e of plan.elements) {
|
||||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||||
const owner = personByRole(persons, e.ownerRole);
|
const owner = personByRole(persons, e.ownerRole);
|
||||||
if (!owner || !workingByPerson.get(owner.id)) continue;
|
if (!owner || !workingByPerson.get(owner.id)) continue;
|
||||||
const gy = Math.max(0, Math.round(num(e.phaseValues[phase.id]?.gapYears)));
|
const gy = Math.max(0, Math.round(num(e.phaseValues[phase.id]?.gapYears)));
|
||||||
|
gapThisPhase.set(owner.id, (gapThisPhase.get(owner.id) ?? 0) + gy);
|
||||||
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
|
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Uebergang, an dem die
|
||||||
|
// Beitragskarriere geprueft werden koennte -- 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 Uebergang 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 der Pensionierten: Vollrente zum mdJE, gekuerzt um die Ausfalljahre.
|
||||||
const ahvUncapped = new Map<string, number>();
|
const ahvUncapped = new Map<string, number>();
|
||||||
for (const e of plan.elements) {
|
for (const e of plan.elements) {
|
||||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||||
const owner = personByRole(persons, e.ownerRole);
|
const owner = personByRole(persons, e.ownerRole);
|
||||||
if (!owner || workingByPerson.get(owner.id)) continue;
|
if (!owner || workingByPerson.get(owner.id)) continue;
|
||||||
const gap = gapYearsByPerson.get(owner.id) ?? 0;
|
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 };
|
||||||
const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS);
|
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
||||||
ahvUncapped.set(owner.id, Math.round(AHV_MAX_ANNUAL_SINGLE * factor));
|
const mdJE = ahvMdje(career, before.avg, before.gap);
|
||||||
|
const totalGap = (gapYearsByPerson.get(owner.id) ?? 0) + before.gap;
|
||||||
|
ahvUncapped.set(owner.id, ahvAnnualPension(mdJE, totalGap));
|
||||||
}
|
}
|
||||||
const ahvFinal = new Map(ahvUncapped);
|
const ahvFinal = new Map(ahvUncapped);
|
||||||
if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) {
|
if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) {
|
||||||
@@ -218,6 +302,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
// --- Element-Laufzeitzustaende aufbauen ---
|
// --- Element-Laufzeitzustaende aufbauen ---
|
||||||
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
|
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
|
||||||
const ecById = new Map<string, ElementPhaseComputed>();
|
const ecById = new Map<string, ElementPhaseComputed>();
|
||||||
|
// Reales Durchschnittseinkommen dieser Phase je Person (fuer die AHV-Karriere).
|
||||||
|
const phaseRealIncomeByPerson = new Map<string, number>();
|
||||||
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||||
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
||||||
@@ -280,6 +366,21 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
? Math.round(pd.amount)
|
? Math.round(pd.amount)
|
||||||
: ec.baseValue;
|
: ec.baseValue;
|
||||||
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
|
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
|
||||||
|
|
||||||
|
// AHV: reales Erwerbseinkommen der Person mitfuehren. Nur Einkommen, die einer
|
||||||
|
// Person zugeordnet sind -- bei einem Einzelplan zaehlt "Gemeinsam" zur Person A.
|
||||||
|
if (e.category === "INCOME" && ownerWorking) {
|
||||||
|
const attributed =
|
||||||
|
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
|
||||||
|
if (attributed) {
|
||||||
|
const avgReal = avgRealFlow(basis, idx, infl, duration, cumInflStart);
|
||||||
|
phaseRealIncomeByPerson.set(
|
||||||
|
attributed.id,
|
||||||
|
(phaseRealIncomeByPerson.get(attributed.id) ?? 0) + avgReal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Basiswert der Folgephase fortschreiben (nominal fuer Einkommen, real fuer Ausgaben).
|
// Basiswert der Folgephase fortschreiben (nominal fuer Einkommen, real fuer Ausgaben).
|
||||||
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
|
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
|
||||||
break;
|
break;
|
||||||
@@ -376,6 +477,17 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AHV-Karriere fortschreiben: Beitragsjahre EINMAL je Person und Phase (nicht je
|
||||||
|
// Einkommens-Element), gewichtet mit dem realen Durchschnittseinkommen der Phase.
|
||||||
|
for (const p of persons) {
|
||||||
|
if (!workingByPerson.get(p.id)) continue;
|
||||||
|
const contribYears = Math.max(0, duration - (gapThisPhase.get(p.id) ?? 0));
|
||||||
|
if (contribYears <= 0) continue;
|
||||||
|
const avgReal = phaseRealIncomeByPerson.get(p.id) ?? 0;
|
||||||
|
ahvIncomeAccum.set(p.id, (ahvIncomeAccum.get(p.id) ?? 0) + avgReal * contribYears);
|
||||||
|
ahvYearsAccum.set(p.id, (ahvYearsAccum.get(p.id) ?? 0) + contribYears);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
|
// --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
|
||||||
// Investitionen dieser Phase werden am Phasenanfang abgezogen -> der Cash-Startwert
|
// Investitionen dieser Phase werden am Phasenanfang abgezogen -> der Cash-Startwert
|
||||||
// zeigt den Bestand NACH den Investitionen (kein Doppelzaehlen mit dem Vermoegen).
|
// zeigt den Bestand NACH den Investitionen (kein Doppelzaehlen mit dem Vermoegen).
|
||||||
@@ -571,6 +683,22 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AHV: beim Pensions-Uebergang die geprueften Karriere-Werte vor Planbeginn uebernehmen.
|
||||||
|
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 Schaetzung fuer die Jahre
|
||||||
|
// vor Planbeginn -- exakt der Wert, den der Pruef-Dialog vorbelegt. Ein Fallback auf
|
||||||
|
// 0 wuerde die Rente still und massiv zu tief rechnen.
|
||||||
|
avg: num(td.avgIncomeBefore, career.plannedAvgIncome),
|
||||||
|
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
carry.hasCarry = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (carry.status !== "ACTIVE") {
|
if (carry.status !== "ACTIVE") {
|
||||||
carry.hasCarry = true;
|
carry.hasCarry = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -666,7 +794,34 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
|
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
|
||||||
return { phases: result, yearly, nachlass, ruinAge };
|
const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson));
|
||||||
|
return { phases: result, yearly, nachlass, ruinAge, ahvCareer };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit
|
||||||
|
// idx, real wird er mit der Inflation deflationiert -> geometrische Reihe mit q.
|
||||||
|
function avgRealFlow(basis: number, idx: number, infl: number, duration: number, cumInflStart: number): number {
|
||||||
|
const q = (1 + idx / 100) / (1 + infl / 100);
|
||||||
|
const sum = Math.abs(q - 1) < 1e-12 ? duration : (1 - Math.pow(q, duration)) / (1 - q);
|
||||||
|
return (basis / (cumInflStart || 1)) * (sum / duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCareer(
|
||||||
|
owner: { id: string; role: PersonRole; age: number },
|
||||||
|
incomeAccum: Map<string, number>,
|
||||||
|
yearsAccum: Map<string, number>,
|
||||||
|
gapAccum: Map<string, number>
|
||||||
|
): AhvCareer {
|
||||||
|
const planYears = yearsAccum.get(owner.id) ?? 0;
|
||||||
|
const incomeSum = incomeAccum.get(owner.id) ?? 0;
|
||||||
|
return {
|
||||||
|
personId: owner.id,
|
||||||
|
role: owner.role,
|
||||||
|
plannedAvgIncome: planYears > 0 ? incomeSum / planYears : 0,
|
||||||
|
planYears,
|
||||||
|
yearsBeforePlan: ahvYearsBeforePlan(owner.age),
|
||||||
|
gapYearsInPlan: gapAccum.get(owner.id) ?? 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function retiresInPhase(
|
function retiresInPhase(
|
||||||
|
|||||||
+16
-3
@@ -1,9 +1,22 @@
|
|||||||
// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch
|
// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch
|
||||||
// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt.
|
// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt.
|
||||||
|
|
||||||
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente (2'520/Monat × 13 = 32'760).
|
// R0 = Mindestbetrag der vollen monatlichen Altersrente (Skala 44). Alle Schwellen der
|
||||||
// Quelle: BSV / AHV-IV 2026.
|
// Rentenformel sind Vielfache von R0 -- aendert sich R0, verschiebt sich die ganze Skala.
|
||||||
export const AHV_MAX_ANNUAL_SINGLE = 32760;
|
// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gueltig ab 1.1.2026;
|
||||||
|
// amtliche Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44".
|
||||||
|
export const AHV_MIN_MONTHLY_FULL = 1260;
|
||||||
|
|
||||||
|
// Anzahl Rentenzahlungen pro Jahr. Ab 1.1.2026 gibt es die 13. Altersrente (Art. 34bis AHVG),
|
||||||
|
// deshalb 13 statt 12.
|
||||||
|
export const AHV_PENSION_MONTHS = 13;
|
||||||
|
|
||||||
|
// Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag -- faktisch ab Alter 21.
|
||||||
|
export const AHV_CONTRIBUTION_START_AGE = 21;
|
||||||
|
|
||||||
|
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente: 2 x R0 x 13 = 32'760.
|
||||||
|
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
|
||||||
|
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
|
||||||
|
|
||||||
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
|
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
|
||||||
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
|
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export interface PhaseData {
|
|||||||
teuerungsausgleich?: number;
|
teuerungsausgleich?: number;
|
||||||
// AHV
|
// AHV
|
||||||
gapYears?: number;
|
gapYears?: number;
|
||||||
|
// AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen
|
||||||
|
// Pensions-Uebergang, an dem die Karriere geprueft werden koennte): Beitragskarriere.
|
||||||
|
// avgIncomeBefore ist REAL (heutige Kaufkraft).
|
||||||
|
avgIncomeBefore?: number;
|
||||||
|
gapYearsBefore?: number;
|
||||||
// PENSION_FUND / PILLAR_3A / OTHER_ASSET
|
// PENSION_FUND / PILLAR_3A / OTHER_ASSET
|
||||||
currentValue?: number;
|
currentValue?: number;
|
||||||
startValue?: number;
|
startValue?: number;
|
||||||
@@ -80,6 +85,12 @@ export type TransitionDecision = "HOLD" | "SELL";
|
|||||||
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
|
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
|
||||||
|
|
||||||
export interface TransitionData {
|
export interface TransitionData {
|
||||||
|
// AHV (Pensions-Uebergang): Pruefung der Beitragskarriere. Das geplante Durchschnitts-
|
||||||
|
// einkommen kommt aus dem Plan; reicht der Plan nicht bis zum Beitragsbeginn (Alter 21)
|
||||||
|
// zurueck, ergaenzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL.
|
||||||
|
reviewed?: boolean;
|
||||||
|
avgIncomeBefore?: number;
|
||||||
|
gapYearsBefore?: number;
|
||||||
// PENSION_FUND / PILLAR_3A (normaler Uebergang): expliziter Bezugs-Entscheid.
|
// PENSION_FUND / PILLAR_3A (normaler Uebergang): expliziter Bezugs-Entscheid.
|
||||||
withdrawalMode?: "NONE" | "AMOUNT";
|
withdrawalMode?: "NONE" | "AMOUNT";
|
||||||
withdrawal?: number;
|
withdrawal?: number;
|
||||||
@@ -135,6 +146,8 @@ export const phaseDataSchema = z
|
|||||||
amount: nonNeg.optional(),
|
amount: nonNeg.optional(),
|
||||||
teuerungsausgleich: z.number().min(-20).max(50).optional(),
|
teuerungsausgleich: z.number().min(-20).max(50).optional(),
|
||||||
gapYears: z.number().int().min(0).optional(),
|
gapYears: z.number().int().min(0).optional(),
|
||||||
|
avgIncomeBefore: nonNeg.optional(),
|
||||||
|
gapYearsBefore: z.number().int().min(0).max(50).optional(),
|
||||||
currentValue: nonNeg.optional(),
|
currentValue: nonNeg.optional(),
|
||||||
startValue: nonNeg.optional(),
|
startValue: nonNeg.optional(),
|
||||||
expectedReturn: z.number().min(-50).max(100).optional(),
|
expectedReturn: z.number().min(-50).max(100).optional(),
|
||||||
@@ -150,6 +163,9 @@ export const phaseDataSchema = z
|
|||||||
|
|
||||||
export const transitionDataSchema = z
|
export const transitionDataSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
reviewed: z.boolean().optional(),
|
||||||
|
avgIncomeBefore: nonNeg.optional(),
|
||||||
|
gapYearsBefore: z.number().int().min(0).max(50).optional(),
|
||||||
withdrawalMode: z.enum(["NONE", "AMOUNT"]).optional(),
|
withdrawalMode: z.enum(["NONE", "AMOUNT"]).optional(),
|
||||||
withdrawal: nonNeg.optional(),
|
withdrawal: nonNeg.optional(),
|
||||||
payoutMode: z.enum(["CAPITAL", "PENSION", "COMBI"]).optional(),
|
payoutMode: z.enum(["CAPITAL", "PENSION", "COMBI"]).optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user