AHV einkommensabhaengig + Warnhinweis bei Aenderungen in frueheren Phasen
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:
2026-07-17 09:02:42 +02:00
parent 9f5bd754eb
commit e901d23970
8 changed files with 771 additions and 53 deletions
+212 -21
View File
@@ -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` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.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 v1v5 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 v1v5 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 180202, `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 46, `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 050 |
| `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 46, `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 050 |
| `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 |
+150 -4
View File
@@ -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} />
) : ( ) : (
+5
View File
@@ -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}` : ""}`}
+39 -12
View File
@@ -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,7 +1200,9 @@ 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
? "Pensionierung: Beitragskarriere prüfen die Rente hängt vom Durchschnittseinkommen ab."
: (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
? "Hier könnten Sie optional Kapital beziehen." ? "Hier könnten Sie optional Kapital beziehen."
: el.category === "PENSION_FUND" && retire : el.category === "PENSION_FUND" && retire
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)." ? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
+166 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+16
View File
@@ -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(),