Detailansichten, Wasserfaelle und vollstaendige Rechenweg-Offenlegung
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:
2026-07-18 17:12:55 +02:00
parent 1836cad7f3
commit 4791dccf93
9 changed files with 2066 additions and 33 deletions
+222 -6
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.10 |
| **Version** | 0.11 |
| **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `d203e50` inkl. Szenario-Vergleich in der Monte-Carlo-Simulation und Sensitivitätsanalyse (Branch `main`) |
| **Codestand** | Arbeitsstand nach `1836cad` inkl. Detailansichten, Wasserfall-Zerlegungen und vollständiger Rechenweg-Offenlegung (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.11 | 2026-07-18 | Claude (Opus 4.8) | **Detailansichten (Roadmap Nr. 43)** und **vollständige Offenlegung der Berechnungslogiken (Roadmap Nr. 41)**. (1) Neue **Systemparameter-Ansicht** in der Seitenleiste: alle fest hinterlegten Grössen mit Wert, Bedeutung, Herleitung, Quelle und Stand als strukturierte Daten aus `constants.ts`, also aus derselben Quelle, aus der gerechnet wird. (2) **Nur-Lese-Detailansicht** je Element und je Lebensphase über ein Expand-Icon: Element mit Verlaufsgrafik über **alle Planjahre** (dafür führt `computePlan` neu `ElementPhaseComputed.yearly` je Element mit), Phase mit Vermögensaufteilung und **zwei Wasserfällen**. (3) Die **Wasserfälle** sind bewusst getrennt: Der Vermögens-Wasserfall zeigt nur echte Zu- und Abgänge (Quote, Kapitalerträge, Wertsteigerung, PK-Beiträge, Steuern, Verrentung, Einmalposten); Sparraten, Amortisationen und Investitionen sind **Umbuchungen** und erscheinen ausschliesslich im Cash-Wasserfall als Vermögensabgang gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. Neue Strukturen `WealthBridge` / `CashBridge` inkl. Restposten als Kontrollgrösse. (4) **Rechenweg-Protokoll**: `computePlan(plan, sample?, { explain })` protokolliert die Schritte, die es ohnehin ausführt Formel, eingesetzte Zahlen, Ergebnis und Hinweis auf geltende Vereinfachungen. Abdeckung über **alle** Ebenen (Element je Phase, Element je Übergang, Phasen-Kennzahlen, Plan-Ebene). Standardmässig aus, damit die Monte-Carlo-Simulation unberührt bleibt. Jeder Rechenweg verlinkt in das passende Kapitel dieser Spezifikation; ein Test prüft, dass alle Verweise eine existierende Überschrift treffen. Neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21; 12 Tests ergänzt (80 → 92). Keine DB-Änderung; die 43 Golden Tests laufen unverändert. |
| 0.10 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Vergleich in der Monte-Carlo-Simulation** und **Sensitivitätsanalyse / Tornado** (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu **mehrere Szenarien desselben Plans in einem Lauf**. Die historischen Annahmen werden dabei nur **einmal je logischem Element** erfasst die Zuordnung über die Herkunfts-Kette `sourceElementId`, dieselbe Grundlage wie beim Diff (neue Funktionen `resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`). Alle Szenarien laufen mit **demselben Seed** (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der **Zielbetrag bleibt szenario-eigen** (vorbelegt mit dem jeweils geplanten Endvermögen) nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein *eigenes* Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich **Einflussfaktoren** (eigener Button, eigener Dialog) mit einem **Tornado-Chart** nach dem One-at-a-time-Verfahren: neues reines Modul `sensitivity.ts` mit sieben Treibern, je Treiber an-/abwählbar und mit **pflichtiger, frei definierbarer Bandbreite ohne Default**. Das **Pensionsalter ist bewusst nicht enthalten** (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. **Ausserdem vier Dokumentationsfehler korrigiert:** Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. |
| 0.9 | 2026-07-18 | Claude (Opus 4.8) | **UI-Umbau und Planstart.** (1) Die Grafiken liegen neu im eigenen Bereich **Grafiken** (Dialog, Button oben) statt unter der Matrix. (2) Kennzahl **Geschätzter Nachlass** entfernt sie war identisch mit dem nominalen Endvermögen. (3) **Monte-Carlo-Button** nach oben zu den Szenario-Aktionen verschoben. (4) Neues Profilfeld **Planstart (Jahr)** (`Scenario.startYear`, Migration; reine Anzeige, Berechnung bleibt in relativen Jahren) erscheint auf der Zeitachse. (5) Zeitachse zeigt neu die **Lebensphasen als Segmente** (Breite = Dauer, Einfärbung nach Phasentyp, Jahresspanne). (6) **Lebensphase bearbeiten** neu als Popup statt Panel unter der Tabelle. (7) **Vermögensverlauf** über **alle Jahre** statt nur über die Phasengrenzen dafür führt `computePlan` das Vermögen neu pro Jahr mit (`YearPoint.wealthNominal/wealthReal`). Zwei Tests ergänzt (58 → 60). |
| 0.8 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Hierarchie (V6)** grösste Umstrukturierung bisher. Der **Plan** ist neu ein schlanker Behälter ohne Finanzdaten; die berechenbare Einheit ist das **Szenario**, das Grundprofil (inkl. **Pensionsalter** → Frühpensionierungs-Szenarien), Phasen und Elemente trägt. Jeder Plan erhält beim Anlegen automatisch ein **Basisszenario**; weitere Szenarien entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als **Baum** darunter (Sidebar zeigt die Verschachtelung). Kopierte Phasen/Elemente tragen Herkunfts-Verweise (`sourcePhaseId`, `sourceElementId`) darauf beruht die **Abweichungs-Markierung**: geändert = gelb, neu = grün, entfernt = graue Geisterzeile (eigene Theme-Tokens für alle drei Farbschemata). Charts vergleichen neu die Geschwister-Szenarien. Datenmodell: neue Tabelle `Plan`, bisheriger `Plan``Scenario` (IDs erhalten), `planId``scenarioId` in Person/Phase/FinancialElement. API neu unter `/api/scenarios/*`. Neue Kapitel 2.1, 3.2, 4.13; 9 Diff-Tests + Migrations-Test (48 → 58). **Migration mit echtem Postgres (PGlite) verifiziert**, inkl. verschachtelter Szenarien und Cascade. |
@@ -858,6 +859,47 @@ Tornado Pflichteingaben braucht. Ein Hinweis am Ende der Parameterliste benennt,
Referenz: `src/components/SensitivityDialog.tsx`, `src/components/AppShell.tsx`.
### 3.6.7 Detailansichten je Element und je Lebensphase
Jede Element-Zeile und jeder Phasenkopf trägt oben rechts ein **Expand-Icon** (erscheint beim
Überfahren). Es öffnet eine **Nur-Lese-Detailansicht** mit Reitern.
**Element** zwei Reiter:
| Reiter | Inhalt |
|---|---|
| Verlauf | Liniendiagramm über **alle Planjahre**, nicht nur die Phasengrenzen. Bei einer Immobilie drei Reihen (Eigenkapital, Verkehrswert, Restschuld). Darunter eine Tabelle Beginn/Ende je Lebensphase. |
| Rechenweg | Die Herleitung je Phase und je Übergang (siehe [4.14](#414-verlaufswerte-brücken-und-rechenwege)) |
**Lebensphase** drei Reiter:
| Reiter | Inhalt |
|---|---|
| Übersicht | Typ, Dauer, Vermögen Beginn/Ende; Vermögensaufteilung als gestapelter Balken (Beginn und Ende) |
| Wasserfall | Vermögens- und Cash-Wasserfall ([4.14.2](#4142-die-beiden-wasserfälle)) |
| Rechenweg | Quote, Spar-/Verzehrrate, Cash-Fortschreibung, Vermögen, Phasentyp, Cash-Übergang |
Der Phasenkopf reagiert bereits auf Klick (öffnet das Bearbeiten-Popup); das Expand-Icon stoppt
deshalb die Ereignis-Weitergabe, sonst gingen beide Dialoge gleichzeitig auf.
Zusätzlich öffnet der Button **Rechenwege** in der Aktionsleiste die **plan-weiten** Grössen:
Deflatoren, AHV-Beitragskarriere je Person und Ruinalter.
Referenz: `src/components/DetailView.tsx`, `src/components/PlanView.tsx`.
### 3.6.8 Systemparameter-Ansicht
Eigener Eintrag in der Seitenleiste (über der SPEZIFIKATION). Zeigt alle fest hinterlegten
Systemgrössen, gruppiert nach AHV, Vorsorge und Steuern, je mit **Wert, Bedeutung, Herleitung,
Quelle und Stand**.
Die Einträge stammen aus `SYSTEM_PARAMETERS` in `src/lib/constants.ts` **derselben Datei, aus
der die Berechnung liest**. Ein Auseinanderlaufen von angezeigtem und gerechnetem Wert ist damit
konstruktiv ausgeschlossen. Abgeleitete Grössen wie `AHV_MAX_ANNUAL_SINGLE` zeigen ihre
Herleitung (`2 × R0 × 13`) statt nur das Ergebnis.
Referenz: `src/components/SystemParametersView.tsx`, `src/lib/constants.ts`.
## 3.7 Bedienoberfläche
### 3.7.1 Layout
@@ -1764,6 +1806,127 @@ spannt deshalb über `min…max`; welche Eingabe zu welchem Ende gehört, zeigt
Referenz: `src/lib/sensitivity.ts`, `src/components/SensitivityDialog.tsx`.
## 4.14 Verlaufswerte, Brücken und Rechenwege
Dieses Kapitel beschreibt, was `computePlan` über die reinen Ergebniswerte hinaus mitführt
die Grundlage der Detailansichten (Roadmap Nr. 43) und der Transparenz-Offenlegung (Nr. 41).
### 4.14.1 Verlaufswerte je Element
`ElementPhaseComputed.yearly` hält einen Punkt **pro Phasenjahr** je Element (Jahr, Alter, Wert;
bei Immobilien zusätzlich Verkehrswert und Restschuld).
Der Grund ist derselbe wie beim Vermögensverlauf in Version 0.9: Nur Start- und Endwert zu kennen
reicht nicht. Eine Anlage mit 5 % über 20 Jahre wächst **konvex** eine Gerade zwischen den
Phasengrenzen wäre sichtbar falsch und würde den Zinseszins optisch unterschlagen. Der Punkt wird
am **Jahresende** erfasst, nach Verzinsung und Tilgung, konsistent zum `YearPoint`.
### 4.14.2 Die beiden Wasserfälle
Ein Wasserfall zerlegt eine Veränderung in ihre Summanden. Der entscheidende Punkt ist, **welche
Posten dazugehören** und hier liegt eine Falle:
> **Sparraten, Amortisationen und Zusatzinvestitionen sind Umbuchungen, keine Verluste.** Eine
> 3a-Einzahlung verlässt das Cash und erhöht im selben Zug das 3a-Guthaben; das Vermögen bleibt
> unverändert. Eine Amortisation senkt das Cash und die Hypothek das Eigenkapital steigt sogar.
> Als Abgang im Vermögens-Wasserfall gezeichnet würden diese Posten eine Vermögensminderung
> vortäuschen, die es nicht gibt.
Deshalb gibt es **zwei** Zerlegungen mit unterschiedlichen Fragestellungen:
**`WealthBridge` „Warum hat sich mein Vermögen so entwickelt?"**
```
Vermögen Ende Vorphase
+ einmaliger Zufluss einmalige Kosten ⎫
Steuern am Übergang (Kapitalbezug, Grundstückgewinn) ⎬ Vermögensänderungen
verrentetes PK-Kapital (verlässt die Bilanz) ⎪ AN der Phasengrenze
± Verkaufspreis minus Verkehrswert ⎭
= Vermögen zu Phasenbeginn
+ Spar-/Verzehrquote (Summe über alle Phasenjahre) ⎫
+ Kapitalerträge (PK, 3a, Sonstiges Vermögen) ⎬ INNERHALB der Phase
+ Wertsteigerung der Liegenschaft ⎪
+ PK-Beiträge ⎭
= Vermögen am Phasenende
```
Zwei Posten verdienen eine Erläuterung:
- **PK-Beiträge sind ein echter Zugang.** Sie belasten das Cash nicht (im Nettolohn bereits
abgezogen), erhöhen aber das Vorsorgekapital anders als 3a-Beiträge, die aus dem Cash fliessen
und deshalb reine Umbuchung sind.
- **Verrentetes PK-Kapital verlässt die Bilanz.** Bei der Verrentung wird Kapital in einen
Rentenstrom umgewandelt; der Saldo fällt auf 0. Ohne diesen Posten ginge die Brücke am
Pensions-Übergang nicht auf.
**`CashBridge` „Wohin ist mein Cash geflossen?"**
```
Cash Ende Vorphase (Phase 1: Cash-Anfangswert)
+ Kapitalzufluss + einmaliger Zufluss
Sofort-Tilgung/Sonderamortisation einmalige Kosten
Investitionen am Phasenanfang
= Cash zu Phasenbeginn
+ Spar-/Verzehrquote Sparraten Amortisationen/Tilgungen + Bezugsraten
= Cash am Phasenende
```
Beide Strukturen führen einen **Restposten** (`residual`) mit: die Differenz zwischen dem
gerechneten Endwert und der Summe der Summanden. Er entsteht nur durch die Rundung der einzelnen
Posten auf ganze Franken und liegt im einstelligen Bereich; ein grösserer Wert wäre ein Hinweis
auf eine unvollständige Zerlegung. Zwei Tests prüfen ihn über einen Plan, der alle Element-Arten
und Übergangs-Entscheide enthält.
### 4.14.3 Rechenweg-Protokoll
`computePlan(plan, sample?, { explain: true })` legt zu jedem Ergebnis die Herleitung ab:
```ts
interface TraceStep { label; formula?; substituted?; result; unit?; note? }
interface Trace { title; specAnchor?; steps: TraceStep[] }
```
`formula` ist die abstrakte Regel, `substituted` dieselbe Regel **mit den eingesetzten Zahlen**,
`note` benennt eine an dieser Stelle geltende Vereinfachung.
**Der Architekturentscheid dahinter ist der wichtigste Teil dieses Kapitels.** Die naheliegende
Alternative wäre, die Formeln im UI nachzurechnen und anzuzeigen. Das ergäbe eine **zweite
Implementierung jeder Formel** und damit die Möglichkeit, dass die angezeigte Herleitung still
von der tatsächlichen Rechnung abdriftet. Bei einem Tool, dessen Kernversprechen die rechnerische
Korrektheit ist, wäre das die gefährlichste Variante überhaupt: Ein Nutzer, der nachrechnet und
eine Abweichung findet, verliert mehr Vertrauen, als eine Black Box je gekostet hätte.
Deshalb entstehen die Schritte **innerhalb** von `computePlan`, als Nebenprodukt der Rechnung, die
ohnehin läuft. Die Erklärung *ist* die Rechnung.
Zwei Konsequenzen daraus:
- **Standardmässig aus.** Die Monte-Carlo-Simulation ruft `computePlan` zehntausendfach auf und
darf von der Protokollierung nichts merken. Ein Test prüft, dass `explain` die Ergebniswerte
nicht verschiebt.
- **Die Arithmetik wird nicht umgestellt.** Wo für das Protokoll eine Zwischengrösse gebraucht
wird (etwa der Renditeanteil eines Jahres), wird sie als **Differenz** abgeleitet statt die
Formel umzuformen `a × (1 + r)` und `a + a × r` sind in Gleitkomma-Arithmetik nicht bitgleich.
Die 43 Golden Tests laufen unverändert.
**Abdeckung.** Vollständig über alle Ebenen: Element je Phase (Einkommen, Ausgaben, PK, 3a,
Immobilie, Sonstiges Vermögen, Schulden, AHV-/PK-Renten), Element je Übergang (Bezugsarten,
Verkauf inkl. Grundstückgewinnsteuer, Teilverkauf, Sonderamortisation, Sofort-Tilgung),
Phasen-Kennzahlen (Quote, Spar-/Verzehrrate, Cash-Fortschreibung, Vermögen, Phasentyp,
Cash-Übergang) und Plan-Ebene (Deflatoren, AHV-Karriere je Person, Ruinalter).
**Verweis in die Spezifikation.** Jeder Trace trägt optional einen `specAnchor` auf das zugehörige
Kapitel dieses Dokuments; die App springt von dort in die eingebaute SPEZIFIKATION-Ansicht (die
Anker erzeugt `rehype-slug`). Ein Test liest `SPEZIFIKATION.md` und prüft, dass **jeder** Verweis
eine existierende Überschrift trifft sonst würden die Links bei einer Umbenennung still ins
Leere zeigen.
**Wo die Erklärung gerechnet wird.** Nicht auf dem Server: `computePlan` ist rein und läuft im
Browser (wie schon bei Monte Carlo und Sensitivitätsanalyse), und der Client hält den
`PlanInput` ohnehin. Die Detailansicht rechnet die erklärte Fassung beim Öffnen lokal kein
API-Umbau, keine grössere Antwort, keine Serverlast, und per Konstruktion identisch zum
Serverergebnis.
Referenz: `src/lib/calculations.ts`, `src/components/DetailView.tsx`.
---
# 5. Technische Spezifikation
@@ -1804,7 +1967,7 @@ FPT/
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
│ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes)
│ ├── components/ 15 React-Komponenten (alle "use client")
│ ├── components/ 17 React-Komponenten (alle "use client")
│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren)
│ ├── lib/ Domänenlogik (siehe 5.3)
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
@@ -1837,6 +2000,7 @@ PlanComputed ← an den Client geliefert
| `constants.ts` | Schweizer Systemparameter |
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`. Rein, läuft im Browser. |
| `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand Grundlage der Systemparameter-Ansicht |
| `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) |
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
| `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) |
@@ -2092,7 +2256,9 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
| `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
| `SensitivityDialog` | ~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle |
| `SpecView` | 50 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument |
| `DetailView` | ~470 | Detailansichten Element/Phase, Wasserfall-Darstellung, Rechenweg-Blöcke, plan-weite Rechenwege |
| `SystemParametersView` | ~90 | Systemparameter mit Wert, Bedeutung, Herleitung, Quelle und Stand |
| `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen |
| `InfoBubble` | 28 | Hilfe-Tooltip |
### 5.5.3 Wiederverwendungsmuster
@@ -2322,10 +2488,11 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|---|---|---|
| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" |
| `sensitivity.test.ts` | 14 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung |
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
| `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich |
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
| **Total** | **80** | |
| **Total** | **92** | |
## 8.2 Testfälle
@@ -2361,6 +2528,18 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| **Tornado: Lebensdauer** | verschiebt nur die letzte Phase; Kappung bei mindestens 1 Jahr |
| **Tornado: Verfügbarkeit** | Treiber ohne passende Elemente werden ausgeblendet (z. B. Immobilien-Wertsteigerung ohne Immobilie) |
| **Tornado: Sortierung/Richtung** | absteigend nach Spannweite; Treiber ohne Bandbreite hat Spannweite 0 und steht zuunterst; höhere Ausgaben → tieferes, höhere Rendite → höheres Endvermögen |
| **Verlauf: ein Punkt je Jahr** | je aktivem Element genau `durationYears` Punkte pro Phase |
| **Verlauf: Konvexität** | 200'000 @ 5 % + 10'000 Sparbeitrag: Jahr 1 = 220'000, und die **Jahreszuwächse wachsen** eine Gerade zwischen den Phasengrenzen hätte konstante Zuwächse |
| **Verlauf: Immobilie** | Verkehrswert, Restschuld und Eigenkapital werden getrennt geführt; Eigenkapital = Verkehrswert Restschuld |
| **Brücke: Vermögen geht auf** | Restposten ≤ 5 CHF über einen Plan mit allen Element-Arten und Übergangs-Entscheiden |
| **Brücke: Cash geht auf** | dito; Start- und Endwert stimmen mit `cashStart`/`cashEnd` überein |
| **Brücke: Umbuchung** | 5 × 12'000 Sparbeitrag: Cash 60'000, Vermögen **unverändert** die Sparrate erscheint nur im Cash-Wasserfall |
| **Brücke: PK-Beiträge** | 4 × 10'000 PK-Beitrag: Vermögen +40'000, Cash unbelastet |
| **Brücke: Übergangsposten** | Verrentung, Kapitalbezugssteuer und Einmalposten erscheinen in der Brücke der **Folge**phase |
| **explain: Standardmässig aus** | ohne Option keine Traces; mit Option identische Ergebniswerte (Endvermögen, Cash, Ruinalter) |
| **explain: Abdeckung** | Traces auf Element-, Übergangs-, Phasen- und Plan-Ebene vorhanden |
| **explain: eingesetzte Zahlen** | jeder Schritt trägt die Formel **und** die substituierte Fassung; das Resultat stimmt mit dem Ergebniswert überein |
| **explain: Spec-Verweise** | jeder `specAnchor` trifft eine existierende Überschrift in `SPEZIFIKATION.md` (gegen die echte Datei geprüft) |
| **Vermögen je Jahr** | 100k @ 10 % über 3 J. → 110k/121k/133.1k je Jahrespunkt; Endjahr = Phasen-Endvermögen |
| **Vermögen real** | 100k bei 10 % Inflation → real 90'909 |
| Test 1 Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
@@ -2623,6 +2802,39 @@ Migration), hat aber den Preis, dass eine Analyse bei jedem Öffnen neu parametr
Bei der Monte-Carlo-Simulation über mehrere Szenarien fällt das stärker ins Gewicht als zuvor,
weil dort mehr Eingaben zusammenkommen.
---
## 9.20 Transparenz legt auch die Vereinfachungen offen
Die vollständige Offenlegung der Rechenwege macht sichtbar, was das Modell **nicht** kann: die
nach der Pensionierung nicht indexierte AHV-Rente ([9.11](#911-ahv-rente-wird-nach-der-pensionierung-nicht-indexiert)),
den pauschalen Netto-Brutto-Faktor 1.12 ([4.4.5](#445-netto-brutto-umrechnung-für-die-ahv)), die
nicht indexierten Spar- und Bezugsraten ([9.6](#96-spar--und-bezugsraten-werden-nicht-indexiert))
und die fehlende Steuerberechnung ([9.14](#914-keine-steuerschätzung)).
Das ist der eigentliche Wert der Offenlegung und kein Nebeneffekt. Entscheidend ist aber, dass die
Erklärtexte die Grenze **mitnennen**, statt sie zu übergehen dafür ist das Feld `note` je
Rechenschritt da. Ein fachkundiger Nutzer, der eine Vereinfachung selbst entdeckt, nachdem ihm
volle Transparenz zugesagt wurde, zieht den härteren Schluss.
## 9.21 Wasserfall: die Zuordnung ist eine Interpretation
Welcher Posten in welchen Wasserfall gehört, ist eine **fachliche Entscheidung** und nicht aus den
Zahlen ableitbar. Die hier getroffene Umbuchungen nur im Cash-Wasserfall, echte Zu- und Abgänge
nur im Vermögens-Wasserfall ([4.14.2](#4142-die-beiden-wasserfälle)) ist begründet, aber nicht
die einzig denkbare. Wer die Sparrate als „gebundenes Geld" verstanden wissen will, würde sie
anders einordnen.
Konkret uneindeutig sind zwei Fälle:
- **Amortisation** senkt Cash und Hypothek. Im Vermögens-Wasserfall taucht sie nicht auf, obwohl
sie das *Eigenkapital* erhöht die Erhöhung ist bereits im unveränderten Vermögenssaldo
enthalten.
- **Verrentung** erscheint als Vermögensabgang, obwohl der Gegenwert als Rentenstrom weiterlebt.
Der Rentenstrom ist im Modell aber kein Bilanzposten, sondern Einkommen; er taucht in den
Folgejahren über die Quote wieder auf.
Der Restposten (`residual`) ist die Kontrollgrösse dafür, dass die gewählte Zerlegung wenigstens
**vollständig** ist nicht dafür, dass sie die einzig sinnvolle ist.
---
# 10. Glossar
@@ -2633,6 +2845,10 @@ weil dort mehr Eingaben zusammenkommen.
| **Szenario** | Die berechenbare Einheit (seit V6): trägt Grundprofil, Phasenkette und Elemente. Jeder Plan hat genau ein Basisszenario; weitere entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als Baum darunter |
| **Logisches Element** | Dasselbe finanzielle Element über Szenariogrenzen hinweg, erkannt über die Herkunfts-Kette `sourceElementId` Grundlage der einmaligen Parametereingabe im Szenario-Vergleich ([4.12.6](#4126-mehrere-szenarien-im-vergleich)) |
| **Spannweite (Tornado)** | Differenz zwischen dem Ergebnis beim tiefen und beim hohen Wert eines Treibers; bestimmt Balkenlänge und Rangfolge |
| **Umbuchung** | Bewegung, die Geld zwischen Cash und einem Vermögenswert verschiebt, ohne das Vermögen zu verändern (Sparrate, Amortisation, Zusatzinvestition). Erscheint nur im Cash-Wasserfall |
| **Vermögens-/Cash-Brücke** | Zerlegung der Vermögens- bzw. Cash-Veränderung einer Phase in ihre Summanden (`WealthBridge` / `CashBridge`) |
| **Restposten** | Differenz zwischen gerechnetem Endwert und der Summe der Brücken-Summanden; reine Rundung, Kontrollgrösse für die Vollständigkeit |
| **Rechenweg (Trace)** | Protokoll der Rechenschritte mit Formel, eingesetzten Zahlen, Ergebnis und geltender Vereinfachung; entsteht innerhalb von `computePlan` |
| **Grundprofil** | Haushaltsform, Personen (Alter, Pensionsalter, Name), Inflationsannahme |
| **Lebensphase** | Zeitabschnitt mit fester Dauer; darf keine Pensionierung überspannen |
| **Phasentyp** | `ERWERB` / `PENSION` / `MIXED`; abgeleitet, nie gespeichert |
@@ -2661,4 +2877,4 @@ weil dort mehr Eingaben zusammenkommen.
---
*Ende der Spezifikation v0.10*
*Ende der Spezifikation v0.11*
+70 -8
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import {
BarChart3,
BookOpen,
Copy,
Dices,
FileText,
@@ -12,6 +13,7 @@ import {
Menu,
PiggyBank,
Plus,
SlidersHorizontal,
Tornado,
Trash2,
X,
@@ -21,6 +23,9 @@ import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
import { computePlan } from "@/lib/calculations";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
@@ -47,6 +52,17 @@ export function AppShell({ username }: { username: string }) {
const [showCharts, setShowCharts] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false);
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
function openSpecAt(anchor: string) {
setSpecAnchor(anchor);
setShowSpec(true);
setShowSystemParams(false);
setSelectedScenarioId(null);
}
const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
@@ -86,6 +102,7 @@ export function AppShell({ username }: { username: string }) {
function openScenario(id: string) {
setSelectedScenarioId(id);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}
@@ -127,10 +144,11 @@ export function AppShell({ username }: { username: string }) {
onClick={() => {
setSelectedScenarioId(null);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
selectedScenarioId === null && !showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
@@ -176,11 +194,28 @@ export function AppShell({ username }: { username: string }) {
</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
type="button"
onClick={() => {
setShowSpec(true);
setSpecAnchor(null);
setShowSystemParams(false);
setSelectedScenarioId(null);
setSidebarOpen(false);
}}
@@ -230,7 +265,9 @@ export function AppShell({ username }: { username: string }) {
<Menu className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
{showSpec
{showSystemParams
? "Systemparameter"
: showSpec
? "Spezifikation"
: detail
? `${detail.meta.planName} · ${detail.meta.name}`
@@ -240,11 +277,13 @@ export function AppShell({ username }: { username: string }) {
</header>
<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
username={username}
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-wrap items-center gap-2">
<button
@@ -301,6 +340,15 @@ export function AppShell({ username }: { username: string }) {
<Tornado className="h-4 w-4" />
Einflussfaktoren berechnen
</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 && (
@@ -313,7 +361,13 @@ export function AppShell({ username }: { username: string }) {
)}
</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>
)}
</main>
@@ -358,6 +412,14 @@ export function AppShell({ username }: { username: string }) {
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
)}
{showPlanTraces && detail && (
<PlanTraceDialog
computed={computePlan(detail.plan, undefined, { explain: true })}
onClose={() => setShowPlanTraces(false)}
onOpenSpec={openSpecAt}
/>
)}
{copyFrom && (
<CopyScenarioDialog
source={copyFrom}
+511
View File
@@ -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} />
))}
</>
),
},
]}
/>
);
}
+79 -6
View File
@@ -10,6 +10,7 @@ import {
CreditCard,
Home,
Landmark,
Maximize2,
PiggyBank,
Plus,
Settings2,
@@ -19,6 +20,7 @@ import {
X,
} from "lucide-react";
import { Timeline } from "@/components/Timeline";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
import {
CashTransitionFields,
ElementDetail,
@@ -46,7 +48,7 @@ import {
type PhaseData,
type TransitionData,
} from "@/lib/elements";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
import type { ScenarioDiff } from "@/lib/diff";
import type { ElementInput, PlanInput } from "@/lib/types";
@@ -92,12 +94,14 @@ export function PlanView({
computed,
diff,
onChanged,
onOpenSpec,
}: {
plan: PlanInput;
computed: PlanComputed;
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
// Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau.
const cellDiff = (elementId: string, phaseId: string) =>
@@ -124,6 +128,16 @@ export function PlanView({
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
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(() => {
const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null;
@@ -405,6 +419,7 @@ export function PlanView({
mode={valueMode}
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/>
) : (
@@ -499,12 +514,24 @@ export function PlanView({
{!collapsed &&
els.map((el) => (
<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"}`}>
<div className="flex items-center gap-1 truncate text-xs font-medium text-fg">
{el.name}
<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 text-xs font-medium text-fg">
<span className="min-w-0 flex-1 truncate">{el.name}</span>
{diff?.elementRow.get(el.id) === "added" && (
<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>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<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 && (() => {
const element = plan.elements.find((e) => e.id === editPhaseCell.elementId);
const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId);
@@ -854,6 +911,7 @@ function PhaseHeader({
mode,
diffKind,
onClick,
onExpand,
active,
}: {
phase: PhaseComputed;
@@ -861,6 +919,7 @@ function PhaseHeader({
mode: ValueMode;
diffKind: "changed" | "added" | "removed" | null;
onClick: () => void;
onExpand: () => void;
active: boolean;
}) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
@@ -870,7 +929,7 @@ function PhaseHeader({
return (
<th
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
? "bg-accent-soft"
: diffKind === "added"
@@ -881,7 +940,7 @@ function PhaseHeader({
}`}
>
<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" && (
<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" />
)}
{/* 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 className="mt-0.5 flex flex-wrap gap-1">
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
+9 -1
View File
@@ -9,7 +9,7 @@ import { api } from "@/lib/api-client";
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema.
export function SpecView() {
export function SpecView({ anchor }: { anchor?: string | null }) {
const [markdown, setMarkdown] = useState<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."));
}, []);
// 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 (markdown === null) return <p className="text-sm text-muted">Laedt</p>;
+92
View File
@@ -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
View File
@@ -26,6 +26,44 @@ export interface PersonPhaseInfo {
retiresAtStart: boolean;
}
// --- Rechenweg-Protokoll (Roadmap Nr. 41) ------------------------------------------------
// Ein Schritt haelt die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte
// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin laeuft.
// Damit kann die angezeigte Herleitung nicht von der tatsaechlichen Rechnung abdriften --
// eine zweite Formel-Implementierung im UI waere die gefaehrlichste Variante ueberhaupt.
export interface TraceStep {
label: string;
formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhoehung)^(t-1)"
substituted?: string; // mit eingesetzten Zahlen, z. B. "100'000 x (1 + 2%)^4"
result: number;
unit?: "CHF" | "%" | "Jahre" | "";
note?: string; // Vereinfachung/Grenze, die an dieser Stelle gilt
}
export interface Trace {
title: string;
specAnchor?: string; // Sprungmarke in die SPEZIFIKATION (Kapitel 4)
steps: TraceStep[];
}
export interface ComputeOptions {
// Standardmaessig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
// und darf von der Protokollierung nichts merken.
explain?: boolean;
}
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % ueber 20 Jahre waechst
// konvex, eine Gerade von Start zu Ende waere sichtbar falsch (gleiche Ueberlegung wie beim
// Vermoegensverlauf in v0.9).
export interface ElementYearPoint {
year: number; // 1-basiert ab Planbeginn
age: number; // Alter Person A am Jahresende
value: number; // Haupt-Kennzahl (Saldo, Eigenkapital, Flow, Rente)
propertyValue?: number; // nur REAL_ESTATE: Verkehrswert der Liegenschaft
mortgage?: number; // nur REAL_ESTATE: Restschuld
}
export interface ElementPhaseComputed {
elementId: string;
category: ElementCategory;
@@ -41,6 +79,51 @@ export interface ElementPhaseComputed {
endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
summary: string;
note: string | null;
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
transitionTrace?: Trace; // Rechenweg des Uebergangs NACH dieser Phase (nur mit explain)
}
// --- Wasserfall-Zerlegungen (Roadmap Nr. 43) ---------------------------------------------
//
// WICHTIG -- der Unterschied zwischen den beiden: Sparraten, Amortisationen und
// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhoehen im selben Zug
// einen Vermoegenswert bzw. senken eine Schuld; das Vermoegen bleibt unveraendert. Sie
// gehoeren deshalb in die Cash-Bruecke und ausdruecklich NICHT in die Vermoegens-Bruecke --
// dort als Abgang gezeichnet wuerden sie eine Vermoegensminderung suggerieren, die es nicht
// gibt.
export interface WealthBridge {
openingWealth: number; // Endvermoegen der Vorphase (Phase 1: Startvermoegen)
// Uebergang IN diese Phase -- echte Vermoegensaenderungen an der Phasengrenze:
oneOffInflow: number; // einmaliger Zufluss (netto nach Steuer)
oneOffOutflow: number; // einmalige Kosten
transitionTax: number; // Kapitalbezugs- und Grundstueckgewinnsteuer (Abgang)
pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlaesst die Bilanz)
saleGainLoss: number; // Verkaufspreis minus Verkehrswert bei einem Immobilienverkauf
startWealth: number; // Kontrollpunkt = startWealthNominal
// Innerhalb der Phase:
quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) ueber alle Phasenjahre
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermoegen
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
pensionFundContribution: number; // PK-Beitraege: erhoehen das Vermoegen, ohne Cash zu kosten
endWealth: number; // = endWealthNominal
residual: number; // Rundungsdifferenz (Kontrollgroesse, sollte nahe 0 sein)
}
export interface CashBridge {
openingCash: number; // Cash-Endbestand der Vorphase (Phase 1: initialCash)
capitalInflow: number; // Verkaeufe + PK-/3a-Bezuege aus dem Uebergang
oneOffInflow: number;
immediateRepay: number; // Sofort-Tilgungen und Sonderamortisation (Abgang)
oneOffOutflow: number;
investments: number; // Zusatz-/Neuinvestitionen am Phasenanfang (Abgang)
cashStart: number; // Kontrollpunkt
quotaTotal: number;
savingRates: number; // 3a + Sparbeitraege (Abgang)
debtRates: number; // Amortisationen + Tilgungen (Abgang)
withdrawals: number; // Bezugsraten aus Sonstigem Vermoegen (Zugang)
cashEnd: number;
residual: number;
}
export interface PhaseComputed {
@@ -85,6 +168,9 @@ export interface PhaseComputed {
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`)
endWealthReal: number;
wealthBridge: WealthBridge;
cashBridge: CashBridge;
traces?: Trace[]; // Rechenwege der Phasen-Kennzahlen (nur mit explain)
}
// Ein Datenpunkt pro Jahr (ueber alle Phasen), fuer die Verlaufsgrafik.
@@ -106,6 +192,7 @@ export interface PlanComputed {
nachlass: number;
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (fuer die AHV-Pruefung am Uebergang)
traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain)
}
// --- AHV-Rentenformel (Skala 44) ---------------------------------------------------------
@@ -223,7 +310,23 @@ export interface PlanSample {
assetReturn: (elementId: string, year: number) => number;
}
export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed {
// Kurzform fuer einen Rechenschritt. `pct` formatiert Prozentsaetze lesbar (2 statt 2.0000001).
function st(
label: string,
result: number,
formula?: string,
substituted?: string,
note?: string,
unit: TraceStep["unit"] = "CHF"
): TraceStep {
return { label, formula, substituted, result, unit, note };
}
function pct(v: number): string {
return `${Math.round(v * 1000) / 1000} %`;
}
export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed {
const explain = options?.explain === true;
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = plan.persons;
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
@@ -262,6 +365,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
let incomingOneOffInflowLabel: string | null = null;
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
let incomingOneOffOutflowLabel: string | null = null;
// Echte Vermoegensaenderungen am Uebergang IN die Folgephase (fuer die Vermoegens-Bruecke).
let incomingTax = 0; // Kapitalbezugs- + Grundstueckgewinnsteuer
let incomingPensionConversion = 0; // in eine Rente umgewandeltes PK-Kapital
let incomingSaleGainLoss = 0; // Verkaufspreis minus Verkehrswert
let previousEndWealth = 0; // Endvermoegen der Vorphase (Startpunkt der Bruecke)
let previousCashEnd = Math.round(plan.initialCash || 0);
let ruinAge: number | null = null;
for (let i = 0; i < phases.length; i++) {
@@ -345,7 +454,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
const assets: { value: number; rate: number; r: number; withdrawal: number; ec: ElementPhaseComputed }[] = [];
// `isPk` fuer die Vermoegens-Bruecke: PK-Beitraege verlassen das Cash NICHT (sie sind im
// Nettolohn bereits abgezogen), erhoehen aber das Vermoegen -- sie sind also ein echter
// Zugang und keine Umbuchung.
const assets: {
value: number;
rate: number;
r: number;
withdrawal: number;
isPk: boolean;
ec: ElementPhaseComputed;
}[] = [];
// mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der
// Liegenschaft (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis.
@@ -387,6 +506,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
endValue: 0,
summary: "",
note: null,
yearly: [],
};
ecById.set(e.id, ec);
@@ -470,7 +590,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, ec });
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: true, ec });
}
break;
}
@@ -488,7 +608,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, ec });
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: false, ec });
}
break;
}
@@ -504,7 +624,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, ec });
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, isPk: false, ec });
break;
}
case "REAL_ESTATE": {
@@ -570,6 +690,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
let quotaStart = 0;
let quotaEnd = 0;
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr
// Summanden der beiden Wasserfall-Bruecken (ueber alle Phasenjahre kumuliert).
let quotaTotal = 0;
let investmentReturnTotal = 0;
let propertyAppreciationTotal = 0;
let pensionFundContributionTotal = 0;
let savingRatesTotal = 0;
let debtRatesTotal = 0;
let withdrawalsTotal = 0;
for (let t = 1; t <= duration; t++) {
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
@@ -621,10 +749,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
let cashFromWithdraw = 0;
for (const a of assets) {
const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r;
// Arithmetik unveraendert lassen; die Rendite wird als Differenz abgeleitet, damit
// die Protokollierung das Ergebnis nicht (per Gleitkomma-Umformung) verschiebt.
const grown = a.value * (1 + r / 100) + a.rate;
const growth = grown - a.rate - a.value;
const w = Math.min(a.withdrawal, Math.max(0, grown));
a.value = grown - w;
cashFromWithdraw += w;
investmentReturnTotal += growth;
if (a.isPk) pensionFundContributionTotal += a.rate;
}
// Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
@@ -637,7 +770,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
// Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital -- das ist der
// Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000.
const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth;
re.value *= 1 + g / 100;
const valueBefore = re.value;
re.value *= 1 + g / 100; // Arithmetik unveraendert
propertyAppreciationTotal += re.value - valueBefore;
}
for (const d of debts) {
const pay = Math.min(d.repay, d.owed);
@@ -649,6 +784,43 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
if (cash < 0) cashNegative = true;
quotaTotal += quote;
savingRatesTotal += fixedRatesTotal;
debtRatesTotal += debtRates;
withdrawalsTotal += cashFromWithdraw;
// Verlaufspunkt je Element (Detailansicht). Bewusst NACH Verzinsung und Tilgung, also
// der Stand am Jahresende -- konsistent zum Gesamtvermoegen im YearPoint.
const age = personA.age + yearsBefore + t;
const yr = yearsBefore + t;
for (const inc of incomes) {
inc.ec.yearly.push({ year: yr, age, value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)) });
}
for (const exp of expenses) {
exp.ec.yearly.push({
year: yr,
age,
value: Math.round(exp.basis * Math.pow(1 + exp.idx / 100, t - 1) * inflFactor),
});
}
for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value) });
for (const re of realEstates) {
re.ec.yearly.push({
year: yr,
age,
value: Math.round(re.value - re.mortgage), // Eigenkapital
propertyValue: Math.round(re.value),
mortgage: Math.round(re.mortgage),
});
}
for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) });
// Renten (AHV, verrentete PK) laufen nominal fix durch die Phase.
for (const ec of ecById.values()) {
if ((ec.category === "AHV" || ec.category === "PENSION_FUND") && ec.startValue > 0 && ec.yearly.length < t) {
ec.yearly.push({ year: yr, age, value: ec.startValue });
}
}
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
let total = cash;
for (const a of assets) total += a.value;
@@ -692,11 +864,287 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
if (d.owed === 0) d.ec.note = "Wird getilgt";
}
// --- Rechenweg je Element (Roadmap Nr. 41) ---
// Bewusst HIER, nach der Jahresschleife: erst jetzt stehen die Endwerte fest.
if (explain) {
for (const inc of incomes) {
inc.ec.trace = {
title: `Einkommen «${inc.ec.name}» in dieser Lebensphase`,
specAnchor: "461-income--expense",
steps: [
st(
inc.ec.carried ? "Basiswert (aus der Vorphase fortgeschrieben)" : "Basiswert (erfasst)",
Math.round(inc.basis),
undefined,
undefined,
inc.ec.carried
? "Der Endwert der Vorphase wird fortgeschrieben, solange kein abweichender Wert erfasst ist."
: "Erfasst wird das NETTO-Einkommen, nominal."
),
st("Nominale Lohnerhöhung", inc.idx, undefined, undefined, undefined, "%"),
st(
`Einkommen im letzten Phasenjahr (Jahr ${duration})`,
inc.ec.endValue,
"Basis × (1 + Lohnerhöhung)^(Dauer 1)",
`${fmt(inc.basis)} × (1 + ${pct(inc.idx)})^${duration - 1}`
),
st(
"Basiswert für die Folgephase",
Math.round(inc.basis * Math.pow(1 + inc.idx / 100, duration)),
"Basis × (1 + Lohnerhöhung)^Dauer",
`${fmt(inc.basis)} × (1 + ${pct(inc.idx)})^${duration}`,
"Ein Jahr weiter als der Endwert die Folgephase beginnt nach dem letzten Jahr dieser Phase."
),
],
};
}
for (const exp of expenses) {
const realEnd = exp.basis * Math.pow(1 + exp.idx / 100, duration - 1);
exp.ec.trace = {
title: `Ausgaben «${exp.ec.name}» in dieser Lebensphase`,
specAnchor: "461-income--expense",
steps: [
st(
exp.ec.carried ? "Basiswert real (fortgeschrieben)" : "Basiswert real (erfasst)",
Math.round(exp.basis),
undefined,
undefined,
"Ausgaben werden REAL erfasst, also in heutiger Kaufkraft."
),
st("Reale Mehrausgaben pro Jahr", exp.idx, undefined, undefined, "Zusätzlich zur Inflation.", "%"),
st(
"Nominal im ersten Phasenjahr",
exp.ec.startValue,
"Basis × kumulierte Inflation (Phasenbeginn)",
`${fmt(exp.basis)} × ${Math.round(cumInflStart * 10000) / 10000}`
),
st(
`Real im letzten Phasenjahr (Jahr ${duration})`,
Math.round(realEnd),
"Basis × (1 + reale Mehrausgaben)^(Dauer 1)",
`${fmt(exp.basis)} × (1 + ${pct(exp.idx)})^${duration - 1}`
),
st(
`Nominal im letzten Phasenjahr (Jahr ${duration})`,
exp.ec.endValue,
"real × Flow-Deflator am Phasenende",
`${fmt(realEnd)} × ${Math.round(flowDeflatorEnd * 10000) / 10000}`,
"Der Flow-Deflator liegt eine Kaufkraft-Stufe unter dem Bestands-Deflator: ein Flow fällt IM Jahr `Dauer` an, ein Bestand wird NACH diesem Jahr gemessen."
),
],
};
}
for (const a of assets) {
const isPk = a.isPk;
a.ec.trace = {
title: `${isPk ? "Pensionskasse" : "Kapitalanlage"} «${a.ec.name}» in dieser Lebensphase`,
specAnchor: isPk ? "463-pension_fund" : "466-other_asset",
steps: [
st(a.ec.carried ? "Startwert (fortgeschrieben)" : "Startwert (erfasst)", a.ec.baseValue),
...(a.ec.startValue !== a.ec.baseValue
? [st("Zusatzeinlage aus Kapital", a.ec.startValue - a.ec.baseValue, undefined, undefined, "Wird dem Cash entnommen.")]
: []),
st("Erwartete Rendite", a.r, undefined, undefined, undefined, "%"),
st(
"Jährliche Einzahlung",
a.rate,
undefined,
undefined,
isPk
? "Arbeitnehmer- und Arbeitgeberbeitrag. Belastet das Cash NICHT die Beiträge sind im Nettolohn bereits abgezogen."
: "Verlässt das Cash und zählt zur geplanten Sparrate."
),
...(a.withdrawal > 0
? [st("Jährliche Bezugsrate", a.withdrawal, undefined, undefined, "Entnahme aus dem Vermögen ins Cash, am Bestand gekappt.")]
: []),
st(
"Jahresschritt",
a.ec.endValue,
"Bestand × (1 + Rendite) + Einzahlung Bezugsrate",
`über ${duration} Jahre, beginnend bei ${fmt(a.ec.startValue)}`,
"Verzinsung ist nachschüssig: die Einzahlung des laufenden Jahres wird noch nicht mitverzinst."
),
st("Endwert der Phase", a.ec.endValue),
],
};
}
for (const re of realEstates) {
const interestStart = Math.round((re.ec.mortgageStart * re.interestRate) / 100);
const interestEnd = Math.round((re.ec.mortgageEnd * re.interestRate) / 100);
re.ec.trace = {
title: `Immobilie «${re.ec.name}» in dieser Lebensphase`,
specAnchor: "465-real_estate-immobilie",
steps: [
st("Verkehrswert zu Phasenbeginn", re.ec.startValue + re.ec.mortgageStart),
st("Resthypothek zu Phasenbeginn", re.ec.mortgageStart),
st("Eigenkapital zu Phasenbeginn", re.ec.startValue, "Verkehrswert Resthypothek", `${fmt(re.ec.startValue + re.ec.mortgageStart)} ${fmt(re.ec.mortgageStart)}`),
st("Ursprünglicher Kaufpreis", re.purchase, undefined, undefined, "Basis der Grundstückgewinnsteuer beim Verkauf läuft getrennt vom Verkehrswert."),
st("Wertsteigerung pro Jahr", re.growth, undefined, undefined, "Wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital daher der Hebel.", "%"),
st("Amortisation pro Jahr", re.amort, undefined, undefined, "Am Restsaldo gekappt: ist die Hypothek getilgt, entfällt die Rate."),
st("Hypothekarzins", re.interestRate, undefined, undefined, re.addInterest ? "Wird zu den Ausgaben dazugerechnet." : "Gilt als bereits im Ausgaben-Element enthalten und wird NICHT zusätzlich abgezogen.", "%"),
st("Zinsbetrag im ersten Jahr", interestStart, "Restschuld × Zinssatz", `${fmt(re.ec.mortgageStart)} × ${pct(re.interestRate)}`),
st("Zinsbetrag im letzten Jahr", interestEnd, "Restschuld × Zinssatz", `${fmt(re.ec.mortgageEnd)} × ${pct(re.interestRate)}`, "Sinkt automatisch mit der Amortisation."),
st("Verkehrswert am Phasenende", re.ec.endValue + re.ec.mortgageEnd),
st("Resthypothek am Phasenende", re.ec.mortgageEnd),
st("Eigenkapital am Phasenende", re.ec.endValue, "Verkehrswert Resthypothek", `${fmt(re.ec.endValue + re.ec.mortgageEnd)} ${fmt(re.ec.mortgageEnd)}`),
],
};
}
for (const d of debts) {
d.ec.trace = {
title: `Schuld «${d.ec.name}» in dieser Lebensphase`,
specAnchor: "467-other_debt",
steps: [
st("Restschuld zu Phasenbeginn", -d.ec.startValue),
st("Jährliche Tilgung", d.repay, undefined, undefined, "Am Restsaldo gekappt im letzten Zahlungsjahr wird nur der Restbetrag fällig."),
st("Restschuld am Phasenende", d.owed, `min(Tilgung, Restschuld) über ${duration} Jahre`),
st("Beitrag zum Vermögen", d.ec.endValue, undefined, undefined, "Schulden gehen mit negativem Vorzeichen ins Vermögen ein."),
],
};
}
// AHV-/PK-Renten: der Rechenweg der Rentenhoehe selbst liegt auf Plan-Ebene (mdJE).
for (const e of orderedElements) {
const ec = ecById.get(e.id)!;
if (ec.trace || ec.startValue === 0) continue;
if (e.category === "AHV") {
const owner = e.ownerRole ? personByRole(persons, e.ownerRole) : null;
const gap = owner ? gapYearsByPerson.get(owner.id) ?? 0 : 0;
const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 } : { avg: 0, gap: 0 };
const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null;
const mdJE = career ? ahvMdje(career, before.avg, before.gap) : 0;
ec.trace = {
title: `AHV-Rente «${ec.name}»`,
specAnchor: "44-ahv-rente",
steps: [
st("Massgebendes durchschnittliches Jahreseinkommen (mdJE)", Math.round(mdJE), undefined, undefined, "Gewichtetes Mittel über alle Beitragsjahre, REAL gerechnet und auf den Bruttolohn hochgerechnet."),
st("Volle Monatsrente zum mdJE", Math.round(ahvMonthlyFullPension(mdJE)), "Rentenformel Skala 44", `mdJE ${fmt(mdJE)} → Skala 44`),
st("Ausfalljahre total", gap + before.gap, undefined, undefined, "Jedes Ausfalljahr kürzt die Rente um 1/44.", "Jahre"),
st(
"Rentenskala-Faktor",
Math.round(((AHV_FULL_CONTRIBUTION_YEARS - (gap + before.gap)) / AHV_FULL_CONTRIBUTION_YEARS) * 1000) / 10,
"(44 Ausfalljahre) / 44",
`(${AHV_FULL_CONTRIBUTION_YEARS} ${gap + before.gap}) / ${AHV_FULL_CONTRIBUTION_YEARS}`,
undefined,
"%"
),
st(
"Jahresrente",
ec.startValue,
"Monatsrente × 13 × Skalafaktor",
`${fmt(ahvMonthlyFullPension(mdJE))} × ${AHV_PENSION_MONTHS} × Faktor`,
plan.householdType === "COUPLE"
? "Bei Ehepaaren zusätzlich auf 150 % der Einzel-Maximalrente plafoniert. Die Rente wird danach nominal eingefroren und nicht indexiert."
: "Die Rente wird danach nominal eingefroren und nicht indexiert (bewusste Vereinfachung)."
),
],
};
} else if (e.category === "PENSION_FUND") {
ec.trace = {
title: `PK-Rente «${ec.name}»`,
specAnchor: "491-pension_fund",
steps: [
st("Jährliche Rente", ec.startValue, "verrentetes Kapital × Umwandlungssatz", undefined, "Der Umwandlungssatz wurde beim Pensions-Übergang gewählt. Die Rente ist danach nominal fix."),
],
};
}
}
}
const cashEnd = Math.round(cash);
const startWealthNominal = Math.round(wealthStart + cashStart);
const endWealthNominal = Math.round(wealthEnd + cashEnd);
cumulativeInflation = cumInfl[yearsBefore + duration];
// --- Rechenweg der Phasen-Kennzahlen ---
const phaseTraces: Trace[] = [];
if (explain) {
phaseTraces.push({
title: "Spar- bzw. Verzehrquote",
specAnchor: "47-die-jahresschleife",
steps: [
st("Einkommen im ersten Jahr (inkl. Renten)", Math.round(incomeStart)),
st("Ausgaben im ersten Jahr (nominal)", Math.round(expenseStart)),
st("Quote im ersten Jahr", Math.round(quotaStart), "Einkommen nominale Ausgaben", `${fmt(incomeStart)} ${fmt(expenseStart)}`,
quotaStart < 0 ? "Negativ das ist eine Verzehrphase: die laufenden Ausgaben übersteigen das Einkommen." : undefined),
st("Quote im letzten Jahr", Math.round(quotaEnd), "Einkommen nominale Ausgaben", `${fmt(incomeEnd)} ${fmt(expenseEnd)}`),
st("Summe über alle Phasenjahre", Math.round(quotaTotal), undefined, undefined, "Diese Summe ist der Beitrag der laufenden Flows zur Vermögensentwicklung."),
],
});
phaseTraces.push({
title: "Geplante Spar- und Verzehrrate",
specAnchor: "47-die-jahresschleife",
steps: [
st("Feste Sparraten (3a + Sparbeiträge)", fixedRatesTotal, undefined, undefined, "PK-Beiträge zählen NICHT dazu sie sind im Nettolohn bereits abgezogen."),
st("Amortisationen und Tilgungen im ersten Jahr", plannedSaveRate - fixedRatesTotal),
st("Geplante Sparrate (erstes Jahr)", plannedSaveRate, "Sparraten + Amortisationen + Tilgungen", `${fmt(fixedRatesTotal)} + ${fmt(plannedSaveRate - fixedRatesTotal)}`,
"Bewusst das erste Phasenjahr: läuft eine Schuld während der Phase aus, liegt die Rate später tiefer."),
st("Geplante Verzehrrate", plannedWithdrawTotal, undefined, undefined, "Summe der Bezugsraten aus Sonstigem Vermögen."),
],
});
phaseTraces.push({
title: "Cash-Fortschreibung",
specAnchor: "47-die-jahresschleife",
steps: [
st(isFirstPhase ? "Cash-Anfangswert" : "Cash-Endbestand der Vorphase", isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd),
...(incomingInflow ? [st("+ Kapitalzufluss aus dem Übergang", Math.round(incomingInflow), undefined, undefined, "Verkäufe und PK-/3a-Bezüge, netto nach Steuer.")] : []),
...(incomingOneOffInflow ? [st("+ Einmaliger Zufluss", Math.round(incomingOneOffInflow))] : []),
...(incomingImmediateRepay ? [st(" Sofort-Tilgung / Sonderamortisation", -Math.round(incomingImmediateRepay))] : []),
...(incomingOneOffOutflow ? [st(" Einmalige Kosten", -Math.round(incomingOneOffOutflow))] : []),
...(!isFirstPhase && investmentsFromCash ? [st(" Investitionen am Phasenanfang", -Math.round(investmentsFromCash), undefined, undefined, "Werden vorab abgezogen, damit sie nicht doppelt (im Cash UND im Vermögen) zählen.")] : []),
st("= Cash zu Phasenbeginn", Math.round(cashStart)),
st("+ Summe der Quoten", Math.round(quotaTotal)),
st(" Sparraten", -Math.round(savingRatesTotal)),
st(" Amortisationen und Tilgungen", -Math.round(debtRatesTotal)),
st("+ Bezugsraten", Math.round(withdrawalsTotal)),
st("= Cash am Phasenende", cashEnd, undefined, undefined,
cashNegative ? "Der Cash-Bestand fällt in dieser Phase unter 0 das ist eine Liquiditätslücke. Sie wird gemeldet, aber nicht automatisch ausgeglichen." : undefined),
],
});
phaseTraces.push({
title: "Vermögen zu Beginn und am Ende",
specAnchor: "48-endwerte-und-phasen-kennzahlen",
steps: [
st("Summe der Element-Startwerte", Math.round(wealthStart)),
st("+ Cash zu Phasenbeginn", Math.round(cashStart)),
st("= Vermögen zu Phasenbeginn", startWealthNominal),
st("Summe der Element-Endwerte", Math.round(wealthEnd)),
st("+ Cash am Phasenende", cashEnd),
st("= Vermögen am Phasenende (nominal)", endWealthNominal),
st("Bestands-Deflator am Phasenende", Math.round(cumulativeInflation * 10000) / 10000, undefined, undefined, undefined, ""),
st("= Vermögen am Phasenende (real)", Math.round(endWealthNominal / cumulativeInflation),
"nominal / kumulierte Inflation", `${fmt(endWealthNominal)} / ${Math.round(cumulativeInflation * 10000) / 10000}`,
"Real heisst: kaufkraftbereinigt auf den Planbeginn."),
],
});
phaseTraces.push({
title: "Phasentyp und Alter",
specAnchor: "43-personen-und-phasentyp",
steps: [
...personInfos.map((p) =>
st(
`${p.role === "PERSON_A" ? "Person A" : "Person B"}: Alter zu Phasenbeginn`,
p.startAge,
"aktuelles Alter + Jahre vor dieser Phase",
`${persons.find((x) => x.id === p.personId)?.age ?? 0} + ${yearsBefore}`,
p.working ? "erwerbstätig (Alter < Pensionsalter)" : "pensioniert",
"Jahre"
)
),
st("Phasentyp", 0, undefined, type === "ERWERB" ? "alle erwerbstätig" : type === "PENSION" ? "alle pensioniert" : "gemischt",
"Der Phasentyp wird nie gespeichert, sondern in jeder Berechnung neu abgeleitet.", ""),
],
});
}
result.push({
id: phase.id,
name: phase.name,
@@ -731,11 +1179,69 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
cumulativeInflationEnd: cumulativeInflation,
flowDeflatorEnd,
endWealthReal: endWealthNominal / cumulativeInflation,
wealthBridge: {
openingWealth: isFirstPhase ? startWealthNominal : previousEndWealth,
oneOffInflow: Math.round(incomingOneOffInflow),
oneOffOutflow: Math.round(incomingOneOffOutflow),
transitionTax: Math.round(incomingTax),
pensionConversion: Math.round(incomingPensionConversion),
saleGainLoss: Math.round(incomingSaleGainLoss),
startWealth: startWealthNominal,
quotaTotal: Math.round(quotaTotal),
investmentReturn: Math.round(investmentReturnTotal),
propertyAppreciation: Math.round(propertyAppreciationTotal),
pensionFundContribution: Math.round(pensionFundContributionTotal),
endWealth: endWealthNominal,
residual:
endWealthNominal -
((isFirstPhase ? startWealthNominal : previousEndWealth) +
Math.round(incomingOneOffInflow) -
Math.round(incomingOneOffOutflow) -
Math.round(incomingTax) -
Math.round(incomingPensionConversion) +
Math.round(incomingSaleGainLoss) +
Math.round(quotaTotal) +
Math.round(investmentReturnTotal) +
Math.round(propertyAppreciationTotal) +
Math.round(pensionFundContributionTotal)),
},
cashBridge: {
openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd,
capitalInflow: Math.round(incomingInflow),
oneOffInflow: Math.round(incomingOneOffInflow),
immediateRepay: Math.round(incomingImmediateRepay),
oneOffOutflow: Math.round(incomingOneOffOutflow),
investments: isFirstPhase ? 0 : Math.round(investmentsFromCash),
cashStart: Math.round(cashStart),
quotaTotal: Math.round(quotaTotal),
savingRates: Math.round(savingRatesTotal),
debtRates: Math.round(debtRatesTotal),
withdrawals: Math.round(withdrawalsTotal),
cashEnd,
residual:
cashEnd -
(Math.round(cashStart) +
Math.round(quotaTotal) -
Math.round(savingRatesTotal) -
Math.round(debtRatesTotal) +
Math.round(withdrawalsTotal)),
},
traces: explain ? phaseTraces : undefined,
});
previousEndWealth = endWealthNominal;
previousCashEnd = cashEnd;
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
let txInflow = 0;
let txImmediateRepay = 0;
// Echte Vermoegensaenderungen an dieser Grenze (fuer die Bruecke der Folgephase).
// Verkaeufe, Bezuege und Tilgungen sind fuer sich Umbuchungen -- vermoegenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlaesst die Bilanz) und die Differenz
// zwischen Verkaufspreis und Verkehrswert.
let txTax = 0;
let txPensionConversion = 0;
let txSaleGainLoss = 0;
// Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase
// existiert -- nach der letzten Phase gibt es keinen Uebergang. Der Wechselkurs zwischen
@@ -796,22 +1302,31 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
continue;
}
const txStepsBefore = txInflow;
const taxBefore = txTax;
switch (e.category) {
case "PENSION_FUND": {
if (ownerRetiresNext) {
const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") {
txInflow += Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += value - net;
carry.value = 0;
carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") {
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value;
carry.value = 0;
} else {
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
txInflow += Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += capital - net;
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value - capital;
carry.value = 0;
}
} else {
@@ -819,18 +1334,24 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += withdrawal - net;
}
break;
}
case "PILLAR_3A": {
if (ownerRetiresNext) {
txInflow += Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += ec.endValue - net;
carry.value = 0;
} else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += withdrawal - net;
}
break;
}
@@ -858,6 +1379,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
const gain = Math.max(0, salePrice - re.purchase);
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
txInflow += Math.round(salePrice - re.mortgage - tax);
txTax += Math.round(tax);
// Der Verkaufspreis kann vom fortgeschriebenen Verkehrswert abweichen -- diese
// Differenz ist ein echter Vermoegensgewinn bzw. -verlust an der Grenze.
txSaleGainLoss += Math.round(salePrice - re.value);
carry.status = "SOLD";
} else {
// Sonderamortisation: Einmaltilgung der Hypothek aus dem Cash, am Restsaldo gekappt.
@@ -883,6 +1408,76 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
default:
break;
}
// --- Rechenweg des Uebergangs je Element ---
if (explain && nextPhase) {
const inflowHere = txInflow - txStepsBefore;
const taxHere = txTax - taxBefore;
const steps: TraceStep[] = [st("Wert am Ende der Vorphase", ec.endValue)];
let title = `Übergang «${ec.name}»`;
let anchor: string | undefined;
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE";
title = `Übergang «${ec.name}» ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`;
if (ownerRetiresNext && e.category === "PENSION_FUND") {
steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, ""));
if (mode === "PENSION" || mode === "COMBI") {
steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
}
}
if (taxHere > 0) {
steps.push(st("Kapitalbezugssteuer", num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE), undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
steps.push(st("Steuerbetrag", -taxHere));
}
if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug Kapitalbezugssteuer"));
} else if (e.category === "REAL_ESTATE") {
anchor = "494-real_estate";
const re = realEstates.find((r) => r.ec.elementId === e.id);
if (td.decision === "SELL") {
const salePrice = Math.round(num(td.salePrice));
title = `Verkauf «${ec.name}»`;
steps.push(st("Verkaufspreis", salePrice));
steps.push(st("Ursprünglicher Kaufpreis", re?.purchase ?? 0, undefined, undefined, "Bemessungsgrundlage der Grundstückgewinnsteuer nicht der zwischenzeitliche Verkehrswert."));
steps.push(st("Steuerbarer Gewinn", Math.max(0, salePrice - (re?.purchase ?? 0)), "max(0, Verkaufspreis Kaufpreis)", `max(0, ${fmt(salePrice)} ${fmt(re?.purchase ?? 0)})`, "Ein Verlustverkauf erzeugt keine Steuer."));
steps.push(st("Grundstückgewinnsteuer", num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE), undefined, undefined, "Die Haltedauer-Abstufung ist nicht modelliert.", "%"));
steps.push(st("Steuerbetrag", -taxHere));
steps.push(st("Hypothekenablösung", -(re?.mortgage ?? 0)));
steps.push(st("Netto ins Cash", inflowHere, "Verkaufspreis Hypothek Steuer", `${fmt(salePrice)} ${fmt(re?.mortgage ?? 0)} ${fmt(taxHere)}`));
} else {
const extra = Math.min(re?.mortgage ?? 0, Math.round(num(td.extraAmortization)));
steps.push(st("Entscheid", 0, undefined, "Halten", undefined, ""));
if (extra > 0) {
steps.push(st("Sonderamortisation", -extra, undefined, undefined, "Einmaltilgung aus dem Cash der Folgephase; senkt Restschuld und künftige Zinsen."));
steps.push(st("Resthypothek danach", carry.mortgage));
}
}
} else if (e.category === "OTHER_ASSET") {
anchor = "493-other_asset";
if (td.decision === "SELL") {
steps.push(st("Entscheid", 0, undefined, "Vollverkauf", undefined, ""));
steps.push(st("Erlös ins Cash", inflowHere, undefined, undefined, "Kein Steuerabzug private Kapitalgewinne sind in der Schweiz steuerfrei."));
} else if (td.decision === "PARTIAL") {
steps.push(st("Entscheid", 0, undefined, "Teilverkauf", undefined, ""));
steps.push(st("Betrag ins Cash", inflowHere, "min(Endwert, gewünschter Betrag)"));
steps.push(st("Rest bleibt investiert", carry.value, undefined, undefined, "Der verbleibende Teil wächst in der Folgephase weiter."));
} else {
steps.push(st("Entscheid", 0, undefined, "Halten", undefined, ""));
steps.push(st("Wert in der Folgephase", carry.value));
}
} else if (e.category === "OTHER_DEBT") {
anchor = "495-other_debt";
const immediate = Math.round(num(td.immediateRepayment));
steps.push(st("Restschuld", -ec.endValue));
if (immediate > 0) steps.push(st("Sofortige Tilgung", -Math.min(-ec.endValue, immediate), undefined, undefined, "Wird dem Cash der Folgephase entnommen."));
steps.push(st("Restschuld in der Folgephase", carry.owed, undefined, undefined, carry.owed === 0 ? "Vollständig getilgt." : undefined));
}
if (steps.length > 1) ec.transitionTrace = { title, specAnchor: anchor, steps };
}
carry.hasCarry = true;
}
@@ -893,12 +1488,84 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
incomingOneOffInflowLabel = txOneOffInflowLabel;
incomingOneOffOutflow = txOneOffOutflow;
incomingOneOffOutflowLabel = txOneOffOutflowLabel;
incomingTax = txTax;
incomingPensionConversion = txPensionConversion;
incomingSaleGainLoss = txSaleGainLoss;
// Rechenweg des Cash-Uebergangs (einmalige Sonderein-/ausgaben). Wird nachtraeglich an
// die Trace-Liste dieser Phase gehaengt -- `result` haelt die Referenz auf das Array.
if (explain && nextPhase && (txOneOffInflow !== 0 || txOneOffOutflow !== 0)) {
const ct = phase.cashTransition ?? {};
const steps: TraceStep[] = [];
if (txOneOffInflow !== 0) {
const gross = Math.round(num(ct.inflowAmount));
steps.push(st(`Zufluss «${ct.inflowLabel?.trim() || "ohne Bezeichnung"}» brutto`, gross, undefined, undefined, "Nominal erfasst der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt."));
steps.push(st("Steuersatz", num(ct.inflowTaxRate, 0), undefined, undefined, "Default 0 % Erbschaften an direkte Nachkommen sind in den meisten Kantonen steuerfrei.", "%"));
steps.push(st("Netto ins Cash", txOneOffInflow, "brutto × (1 Steuersatz)", `${fmt(gross)} × (1 ${pct(num(ct.inflowTaxRate, 0))})`));
}
if (txOneOffOutflow !== 0) {
steps.push(st(`Kosten «${ct.outflowLabel?.trim() || "ohne Bezeichnung"}» real`, Math.round(num(ct.outflowAmount)), undefined, undefined, "Real erfasst, in heutiger Kaufkraft."));
steps.push(st("Bestands-Deflator an der Phasengrenze", Math.round(cumulativeInflation * 10000) / 10000, undefined, undefined, undefined, ""));
steps.push(st("Nominal vom Cash abgezogen", -txOneOffOutflow, "real × kumulierte Inflation", `${fmt(num(ct.outflowAmount))} × ${Math.round(cumulativeInflation * 10000) / 10000}`));
}
steps.push(st("Wirkung auf die Quote", 0, undefined, "keine", "Einmalposten sind keine laufenden Flows sie wirken ausschliesslich aufs Cash.", ""));
phaseTraces.push({ title: "Cash-Übergang: einmalige Sonderein-/ausgaben", specAnchor: "496-cash-einmalige-sonderein-ausgaben", steps });
}
yearsBefore += duration;
}
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson));
return { phases: result, yearly, nachlass, ruinAge, ahvCareer };
// --- Plan-weite Rechenwege ---
const planTraces: Trace[] = [];
if (explain) {
planTraces.push({
title: "Inflation und die drei Deflatoren",
specAnchor: "45-nominal-real-und-die-deflatoren",
steps: [
st("Angenommene Inflationsrate", plan.inflationRateDefault, undefined, undefined, "Gilt plan-weit für alle Lebensphasen; eine phasenspezifische Rate gibt es nicht.", "%"),
st("Planlaufzeit", totalYears, undefined, undefined, undefined, "Jahre"),
st("Kumulierte Inflation am Planende", Math.round(cumInfl[totalYears] * 10000) / 10000, "Produkt von (1 + Inflation) über alle Jahre", `(1 + ${pct(plan.inflationRateDefault)})^${totalYears}`, undefined, ""),
st("Kaufkraft eines heutigen Frankens am Planende", Math.round((1 / (cumInfl[totalYears] || 1)) * 10000) / 10000, "1 / kumulierte Inflation", undefined,
"Bestände am Phasenende, Bestände zu Phasenbeginn und Flow-Endwerte nutzen drei UNTERSCHIEDLICHE Deflatoren ein Flow fällt IM Jahr an, ein Bestand wird NACH dem Jahr gemessen.", ""),
],
});
for (const career of ahvCareer) {
const before = ahvBeforeByPerson.get(career.personId) ?? { avg: 0, gap: 0 };
const mdJE = ahvMdje(career, before.avg, before.gap);
planTraces.push({
title: `AHV-Beitragskarriere ${career.role === "PERSON_A" ? "Person A" : "Person B"}`,
specAnchor: "442-beitragskarriere-und-mdje",
steps: [
st("Beitragsjahre im Plan", career.planYears, "Summe (Phasendauer Ausfalljahre)", undefined, undefined, "Jahre"),
st("Durchschnittliches Bruttoeinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
"Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."),
st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn 21 Ausfalljahre davor", undefined, undefined, "Jahre"),
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined,
before.avg === 0 && career.yearsBeforePlan > 0 ? "Noch nicht erfasst am Pensions-Übergang zu prüfen." : "Aus der AHV-Rentenvorausberechnung übernommen."),
st("Massgebendes durchschnittliches Jahreseinkommen", Math.round(mdJE),
"(Einkommen davor × Jahre davor + Einkommen im Plan × Jahre im Plan) / Total Jahre", undefined,
"REAL gerechnet: die echte AHV wertet vergangene Einkommen auf UND indexiert die Schwellen beides hebt sich real weitgehend auf."),
st("Ausfalljahre total", (gapYearsByPerson.get(career.personId) ?? 0) + before.gap, undefined, undefined, undefined, "Jahre"),
],
});
}
planTraces.push({
title: "Ruinalter",
specAnchor: "47-die-jahresschleife",
steps: [
st("Ruinalter", ruinAge ?? 0, undefined, ruinAge === null ? "kein Ruin das Gesamtvermögen bleibt über die ganze Planung positiv" : `Alter ${ruinAge}`,
"Bezieht sich auf das GESAMTvermögen inklusive Immobilien, nicht auf das Cash. Wird nur beim ersten Auftreten gesetzt.", ruinAge === null ? "" : "Jahre"),
st("Endvermögen (Nachlass, nominal)", nachlass, undefined, undefined, "Entspricht dem nominalen Endvermögen der letzten Lebensphase."),
],
});
}
return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
}
// Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit
+149
View File
@@ -54,3 +54,152 @@ export const PILLAR_3A_MAX_ANNUAL = 7258;
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jaehrliche Rente = Kapital x Satz)
export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug)
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstueckgewinnsteuer
// --- Strukturierte Fassung fuer die Systemparameter-Ansicht (Roadmap Nr. 41) -------------
//
// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberflaeche
// Wert, Bedeutung, Herleitung und Quelle anzeigen, OHNE dass die Zahlen ein zweites Mal
// gepflegt werden muessen -- die Eintraege referenzieren die Konstanten direkt und koennen
// deshalb nicht von ihnen abdriften.
export type ParameterGroup = "AHV" | "Vorsorge" | "Steuern";
export interface SystemParameter {
key: string;
label: string;
value: number;
unit: "CHF" | "%" | "Jahre" | "Anzahl";
meaning: string;
derivation?: string; // nur bei abgeleiteten Werten
source: string;
validFrom: string;
group: ParameterGroup;
editablePerEvent?: boolean; // Default-Satz, pro Ereignis ueberschreibbar
}
export const SYSTEM_PARAMETERS: SystemParameter[] = [
{
key: "AHV_MIN_MONTHLY_FULL",
label: "Mindestbetrag der vollen Monatsrente (R0)",
value: AHV_MIN_MONTHLY_FULL,
unit: "CHF",
meaning:
"Ankerwert der ganzen Rentenskala 44. Sämtliche Schwellen der Rentenformel sind Vielfache von R0 ändert sich R0, verschiebt sich die gesamte Skala.",
source: 'BSV/MAS «Berechnungsvorschriften der AHV/IV-Renten»; amtliche Tabelle 318.117.1 «Monatliche Vollrenten, Skala 44»',
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_PENSION_MONTHS",
label: "Rentenzahlungen pro Jahr",
value: AHV_PENSION_MONTHS,
unit: "Anzahl",
meaning:
"13 statt 12, weil seit 1.1.2026 die 13. Altersrente ausgerichtet wird. Die Rentenformel liefert Monatsbeträge; der Jahresbetrag ist deshalb × 13.",
source: "Art. 34bis AHVG (13. Altersrente)",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_MAX_ANNUAL_SINGLE",
label: "Maximale einfache Altersrente pro Jahr",
value: AHV_MAX_ANNUAL_SINGLE,
unit: "CHF",
meaning: "Obergrenze der Einzelrente. Wird ab einem massgebenden Durchschnittseinkommen von 90'720 erreicht.",
derivation: `2 × R0 × ${AHV_PENSION_MONTHS} = 2 × ${AHV_MIN_MONTHLY_FULL} × ${AHV_PENSION_MONTHS} = ${AHV_MAX_ANNUAL_SINGLE}`,
source: "abgeleitet aus R0 und der Anzahl Rentenzahlungen",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_FULL_CONTRIBUTION_YEARS",
label: "Volle Beitragsdauer (Rentenskala)",
value: AHV_FULL_CONTRIBUTION_YEARS,
unit: "Jahre",
meaning: "Für eine ungekürzte Rente nötige Beitragsjahre. Jedes fehlende Jahr kürzt die Rente um 1/44.",
source: "AHVG, Rentenskala 44",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_CONTRIBUTION_START_AGE",
label: "Beginn der Beitragspflicht",
value: AHV_CONTRIBUTION_START_AGE,
unit: "Jahre",
meaning:
"Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag, faktisch ab Alter 21. Bestimmt, wie viele Beitragsjahre vor dem Planbeginn liegen.",
source: "AHVG",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_COUPLE_CAP_FACTOR",
label: "Ehepaar-Plafonierung",
value: AHV_COUPLE_CAP_FACTOR * 100,
unit: "%",
meaning:
"Die Summe beider Einzelrenten ist auf 150 % der Einzel-Maximalrente begrenzt. Bei Überschreitung werden beide Renten proportional gekürzt.",
derivation: `${AHV_MAX_ANNUAL_SINGLE} × ${AHV_COUPLE_CAP_FACTOR} = ${AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR} CHF pro Jahr für das Paar`,
source: "AHVG",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_GROSS_FROM_NET_FACTOR",
label: "Umrechnung Netto- auf Bruttolohn (für die AHV)",
value: AHV_GROSS_FROM_NET_FACTOR,
unit: "Anzahl",
meaning:
"Das Tool erfasst das Einkommen netto (so stimmt der Cash-Fluss), die AHV bemisst sich am Bruttolohn. Dieser Faktor rechnet für die Rentenberechnung zurück.",
derivation:
"Arbeitnehmer-Abzüge: AHV/IV/EO 5.30 % + ALV 1.10 % + NBU ~1.00 % + PK ~25 % = ~913 % → 1/(1q) = 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,
},
];
+255
View File
@@ -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([]);
});
});