diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 77a22ef..d050e54 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.3 | -| **Datum** | 2026-07-16 | +| **Version** | 0.4 | +| **Datum** | 2026-07-17 | | **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 | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | 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//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.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` (`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.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 -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) -rente = round(32'760 × factor) +yearsBefore = max(0, yearsBeforePlan − gapYearsBefore) +mdJE = (avgIncomeBefore × yearsBefore + plannedAvgIncome × planYears) + / (yearsBefore + planYears) ``` -- `AHV_MAX_ANNUAL_SINGLE = 32'760` – maximale einfache Altersrente pro Jahr inkl. 13. Rente - (2'520/Monat × 13), Stand 2026, Quelle BSV. -- `AHV_FULL_CONTRIBUTION_YEARS = 44` – volle Beitragsdauer (Rentenskala 44). +Ausfalljahre reduzieren die **Gewichtung** (und die Skala), nicht das Durchschnittseinkommen – +genau wie in der echten AHV: Wer zwei Jahre aussetzt, hat deswegen kein tieferes +Durchschnittseinkommen, aber weniger Beitragsjahre. -**3. Ehepaar-Plafonierung** – nur bei `householdType = COUPLE` **und** wenn für **beide** -Personen eine Rente vorliegt: +### 4.4.3 Warum real gerechnet wird + +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 @@ -811,9 +940,11 @@ falls (renteA + renteB) > cap: ``` 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 @@ -1158,7 +1289,10 @@ Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bunde | 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_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) | | `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 | | `teuerungsausgleich` | INCOME, EXPENSE | −20 bis 50 | | `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 | | `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 | | `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 | |---|---|---| +| `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` | | `withdrawal` | PK, 3a (normal) | ≥ 0, **brutto** | | `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 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`). Es gibt **keine** Komponenten-, API- oder E2E-Tests. @@ -1669,6 +1809,15 @@ Es gibt **keine** Komponenten-, API- oder E2E-Tests. | 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 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 | @@ -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 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 `≥ 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. - 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`) | | **Deflator** | Kumulierte Inflation seit Planbeginn | | **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 | | **Umwandlungssatz** | Prozentsatz zur Verrentung des PK-Kapitals | | **Ruin(alter)** | Alter von Person A, in dem das Gesamtvermögen erstmals unter 0 fällt | diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index de8ec42..876735a 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; +import { AlertTriangle } from "lucide-react"; import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; import { formatChf } from "@/lib/format"; import { api } from "@/lib/api-client"; +import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations"; import { CATEGORY_LABELS, num } from "@/lib/elements"; import { DEFAULT_CAPITAL_TAX_RATE, @@ -31,6 +33,10 @@ export interface CellContext { carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige) 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 { @@ -42,6 +48,24 @@ interface Props { 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 ( +
+ + + Achtung: Diese Lebensphase ist nicht die letzte. 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. + +
+ ); +} + // Read-only Anzeige eines abgeleiteten (fortgeschriebenen) Wertes. function DerivedField({ label, value, help }: { label: string; value: number; help?: string }) { return ( @@ -111,9 +135,86 @@ function WithdrawalDecision({ // Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default // (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) => 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 ( + <> +

+ Die AHV-Rente haengt vom massgebenden durchschnittlichen Jahreseinkommen{" "} + 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. +

+ + + + + {brauchtVorgeschichte && ( + <> +

+ Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre + davor kennt das Tool nicht – bitte ergaenzen. +

+ setT({ avgIncomeBefore: v })} + /> + setT({ gapYearsBefore: Math.max(0, Math.min(career.yearsBeforePlan, Math.round(v))) })} + /> + + )} + + + + + ); +} + export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData { 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"; } else if (category === "PENSION_FUND") { if (isRetirement) { @@ -271,6 +372,9 @@ export function CashTransitionFields({ // "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen). export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean { switch (category) { + case "AHV": + // Nur bei der Pensionierung ist eine Pruefung der Beitragskarriere noetig. + return isRetirement ? td.reviewed === true : true; case "REAL_ESTATE": case "OTHER_ASSET": return td.decision !== undefined; @@ -342,10 +446,42 @@ export function ElementPhaseFields({ } case "AHV": 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 ( + <> +

+ 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). +

+ setP({ avgIncomeBefore: v })} + /> + setP({ gapYearsBefore: Math.max(0, Math.round(v)) })} + /> + + + ); + } return (

- Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der - Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente. + Die AHV-Rente wird aus der beim Pensions-Uebergang geprueften Beitragskarriere berechnet + (massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die + Plafonierung auf 150% der Maximalrente.

); } @@ -517,13 +653,21 @@ export function ElementTransitionFields({ switch (element.category) { case "INCOME": case "EXPENSE": - case "AHV": return (

Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste Lebensphase uebernommen und koennen dort angepasst werden.

); + case "AHV": + if (context.isRetirementTransition && context.ahvCareer) { + return ; + } + return ( +

+ Die Beitragskarriere wird erst beim Uebergang in die Pensionierung geprueft. +

+ ); case "PENSION_FUND": if (context.isRetirementTransition) { const mode = td.payoutMode ?? "PENSION"; @@ -689,6 +833,8 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
+ {/* Punkt 4: Warnung, dass Aenderungen sich in die Folgephasen fortschreiben. */} + {!isTransition && } {isTransition ? ( ) : ( diff --git a/src/components/PhaseDetail.tsx b/src/components/PhaseDetail.tsx index dbdffa4..60bedf4 100644 --- a/src/components/PhaseDetail.tsx +++ b/src/components/PhaseDetail.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { NumberField, TextField } from "@/components/FormField"; +import { CarryWarning } from "@/components/ElementDetail"; import { api } from "@/lib/api-client"; import type { PhaseInput } from "@/lib/types"; @@ -10,12 +11,14 @@ export function PhaseDetail({ phase, maxDurationYears, isLast, + laterPhaseCount, onSaved, onDeleted, }: { phase: PhaseInput; maxDurationYears: number | null; isLast: boolean; + laterPhaseCount: number; onSaved: () => void; onDeleted: () => void; }) { @@ -67,6 +70,8 @@ export function PhaseDetail({
+ {/* Eine geaenderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermoegen). */} + = { OTHER_DEBT: , }; +// 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[] = [ + "AHV", "PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", @@ -161,6 +164,18 @@ export function PlanView({ 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. function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext { const ce = computedElement(phase.id, element.id); @@ -179,6 +194,8 @@ export function PlanView({ carried: ce?.carried ?? false, derivedStart: ce?.baseValue ?? 0, deflatorStart: phase.cumulativeInflationStart, + laterPhaseCount: laterPhaseCount(phase), + ahvCareer: careerFor(element), }; } @@ -195,14 +212,20 @@ export function PlanView({ carried: ce?.carried ?? false, derivedStart: 0, deflatorStart: fromPhase.cumulativeInflationStart, + laterPhaseCount: laterPhaseCount(fromPhase), + ahvCareer: careerFor(element), }; } // 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). - function transitionInactive(el: ElementInput, fromPhase: PhaseComputed): boolean { + // (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet) + // ODER AHV ausserhalb des Pensions-Uebergangs. + function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean { const ce = computedElement(fromPhase.id, el.id); 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.ownerRole && el.ownerRole !== "HOUSEHOLD") { 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; for (const el of plan.elements) { 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 retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false; if (!isTransitionAnswered(el.category, retire, td)) n++; @@ -232,18 +255,15 @@ export function PlanView({ // Ist der Uebergangs-Entscheid dieses Elements noch offen? 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); return !isTransitionAnswered(el.category, retire, el.transitionValues[fromPhase.id] ?? {}); } - function transitionElements(fromPhase: PhaseComputed): ElementInput[] { + function transitionElements(fromPhase: PhaseComputed, toPhase?: PhaseComputed): ElementInput[] { return plan.elements .filter((el) => TRANSITION_CATEGORIES.includes(el.category)) - .filter((el) => { - const ce = computedElement(fromPhase.id, el.id); - return !ce || ce.status === "ACTIVE"; - }) + .filter((el) => !transitionInactive(el, fromPhase, toPhase)) .sort((a, b) => a.orderIndex - b.orderIndex); } @@ -461,7 +481,7 @@ export function PlanView({ ); } - 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 open = canTransition && transitionUnanswered(el, col.fromPhase, col.toPhase); return ( @@ -571,7 +591,7 @@ export function PlanView({ if (!fromPhase) return null; const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; const toPhase = computed.phases[toIndex]; - const els = transitionElements(fromPhase); + const els = transitionElements(fromPhase, toPhase); return ( { setSelected(null); @@ -679,6 +700,8 @@ export function PlanView({ function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string { const td = el.transitionValues[fromPhase.id] ?? {}; switch (el.category) { + case "AHV": + return td.reviewed === true ? "Geprueft" : "?"; case "REAL_ESTATE": case "OTHER_ASSET": return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?"; @@ -913,6 +936,8 @@ function AddElementDialog({ carried: false, derivedStart: 0, deflatorStart: firstPhase.cumulativeInflationStart, + laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis + ahvCareer: null, }; async function create() { @@ -1175,13 +1200,15 @@ function TransitionReviewDialog({ const ctx = buildContext(el); const retire = isRetirement(el); const hint = - (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire - ? "Hier könnten Sie optional Kapital beziehen." - : el.category === "PENSION_FUND" && retire - ? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)." - : el.category === "PILLAR_3A" && retire - ? "Wird bei Pensionierung vollständig bezogen." - : null; + 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." + : el.category === "PENSION_FUND" && retire + ? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)." + : el.category === "PILLAR_3A" && retire + ? "Wird bei Pensionierung vollständig bezogen." + : null; return (
diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts index 6e8a7b9..383c066 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -1,5 +1,5 @@ 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 { 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; +// 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 + // 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", () => { it("Test 1 – Ansparen (Einkommen +2% nominal, Ausgaben real flach -> nominal +2%)", () => { const p = plan({ diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index dfb2181..2f69c4d 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -1,7 +1,10 @@ import { + AHV_CONTRIBUTION_START_AGE, AHV_COUPLE_CAP_FACTOR, AHV_FULL_CONTRIBUTION_YEARS, AHV_MAX_ANNUAL_SINGLE, + AHV_MIN_MONTHLY_FULL, + AHV_PENSION_MONTHS, DEFAULT_CAPITAL_TAX_RATE, DEFAULT_PK_CONVERSION_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE, @@ -95,6 +98,60 @@ export interface PlanComputed { yearly: YearPoint[]; nachlass: number; 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). @@ -130,7 +187,8 @@ function fmt(v: number): string { 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(persons: T[], role: string): T | 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); const gapYearsByPerson = new Map(); + // AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre. + const ahvIncomeAccum = new Map(); + const ahvYearsAccum = new Map(); + // Karriere VOR Planbeginn -- aus der Pruefung am Pensions-Uebergang bzw. (fuer bereits + // Pensionierte) aus der Phasenzelle der ersten Phase. + const ahvBeforeByPerson = new Map(); const carries = new Map(); 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 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(); 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; 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); } + + // 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(); 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; - const gap = gapYearsByPerson.get(owner.id) ?? 0; - const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS); - ahvUncapped.set(owner.id, Math.round(AHV_MAX_ANNUAL_SINGLE * factor)); + const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 }; + const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson); + const mdJE = ahvMdje(career, before.avg, before.gap); + const totalGap = (gapYearsByPerson.get(owner.id) ?? 0) + before.gap; + ahvUncapped.set(owner.id, ahvAnnualPension(mdJE, totalGap)); } const ahvFinal = new Map(ahvUncapped); if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) { @@ -218,6 +302,8 @@ export function computePlan(plan: PlanInput): PlanComputed { // --- Element-Laufzeitzustaende aufbauen --- const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex); const ecById = new Map(); + // Reales Durchschnittseinkommen dieser Phase je Person (fuer die AHV-Karriere). + const phaseRealIncomeByPerson = new Map(); const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; let renteTotal = 0; // AHV + PK-Renten (nominal fix) @@ -280,6 +366,21 @@ export function computePlan(plan: PlanInput): PlanComputed { ? Math.round(pd.amount) : ec.baseValue; (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). carry.flowBasis = basis * Math.pow(1 + idx / 100, duration); 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 --- // Investitionen dieser Phase werden am Phasenanfang abgezogen -> der Cash-Startwert // zeigt den Bestand NACH den Investitionen (kein Doppelzaehlen mit dem Vermoegen). @@ -571,6 +683,22 @@ export function computePlan(plan: PlanInput): PlanComputed { 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") { carry.hasCarry = true; continue; @@ -666,7 +794,34 @@ export function computePlan(plan: PlanInput): PlanComputed { } 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, + yearsAccum: Map, + gapAccum: Map +): 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( diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a1c0726..1cccdcd 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,9 +1,22 @@ // Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch // 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). -// Quelle: BSV / AHV-IV 2026. -export const AHV_MAX_ANNUAL_SINGLE = 32760; +// R0 = Mindestbetrag der vollen monatlichen Altersrente (Skala 44). Alle Schwellen der +// Rentenformel sind Vielfache von R0 -- aendert sich R0, verschiebt sich die ganze Skala. +// 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- // Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt. diff --git a/src/lib/elements.ts b/src/lib/elements.ts index e23874b..aca1494 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -58,6 +58,11 @@ export interface PhaseData { teuerungsausgleich?: number; // AHV 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 currentValue?: number; startValue?: number; @@ -80,6 +85,12 @@ export type TransitionDecision = "HOLD" | "SELL"; export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI"; 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. withdrawalMode?: "NONE" | "AMOUNT"; withdrawal?: number; @@ -135,6 +146,8 @@ export const phaseDataSchema = z amount: nonNeg.optional(), teuerungsausgleich: z.number().min(-20).max(50).optional(), gapYears: z.number().int().min(0).optional(), + avgIncomeBefore: nonNeg.optional(), + gapYearsBefore: z.number().int().min(0).max(50).optional(), currentValue: nonNeg.optional(), startValue: nonNeg.optional(), expectedReturn: z.number().min(-50).max(100).optional(), @@ -150,6 +163,9 @@ export const phaseDataSchema = z export const transitionDataSchema = z .object({ + reviewed: z.boolean().optional(), + avgIncomeBefore: nonNeg.optional(), + gapYearsBefore: z.number().int().min(0).max(50).optional(), withdrawalMode: z.enum(["NONE", "AMOUNT"]).optional(), withdrawal: nonNeg.optional(), payoutMode: z.enum(["CAPITAL", "PENSION", "COMBI"]).optional(),