Detailansichten, Wasserfaelle und vollstaendige Rechenweg-Offenlegung
Deploy App / deploy (push) Successful in 57s
Deploy App / deploy (push) Successful in 57s
Roadmap Nr. 43 (Detailansichten) und Nr. 41 (Berechnungslogiken offenlegen).
Systemparameter-Ansicht:
- SYSTEM_PARAMETERS in constants.ts: Wert, Bedeutung, Herleitung, Quelle,
Stand -- aus derselben Datei, aus der gerechnet wird
Detailansichten (nur lesen, per Expand-Icon):
- Element: Verlauf ueber ALLE Planjahre (neu ElementPhaseComputed.yearly),
bei Immobilien Verkehrswert/Restschuld/Eigenkapital getrennt
- Phase: Vermoegensaufteilung + zwei Wasserfaelle
Zwei getrennte Wasserfaelle (WealthBridge / CashBridge):
- Sparraten, Amortisationen und Investitionen sind UMBUCHUNGEN und erscheinen
nur im Cash-Wasserfall -- als Vermoegensabgang gezeichnet wuerden sie einen
Verlust vortaeuschen, den es nicht gibt
- PK-Beitraege dagegen sind ein echter Vermoegenszugang (belasten kein Cash),
Verrentung ein echter Abgang (Kapital verlaesst die Bilanz)
- residual als Kontrollgroesse fuer die Vollstaendigkeit der Zerlegung
Rechenweg-Protokoll, vollstaendige Abdeckung:
- computePlan(plan, sample?, { explain }) protokolliert die Schritte, die es
ohnehin ausfuehrt -- die Erklaerung IST die Rechnung, statt einer zweiten
Formel-Implementierung im UI, die still abdriften koennte
- standardmaessig aus (Monte Carlo bleibt unberuehrt)
- Arithmetik nicht umgestellt: Zwischengroessen werden als Differenz
abgeleitet, damit die 43 Golden Tests bitgleich bleiben
- jeder Trace verlinkt in die SPEZIFIKATION; ein Test prueft gegen die echte
Datei, dass alle Verweise eine existierende Ueberschrift treffen
SPEZIFIKATION auf 0.11: neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21.
12 Tests ergaenzt (80 -> 92). Keine DB-Aenderung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+222
-6
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.10 |
|
| **Version** | 0.11 |
|
||||||
| **Datum** | 2026-07-18 |
|
| **Datum** | 2026-07-18 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **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 |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| Version | Datum | Autor | Änderung |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 0.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.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.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. |
|
| 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`.
|
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 Bedienoberfläche
|
||||||
|
|
||||||
### 3.7.1 Layout
|
### 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`.
|
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
|
# 5. Technische Spezifikation
|
||||||
@@ -1804,7 +1967,7 @@ FPT/
|
|||||||
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
|
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
|
||||||
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
|
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
|
||||||
│ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes)
|
│ │ └── 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)
|
│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren)
|
||||||
│ ├── lib/ Domänenlogik (siehe 5.3)
|
│ ├── lib/ Domänenlogik (siehe 5.3)
|
||||||
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
|
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
|
||||||
@@ -1837,6 +2000,7 @@ PlanComputed ← an den Client geliefert
|
|||||||
| `constants.ts` | Schweizer Systemparameter |
|
| `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. |
|
| `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. |
|
| `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) |
|
| `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) |
|
||||||
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
|
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
|
||||||
| `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) |
|
| `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 |
|
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
|
||||||
| `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
|
| `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 |
|
| `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 |
|
| `InfoBubble` | 28 | Hilfe-Tooltip |
|
||||||
|
|
||||||
### 5.5.3 Wiederverwendungsmuster
|
### 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" |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
||||||
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
||||||
| **Total** | **80** | |
|
| **Total** | **92** | |
|
||||||
|
|
||||||
## 8.2 Testfälle
|
## 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: 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: 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 |
|
| **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 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 |
|
| **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 |
|
| 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,
|
Bei der Monte-Carlo-Simulation über mehrere Szenarien fällt das stärker ins Gewicht als zuvor,
|
||||||
weil dort mehr Eingaben zusammenkommen.
|
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
|
# 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 |
|
| **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)) |
|
| **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 |
|
| **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 |
|
| **Grundprofil** | Haushaltsform, Personen (Alter, Pensionsalter, Name), Inflationsannahme |
|
||||||
| **Lebensphase** | Zeitabschnitt mit fester Dauer; darf keine Pensionierung überspannen |
|
| **Lebensphase** | Zeitabschnitt mit fester Dauer; darf keine Pensionierung überspannen |
|
||||||
| **Phasentyp** | `ERWERB` / `PENSION` / `MIXED`; abgeleitet, nie gespeichert |
|
| **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*
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
Copy,
|
Copy,
|
||||||
Dices,
|
Dices,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Plus,
|
Plus,
|
||||||
|
SlidersHorizontal,
|
||||||
Tornado,
|
Tornado,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
@@ -21,6 +23,9 @@ import { Dashboard } from "@/components/Dashboard";
|
|||||||
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
||||||
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
||||||
import { SpecView } from "@/components/SpecView";
|
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 { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
@@ -47,6 +52,17 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
const [showCharts, setShowCharts] = useState(false);
|
const [showCharts, setShowCharts] = useState(false);
|
||||||
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
|
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
|
||||||
const [showSensitivity, setShowSensitivity] = 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<string | null>(null);
|
||||||
|
|
||||||
|
function openSpecAt(anchor: string) {
|
||||||
|
setSpecAnchor(anchor);
|
||||||
|
setShowSpec(true);
|
||||||
|
setShowSystemParams(false);
|
||||||
|
setSelectedScenarioId(null);
|
||||||
|
}
|
||||||
|
|
||||||
const loadPlans = useCallback(async () => {
|
const loadPlans = useCallback(async () => {
|
||||||
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
||||||
@@ -86,6 +102,7 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
function openScenario(id: string) {
|
function openScenario(id: string) {
|
||||||
setSelectedScenarioId(id);
|
setSelectedScenarioId(id);
|
||||||
setShowSpec(false);
|
setShowSpec(false);
|
||||||
|
setShowSystemParams(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,10 +144,11 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedScenarioId(null);
|
setSelectedScenarioId(null);
|
||||||
setShowSpec(false);
|
setShowSpec(false);
|
||||||
|
setShowSystemParams(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
@@ -176,11 +194,28 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="mt-4 border-t border-border pt-3">
|
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSystemParams(true);
|
||||||
|
setShowSpec(false);
|
||||||
|
setSelectedScenarioId(null);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
||||||
|
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4 shrink-0" />
|
||||||
|
Systemparameter
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSpec(true);
|
setShowSpec(true);
|
||||||
|
setSpecAnchor(null);
|
||||||
|
setShowSystemParams(false);
|
||||||
setSelectedScenarioId(null);
|
setSelectedScenarioId(null);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -230,7 +265,9 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
|
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
|
||||||
{showSpec
|
{showSystemParams
|
||||||
|
? "Systemparameter"
|
||||||
|
: showSpec
|
||||||
? "Spezifikation"
|
? "Spezifikation"
|
||||||
: detail
|
: detail
|
||||||
? `${detail.meta.planName} · ${detail.meta.name}`
|
? `${detail.meta.planName} · ${detail.meta.name}`
|
||||||
@@ -240,11 +277,13 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 px-4 py-6 lg:px-8">
|
<main className="flex-1 px-4 py-6 lg:px-8">
|
||||||
{showSpec && <SpecView />}
|
{showSystemParams && <SystemParametersView />}
|
||||||
|
|
||||||
{!showSpec && loading && <p className="text-sm text-muted">Laedt…</p>}
|
{showSpec && <SpecView anchor={specAnchor} />}
|
||||||
|
|
||||||
{!showSpec && !loading && selectedScenarioId === null && (
|
{!showSpec && !showSystemParams && loading && <p className="text-sm text-muted">Laedt…</p>}
|
||||||
|
|
||||||
|
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
|
||||||
<DashboardHome
|
<DashboardHome
|
||||||
username={username}
|
username={username}
|
||||||
plans={plans}
|
plans={plans}
|
||||||
@@ -254,7 +293,7 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!showSpec && !loading && detail && selectedScenarioId && (
|
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -301,6 +340,15 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<Tornado className="h-4 w-4" />
|
<Tornado className="h-4 w-4" />
|
||||||
Einflussfaktoren berechnen
|
Einflussfaktoren berechnen
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPlanTraces(true)}
|
||||||
|
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
Rechenwege
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{diff && detail.base && (
|
{diff && detail.base && (
|
||||||
@@ -313,7 +361,13 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlanView plan={detail.plan} computed={detail.computed} diff={diff} onChanged={refreshCurrent} />
|
<PlanView
|
||||||
|
plan={detail.plan}
|
||||||
|
computed={detail.computed}
|
||||||
|
diff={diff}
|
||||||
|
onChanged={refreshCurrent}
|
||||||
|
onOpenSpec={openSpecAt}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
@@ -358,6 +412,14 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
|
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPlanTraces && detail && (
|
||||||
|
<PlanTraceDialog
|
||||||
|
computed={computePlan(detail.plan, undefined, { explain: true })}
|
||||||
|
onClose={() => setShowPlanTraces(false)}
|
||||||
|
onOpenSpec={openSpecAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{copyFrom && (
|
{copyFrom && (
|
||||||
<CopyScenarioDialog
|
<CopyScenarioDialog
|
||||||
source={copyFrom}
|
source={copyFrom}
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { BookOpen, X } from "lucide-react";
|
||||||
|
import { formatChf } from "@/lib/format";
|
||||||
|
import { CATEGORY_LABELS } from "@/lib/elements";
|
||||||
|
import type { ElementCategory } from "@/lib/elements";
|
||||||
|
import type {
|
||||||
|
CashBridge,
|
||||||
|
ElementYearPoint,
|
||||||
|
PhaseComputed,
|
||||||
|
PlanComputed,
|
||||||
|
Trace,
|
||||||
|
TraceStep,
|
||||||
|
WealthBridge,
|
||||||
|
} from "@/lib/calculations";
|
||||||
|
|
||||||
|
// --- Wasserfall ---------------------------------------------------------------------------
|
||||||
|
// Recharts kennt keinen Wasserfall: Er entsteht aus zwei gestapelten Balken -- einem
|
||||||
|
// unsichtbaren Sockel und dem sichtbaren Delta darueber.
|
||||||
|
|
||||||
|
interface WaterfallItem {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
total?: boolean; // Zwischen-/Endsumme: startet bei 0 statt beim laufenden Saldo
|
||||||
|
}
|
||||||
|
|
||||||
|
function waterfallData(items: WaterfallItem[]) {
|
||||||
|
let running = 0;
|
||||||
|
return items.map((it) => {
|
||||||
|
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 (
|
||||||
|
<div className="w-full" style={{ height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 60 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-32} textAnchor="end" height={70} />
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "var(--surface-2)" }}
|
||||||
|
formatter={(_v, _n, p) => [formatChf(p?.payload?.value ?? 0), p?.payload?.label ?? ""]}
|
||||||
|
labelFormatter={() => ""}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="base" stackId="w" fill="transparent" isAnimationActive={false} />
|
||||||
|
<Bar dataKey="delta" stackId="w" isAnimationActive={false} radius={[2, 2, 0, 0]}>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<Cell key={i} fill={WF_COLOR[d.kind]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr className="border-t border-border align-top">
|
||||||
|
<td className="px-3 py-1.5 text-fg">{step.label}</td>
|
||||||
|
<td className="px-3 py-1.5 text-[11px] text-muted">
|
||||||
|
{step.formula && <div className="font-mono">{step.formula}</div>}
|
||||||
|
{step.substituted && unit !== "" && <div className="font-mono text-faint">{step.substituted}</div>}
|
||||||
|
{step.note && <div className="mt-0.5 italic text-faint">{step.note}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-1.5 text-right font-medium text-fg">{value}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraceBlock({ trace, onOpenSpec }: { trace: Trace; onOpenSpec?: (anchor: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface-2 px-3 py-2">
|
||||||
|
<span className="text-xs font-semibold text-fg">{trace.title}</span>
|
||||||
|
{trace.specAnchor && onOpenSpec && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenSpec(trace.specAnchor!)}
|
||||||
|
className="flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-muted hover:bg-surface"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-3 w-3" /> in der Spezifikation
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<tbody>
|
||||||
|
{trace.steps.map((s, i) => (
|
||||||
|
<TraceStepRow key={i} step={s} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
|
||||||
|
{subtitle && <p className="text-xs text-muted">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex w-fit rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(t.key)}
|
||||||
|
className={`rounded-md px-3 py-1 font-medium ${
|
||||||
|
current?.key === t.key ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">{current?.content}</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-faint">Nur-Lese-Ansicht. Werte werden hier nicht verändert.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 ? (
|
||||||
|
<p className="text-sm text-muted">Für dieses Element gibt es in diesem Plan keinen Verlauf.</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-80 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||||
|
<Tooltip formatter={(v, n) => [typeof v === "number" ? formatChf(v) : v, n]} labelFormatter={(v) => `Alter ${v}`} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Line dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
{MULTI_SERIES.includes(category) && (
|
||||||
|
<>
|
||||||
|
<Line dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||||
|
<Line dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-border">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-surface-2 text-xs text-faint">
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">Lebensphase</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Beginn</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Ende</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">Hinweis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{perPhase.map(({ phase, ec }) => (
|
||||||
|
<tr key={phase.id} className="border-t border-border">
|
||||||
|
<td className="px-3 py-2 text-fg">{phase.name}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.startValue)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.endValue)}</td>
|
||||||
|
<td className="px-3 py-2 text-[11px] text-faint">{ec!.note ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rechenweg = (
|
||||||
|
<>
|
||||||
|
{perPhase.every(({ ec }) => !ec!.trace && !ec!.transitionTrace) && (
|
||||||
|
<p className="text-sm text-muted">Für dieses Element gibt es keinen eigenen Rechenweg.</p>
|
||||||
|
)}
|
||||||
|
{perPhase.map(({ phase, ec }) => (
|
||||||
|
<div key={phase.id} className="flex flex-col gap-2">
|
||||||
|
{(ec!.trace || ec!.transitionTrace) && (
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{phase.name}</div>
|
||||||
|
)}
|
||||||
|
{ec!.trace && <TraceBlock trace={ec!.trace} onOpenSpec={onOpenSpec} />}
|
||||||
|
{ec!.transitionTrace && <TraceBlock trace={ec!.transitionTrace} onOpenSpec={onOpenSpec} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailShell
|
||||||
|
title={name}
|
||||||
|
subtitle={CATEGORY_LABELS[category]}
|
||||||
|
onClose={onClose}
|
||||||
|
tabs={[
|
||||||
|
{ key: "verlauf", label: "Verlauf", content: verlauf },
|
||||||
|
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 = (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
|
||||||
|
<Kpi label="Dauer" text={`${phase.durationYears} Jahre`} />
|
||||||
|
<Kpi label="Vermögen Beginn" text={formatChf(phase.startWealthNominal)} />
|
||||||
|
<Kpi label="Vermögen Ende" text={formatChf(phase.endWealthNominal)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-1 text-xs font-semibold text-fg">Vermögensaufteilung</h3>
|
||||||
|
<p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens zu Beginn und am Ende dieser Phase.</p>
|
||||||
|
{allocEls.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">In dieser Phase gibt es kein Anlagevermögen.</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-56 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={alloc} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||||
|
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
{allocEls.map((el, i) => (
|
||||||
|
<Bar key={el.elementId} dataKey={el.elementId} name={el.name} stackId="a" fill={ALLOC_PALETTE[i % ALLOC_PALETTE.length]} isAnimationActive={false} />
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const wasserfall = (
|
||||||
|
<>
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-1 text-xs font-semibold text-fg">Woher kommt die Vermögensänderung?</h3>
|
||||||
|
<p className="mb-2 text-[11px] text-muted">
|
||||||
|
Nur <strong>echte</strong> Zu- und Abgänge. Sparraten, Amortisationen und Zusatzinvestitionen erscheinen hier
|
||||||
|
bewusst <strong>nicht</strong>: 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.
|
||||||
|
</p>
|
||||||
|
<Waterfall items={bridgeItems(phase.wealthBridge, isFirst)} />
|
||||||
|
{Math.abs(phase.wealthBridge.residual) > 2 && (
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Rundungsdifferenz: {formatChf(phase.wealthBridge.residual)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-1 text-xs font-semibold text-fg">Wohin ist das Cash geflossen?</h3>
|
||||||
|
<p className="mb-2 text-[11px] text-muted">
|
||||||
|
Hier erscheinen die Umbuchungen: Sparraten, Amortisationen und Investitionen verlassen das Cash-Konto, auch
|
||||||
|
wenn sie das Vermögen nicht mindern.
|
||||||
|
</p>
|
||||||
|
<Waterfall items={cashItems(phase.cashBridge, isFirst)} />
|
||||||
|
{Math.abs(phase.cashBridge.residual) > 2 && (
|
||||||
|
<p className="text-[11px] text-faint">Rundungsdifferenz: {formatChf(phase.cashBridge.residual)}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rechenweg = (
|
||||||
|
<>
|
||||||
|
{(phase.traces ?? []).length === 0 && <p className="text-sm text-muted">Kein Rechenweg verfügbar.</p>}
|
||||||
|
{(phase.traces ?? []).map((t, i) => (
|
||||||
|
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailShell
|
||||||
|
title={phase.name}
|
||||||
|
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
|
||||||
|
onClose={onClose}
|
||||||
|
tabs={[
|
||||||
|
{ key: "uebersicht", label: "Übersicht", content: uebersicht },
|
||||||
|
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
|
||||||
|
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kpi({ label, text }: { label: string; text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
|
||||||
|
<div className="text-[11px] text-muted">{label}</div>
|
||||||
|
<div className="text-sm font-semibold text-fg">{text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plan-weite Rechenwege (Deflatoren, AHV-Karriere, Ruinalter) ---------------------------
|
||||||
|
|
||||||
|
export function PlanTraceDialog({
|
||||||
|
computed,
|
||||||
|
onClose,
|
||||||
|
onOpenSpec,
|
||||||
|
}: {
|
||||||
|
computed: PlanComputed;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpenSpec?: (anchor: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DetailShell
|
||||||
|
title="Rechenwege dieses Szenarios"
|
||||||
|
subtitle="Plan-weite Grössen, die in alle Phasen hineinwirken"
|
||||||
|
onClose={onClose}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
key: "plan",
|
||||||
|
label: "Plan-Ebene",
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
{(computed.traces ?? []).map((t, i) => (
|
||||||
|
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Home,
|
Home,
|
||||||
Landmark,
|
Landmark,
|
||||||
|
Maximize2,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Plus,
|
Plus,
|
||||||
Settings2,
|
Settings2,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Timeline } from "@/components/Timeline";
|
import { Timeline } from "@/components/Timeline";
|
||||||
|
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||||
import {
|
import {
|
||||||
CashTransitionFields,
|
CashTransitionFields,
|
||||||
ElementDetail,
|
ElementDetail,
|
||||||
@@ -46,7 +48,7 @@ import {
|
|||||||
type PhaseData,
|
type PhaseData,
|
||||||
type TransitionData,
|
type TransitionData,
|
||||||
} from "@/lib/elements";
|
} 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 { ScenarioDiff } from "@/lib/diff";
|
||||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
@@ -92,12 +94,14 @@ export function PlanView({
|
|||||||
computed,
|
computed,
|
||||||
diff,
|
diff,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
onOpenSpec,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
|
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
|
||||||
diff: ScenarioDiff | null;
|
diff: ScenarioDiff | null;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
|
onOpenSpec?: (anchor: string) => void;
|
||||||
}) {
|
}) {
|
||||||
// Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau.
|
// Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau.
|
||||||
const cellDiff = (elementId: string, phaseId: string) =>
|
const cellDiff = (elementId: string, phaseId: string) =>
|
||||||
@@ -124,6 +128,16 @@ export function PlanView({
|
|||||||
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
|
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
|
||||||
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
|
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
|
||||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
const [valueMode, setValueMode] = useState<ValueMode>("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(() => {
|
useEffect(() => {
|
||||||
const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null;
|
const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null;
|
||||||
@@ -405,6 +419,7 @@ export function PlanView({
|
|||||||
mode={valueMode}
|
mode={valueMode}
|
||||||
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
|
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
|
||||||
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
|
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}
|
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -499,12 +514,24 @@ export function PlanView({
|
|||||||
{!collapsed &&
|
{!collapsed &&
|
||||||
els.map((el) => (
|
els.map((el) => (
|
||||||
<tr key={el.id} className="hover:bg-surface-2">
|
<tr key={el.id} className="hover:bg-surface-2">
|
||||||
<td className={`sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
|
<td className={`group/row sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
|
||||||
<div className="flex items-center gap-1 truncate text-xs font-medium text-fg">
|
<div className="flex items-center gap-1 text-xs font-medium text-fg">
|
||||||
{el.name}
|
<span className="min-w-0 flex-1 truncate">{el.name}</span>
|
||||||
{diff?.elementRow.get(el.id) === "added" && (
|
{diff?.elementRow.get(el.id) === "added" && (
|
||||||
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Detailansicht ${el.name}`}
|
||||||
|
title="Detailansicht (nur lesen)"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDetailFor({ kind: "element", id: el.id });
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/row:opacity-100"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||||
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
|
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
|
||||||
@@ -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 (
|
||||||
|
<ElementDetailDialog
|
||||||
|
key={`det-${detailFor.id}`}
|
||||||
|
elementId={el.id}
|
||||||
|
name={el.name}
|
||||||
|
category={el.category}
|
||||||
|
computed={explained}
|
||||||
|
onClose={() => setDetailFor(null)}
|
||||||
|
onOpenSpec={onOpenSpec}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{detailFor?.kind === "phase" && explained && (() => {
|
||||||
|
const ph = explained.phases.find((p) => p.id === detailFor.id);
|
||||||
|
if (!ph) return null;
|
||||||
|
return (
|
||||||
|
<PhaseDetailDialog
|
||||||
|
key={`detph-${detailFor.id}`}
|
||||||
|
phase={ph}
|
||||||
|
isFirst={ph.sequenceNumber === 1}
|
||||||
|
onClose={() => setDetailFor(null)}
|
||||||
|
onOpenSpec={onOpenSpec}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{editPhaseCell && (() => {
|
{editPhaseCell && (() => {
|
||||||
const element = plan.elements.find((e) => e.id === editPhaseCell.elementId);
|
const element = plan.elements.find((e) => e.id === editPhaseCell.elementId);
|
||||||
const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId);
|
const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId);
|
||||||
@@ -854,6 +911,7 @@ function PhaseHeader({
|
|||||||
mode,
|
mode,
|
||||||
diffKind,
|
diffKind,
|
||||||
onClick,
|
onClick,
|
||||||
|
onExpand,
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
phase: PhaseComputed;
|
phase: PhaseComputed;
|
||||||
@@ -861,6 +919,7 @@ function PhaseHeader({
|
|||||||
mode: ValueMode;
|
mode: ValueMode;
|
||||||
diffKind: "changed" | "added" | "removed" | null;
|
diffKind: "changed" | "added" | "removed" | null;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onExpand: () => void;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}) {
|
}) {
|
||||||
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
|
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
|
||||||
@@ -870,7 +929,7 @@ function PhaseHeader({
|
|||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
className={`group/ph min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||||
active
|
active
|
||||||
? "bg-accent-soft"
|
? "bg-accent-soft"
|
||||||
: diffKind === "added"
|
: diffKind === "added"
|
||||||
@@ -881,7 +940,7 @@ function PhaseHeader({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
|
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{phase.name}</span>
|
||||||
{diffKind === "added" && (
|
{diffKind === "added" && (
|
||||||
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
||||||
)}
|
)}
|
||||||
@@ -890,6 +949,20 @@ function PhaseHeader({
|
|||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||||
)}
|
)}
|
||||||
|
{/* Der Kopf oeffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event
|
||||||
|
deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Detailansicht ${phase.name}`}
|
||||||
|
title="Detailansicht (nur lesen)"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onExpand();
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/ph:opacity-100"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||||
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
|
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { api } from "@/lib/api-client";
|
|||||||
|
|
||||||
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
|
// 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.
|
// 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<string | null>(null);
|
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -20,6 +20,14 @@ export function SpecView() {
|
|||||||
.catch((e) => setError(e instanceof Error ? e.message : "Laden fehlgeschlagen."));
|
.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 <p className="text-sm text-danger">{error}</p>;
|
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||||
if (markdown === null) return <p className="text-sm text-muted">Laedt…</p>;
|
if (markdown === null) return <p className="text-sm text-muted">Laedt…</p>;
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SlidersHorizontal className="h-5 w-5 text-accent" />
|
||||||
|
<h2 className="text-lg font-semibold text-fg">Systemparameter</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="max-w-3xl text-sm text-muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{GROUPS.map((g) => {
|
||||||
|
const rows = SYSTEM_PARAMETERS.filter((p) => p.group === g.key);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section key={g.key} className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-fg">{g.title}</h3>
|
||||||
|
<p className="mb-3 mt-0.5 text-xs text-muted">{g.intro}</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{rows.map((p) => (
|
||||||
|
<div key={p.key} className="rounded-lg border border-border bg-surface-2 p-3">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-fg">{p.label}</span>
|
||||||
|
<span className="text-sm font-semibold text-accent">{formatValue(p)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted">{p.meaning}</p>
|
||||||
|
{p.derivation && (
|
||||||
|
<p className="mt-1 font-mono text-[11px] text-faint">Herleitung: {p.derivation}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-faint">
|
||||||
|
<span>Quelle: {p.source}</span>
|
||||||
|
<span>Stand: {p.validFrom}</span>
|
||||||
|
{p.editablePerEvent && <span className="text-accent">pro Ereignis überschreibbar</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<p className="max-w-3xl text-xs text-faint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+679
-12
@@ -26,6 +26,44 @@ export interface PersonPhaseInfo {
|
|||||||
retiresAtStart: boolean;
|
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 {
|
export interface ElementPhaseComputed {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
category: ElementCategory;
|
category: ElementCategory;
|
||||||
@@ -41,6 +79,51 @@ export interface ElementPhaseComputed {
|
|||||||
endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
|
endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
|
||||||
summary: string;
|
summary: string;
|
||||||
note: string | null;
|
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 {
|
export interface PhaseComputed {
|
||||||
@@ -85,6 +168,9 @@ export interface PhaseComputed {
|
|||||||
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
|
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
|
||||||
flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`)
|
flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`)
|
||||||
endWealthReal: number;
|
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.
|
// Ein Datenpunkt pro Jahr (ueber alle Phasen), fuer die Verlaufsgrafik.
|
||||||
@@ -106,6 +192,7 @@ export interface PlanComputed {
|
|||||||
nachlass: number;
|
nachlass: number;
|
||||||
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
|
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
|
||||||
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (fuer die AHV-Pruefung am Uebergang)
|
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) ---------------------------------------------------------
|
// --- AHV-Rentenformel (Skala 44) ---------------------------------------------------------
|
||||||
@@ -223,7 +310,23 @@ export interface PlanSample {
|
|||||||
assetReturn: (elementId: string, year: number) => number;
|
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 phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||||
const persons = plan.persons;
|
const persons = plan.persons;
|
||||||
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
|
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 incomingOneOffInflowLabel: string | null = null;
|
||||||
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
|
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
|
||||||
let incomingOneOffOutflowLabel: string | null = null;
|
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;
|
let ruinAge: number | null = null;
|
||||||
|
|
||||||
for (let i = 0; i < phases.length; i++) {
|
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 incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||||
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
||||||
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
|
// 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
|
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der
|
||||||
// Liegenschaft (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis.
|
// Liegenschaft (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis.
|
||||||
@@ -387,6 +506,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
|
|||||||
endValue: 0,
|
endValue: 0,
|
||||||
summary: "",
|
summary: "",
|
||||||
note: null,
|
note: null,
|
||||||
|
yearly: [],
|
||||||
};
|
};
|
||||||
ecById.set(e.id, ec);
|
ecById.set(e.id, ec);
|
||||||
|
|
||||||
@@ -470,7 +590,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
|
|||||||
ec.baseValue = base;
|
ec.baseValue = base;
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
wealthStart += 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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -488,7 +608,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
|
|||||||
ec.baseValue = base;
|
ec.baseValue = base;
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
wealthStart += 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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -504,7 +624,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
|
|||||||
ec.baseValue = base;
|
ec.baseValue = base;
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
wealthStart += 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;
|
break;
|
||||||
}
|
}
|
||||||
case "REAL_ESTATE": {
|
case "REAL_ESTATE": {
|
||||||
@@ -570,6 +690,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
|
|||||||
let quotaStart = 0;
|
let quotaStart = 0;
|
||||||
let quotaEnd = 0;
|
let quotaEnd = 0;
|
||||||
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr
|
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++) {
|
for (let t = 1; t <= duration; t++) {
|
||||||
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
|
// 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;
|
let cashFromWithdraw = 0;
|
||||||
for (const a of assets) {
|
for (const a of assets) {
|
||||||
const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r;
|
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 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));
|
const w = Math.min(a.withdrawal, Math.max(0, grown));
|
||||||
a.value = grown - w;
|
a.value = grown - w;
|
||||||
cashFromWithdraw += 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
|
// 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
|
// 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.
|
// 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;
|
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) {
|
for (const d of debts) {
|
||||||
const pay = Math.min(d.repay, d.owed);
|
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;
|
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
|
||||||
if (cash < 0) cashNegative = true;
|
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).
|
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
|
||||||
let total = cash;
|
let total = cash;
|
||||||
for (const a of assets) total += a.value;
|
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";
|
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 cashEnd = Math.round(cash);
|
||||||
const startWealthNominal = Math.round(wealthStart + cashStart);
|
const startWealthNominal = Math.round(wealthStart + cashStart);
|
||||||
const endWealthNominal = Math.round(wealthEnd + cashEnd);
|
const endWealthNominal = Math.round(wealthEnd + cashEnd);
|
||||||
cumulativeInflation = cumInfl[yearsBefore + duration];
|
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({
|
result.push({
|
||||||
id: phase.id,
|
id: phase.id,
|
||||||
name: phase.name,
|
name: phase.name,
|
||||||
@@ -731,11 +1179,69 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
|
|||||||
cumulativeInflationEnd: cumulativeInflation,
|
cumulativeInflationEnd: cumulativeInflation,
|
||||||
flowDeflatorEnd,
|
flowDeflatorEnd,
|
||||||
endWealthReal: endWealthNominal / cumulativeInflation,
|
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 ---
|
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
|
||||||
let txInflow = 0;
|
let txInflow = 0;
|
||||||
let txImmediateRepay = 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
|
// Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase
|
||||||
// existiert -- nach der letzten Phase gibt es keinen Uebergang. Der Wechselkurs zwischen
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const txStepsBefore = txInflow;
|
||||||
|
const taxBefore = txTax;
|
||||||
|
|
||||||
switch (e.category) {
|
switch (e.category) {
|
||||||
case "PENSION_FUND": {
|
case "PENSION_FUND": {
|
||||||
if (ownerRetiresNext) {
|
if (ownerRetiresNext) {
|
||||||
const value = ec.endValue;
|
const value = ec.endValue;
|
||||||
const mode = td.payoutMode ?? "PENSION";
|
const mode = td.payoutMode ?? "PENSION";
|
||||||
if (mode === "CAPITAL") {
|
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.value = 0;
|
||||||
carry.pkPensionAnnual = 0;
|
carry.pkPensionAnnual = 0;
|
||||||
} else if (mode === "PENSION") {
|
} else if (mode === "PENSION") {
|
||||||
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
|
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
|
||||||
|
txPensionConversion += value;
|
||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
} else {
|
} else {
|
||||||
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
|
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);
|
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
|
||||||
|
txPensionConversion += value - capital;
|
||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} 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.
|
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
|
||||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||||
carry.value = ec.endValue - 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;
|
break;
|
||||||
}
|
}
|
||||||
case "PILLAR_3A": {
|
case "PILLAR_3A": {
|
||||||
if (ownerRetiresNext) {
|
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;
|
carry.value = 0;
|
||||||
} else {
|
} else {
|
||||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||||
carry.value = ec.endValue - 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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -858,6 +1379,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
|
|||||||
const gain = Math.max(0, salePrice - re.purchase);
|
const gain = Math.max(0, salePrice - re.purchase);
|
||||||
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
|
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
|
||||||
txInflow += Math.round(salePrice - re.mortgage - tax);
|
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";
|
carry.status = "SOLD";
|
||||||
} else {
|
} else {
|
||||||
// Sonderamortisation: Einmaltilgung der Hypothek aus dem Cash, am Restsaldo gekappt.
|
// Sonderamortisation: Einmaltilgung der Hypothek aus dem Cash, am Restsaldo gekappt.
|
||||||
@@ -883,6 +1408,76 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
|
|||||||
default:
|
default:
|
||||||
break;
|
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;
|
carry.hasCarry = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,12 +1488,84 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
|
|||||||
incomingOneOffInflowLabel = txOneOffInflowLabel;
|
incomingOneOffInflowLabel = txOneOffInflowLabel;
|
||||||
incomingOneOffOutflow = txOneOffOutflow;
|
incomingOneOffOutflow = txOneOffOutflow;
|
||||||
incomingOneOffOutflowLabel = txOneOffOutflowLabel;
|
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;
|
yearsBefore += duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
|
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
|
||||||
const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson));
|
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
|
// Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit
|
||||||
|
|||||||
@@ -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_PK_CONVERSION_RATE = 6; // % (jaehrliche Rente = Kapital x Satz)
|
||||||
export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug)
|
export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug)
|
||||||
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstueckgewinnsteuer
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user