diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 7a3bd5b..fef5388 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.10 | +| **Version** | 0.11 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `d203e50` inkl. Szenario-Vergleich in der Monte-Carlo-Simulation und Sensitivitätsanalyse (Branch `main`) | +| **Codestand** | Arbeitsstand nach `1836cad` inkl. Detailansichten, Wasserfall-Zerlegungen und vollständiger Rechenweg-Offenlegung (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.11 | 2026-07-18 | Claude (Opus 4.8) | **Detailansichten (Roadmap Nr. 43)** und **vollständige Offenlegung der Berechnungslogiken (Roadmap Nr. 41)**. (1) Neue **Systemparameter-Ansicht** in der Seitenleiste: alle fest hinterlegten Grössen mit Wert, Bedeutung, Herleitung, Quelle und Stand – als strukturierte Daten aus `constants.ts`, also aus derselben Quelle, aus der gerechnet wird. (2) **Nur-Lese-Detailansicht** je Element und je Lebensphase über ein Expand-Icon: Element mit Verlaufsgrafik über **alle Planjahre** (dafür führt `computePlan` neu `ElementPhaseComputed.yearly` je Element mit), Phase mit Vermögensaufteilung und **zwei Wasserfällen**. (3) Die **Wasserfälle** sind bewusst getrennt: Der Vermögens-Wasserfall zeigt nur echte Zu- und Abgänge (Quote, Kapitalerträge, Wertsteigerung, PK-Beiträge, Steuern, Verrentung, Einmalposten); Sparraten, Amortisationen und Investitionen sind **Umbuchungen** und erscheinen ausschliesslich im Cash-Wasserfall – als Vermögensabgang gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. Neue Strukturen `WealthBridge` / `CashBridge` inkl. Restposten als Kontrollgrösse. (4) **Rechenweg-Protokoll**: `computePlan(plan, sample?, { explain })` protokolliert die Schritte, die es ohnehin ausführt – Formel, eingesetzte Zahlen, Ergebnis und Hinweis auf geltende Vereinfachungen. Abdeckung über **alle** Ebenen (Element je Phase, Element je Übergang, Phasen-Kennzahlen, Plan-Ebene). Standardmässig aus, damit die Monte-Carlo-Simulation unberührt bleibt. Jeder Rechenweg verlinkt in das passende Kapitel dieser Spezifikation; ein Test prüft, dass alle Verweise eine existierende Überschrift treffen. Neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21; 12 Tests ergänzt (80 → 92). Keine DB-Änderung; die 43 Golden Tests laufen unverändert. | | 0.10 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Vergleich in der Monte-Carlo-Simulation** und **Sensitivitätsanalyse / Tornado** (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu **mehrere Szenarien desselben Plans in einem Lauf**. Die historischen Annahmen werden dabei nur **einmal je logischem Element** erfasst – die Zuordnung über die Herkunfts-Kette `sourceElementId`, dieselbe Grundlage wie beim Diff (neue Funktionen `resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`). Alle Szenarien laufen mit **demselben Seed** (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der **Zielbetrag bleibt szenario-eigen** (vorbelegt mit dem jeweils geplanten Endvermögen) – nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein *eigenes* Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich **Einflussfaktoren** (eigener Button, eigener Dialog) mit einem **Tornado-Chart** nach dem One-at-a-time-Verfahren: neues reines Modul `sensitivity.ts` mit sieben Treibern, je Treiber an-/abwählbar und mit **pflichtiger, frei definierbarer Bandbreite ohne Default**. Das **Pensionsalter ist bewusst nicht enthalten** (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. **Ausserdem vier Dokumentationsfehler korrigiert:** Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. | | 0.9 | 2026-07-18 | Claude (Opus 4.8) | **UI-Umbau und Planstart.** (1) Die Grafiken liegen neu im eigenen Bereich **Grafiken** (Dialog, Button oben) statt unter der Matrix. (2) Kennzahl **Geschätzter Nachlass** entfernt – sie war identisch mit dem nominalen Endvermögen. (3) **Monte-Carlo-Button** nach oben zu den Szenario-Aktionen verschoben. (4) Neues Profilfeld **Planstart (Jahr)** (`Scenario.startYear`, Migration; reine Anzeige, Berechnung bleibt in relativen Jahren) – erscheint auf der Zeitachse. (5) Zeitachse zeigt neu die **Lebensphasen als Segmente** (Breite = Dauer, Einfärbung nach Phasentyp, Jahresspanne). (6) **Lebensphase bearbeiten** neu als Popup statt Panel unter der Tabelle. (7) **Vermögensverlauf** über **alle Jahre** statt nur über die Phasengrenzen – dafür führt `computePlan` das Vermögen neu pro Jahr mit (`YearPoint.wealthNominal/wealthReal`). Zwei Tests ergänzt (58 → 60). | | 0.8 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Hierarchie (V6)** – grösste Umstrukturierung bisher. Der **Plan** ist neu ein schlanker Behälter ohne Finanzdaten; die berechenbare Einheit ist das **Szenario**, das Grundprofil (inkl. **Pensionsalter** → Frühpensionierungs-Szenarien), Phasen und Elemente trägt. Jeder Plan erhält beim Anlegen automatisch ein **Basisszenario**; weitere Szenarien entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als **Baum** darunter (Sidebar zeigt die Verschachtelung). Kopierte Phasen/Elemente tragen Herkunfts-Verweise (`sourcePhaseId`, `sourceElementId`) – darauf beruht die **Abweichungs-Markierung**: geändert = gelb, neu = grün, entfernt = graue Geisterzeile (eigene Theme-Tokens für alle drei Farbschemata). Charts vergleichen neu die Geschwister-Szenarien. Datenmodell: neue Tabelle `Plan`, bisheriger `Plan` → `Scenario` (IDs erhalten), `planId` → `scenarioId` in Person/Phase/FinancialElement. API neu unter `/api/scenarios/*`. Neue Kapitel 2.1, 3.2, 4.13; 9 Diff-Tests + Migrations-Test (48 → 58). **Migration mit echtem Postgres (PGlite) verifiziert**, inkl. verschachtelter Szenarien und Cascade. | @@ -858,6 +859,47 @@ Tornado Pflichteingaben braucht. Ein Hinweis am Ende der Parameterliste benennt, Referenz: `src/components/SensitivityDialog.tsx`, `src/components/AppShell.tsx`. +### 3.6.7 Detailansichten je Element und je Lebensphase + +Jede Element-Zeile und jeder Phasenkopf trägt oben rechts ein **Expand-Icon** (erscheint beim +Überfahren). Es öffnet eine **Nur-Lese-Detailansicht** mit Reitern. + +**Element** – zwei Reiter: + +| Reiter | Inhalt | +|---|---| +| Verlauf | Liniendiagramm über **alle Planjahre**, nicht nur die Phasengrenzen. Bei einer Immobilie drei Reihen (Eigenkapital, Verkehrswert, Restschuld). Darunter eine Tabelle Beginn/Ende je Lebensphase. | +| Rechenweg | Die Herleitung je Phase und je Übergang (siehe [4.14](#414-verlaufswerte-brücken-und-rechenwege)) | + +**Lebensphase** – drei Reiter: + +| Reiter | Inhalt | +|---|---| +| Übersicht | Typ, Dauer, Vermögen Beginn/Ende; Vermögensaufteilung als gestapelter Balken (Beginn und Ende) | +| Wasserfall | Vermögens- und Cash-Wasserfall ([4.14.2](#4142-die-beiden-wasserfälle)) | +| Rechenweg | Quote, Spar-/Verzehrrate, Cash-Fortschreibung, Vermögen, Phasentyp, Cash-Übergang | + +Der Phasenkopf reagiert bereits auf Klick (öffnet das Bearbeiten-Popup); das Expand-Icon stoppt +deshalb die Ereignis-Weitergabe, sonst gingen beide Dialoge gleichzeitig auf. + +Zusätzlich öffnet der Button **Rechenwege** in der Aktionsleiste die **plan-weiten** Grössen: +Deflatoren, AHV-Beitragskarriere je Person und Ruinalter. + +Referenz: `src/components/DetailView.tsx`, `src/components/PlanView.tsx`. + +### 3.6.8 Systemparameter-Ansicht + +Eigener Eintrag in der Seitenleiste (über der SPEZIFIKATION). Zeigt alle fest hinterlegten +Systemgrössen, gruppiert nach AHV, Vorsorge und Steuern, je mit **Wert, Bedeutung, Herleitung, +Quelle und Stand**. + +Die Einträge stammen aus `SYSTEM_PARAMETERS` in `src/lib/constants.ts` – **derselben Datei, aus +der die Berechnung liest**. Ein Auseinanderlaufen von angezeigtem und gerechnetem Wert ist damit +konstruktiv ausgeschlossen. Abgeleitete Grössen wie `AHV_MAX_ANNUAL_SINGLE` zeigen ihre +Herleitung (`2 × R0 × 13`) statt nur das Ergebnis. + +Referenz: `src/components/SystemParametersView.tsx`, `src/lib/constants.ts`. + ## 3.7 Bedienoberfläche ### 3.7.1 Layout @@ -1764,6 +1806,127 @@ spannt deshalb über `min…max`; welche Eingabe zu welchem Ende gehört, zeigt Referenz: `src/lib/sensitivity.ts`, `src/components/SensitivityDialog.tsx`. +## 4.14 Verlaufswerte, Brücken und Rechenwege + +Dieses Kapitel beschreibt, was `computePlan` über die reinen Ergebniswerte hinaus mitführt – +die Grundlage der Detailansichten (Roadmap Nr. 43) und der Transparenz-Offenlegung (Nr. 41). + +### 4.14.1 Verlaufswerte je Element + +`ElementPhaseComputed.yearly` hält einen Punkt **pro Phasenjahr** je Element (Jahr, Alter, Wert; +bei Immobilien zusätzlich Verkehrswert und Restschuld). + +Der Grund ist derselbe wie beim Vermögensverlauf in Version 0.9: Nur Start- und Endwert zu kennen +reicht nicht. Eine Anlage mit 5 % über 20 Jahre wächst **konvex** – eine Gerade zwischen den +Phasengrenzen wäre sichtbar falsch und würde den Zinseszins optisch unterschlagen. Der Punkt wird +am **Jahresende** erfasst, nach Verzinsung und Tilgung, konsistent zum `YearPoint`. + +### 4.14.2 Die beiden Wasserfälle + +Ein Wasserfall zerlegt eine Veränderung in ihre Summanden. Der entscheidende Punkt ist, **welche +Posten dazugehören** – und hier liegt eine Falle: + +> **Sparraten, Amortisationen und Zusatzinvestitionen sind Umbuchungen, keine Verluste.** Eine +> 3a-Einzahlung verlässt das Cash und erhöht im selben Zug das 3a-Guthaben; das Vermögen bleibt +> unverändert. Eine Amortisation senkt das Cash und die Hypothek – das Eigenkapital steigt sogar. +> Als Abgang im Vermögens-Wasserfall gezeichnet würden diese Posten eine Vermögensminderung +> vortäuschen, die es nicht gibt. + +Deshalb gibt es **zwei** Zerlegungen mit unterschiedlichen Fragestellungen: + +**`WealthBridge` – „Warum hat sich mein Vermögen so entwickelt?"** + +``` +Vermögen Ende Vorphase + + einmaliger Zufluss − einmalige Kosten ⎫ + − Steuern am Übergang (Kapitalbezug, Grundstückgewinn) ⎬ Vermögensänderungen + − verrentetes PK-Kapital (verlässt die Bilanz) ⎪ AN der Phasengrenze + ± Verkaufspreis minus Verkehrswert ⎭ += Vermögen zu Phasenbeginn + + Spar-/Verzehrquote (Summe über alle Phasenjahre) ⎫ + + Kapitalerträge (PK, 3a, Sonstiges Vermögen) ⎬ INNERHALB der Phase + + Wertsteigerung der Liegenschaft ⎪ + + PK-Beiträge ⎭ += Vermögen am Phasenende +``` + +Zwei Posten verdienen eine Erläuterung: +- **PK-Beiträge sind ein echter Zugang.** Sie belasten das Cash nicht (im Nettolohn bereits + abgezogen), erhöhen aber das Vorsorgekapital – anders als 3a-Beiträge, die aus dem Cash fliessen + und deshalb reine Umbuchung sind. +- **Verrentetes PK-Kapital verlässt die Bilanz.** Bei der Verrentung wird Kapital in einen + Rentenstrom umgewandelt; der Saldo fällt auf 0. Ohne diesen Posten ginge die Brücke am + Pensions-Übergang nicht auf. + +**`CashBridge` – „Wohin ist mein Cash geflossen?"** + +``` +Cash Ende Vorphase (Phase 1: Cash-Anfangswert) + + Kapitalzufluss + einmaliger Zufluss + − Sofort-Tilgung/Sonderamortisation − einmalige Kosten + − Investitionen am Phasenanfang += Cash zu Phasenbeginn + + Spar-/Verzehrquote − Sparraten − Amortisationen/Tilgungen + Bezugsraten += Cash am Phasenende +``` + +Beide Strukturen führen einen **Restposten** (`residual`) mit: die Differenz zwischen dem +gerechneten Endwert und der Summe der Summanden. Er entsteht nur durch die Rundung der einzelnen +Posten auf ganze Franken und liegt im einstelligen Bereich; ein grösserer Wert wäre ein Hinweis +auf eine unvollständige Zerlegung. Zwei Tests prüfen ihn über einen Plan, der alle Element-Arten +und Übergangs-Entscheide enthält. + +### 4.14.3 Rechenweg-Protokoll + +`computePlan(plan, sample?, { explain: true })` legt zu jedem Ergebnis die Herleitung ab: + +```ts +interface TraceStep { label; formula?; substituted?; result; unit?; note? } +interface Trace { title; specAnchor?; steps: TraceStep[] } +``` + +`formula` ist die abstrakte Regel, `substituted` dieselbe Regel **mit den eingesetzten Zahlen**, +`note` benennt eine an dieser Stelle geltende Vereinfachung. + +**Der Architekturentscheid dahinter ist der wichtigste Teil dieses Kapitels.** Die naheliegende +Alternative wäre, die Formeln im UI nachzurechnen und anzuzeigen. Das ergäbe eine **zweite +Implementierung jeder Formel** – und damit die Möglichkeit, dass die angezeigte Herleitung still +von der tatsächlichen Rechnung abdriftet. Bei einem Tool, dessen Kernversprechen die rechnerische +Korrektheit ist, wäre das die gefährlichste Variante überhaupt: Ein Nutzer, der nachrechnet und +eine Abweichung findet, verliert mehr Vertrauen, als eine Black Box je gekostet hätte. + +Deshalb entstehen die Schritte **innerhalb** von `computePlan`, als Nebenprodukt der Rechnung, die +ohnehin läuft. Die Erklärung *ist* die Rechnung. + +Zwei Konsequenzen daraus: +- **Standardmässig aus.** Die Monte-Carlo-Simulation ruft `computePlan` zehntausendfach auf und + darf von der Protokollierung nichts merken. Ein Test prüft, dass `explain` die Ergebniswerte + nicht verschiebt. +- **Die Arithmetik wird nicht umgestellt.** Wo für das Protokoll eine Zwischengrösse gebraucht + wird (etwa der Renditeanteil eines Jahres), wird sie als **Differenz** abgeleitet statt die + Formel umzuformen – `a × (1 + r)` und `a + a × r` sind in Gleitkomma-Arithmetik nicht bitgleich. + Die 43 Golden Tests laufen unverändert. + +**Abdeckung.** Vollständig über alle Ebenen: Element je Phase (Einkommen, Ausgaben, PK, 3a, +Immobilie, Sonstiges Vermögen, Schulden, AHV-/PK-Renten), Element je Übergang (Bezugsarten, +Verkauf inkl. Grundstückgewinnsteuer, Teilverkauf, Sonderamortisation, Sofort-Tilgung), +Phasen-Kennzahlen (Quote, Spar-/Verzehrrate, Cash-Fortschreibung, Vermögen, Phasentyp, +Cash-Übergang) und Plan-Ebene (Deflatoren, AHV-Karriere je Person, Ruinalter). + +**Verweis in die Spezifikation.** Jeder Trace trägt optional einen `specAnchor` auf das zugehörige +Kapitel dieses Dokuments; die App springt von dort in die eingebaute SPEZIFIKATION-Ansicht (die +Anker erzeugt `rehype-slug`). Ein Test liest `SPEZIFIKATION.md` und prüft, dass **jeder** Verweis +eine existierende Überschrift trifft – sonst würden die Links bei einer Umbenennung still ins +Leere zeigen. + +**Wo die Erklärung gerechnet wird.** Nicht auf dem Server: `computePlan` ist rein und läuft im +Browser (wie schon bei Monte Carlo und Sensitivitätsanalyse), und der Client hält den +`PlanInput` ohnehin. Die Detailansicht rechnet die erklärte Fassung beim Öffnen lokal – kein +API-Umbau, keine grössere Antwort, keine Serverlast, und per Konstruktion identisch zum +Serverergebnis. + +Referenz: `src/lib/calculations.ts`, `src/components/DetailView.tsx`. + --- # 5. Technische Spezifikation @@ -1804,7 +1967,7 @@ FPT/ │ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell) │ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata │ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes) -│ ├── components/ 15 React-Komponenten (alle "use client") +│ ├── components/ 17 React-Komponenten (alle "use client") │ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren) │ ├── lib/ Domänenlogik (siehe 5.3) │ └── middleware.ts Zugriffsschutz (Edge-Runtime) @@ -1837,6 +2000,7 @@ PlanComputed ← an den Client geliefert | `constants.ts` | Schweizer Systemparameter | | `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. | | `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`. Rein, läuft im Browser. | +| `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand – Grundlage der Systemparameter-Ansicht | | `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) | | `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen | | `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) | @@ -2092,7 +2256,9 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server. | `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern | | `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer | | `SensitivityDialog` | ~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle | -| `SpecView` | 50 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument | +| `DetailView` | ~470 | Detailansichten Element/Phase, Wasserfall-Darstellung, Rechenweg-Blöcke, plan-weite Rechenwege | +| `SystemParametersView` | ~90 | Systemparameter mit Wert, Bedeutung, Herleitung, Quelle und Stand | +| `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen | | `InfoBubble` | 28 | Hilfe-Tooltip | ### 5.5.3 Wiederverwendungsmuster @@ -2322,10 +2488,11 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests |---|---|---| | `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" | | `sensitivity.test.ts` | 14 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung | +| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise | | `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | -| **Total** | **80** | | +| **Total** | **92** | | ## 8.2 Testfälle @@ -2361,6 +2528,18 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | **Tornado: Lebensdauer** | verschiebt nur die letzte Phase; Kappung bei mindestens 1 Jahr | | **Tornado: Verfügbarkeit** | Treiber ohne passende Elemente werden ausgeblendet (z. B. Immobilien-Wertsteigerung ohne Immobilie) | | **Tornado: Sortierung/Richtung** | absteigend nach Spannweite; Treiber ohne Bandbreite hat Spannweite 0 und steht zuunterst; höhere Ausgaben → tieferes, höhere Rendite → höheres Endvermögen | +| **Verlauf: ein Punkt je Jahr** | je aktivem Element genau `durationYears` Punkte pro Phase | +| **Verlauf: Konvexität** | 200'000 @ 5 % + 10'000 Sparbeitrag: Jahr 1 = 220'000, und die **Jahreszuwächse wachsen** – eine Gerade zwischen den Phasengrenzen hätte konstante Zuwächse | +| **Verlauf: Immobilie** | Verkehrswert, Restschuld und Eigenkapital werden getrennt geführt; Eigenkapital = Verkehrswert − Restschuld | +| **Brücke: Vermögen geht auf** | Restposten ≤ 5 CHF über einen Plan mit allen Element-Arten und Übergangs-Entscheiden | +| **Brücke: Cash geht auf** | dito; Start- und Endwert stimmen mit `cashStart`/`cashEnd` überein | +| **Brücke: Umbuchung** | 5 × 12'000 Sparbeitrag: Cash −60'000, Vermögen **unverändert** – die Sparrate erscheint nur im Cash-Wasserfall | +| **Brücke: PK-Beiträge** | 4 × 10'000 PK-Beitrag: Vermögen +40'000, Cash unbelastet | +| **Brücke: Übergangsposten** | Verrentung, Kapitalbezugssteuer und Einmalposten erscheinen in der Brücke der **Folge**phase | +| **explain: Standardmässig aus** | ohne Option keine Traces; mit Option identische Ergebniswerte (Endvermögen, Cash, Ruinalter) | +| **explain: Abdeckung** | Traces auf Element-, Übergangs-, Phasen- und Plan-Ebene vorhanden | +| **explain: eingesetzte Zahlen** | jeder Schritt trägt die Formel **und** die substituierte Fassung; das Resultat stimmt mit dem Ergebniswert überein | +| **explain: Spec-Verweise** | jeder `specAnchor` trifft eine existierende Überschrift in `SPEZIFIKATION.md` (gegen die echte Datei geprüft) | | **Vermögen je Jahr** | 100k @ 10 % über 3 J. → 110k/121k/133.1k je Jahrespunkt; Endjahr = Phasen-Endvermögen | | **Vermögen real** | 100k bei 10 % Inflation → real 90'909 | | Test 1 – Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash | @@ -2623,6 +2802,39 @@ Migration), hat aber den Preis, dass eine Analyse bei jedem Öffnen neu parametr Bei der Monte-Carlo-Simulation über mehrere Szenarien fällt das stärker ins Gewicht als zuvor, weil dort mehr Eingaben zusammenkommen. +--- +## 9.20 Transparenz legt auch die Vereinfachungen offen + +Die vollständige Offenlegung der Rechenwege macht sichtbar, was das Modell **nicht** kann: die +nach der Pensionierung nicht indexierte AHV-Rente ([9.11](#911-ahv-rente-wird-nach-der-pensionierung-nicht-indexiert)), +den pauschalen Netto-Brutto-Faktor 1.12 ([4.4.5](#445-netto-brutto-umrechnung-für-die-ahv)), die +nicht indexierten Spar- und Bezugsraten ([9.6](#96-spar--und-bezugsraten-werden-nicht-indexiert)) +und die fehlende Steuerberechnung ([9.14](#914-keine-steuerschätzung)). + +Das ist der eigentliche Wert der Offenlegung und kein Nebeneffekt. Entscheidend ist aber, dass die +Erklärtexte die Grenze **mitnennen**, statt sie zu übergehen – dafür ist das Feld `note` je +Rechenschritt da. Ein fachkundiger Nutzer, der eine Vereinfachung selbst entdeckt, nachdem ihm +volle Transparenz zugesagt wurde, zieht den härteren Schluss. + +## 9.21 Wasserfall: die Zuordnung ist eine Interpretation + +Welcher Posten in welchen Wasserfall gehört, ist eine **fachliche Entscheidung** und nicht aus den +Zahlen ableitbar. Die hier getroffene – Umbuchungen nur im Cash-Wasserfall, echte Zu- und Abgänge +nur im Vermögens-Wasserfall ([4.14.2](#4142-die-beiden-wasserfälle)) – ist begründet, aber nicht +die einzig denkbare. Wer die Sparrate als „gebundenes Geld" verstanden wissen will, würde sie +anders einordnen. + +Konkret uneindeutig sind zwei Fälle: +- **Amortisation** senkt Cash und Hypothek. Im Vermögens-Wasserfall taucht sie nicht auf, obwohl + sie das *Eigenkapital* erhöht – die Erhöhung ist bereits im unveränderten Vermögenssaldo + enthalten. +- **Verrentung** erscheint als Vermögensabgang, obwohl der Gegenwert als Rentenstrom weiterlebt. + Der Rentenstrom ist im Modell aber kein Bilanzposten, sondern Einkommen; er taucht in den + Folgejahren über die Quote wieder auf. + +Der Restposten (`residual`) ist die Kontrollgrösse dafür, dass die gewählte Zerlegung wenigstens +**vollständig** ist – nicht dafür, dass sie die einzig sinnvolle ist. + --- # 10. Glossar @@ -2633,6 +2845,10 @@ weil dort mehr Eingaben zusammenkommen. | **Szenario** | Die berechenbare Einheit (seit V6): trägt Grundprofil, Phasenkette und Elemente. Jeder Plan hat genau ein Basisszenario; weitere entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als Baum darunter | | **Logisches Element** | Dasselbe finanzielle Element über Szenariogrenzen hinweg, erkannt über die Herkunfts-Kette `sourceElementId` – Grundlage der einmaligen Parametereingabe im Szenario-Vergleich ([4.12.6](#4126-mehrere-szenarien-im-vergleich)) | | **Spannweite (Tornado)** | Differenz zwischen dem Ergebnis beim tiefen und beim hohen Wert eines Treibers; bestimmt Balkenlänge und Rangfolge | +| **Umbuchung** | Bewegung, die Geld zwischen Cash und einem Vermögenswert verschiebt, ohne das Vermögen zu verändern (Sparrate, Amortisation, Zusatzinvestition). Erscheint nur im Cash-Wasserfall | +| **Vermögens-/Cash-Brücke** | Zerlegung der Vermögens- bzw. Cash-Veränderung einer Phase in ihre Summanden (`WealthBridge` / `CashBridge`) | +| **Restposten** | Differenz zwischen gerechnetem Endwert und der Summe der Brücken-Summanden; reine Rundung, Kontrollgrösse für die Vollständigkeit | +| **Rechenweg (Trace)** | Protokoll der Rechenschritte mit Formel, eingesetzten Zahlen, Ergebnis und geltender Vereinfachung; entsteht innerhalb von `computePlan` | | **Grundprofil** | Haushaltsform, Personen (Alter, Pensionsalter, Name), Inflationsannahme | | **Lebensphase** | Zeitabschnitt mit fester Dauer; darf keine Pensionierung überspannen | | **Phasentyp** | `ERWERB` / `PENSION` / `MIXED`; abgeleitet, nie gespeichert | @@ -2661,4 +2877,4 @@ weil dort mehr Eingaben zusammenkommen. --- -*Ende der Spezifikation v0.10* +*Ende der Spezifikation v0.11* diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 5052494..6ad93ac 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { BarChart3, + BookOpen, Copy, Dices, FileText, @@ -12,6 +13,7 @@ import { Menu, PiggyBank, Plus, + SlidersHorizontal, Tornado, Trash2, X, @@ -21,6 +23,9 @@ import { Dashboard } from "@/components/Dashboard"; import { MonteCarloDialog } from "@/components/MonteCarloDialog"; import { SensitivityDialog } from "@/components/SensitivityDialog"; import { SpecView } from "@/components/SpecView"; +import { SystemParametersView } from "@/components/SystemParametersView"; +import { PlanTraceDialog } from "@/components/DetailView"; +import { computePlan } from "@/lib/calculations"; import { ProfileMenu } from "@/components/ProfileMenu"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; import { api } from "@/lib/api-client"; @@ -47,6 +52,17 @@ export function AppShell({ username }: { username: string }) { const [showCharts, setShowCharts] = useState(false); const [showMonteCarlo, setShowMonteCarlo] = useState(false); const [showSensitivity, setShowSensitivity] = useState(false); + const [showSystemParams, setShowSystemParams] = useState(false); + const [showPlanTraces, setShowPlanTraces] = useState(false); + // Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus. + const [specAnchor, setSpecAnchor] = useState(null); + + function openSpecAt(anchor: string) { + setSpecAnchor(anchor); + setShowSpec(true); + setShowSystemParams(false); + setSelectedScenarioId(null); + } const loadPlans = useCallback(async () => { const data = await api.get<{ plans: PlanListItem[] }>("/api/plans"); @@ -86,6 +102,7 @@ export function AppShell({ username }: { username: string }) { function openScenario(id: string) { setSelectedScenarioId(id); setShowSpec(false); + setShowSystemParams(false); setSidebarOpen(false); } @@ -127,10 +144,11 @@ export function AppShell({ username }: { username: string }) { onClick={() => { setSelectedScenarioId(null); setShowSpec(false); + setShowSystemParams(false); setSidebarOpen(false); }} className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${ - selectedScenarioId === null && !showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2" + selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2" }`} > @@ -176,11 +194,28 @@ export function AppShell({ username }: { username: string }) { ))} -
+
+

- {showSpec + {showSystemParams + ? "Systemparameter" + : showSpec ? "Spezifikation" : detail ? `${detail.meta.planName} · ${detail.meta.name}` @@ -240,11 +277,13 @@ export function AppShell({ username }: { username: string }) {
- {showSpec && } + {showSystemParams && } - {!showSpec && loading &&

Laedt…

} + {showSpec && } - {!showSpec && !loading && selectedScenarioId === null && ( + {!showSpec && !showSystemParams && loading &&

Laedt…

} + + {!showSpec && !showSystemParams && !loading && selectedScenarioId === null && ( )} - {!showSpec && !loading && detail && selectedScenarioId && ( + {!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
+ )} {diff && detail.base && ( @@ -313,7 +361,13 @@ export function AppShell({ username }: { username: string }) { )}
- +
)}
@@ -358,6 +412,14 @@ export function AppShell({ username }: { username: string }) { setShowSensitivity(false)} /> )} + {showPlanTraces && detail && ( + setShowPlanTraces(false)} + onOpenSpec={openSpecAt} + /> + )} + {copyFrom && ( { + if (it.total) { + running = it.value; + return { label: it.label, base: 0, delta: Math.abs(it.value), value: it.value, kind: "total" as const }; + } + const start = running; + running += it.value; + return { + label: it.label, + base: Math.min(start, running), + delta: Math.abs(it.value), + value: it.value, + kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg", + }; + }); +} + +const WF_COLOR = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" }; + +function Waterfall({ items, height = 300 }: { items: WaterfallItem[]; height?: number }) { + const data = useMemo(() => waterfallData(items), [items]); + if (data.length === 0) return null; + return ( +
+ + + + + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} + /> + [formatChf(p?.payload?.value ?? 0), p?.payload?.label ?? ""]} + labelFormatter={() => ""} + /> + + + {data.map((d, i) => ( + + ))} + + + +
+ ); +} + +function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] { + const items: WaterfallItem[] = []; + if (!isFirst) { + items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true }); + if (w.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: w.oneOffInflow }); + if (w.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -w.oneOffOutflow }); + if (w.transitionTax) items.push({ label: "Steuern am Übergang", value: -w.transitionTax }); + if (w.pensionConversion) items.push({ label: "PK verrentet", value: -w.pensionConversion }); + if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss }); + } + items.push({ label: "Vermögen Phasenbeginn", value: w.startWealth, total: true }); + if (w.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: w.quotaTotal }); + if (w.investmentReturn) items.push({ label: "Kapitalerträge", value: w.investmentReturn }); + if (w.propertyAppreciation) items.push({ label: "Wertsteigerung Immobilie", value: w.propertyAppreciation }); + if (w.pensionFundContribution) items.push({ label: "PK-Beiträge", value: w.pensionFundContribution }); + items.push({ label: "Vermögen Phasenende", value: w.endWealth, total: true }); + return items; +} + +function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] { + const items: WaterfallItem[] = []; + items.push({ label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase", value: c.openingCash, total: true }); + if (c.capitalInflow) items.push({ label: "Kapitalzufluss", value: c.capitalInflow }); + if (c.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: c.oneOffInflow }); + if (c.immediateRepay) items.push({ label: "Sofort-Tilgung", value: -c.immediateRepay }); + if (c.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -c.oneOffOutflow }); + if (c.investments) items.push({ label: "Investitionen", value: -c.investments }); + items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true }); + if (c.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: c.quotaTotal }); + if (c.savingRates) items.push({ label: "Sparraten", value: -c.savingRates }); + if (c.debtRates) items.push({ label: "Amort./Tilgung", value: -c.debtRates }); + if (c.withdrawals) items.push({ label: "Bezugsraten", value: c.withdrawals }); + items.push({ label: "Cash Phasenende", value: c.cashEnd, total: true }); + return items; +} + +// --- Rechenweg ---------------------------------------------------------------------------- + +function TraceStepRow({ step }: { step: TraceStep }) { + const unit = step.unit ?? "CHF"; + const value = + unit === "" ? step.substituted ?? "" : unit === "%" ? `${step.result} %` : unit === "Jahre" ? `${step.result}` : formatChf(step.result); + return ( + + {step.label} + + {step.formula &&
{step.formula}
} + {step.substituted && unit !== "" &&
{step.substituted}
} + {step.note &&
{step.note}
} + + {value} + + ); +} + +export function TraceBlock({ trace, onOpenSpec }: { trace: Trace; onOpenSpec?: (anchor: string) => void }) { + return ( +
+
+ {trace.title} + {trace.specAnchor && onOpenSpec && ( + + )} +
+ + + {trace.steps.map((s, i) => ( + + ))} + +
+
+ ); +} + +// --- Gemeinsame Dialog-Huelle mit Reitern -------------------------------------------------- + +function DetailShell({ + title, + subtitle, + tabs, + onClose, +}: { + title: string; + subtitle?: string; + tabs: { key: string; label: string; content: React.ReactNode }[]; + onClose: () => void; +}) { + const [active, setActive] = useState(tabs[0]?.key); + const current = tabs.find((t) => t.key === active) ?? tabs[0]; + return ( +
+
e.stopPropagation()} + className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" + > +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ +
+ +
+ {tabs.map((t) => ( + + ))} +
+ +
{current?.content}
+ +

Nur-Lese-Ansicht. Werte werden hier nicht verändert.

+
+
+ ); +} + +// --- Element-Detailansicht ---------------------------------------------------------------- + +const MULTI_SERIES: ElementCategory[] = ["REAL_ESTATE"]; + +export function ElementDetailDialog({ + elementId, + name, + category, + computed, + onClose, + onOpenSpec, +}: { + elementId: string; + name: string; + category: ElementCategory; + computed: PlanComputed; + onClose: () => void; + onOpenSpec?: (anchor: string) => void; +}) { + // Verlauf ueber ALLE Phasen zusammensetzen. + const points = useMemo(() => { + const out: ElementYearPoint[] = []; + for (const ph of computed.phases) { + const ec = ph.elements.find((e) => e.elementId === elementId); + if (ec) out.push(...ec.yearly); + } + return out; + }, [computed, elementId]); + + const perPhase = useMemo( + () => + computed.phases + .map((ph) => ({ phase: ph, ec: ph.elements.find((e) => e.elementId === elementId) })) + .filter((x) => x.ec), + [computed, elementId] + ); + + const isFlow = category === "INCOME" || category === "EXPENSE"; + const valueLabel = isFlow + ? category === "INCOME" + ? "Einkommen (nominal)" + : "Ausgaben (nominal)" + : category === "OTHER_DEBT" + ? "Beitrag zum Vermögen" + : category === "REAL_ESTATE" + ? "Eigenkapital" + : "Wert"; + + const verlauf = ( + <> + {points.length === 0 ? ( +

Für dieses Element gibt es in diesem Plan keinen Verlauf.

+ ) : ( +
+ + + + `${v} J.`} /> + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> + [typeof v === "number" ? formatChf(v) : v, n]} labelFormatter={(v) => `Alter ${v}`} /> + + + {MULTI_SERIES.includes(category) && ( + <> + + + + )} + + +
+ )} + +
+ + + + + + + + + + + {perPhase.map(({ phase, ec }) => ( + + + + + + + ))} + +
LebensphaseBeginnEndeHinweis
{phase.name}{formatChf(ec!.startValue)}{formatChf(ec!.endValue)}{ec!.note ?? ""}
+
+ + ); + + const rechenweg = ( + <> + {perPhase.every(({ ec }) => !ec!.trace && !ec!.transitionTrace) && ( +

Für dieses Element gibt es keinen eigenen Rechenweg.

+ )} + {perPhase.map(({ phase, ec }) => ( +
+ {(ec!.trace || ec!.transitionTrace) && ( +
{phase.name}
+ )} + {ec!.trace && } + {ec!.transitionTrace && } +
+ ))} + + ); + + return ( + + ); +} + +// --- Phasen-Detailansicht ------------------------------------------------------------------ + +const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"]; +const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"]; + +export function PhaseDetailDialog({ + phase, + isFirst, + onClose, + onOpenSpec, +}: { + phase: PhaseComputed; + isFirst: boolean; + onClose: () => void; + onOpenSpec?: (anchor: string) => void; +}) { + const alloc = useMemo(() => { + const rows = phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)); + return [ + { label: "Beginn", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.startValue)])) }, + { label: "Ende", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.endValue)])) }, + ]; + }, [phase]); + const allocEls = useMemo( + () => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)), + [phase] + ); + + const uebersicht = ( + <> +
+ + + + +
+ +
+

Vermögensaufteilung

+

Zusammensetzung des Anlagevermögens zu Beginn und am Ende dieser Phase.

+ {allocEls.length === 0 ? ( +

In dieser Phase gibt es kein Anlagevermögen.

+ ) : ( +
+ + + + + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> + (typeof v === "number" ? formatChf(v) : v)} /> + + {allocEls.map((el, i) => ( + + ))} + + +
+ )} +
+ + ); + + const wasserfall = ( + <> +
+

Woher kommt die Vermögensänderung?

+

+ Nur echte Zu- und Abgänge. Sparraten, Amortisationen und Zusatzinvestitionen erscheinen hier + bewusst nicht: Sie verschieben Geld vom Cash in einen Vermögenswert, ohne das Vermögen zu + verändern – als Balken gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. +

+ + {Math.abs(phase.wealthBridge.residual) > 2 && ( +

+ Rundungsdifferenz: {formatChf(phase.wealthBridge.residual)} +

+ )} +
+ +
+

Wohin ist das Cash geflossen?

+

+ Hier erscheinen die Umbuchungen: Sparraten, Amortisationen und Investitionen verlassen das Cash-Konto, auch + wenn sie das Vermögen nicht mindern. +

+ + {Math.abs(phase.cashBridge.residual) > 2 && ( +

Rundungsdifferenz: {formatChf(phase.cashBridge.residual)}

+ )} +
+ + ); + + const rechenweg = ( + <> + {(phase.traces ?? []).length === 0 &&

Kein Rechenweg verfügbar.

} + {(phase.traces ?? []).map((t, i) => ( + + ))} + + ); + + return ( + + ); +} + +function Kpi({ label, text }: { label: string; text: string }) { + return ( +
+
{label}
+
{text}
+
+ ); +} + +// --- Plan-weite Rechenwege (Deflatoren, AHV-Karriere, Ruinalter) --------------------------- + +export function PlanTraceDialog({ + computed, + onClose, + onOpenSpec, +}: { + computed: PlanComputed; + onClose: () => void; + onOpenSpec?: (anchor: string) => void; +}) { + return ( + + {(computed.traces ?? []).map((t, i) => ( + + ))} + + ), + }, + ]} + /> + ); +} diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index cf9daa0..10771af 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -10,6 +10,7 @@ import { CreditCard, Home, Landmark, + Maximize2, PiggyBank, Plus, Settings2, @@ -19,6 +20,7 @@ import { X, } from "lucide-react"; import { Timeline } from "@/components/Timeline"; +import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView"; import { CashTransitionFields, ElementDetail, @@ -46,7 +48,7 @@ import { type PhaseData, type TransitionData, } from "@/lib/elements"; -import type { PhaseComputed, PlanComputed } from "@/lib/calculations"; +import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations"; import type { ScenarioDiff } from "@/lib/diff"; import type { ElementInput, PlanInput } from "@/lib/types"; @@ -92,12 +94,14 @@ export function PlanView({ computed, diff, onChanged, + onOpenSpec, }: { plan: PlanInput; computed: PlanComputed; // Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren). diff: ScenarioDiff | null; onChanged: () => void; + onOpenSpec?: (anchor: string) => void; }) { // Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau. const cellDiff = (elementId: string, phaseId: string) => @@ -124,6 +128,16 @@ export function PlanView({ // fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird. const [editCashTransition, setEditCashTransition] = useState(null); const [valueMode, setValueMode] = useState("nominal"); + // Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Oeffnen erzeugt. + const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null); + + // Erklaerte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein + // und laeuft im Browser -- es braucht dafuer weder einen API-Aufruf noch eine groessere + // Server-Antwort, und das Ergebnis ist per Konstruktion identisch zum Serverergebnis. + const explained = useMemo( + () => (detailFor ? computePlan(plan, undefined, { explain: true }) : null), + [detailFor, plan] + ); useEffect(() => { const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null; @@ -405,6 +419,7 @@ export function PlanView({ mode={valueMode} diffKind={diff?.phaseHeader.get(col.phase.id) ?? null} onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })} + onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })} active={selected?.type === "phase" && selected.phaseId === col.phase.id} /> ) : ( @@ -499,12 +514,24 @@ export function PlanView({ {!collapsed && els.map((el) => ( - -
- {el.name} + +
+ {el.name} {diff?.elementRow.get(el.id) === "added" && ( neu )} +
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
{personLabel(el.ownerRole)}
@@ -706,6 +733,36 @@ export function PlanView({ ); })()} + {detailFor?.kind === "element" && explained && (() => { + const el = plan.elements.find((e) => e.id === detailFor.id); + if (!el) return null; + return ( + setDetailFor(null)} + onOpenSpec={onOpenSpec} + /> + ); + })()} + + {detailFor?.kind === "phase" && explained && (() => { + const ph = explained.phases.find((p) => p.id === detailFor.id); + if (!ph) return null; + return ( + setDetailFor(null)} + onOpenSpec={onOpenSpec} + /> + ); + })()} + {editPhaseCell && (() => { const element = plan.elements.find((e) => e.id === editPhaseCell.elementId); const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId); @@ -854,6 +911,7 @@ function PhaseHeader({ mode, diffKind, onClick, + onExpand, active, }: { phase: PhaseComputed; @@ -861,6 +919,7 @@ function PhaseHeader({ mode: ValueMode; diffKind: "changed" | "added" | "removed" | null; onClick: () => void; + onExpand: () => void; active: boolean; }) { const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote"; @@ -870,7 +929,7 @@ function PhaseHeader({ return (
- {phase.name} + {phase.name} {diffKind === "added" && ( neu )} @@ -890,6 +949,20 @@ function PhaseHeader({ ) : ( )} + {/* Der Kopf oeffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event + deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */} +
diff --git a/src/components/SpecView.tsx b/src/components/SpecView.tsx index 82fb793..15aafdd 100644 --- a/src/components/SpecView.tsx +++ b/src/components/SpecView.tsx @@ -9,7 +9,7 @@ import { api } from "@/lib/api-client"; // Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber // die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema. -export function SpecView() { +export function SpecView({ anchor }: { anchor?: string | null }) { const [markdown, setMarkdown] = useState(null); const [error, setError] = useState(null); @@ -20,6 +20,14 @@ export function SpecView() { .catch((e) => setError(e instanceof Error ? e.message : "Laden fehlgeschlagen.")); }, []); + // Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die + // Ueberschrift mit der von rehype-slug erzeugten id. + useEffect(() => { + if (!anchor || markdown === null) return; + const el = document.getElementById(anchor); + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + }, [anchor, markdown]); + if (error) return

{error}

; if (markdown === null) return

Laedt…

; diff --git a/src/components/SystemParametersView.tsx b/src/components/SystemParametersView.tsx new file mode 100644 index 0000000..2ee900f --- /dev/null +++ b/src/components/SystemParametersView.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { SlidersHorizontal } from "lucide-react"; +import { formatChf } from "@/lib/format"; +import { SYSTEM_PARAMETERS, type ParameterGroup, type SystemParameter } from "@/lib/constants"; + +const GROUPS: { key: ParameterGroup; title: string; intro: string }[] = [ + { + key: "AHV", + title: "AHV", + intro: + "Diese Werte legt der Bund fest. Sie ändern sich periodisch – der Stand ist je Parameter ausgewiesen.", + }, + { + key: "Vorsorge", + title: "Berufliche und gebundene Vorsorge", + intro: "Höchstbeträge und Umwandlung. Der Umwandlungssatz ist ein Vorschlag und pro Ereignis überschreibbar.", + }, + { + key: "Steuern", + title: "Steuersätze", + intro: + "Bewusst pauschale Vorschlagswerte, keine Steuerberechnung: Die tatsächlichen Sätze sind kantonal, progressiv und vom Einzelfall abhängig. Sie lassen sich pro Ereignis überschreiben.", + }, +]; + +function formatValue(p: SystemParameter): string { + switch (p.unit) { + case "CHF": + return `${formatChf(p.value)} CHF`; + case "%": + return `${p.value} %`; + case "Jahre": + return `${p.value} Jahre`; + default: + return String(p.value); + } +} + +// Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41). +// Die Eintraege stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch +// gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen. +export function SystemParametersView() { + return ( +
+
+ +

Systemparameter

+
+ +

+ Alle fest hinterlegten Grössen, mit denen das Tool rechnet – inklusive Herleitung und Quelle. Sie stammen aus + derselben Datei, aus der auch die Berechnung liest; was hier steht, ist also garantiert das, was gerechnet wird. +

+ + {GROUPS.map((g) => { + const rows = SYSTEM_PARAMETERS.filter((p) => p.group === g.key); + if (rows.length === 0) return null; + return ( +
+

{g.title}

+

{g.intro}

+
+ {rows.map((p) => ( +
+
+ {p.label} + {formatValue(p)} +
+

{p.meaning}

+ {p.derivation && ( +

Herleitung: {p.derivation}

+ )} +
+ Quelle: {p.source} + Stand: {p.validFrom} + {p.editablePerEvent && pro Ereignis überschreibbar} +
+
+ ))} +
+
+ ); + })} + +

+ Die drei Steuersätze werden sowohl als Vorschlag im Eingabefeld als auch in der Berechnung als Rückfallwert + verwendet – damit ein nicht angetippter Wert nicht fälschlich als 0 % gerechnet wird. +

+
+ ); +} diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index d28ef28..d53a70a 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -26,6 +26,44 @@ export interface PersonPhaseInfo { retiresAtStart: boolean; } +// --- Rechenweg-Protokoll (Roadmap Nr. 41) ------------------------------------------------ +// Ein Schritt haelt die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte +// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin laeuft. +// Damit kann die angezeigte Herleitung nicht von der tatsaechlichen Rechnung abdriften -- +// eine zweite Formel-Implementierung im UI waere die gefaehrlichste Variante ueberhaupt. +export interface TraceStep { + label: string; + formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhoehung)^(t-1)" + substituted?: string; // mit eingesetzten Zahlen, z. B. "100'000 x (1 + 2%)^4" + result: number; + unit?: "CHF" | "%" | "Jahre" | ""; + note?: string; // Vereinfachung/Grenze, die an dieser Stelle gilt +} + +export interface Trace { + title: string; + specAnchor?: string; // Sprungmarke in die SPEZIFIKATION (Kapitel 4) + steps: TraceStep[]; +} + +export interface ComputeOptions { + // Standardmaessig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf + // und darf von der Protokollierung nichts merken. + explain?: boolean; +} + +// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43). +// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % ueber 20 Jahre waechst +// konvex, eine Gerade von Start zu Ende waere sichtbar falsch (gleiche Ueberlegung wie beim +// Vermoegensverlauf in v0.9). +export interface ElementYearPoint { + year: number; // 1-basiert ab Planbeginn + age: number; // Alter Person A am Jahresende + value: number; // Haupt-Kennzahl (Saldo, Eigenkapital, Flow, Rente) + propertyValue?: number; // nur REAL_ESTATE: Verkehrswert der Liegenschaft + mortgage?: number; // nur REAL_ESTATE: Restschuld +} + export interface ElementPhaseComputed { elementId: string; category: ElementCategory; @@ -41,6 +79,51 @@ export interface ElementPhaseComputed { endValue: number; // Wert/Flow am Phasenende (letztes Jahr) summary: string; note: string | null; + yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase + trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain) + transitionTrace?: Trace; // Rechenweg des Uebergangs NACH dieser Phase (nur mit explain) +} + +// --- Wasserfall-Zerlegungen (Roadmap Nr. 43) --------------------------------------------- +// +// WICHTIG -- der Unterschied zwischen den beiden: Sparraten, Amortisationen und +// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhoehen im selben Zug +// einen Vermoegenswert bzw. senken eine Schuld; das Vermoegen bleibt unveraendert. Sie +// gehoeren deshalb in die Cash-Bruecke und ausdruecklich NICHT in die Vermoegens-Bruecke -- +// dort als Abgang gezeichnet wuerden sie eine Vermoegensminderung suggerieren, die es nicht +// gibt. +export interface WealthBridge { + openingWealth: number; // Endvermoegen der Vorphase (Phase 1: Startvermoegen) + // Uebergang IN diese Phase -- echte Vermoegensaenderungen an der Phasengrenze: + oneOffInflow: number; // einmaliger Zufluss (netto nach Steuer) + oneOffOutflow: number; // einmalige Kosten + transitionTax: number; // Kapitalbezugs- und Grundstueckgewinnsteuer (Abgang) + pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlaesst die Bilanz) + saleGainLoss: number; // Verkaufspreis minus Verkehrswert bei einem Immobilienverkauf + startWealth: number; // Kontrollpunkt = startWealthNominal + // Innerhalb der Phase: + quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) ueber alle Phasenjahre + investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermoegen + propertyAppreciation: number; // Wertsteigerung der Liegenschaft + pensionFundContribution: number; // PK-Beitraege: erhoehen das Vermoegen, ohne Cash zu kosten + endWealth: number; // = endWealthNominal + residual: number; // Rundungsdifferenz (Kontrollgroesse, sollte nahe 0 sein) +} + +export interface CashBridge { + openingCash: number; // Cash-Endbestand der Vorphase (Phase 1: initialCash) + capitalInflow: number; // Verkaeufe + PK-/3a-Bezuege aus dem Uebergang + oneOffInflow: number; + immediateRepay: number; // Sofort-Tilgungen und Sonderamortisation (Abgang) + oneOffOutflow: number; + investments: number; // Zusatz-/Neuinvestitionen am Phasenanfang (Abgang) + cashStart: number; // Kontrollpunkt + quotaTotal: number; + savingRates: number; // 3a + Sparbeitraege (Abgang) + debtRates: number; // Amortisationen + Tilgungen (Abgang) + withdrawals: number; // Bezugsraten aus Sonstigem Vermoegen (Zugang) + cashEnd: number; + residual: number; } export interface PhaseComputed { @@ -85,6 +168,9 @@ export interface PhaseComputed { cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte) flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`) endWealthReal: number; + wealthBridge: WealthBridge; + cashBridge: CashBridge; + traces?: Trace[]; // Rechenwege der Phasen-Kennzahlen (nur mit explain) } // Ein Datenpunkt pro Jahr (ueber alle Phasen), fuer die Verlaufsgrafik. @@ -106,6 +192,7 @@ export interface PlanComputed { 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) + traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain) } // --- AHV-Rentenformel (Skala 44) --------------------------------------------------------- @@ -223,7 +310,23 @@ export interface PlanSample { assetReturn: (elementId: string, year: number) => number; } -export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed { +// Kurzform fuer einen Rechenschritt. `pct` formatiert Prozentsaetze lesbar (2 statt 2.0000001). +function st( + label: string, + result: number, + formula?: string, + substituted?: string, + note?: string, + unit: TraceStep["unit"] = "CHF" +): TraceStep { + return { label, formula, substituted, result, unit, note }; +} +function pct(v: number): string { + return `${Math.round(v * 1000) / 1000} %`; +} + +export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed { + const explain = options?.explain === true; const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); const persons = plan.persons; const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0]; @@ -262,6 +365,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed let incomingOneOffInflowLabel: string | null = null; let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal) let incomingOneOffOutflowLabel: string | null = null; + // Echte Vermoegensaenderungen am Uebergang IN die Folgephase (fuer die Vermoegens-Bruecke). + let incomingTax = 0; // Kapitalbezugs- + Grundstueckgewinnsteuer + let incomingPensionConversion = 0; // in eine Rente umgewandeltes PK-Kapital + let incomingSaleGainLoss = 0; // Verkaufspreis minus Verkehrswert + let previousEndWealth = 0; // Endvermoegen der Vorphase (Startpunkt der Bruecke) + let previousCashEnd = Math.round(plan.initialCash || 0); let ruinAge: number | null = null; for (let i = 0; i < phases.length; i++) { @@ -345,7 +454,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; let renteTotal = 0; // AHV + PK-Renten (nominal fix) - const assets: { value: number; rate: number; r: number; withdrawal: number; ec: ElementPhaseComputed }[] = []; + // `isPk` fuer die Vermoegens-Bruecke: PK-Beitraege verlassen das Cash NICHT (sie sind im + // Nettolohn bereits abgezogen), erhoehen aber das Vermoegen -- sie sind also ein echter + // Zugang und keine Umbuchung. + const assets: { + value: number; + rate: number; + r: number; + withdrawal: number; + isPk: boolean; + ec: ElementPhaseComputed; + }[] = []; // mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am // Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der // Liegenschaft (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis. @@ -387,6 +506,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed endValue: 0, summary: "", note: null, + yearly: [], }; ecById.set(e.id, ec); @@ -470,7 +590,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed ec.baseValue = base; ec.startValue = start; wealthStart += start; - assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, ec }); + assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: true, ec }); } break; } @@ -488,7 +608,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed ec.baseValue = base; ec.startValue = start; wealthStart += start; - assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, ec }); + assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: false, ec }); } break; } @@ -504,7 +624,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed ec.baseValue = base; ec.startValue = start; wealthStart += start; - assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, ec }); + assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, isPk: false, ec }); break; } case "REAL_ESTATE": { @@ -570,6 +690,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed let quotaStart = 0; let quotaEnd = 0; let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr + // Summanden der beiden Wasserfall-Bruecken (ueber alle Phasenjahre kumuliert). + let quotaTotal = 0; + let investmentReturnTotal = 0; + let propertyAppreciationTotal = 0; + let pensionFundContributionTotal = 0; + let savingRatesTotal = 0; + let debtRatesTotal = 0; + let withdrawalsTotal = 0; for (let t = 1; t <= duration; t++) { // Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix). @@ -621,10 +749,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed let cashFromWithdraw = 0; for (const a of assets) { const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r; + // Arithmetik unveraendert lassen; die Rendite wird als Differenz abgeleitet, damit + // die Protokollierung das Ergebnis nicht (per Gleitkomma-Umformung) verschiebt. const grown = a.value * (1 + r / 100) + a.rate; + const growth = grown - a.rate - a.value; const w = Math.min(a.withdrawal, Math.max(0, grown)); a.value = grown - w; cashFromWithdraw += w; + investmentReturnTotal += growth; + if (a.isPk) pensionFundContributionTotal += a.rate; } // Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die @@ -637,7 +770,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed // Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital -- das ist der // Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000. const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth; - re.value *= 1 + g / 100; + const valueBefore = re.value; + re.value *= 1 + g / 100; // Arithmetik unveraendert + propertyAppreciationTotal += re.value - valueBefore; } for (const d of debts) { const pay = Math.min(d.repay, d.owed); @@ -649,6 +784,43 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw; if (cash < 0) cashNegative = true; + quotaTotal += quote; + savingRatesTotal += fixedRatesTotal; + debtRatesTotal += debtRates; + withdrawalsTotal += cashFromWithdraw; + + // Verlaufspunkt je Element (Detailansicht). Bewusst NACH Verzinsung und Tilgung, also + // der Stand am Jahresende -- konsistent zum Gesamtvermoegen im YearPoint. + const age = personA.age + yearsBefore + t; + const yr = yearsBefore + t; + for (const inc of incomes) { + inc.ec.yearly.push({ year: yr, age, value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)) }); + } + for (const exp of expenses) { + exp.ec.yearly.push({ + year: yr, + age, + value: Math.round(exp.basis * Math.pow(1 + exp.idx / 100, t - 1) * inflFactor), + }); + } + for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value) }); + for (const re of realEstates) { + re.ec.yearly.push({ + year: yr, + age, + value: Math.round(re.value - re.mortgage), // Eigenkapital + propertyValue: Math.round(re.value), + mortgage: Math.round(re.mortgage), + }); + } + for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) }); + // Renten (AHV, verrentete PK) laufen nominal fix durch die Phase. + for (const ec of ecById.values()) { + if ((ec.category === "AHV" || ec.category === "PENSION_FUND") && ec.startValue > 0 && ec.yearly.length < t) { + ec.yearly.push({ year: yr, age, value: ec.startValue }); + } + } + // Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung). let total = cash; for (const a of assets) total += a.value; @@ -692,11 +864,287 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed if (d.owed === 0) d.ec.note = "Wird getilgt"; } + // --- Rechenweg je Element (Roadmap Nr. 41) --- + // Bewusst HIER, nach der Jahresschleife: erst jetzt stehen die Endwerte fest. + if (explain) { + for (const inc of incomes) { + inc.ec.trace = { + title: `Einkommen «${inc.ec.name}» in dieser Lebensphase`, + specAnchor: "461-income--expense", + steps: [ + st( + inc.ec.carried ? "Basiswert (aus der Vorphase fortgeschrieben)" : "Basiswert (erfasst)", + Math.round(inc.basis), + undefined, + undefined, + inc.ec.carried + ? "Der Endwert der Vorphase wird fortgeschrieben, solange kein abweichender Wert erfasst ist." + : "Erfasst wird das NETTO-Einkommen, nominal." + ), + st("Nominale Lohnerhöhung", inc.idx, undefined, undefined, undefined, "%"), + st( + `Einkommen im letzten Phasenjahr (Jahr ${duration})`, + inc.ec.endValue, + "Basis × (1 + Lohnerhöhung)^(Dauer − 1)", + `${fmt(inc.basis)} × (1 + ${pct(inc.idx)})^${duration - 1}` + ), + st( + "Basiswert für die Folgephase", + Math.round(inc.basis * Math.pow(1 + inc.idx / 100, duration)), + "Basis × (1 + Lohnerhöhung)^Dauer", + `${fmt(inc.basis)} × (1 + ${pct(inc.idx)})^${duration}`, + "Ein Jahr weiter als der Endwert – die Folgephase beginnt nach dem letzten Jahr dieser Phase." + ), + ], + }; + } + + for (const exp of expenses) { + const realEnd = exp.basis * Math.pow(1 + exp.idx / 100, duration - 1); + exp.ec.trace = { + title: `Ausgaben «${exp.ec.name}» in dieser Lebensphase`, + specAnchor: "461-income--expense", + steps: [ + st( + exp.ec.carried ? "Basiswert real (fortgeschrieben)" : "Basiswert real (erfasst)", + Math.round(exp.basis), + undefined, + undefined, + "Ausgaben werden REAL erfasst, also in heutiger Kaufkraft." + ), + st("Reale Mehrausgaben pro Jahr", exp.idx, undefined, undefined, "Zusätzlich zur Inflation.", "%"), + st( + "Nominal im ersten Phasenjahr", + exp.ec.startValue, + "Basis × kumulierte Inflation (Phasenbeginn)", + `${fmt(exp.basis)} × ${Math.round(cumInflStart * 10000) / 10000}` + ), + st( + `Real im letzten Phasenjahr (Jahr ${duration})`, + Math.round(realEnd), + "Basis × (1 + reale Mehrausgaben)^(Dauer − 1)", + `${fmt(exp.basis)} × (1 + ${pct(exp.idx)})^${duration - 1}` + ), + st( + `Nominal im letzten Phasenjahr (Jahr ${duration})`, + exp.ec.endValue, + "real × Flow-Deflator am Phasenende", + `${fmt(realEnd)} × ${Math.round(flowDeflatorEnd * 10000) / 10000}`, + "Der Flow-Deflator liegt eine Kaufkraft-Stufe unter dem Bestands-Deflator: ein Flow fällt IM Jahr `Dauer` an, ein Bestand wird NACH diesem Jahr gemessen." + ), + ], + }; + } + + for (const a of assets) { + const isPk = a.isPk; + a.ec.trace = { + title: `${isPk ? "Pensionskasse" : "Kapitalanlage"} «${a.ec.name}» in dieser Lebensphase`, + specAnchor: isPk ? "463-pension_fund" : "466-other_asset", + steps: [ + st(a.ec.carried ? "Startwert (fortgeschrieben)" : "Startwert (erfasst)", a.ec.baseValue), + ...(a.ec.startValue !== a.ec.baseValue + ? [st("Zusatzeinlage aus Kapital", a.ec.startValue - a.ec.baseValue, undefined, undefined, "Wird dem Cash entnommen.")] + : []), + st("Erwartete Rendite", a.r, undefined, undefined, undefined, "%"), + st( + "Jährliche Einzahlung", + a.rate, + undefined, + undefined, + isPk + ? "Arbeitnehmer- und Arbeitgeberbeitrag. Belastet das Cash NICHT – die Beiträge sind im Nettolohn bereits abgezogen." + : "Verlässt das Cash und zählt zur geplanten Sparrate." + ), + ...(a.withdrawal > 0 + ? [st("Jährliche Bezugsrate", a.withdrawal, undefined, undefined, "Entnahme aus dem Vermögen ins Cash, am Bestand gekappt.")] + : []), + st( + "Jahresschritt", + a.ec.endValue, + "Bestand × (1 + Rendite) + Einzahlung − Bezugsrate", + `über ${duration} Jahre, beginnend bei ${fmt(a.ec.startValue)}`, + "Verzinsung ist nachschüssig: die Einzahlung des laufenden Jahres wird noch nicht mitverzinst." + ), + st("Endwert der Phase", a.ec.endValue), + ], + }; + } + + for (const re of realEstates) { + const interestStart = Math.round((re.ec.mortgageStart * re.interestRate) / 100); + const interestEnd = Math.round((re.ec.mortgageEnd * re.interestRate) / 100); + re.ec.trace = { + title: `Immobilie «${re.ec.name}» in dieser Lebensphase`, + specAnchor: "465-real_estate-immobilie", + steps: [ + st("Verkehrswert zu Phasenbeginn", re.ec.startValue + re.ec.mortgageStart), + st("Resthypothek zu Phasenbeginn", re.ec.mortgageStart), + st("Eigenkapital zu Phasenbeginn", re.ec.startValue, "Verkehrswert − Resthypothek", `${fmt(re.ec.startValue + re.ec.mortgageStart)} − ${fmt(re.ec.mortgageStart)}`), + st("Ursprünglicher Kaufpreis", re.purchase, undefined, undefined, "Basis der Grundstückgewinnsteuer beim Verkauf – läuft getrennt vom Verkehrswert."), + st("Wertsteigerung pro Jahr", re.growth, undefined, undefined, "Wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital – daher der Hebel.", "%"), + st("Amortisation pro Jahr", re.amort, undefined, undefined, "Am Restsaldo gekappt: ist die Hypothek getilgt, entfällt die Rate."), + st("Hypothekarzins", re.interestRate, undefined, undefined, re.addInterest ? "Wird zu den Ausgaben dazugerechnet." : "Gilt als bereits im Ausgaben-Element enthalten und wird NICHT zusätzlich abgezogen.", "%"), + st("Zinsbetrag im ersten Jahr", interestStart, "Restschuld × Zinssatz", `${fmt(re.ec.mortgageStart)} × ${pct(re.interestRate)}`), + st("Zinsbetrag im letzten Jahr", interestEnd, "Restschuld × Zinssatz", `${fmt(re.ec.mortgageEnd)} × ${pct(re.interestRate)}`, "Sinkt automatisch mit der Amortisation."), + st("Verkehrswert am Phasenende", re.ec.endValue + re.ec.mortgageEnd), + st("Resthypothek am Phasenende", re.ec.mortgageEnd), + st("Eigenkapital am Phasenende", re.ec.endValue, "Verkehrswert − Resthypothek", `${fmt(re.ec.endValue + re.ec.mortgageEnd)} − ${fmt(re.ec.mortgageEnd)}`), + ], + }; + } + + for (const d of debts) { + d.ec.trace = { + title: `Schuld «${d.ec.name}» in dieser Lebensphase`, + specAnchor: "467-other_debt", + steps: [ + st("Restschuld zu Phasenbeginn", -d.ec.startValue), + st("Jährliche Tilgung", d.repay, undefined, undefined, "Am Restsaldo gekappt – im letzten Zahlungsjahr wird nur der Restbetrag fällig."), + st("Restschuld am Phasenende", d.owed, `min(Tilgung, Restschuld) über ${duration} Jahre`), + st("Beitrag zum Vermögen", d.ec.endValue, undefined, undefined, "Schulden gehen mit negativem Vorzeichen ins Vermögen ein."), + ], + }; + } + + // AHV-/PK-Renten: der Rechenweg der Rentenhoehe selbst liegt auf Plan-Ebene (mdJE). + for (const e of orderedElements) { + const ec = ecById.get(e.id)!; + if (ec.trace || ec.startValue === 0) continue; + if (e.category === "AHV") { + const owner = e.ownerRole ? personByRole(persons, e.ownerRole) : null; + const gap = owner ? gapYearsByPerson.get(owner.id) ?? 0 : 0; + const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 } : { avg: 0, gap: 0 }; + const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null; + const mdJE = career ? ahvMdje(career, before.avg, before.gap) : 0; + ec.trace = { + title: `AHV-Rente «${ec.name}»`, + specAnchor: "44-ahv-rente", + steps: [ + st("Massgebendes durchschnittliches Jahreseinkommen (mdJE)", Math.round(mdJE), undefined, undefined, "Gewichtetes Mittel über alle Beitragsjahre, REAL gerechnet und auf den Bruttolohn hochgerechnet."), + st("Volle Monatsrente zum mdJE", Math.round(ahvMonthlyFullPension(mdJE)), "Rentenformel Skala 44", `mdJE ${fmt(mdJE)} → Skala 44`), + st("Ausfalljahre total", gap + before.gap, undefined, undefined, "Jedes Ausfalljahr kürzt die Rente um 1/44.", "Jahre"), + st( + "Rentenskala-Faktor", + Math.round(((AHV_FULL_CONTRIBUTION_YEARS - (gap + before.gap)) / AHV_FULL_CONTRIBUTION_YEARS) * 1000) / 10, + "(44 − Ausfalljahre) / 44", + `(${AHV_FULL_CONTRIBUTION_YEARS} − ${gap + before.gap}) / ${AHV_FULL_CONTRIBUTION_YEARS}`, + undefined, + "%" + ), + st( + "Jahresrente", + ec.startValue, + "Monatsrente × 13 × Skalafaktor", + `${fmt(ahvMonthlyFullPension(mdJE))} × ${AHV_PENSION_MONTHS} × Faktor`, + plan.householdType === "COUPLE" + ? "Bei Ehepaaren zusätzlich auf 150 % der Einzel-Maximalrente plafoniert. Die Rente wird danach nominal eingefroren und nicht indexiert." + : "Die Rente wird danach nominal eingefroren und nicht indexiert (bewusste Vereinfachung)." + ), + ], + }; + } else if (e.category === "PENSION_FUND") { + ec.trace = { + title: `PK-Rente «${ec.name}»`, + specAnchor: "491-pension_fund", + steps: [ + st("Jährliche Rente", ec.startValue, "verrentetes Kapital × Umwandlungssatz", undefined, "Der Umwandlungssatz wurde beim Pensions-Übergang gewählt. Die Rente ist danach nominal fix."), + ], + }; + } + } + } + const cashEnd = Math.round(cash); const startWealthNominal = Math.round(wealthStart + cashStart); const endWealthNominal = Math.round(wealthEnd + cashEnd); cumulativeInflation = cumInfl[yearsBefore + duration]; + // --- Rechenweg der Phasen-Kennzahlen --- + const phaseTraces: Trace[] = []; + if (explain) { + phaseTraces.push({ + title: "Spar- bzw. Verzehrquote", + specAnchor: "47-die-jahresschleife", + steps: [ + st("Einkommen im ersten Jahr (inkl. Renten)", Math.round(incomeStart)), + st("Ausgaben im ersten Jahr (nominal)", Math.round(expenseStart)), + st("Quote im ersten Jahr", Math.round(quotaStart), "Einkommen − nominale Ausgaben", `${fmt(incomeStart)} − ${fmt(expenseStart)}`, + quotaStart < 0 ? "Negativ – das ist eine Verzehrphase: die laufenden Ausgaben übersteigen das Einkommen." : undefined), + st("Quote im letzten Jahr", Math.round(quotaEnd), "Einkommen − nominale Ausgaben", `${fmt(incomeEnd)} − ${fmt(expenseEnd)}`), + st("Summe über alle Phasenjahre", Math.round(quotaTotal), undefined, undefined, "Diese Summe ist der Beitrag der laufenden Flows zur Vermögensentwicklung."), + ], + }); + + phaseTraces.push({ + title: "Geplante Spar- und Verzehrrate", + specAnchor: "47-die-jahresschleife", + steps: [ + st("Feste Sparraten (3a + Sparbeiträge)", fixedRatesTotal, undefined, undefined, "PK-Beiträge zählen NICHT dazu – sie sind im Nettolohn bereits abgezogen."), + st("Amortisationen und Tilgungen im ersten Jahr", plannedSaveRate - fixedRatesTotal), + st("Geplante Sparrate (erstes Jahr)", plannedSaveRate, "Sparraten + Amortisationen + Tilgungen", `${fmt(fixedRatesTotal)} + ${fmt(plannedSaveRate - fixedRatesTotal)}`, + "Bewusst das erste Phasenjahr: läuft eine Schuld während der Phase aus, liegt die Rate später tiefer."), + st("Geplante Verzehrrate", plannedWithdrawTotal, undefined, undefined, "Summe der Bezugsraten aus Sonstigem Vermögen."), + ], + }); + + phaseTraces.push({ + title: "Cash-Fortschreibung", + specAnchor: "47-die-jahresschleife", + steps: [ + st(isFirstPhase ? "Cash-Anfangswert" : "Cash-Endbestand der Vorphase", isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd), + ...(incomingInflow ? [st("+ Kapitalzufluss aus dem Übergang", Math.round(incomingInflow), undefined, undefined, "Verkäufe und PK-/3a-Bezüge, netto nach Steuer.")] : []), + ...(incomingOneOffInflow ? [st("+ Einmaliger Zufluss", Math.round(incomingOneOffInflow))] : []), + ...(incomingImmediateRepay ? [st("− Sofort-Tilgung / Sonderamortisation", -Math.round(incomingImmediateRepay))] : []), + ...(incomingOneOffOutflow ? [st("− Einmalige Kosten", -Math.round(incomingOneOffOutflow))] : []), + ...(!isFirstPhase && investmentsFromCash ? [st("− Investitionen am Phasenanfang", -Math.round(investmentsFromCash), undefined, undefined, "Werden vorab abgezogen, damit sie nicht doppelt (im Cash UND im Vermögen) zählen.")] : []), + st("= Cash zu Phasenbeginn", Math.round(cashStart)), + st("+ Summe der Quoten", Math.round(quotaTotal)), + st("− Sparraten", -Math.round(savingRatesTotal)), + st("− Amortisationen und Tilgungen", -Math.round(debtRatesTotal)), + st("+ Bezugsraten", Math.round(withdrawalsTotal)), + st("= Cash am Phasenende", cashEnd, undefined, undefined, + cashNegative ? "Der Cash-Bestand fällt in dieser Phase unter 0 – das ist eine Liquiditätslücke. Sie wird gemeldet, aber nicht automatisch ausgeglichen." : undefined), + ], + }); + + phaseTraces.push({ + title: "Vermögen zu Beginn und am Ende", + specAnchor: "48-endwerte-und-phasen-kennzahlen", + steps: [ + st("Summe der Element-Startwerte", Math.round(wealthStart)), + st("+ Cash zu Phasenbeginn", Math.round(cashStart)), + st("= Vermögen zu Phasenbeginn", startWealthNominal), + st("Summe der Element-Endwerte", Math.round(wealthEnd)), + st("+ Cash am Phasenende", cashEnd), + st("= Vermögen am Phasenende (nominal)", endWealthNominal), + st("Bestands-Deflator am Phasenende", Math.round(cumulativeInflation * 10000) / 10000, undefined, undefined, undefined, ""), + st("= Vermögen am Phasenende (real)", Math.round(endWealthNominal / cumulativeInflation), + "nominal / kumulierte Inflation", `${fmt(endWealthNominal)} / ${Math.round(cumulativeInflation * 10000) / 10000}`, + "Real heisst: kaufkraftbereinigt auf den Planbeginn."), + ], + }); + + phaseTraces.push({ + title: "Phasentyp und Alter", + specAnchor: "43-personen-und-phasentyp", + steps: [ + ...personInfos.map((p) => + st( + `${p.role === "PERSON_A" ? "Person A" : "Person B"}: Alter zu Phasenbeginn`, + p.startAge, + "aktuelles Alter + Jahre vor dieser Phase", + `${persons.find((x) => x.id === p.personId)?.age ?? 0} + ${yearsBefore}`, + p.working ? "erwerbstätig (Alter < Pensionsalter)" : "pensioniert", + "Jahre" + ) + ), + st("Phasentyp", 0, undefined, type === "ERWERB" ? "alle erwerbstätig" : type === "PENSION" ? "alle pensioniert" : "gemischt", + "Der Phasentyp wird nie gespeichert, sondern in jeder Berechnung neu abgeleitet.", ""), + ], + }); + } + result.push({ id: phase.id, name: phase.name, @@ -731,11 +1179,69 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed cumulativeInflationEnd: cumulativeInflation, flowDeflatorEnd, endWealthReal: endWealthNominal / cumulativeInflation, + wealthBridge: { + openingWealth: isFirstPhase ? startWealthNominal : previousEndWealth, + oneOffInflow: Math.round(incomingOneOffInflow), + oneOffOutflow: Math.round(incomingOneOffOutflow), + transitionTax: Math.round(incomingTax), + pensionConversion: Math.round(incomingPensionConversion), + saleGainLoss: Math.round(incomingSaleGainLoss), + startWealth: startWealthNominal, + quotaTotal: Math.round(quotaTotal), + investmentReturn: Math.round(investmentReturnTotal), + propertyAppreciation: Math.round(propertyAppreciationTotal), + pensionFundContribution: Math.round(pensionFundContributionTotal), + endWealth: endWealthNominal, + residual: + endWealthNominal - + ((isFirstPhase ? startWealthNominal : previousEndWealth) + + Math.round(incomingOneOffInflow) - + Math.round(incomingOneOffOutflow) - + Math.round(incomingTax) - + Math.round(incomingPensionConversion) + + Math.round(incomingSaleGainLoss) + + Math.round(quotaTotal) + + Math.round(investmentReturnTotal) + + Math.round(propertyAppreciationTotal) + + Math.round(pensionFundContributionTotal)), + }, + cashBridge: { + openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd, + capitalInflow: Math.round(incomingInflow), + oneOffInflow: Math.round(incomingOneOffInflow), + immediateRepay: Math.round(incomingImmediateRepay), + oneOffOutflow: Math.round(incomingOneOffOutflow), + investments: isFirstPhase ? 0 : Math.round(investmentsFromCash), + cashStart: Math.round(cashStart), + quotaTotal: Math.round(quotaTotal), + savingRates: Math.round(savingRatesTotal), + debtRates: Math.round(debtRatesTotal), + withdrawals: Math.round(withdrawalsTotal), + cashEnd, + residual: + cashEnd - + (Math.round(cashStart) + + Math.round(quotaTotal) - + Math.round(savingRatesTotal) - + Math.round(debtRatesTotal) + + Math.round(withdrawalsTotal)), + }, + traces: explain ? phaseTraces : undefined, }); + previousEndWealth = endWealthNominal; + previousCashEnd = cashEnd; + // --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden --- let txInflow = 0; let txImmediateRepay = 0; + // Echte Vermoegensaenderungen an dieser Grenze (fuer die Bruecke der Folgephase). + // Verkaeufe, Bezuege und Tilgungen sind fuer sich Umbuchungen -- vermoegenswirksam sind + // nur die Steuer, die Verrentung (Kapital verlaesst die Bilanz) und die Differenz + // zwischen Verkaufspreis und Verkehrswert. + let txTax = 0; + let txPensionConversion = 0; + let txSaleGainLoss = 0; // Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase // existiert -- nach der letzten Phase gibt es keinen Uebergang. Der Wechselkurs zwischen @@ -796,22 +1302,31 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed continue; } + const txStepsBefore = txInflow; + const taxBefore = txTax; + switch (e.category) { case "PENSION_FUND": { if (ownerRetiresNext) { const value = ec.endValue; const mode = td.payoutMode ?? "PENSION"; if (mode === "CAPITAL") { - txInflow += Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + txInflow += net; + txTax += value - net; carry.value = 0; carry.pkPensionAnnual = 0; } else if (mode === "PENSION") { carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); + txPensionConversion += value; carry.value = 0; } else { const capital = Math.min(value, Math.round(num(td.capitalAmount))); - txInflow += Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + txInflow += net; + txTax += capital - net; carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); + txPensionConversion += value - capital; carry.value = 0; } } else { @@ -819,18 +1334,24 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed // Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash. const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); carry.value = ec.endValue - withdrawal; - txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + const net = Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + txInflow += net; + txTax += withdrawal - net; } break; } case "PILLAR_3A": { if (ownerRetiresNext) { - txInflow += Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + txInflow += net; + txTax += ec.endValue - net; carry.value = 0; } else { const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); carry.value = ec.endValue - withdrawal; - txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + const net = Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); + txInflow += net; + txTax += withdrawal - net; } break; } @@ -858,6 +1379,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed const gain = Math.max(0, salePrice - re.purchase); const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100); txInflow += Math.round(salePrice - re.mortgage - tax); + txTax += Math.round(tax); + // Der Verkaufspreis kann vom fortgeschriebenen Verkehrswert abweichen -- diese + // Differenz ist ein echter Vermoegensgewinn bzw. -verlust an der Grenze. + txSaleGainLoss += Math.round(salePrice - re.value); carry.status = "SOLD"; } else { // Sonderamortisation: Einmaltilgung der Hypothek aus dem Cash, am Restsaldo gekappt. @@ -883,6 +1408,76 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed default: break; } + + // --- Rechenweg des Uebergangs je Element --- + if (explain && nextPhase) { + const inflowHere = txInflow - txStepsBefore; + const taxHere = txTax - taxBefore; + const steps: TraceStep[] = [st("Wert am Ende der Vorphase", ec.endValue)]; + let title = `Übergang «${ec.name}»`; + let anchor: string | undefined; + + if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") { + anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a"; + const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE"; + title = `Übergang «${ec.name}» – ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`; + if (ownerRetiresNext && e.category === "PENSION_FUND") { + steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, "")); + if (mode === "PENSION" || mode === "COMBI") { + steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%")); + steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen.")); + } + } + if (taxHere > 0) { + steps.push(st("Kapitalbezugssteuer", num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE), undefined, undefined, "Pauschalsatz – die tatsächliche Steuer ist kantonal und progressiv.", "%")); + steps.push(st("Steuerbetrag", -taxHere)); + } + if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug − Kapitalbezugssteuer")); + } else if (e.category === "REAL_ESTATE") { + anchor = "494-real_estate"; + const re = realEstates.find((r) => r.ec.elementId === e.id); + if (td.decision === "SELL") { + const salePrice = Math.round(num(td.salePrice)); + title = `Verkauf «${ec.name}»`; + steps.push(st("Verkaufspreis", salePrice)); + steps.push(st("Ursprünglicher Kaufpreis", re?.purchase ?? 0, undefined, undefined, "Bemessungsgrundlage der Grundstückgewinnsteuer – nicht der zwischenzeitliche Verkehrswert.")); + steps.push(st("Steuerbarer Gewinn", Math.max(0, salePrice - (re?.purchase ?? 0)), "max(0, Verkaufspreis − Kaufpreis)", `max(0, ${fmt(salePrice)} − ${fmt(re?.purchase ?? 0)})`, "Ein Verlustverkauf erzeugt keine Steuer.")); + steps.push(st("Grundstückgewinnsteuer", num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE), undefined, undefined, "Die Haltedauer-Abstufung ist nicht modelliert.", "%")); + steps.push(st("Steuerbetrag", -taxHere)); + steps.push(st("Hypothekenablösung", -(re?.mortgage ?? 0))); + steps.push(st("Netto ins Cash", inflowHere, "Verkaufspreis − Hypothek − Steuer", `${fmt(salePrice)} − ${fmt(re?.mortgage ?? 0)} − ${fmt(taxHere)}`)); + } else { + const extra = Math.min(re?.mortgage ?? 0, Math.round(num(td.extraAmortization))); + steps.push(st("Entscheid", 0, undefined, "Halten", undefined, "")); + if (extra > 0) { + steps.push(st("Sonderamortisation", -extra, undefined, undefined, "Einmaltilgung aus dem Cash der Folgephase; senkt Restschuld und künftige Zinsen.")); + steps.push(st("Resthypothek danach", carry.mortgage)); + } + } + } else if (e.category === "OTHER_ASSET") { + anchor = "493-other_asset"; + if (td.decision === "SELL") { + steps.push(st("Entscheid", 0, undefined, "Vollverkauf", undefined, "")); + steps.push(st("Erlös ins Cash", inflowHere, undefined, undefined, "Kein Steuerabzug – private Kapitalgewinne sind in der Schweiz steuerfrei.")); + } else if (td.decision === "PARTIAL") { + steps.push(st("Entscheid", 0, undefined, "Teilverkauf", undefined, "")); + steps.push(st("Betrag ins Cash", inflowHere, "min(Endwert, gewünschter Betrag)")); + steps.push(st("Rest bleibt investiert", carry.value, undefined, undefined, "Der verbleibende Teil wächst in der Folgephase weiter.")); + } else { + steps.push(st("Entscheid", 0, undefined, "Halten", undefined, "")); + steps.push(st("Wert in der Folgephase", carry.value)); + } + } else if (e.category === "OTHER_DEBT") { + anchor = "495-other_debt"; + const immediate = Math.round(num(td.immediateRepayment)); + steps.push(st("Restschuld", -ec.endValue)); + if (immediate > 0) steps.push(st("Sofortige Tilgung", -Math.min(-ec.endValue, immediate), undefined, undefined, "Wird dem Cash der Folgephase entnommen.")); + steps.push(st("Restschuld in der Folgephase", carry.owed, undefined, undefined, carry.owed === 0 ? "Vollständig getilgt." : undefined)); + } + + if (steps.length > 1) ec.transitionTrace = { title, specAnchor: anchor, steps }; + } + carry.hasCarry = true; } @@ -893,12 +1488,84 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed incomingOneOffInflowLabel = txOneOffInflowLabel; incomingOneOffOutflow = txOneOffOutflow; incomingOneOffOutflowLabel = txOneOffOutflowLabel; + incomingTax = txTax; + incomingPensionConversion = txPensionConversion; + incomingSaleGainLoss = txSaleGainLoss; + + // Rechenweg des Cash-Uebergangs (einmalige Sonderein-/ausgaben). Wird nachtraeglich an + // die Trace-Liste dieser Phase gehaengt -- `result` haelt die Referenz auf das Array. + if (explain && nextPhase && (txOneOffInflow !== 0 || txOneOffOutflow !== 0)) { + const ct = phase.cashTransition ?? {}; + const steps: TraceStep[] = []; + if (txOneOffInflow !== 0) { + const gross = Math.round(num(ct.inflowAmount)); + steps.push(st(`Zufluss «${ct.inflowLabel?.trim() || "ohne Bezeichnung"}» brutto`, gross, undefined, undefined, "Nominal erfasst – der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt.")); + steps.push(st("Steuersatz", num(ct.inflowTaxRate, 0), undefined, undefined, "Default 0 % – Erbschaften an direkte Nachkommen sind in den meisten Kantonen steuerfrei.", "%")); + steps.push(st("Netto ins Cash", txOneOffInflow, "brutto × (1 − Steuersatz)", `${fmt(gross)} × (1 − ${pct(num(ct.inflowTaxRate, 0))})`)); + } + if (txOneOffOutflow !== 0) { + steps.push(st(`Kosten «${ct.outflowLabel?.trim() || "ohne Bezeichnung"}» real`, Math.round(num(ct.outflowAmount)), undefined, undefined, "Real erfasst, in heutiger Kaufkraft.")); + steps.push(st("Bestands-Deflator an der Phasengrenze", Math.round(cumulativeInflation * 10000) / 10000, undefined, undefined, undefined, "")); + steps.push(st("Nominal vom Cash abgezogen", -txOneOffOutflow, "real × kumulierte Inflation", `${fmt(num(ct.outflowAmount))} × ${Math.round(cumulativeInflation * 10000) / 10000}`)); + } + steps.push(st("Wirkung auf die Quote", 0, undefined, "keine", "Einmalposten sind keine laufenden Flows – sie wirken ausschliesslich aufs Cash.", "")); + phaseTraces.push({ title: "Cash-Übergang: einmalige Sonderein-/ausgaben", specAnchor: "496-cash-einmalige-sonderein-ausgaben", steps }); + } + yearsBefore += duration; } const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0; const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson)); - return { phases: result, yearly, nachlass, ruinAge, ahvCareer }; + + // --- Plan-weite Rechenwege --- + const planTraces: Trace[] = []; + if (explain) { + planTraces.push({ + title: "Inflation und die drei Deflatoren", + specAnchor: "45-nominal-real-und-die-deflatoren", + steps: [ + st("Angenommene Inflationsrate", plan.inflationRateDefault, undefined, undefined, "Gilt plan-weit für alle Lebensphasen; eine phasenspezifische Rate gibt es nicht.", "%"), + st("Planlaufzeit", totalYears, undefined, undefined, undefined, "Jahre"), + st("Kumulierte Inflation am Planende", Math.round(cumInfl[totalYears] * 10000) / 10000, "Produkt von (1 + Inflation) über alle Jahre", `(1 + ${pct(plan.inflationRateDefault)})^${totalYears}`, undefined, ""), + st("Kaufkraft eines heutigen Frankens am Planende", Math.round((1 / (cumInfl[totalYears] || 1)) * 10000) / 10000, "1 / kumulierte Inflation", undefined, + "Bestände am Phasenende, Bestände zu Phasenbeginn und Flow-Endwerte nutzen drei UNTERSCHIEDLICHE Deflatoren – ein Flow fällt IM Jahr an, ein Bestand wird NACH dem Jahr gemessen.", ""), + ], + }); + + for (const career of ahvCareer) { + const before = ahvBeforeByPerson.get(career.personId) ?? { avg: 0, gap: 0 }; + const mdJE = ahvMdje(career, before.avg, before.gap); + planTraces.push({ + title: `AHV-Beitragskarriere – ${career.role === "PERSON_A" ? "Person A" : "Person B"}`, + specAnchor: "442-beitragskarriere-und-mdje", + steps: [ + st("Beitragsjahre im Plan", career.planYears, "Summe (Phasendauer − Ausfalljahre)", undefined, undefined, "Jahre"), + st("Durchschnittliches Bruttoeinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined, + "Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."), + st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn − 21 − Ausfalljahre davor", undefined, undefined, "Jahre"), + st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined, + before.avg === 0 && career.yearsBeforePlan > 0 ? "Noch nicht erfasst – am Pensions-Übergang zu prüfen." : "Aus der AHV-Rentenvorausberechnung übernommen."), + st("Massgebendes durchschnittliches Jahreseinkommen", Math.round(mdJE), + "(Einkommen davor × Jahre davor + Einkommen im Plan × Jahre im Plan) / Total Jahre", undefined, + "REAL gerechnet: die echte AHV wertet vergangene Einkommen auf UND indexiert die Schwellen – beides hebt sich real weitgehend auf."), + st("Ausfalljahre total", (gapYearsByPerson.get(career.personId) ?? 0) + before.gap, undefined, undefined, undefined, "Jahre"), + ], + }); + } + + planTraces.push({ + title: "Ruinalter", + specAnchor: "47-die-jahresschleife", + steps: [ + st("Ruinalter", ruinAge ?? 0, undefined, ruinAge === null ? "kein Ruin – das Gesamtvermögen bleibt über die ganze Planung positiv" : `Alter ${ruinAge}`, + "Bezieht sich auf das GESAMTvermögen inklusive Immobilien, nicht auf das Cash. Wird nur beim ersten Auftreten gesetzt.", ruinAge === null ? "" : "Jahre"), + st("Endvermögen (Nachlass, nominal)", nachlass, undefined, undefined, "Entspricht dem nominalen Endvermögen der letzten Lebensphase."), + ], + }); + } + + return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined }; } // Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1e2af91..de25aae 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -54,3 +54,152 @@ export const PILLAR_3A_MAX_ANNUAL = 7258; export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jaehrliche Rente = Kapital x Satz) export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug) export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstueckgewinnsteuer + +// --- Strukturierte Fassung fuer die Systemparameter-Ansicht (Roadmap Nr. 41) ------------- +// +// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberflaeche +// Wert, Bedeutung, Herleitung und Quelle anzeigen, OHNE dass die Zahlen ein zweites Mal +// gepflegt werden muessen -- die Eintraege referenzieren die Konstanten direkt und koennen +// deshalb nicht von ihnen abdriften. + +export type ParameterGroup = "AHV" | "Vorsorge" | "Steuern"; + +export interface SystemParameter { + key: string; + label: string; + value: number; + unit: "CHF" | "%" | "Jahre" | "Anzahl"; + meaning: string; + derivation?: string; // nur bei abgeleiteten Werten + source: string; + validFrom: string; + group: ParameterGroup; + editablePerEvent?: boolean; // Default-Satz, pro Ereignis ueberschreibbar +} + +export const SYSTEM_PARAMETERS: SystemParameter[] = [ + { + key: "AHV_MIN_MONTHLY_FULL", + label: "Mindestbetrag der vollen Monatsrente (R0)", + value: AHV_MIN_MONTHLY_FULL, + unit: "CHF", + meaning: + "Ankerwert der ganzen Rentenskala 44. Sämtliche Schwellen der Rentenformel sind Vielfache von R0 – ändert sich R0, verschiebt sich die gesamte Skala.", + source: 'BSV/MAS «Berechnungsvorschriften der AHV/IV-Renten»; amtliche Tabelle 318.117.1 «Monatliche Vollrenten, Skala 44»', + validFrom: "1.1.2026", + group: "AHV", + }, + { + key: "AHV_PENSION_MONTHS", + label: "Rentenzahlungen pro Jahr", + value: AHV_PENSION_MONTHS, + unit: "Anzahl", + meaning: + "13 statt 12, weil seit 1.1.2026 die 13. Altersrente ausgerichtet wird. Die Rentenformel liefert Monatsbeträge; der Jahresbetrag ist deshalb × 13.", + source: "Art. 34bis AHVG (13. Altersrente)", + validFrom: "1.1.2026", + group: "AHV", + }, + { + key: "AHV_MAX_ANNUAL_SINGLE", + label: "Maximale einfache Altersrente pro Jahr", + value: AHV_MAX_ANNUAL_SINGLE, + unit: "CHF", + meaning: "Obergrenze der Einzelrente. Wird ab einem massgebenden Durchschnittseinkommen von 90'720 erreicht.", + derivation: `2 × R0 × ${AHV_PENSION_MONTHS} = 2 × ${AHV_MIN_MONTHLY_FULL} × ${AHV_PENSION_MONTHS} = ${AHV_MAX_ANNUAL_SINGLE}`, + source: "abgeleitet aus R0 und der Anzahl Rentenzahlungen", + validFrom: "1.1.2026", + group: "AHV", + }, + { + key: "AHV_FULL_CONTRIBUTION_YEARS", + label: "Volle Beitragsdauer (Rentenskala)", + value: AHV_FULL_CONTRIBUTION_YEARS, + unit: "Jahre", + meaning: "Für eine ungekürzte Rente nötige Beitragsjahre. Jedes fehlende Jahr kürzt die Rente um 1/44.", + source: "AHVG, Rentenskala 44", + validFrom: "1.1.2026", + group: "AHV", + }, + { + key: "AHV_CONTRIBUTION_START_AGE", + label: "Beginn der Beitragspflicht", + value: AHV_CONTRIBUTION_START_AGE, + unit: "Jahre", + meaning: + "Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag, faktisch ab Alter 21. Bestimmt, wie viele Beitragsjahre vor dem Planbeginn liegen.", + source: "AHVG", + validFrom: "1.1.2026", + group: "AHV", + }, + { + key: "AHV_COUPLE_CAP_FACTOR", + label: "Ehepaar-Plafonierung", + value: AHV_COUPLE_CAP_FACTOR * 100, + unit: "%", + meaning: + "Die Summe beider Einzelrenten ist auf 150 % der Einzel-Maximalrente begrenzt. Bei Überschreitung werden beide Renten proportional gekürzt.", + derivation: `${AHV_MAX_ANNUAL_SINGLE} × ${AHV_COUPLE_CAP_FACTOR} = ${AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR} CHF pro Jahr für das Paar`, + source: "AHVG", + validFrom: "1.1.2026", + group: "AHV", + }, + { + key: "AHV_GROSS_FROM_NET_FACTOR", + label: "Umrechnung Netto- auf Bruttolohn (für die AHV)", + value: AHV_GROSS_FROM_NET_FACTOR, + unit: "Anzahl", + meaning: + "Das Tool erfasst das Einkommen netto (so stimmt der Cash-Fluss), die AHV bemisst sich am Bruttolohn. Dieser Faktor rechnet für die Rentenberechnung zurück.", + derivation: + "Arbeitnehmer-Abzüge: AHV/IV/EO 5.30 % + ALV 1.10 % + NBU ~1.00 % + PK ~2–5 % = ~9–13 % → 1/(1−q) = 1.10 bis 1.16, Mittel 1.12", + source: 'BSV «Beträge gültig ab 1.1.2026»; AHV-Merkblätter 2.01 und 2.08', + validFrom: "1.1.2026", + group: "AHV", + }, + { + key: "PILLAR_3A_MAX_ANNUAL", + label: "Maximaler Säule-3a-Beitrag pro Jahr", + value: PILLAR_3A_MAX_ANNUAL, + unit: "CHF", + meaning: "Höchstbetrag für Personen mit Pensionskasse. Im Eingabefeld hart geklammert.", + source: 'BSV «Beträge gültig ab 1.1.2026»', + validFrom: "1.1.2026", + group: "Vorsorge", + }, + { + key: "DEFAULT_PK_CONVERSION_RATE", + label: "Umwandlungssatz Pensionskasse (Vorschlag)", + value: DEFAULT_PK_CONVERSION_RATE, + unit: "%", + meaning: "Jährliche Rente = verrentetes Kapital × Umwandlungssatz. Der tatsächliche Satz steht im PK-Reglement.", + source: "Vorschlagswert; überobligatorische Sätze liegen oft tiefer", + validFrom: "—", + group: "Vorsorge", + editablePerEvent: true, + }, + { + key: "DEFAULT_CAPITAL_TAX_RATE", + label: "Kapitalbezugssteuer (Vorschlag)", + value: DEFAULT_CAPITAL_TAX_RATE, + unit: "%", + meaning: + "Steuer auf dem Kapitalbezug aus PK und Säule 3a. Kantonal sehr unterschiedlich und progressiv – dieser Pauschalsatz ersetzt keine Steuerberechnung.", + source: "Vorschlagswert; kantonal stark abweichend", + validFrom: "—", + group: "Steuern", + editablePerEvent: true, + }, + { + key: "DEFAULT_PROPERTY_GAINS_TAX_RATE", + label: "Grundstückgewinnsteuer (Vorschlag)", + value: DEFAULT_PROPERTY_GAINS_TAX_RATE, + unit: "%", + meaning: + "Steuer auf dem Gewinn beim Immobilienverkauf (Verkaufspreis − ursprünglicher Kaufpreis). Die Haltedauer-Abstufung ist nicht modelliert.", + source: "Vorschlagswert; kantonal stark abweichend, teils degressiv nach Haltedauer", + validFrom: "—", + group: "Steuern", + editablePerEvent: true, + }, +]; diff --git a/src/lib/explain.test.ts b/src/lib/explain.test.ts new file mode 100644 index 0000000..bc225a4 --- /dev/null +++ b/src/lib/explain.test.ts @@ -0,0 +1,255 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, it, expect } from "vitest"; +import { computePlan } from "@/lib/calculations"; +import type { Trace } from "@/lib/calculations"; +import type { PlanInput } from "@/lib/types"; + +// Reichhaltiger Plan: Erwerbsphase mit Einkommen, Ausgaben, PK, 3a, Immobilie (Hypothek, +// Amortisation, Wertsteigerung) und einer Schuld; danach Pensionsphase mit Verzehr. +// Am Uebergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten. +function richPlan(): PlanInput { + return { + id: "p", + name: "T", + householdType: "SINGLE", + inflationRateDefault: 1.5, + initialCash: 50000, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 55, retirementAge: 65 }], + phases: [ + { + id: "p1", + sequenceNumber: 1, + name: "Erwerb", + durationYears: 10, + cashTransition: { mode: "BOTH", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10, outflowLabel: "Pool", outflowAmount: 20000 }, + }, + { id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 15, cashTransition: {} }, + ], + elements: [ + { + id: "inc", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1, + phaseValues: { p1: { amount: 120000, teuerungsausgleich: 1.5 } }, transitionValues: {}, + }, + { + id: "exp", category: "EXPENSE", name: "Leben", ownerRole: "HOUSEHOLD", orderIndex: 2, + phaseValues: { p1: { amount: 80000 }, p2: { amount: 70000 } }, transitionValues: {}, + }, + { + id: "ahv", category: "AHV", name: "AHV", ownerRole: "PERSON_A", orderIndex: 3, + phaseValues: { p1: { gapYears: 0 } }, + transitionValues: { p1: { reviewed: true, avgIncomeBefore: 90000, gapYearsBefore: 0 } }, + }, + { + id: "pk", category: "PENSION_FUND", name: "PK", ownerRole: "PERSON_A", orderIndex: 4, + phaseValues: { p1: { currentValue: 400000, annualContribution: 20000, expectedReturn: 2 } }, + transitionValues: { p1: { payoutMode: "PENSION", conversionRate: 6 } }, + }, + { + id: "s3a", category: "PILLAR_3A", name: "3a", ownerRole: "PERSON_A", orderIndex: 5, + phaseValues: { p1: { currentValue: 80000, annualContribution: 7000, expectedReturn: 3 } }, + transitionValues: { p1: { capitalTaxRate: 8 } }, + }, + { + id: "re", category: "REAL_ESTATE", name: "Haus", ownerRole: "HOUSEHOLD", orderIndex: 6, + phaseValues: { + p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 15000, interestRate: 1.5, interestHandling: "ADD", valueGrowth: 1 }, + p2: { amortization: 0, interestRate: 1.5, interestHandling: "ADD", valueGrowth: 1 }, + }, + transitionValues: { p1: { decision: "HOLD", extraAmortization: 50000 } }, + }, + { + id: "asset", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 7, + phaseValues: { + p1: { startValue: 200000, expectedReturn: 5, annualContribution: 10000 }, + p2: { expectedReturn: 4, annualWithdrawal: 30000 }, + }, + transitionValues: {}, + }, + { + id: "debt", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 8, + phaseValues: { p1: { startValue: 60000, annualRepayment: 8000 } }, + transitionValues: { p1: { immediateRepayment: 5000 } }, + }, + ], + }; +} + +describe("Verlaufswerte je Element", () => { + it("liefert einen Punkt pro Phasenjahr", () => { + const c = computePlan(richPlan()); + for (const ph of c.phases) { + for (const ec of ph.elements) { + if (ec.status !== "ACTIVE" || ec.note) continue; + if (ec.yearly.length === 0) continue; + expect(ec.yearly.length).toBe(ph.durationYears); + } + } + }); + + it("bildet Zinseszins ab, nicht eine Gerade zwischen den Phasengrenzen", () => { + // 200'000 @ 5 % + 10'000 Sparbeitrag: Jahr 1 = 200'000 x 1.05 + 10'000 = 220'000. + const c = computePlan(richPlan()); + const etf = c.phases[0].elements.find((e) => e.elementId === "asset")!; + expect(etf.yearly[0].value).toBe(220000); + expect(etf.yearly[1].value).toBe(Math.round(220000 * 1.05 + 10000)); + // Konvexitaet: die Jahreszuwaechse werden von Jahr zu Jahr GROESSER. Eine Gerade + // zwischen den Phasengrenzen haette konstante Zuwaechse -- genau der Fehler, den die + // per-Jahr-Werte vermeiden. + const deltas = etf.yearly.slice(1).map((p, i) => p.value - etf.yearly[i].value); + for (let i = 1; i < deltas.length; i++) { + expect(deltas[i]).toBeGreaterThan(deltas[i - 1]); + } + }); + + it("Immobilie fuehrt Verkehrswert und Restschuld getrennt mit", () => { + const c = computePlan(richPlan()); + const re = c.phases[0].elements.find((e) => e.elementId === "re")!; + const y0 = re.yearly[0]; + expect(y0.propertyValue).toBe(Math.round(1000000 * 1.01)); + expect(y0.mortgage).toBe(700000 - 15000); + expect(y0.value).toBe(y0.propertyValue! - y0.mortgage!); // Eigenkapital + }); +}); + +describe("Wasserfall-Bruecken", () => { + it("Vermoegens-Bruecke geht auf (Restposten ~ 0)", () => { + const c = computePlan(richPlan()); + for (const ph of c.phases) { + // Rundungen auf ganze Franken je Summand -> ein paar Franken Toleranz. + expect(Math.abs(ph.wealthBridge.residual)).toBeLessThanOrEqual(5); + expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal); + expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal); + } + }); + + it("Cash-Bruecke geht auf (Restposten ~ 0)", () => { + const c = computePlan(richPlan()); + for (const ph of c.phases) { + expect(Math.abs(ph.cashBridge.residual)).toBeLessThanOrEqual(5); + expect(ph.cashBridge.cashStart).toBe(ph.cashStart); + expect(ph.cashBridge.cashEnd).toBe(ph.cashEnd); + } + }); + + it("Sparraten erscheinen in der CASH-, nicht in der Vermoegens-Bruecke", () => { + // Reiner Umbuchungs-Plan: 12'000 Sparbeitrag pro Jahr, 0 % Rendite, keine Flows. + const plan: PlanInput = { + id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 100000, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }], + phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }], + elements: [ + { + id: "a", category: "OTHER_ASSET", name: "Spar", ownerRole: "HOUSEHOLD", orderIndex: 1, + phaseValues: { p1: { startValue: 0, expectedReturn: 0, annualContribution: 12000 } }, transitionValues: {}, + }, + ], + }; + const ph = computePlan(plan).phases[0]; + // Cash: 5 x 12'000 fliessen ab. + expect(ph.cashBridge.savingRates).toBe(60000); + expect(ph.cashEnd).toBe(100000 - 60000); + // Vermoegen: unveraendert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote. + expect(ph.wealthBridge.quotaTotal).toBe(0); + expect(ph.wealthBridge.investmentReturn).toBe(0); + expect(ph.startWealthNominal).toBe(100000); + expect(ph.endWealthNominal).toBe(100000); + }); + + it("PK-Beitraege sind ein Vermoegenszugang, ohne das Cash zu belasten", () => { + const plan: PlanInput = { + id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }], + phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 4, cashTransition: {} }], + elements: [ + { + id: "pk", category: "PENSION_FUND", name: "PK", ownerRole: "PERSON_A", orderIndex: 1, + phaseValues: { p1: { currentValue: 0, annualContribution: 10000, expectedReturn: 0 } }, transitionValues: {}, + }, + ], + }; + const ph = computePlan(plan).phases[0]; + expect(ph.wealthBridge.pensionFundContribution).toBe(40000); + expect(ph.cashBridge.savingRates).toBe(0); // belastet das Cash NICHT + expect(ph.endWealthNominal).toBe(40000); + }); + + it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermoegens-Bruecke der Folgephase", () => { + const c = computePlan(richPlan()); + const p2 = c.phases[1]; + expect(p2.wealthBridge.pensionConversion).toBeGreaterThan(0); // PK wurde verrentet + expect(p2.wealthBridge.transitionTax).toBeGreaterThan(0); // 8 % auf den 3a-Bezug + expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzueglich 10 % Steuer + expect(p2.wealthBridge.oneOffOutflow).toBeGreaterThan(20000); // real 20'000, nominal aufgewertet + }); +}); + +describe("Rechenweg (explain)", () => { + it("ist standardmaessig aus und aendert das Ergebnis nicht", () => { + const plain = computePlan(richPlan()); + const explained = computePlan(richPlan(), undefined, { explain: true }); + + expect(plain.traces).toBeUndefined(); + expect(plain.phases[0].traces).toBeUndefined(); + expect(plain.phases[0].elements[0].trace).toBeUndefined(); + + // Die Protokollierung darf die Zahlen nicht verschieben. + expect(explained.nachlass).toBe(plain.nachlass); + expect(explained.ruinAge).toBe(plain.ruinAge); + for (let i = 0; i < plain.phases.length; i++) { + expect(explained.phases[i].endWealthNominal).toBe(plain.phases[i].endWealthNominal); + expect(explained.phases[i].cashEnd).toBe(plain.phases[i].cashEnd); + } + }); + + it("liefert Rechenwege auf Element-, Phasen- und Plan-Ebene", () => { + const c = computePlan(richPlan(), undefined, { explain: true }); + expect((c.traces ?? []).length).toBeGreaterThan(0); + expect((c.phases[0].traces ?? []).length).toBeGreaterThan(0); + + const withTrace = c.phases[0].elements.filter((e) => e.trace); + // Einkommen, Ausgaben, PK, 3a, Immobilie, Vermoegen, Schuld + expect(withTrace.length).toBeGreaterThanOrEqual(7); + + const withTxTrace = c.phases[0].elements.filter((e) => e.transitionTrace); + expect(withTxTrace.length).toBeGreaterThan(0); + }); + + it("setzt in jedem Schritt eingesetzte Zahlen statt nur einer abstrakten Formel", () => { + const c = computePlan(richPlan(), undefined, { explain: true }); + const inc = c.phases[0].elements.find((e) => e.elementId === "inc")!; + const endStep = inc.trace!.steps.find((s) => s.label.startsWith("Einkommen im letzten"))!; + expect(endStep.formula).toContain("Lohnerhöhung"); + expect(endStep.substituted).toContain("120'000"); + expect(endStep.result).toBe(inc.endValue); + }); + + // Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Ueberschrift umbenannt, + // zeigen die Verweise ins Leere -- und zwar still. Dieser Test faengt das ab. + it("alle Spezifikations-Verweise treffen eine existierende Ueberschrift", () => { + const md = readFileSync(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8"); + // Slug-Regel von github-slugger (das rehype-slug verwendet) nachgebildet. + const slug = (s: string) => + s + .toLowerCase() + .trim() + .replace(/[^\p{L}\p{N}\p{M}\p{Pc}\- ]/gu, "") + .replace(/ /g, "-"); + const headings = new Set(); + for (const line of md.split("\n")) { + const m = line.match(/^#{1,6}\s+(.*)$/); + if (m) headings.add(slug(m[1].replace(/[*`]/g, ""))); + } + + const c = computePlan(richPlan(), undefined, { explain: true }); + const traces: Trace[] = [ + ...(c.traces ?? []), + ...c.phases.flatMap((p) => p.traces ?? []), + ...c.phases.flatMap((p) => p.elements.flatMap((e) => [e.trace, e.transitionTrace])).filter((t): t is Trace => !!t), + ]; + const anchors = [...new Set(traces.map((t) => t.specAnchor).filter((a): a is string => !!a))]; + expect(anchors.length).toBeGreaterThan(5); + const missing = anchors.filter((a) => !headings.has(a)); + expect(missing).toEqual([]); + }); +});