Files
FPT/SPEZIFIKATION.md
T
admGitAICDS b47d3d0a3e
Deploy App / deploy (push) Successful in 1m10s
Modul-Review 1: Auth-Nachbesserungen (Sicherheit & UX)
Ergebnis der ersten Test- und Review-Runde zum Modul "Zugang & App-Rahmen":

- Login-Timing-Ausgleich: unbekannter Benutzer wird gegen Dummy-bcrypt-Hash
  geprueft -> Antwortzeit verraet nicht mehr, ob ein Name existiert
- Zurueck-Knopf nach Logout: pageshow-Waechter prueft die Session erneut und
  leitet die aus dem bfcache zurueckgeholte Ansicht auf /login
- Rate-Limiting (neues lib/rate-limit.ts): Login 10/15min, Registrierung
  5/h je IP, Passwortaenderung 10/15min je Benutzer; 429 + Retry-After
- Passwort-Dialog laeuft neu ueber Modal -> schliesst auf Esc (Fokus-Falle,
  aria-modal inklusive)
- Registrierungs-Fehler getrennt: nur belegter Name = 409 mit freundlicher
  Meldung, sonst 500 statt roher Prisma-Meldung

SPEZIFIKATION 0.27 (3.1.2/3/4, neues 3.1.6). 261 -> 267 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:45:31 +02:00

4403 lines
273 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FPT Financial Planning Tool
## Funktionale und Technische Spezifikation
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.27 |
| **Datum** | 2026-07-24 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `d6855ef` inkl. Sicherheits-Nachbesserungen Auth (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` |
---
## Änderungshistorie
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.27 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 1 (Zugang & App-Rahmen): fünf Nachbesserungen an der Authentifizierung.** Ergebnis der ersten gemeinsamen Test- und Review-Runde. (1) **Timing-Ausgleich beim Login:** Ein unbekannter Benutzer wird neu gegen einen Dummy-bcrypt-Hash geprüft, damit die Antwortzeit dieselbe ist wie bei einem bekannten -- vorher liess sich aus der Dauer ablesen, ob ein Benutzername existiert (Kap. 3.1.2). (2) **Zurück-Knopf nach Logout:** Die aus dem Browser-Cache (bfcache) zurückgeholte, eingefrorene Ansicht prüft neu beim `pageshow` die Session und leitet ohne Anmeldung sofort auf `/login` -- vorher blieb die alte Ansicht sichtbar (Kap. 3.1.3). (3) **Rate-Limiting:** Login (10/15 min je IP), Registrierung (5/h je IP) und Passwortänderung (10/15 min je Benutzer) sind gegen Durchprobieren gebremst; neues In-Memory-Modul `rate-limit.ts`, 429 mit `Retry-After` (Kap. 3.1.6). (4) **Passwort-Dialog** läuft neu über die zentrale `Modal`-Komponente und schliesst damit auf Esc (Fokus-Falle, aria-modal inklusive) -- war zuvor von Hand gebaut (Kap. 3.1.4). (5) **Registrierungs-Fehler** werden sauber getrennt: nur der belegte Benutzername ist ein 409 mit freundlicher Meldung, jeder andere Fehler ein 500 statt einer rohen Prisma-Meldung als Konflikt. 6 Tests ergänzt (261 → 267). Offen für Roadmap #34: kein Passwort-Längen-Maximum (bcrypt-72-Byte-Grenze), keine ARIA-Labels auf Login/Palette. |
| 0.26 | 2026-07-21 | Claude (Opus 4.8) | **Pensionsalter anpassen** (Roadmap Nr. 44, neue Kapitel 3.12, 4.4.7 und 4.16). Bisher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Lebensphase endet ein frei geändertes Alter hätte die Phasengrenze zerrissen. Neu wird nicht das Alter geändert, sondern **die Grenze verschoben**: Die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt gleich. Der Spielraum endet dort, wo eine angrenzende Phase unter ein Jahr fiele; ein Schritt weiter **entfällt sie ganz**, was vorher bestätigt wird, weil dabei zwei Übergänge **zusammengelegt** werden (bereits getroffene Entscheide bleiben, leere Felder werden aus dem entfallenden Übergang ergänzt, einmalige Cash-Beträge werden addiert der Steuersatz betragsgewichtet). Das Pensionsalter ist damit auch **Treiber im Tornado** und **Regler in der Live-Simulation**. **AHV-Referenzalter (4.4.7):** Die Rente beginnt neu **immer mit 65**, unabhängig vom Pensionsalter wer länger arbeitet, erhält sie zusätzlich zum Lohn; wer früher aufhört, zahlt bis 65 einen **Beitrag als Nichterwerbstätige(r)**, der als laufende Ausgabe auf die Verzehrquote schlägt und mit 65 wegfällt (neues Feld `ahvContribution`, ohne Default, Hilfetext nennt die reale Bandbreite von rund 530 bis 26'500 CHF pro Jahr). Beide Wechsel können **innerhalb** einer Phase liegen, die AHV wird deshalb **jahresweise** statt phasenweise gerechnet. **Punkt B:** Ein Einkommen, das einer **Person** zugeordnet ist, fällt bei deren Pensionierung auf 0 bisher lief der Lohn stillschweigend in die Pension weiter. Gemeinsame Einkommen (Mieterträge o. Ä.) bleiben; ein ausdrücklich erfasster Betrag gewinnt, damit ein Teilzeitpensum modellierbar bleibt. **Punkt A:** Wiederkehr-Parameter (Raten, Beiträge, Amortisation, Wertsteigerung, Zinssatz) werden neu **live aus der Vorphase geerbt** statt beim Anlegen der Phase kopiert sichtbar als angehaktes **«Aus Vorphase übernehmen»** je Feld. Vorher blieb die Kopie stehen, wenn man die Vorphase später änderte. **Punkt C:** Der Kapitalzufluss am Pensions-Übergang (PK, 3a, Verkaufserlös) lässt sich in **Prozent** auf Amortisation, Anlage und Cash aufteilen bewusst nicht in Franken, weil sich der Betrag mit dem Pensionsalter ändert und eine Quote mitskaliert. **Nebenbei ein echter Fehler behoben:** Der fortgeschriebene Basiswert für Einkommen und Ausgaben wurde **vor** der Jahresschleife berechnet effektive Werte kamen dadurch nie in der Folgephase an. Neues Modul `retirement.ts`, neuer Endpunkt `POST /api/scenarios/<id>/retirement`; 40 Tests ergänzt (221 → 261). |
| 0.25 | 2026-07-21 | Claude (Opus 4.8) | **PDF-Berichte** (Roadmap Nr. 11, neues Kapitel 3.11). Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: Liste der erzeugten Berichte plus Assistent zum Anlegen (Titel, Notiz, nominal **oder** real, Plan- oder effektive Daten, bis zu **drei** Szenarien, beliebige gespeicherte Analysen). Das **Layout ist immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen: Deckblatt mit Zusammenfassung und drei Kernaussagen, dann je Szenario Kennzahlen, Vermögensverlauf, Lebensphasen und Annahmen, danach Vergleich, Plan/Ist, Analysen und die Hinweise. **Die PDF-Datei wird als Datei abgelegt** (BYTEA in Postgres, nicht im Container-Dateisystem, das jeder Deploy neu baut): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein eine Neuerzeugung könnte das nach Änderungen an Plan, Rechenkern oder Layout nicht garantieren. **Kennzahlen je Szenario:** Endvermögen, Kapitalreichweite, Vermögen und Vorsorgekapital bei Pensionierung, AHV- und PK-Rente sowie die **offenen Entscheide** die einzige unmittelbar handlungsleitende Zahl. Damit Bericht und Matrix nie verschiedene Zahlen nennen, liegt deren Zählung neu als reine Funktion in `decisions.ts`, die beide benutzen. **Zu jeder Kennzahl steht ihre Grundlage** als kurzer Verweis; die vollständigen Annahmen (Startwerte, Renditen, Raten je Element) stehen **einmal** je Szenario, statt bei jeder Kennzahl wiederholt zu werden. Ein **Haftungsausschluss** ist verpflichtend und durch einen Test gesichert ein formal gesetztes PDF wird sonst als Beratung gelesen. **Technik:** `pdfkit` in der Node-Runtime statt Headless-Browser (kein Chromium im Image); `@react-pdf/renderer` schied aus, weil es mit React 19 / Next 16 bricht. Diagramme entstehen als **echte Vektoren** aus den gespeicherten Zahlen genau dafür wurden die Analysen in 0.24 als Zahlen und nicht als Bilder abgelegt. `pdfkit` ist als externes Paket deklariert, weil es Font-Metriken über Dateipfade lädt und gebündelt erst in der Produktion bräche. Neue Tabelle `Report`, Endpunkte unter `/api/plans/<id>/reports`, neue Module `report.ts`, `report-pdf.ts`, `decisions.ts`; 9 Tests ergänzt (212 → 221). |
| 0.24 | 2026-07-20 | Claude (Opus 4.8) | **Navigation auf Plan-Ebene und gespeicherte Analysen** (neues Kapitel 3.10). Die Seitenleiste ist neu zweistufig: Unter jedem Plan liegen die Unterpunkte **Szenarien**, **Effektive Werte** und **Analysen**; ein Klick auf den Plan-Namen öffnet ein **Plan-Dashboard** (Kennzahlen Haushaltsdaten direkt, gerechnete Werte ausdrücklich «laut Basisszenario», dazu die Ist-Abweichung, falls erfasst). Die **Szenario-Liste** zeigt je Szenario Version, Elementzahl, Endvermögen und Ruinalter mit den Aktionen Historie und Matrix; das Basisszenario ist hervorgehoben, der Szenario-Baum in der Seitenleiste bleibt daneben erhalten. Die **Analysen-Ansicht** bietet vier umklappende Kacheln (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren Umklappen auch per Antippen für Touch). **Grafiken** öffnen neu nicht mehr alle drei Diagramme, sondern lassen zuerst **eines** wählen; der Szenario-Vergleich zieht mit zu den Grafiken, der CSV-Export auf die Matrix. **Gespeicherte Analysen:** Jede Grafik, MC-Simulation und Einflussfaktoren-Berechnung kann festgehalten werden als **Zahlen, nicht als Bild** (read-only, es wird nichts neu gerechnet). Das hält den Datensatz klein und macht ihn druckfähig: Der spätere PDF-Bericht (Roadmap Nr. 11) zeichnet daraus vektoriell in Druckauflösung, was ein Screenshot nicht könnte. Bewusst **nicht** gespeichert wird `finalWealthSorted` (megabyteweise). Jedes Werkzeug schreibt sein Ergebnis in dieselbe generische Form, sodass eine Nur-Lese-Ansicht genügt. Neue Tabelle `SavedAnalysis`, neue Endpunkte unter `/api/plans/<id>/analyses` und `/dashboard`; neue Komponenten `PlanViews`, `SavedAnalysisView`, `SaveAnalysisButton`, neues Modul `analyses.ts`. Kein Eingriff in den Rechenkern; Testbestand 212 (Migration um `SavedAnalysis` erweitert). |
| 0.23 | 2026-07-20 | Claude (Opus 4.8) | **V7: Der Haushalt liegt am Plan, die Annahmen am Szenario.** **Haushaltsform**, **Personen** (Name, Alter) und **Planstartjahr** wandern vom Szenario auf den **Plan**; das **Pensionsalter** bleibt szenario-eigen (es ist der Kern jedes Früh-/Spätpensionierungs-Szenarios), ebenso Inflation und Cash-Anfangswert. Begründung: Diese Angaben beschreiben den Haushalt, nicht eine Planungsvariante unterscheiden sie sich, ist es ein anderer **Plan**, kein anderes Szenario. Neue Tabelle `PlanPerson` (Rolle, Name, Alter je Plan); `Person` behält nur noch Rolle und Pensionsalter; `Plan` bekommt `householdType` und `startYear`. **Der Rechenkern bleibt unberührt:** `toPlanInput()` fügt Plan- und Szenario-Ebene wieder zu einem unveränderten `PlanInput` zusammen, die 43 Golden Tests laufen durch. **Nebeneffekt, der ein reales Problem löst:** Weil das Startjahr nun plan-weit ist, landet ein erfasster Ist-Satz für 2031 in **allen** Szenarien zwingend auf demselben Planjahr vorher war das nicht garantiert. Das **Wiederherstellen einer Szenario-Version** setzt folgerichtig nur noch das Szenario-Eigene zurück; plan-weite Angaben über eine Version *eines* Szenarios zu überschreiben, hätte die übrigen stillschweigend mitverändert. Der Profil-Dialog kennzeichnet neu je Feld, ob es **plan-weit** oder **nur dieses Szenario** gilt. Die Migration übernimmt die Werte aus dem **Basisszenario**; zwei neue Tests spielen dafür echte V6-Daten ein und prüfen die Übernahme inkl. abweichender Nebenszenarien (210 → 212). |
| 0.22 | 2026-07-20 | Claude (Opus 4.8) | **Fehlerbehebung: Cash-Vorbelegung im Ist-Wizard.** Der Wizard für die effektiven Werte zeigte als geplanten Cash-Bestand den Stand am **Phasenende** statt am gewählten Stichtag -- in einer Phase von 2026 bis 2036 also für 2031 den Wert von 2036. Ursache: Der Dialog las `cashBridge.cashEnd`, weil `computePlan` den Cash-Bestand bisher nur **je Phase** auswies. `YearPoint` trägt neu ein Feld `cash` (Stand am Jahresende), analog zu `wealthNominal`; der Wizard liest daraus. Die Vorbelegung der ELEMENTE war nie betroffen -- die stammte schon immer aus dem Jahresverlauf. Zwei Regressionstests decken den gemeldeten Fall ab (208 -> 210). Rein additiv, die 43 Golden Tests laufen unverändert. |
| 0.21 | 2026-07-20 | Claude (Opus 4.8) | **Effektive Werte / Plan-Ist-Vergleich** (Roadmap Nr. 5, neue Kapitel 3.9 und 9.29). Macht aus dem Planer ein Monitoring-Werkzeug. Neuer Knopf **«Effektive Werte»** auf Plan-Ebene: Liste der Erfassungen plus Wizard in zwei Schritten (Stichtag, dann alle Elemente **aller** Szenarien inkl. **Cash**, Einkommen und Ausgaben, vorbelegt mit dem Planwert für dieses Jahr). Ein Ist-Satz hängt am **Plan**, nicht am Szenario die Wirklichkeit ist dieselbe, egal wogegen man sie hält; die Zuordnung läuft über die Herkunfts-Kette `sourceElementId`. Das exakte Datum steht in Liste und Zeitachse, für die Rechnung zählt nur die **Jahreszahl**. **Zweiter Rechenlauf:** `computePlan` nimmt neu `{ actuals }`; die Werte schnappen in **jedem** erfassten Jahr auf die Realität und laufen von dort planmässig weiter (Lücken fallen auf die Plandaten zurück). Ohne die Option verhält sich die Funktion exakt wie bisher die 43 Golden Tests laufen unverändert. Der Sprung wird als eigene Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke): Eine Planabweichung ist keine Rendite, und ohne diese Zeile ginge die Zerlegung im Ist-Jahr nicht mehr auf. **Matrix:** zweiter Umschalter «Plan» / «Effektiv»; im Ist-Modus steht neben dem Wert die **Abweichung** zum Plan, farbig bewusst kein «beide», das wären mit nominal/real acht Zahlen je Zelle (Begründung 9.29). **Zeitachse:** Marker je erfasstem Jahr, der jüngste farbig, ältere blass. **Alle vier Analysewerkzeuge** erhalten eine einheitliche Leiste (nominal/real als **Einfach**auswahl, Plan/Effektiv); im Vermögensverlauf kommt die Planlinie **gestrichelt** als Referenz dazu, max. vier Serien. **Monte-Carlo:** Der Zielbetrag dreht mit real/nominal mit und wird entsprechend beschriftet; eine Zeile weist aus, ab welchem Jahr simuliert wird die Jahre davor sind durch Ist-Werte belegt und werden nicht gewürfelt. Das Startjahr ist **abgeleitet, nicht eingebbar**: Ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst wurden. Ein Ist-Satz erzeugt **keine** Szenario-Version er ist eine Beobachtung, keine Planänderung. Neue Tabelle `ActualsSet` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/plans/<id>/actuals`, neue Module `actuals.ts` und `dataview.ts`; 27 Tests ergänzt (181 → 208). |
| 0.20 | 2026-07-19 | Claude (Opus 4.8) | **Raten über Lebensphasen übernehmen** (neues Kapitel 3.6.11) und **Rate in der Verlaufsgrafik**. (1) Ändert man ein **Ratenfeld**, fragt das Bearbeitungspanel neu nach der Reichweite: **nur diese Phase** (Vorgabe, bisheriges Verhalten), **diese + folgende** oder **alle Phasen**. Anlass war, dass eine geänderte Rendite bisher nur für die eine Phase galt und viermal eingetippt werden musste. Als Ratenfelder gelten `expectedReturn` (PK, 3a, Sonstiges Vermögen), `valueGrowth` und `interestRate` (Immobilie) sowie `teuerungsausgleich` (Einkommen, Ausgaben); AHV und Schulden haben keine. Die Rückfrage erscheint **inline und erst beim Speichern wirksam**, nicht als Modal das Zahlenfeld löst bei jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. Sie erscheint nur bei **tatsächlich veränderten** Raten («nicht gesetzt» und 0 gelten als gleich). Beim Übertragen bleiben die **übrigen Werte der Zielphasen erhalten** der Endpunkt ersetzt den ganzen Werte-Satz, ein blosses Kopieren des Entwurfs hätte dort Beträge, Sparraten und Bezüge gelöscht (durch Test abgesichert). Phasen, in denen der Wert schon stimmt, werden übersprungen. Kein neuer Schreibpfad: ein PUT je Zielphase über den bestehenden Endpunkt, alle in einer Bearbeitungssitzung und damit in **einer** Nebenversion. (2) `ElementYearPoint` führt neu ein Feld **`rate`** mit additiv, es wird nur durchgereicht, was die Rechnung ohnehin benutzt; die 43 Golden Tests laufen unverändert. Die Verlaufsgrafik der Element-Detailansicht zeigt die Rate damit auf einer **zweiten Y-Achse rechts** in Prozent, als **Stufenlinie** (innerhalb einer Phase konstant, Sprung an der Phasengrenze). Neues Modul `ratefields.ts`; 17 Tests ergänzt (164 → 181). Keine DB- oder API-Änderung. |
| 0.19 | 2026-07-19 | Claude (Opus 4.8) | **Versionierung und Änderungshistorie je Szenario** (neues Kapitel 3.8). Jedes Szenario trägt eine Version **A.B**: **B** entsteht automatisch, **A** manuell mit Pflichtkommentar. **Der zentrale Entwurfsentscheid:** FPT hat keinen Speichern-Knopf jede Änderung schreibt sofort, ein Assistenten-Durchlauf macht ~14 Schreibvorgänge, ein Verteil-Klick einen je Zielelement. Eine Version je Schreibvorgang wäre ein Tastenprotokoll gewesen; stattdessen werden alle Schreibvorgänge innerhalb von **10 Minuten zu einer** Nebenversion zusammengefasst, inhaltlich unveränderte Stände erzeugen gar keine, und verschiedene Benutzer laufen nie in einer Version zusammen. Eine Version hält den **vollständigen** Zustand als JSON in der Form `PlanInput` dadurch ist die **Versionsauswahl in allen vier Analysewerkzeugen** (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) fast kostenlos; bei Monte-Carlo **je Szenario einzeln**, weil dort mehrere gleichzeitig laufen. **Wiederherstellen** ist ungefährlich gebaut: Es legt den zurückgesetzten Stand selbst als neue Version an («Wiederhergestellt aus A.B»), löscht also nichts, und **erhält die IDs** von Phasen und Elementen sonst verlören alle Kind-Szenarien ihre Diff-Basis und zeigten schlagartig alles als «neu». Wo ein Bezug trotzdem bricht (der alte Stand kannte das Element noch nicht), **warnt der Dialog vorher namentlich**. Die destruktive Logik liegt als reine Funktion `planRestore` vor und ist dort getestet; `versioning-db.ts` führt sie nur aus. Ein **statischer Wächter-Test** liest alle Route-Dateien und verlangt, dass jeder schreibende Endpunkt eine Version auslöst eine vergessene Stelle wäre eine stille Lücke. Neue Tabelle `ScenarioVersion` + `Scenario.currentMajor` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/scenarios/<id>/versions`. Neue Kapitel 3.8 und 9.28; 25 Tests ergänzt (139 → 164). |
| 0.18 | 2026-07-19 | Claude (Opus 4.8) | **Live-Simulation** (Roadmap Nr. 22). Neuer Button und Dialog als Zweispalter: links Schieberegler, rechts eine wählbare Grafik, darüber eine Kennzahlenleiste. Dreht man an einem Regler, wird der Plan **sofort** neu gerechnet ohne für jede Variante eine Szenario-Kopie anzulegen. **Keine eigene Rechenlogik:** Die Regler benutzen dieselben Transformationen wie der Tornado (`applyDriver`), können also gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse. Neu ist nur `applyElementDriver` dieselbe Verschiebung auf ein **einzelnes** Element statt auf eine ganze Kategorie: Standardmässig gibt es einen Sammelregler «Rendite», ein Klick auf «Renditen einzeln aufschlüsseln» ersetzt ihn durch je einen Regler pro Anlage (der Sammelregler wird dabei **entfernt**, nicht ergänzt, sonst zählte eine Bewegung doppelt; ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben). Der unveränderte Plan wird als **Referenzlinie** mitgezeichnet, und die Kennzahlenleiste weist Endvermögen nominal/real **mit Differenz zum Plan** aus sowie als eigene Karte ob das Kapital reicht; ein gekippter Plan ist einer Verlaufslinie sonst nicht anzusehen. Gemessene Laufzeit von `computePlan`: **0.2 ms** auf einem 60-Jahres-Plan mit 10 Elementen, also rund 1 % des 16-ms-Frame-Budgets deshalb wird synchron gerechnet, **ohne Debounce und ohne Worker**. Anders als der Tornado haben die Regler **Standardbereiche** (Begründung des scheinbaren Widerspruchs zu 9.18: neues Kapitel 9.27), beide Enden editierbar. **Das Pensionsalter fehlt weiterhin** (9.18, eigener Roadmap-Punkt); «Als Szenario speichern» ist bewusst zurückgestellt, ersatzweise zeigt der Dialog die aktive Einstellung als lesbare Zeile. Die Vermögensaufteilung wurde als `AllocationChart` aus dem Dashboard herausgelöst, damit beide sie nutzen. Neue Kapitel 4.15 und 9.27; 15 Tests ergänzt (124 → 139). Keine API-, DB- oder Schreib-Änderung das Feature liest ausschliesslich. Nebenbei dieselbe vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) wie in 0.17, diesmal im Dashboard. |
| 0.17 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo: zwei Welten, vier Fälle.** Behebt einen Darstellungs-Widerspruch: Zuvor konnte «Planung 69 % erreicht» neben «Ziel 3 Mio nur 41 %» stehen, obwohl 3 Mio unter dem Plan-Endbetrag von 3.7 Mio lag die beiden Zahlen stammten aus **verschiedenen simulierten Welten**. Neu läuft die Simulation **immer zweimal** (historische Renditen / geplante Werte, gemeinsamer Seed) und liest aus **jeder** Verteilung **beide** Schwellen ab: Plan-Endbetrag und Zielbetrag. Fall 1 und Fall 3 stammen damit aus derselben Verteilung, wodurch ein tieferes Ziel **nie** unwahrscheinlicher sein kann als ein höheres der Widerspruch ist strukturell ausgeschlossen (Test). Zweite Korrektur: Der Nullpunkt für das Urteil ist **nicht 50 %**, sondern **Fall 2** (derselbe Schwellwert in der eigenen geplanten Welt); durch den Volatilitäts-Drag liegt der je nach Streuung bei 2748 %. Verglichen wird Fall 1 gegen Fall 2 mit ± 5 pp Toleranzband → «zurückhaltend / realistisch / zu optimistisch». Darstellung: Fall 1 prominent mit Urteil, Fall 3+4 als Satzpaar untergeordnet, Fall 2 und die Mediane klein als Referenz. Der Drei-Wege-Umschalter aus 0.16 entfällt; historische Mittelwerte **und** Zielbetrag sind jetzt beide Pflicht. Technisch: `MonteCarloResult.finalWealthSorted` (alle Endvermögen sortiert) plus neuer Helfer `probabilityAtLeast` (Binärsuche) vier Zahlen aus zwei Läufen statt vier Läufen. Kapitel 4.12.7 und 9.26 neu gefasst; 3 Tests ergänzt (121 → 124). Keine Änderung am Rechenkern. |
| 0.16 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo mit zwei Fragestellungen** (Roadmap Nr. 46). Ein Umschalter oben trennt: **«Planung prüfen»** (Fall 1, wie bisher) würfelt um die **historischen** Renditen und prüft gegen den **Planungs-Endbetrag** (read-only) «wie realistisch ist meine Planung?». **«Ziel prüfen»** (Fall 2, neu) würfelt um die **geplanten** Werte aus dem Plan und prüft gegen einen **manuellen Zielbetrag** «erreiche ich mein Ziel?»; hier sind keine historischen Mittelwerte nötig, nur die Streuung. **«Beides»** rechnet beide Durchgänge gleichzeitig (gemeinsamer Seed). Neue Ergebnis-**Deutungstexte** je Fall (weich formuliert wegen des Volatilitäts-Drags). Der Fächer stammt aus dem historischen Durchgang; der Zielbetrag ist in Fall 2 **einer für alle** Szenarien. `runMonteCarloMulti` nimmt neu die Inflation **je Szenario** (`inflationMeanFor`); neuer Helfer `plannedReturnOf`. Kapitel 4.12 überarbeitet, 4.12.7 und 9.26 neu; 8 Tests ergänzt (119 → 121). Nebenbei eine vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) korrigiert. Keine Änderung am Rechenkern. |
| 0.15 | 2026-07-19 | Claude (Opus 4.8) | **Plan-Assistent überarbeitet** (Schritt 2 und 4). Rein an der Oberfläche, keine Änderung an Berechnung, Datenmodell oder API. **(Schritt 2 Lebensphasen):** Die Lebenslinie zerfällt neu an den **fixen Pensionierungszeitpunkten** in Abschnitte (neues reines Modul `phaseplan.ts`, `planSegments`): Erwerb (alle arbeiten), Misch (eine pensioniert, eine arbeitet), Pension (alle pensioniert) jeweils mit **kurzer Definition**. In den durch eine Pensionierung **fest begrenzten** Abschnitten verteilt der Nutzer beliebig viele Phasen mit **+/Papierkorb** und **eigenem Namen je Phase**; eine Live-Summe erzwingt, dass die Phasendauern exakt aufgehen («Weiter» ist bis dahin gesperrt). Der letzte Pensions-Abschnitt ist **offen** (Lebensdauer frei). Die Anzahl Abschnitte wird **abgeleitet** Einzelplan: 2 (Erwerb, Pension); Paar mit unterschiedlichem Pensionsalter: 3. Neue **Zeitachse** mit Pensionierungs-Flaggen und nummerierter Beschriftung **unter** dem Balken (auch kurze Phasen bleiben lesbar). Behebt den Fehler, dass die Erwerbsphase zuvor beliebig über die Pensionierung hinaus gesetzt werden konnte. **(Schritt 4 Vorsorge & Vermögen):** bei Paaren aufgeteilt in **Gemeinsam / Person A / Person B**; PK und 3a sind je Person, Wertschriften/Wohneigentum/Schulden je Bereich (gemeinsam oder pro Person). Neue Kapitel 3.2.8 überarbeitet; 8 Tests ergänzt (111 → 119). |
| 0.14 | 2026-07-18 | Claude (Opus 4.8) | **Phasenkopf überarbeitet und zwei Verteil-Werkzeuge.** (1) **Zweizeilige Wertdarstellung:** Im Anzeigemodus «Beide» steht der Realwert neu in Klammern in einer **eigenen Zeile** unter dem nominalen Wert statt daneben im Phasenkopf *und* in den Matrix-Zellen. Der Pfeil wiederholt sich auf der zweiten Zeile, damit der Bezug Start → Ende erhalten bleibt. Nebeneffekt: Die Spalten werden schmaler, wodurch **jede Kennzahl umbruchfrei** (`whitespace-nowrap`) dargestellt werden kann. (2) Die Kennzahl heisst korrekt **«Sparquote»** bzw. **«Verzehrquote»** statt «Quote»/«Verzehr». (3) Neuer Block **«Verfügbares Kapital»** im Phasenkopf (ab Phase 2, nur wenn > 0): der beim Übergang zugeflossene Topf mit «davon verteilt» und «Rest auf Cash». (4) Zwei neue Werkzeuge als eigene Popups: **«Kapital verteilen»** (Zusatzeinlagen in PK/3a/Vermögen, Sonderamortisation, Sofort-Tilgung) und **«Sparquote/Bezug verteilen»** (jährliche Raten), beide mit **Live-Vorschau** über eine erneute `computePlan`-Rechnung im Browser die angezeigte Wirkung ist dadurch per Konstruktion exakt die spätere, inklusive aller Kappungen. Der Quoten-Dialog weist neben erstem und letztem Jahr die **absolute Quote über die ganze Phase** aus und warnt, wenn die Quote über die Phase sinkt. Neues reines Modul `distribution.ts`. Neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3 überarbeitet. 8 Tests ergänzt (103 → 111). **Keine Änderung an Berechnung, Datenmodell oder API** beide Werkzeuge schreiben ausschliesslich bestehende Felder über bestehende Endpunkte. |
| 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.63.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. |
| 0.12 | 2026-07-18 | Claude (Opus 4.8) | **Lesbarkeit der Wasserfälle, Verkaufspreis-Abgleich und Erklärung wirkungsloser Tornado-Treiber.** (1) Die beiden Wasserfälle werden **nicht mehr mit Recharts** gezeichnet, sondern als eigene liegende Darstellung: Verbindungslinien zwischen den Balken, Wertbeschriftung an jedem Schritt, Abschnitts-Überschriften („Am Übergang" / „Innerhalb der Phase") und eine aufklappbare Tabelle mit **laufendem Zwischenstand**. Anlass war, dass die bisherige Darstellung faktisch nicht lesbar war die Zahlen waren korrekt, die Grafik nicht. (2) Der Restposten beider Brücken wird bei Abweichung neu als **Fehlermeldung** ausgewiesen statt als beiläufige „Rundungsdifferenz"; eine nicht aufgehende Zerlegung ist ein Rechenfehler und kein Schönheitsproblem. (3) **Verkaufspreis einer Immobilie** wird beim Wechsel auf „Verkaufen" neu mit dem **modellierten Verkehrswert** vorbelegt; der Dialog weist Verkehrswert und Abweichung aus und warnt ab 10 % Differenz (Kap. 3.5.8, 9.22). Damit fällt auf, wenn angenommene Wertsteigerung und erwarteter Verkaufspreis nicht zusammenpassen. (4) Der Tornado erklärt neu **Nullbalken** statt sie stumm zu zeigen insbesondere den Fall, dass die Immobilien-Wertsteigerung bei einem Verkauf nachweislich wirkungslos ist (`ineffectiveReason`, Kap. 4.13.5). Neue Kapitel 3.5.8, 4.13.5, 9.22; 11 Tests ergänzt (92 → 103), darunter die Invariante `residual === 0` über sieben Plankonstellationen. Keine DB-Änderung, keine Änderung an der Berechnung. |
| 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. |
| 0.7 | 2026-07-17 | Claude (Opus 4.8) | **Monte-Carlo-Simulation** (Roadmap Nr. 19, Stufe A). Button in der Planansicht → Dialog mit Erklärung, Eingaben und Ergebnis. Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet; ausgewiesen werden **Ruinwahrscheinlichkeit**, **Erfolgswahrscheinlichkeit** (P(Endvermögen ≥ Zielbetrag)) und ein **Fächer** (10 %/Median/90 %) plus die deterministische Linie. Läuft komplett im Browser (`computePlan` ist rein). Modell: fettschwänzige Verteilung (Student-t, ν=5), gemeinsamer Marktschock (ρ=0.7), Böden 0 % für PK/3a und 100 % sonst. Pro renditetragendem Element und für die Inflation je: historischer Ø (Pflicht), Streuungsstufe, σ. Neue Datei `montecarlo.ts` + optionaler `sample`-Parameter in `computePlan` (Inflation neu als kumulatives Array; deterministisch identisch). Neues Kapitel 4.12; 7 MC-Tests + Refactor-Absicherung (41 → 48). Keine Verhaltensänderung, keine DB-Änderung. |
| 0.6 | 2026-07-17 | Claude (Opus 4.8) | **Teilverkauf** von Sonstigem Vermögen (Roadmap Nr. 42) und **Sonderamortisation** der Hypothek (Roadmap Nr. 15). `OTHER_ASSET` am Übergang neu: Halten / Verkaufen / **Teilverkauf** ein Betrag fliesst ins Cash (erscheint als „Kapitalzufluss" im Phasenkopf), der Rest bleibt investiert. `REAL_ESTATE` im Halten-Fall neu mit **Einmaltilgung** aus dem Cash (analog zur Sofort-Tilgung bei Schulden; erscheint als „Kapitalinvestition"). Damit lässt sich die indirekte Amortisation via 3a mechanisch nachbilden. Die eigentliche Roadmap Nr. 15 (Steuerwirkung der indirekten Amortisation) bleibt mit dem Steuer-Bündel 13/14/23 zurückgestellt siehe 9.14. Kapitel 4.9.3/4.9.4 ergänzt. Fünf Regressionstests (36 → 41). Keine Verhaltensänderung für bestehende Pläne. |
| 0.5 | 2026-07-17 | Claude (Opus 4.8) | **Netto/Brutto geklärt** (Roadmap Nr. 9, reduziert) und **Immobilien-Modul erweitert** (Roadmap Nr. 8). Einkommen ist neu explizit als **Nettolohn** definiert (Label und Hilfetext); für die AHV rechnet das Tool intern mit `AHV_GROSS_FROM_NET_FACTOR = 1.12` auf den Bruttolohn hoch die AHV bemisst sich am Brutto, die bisherige Netto-Basis unterschätzte die Rente um bis zu ~1'900/Jahr. Immobilie neu mit **Hypothekarzins** (% der Restschuld, sinkt mit der Amortisation, mit Doppelzählungs-Schalter) und **Wertsteigerung** (auf die **Liegenschaft**, nicht auf das Eigenkapital Hebeleffekt). Grundstückgewinnsteuer bemisst sich neu explizit am ursprünglichen Kaufpreis. Keine Aufschlüsselung bei Einkommen oder Ausgaben, keine Steuerschätzung (Begründung: 9.14). Neues Kapitel 4.4.5; 4.6.5 und 4.9.4 überarbeitet; Abschnitt 9 um zwei Punkte ergänzt. Sechs Regressionstests (30 → 36). **Verhaltensänderung:** siehe 9.13. |
| 0.4 | 2026-07-17 | Claude (Opus 4.8) | **AHV-Rente einkommensabhängig** (Roadmap Nr. 3) und **Fortschreibungs-Warnhinweis** (Roadmap Nr. 4). Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) über das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente; verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen). Prüfung der Beitragskarriere am Pensions-Übergang, mit Zusatzfeldern für die Jahre vor Planbeginn (ab Alter 21). Alles **real** gerechnet. Neue Konstanten `AHV_MIN_MONTHLY_FULL`, `AHV_PENSION_MONTHS`, `AHV_CONTRIBUTION_START_AGE`; `AHV_MAX_ANNUAL_SINGLE` neu abgeleitet. Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren. Neue Kapitel 3.5.6, 4.4; Abschnitt 9 um zwei Punkte ergänzt. Zwölf Regressionstests (18 → 30). **Verhaltensänderung:** siehe 9.10. |
| 0.3 | 2026-07-16 | Claude (Opus 4.8) | **Einmalige Sonderein-/ausgaben** umgesetzt (Roadmap Nr. 1). Der Cash-Übergang zwischen zwei Phasen ist neu ein eigener Entscheid: 1:1 übernehmen · einmaliger Zufluss · einmalige Kosten · beides. Beträge gehen direkt aufs Cash-Konto. Zufluss wird nominal erfasst (real angezeigt) mit optionalem Steuersatz (Default 0 %), Kosten werden real erfasst (nominal angezeigt); beide bleiben aus der Sparquote heraus. Der Entscheid startet unbeantwortet und zählt im „offen"-Badge mit. Neue Spalte `Phase.cashTransition` (JSON) + Migration, neue Route `PUT /api/phases/<id>/cash-transition`, eigene Kennzahlen im Phasenkopf. Fünf Regressionstests ergänzt (13 → 18). Neue Kapitel 3.5.5, 4.9.6; Abschnitt 9 um zwei Grenzen ergänzt. |
| 0.2 | 2026-07-16 | Claude (Opus 4.8) | Drei Fixes umgesetzt und dokumentiert: **(1)** `Phase.inflationRate` ersatzlos entfernt (DB-Migration, API, Typen, UI) die Inflation liegt seit V5 plan-weit; das Feld war wirkungslos. **(2)** Amortisation und Tilgung stoppen neu, sobald Hypothek bzw. Schuld abbezahlt sind sie belasten danach weder Cash noch Sparquote (Kap. 4.6.5, 4.6.7, 4.7, 4.8). **(3)** Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezügen vor der Pensionierung, inkl. Steuerfeld und Netto-Vorschau im UI (Kap. 3.5.2, 4.9.1, 4.9.2). Kennzahl `plannedSaveRate` ist neu die Rate des **ersten** Phasenjahres. Drei Regressionstests ergänzt (10 → 13). Abschnitt 9 neu nummeriert (erledigte Punkte entfernt, „Sparraten werden nicht indexiert" ergänzt). |
| 0.1 | 2026-07-16 | Claude (Opus 4.8) | Erstfassung. Vollständige Neuerstellung aus dem Code (Stand `f768e01`). Ersetzt die bisherigen FDD/TDD-Dokumente v1v5 vollständig. |
> **Pflegehinweis:** Dieses Dokument ist ein lebendes Dokument. Bei jeder Aktualisierung wird
> (a) die Änderungshistorie um eine Zeile ergänzt, (b) die Versionsnummer im Kopf hochgezählt
> und (c) der referenzierte Codestand (Commit) aktualisiert.
>
> Die Versionsnummer ist ein **Zähler nach dem Punkt**, keine Dezimalzahl: auf 0.9 folgt 0.10,
> darauf 0.11 und so weiter. Ein Sprung auf 1.0 ist damit dem Erreichen eines echten
> Meilensteins vorbehalten und passiert nicht als Nebeneffekt des Hochzählens.
---
## Inhaltsverzeichnis
1. [Einleitung und Abgrenzung](#1-einleitung-und-abgrenzung)
2. [Fachliche Grundkonzepte](#2-fachliche-grundkonzepte)
3. [Funktionale Spezifikation](#3-funktionale-spezifikation)
4. [Berechnungsmodell](#4-berechnungsmodell)
5. [Technische Spezifikation](#5-technische-spezifikation)
6. [API-Referenz](#6-api-referenz)
7. [Betrieb und Deployment](#7-betrieb-und-deployment)
8. [Qualitätssicherung](#8-qualitätssicherung)
9. [Bekannte Einschränkungen und Modellentscheide](#9-bekannte-einschränkungen-und-modellentscheide)
10. [Glossar](#10-glossar)
---
# 1. Einleitung und Abgrenzung
## 1.1 Zweck des Tools
Das FPT ist eine Webanwendung zur persönlichen Finanzplanung über Lebensabschnitte hinweg,
ausgelegt auf Schweizer Verhältnisse (AHV, Pensionskasse, Säule 3a, Grundstückgewinnsteuer).
Ein Benutzer bildet seine finanzielle Situation als Kette von **Lebensphasen** ab, hinterlegt
**finanzielle Elemente** (Einkommen, Ausgaben, Vorsorge, Immobilien, Vermögen, Schulden) und
trifft an den **Übergängen** zwischen den Phasen bewusste Entscheide (halten, verkaufen,
Kapital beziehen, verrenten). Das Tool rechnet daraus Jahr für Jahr das Vermögen, den
Cash-Bestand, Spar- bzw. Verzehrquoten sowie ein allfälliges Ruinalter fort.
## 1.2 Was das Tool nicht ist
Aus dem Code direkt ableitbare Abgrenzungen:
- **Keine Steuerberechnung** ausser den drei explizit modellierten Sätzen
(Kapitalbezugssteuer, Grundstückgewinnsteuer, PK-Umwandlungssatz). Einkommens- und
Vermögenssteuern sind nicht modelliert und müssen vom Benutzer in den Ausgaben erfasst werden
(Begründung: [9.14](#914-keine-steuerschätzung)).
- **Keine Nebenkosten, kein Eigenmietwert, keine Mieteinnahmen** bei Immobilien
(siehe [9.3](#93-immobilien-was-noch-fehlt)).
- **Keine automatische Deckung von Liquiditätslücken.** Negatives Cash wird gemeldet, aber nicht
korrigiert (siehe [9.1](#91-cash-wird-nicht-automatisch-ausgeglichen)).
- **Keine Mehrbenutzer-Kollaboration.** Pläne gehören genau einem Benutzer.
> **Hinweis zur Dokumenthistorie:** Bis Version 0.9 stand hier zusätzlich „keine
> Monte-Carlo-Simulation", „keine Hypothekarzinsen" und „keine Wertentwicklung von Immobilien".
> Alle drei sind seit Version 0.5 bzw. 0.7 umgesetzt ([4.4.5](#445-netto-brutto-umrechnung-für-die-ahv),
> [4.6.5](#465-real_estate-immobilie), [4.12](#412-monte-carlo-simulation)); die Abgrenzung war
> versehentlich stehen geblieben.
Die deterministische Rechnung bleibt der Kern des Tools; Stochastik kommt ausschliesslich in der
**Monte-Carlo-Simulation** ([4.12](#412-monte-carlo-simulation)) und als reine Was-wäre-wenn-
Rechnung in der **Sensitivitätsanalyse** ([4.13](#413-sensitivitätsanalyse-tornado)) dazu.
Beide verändern die gespeicherten Plandaten nicht.
## 1.3 Kernprinzip: Plan als selbsttragende Einheit
Seit dem V3-Rework (Migration `20260713150000_profile_to_plan_v3`) trägt **jeder Plan sein
eigenes Grundprofil**: Haushaltsform, Personen (Alter, Pensionsalter, Name) und
Inflationsannahme. Es gibt keine übergeordnete Haushalts-Entität mehr. Ein Szenario ist
deshalb eine vollständige Deep-Copy und vom Ursprungsplan unabhängig veränderbar.
Referenz: `prisma/schema.prisma` Zeilen 810, `src/lib/types.ts` Zeilen 3849.
---
# 2. Fachliche Grundkonzepte
## 2.1 Die Ebenen
```
User
└── Plan (Behälter: nur Name — KEINE Finanzdaten)
└── Scenario[] (die berechenbare Einheit)
│ Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert
│ isBase = genau eines je Plan · parentScenarioId = Baum + Vergleichsbasis
├── Person[] (1 bei SINGLE, 2 bei COUPLE — je Szenario eigen!)
├── Phase[] (Kette 1..n; sourcePhaseId = Gegenstück in der Vorlage)
└── FinancialElement[] (szenario-weit; sourceElementId = Gegenstück in der Vorlage)
├── ElementPhaseValue[] (Werte je Phase, JSON)
└── ElementTransitionValue[] (Entscheide je Übergang, JSON)
```
**Zwei Designprinzipien tragen dieses Modell:**
**(1) Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität.** Eine
Pensionskasse „PK Arbeitgeber" existiert einmal pro Szenario; sie hat pro Phase einen Werte-Satz
und pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eines
Vermögensgegenstands über die Zeit erhalten Voraussetzung für die Fortschreibung (Carry) und
die Vermögensaufteilungs-Grafik.
**(2) Das Grundprofil liegt am Szenario, nicht am Plan** (V6). Nur so lassen sich die
wertvollsten Szenario-Fragen abbilden allen voran ein abweichendes **Pensionsalter**
(„Was, wenn ich mit 62 statt 65 aufhöre?"), das in `Person` steckt. Wäre das Profil geteilt,
wären Frühpensionierungs-Szenarien unmöglich.
Der **Plan** ist damit ein reiner Behälter: Er bündelt Szenarien und trägt den Eigentümer
(`userId`). Ownership von Szenario/Phase/Element läuft über die Kette
`Scenario → Plan → User`.
Referenz: `prisma/schema.prisma`.
## 2.2 Die Matrix als Leitmetapher
Die Hauptansicht ist eine Tabelle:
- **Zeilen** = finanzielle Elemente, gruppiert nach Kategorie; zuoberst die systemseitige,
read-only Zeile **Cash**.
- **Spalten** = abwechselnd **Phasenspalten** und **Übergangsspalten**
(Phase 1 → Übergang → Phase 2 → Übergang → Phase 3 …). Der letzten Phase folgt keine
Übergangsspalte.
- **Zellen** = anklickbar; Phasenzellen öffnen die Werte-Eingabe, Übergangszellen den
Entscheid-Dialog.
Referenz: `src/components/PlanView.tsx` Zeilen 111120 (Spaltenaufbau), 333477 (Matrix).
## 2.3 Phasentyp abgeleitet, nicht gespeichert
Der Typ einer Phase wird **nie gespeichert**, sondern in jeder Berechnung aus Alter und
Pensionsalter der Personen abgeleitet:
| Bedingung (zu Phasenbeginn) | Typ |
|---|---|
| alle Personen `startAge < retirementAge` | `ERWERB` |
| alle Personen `startAge >= retirementAge` | `PENSION` |
| gemischt | `MIXED` |
Referenz: `src/lib/calculations.ts` Zeilen 158172; Kommentar in `prisma/schema.prisma` Zeilen 9495.
## 2.4 Cash als Ausgleichskonto
Cash ist kein vom Benutzer erfassbares Element, sondern das systemseitige Ausgleichskonto:
- Es startet mit `Plan.initialCash` (Phase 1).
- Es nimmt jährlich die **Spar-/Verzehrquote** (Einkommen nominale Ausgaben) auf.
- Es finanziert die **geplanten Sparraten** (3a-Beiträge, Sparbeiträge, Amortisationen, Tilgungen).
- Es empfängt die **Bezugsraten** aus Sonstigem Vermögen.
- Es empfängt an Übergängen **Kapitalzuflüsse** (Verkäufe, PK-/3a-Bezüge) und finanziert
**Sofort-Tilgungen** sowie **Zusatzinvestitionen** der Folgephase.
- Es nimmt an Übergängen **einmalige Sonderein-/ausgaben** auf (Erbschaft, Poolbau, Autokauf)
siehe [3.5.5](#355-cash-übergang-einmalige-sonderein-ausgaben).
- Es darf **negativ werden** dies ist die Definition einer Liquiditätslücke und wird rot
markiert, aber nicht automatisch korrigiert.
Cash ist die einzige Zeile der Matrix ohne `FinancialElement`-Datensatz. Zwei Zellen sind
dennoch bearbeitbar: die **erste** Phasenzelle (Cash-Anfangswert) und **jede Übergangszelle**
(einmalige Sonderein-/ausgaben).
Referenz: `src/lib/calculations.ts` (Jahresschleife und Übergang).
---
# 3. Funktionale Spezifikation
## 3.1 Authentifizierung und Benutzerkonto
### 3.1.1 Registrierung
- Öffentlich zugänglich (kein Einladungscode, keine Freischaltung).
- Benutzername: Muster `^[a-zA-Z0-9._-]{3,32}$` 3 bis 32 Zeichen, Buchstaben, Zahlen, Punkt,
Unterstrich, Bindestrich. Muss plan-übergreifend eindeutig sein (`User.username @unique`).
- Passwort: mindestens 6 Zeichen; wird mit bcrypt (Cost-Faktor 12) gehasht.
- Bei Erfolg wird sofort eine Session gesetzt (Login inbegriffen), HTTP 201.
- Bei belegtem Benutzernamen: HTTP 409 mit Meldung „Dieser Benutzername ist bereits vergeben."
Referenz: `src/lib/users.ts` Zeilen 622, `src/app/api/auth/register/route.ts`.
### 3.1.2 Anmeldung
- Benutzername + Passwort; Prüfung via `bcrypt.compare`.
- Fehlermeldung ist bewusst unspezifisch: „Benutzername oder Passwort falsch." (HTTP 401)
verrät nicht, ob der Benutzer existiert. Auch die **Antwortzeit** verrät es nicht: Ein
unbekannter Benutzer wird gegen einen Dummy-bcrypt-Hash geprüft, damit die Dauer dieselbe
ist wie bei einem bekannten mit falschem Passwort (`TIMING_DUMMY_HASH` in `users.ts`).
- Bei Erfolg: JWT (HS256, Payload `{ userId }`, Gültigkeit 30 Tage) im HttpOnly-Cookie
`fpt_session` (`sameSite=lax`, `secure` nur in Produktion, `maxAge` 30 Tage).
Referenz: `src/app/api/auth/login/route.ts`, `src/lib/auth.ts`.
### 3.1.3 Abmeldung
`POST /api/auth/logout` löscht das Cookie. Da die Session zustandslos ist (JWT), bleibt ein
bereits kopiertes Token bis zum Ablauf technisch gültig es gibt keine serverseitige
Token-Sperrliste.
Nach dem Abmelden führt der **Zurück-Knopf** des Browsers zur zuletzt gezeigten Ansicht aus
dem *bfcache* zurück -- ohne neue Anfrage, also ohne dass Middleware oder Session-Prüfung
greifen. Damit die eingefrorene, scheinbar noch angemeldete Ansicht nicht stehen bleibt,
prüft die Hauptseite beim `pageshow`-Ereignis (nur bei einer aus dem bfcache zurückgeholten
Seite) erneut `/api/auth/me` und leitet ohne gültige Session sofort auf `/login`. Es sind
dabei nie echte Daten freigegeben -- das Cookie ist gelöscht --, aber die alten Zahlen sollen
auf einem geteilten Rechner gar nicht erst wieder sichtbar werden. Referenz: `src/app/page.tsx`.
### 3.1.4 Passwortänderung
Im Profilmenü. Erfordert das aktuelle Passwort; das neue Passwort muss ≥ 6 Zeichen haben und
wird im Dialog gegen eine Wiederholung geprüft (Client-seitig). Nach Erfolg erscheint 1.2 s lang
„Passwort geändert.", dann schliesst der Dialog. Der Dialog läuft über die zentrale
`Modal`-Komponente und schliesst damit auch auf **Esc** (Fokus-Falle und `aria-modal`
inklusive, siehe 3.7.6).
Referenz: `src/app/api/auth/change-password/route.ts`, `src/components/ProfileMenu.tsx` Zeilen 117140.
### 3.1.5 Zugriffsschutz
Zweistufig:
1. **Middleware** (`src/middleware.ts`, Edge-Runtime): schützt alle Pfade ausser
`/login`, `/api/auth/login`, `/api/auth/register`, `/_next/*`, `/favicon*`.
Ohne gültiges Token → API-Aufrufe erhalten HTTP 401, Seitenaufrufe werden nach
`/login?next=<pfad>` umgeleitet.
2. **Ownership-Check in jeder API-Route**: `getCurrentUserId()` plus eine Abfrage, die den
Datensatz nur zurückgibt, wenn er dem Benutzer gehört (`getOwnedPlan`, `getOwnedPhase`,
`getOwnedElement` in `src/lib/queries.ts`). Ein fremder Datensatz führt zu HTTP 404
(nicht 403) die Existenz wird nicht preisgegeben.
### 3.1.6 Missbrauchsschutz (Rate-Limiting)
Login, Registrierung und Passwortänderung sind gegen wiederholtes Durchprobieren gebremst
(`src/lib/rate-limit.ts`):
| Endpunkt | Grenze | Schlüssel |
|---|---|---|
| `POST /api/auth/login` | 10 / 15 min | Client-IP |
| `POST /api/auth/register` | 5 / 60 min | Client-IP |
| `POST /api/auth/change-password` | 10 / 15 min | Benutzer-ID |
Bei Überschreitung: **HTTP 429** mit lesbarer Meldung und `Retry-After`-Header. Der Zähler ist
ein **In-Memory-Fixed-Window** bewusst einfach, weil das Tool in einem einzigen Container
läuft; nach einem Deploy ist er leer. Die Client-IP kommt aus `X-Forwarded-For` (Traefik);
ohne Header fallen alle auf denselben Eimer, was im Zweifel eher zu stark als zu schwach
bremst. Für einen Mehrinstanz-Betrieb müsste der Zähler nach Redis o. Ä. wandern (Roadmap #34).
**Bewusst offen (Roadmap #34, Pentest):** kein Passwort-Längen-Maximum (bcrypt prüft still nur
die ersten 72 Byte), keine ARIA-Labels auf der Login-Maske und der Befehls-Palette.
## 3.2 Plan-Verwaltung
### 3.2.1 Plan erstellen
Der «+»-Knopf öffnet eine Auswahl mit drei Wegen:
| Weg | Für wen | Was passiert |
|---|---|---|
| **Geführt erstellen** (empfohlen) | neue Nutzer | der Assistent aus [3.2.8](#328-geführter-assistent-und-beispielplan) |
| **Leer starten** | geübte Nutzer | der bisherige Dialog (unten) nur Grundprofil, keine Phasen/Elemente |
| **Beispielplan ansehen** | Erkunden | legt einen fiktiven, voll ausgefüllten Plan an ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
Der Dialog «Leer starten» fragt Name plus das vollständige Grundprofil:
| Feld | Typ | Default | Wertebereich |
|---|---|---|---|
| Name des Plans | Text | „Basisplan" | 1120 Zeichen |
| Haushaltsform | Auswahl | `SINGLE` | `SINGLE` / `COUPLE` |
| Name je Person | Text (optional) | leer | ≤ 60 Zeichen |
| Aktuelles Alter | Zahl | 35 | 0120 |
| Pensionierungsalter | Zahl | 65 | 30100 |
| Erwartete Inflationsrate (%) | Zahl | 1.5 | 20 bis 50 |
Konsistenzregel: `SINGLE` erfordert genau eine Person, `COUPLE` genau zwei (Person A und B).
Verletzung → HTTP 400 mit Klartextmeldung.
Referenz: `src/components/PlanProfileFields.tsx` Zeilen 10121018, `src/app/api/plans/route.ts`
Zeilen 635.
Ein neu erstellter Plan hat **keine Phasen und keine Elemente**; `initialCash` ist 0.
### 3.2.2 Grundprofil ändern
Über „Einstellungen" in der Planansicht. Wichtig: Die Personen werden serverseitig in einer
Transaktion **gelöscht und neu angelegt** (`deleteMany` + `create`). Die Person-IDs ändern sich
dadurch. Da Elemente über `ownerRole` (nicht über `personId`) zugeordnet sind, bleibt die
Zuordnung erhalten.
Ein Wechsel von `COUPLE` auf `SINGLE` entfernt Person B. Elemente mit `ownerRole = PERSON_B`
bleiben in der Datenbank bestehen, finden aber keinen Owner mehr siehe
[9.2](#92-verwaiste-person_b-elemente).
Referenz: `src/app/api/plans/[planId]/route.ts` Zeilen 5570.
### 3.2.3 Cash-Anfangswert
Klick auf die erste Zelle der Cash-Zeile öffnet den Dialog „Cash-Anfangswert". Wertebereich
0 bis 1'000'000'000, wird auf ganze Franken gerundet.
Referenz: `src/components/PlanView.tsx` Zeilen 11301156, `src/app/api/plans/[planId]/route.ts` Zeile 36.
### 3.2.4 Plan löschen
Aus der Übersichtskachel oder der Planansicht, mit Browser-`confirm()`. Löscht per Datenbank-Cascade
Personen, Phasen, Elemente und alle Werte. Szenarien, die auf diesen Plan als `parentPlanId`
zeigen, werden **nicht** gelöscht ihre `parentPlanId` wird auf `NULL` gesetzt
(`onDelete: SetNull`).
Referenz: `prisma/schema.prisma` Zeile 82.
### 3.2.5 Szenarien
Beim Anlegen eines Plans entsteht **automatisch das Basisszenario** (`isBase = true`,
Name „Basisszenario"). Der beim Anlegen erfasste Profilteil (Haushaltsform, Personen,
Inflation) landet dort, der Name am Plan.
Ein weiteres Szenario ist eine **vollständige Kopie eines beliebigen bestehenden Szenarios**
(nicht nur des Basisszenarios). Kopiert werden Grundprofil, alle Personen, alle Phasen, alle
Elemente sowie sämtliche Phasen- und Übergangswerte.
Gesetzt werden dabei:
- `parentScenarioId` = das kopierte Szenario → ergibt den **Baum** in der Seitenleiste **und**
die **Vergleichsbasis** für die Abweichungs-Markierung
- `sourcePhaseId` / `sourceElementId` je kopierter Phase bzw. Element → die **Identität**, über
die der Diff „dieselbe Zelle" wiederfindet
Da jede Kopie wieder kopierbar ist, entstehen **Sub-Szenarien** beliebiger Tiefe; die
Seitenleiste rückt sie entsprechend ein.
**Löschen:** Ein Szenario lässt sich löschen, das **Basisszenario nicht** (dafür den ganzen Plan
löschen). Das Löschen eines Plans entfernt per Cascade alle seine Szenarien.
Referenz: `src/app/api/scenarios/[scenarioId]/copy/route.ts`.
### 3.2.6 Abweichungs-Markierung (Diff)
Jedes Szenario ausser dem Basisszenario wird **live gegen sein Eltern-Szenario** verglichen.
Abweichende Stellen sind in der Matrix farblich markiert:
| Zustand | Darstellung |
|---|---|
| **geändert** | gelb hinterlegt (`--diff` / `--diff-soft`) |
| **neu** (in der Vorlage nicht vorhanden) | grün hinterlegt + Badge „neu" (`--diff-added`) |
| **entfernt** (in der Vorlage vorhanden, hier gelöscht) | graue **Geisterzeile**, durchgestrichen (`--diff-removed`) |
Markiert werden: Phasenzellen, Übergangszellen, Element-Zeilen (Name/Zuordnung), Phasenköpfe
(Name/Dauer), der Cash-Anfangswert, Cash-Übergänge und das Grundprofil-Banner. Über der Matrix
steht die Gesamtzahl der Abweichungen.
Die Farben sind **semantische Tokens** und für Hell, Dunkel und Warm getrennt abgestimmt ein
fester Gelbwert würde im Dunkelschema unbrauchbar aussehen.
**Zwei bewusste Eigenschaften:**
- Der Vergleich läuft gegen das **direkte Eltern-Szenario**, nicht immer gegen die Basis. Bei
einem Sub-Szenario ist das genau „was habe ich gegenüber der Vorlage geändert".
- Der Diff ist **live**: Ändert man die Vorlage, verschiebt sich die Markierung im Kind
rückwirkend (siehe [9.16](#916-diff-ist-live-gegen-die-vorlage)).
Referenz: `src/lib/diff.ts`.
### 3.2.7 Planstart (Kalenderjahr)
Das Grundprofil enthält das Feld **Planstart (Jahr)** das Kalenderjahr, in dem Jahr 1 der
Planung liegt (`Scenario.startYear`). Es dient **ausschliesslich der Darstellung**: Zeitachse
und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unverändert in
**relativen** Jahren ab Planbeginn `startYear` fliesst in keine Formel ein.
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Szenarien wurden per Migration darauf
gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr 1)`.
### 3.2.8 Geführter Assistent und Beispielplan
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und Ausgaben
plus Kontostand, (4) Vorsorge und Vermögen, (5) Zusammenfassung.
**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder
Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem
Erwerbsstatus (reines Modul `phaseplan.ts`, `planSegments`):
| Abschnitt | Bedeutung | Länge |
|---|---|---|
| **Erwerb** | alle arbeiten | fest (bis zur ersten Pensionierung) |
| **Misch** | eine Person pensioniert, eine arbeitet | fest (zwischen den Pensionierungen) |
| **Pension** | alle pensioniert | **offen** (Lebensdauer frei) |
Die **Anzahl** Abschnitte wird abgeleitet, nicht vorgegeben: Einzelplan → 2 (Erwerb, Pension);
Paar mit gleichem Pensionsalter → 2 (keine Mischzeit); Paar mit unterschiedlichem Pensionsalter
→ 3. Ist eine Person bei Planbeginn bereits pensioniert, beginnt die Linie mit einem Misch- oder
Pensions-Abschnitt.
In jedem **fest begrenzten** Abschnitt verteilt der Nutzer beliebig viele Phasen (mit
+/Papierkorb und eigenem Namen je Phase); eine **Live-Summe** erzwingt, dass die Phasendauern
exakt der festen Länge entsprechen «Weiter» bleibt gesperrt, bis es aufgeht. Das ist zwingend:
Die Berechnung leitet den Phasentyp am Phasenbeginn ab und **kappt jede Phase am nächsten
Pensionsereignis** ([2.3](#23-phasentyp--abgeleitet-nicht-gespeichert)) eine Phase, die eine
Pensionierung überspannt, gäbe es nicht. Der frühere Assistent liess die Erwerbsphase beliebig
über das Pensionsalter hinaus setzen; das ist damit behoben. Der **offene** Pensions-Abschnitt
nimmt beliebige Dauern (Summe = geplante Restlebensdauer).
Eine **Zeitachse** zeigt den proportionalen Verlauf mit den Pensionierungs-Fixpunkten als
Flaggen; die Phasen sind nummeriert und **unter** dem Balken beschriftet, damit auch kurze
Phasen lesbar bleiben.
**Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B**
aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien);
Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen.
Zwei bewusste Entscheide bleiben:
- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» in Paar-Plänen zählt
Haushalts-Einkommen nicht für die AHV ([9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv));
der Assistent räumt diese Falle von Anfang an aus.
- Technisch ist der Assistent **reine Orchestrierung bestehender Endpunkte** (Plan → Phase 1 →
Elemente samt Werten → Folgephasen; die Reihenfolge stellt sicher, dass die Phasen-Route die
Folgephasen korrekt vorbelegt). Kein neuer Endpunkt, keine Berechnungsänderung. Grenze: 9.23.
**Der Beispielplan** («Beispiel: Alex Muster», `src/lib/demoplan.ts`) ist ein fiktiver, voll
ausgefüllter Plan per Ein-Klick ebenfalls reine Orchestrierung. Die Übergangs-Entscheide
bleiben **absichtlich offen**: Der neue Nutzer sieht die Ampel («N offen») in Aktion und lernt
das wichtigste Konzept am Beispiel statt aus einer Erklärung.
Nach dem ersten Öffnen eines Plans mit Phasen startet einmalig die **Tour**
([3.7.8](#378-tour-und-nächste-schritte)).
## 3.3 Lebensphasen
### 3.3.1 Phase anlegen
Neue Phasen werden **immer am Ende der Kette** angehängt (`sequenceNumber = Anzahl + 1`).
**Automatische Dauer-Kappung:** Die Dauer wird ans nächste Pensionsereignis gekappt. Formel
(`maxPhaseDuration`): für jede Person, die zu Phasenbeginn noch erwerbstätig ist, gilt
`retirementAge (age + yearsBefore)`; das Minimum dieser Werte ist die Obergrenze. Ist keine
Person mehr erwerbstätig, gibt es keine Obergrenze (`null`). Diese Kappung ist im Dialog
sichtbar („max. N") **und** wird serverseitig erzwungen.
Fachliche Begründung: Eine Phase darf keine Pensionierung überspannen, weil der Phasentyp
und die AHV-/PK-Renten am Phasenbeginn ausgewertet werden.
**Default-Dauer:** die Kappung, sonst 10 Jahre.
**Default-Name:** Phase 1 → „Erste Lebensphase"; sonst „Pensionsphase" wenn zu Phasenbeginn
mindestens eine Person pensioniert ist, sonst „Erwerbsphase".
**Vorbelegung der Elemente:** Beim Anlegen einer Phase wird für jedes noch aktive Element
(nicht `SOLD`, nicht `SETTLED`) ein `ElementPhaseValue` mit den **editierbaren** Feldern der
Vorphase erzeugt (`buildCarryData`):
| Kategorie | Übernommene Felder |
|---|---|
| `INCOME`, `EXPENSE` | nur `teuerungsausgleich` (Basis wird live fortgeschrieben) |
| `AHV` | `gapYears: 0` |
| `PENSION_FUND`, `PILLAR_3A`, `OTHER_ASSET` | `annualContribution`, `expectedReturn` |
| `REAL_ESTATE` | `purchasePrice`, `amortization` (Resthypothek wird live fortgeschrieben) |
| `OTHER_DEBT` | `annualRepayment` |
Bestände (PK-/3a-/Vermögenswert, Resthypothek, Restschuld) werden **bewusst nicht als Snapshot
gespeichert**, sondern in jeder Berechnung live aus der Vorphase fortgeschrieben. Damit wirken
sich nachträgliche Änderungen an frühen Phasen automatisch auf alle Folgephasen aus.
Referenz: `src/app/api/plans/[planId]/phases/route.ts`.
### 3.3.2 Phase bearbeiten
Klick auf einen Phasenkopf öffnet ein **Popup** („Lebensphase: <Name>") mit Bezeichnung und
Dauer konsistent zu allen anderen Eingaben (Element-Zellen, Übergänge). Speichern schliesst
das Popup. Die Dauer wird auch hier gekappt. Eine phasenspezifische Inflationsrate gibt es nicht;
das Panel weist darauf hin: „Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen
gesetzt."
Referenz: `src/components/PhaseDetail.tsx`.
### 3.3.3 Phase löschen
**Nur die letzte Phase** kann gelöscht werden serverseitig geprüft (HTTP 400: „Nur die letzte
Phase kann gelöscht werden."). Damit bleibt die Kette der `sequenceNumber` lückenlos. Der
Löschen-Button erscheint im Detail-Panel nur bei der letzten Phase.
Referenz: `src/app/api/phases/[phaseId]/route.ts` Zeilen 5577.
## 3.4 Finanzielle Elemente
### 3.4.1 Kategorien und Personenzuordnung
| Kategorie | Label | Zuordnung |
|---|---|---|
| `INCOME` | Einkommen | frei: Gemeinsam / Person A / Person B |
| `EXPENSE` | Ausgaben | frei |
| `AHV` | AHV | **zwingend genau eine Person** |
| `PENSION_FUND` | Pensionskasse | **zwingend genau eine Person** |
| `PILLAR_3A` | Säule 3a | **zwingend genau eine Person** |
| `REAL_ESTATE` | Immobilie | frei |
| `OTHER_ASSET` | Sonstiges Vermögen | frei |
| `OTHER_DEBT` | Sonstige Schulden | frei |
Die Vorsorge-Kategorien (`PERSON_ONLY_CATEGORIES`) sind zwingend personengebunden, weil AHV-Rente,
PK-Verrentung und 3a-Bezug am **individuellen** Pensionierungszeitpunkt hängen. Wird für diese
Kategorien kein `PERSON_A`/`PERSON_B` übergeben → HTTP 400.
Für die übrigen Kategorien gilt: fehlt die Zuordnung, wird serverseitig `HOUSEHOLD` gesetzt.
Referenz: `src/lib/elements.ts` Zeilen 1923, `src/app/api/plans/[planId]/elements/route.ts` Zeilen 4354.
### 3.4.2 Element anlegen
Der Dialog „Finanzielles Element" fragt Kategorie, Zuordnung, Bezeichnung **und direkt die
Werte der ersten Lebensphase** ab. Nach dem Anlegen (`POST .../elements`) werden die Werte per
`PUT .../phase/<firstPhaseId>` gespeichert, sofern mindestens ein Feld gesetzt wurde. Der
Bezeichnungs-Default ist das Kategorie-Label.
`orderIndex` = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe.
Referenz: `src/components/PlanView.tsx` Zeilen 799947.
### 3.4.3 Element bearbeiten und löschen
Ein Klick auf eine **Phasenzelle** öffnet den Dialog „Lebensphase: <Name>" mit den
kategorie- und kontextabhängigen Feldern (siehe [3.4.4](#344-feldkatalog-je-kategorie)) sowie
dem Button „Element löschen". Löschen entfernt das Element **aus allen Phasen**
(Browser-`confirm()`, dann Cascade auf `ElementPhaseValue` und `ElementTransitionValue`).
Umbenennen ist per API möglich (`PATCH /api/elements/<id>`), im aktuellen UI aber nicht
angebunden.
### 3.4.4 Feldkatalog je Kategorie
Die angezeigten Felder hängen von drei Kontextgrössen ab:
- **`carried`** ob der Basiswert aus der Vorphase fortgeschrieben wird (ab Phase 2)
- **`ownerWorking`** ob der zugeordnete Besitzer in dieser Phase erwerbstätig ist
- **`durationYears`** Phasendauer (begrenzt z. B. die Ausfalljahre)
Referenz: `src/components/ElementDetail.tsx` Zeilen 124340.
#### INCOME (Einkommen)
**Einkommen ist der NETTOLOHN** der Betrag, der nach allen Lohnabzügen (AHV/ALV,
Pensionskasse, NBU) tatsächlich aufs Konto kommt. Das ist die für den Cash-Fluss richtige
Grösse und die, in der man denkt.
| Feld | JSON | Semantik |
|---|---|---|
| Jahreseinkommen NETTO, nominal (erstes Jahr) | `amount` | Basiswert Jahr 1. Ab Phase 2 vorbelegt mit dem fortgeschriebenen Wert, bewusst änderbar (Teilzeit, Beförderung). |
| ≈ real (heutige Kaufkraft) | | Read-only Info: `amount / deflatorStart` |
| Nominale Lohnerhöhung (%/Jahr) | `teuerungsausgleich` | Default 0 %. 0 % = nominal gleichbleibend, real sinkend. |
Für die **AHV** rechnet das Tool intern auf den Bruttolohn zurück siehe
[4.4.5](#445-netto-brutto-umrechnung-für-die-ahv). Warum die Definition überhaupt nötig war:
Für den Cash-Fluss sind beide Konventionen gleichwertig (`brutto Ausgaben inkl. Abzüge`
`netto Ausgaben ohne Abzüge`), weshalb die Ambiguität lange folgenlos blieb. Mit der
einkommensabhängigen AHV hängt daran aber eine Rente.
#### EXPENSE (Ausgaben)
Hinweistext: „Ausgaben werden REAL erfasst (in heutiger Kaufkraft)."
| Feld | JSON | Semantik |
|---|---|---|
| Jahresausgaben REAL (erstes Jahr) | `amount` | Basiswert in heutiger Kaufkraft. |
| ≈ nominal (in diesem Jahr) | | Read-only Info: `amount * deflatorStart` |
| Reale Mehrausgaben (%/Jahr) | `teuerungsausgleich` | **Zusätzlich** zur Inflation. 0 % = gleicher Lebensstandard. |
Die Asymmetrie (Einkommen nominal, Ausgaben real) ist der Kern des V5-Modells: Man kennt seinen
Lohn nominal, aber seinen Lebensstandard real.
#### AHV
| Zustand | Anzeige |
|---|---|
| Besitzer erwerbstätig | Eingabefeld **Geplante Ausfalljahre** (`gapYears`), 0 bis Phasendauer. Hilfetext: „Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44." |
| Besitzer pensioniert | Nur Hinweistext die Rente wird automatisch berechnet. |
#### PENSION_FUND (Pensionskasse)
| Zustand | Felder |
|---|---|
| erwerbstätig, Phase 1 | **Aktueller PK-Wert** (`currentValue`), **Jährliche Einzahlung** (`annualContribution`), **Erwartete Rendite** (`expectedReturn`) |
| erwerbstätig, ab Phase 2 | **Startwert (fortgeschrieben)** (read-only), **Zusatzeinlage aus Kapital** (`additionalInvestment`), Einzahlung, Rendite |
| pensioniert | Hinweistext (Rente aus Umwandlungssatz bzw. „Vollständig bezogen") |
Wichtig (Hilfetext im UI): Die PK-Einzahlung „Fliesst NICHT in die Sparquote ein (bereits in den
Ausgaben beruecksichtigt)" Lohnabzüge sind im Nettoeinkommen bereits weg.
#### PILLAR_3A (Säule 3a)
Wie PK, aber:
- Die Einzahlung **zählt** zur Sparquote (verlässt das Cash).
- Das Feld ist auf `PILLAR_3A_MAX_ANNUAL` = **7'258 CHF** (2026, mit PK) hart geklammert.
- Bei Pensionierung: „Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen."
#### REAL_ESTATE (Immobilie)
| Feld | JSON | Semantik |
|---|---|---|
| Kaufpreis | `purchasePrice` | nur Phase 1 / Neukauf; ab Phase 2 read-only fortgeschrieben |
| Hypothek | `mortgage` | dito |
| Startwert Netto (fortgeschrieben) | | read-only ab Phase 2: **Verkehrswert** Resthypothek |
| Resthypothek (fortgeschrieben) | | read-only ab Phase 2 |
| Amortisation (CHF/Jahr) | `amortization` | endet, sobald die Hypothek abbezahlt ist |
| **Hypothekarzins (%/Jahr)** | `interestRate` | Zinssatz auf der **Restschuld** |
| **Hypothekarzins-Betrag (Beginn → Ende)** | | read-only: Zinsbetrag im ersten und im letzten Jahr der Phase |
| **Geschätzte Wertsteigerung (%/Jahr)** | `valueGrowth` | wirkt auf die **Liegenschaft** |
| **Sind die Zinsen bereits in den Ausgaben enthalten?** | `interestHandling` | `INCLUDED` (Default) / `ADD` |
Der Zinsbetrag sinkt automatisch mit der Amortisation das kann kein manueller
Ausgabenposten. Beispiel: Hypothek 1'000'000, Amortisation 10'000/Jahr, Zins 1 %, 10 Jahre →
Anzeige `10'000 → 9'100` (Jahr 1 auf 1'000'000, Jahr 10 auf 910'000).
Der Schalter `interestHandling` verhindert die Doppelzählung: Bestehende Pläne haben die
Zinsen im Ausgabenbetrag, deshalb ist `INCLUDED` der Default und das Tool zieht **nichts** ab.
Erst `ADD` rechnet die Zinsen dazu dann gehören sie aus dem Ausgabenbetrag entfernt. Der
Schalter sitzt bewusst auf der **Immobilie** und nicht auf dem Ausgaben-Element: Bei mehreren
Ausgaben-Elementen wäre sonst unklar, welches die Zinsen trägt (und zwei auf „Ja" würden
doppelt zählen).
#### OTHER_ASSET (Sonstiges Vermögen)
| Feld | JSON | Semantik |
|---|---|---|
| Startwert / Startwert (fortgeschrieben) | `startValue` | ab Phase 2 read-only |
| Zusatzinvestition aus Kapital | `additionalInvestment` | nur ab Phase 2 |
| Erwartete Rendite (%/Jahr) | `expectedReturn` | |
| Jährlicher Sparbeitrag | `annualContribution` | fliesst ins Vermögen, verlässt das Cash → **geplante Sparrate** |
| Jährliche Bezugsrate | `annualWithdrawal` | Entnahme aus dem Vermögen ins Cash → **geplante Verzehrrate** |
Die Bezugsrate ist das Instrument für den **laufenden** Kapitalverzehr *innerhalb* einer Phase.
Für einen **einmaligen** Zugriff *am Übergang* gibt es zusätzlich den Teilverkauf (3.5.2, 4.9.3).
#### OTHER_DEBT (Sonstige Schulden)
| Feld | JSON | Semantik |
|---|---|---|
| Restschuld / (fortgeschrieben) | `startValue` | ab Phase 2 read-only |
| Jährliche Tilgung | `annualRepayment` | verlässt das Cash → geplante Sparrate |
Schulden gehen mit **negativem** Vorzeichen ins Vermögen ein.
## 3.5 Übergänge
### 3.5.1 Konzept
Zwischen zwei Phasen liegt ein Übergang. Er ist der Ort, an dem einmalige Entscheide getroffen
werden. Fünf Kategorien haben Übergangs-Entscheide (`TRANSITION_CATEGORIES`):
`PENSION_FUND`, `PILLAR_3A`, `REAL_ESTATE`, `OTHER_ASSET`, `OTHER_DEBT`. Dazu kommt der
**Cash-Entscheid** (siehe [3.5.5](#355-cash-übergang-einmalige-sonderein-ausgaben)), der an
jedem Übergang zu treffen ist.
Für `INCOME`, `EXPENSE` und `AHV` erscheint: „Fuer diese Kategorie gibt es im Uebergang keine
Eingaben."
### 3.5.2 Normaler Übergang vs. Pensions-Übergang
Ein Übergang ist für ein Element ein **Pensions-Übergang**, wenn dessen Besitzer in der Von-Phase
erwerbstätig und in der Ziel-Phase pensioniert ist (`isRetirementTransition`). Nur
personenzugeordnete Elemente (nicht `HOUSEHOLD`) können das auslösen.
| Kategorie | Normaler Übergang (Vorbezug) | Pensions-Übergang |
|---|---|---|
| `PENSION_FUND` | Bezug? Kein Bezug / Bezug + Bruttobetrag (max. Endwert) + **Kapitalbezugssteuer (%)** | **Bezugsart**: Rente / Kapitalbezug / Kombination |
| `PILLAR_3A` | Bezug? Kein Bezug / Bezug + Bruttobetrag + **Kapitalbezugssteuer (%)** | Nur **Kapitalbezugssteuer (%)** Bezug ist zwingend vollständig |
| `REAL_ESTATE` | Halten (+ optionale **Sonderamortisation**) / Verkaufen (+ Verkaufspreis, Grundstückgewinnsteuer) | identisch |
| `OTHER_ASSET` | Halten / Verkaufen / **Teilverkauf** (+ Betrag) | identisch |
| `OTHER_DEBT` | Sofortige Tilgung (CHF) | identisch |
Ein **Vorbezug** (vor der Pensionierung, z. B. für Wohneigentum oder Selbstständigkeit) ist wie
der Bezug bei Pensionierung kapitalbezugssteuerpflichtig. Der eingegebene Betrag ist der
**Bruttobezug**: er wird in voller Höhe dem Vorsorgekapital entnommen, ins Cash fliesst der
Betrag nach Abzug der Steuer. Der Dialog zeigt die resultierende Netto-Auszahlung als
read-only Vorschau an.
Bei PK-Bezugsart:
- **Rente**: Feld Umwandlungssatz (Default 6 %)
- **Kapitalbezug**: Feld Kapitalbezugssteuer (Default 8 %)
- **Kombination**: beide Felder plus „Davon Kapitalbezug (CHF)", geklammert am Endwert
Referenz: `src/components/ElementDetail.tsx` Zeilen 342462.
### 3.5.3 Ampel-Logik: „offene" Entscheide
Ein Entscheid gilt als **beantwortet** (`isTransitionAnswered`), wenn das jeweilige
Entscheidungsfeld gesetzt ist:
| Kategorie | Beantwortet, wenn |
|---|---|
| `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt (inkl. `PARTIAL`) |
| `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt |
| `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt |
| **Cash** | `mode` gesetzt (`isCashTransitionAnswered`) |
| alle anderen | immer beantwortet |
Der Übergangs-Spaltenkopf zeigt entweder „N offen" (Akzentfarbe) oder „geprüft" (grün, Häkchen).
Offene Zellen sind farblich hervorgehoben und zeigen „?".
**Ein Element ist am Übergang inaktiv** (`transitionInactive`), wenn es bereits verkauft/getilgt
ist **oder** wenn es eine PK/3a ist, deren Besitzer schon zu Beginn der Von-Phase pensioniert war
(dann ist bereits bezogen/verrentet). Inaktive Zellen zeigen „–" und sind nicht anklickbar.
Referenz: `src/components/PlanView.tsx` Zeilen 200232, `src/components/ElementDetail.tsx` Zeilen 107120.
### 3.5.4 Geführter Übergang (Review-Dialog)
Klick auf einen Übergangs-Spaltenkopf öffnet „Übergang prüfen: <Von> → <Nach>". Der Dialog
listet zuoberst den **Cash-Entscheid** (einmalige Sonderein-/ausgaben, betrifft jeden Übergang)
und darunter **alle** noch aktiven Elemente der Übergangs-Kategorien mit ihren Entscheidfeldern
und kontextabhängigen Hinweisen:
- PK/3a, normaler Übergang: „Hier könnten Sie optional Kapital beziehen."
- PK, Pensionierung: „Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
- 3a, Pensionierung: „Wird bei Pensionierung vollständig bezogen."
„Alle speichern" schreibt jeden Entscheid einzeln per `PUT`. Entscheidend: Die Formulare sind mit
`withTransitionDefaults` vorbelegt (Halten / Kein Bezug / Rente), damit ein blosses Speichern den
**sichtbaren** Default auch tatsächlich persistiert und die Ampel auf grün geht.
Referenz: `src/components/PlanView.tsx` (`TransitionReviewDialog`), `src/components/ElementDetail.tsx`
(`withTransitionDefaults`).
### 3.5.5 Cash-Übergang: einmalige Sonderein-/ausgaben
Einmalige Ereignisse (Erbschaft, Poolbau, Autokauf, grössere Anschaffung) werden **nicht** als
finanzielles Element modelliert, sondern als Entscheid auf dem **Cash-Konto am Phasenübergang**.
Sie belasten bzw. speisen das Cash direkt.
Der Entscheid hat vier Ausprägungen (`CashTransitionMode`):
| Modus | Bedeutung | Felder |
|---|---|---|
| `NONE` | **1:1 übernehmen** Cash läuft unverändert weiter (Default) | |
| `INFLOW` | **Einmaliger Zufluss** | Bezeichnung, Betrag (nominal), Steuer (%) |
| `OUTFLOW` | **Einmalige Kosten** | Bezeichnung, Betrag (real) |
| `BOTH` | Zufluss **und** Kosten am selben Übergang | beide Feldgruppen |
**Erfassungs-Konventionen** bewusst analog zu den laufenden Flows:
- **Zufluss: nominal erfasst, real angezeigt** (wie Einkommen). Man kennt den Betrag, der
effektiv aufs Konto kommt. Der Realwert erscheint read-only als Info.
- **Kosten: real erfasst, nominal angezeigt** (wie Ausgaben). Man denkt „ein Pool kostet heute
20'000"; die Inflation rechnet daraus den Betrag zum Ereigniszeitpunkt. Der Nominalwert
erscheint read-only als Info.
- **Steuersatz nur beim Zufluss**, Default 0 % (Erbschaften an direkte Nachkommen sind in den
meisten Kantonen steuerfrei). Ins Cash fliesst der Betrag nach Abzug der Steuer.
**Weder Zufluss noch Kosten gehen in die Spar-/Verzehrquote.** Sie sind keine laufenden Flows;
eine Erbschaft von 250'000 würde die Quote zu einem sinnlosen Ausschlag treiben. Sie wirken
ausschliesslich auf das Cash und damit auf Vermögensverlauf, Endvermögen und Ruinalter.
**Bedienung:** Klick auf eine Übergangszelle der Cash-Zeile öffnet den Dialog „Uebergang: Cash".
Die Zelle zeigt `1:1`, `+100'000`, `20'000` bzw. `+100'000 / 20'000`, solange offen ein `?`.
Der Entscheid ist Teil des geführten Übergangs (3.5.4) und zählt im „offen"-Badge mit eine
neu angelegte Phase erzeugt damit automatisch einen offenen Cash-Entscheid am neuen Übergang.
Referenz: `src/components/ElementDetail.tsx` (`CashTransitionFields`), `src/components/PlanView.tsx`
(`CashTransitionDialog`).
### 3.5.6 AHV-Prüfung am Pensions-Übergang
Die AHV-Rente hängt vom **massgebenden durchschnittlichen Jahreseinkommen (mdJE)** über die
ganze Beitragsdauer ab. Diese Grösse kann das Tool nicht allein aus dem Plan bestimmen: Die
Beitragspflicht beginnt mit 21, der Plan aber erst beim heutigen Alter. Bei einer 45-jährigen
Person liegen 24 Beitragsjahre vor dem Planbeginn.
Deshalb ist die Beitragskarriere **am Pensions-Übergang** zu prüfen dort, wo bereits die
PK-Bezugsart und der 3a-Bezug entschieden werden. Der Dialog zeigt:
| Feld | Art |
|---|---|
| Geplantes Durchschnittseinkommen (aus dem Plan) | read-only, real |
| Beitragsjahre im Plan | read-only |
| Durchschnittseinkommen vor Planbeginn (real) | Eingabe **nur wenn Alter bei Planbeginn > 21** |
| Ausfalljahre vor Planbeginn | Eingabe **nur wenn Alter bei Planbeginn > 21** |
| Massgebendes durchschnittliches Jahreseinkommen | read-only, live berechnet |
| Resultierende AHV-Rente pro Jahr | read-only, live berechnet |
Die beiden Eingabefelder erscheinen also nur, wenn sie fachlich gebraucht werden. Die Zelle
zeigt `Geprueft` bzw. `?`; der Entscheid zählt im „offen"-Badge mit. An allen **anderen**
Übergängen ist die AHV-Zelle inaktiv (``).
**Wichtig zum Feld „vor Planbeginn":** Der Wert ist **real** (heutige Kaufkraft). Der
AHV-Kontoauszug listet Einkommen historisch-nominal ein Lohn von 2008 steht dort mit dem
Betrag von 2008 und wäre zu tief. Die Zahl stammt idealerweise aus der **Rentenvorausberechnung**,
dort ist die Aufwertung bereits enthalten. Der Hilfetext im Feld sagt das.
**Sonderfall „bei Planbeginn bereits pensioniert":** Dann gibt es keinen Pensions-Übergang. Die
gleichen Felder erscheinen stattdessen in der **AHV-Phasenzelle der ersten Phase**, zusammen mit
der resultierenden Rente als Live-Vorschau.
Referenz: `src/components/ElementDetail.tsx` (`AhvReviewFields`), Formeln in
`src/lib/calculations.ts` (`ahvMdje`, `ahvAnnualPension`).
### 3.5.7 Warnhinweis bei Änderungen in früheren Phasen
Seit dem V3-Rework werden Werte **live fortgeschrieben** (Endwert = Startwert der Folgephase).
Eine Änderung in einer frühen Phase wirkt damit bis ans Planende durch oft unbemerkt. Seit
Roadmap Nr. 3 gilt das verschärft: Ein geändertes Einkommen in Phase 1 verschiebt über das mdJE
auch die AHV-Rente in Phase 5.
Beim Bearbeiten einer Phase, der noch Phasen folgen, erscheint deshalb ein rot abgesetzter
Warnhinweis mit der Anzahl betroffener Folgephasen. Er erscheint in:
- **Phasenzellen** (Werte eines Elements in einer Phase)
- **Phasen-Detail** (Bezeichnung/Dauer eine geänderte Dauer verschiebt alle Folgephasen)
Nicht in Übergangs-Dialogen (dort ist die Wirkung auf die Folgephase offensichtlich) und nicht
beim Anlegen eines Elements (dort gibt es noch nichts zu überschreiben).
Referenz: `src/components/ElementDetail.tsx` (`CarryWarning`).
### 3.5.8 Verkaufspreis und modellierter Verkehrswert
Das Modell führt zwei Immobilienwerte getrennt: den **Verkehrswert**, der mit `valueGrowth`
wächst, und den **ursprünglichen Kaufpreis** als Basis der Grundstückgewinnsteuer
([4.6.5](#465-real_estate-immobilie)). Beim Verkauf zählt jedoch ausschliesslich der vom
Benutzer **erfasste Verkaufspreis** ([4.9.4](#494-real_estate)).
Daraus ergab sich eine stille Inkonsistenz: Man konnte 2 % jährliche Wertsteigerung annehmen
und die Immobilie trotzdem zum Kaufpreis verkaufen, ohne dass das Tool widersprach.
Deshalb gilt seit Version 0.12:
- Beim Wechsel auf **Verkaufen** wird der Verkaufspreis mit dem **modellierten Verkehrswert
am Phasenende** vorbelegt aber nur, wenn noch keiner erfasst ist (bestehende Pläne bleiben
unverändert).
- Der Dialog zeigt den Verkehrswert daneben read-only an und beziffert die **Abweichung** in
Franken und Prozent.
- Ab **10 %** Abweichung wird der Hinweis rot abgesetzt, mit der Aufforderung zu prüfen, ob
Wertsteigerungsannahme und erwarteter Verkaufspreis zusammenpassen.
Der erfasste Preis bleibt **massgebend** die Vorbelegung ist eine Hilfe, keine Bevormundung.
Ein bewusst abweichender Preis (Notverkauf, Liebhaberpreis, Verkauf an Nachkommen) bleibt
möglich. Die Berechnung ist unverändert.
Referenz: `src/components/ElementDetail.tsx` (`ElementTransitionFields`, `REAL_ESTATE`).
## 3.6 Auswertung und Visualisierung
### 3.6.1 Anzeigemodus nominal / beide / real
Ein Umschalter oben in der Planansicht steuert die Darstellung aller Geldbeträge in Matrix und
Phasenköpfen:
| Modus | Darstellung |
|---|---|
| Nominal | `1'234'567` |
| Beide | nominal oben, real in Klammern **darunter** (siehe unten) |
| Real | `890'123` |
Im Modus **Beide** steht der Realwert seit 0.14 in einer **eigenen Zeile** unter dem nominalen
Wert, nicht mehr daneben. Bei Start-/Endwerten wiederholt sich der Pfeil, damit der zeitliche
Bezug erhalten bleibt:
```
30'000 → 10'000
(29'557) → (7'430)
```
Grund: Nebeneinander wird die Zeile so lang, dass die Spalten unnötig breit werden und
Kennzahlen umbrechen. Untereinander bleiben die Spalten schmal erst dadurch lässt sich jede
Kennzahl **umbruchfrei** darstellen. Die Regel gilt im Phasenkopf **und** in den Matrix-Zellen;
in den Modi «Nominal» und «Real» bleibt alles einzeilig.
„real" bedeutet kaufkraftbereinigt auf den **Planbeginn**: `nominal / Deflator`. Die Wahl wird in
`localStorage` unter `fpt-value-mode` gespeichert.
Es werden zwei verschiedene Deflatoren verwendet siehe [4.5.3](#453-die-drei-deflatoren).
Referenz: `src/components/PlanView.tsx` Zeilen 665699.
### 3.6.2 Zeitachse
Horizontale Achse über das Alter (von jüngster Person bis Planende) mit:
- den **Lebensphasen als Segmente**: Breite proportional zur Dauer, Einfärbung nach Phasentyp
(Erwerb kräftig, Misch mittel, Pension hell), beschriftet mit Name, Dauer und sofern ein
Planstart gesetzt ist der **Jahresspanne** (z. B. „20262046")
- Flaggen-Marker je Person am Pensionsalter (Farbe: Person A indigo, Person B hellblau)
- rotem „Ruin <Alter>"-Marker, falls zutreffend
- Alters- **und Jahres**-Beschriftung an beiden Enden
Die Kalenderjahre stammen aus dem Profilfeld **Planstart** (`Scenario.startYear`). Ist es nicht
gesetzt, zeigt die Achse nur Alter die Berechnung ist davon nie betroffen (siehe 3.2.7).
Referenz: `src/components/Timeline.tsx`.
### 3.6.3 Phasenkopf-Kennzahlen
Seit 0.13 ist der Phasenkopf bewusst **schlank** zwölf Kennzahlen in 10-Pixel-Schrift liest
niemand (Progressive Disclosure):
| Kennzahl | Bedeutung |
|---|---|
| Name + Status-Icon | grünes Häkchen oder rotes Warnsymbol (Liquiditätslücke) |
| Typ-Badge + Dauer | Erwerb / Pension / Misch, „N J." |
| Alter je Person | `<Name> <StartAlter> → <EndAlter>` |
| **Verfügbares Kapital** | ab Phase 2 und nur wenn > 0: Topf, davon verteilt, Rest auf Cash ([3.6.9](#369-verfügbares-kapital-im-phasenkopf)) |
| **Sparquote** bzw. **Verzehrquote** | Einkommen Ausgaben; Label wechselt auf „Verzehrquote", wenn Jahr 1 negativ; rot bei Verzehr |
| Vermögen | Start → Ende (inkl. Cash), hervorgehoben |
| Einmalposten | nur als Kurzhinweis (Bezeichnung), wenn vorhanden |
Die beiden Blöcke «Verfügbares Kapital» und «Sparquote» tragen je einen Knopf, der das passende
Verteil-Werkzeug öffnet ([3.6.10](#3610-verteil-werkzeuge)).
Alles Weitere Einkommen/Ausgaben Jahr 1 → letztes Jahr, geplante Spar-/Verzehrrate,
Kapitalzufluss und -investitionen, die vollen Einmalposten wohnt in der
**Phasen-Detailansicht** (seit 0.11, [3.6.7](#367-detailansichten-je-element-und-je-lebensphase)),
erreichbar über das Expand-Icon im Kopf. Die Kennzahlen selbst sind unverändert berechnet; nur
der Anzeigeort hat gewechselt.
Referenz: `src/components/PlanView.tsx` Zeilen 701769.
### 3.6.4 Analyse-Bereich „Grafiken"
Die Auswertungen liegen **nicht** unter der Matrix, sondern in einem eigenen Bereich: Der Button
**Grafiken** in der oberen Aktionsleiste (neben „Neues Szenario aus diesem" und
„Monte-Carlo-Simulation") öffnet sie als breiten Dialog. So bleibt die Matrix die ruhige
Hauptansicht.
**Kennzahl-Karten:** Endvermögen nominal und Endvermögen real. (Die frühere Karte „Geschätzter
Nachlass" ist entfallen sie war rechnerisch identisch mit dem nominalen Endvermögen und
suggerierte eine zusätzliche Information, die es nicht gab.)
**Grafik 1 Einkommen vs. Ausgaben pro Jahr** (`SparquoteChart`): Ein Datenpunkt pro Jahr
über alle Phasen. Grüne Linie = Einkommen nominal (inkl. Renten), rote Linie = Ausgaben nominal,
graue gestrichelte Linie = Ausgaben real. Die Fläche zwischen Einkommen und nominalen Ausgaben
ist grün (Sparquote) oder rot (Verzehr) eingefärbt. Der Keil zwischen roter und grauer Linie ist
anschaulich „das, was die Inflation frisst".
**Grafik 2 Vermögensverlauf nach Alter** (`WealthChart`): Liniendiagramm über das Alter von
Person A. Je Szenario eine durchgezogene Linie (nominal) und eine gestrichelte (real).
Datenpunkte: **jedes Planjahr** (nicht nur die Phasengrenzen) dadurch werden Verläufe
*innerhalb* einer Phase sichtbar, etwa das Abschmelzen im Kapitalverzehr. Grundlage ist
`YearPoint.wealthNominal/wealthReal` (4.10). Über Checkboxen lassen sich die **Geschwister-
Szenarien überlagern**; deren Daten werden bei Bedarf nachgeladen und im Client
zwischengespeichert.
**Grafik 3 Vermögensaufteilung pro Phase**: Gestapeltes Balkendiagramm mit zwei Balken je Phase
(Beginn / Ende). Gestapelt werden alle Elemente der Kategorien PK, 3a, Immobilie, Sonstiges
Vermögen, die irgendwann einen positiven Wert haben. Stapelung nach `elementId` (nicht Name),
damit gleichnamige Elemente nicht kollidieren. Negative Werte werden auf 0 geklammert.
Referenz: `src/components/Dashboard.tsx`, `src/components/WealthChart.tsx`, `src/components/SparquoteChart.tsx`.
### 3.6.5 CSV-Export
`GET /api/plans/<planId>/export` liefert eine semikolon-getrennte CSV, eine Zeile pro Phase:
```
Phase;Typ;Dauer;Einkommen (Beginn);Ausgaben (Beginn);Quote (Beginn);Quote (Ende);Cash (Ende);Endvermoegen (nominal);Endvermoegen (real)
```
Bei vorhandenem Ruin folgt eine Schlusszeile `Ruin: Kapital aufgebraucht mit Alter <N>`.
Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt.
Referenz: `src/lib/calculations.ts` Zeilen 620648.
### 3.6.6 Analyse-Bereich „Einflussfaktoren"
Der Button **Einflussfaktoren berechnen** in der oberen Aktionsleiste (neben „Grafiken" und
„Monte-Carlo-Simulation") öffnet die Sensitivitätsanalyse als eigenen Dialog. Aufbau bewusst
analog zur Monte-Carlo-Simulation:
1. **Erklärung** was die Analyse beantwortet, wie sie rechnet, was man davon hat, und die zwei
ehrlichen Grenzen (Bandbreiten-Abhängigkeit, keine Wechselwirkungen).
2. **Zielgrösse** Endvermögen real (Default) oder nominal.
3. **Parameter** je Treiber eine Checkbox; erst angehakt erscheinen die beiden Pflichtfelder
„tief" und „hoch" in der Einheit des Treibers, mit Hilfe-Bubble zu plausiblen Bandbreiten.
Nicht anwendbare Treiber werden gar nicht erst angezeigt.
4. **Ergebnis** Basisfall, Tornado-Chart und Tabelle.
Der Dialog ist bewusst **nicht** Teil des Grafiken-Bereichs: Dieser ist reine Ausgabe, während der
Tornado Pflichteingaben braucht. Seit 0.26 gehört auch das **Pensionsalter** je Person zu den
Treibern es erscheint nur, wenn die Phasengrenze überhaupt Spielraum hat
(siehe [9.18](#918-tornado-was-der-chart-nicht-leistet) und [4.16](#416-pensionsalter-verschieben)).
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). Zusätzlich die wirksame **Rate** auf einer zweiten Y-Achse rechts in Prozent als **Stufenlinie**, weil sie innerhalb einer Phase konstant ist und an der Phasengrenze springt; eine interpolierte Kurve würde einen gleitenden Übergang suggerieren, den die Berechnung nicht macht. Die Achse erscheint nur, wenn das Element überhaupt eine Rate trägt. 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.6.9 Verfügbares Kapital im Phasenkopf
Für die Planung einer Phase ist die zentrale Frage: **Wie viel Kapital steht überhaupt zur
Verfügung?** Der Phasenkopf weist das ab Phase 2 als eigenen Block aus (nur wenn > 0):
| Zeile | Bedeutung |
|---|---|
| **Verfügbares Kapital** | der gesamte Topf beim Übergang in diese Phase |
| davon verteilt | Zusatzeinlagen + Sonderamortisation + Sofort-Tilgung |
| Rest auf Cash | was auf dem Cash-Konto liegen bleibt (= `cashStart`) |
Der Topf ist vollständig aus der **Cash-Brücke** ([4.14.2](#4142-die-beiden-wasserfälle))
ableitbar es braucht keine zusätzliche Berechnung:
```
Topf = Cash-Ende der Vorphase + Kapitalzufluss + einmaliger Zufluss einmalige Kosten
= cashStart + Investitionen + Sofort-Tilgungen
```
**Nur ab Phase 2:** In der ersten Phase ignoriert die Berechnung `additionalInvestment` dort
tragen die Elemente ihren Startwert direkt ([4.6.3](#463-pension_fund)). Ein «Verteilen» hätte
dort eine andere Bedeutung, deshalb wird es gar nicht erst angeboten.
Referenz: `src/lib/distribution.ts` (`capitalPot`).
### 3.6.10 Verteil-Werkzeuge
Zwei Popups, erreichbar über je einen Knopf im Phasenkopf. Beide schreiben **ausschliesslich
bestehende Felder** über die bestehenden Endpunkte an der Berechnung ändert sich nichts.
**«Kapital verteilen»** verteilt den Topf aus 3.6.9 auf:
| Ziel | geschriebenes Feld | liegt an |
|---|---|---|
| PK, Säule 3a, Sonstiges Vermögen | `additionalInvestment` | **dieser** Phase |
| Immobilie | `extraAmortization` | dem **Übergang davor** |
| Sonstige Schulden | `immediateRepayment` | dem **Übergang davor** |
Dass zwei verschiedene Objekte beschrieben werden, ist eine Folge des Datenmodells: Die
Zusatzeinlage ist ein Phasenwert, Sonderamortisation und Sofort-Tilgung sind Übergangs-Entscheide.
Beide zehren aber vom selben Topf. Betragsfelder werden dabei in die bestehenden Daten
**hineingemischt** vorhandene Entscheide (`decision`, `salePrice`, `payoutMode` …) bleiben
erhalten.
Was nicht verteilt wird, **bleibt automatisch auf dem Cash** dafür braucht es keine Logik, das
ist das Verhalten des Modells. Wird mehr verteilt als vorhanden, startet die Folgephase mit
negativem Cash; der Dialog weist das als Liquiditätslücke aus.
**«Sparquote verteilen»** (bzw. **«Bezug verteilen»** bei Verzehr) verteilt die laufende Quote auf
jährliche Raten: `annualContribution` (3a, Sonstiges Vermögen), `annualWithdrawal` (Sonstiges
Vermögen), `amortization` (Immobilie), `annualRepayment` (Schulden).
> **Die Pensionskasse fehlt hier bewusst.** Ihr Beitrag stammt aus dem Bruttolohn und belastet
> das Cash-Konto nicht ([4.6.3](#463-pension_fund)) er lässt sich also gar nicht aus der Quote
> verteilen.
Der Dialog weist **drei** Bezugsgrössen aus: Quote im ersten Jahr, im letzten Jahr und
entscheidend die **absolute Quote über die ganze Phase**. Letztere ist die Grösse, gegen die
sich eine flache Jahresrate sinnvoll verteilen lässt (siehe [9.25](#925-die-quote-ist-kein-fester-betrag)).
**Live-Vorschau:** Beide Dialoge kopieren den Plan mit den Entwurfswerten und rechnen ihn erneut
durch `computePlan` im Browser, ohne API-Aufruf. Die angezeigte Wirkung ist dadurch **per
Konstruktion exakt die spätere**, inklusive aller Kappungen (Bezugsrate am Bestand, Amortisation
an der Restschuld). Eine Nebenrechnung im UI hätte hier dieselbe Driftgefahr wie bei den
Rechenwegen ([4.14.3](#4143-rechenweg-protokoll)).
Beide Dialoge zeigen den **Fortschreibungs-Warnhinweis** ([3.5.7](#357-warnhinweis-bei-änderungen-in-früheren-phasen)),
wenn Folgephasen existieren.
Referenz: `src/components/DistributionDialogs.tsx`, `src/lib/distribution.ts`.
### 3.6.11 Raten über Lebensphasen übernehmen
Werte liegen **je Lebensphase** vor. Bei Beträgen ist das richtig das Einkommen ändert sich,
der Vermögensstand ohnehin. Bei **Raten** ist es meist nicht gemeint: Wer die erwartete Rendite
seines ETF auf 5 % setzt, meint fast nie «nur in Phase 2». Ohne Übernahme muss derselbe Wert
vier- oder fünfmal eingetippt werden, und dabei wird zuverlässig eine Phase übersehen.
Ändert man ein Ratenfeld, erscheint deshalb im Bearbeitungspanel eine Rückfrage mit drei
Möglichkeiten:
| Auswahl | Wirkung |
|---|---|
| **Nur diese Phase** (Vorgabe) | bisheriges Verhalten, andere Phasen bleiben unberührt |
| **Diese + folgende** | ab der bearbeiteten Phase vorwärts Vergangenes bleibt stehen |
| **Alle Phasen** | der Wert gilt für den ganzen Plan |
Als **Ratenfelder** gelten:
| Feld | Kategorien |
|---|---|
| `expectedReturn` | Pensionskasse, Säule 3a, Sonstiges Vermögen |
| `valueGrowth` | Immobilie (Wertsteigerung) |
| `interestRate` | Immobilie (Hypothekarzins) |
| `teuerungsausgleich` | Einkommen (Lohnentwicklung), Ausgaben (reale Mehrausgaben) |
**AHV und Schulden haben keine**: Die AHV-Rente folgt der amtlichen Formel
([4.4](#44-ahv-rente)), Schulden tragen ihren Zins nicht als eigenes Feld.
**Vier Entwurfsentscheide:**
**Die Rückfrage erscheint beim Bearbeiten, nicht als Modal.** Das Zahlenfeld löst bei *jedem
Tastendruck* aus ein Dialog erschiene bei der Eingabe «5.2» viermal. Stattdessen taucht die
Auswahl inline unter den Feldern auf, sobald sich eine Rate tatsächlich vom gespeicherten Wert
unterscheidet, und wird beim **Speichern** ausgeführt.
**Nur veränderte Raten lösen sie aus.** Wer bloss einen Betrag anpasst, bekommt keine
Rückfrage. «Nicht gesetzt» und «0» gelten dabei als gleich sonst meldete schon das Öffnen
eines Panels mit leerem Feld eine Änderung.
**Die Zielphasen behalten ihre übrigen Werte.** Der Endpunkt ersetzt den *ganzen* Werte-Satz
einer Phase. Würde man den Entwurf der bearbeiteten Phase einfach hinüberkopieren, verlöre jede
andere Phase ihre Beträge, Sparraten und Bezüge. Übertragen wird deshalb ausschliesslich das
geänderte Ratenfeld, in die bestehenden Werte hineingemischt. Ein Test sichert genau das ab.
**Phasen, in denen der Wert schon stimmt, werden übersprungen** das spart Schreibvorgänge und
verhindert eine Version, obwohl sich inhaltlich nichts geändert hat.
Technisch ist die Übernahme ein Schreibvorgang **je Zielphase** über den bestehenden Endpunkt;
es gibt keinen neuen Schreibpfad. Alle fallen in dieselbe Bearbeitungssitzung und ergeben
deshalb **eine** Nebenversion, nicht eine je Phase ([3.8.1](#381-eine-version-je-sitzung--nicht-je-änderung)).
Referenz: `src/lib/ratefields.ts`, `src/components/ElementDetail.tsx`.
## 3.7 Bedienoberfläche
### 3.7.1 Layout
- **Sidebar** (Desktop ab `lg` fix, mobil als Overlay): Logo, Ctrl-K-Hinweis, „Übersicht",
Gruppe **«Meine Pläne»** (Szenario-Baum, «+»), Gruppe **«Wissen»** («So rechnet FPT» =
gerenderte Spezifikation, «Systemparameter»).
- **Header**: Menü-Button (mobil), Plantitel, Profilmenü.
- **Hauptbereich**: Übersicht (Plan-Kacheln bzw. EmptyState mit «Geführt starten» /
«Beispielplan») oder Planansicht (Umschalter, Zeitachse, Szenario-Profil-Leiste,
«Nächste Schritte», Ruin-Banner, Matrix, Inspector-Panel rechts).
- Die **Matrix** hat einen eigenen Scrollbereich (max. 75 vh): Phasen-Köpfe (oben) und
Elementnamen (links) bleiben beim Scrollen in **beiden Achsen** fixiert.
- Alle Aktions-Icons (Kopieren, Löschen, Detailansicht) sind auch ohne Hover leicht sichtbar
auf Touch-Geräten gibt es kein Hover.
### 3.7.2 Farbschemata
Drei Themes: **Hell**, **Dunkel**, **Warm** (cremefarben, Koralle-Akzent). Wahl im Profilmenü,
persistiert in `localStorage` (`fpt-theme`), gesetzt als `data-theme` am `<html>`. Ohne
gespeicherte Wahl folgt die Oberfläche `prefers-color-scheme`. Ein Inline-Script im `<head>`
setzt das Attribut vor dem ersten Paint (verhindert FOUC).
Alle Farben laufen über semantische CSS-Variablen (`--bg`, `--surface`, `--accent`, `--danger`, …).
Referenz: `src/lib/theme.ts`, `src/app/globals.css`, `src/app/layout.tsx` Zeilen 11811196.
### 3.7.3 Geldeingabefelder
Die `MoneyInput`-Komponente ist ein spezialisiertes Betragsfeld:
- Unfokussiert Anzeige mit Apostroph-Tausendertrennung (`1'234'567`), fokussiert reine Ziffern
- Ein Default-Wert 0 wird beim Fokussieren geleert, sonst der Text markiert
- Pfeiltasten: ↑/↓ = ±1, Shift+↑/↓ = ±100
- Pfeil-Buttons mit **Klick-und-Halten-Beschleunigung**: nach 400 ms Wiederholung im 70-ms-Takt,
Schrittweite wächst mit der Haltedauer (1 → 10 → 100 → 1'000 → 10'000)
- Optionale harte Klammerung über `min` (Default 0) und `max`
**Zahlenformat:** Bewusst nicht über `toLocaleString("de-CH")`, weil dessen Trennzeichen das
typografische Apostroph (U+2019) ist. Das Tool verwendet durchgehend das gerade Apostroph.
Referenz: `src/components/FormField.tsx` Zeilen 60200, `src/lib/format.ts`.
### 3.7.4 Hilfe-Bubbles
Feldbeschriftungen können ein Info-Icon tragen (`InfoBubble`), das per Hover oder Klick einen
Erklärtext einblendet. Wird durchgehend für die fachlich heiklen Felder verwendet.
### 3.7.5 Reaktivität
Die Anwendung rechnet **nicht im Client**. Jede Änderung führt zu einem `PUT`/`PATCH`/`POST`,
gefolgt von `onChanged()``loadDetail(planId, silent = true)`. Der „silent"-Refresh lädt Plan
und Berechnung neu, ohne die `PlanView` zu demontieren so bleibt die Scrollposition nach dem
Schliessen eines Popups erhalten.
Referenz: `src/components/AppShell.tsx`.
### 3.7.6 Sprache und UI-Primitiven
Seit 0.13 gilt durchgehend die **Du-Form** (wie moderne CH-Finanz-Apps) und es werden überall
**echte Umlaute** verwendet zuvor mischte das UI «Übergang prüfen» mit «Uebersicht» und
Sie- mit Du-Anrede. Der Sweep umfasst auch die API-Fehlermeldungen (z. B. „Nur die letzte
Phase kann gelöscht werden.").
Alle Bausteine liegen zentral in `src/components/ui.tsx`:
| Baustein | Ersetzt / leistet |
|---|---|
| `Button` | vier Varianten (primary/secondary/danger/ghost), einheitliche Hover-/Active-Zustände |
| `Modal` | Dialog mit ESC, Fokus-Falle, `aria-modal`, Einblend-Animation |
| `ConfirmProvider` / `useConfirm` | ersetzt `window.confirm()` Browser-Systemdialoge folgen keinem Farbschema |
| `ToastProvider` / `useToast` | ersetzt `alert()`; Erfolgs- und Fehlermeldungen unten rechts |
| `Skeleton` / `PlanSkeleton` | Struktur-Platzhalter statt «Lädt…»-Text |
| `EmptyState` | einheitliche leere Zustände mit Erklärung und Aktion |
| `InspectorShell` | das rechte Bearbeiten-Panel (3.7.7) |
Mehrere Schichten (Panel + Dialog) teilen sich einen **ESC-Stapel**: ESC schliesst immer nur
die oberste. Animationen (150220 ms) respektieren `prefers-reduced-motion`.
**Attention-Farbe:** Offene Entscheide tragen neu einen eigenen Amber-Ton (`--attention`,
je Farbschema abgestimmt) statt der Akzentfarbe «hier fehlt eine Eingabe» und «hier kannst
du klicken» sind damit unterscheidbar.
### 3.7.7 Inspector-Panel statt Modals
Alle **Einzel-Bearbeitungen** Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert,
Phase (Name/Dauer), Szenario-Profil öffnen sich als rechtes Seitenpanel (`InspectorShell`,
26 rem, mobil volle Breite) statt als Modal. Bewusst **ohne Backdrop**: Die Matrix bleibt
sichtbar und klickbar, ein Klick auf eine andere Zelle wechselt einfach den Panel-Inhalt
der Kontext, den Modals nehmen, bleibt erhalten. Genau ein Panel kann offen sein (eine
`Panel`-Union ersetzt die früheren Einzel-Zustände); der `key` erzwingt beim Wechsel den
Neuaufbau des Formulars wie zuvor bei den Dialogen.
**Modals bleiben** für Erstell-Flows (Element, Phase, Plan, Assistent), den geführten
Übergang (mehrere Objekte auf einmal), die Analysen und die Detailansichten.
Der frühere Dialog «Plan-Einstellungen» heisst im Panel korrekt **«Szenario-Profil»** er
bearbeitet seit V6 das Szenario, nicht den Plan.
### 3.7.8 Tour und «Nächste Schritte»
Die **Tour** (`Tour.tsx`) startet einmalig beim ersten Öffnen eines Plans mit Phasen
(localStorage `fpt-tour-done`) und führt in bis zu sechs Schritten über Profil, Zeitachse,
Matrix, Übergänge, Cash und Analysen als Karte am unteren Rand plus pulsierender Rahmen um
das Ziel (`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der
«Tour»-Knopf in der Werkzeugleiste startet sie jederzeit neu. Grenze: 9.24.
Die Karte **«Nächste Schritte»** über der Matrix leitet aus den vorhandenen Daten ab, was
sinnvollerweise als Nächstes ansteht offene Übergangs-Entscheide (mit Direktsprung in den
geführten Übergang), fehlendes Einkommen/fehlende Ausgaben, ein Plan, der vor der
Pensionierung endet, und bei Ruin der Sprung zu den Einflussfaktoren. Kein eigener Zustand,
kein API-Aufruf; die Karte verschwindet von selbst, wenn nichts offen ist. Auch das
**Ruin-Banner** verlinkt neu auf die Einflussfaktoren («Wirksamsten Hebel finden»).
### 3.7.9 Befehls-Palette und Sparklines
**Ctrl/Cmd+K** öffnet die Befehls-Palette (`CommandPalette.tsx`): Springen zu jedem
Plan/Szenario und die wichtigsten Aktionen (Analysen, neues Szenario, neuer Plan), mit
Tastatur-Navigation der Effizienz-Pfad für geübte Nutzer.
Jede Element-Zeile der Matrix trägt eine **Sparkline** (`Sparkline.tsx`): der Wertverlauf
über alle Planjahre als Mini-SVG, gespeist aus den per-Jahr-Werten der Detailansichten
(`ElementPhaseComputed.yearly`, seit 0.11) keine Neuberechnung, reine Darstellung. Flache
Verläufe werden nicht gezeichnet (keine Information).
## 3.8 Versionierung und Änderungshistorie
Jedes **Szenario** trägt eine Version **A.B** und eine vollständige Änderungshistorie. Zugang
über den Knopf **«Änderungshistorie»** in der Szenario-Leiste.
| | Bedeutung | Entsteht |
|---|---|---|
| **B** (Nebenversion) | ein Bearbeitungsstand | **automatisch**, eine je Bearbeitungssitzung |
| **A** (Hauptversion) | ein bewusst gesetzter Meilenstein | **manuell**, mit Pflichtkommentar; setzt B auf 0 |
### 3.8.1 Eine Version je Sitzung nicht je Änderung
FPT hat **keinen Speichern-Knopf**: Jede Änderung schreibt sofort. Eine Version je
Schreibvorgang wäre deshalb ein Tastenprotokoll und keine Historie ein Durchlauf des
Plan-Assistenten macht rund **14** Schreibvorgänge, ein Klick im Verteil-Dialog einen **je
Zielelement**.
Stattdessen werden alle Schreibvorgänge innerhalb eines **Zeitfensters von 10 Minuten** zu
**einer** Nebenversion zusammengefasst: Der erste legt sie an, alle weiteren aktualisieren
sie. Zwei Sicherungen ergänzen das:
- **Unverändert = keine Version.** Ergibt ein Schreibvorgang inhaltlich denselben Stand
(Dialog geöffnet und unverändert geschlossen), entsteht nichts. Verglichen wird über eine
Serialisierung mit **sortierten Schlüsseln** ohne das würden identische Stände als
verschieden gelten, weil die Reihenfolge der Werte aus der Datenbank-Zeilenfolge stammt.
- **Benutzerwechsel trennt immer.** Änderungen verschiedener Benutzer laufen nie in einer
Version zusammen, auch nicht innerhalb des Fensters Vorbereitung auf die spätere Freigabe
an einen Finanzberater.
Eine **Hauptversion** wird nie zusammengefasst und nie nachträglich verändert; die nächste
Änderung beginnt bei A.1.
### 3.8.2 Was die Historie zeigt
Je Version: **wer** (Benutzername), **wann** (Zeitpunkt der letzten Änderung dieser Sitzung)
und bei Hauptversionen der **Kommentar**. Die oberste Zeile ist der aktuelle Stand.
Zwei Aktionen je Version:
- **Anzeigen** ein Nur-Lese-Fenster mit der Matrix dieses Standes ([VersionMatrix](#552-komponenten)),
Phasen als Spalten, Elemente als Zeilen, dazu Endvermögen und Ruinalter. Bewusst **nicht**
die Bearbeitungs-Matrix: Inspector, Übergangs-Ampeln und Diff-Markierung haben für einen
alten Stand keine Bedeutung.
- **Wiederherstellen** setzt das Szenario vollständig auf diesen Stand zurück.
### 3.8.3 Wiederherstellen
Zwei Eigenschaften machen den Knopf ungefährlich:
**Es geht nichts verloren.** Der wiederhergestellte Stand wird selbst als **neue Version**
festgehalten, mit dem Kommentar «Wiederhergestellt aus A.B». Die bisherige Historie bleibt
vollständig auch alles, was nach dem Zielstand kam.
**IDs bleiben erhalten.** Phasen und Elemente behalten ihre IDs (der Snapshot trägt sie mit).
Das ist keine Kosmetik: Kind-Szenarien zeigen über `sourceElementId` / `sourcePhaseId` auf
genau diese IDs, und daran hängt die Abweichungs-Markierung
([3.2.6](#326-abweichungs-markierung-diff)). Würden neue IDs entstehen, erschiene in
jedem Kind-Szenario schlagartig **alles als «neu»** statt als «geändert».
Restlos vermeiden lässt sich das nicht: Geht man auf einen Stand zurück, in dem ein Element
noch gar nicht existierte, auf das ein Kind verweist, bricht dieser eine Bezug zwangsläufig.
Der Dialog **prüft das vorher** und benennt die betroffenen Szenarien namentlich, statt es
stillschweigend zu tun.
### 3.8.4 Analyse auf einer bestimmten Version
Alle vier Analysewerkzeuge **Grafiken**, **Live-Simulation**, **Monte-Carlo** und
**Einflussfaktoren** haben oben einen Wähler **«Berechnungsgrundlage»**: aktueller Stand
(Vorgabe) oder eine festgehaltene Version.
In der **Monte-Carlo-Simulation** ist die Auswahl **zweidimensional**: Sie rechnet mehrere
Szenarien gleichzeitig, und der Wähler hängt deshalb an der jeweiligen Szenario-Zeile man
kann Szenario A in Version 1.2 gegen Szenario B in Version 2.0 stellen.
Das war fast kostenlos: Der Snapshot ist bereits ein **`PlanInput`**, also genau der Typ, den
alle Werkzeuge ohnehin entgegennehmen. Es genügt, den Arbeitsstand durch den Snapshot zu
ersetzen; das Gerechnete entsteht lokal über `computePlan`.
**Eine Ausnahme ist beschriftet:** Der CSV-Export liest immer das Szenario aus der Datenbank
und liefert deshalb den **aktuellen** Stand, nicht die betrachtete Version. Der Knopf sagt das
in diesem Fall ausdrücklich.
### 3.8.5 Vollständigkeit der Historie
Die Versionierung hängt daran, dass **jeder** inhaltsverändernde Endpunkt sie auslöst
(`touchScenario`). Ein vergessener Pfad fiele nicht auf er erzeugte still keine Version, und
die Lücke bemerkte man erst Wochen später. Ein **Test liest deshalb alle Route-Dateien** und
verlangt, dass jeder schreibende Endpunkt entweder `touchScenario` aufruft oder mit Begründung
in einer Ausnahmeliste steht; verwaiste Ausnahmen meldet er ebenfalls.
Das Festhalten einer Version ist bewusst **fehlertolerant**: Schlägt es fehl, wird es
protokolliert, die eigentliche Änderung des Nutzers aber nicht zurückgewiesen. Die Historie
ist Begleitinformation, nicht der Zweck der Anfrage.
Referenz: `src/lib/versioning.ts` (reine Logik), `src/lib/versioning-db.ts` (Datenbank),
`src/components/VersionHistoryDialog.tsx`, `src/components/VersionMatrix.tsx`,
`src/components/VersionPicker.tsx`.
## 3.9 Effektive Werte (Plan-/Ist-Vergleich)
Roadmap Nr. 5. Macht aus dem Planer ein **Monitoring-Werkzeug**: Was ist tatsächlich
eingetreten, und was heisst das für den Rest der Planung?
Knopf **«Effektive Werte»** auf Plan-Ebene → Liste der bisherigen Erfassungen → Wizard in zwei
Schritten.
### 3.9.1 Ein Ist-Satz gehört zum Plan, nicht zum Szenario
Das tatsächliche PK-Guthaben am 18.8.2026 ist **eine Zahl** unabhängig davon, gegen welches
Szenario man sie hält. Ein Ist-Satz hängt deshalb am `Plan` und wird über dieselbe
Herkunfts-Kette (`sourceElementId`) auf die szenario-eigenen Element-IDs abgebildet, die auch
der Diff ([3.2.6](#326-abweichungs-markierung-diff)) und die Monte-Carlo-Gruppierung benutzen.
Erfasst wird also je **Wurzel-Element**.
### 3.9.2 Der Wizard
**Schritt 1 Stichtag.** Exaktes Datum (z. B. 18. August 2026) plus optionale Notiz. Das
Datum erscheint in der Liste und auf der Zeitachse; für die Rechnung zählt **nur die
Jahreszahl**, weil der Rechenkern in ganzen Jahren ab Planbeginn arbeitet. Der Dialog sagt
das ausdrücklich.
**Schritt 2 Werte.** Alle Elemente **aller Szenarien** dieses Plans, zusammengefasst auf
ihre Wurzel, dazu das **Cash-Konto**. Vorbelegt mit dem Stand, den der Plan für dieses Jahr
vorsieht (aus dem Basisszenario; fehlt das Element dort, aus dem erstbesten Szenario, das es
kennt). Der Nutzer überschreibt nur, was tatsächlich abweicht.
Zwei Arten von Werten, die sich verschieden verhalten:
| Art | Elemente | Wirkung |
|---|---|---|
| **Bestand** | PK, 3a, Sonstiges Vermögen, Schulden, Cash | ersetzt den laufenden Stand |
| **Verkehrswert + Schuld** | Immobilie | zwei Felder: Wert und Resthypothek getrennt |
| **Fluss** | Einkommen, Ausgaben | nominaler **Jahresbetrag**; ersetzt die Basis für alle Folgejahre |
**Die AHV erscheint nur, wenn die Rente zum Stichtag bereits läuft.** Vorher gibt es keinen
Stand, den man ablesen könnte die Rente folgt der amtlichen Formel aus der Beitragskarriere
([4.4](#44-ahv-rente)).
**Ist-Werte erfassen Werte, keine Entscheide.** Wenn der Plan die Immobilie verkauft, du sie
aber behalten hast, lässt sich das hier nicht ausdrücken dafür ist ein Szenario da.
### 3.9.3 Die zweite Berechnung
Der Plan-Lauf bleibt **unangetastet**. Parallel läuft ein zweiter mit derselben Mechanik, aber
korrigierter Ausgangsbasis: In jedem Jahr, für das ein Ist-Satz erfasst wurde, schnappen die
Werte auf die Realität und laufen von dort planmässig weiter. **Alle** Sätze gehen ein, nicht
nur der jüngste.
Beispiel aus der Anforderung: Fonds startet 2020 mit 100'000 bei 5 %. Ohne Ist-Daten steht
2022 rechnerisch 115'763. Wird für 2022 ein Ist-Wert von 120'000 erfasst, rechnet die Ist-Sicht
ab dort weiter und steht 2024 bei 132'300. Kommt für 2024 ein Wert von 140'000 dazu, springt
sie erneut. Genau das ist als Test hinterlegt.
**Lücken fallen auf die Plandaten zurück.** Ein Element ohne erfassten Ist-Wert läuft
unverändert auf seiner Planlinie weiter man muss nicht alles wissen, um etwas zu erfassen.
Technisch: `computePlan(plan, sample?, { actuals })`. Ohne die Option verhält sich die
Funktion exakt wie bisher; die 43 Golden Tests laufen unverändert.
**Der Sprung ist keine Rendite.** Die Differenz zwischen Plan und Wirklichkeit wird als eigene
Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke). Würde man sie den
Kapitalerträgen zuschlagen, erschiene ein Planrückstand als Anlageverlust und die Zerlegung
ginge im Ist-Jahr nicht mehr auf ([3.6.8](#367-detailansichten-je-element-und-je-lebensphase)).
### 3.9.4 Anzeige in der Matrix
Ein zweiter Umschalter neben nominal/real/beide, aber mit nur **zwei** Möglichkeiten:
| Auswahl | Wirkung |
|---|---|
| **Plan** | wie bisher |
| **Effektiv** | die Ist-Zahlen, jeweils mit der **Abweichung** zum Plan daneben |
Warum keine dritte Möglichkeit «beide»: Plan und Ist als Rohwerte nebeneinander wären bei
zusätzlich aktivem nominal/real **acht Zahlen je Zelle**. Stattdessen zeigt die Ist-Ansicht den
Wert und daneben klein die Differenz, grün oder rot ([9.29](#929-acht-zahlen-je-zelle--und-wie-wir-sie-vermeiden)).
Der Umschalter erscheint nur, wenn überhaupt Ist-Werte erfasst sind.
**Nur die Abweichung trägt Farbe.** Die Beträge selbst bleiben neutral sonst wird die Matrix
zum Ampelteppich, in dem nichts mehr heraussticht.
### 3.9.5 Zeitachse
Je erfasstem Jahr ein Marker. Der **jüngste** ist farbig, ältere blass sie sind überholt,
aber nicht bedeutungslos. Ohne gesetztes Planstartjahr entfallen die Marker, weil es dann
keinen Kalenderbezug gibt.
### 3.9.6 Die vier Analysewerkzeuge
Grafiken, Live-Simulation, Monte-Carlo und Einflussfaktoren erhalten dieselbe Leiste:
**Werte** (nominal/real) und **Grundlage** (Plan/Effektiv), dazu die schon bestehende
Versionswahl. «Effektiv» ist deaktiviert, solange nichts erfasst ist.
**Nominal/real ist in den Werkzeugen eine Einfachauswahl**, kein «beide» anders als in der
Matrix. Begründung siehe [9.29](#929-acht-zahlen-je-zelle--und-wie-wir-sie-vermeiden).
Besonderheiten:
- **Vermögensverlauf:** Im Ist-Modus kommt die reine Planlinie **gestrichelt** als Referenz
dazu. Maximal vier Serien.
- **Monte-Carlo:** Der Zielbetrag **dreht mit** der gewählten Grösse (real/nominal) und wird
entsprechend beschriftet sonst prüft man einen nominalen Zielbetrag gegen ein reales
Endvermögen. Ausserdem eine Zeile: *Simuliert ab Jahr; die Jahre davor sind durch deine
effektiven Werte belegt und werden nicht gewürfelt.* Das Startjahr ist **abgeleitet, nicht
eingebbar** ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst
wurden.
- **Einflussfaktoren:** Mit Ist-Werten wirken die Treiber nur noch auf die nicht belegten
Jahre. Die Balken fallen dadurch zu Recht kürzer aus.
### 3.9.7 Verhältnis zur Versionierung
Ein Ist-Satz ist eine **Beobachtung, keine Planänderung**: Er erzeugt **keine** Szenario-Version
([3.8](#38-versionierung-und-änderungshistorie)), und es gibt kein Wiederherstellen. Löschen
entfernt ihn ersatzlos; der Plan bleibt unberührt. Die beiden Historien bleiben getrennt.
Referenz: `src/lib/actuals.ts`, `src/lib/dataview.ts`, `src/components/ActualsDialog.tsx`,
`src/components/AnalysisControls.tsx`.
## 3.10 Navigation auf Plan-Ebene und gespeicherte Analysen
Die Seitenleiste ist zweistufig: Unter jedem **Plan** liegen drei leicht eingezogene
Unterpunkte. Ein Klick auf den **Plan-Namen** öffnet dessen Dashboard.
| Ort | Führt zu |
|---|---|
| Plan-Name | **Plan-Dashboard** (Kennzahlen) |
| Szenarien | **Szenario-Liste** darunter bleibt der Szenario-Baum, dessen Einträge direkt in die Matrix führen |
| Effektive Werte | Liste + Wizard der Ist-Werte ([3.9](#39-effektive-werte-plan-ist-vergleich)) |
| Analysen | Vier Werkzeug-Kacheln + Liste gespeicherter Analysen |
### 3.10.1 Plan- vs. Szenario-Ebene der Kennzahlen
Seit V7 ([9.30](#930-warum-der-haushalt-am-plan-hängt)) liegen Haushaltsform, Personen und
Startjahr am Plan; Endvermögen, Ruinalter, Phasen und Elemente sind dagegen **szenario-eigen**.
Das Plan-Dashboard zeigt deshalb die Haushaltsdaten direkt, alle gerechneten Kennzahlen aber
ausdrücklich als **«laut Basisszenario»** das Basisszenario ist der kanonische Vertreter.
Falls Ist-Werte erfasst sind, weist es zusätzlich die **Abweichung** des Endvermögens gegenüber
dem Plan aus.
Die **Szenario-Liste** zeigt je Szenario Name, aktuelle Hauptversion, Anzahl Elemente, das
Endvermögen und ob das Kapital reicht; das Basisszenario ist farblich hervorgehoben, und die
Herkunft (aus welchem Szenario kopiert) steht darunter. Zwei Aktionen je Zeile: **Historie**
und **Matrix**.
### 3.10.2 Analysen: vier Kacheln
Die Werkzeuge **Grafiken**, **Live-Simulation**, **Monte-Carlo** und **Einflussfaktoren** liegen
als Kacheln vor, die beim Darüberfahren oder Antippen (Touch hat kein Hover) umklappen und
einen Erklärtext zeigen. Ein Klick startet das jeweilige Werkzeug auf dem Basisszenario; Szenario
und Version lassen sich darin weiterhin umstellen.
**Grafiken** öffnen neu **nicht mehr alle drei Diagramme**, sondern lassen zuerst **eine** wählen
(Vermögensverlauf / Einkommen vs. Ausgaben / Vermögensaufteilung), dazu wie bisher
nominal/real und Plan/effektiv. Der **Szenario-Vergleich** wandert mit zu den Grafiken; der
**CSV-Export** zieht dorthin, wo er hingehört auf die **Matrix**.
### 3.10.3 Gespeicherte Analysen
Jede Grafik, Monte-Carlo-Simulation und Einflussfaktoren-Berechnung kann **gespeichert** werden
(die Live-Simulation vorerst nicht sie ist bewusst flüchtig). Festgehalten werden **Eingaben
und Ergebnis als Zahlen**, read-only: Beim Öffnen wird **nichts neu gerechnet**, die
gespeicherten Werte werden nur gezeichnet.
**Zahlen statt Bild** und zwar bewusst: Ein Bildschirm-Abbild wäre im Druck unscharf (300 dpi
gegen 96), im Seitenformat fix und im Dunkelmodus falsch eingefärbt. Aus Zahlen zeichnet der
spätere **PDF-Bericht** (Roadmap Nr. 11) die Grafik **vektoriell und in Druckauflösung** neu
und braucht die Zahlen für Tabellen und Fliesstext ohnehin. Bewusst **nicht** gespeichert wird
`finalWealthSorted` aus der Monte-Carlo-Simulation (ein Eintrag je Lauf, megabyteweise); für die
Anzeige genügen die abgelesenen Wahrscheinlichkeiten und die Bänder.
Jedes Werkzeug schreibt sein Ergebnis in **dieselbe generische Form** (Parameter, Kernzahlen,
Tabelle, Grafik). Dadurch braucht die Nur-Lese-Ansicht nur einen Renderer, und der PDF-Bericht
findet überall dieselbe Struktur vor. Der Name wird automatisch vorgeschlagen
(«Typ · Szenario Version · Datum»), ist aber überschreibbar.
Die Liste je Plan zeigt Typ, Zeitpunkt, Szenario/Version, nominal/real und Plan/effektiv. Ein
gespeicherter Datensatz ist eine **Momentaufnahme**, keine Planänderung: Er erzeugt keine
Version, und Löschen entfernt ihn ersatzlos.
Referenz: `src/components/PlanViews.tsx`, `src/components/SavedAnalysisView.tsx`,
`src/components/SaveAnalysisButton.tsx`, `src/lib/analyses.ts`.
## 3.11 PDF-Berichte
Roadmap Nr. 11. Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: eine Liste der erzeugten
Berichte, von dort aus lässt sich ein neuer anlegen.
### 3.11.1 Was ein Bericht ist
Ein Bericht ist ein **festes Dokument**, kein Blick auf den aktuellen Stand. Die erzeugte
PDF-Datei wird **als Datei abgelegt** und lässt sich jederzeit unverändert wieder
herunterladen auch nachdem der Plan weiterentwickelt wurde. Das ist der Zweck: ein
verlässlicher Audit-Trail. Wer heute einen Bericht verschickt, muss in drei Jahren exakt
dasselbe Dokument vorweisen können.
Deshalb wird **nicht** die Definition gespeichert und das PDF bei Bedarf neu erzeugt: Eine
Neuerzeugung könnte nach Änderungen an Plan, Rechenkern oder Layout nicht mehr dieselben
Bytes liefern.
Abgelegt werden drei Dinge: die gewählten **Parameter**, das eingefrorene **Berichtsmodell**
(alle Zahlen, für die Liste und zur Nachvollziehbarkeit) und die **PDF-Datei** selbst. In der
Datenbank, nicht im Dateisystem der Anwendungscontainer wird bei jedem Deploy neu gebaut,
nur das Datenbank-Volume überlebt.
### 3.11.2 Auswahl beim Anlegen
| Angabe | Möglichkeiten |
|---|---|
| Titel und Notiz | frei; die Notiz erscheint auf dem Deckblatt |
| **Werte** | nominal **oder** real eine Leitgrösse für den ganzen Bericht |
| **Grundlage** | Plandaten oder **effektive Werte** (nur wenn erfasst) |
| **Szenarien** | 1 bis **3** (Konstante `MAX_REPORT_SCENARIOS`) |
| **Analysen** | beliebig viele der gespeicherten Auswertungen |
Zwei bewusste Begrenzungen:
**Nur eine Leitgrösse.** «Beides» würde jede Tabellenspalte verdoppeln dasselbe Problem wie
in der Matrix ([9.29](#929-acht-zahlen-je-zelle--und-wie-wir-sie-vermeiden)).
**Höchstens drei Szenarien.** Darüber wird die Vergleichstabelle unlesbar; vergleichbare
Werkzeuge stellen bewusst nur zwei gegenüber. Das Basisszenario führt den Bericht an und
liefert die Zusammenfassung.
### 3.11.3 Aufbau des Dokuments
Das Layout ist **immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen.
| Seite | Inhalt |
|---|---|
| 1 | Deckblatt · **Das Wichtigste in Kürze** (Kennzahlen + drei Kernaussagen) · Ausgangslage |
| je Szenario | Kennzahlen · Vermögensverlauf · Lebensphasen · **Annahmen** |
| bei ≥ 2 Szenarien | Vergleichstabelle |
| bei Ist-Daten | Plan gegenüber effektiven Werten inkl. Abweichung |
| bei gewählten Analysen | die eingefrorenen Auswertungen |
| Schluss | **Wichtige Hinweise** (Haftungsausschluss) |
Die **Zusammenfassung zuerst** ist kein Geschmacksentscheid: Überkomplexität ist die
häufigste Kritik an Beraterberichten, weshalb sich die einseitige Übersicht als eigenes
Format etabliert hat.
### 3.11.4 Kennzahlen und ihre Grundlage
Je Szenario: **Endvermögen** · **Kapital reicht bis** · **Vermögen bei Pensionierung** ·
**Vorsorgekapital bei Pensionierung** (PK + 3a) · **AHV-Rente** · **PK-Rente** ·
**offene Entscheide**.
Die letzte ist die einzige unmittelbar handlungsleitende Zahl sie zählt die noch nicht
getroffenen Übergangs-Entscheide. Damit Bericht und Matrix nie verschiedene Zahlen nennen,
liegt die Zählung neu als reine Funktion in `lib/decisions.ts`, die **beide** benutzen.
**Zu jeder Kennzahl steht ihre Grundlage** aber als **kurzer Verweis**, nicht als
wiederholte Tabelle. Die vollständigen Annahmen (Inflation, Pensionsalter, Startwerte,
Renditen, Sparraten je Element) stehen **einmal** je Szenario in einem eigenen Abschnitt.
Sie bei jeder Kennzahl zu wiederholen würde den Bericht aufblähen, ohne etwas hinzuzufügen.
### 3.11.5 Haftungsausschluss
Ein formal gesetztes PDF wird als Beratung gelesen. Der Bericht schliesst deshalb
**verpflichtend** mit Hinweisen: dass es sich um eine Projektion auf Basis eigener Annahmen
handelt, keine Anlage-, Steuer- oder Vorsorgeberatung; dass Abweichungen erheblich sein
können; dass laufende Einkommens- und Vermögenssteuern **nicht** modelliert sind
([9.14](#914-keine-steuerschätzung)); und dass Monte-Carlo-Wahrscheinlichkeiten die Streuung
**um** die Annahmen messen, nicht deren Richtigkeit. Ein Test stellt sicher, dass der
Abschnitt nicht wegfallen kann.
### 3.11.6 Technik
Erzeugt wird mit **pdfkit** in der Node-Runtime. Bewusst **kein Headless-Browser**: Chromium
würde das Image um Hunderte Megabyte und Systembibliotheken aufblähen. `@react-pdf/renderer`
schied aus, weil es React-Interna nutzt, die mit React 19 / Next 16 brechen.
Weil das Layout ohnehin fix ist, kostet programmatisches Setzen nichts und die Diagramme
entstehen als **echte Vektoren** aus den gespeicherten Zahlen. Genau dafür wurden die
gespeicherten Analysen als Zahlen und nicht als Bilder abgelegt
([3.10.3](#3103-gespeicherte-analysen)): Ein Bildschirm-Abbild wäre im Druck unscharf.
`pdfkit` ist in `next.config.ts` als **externes Paket** deklariert. Es lädt seine
Font-Metriken zur Laufzeit über Dateipfade; gebündelt stimmt `__dirname` nicht mehr und der
Bericht bräche erst in der Produktion.
Die Trennung ist zweistufig: `report.ts` baut aus Plan und Konfiguration ein reines
**Berichtsmodell** (ohne PDF-Kenntnisse, testbar), `report-pdf.ts` zeichnet es. Damit ist
das, was eingefroren wird, dasselbe, was geprüft wird.
Referenz: `src/lib/report.ts`, `src/lib/report-pdf.ts`, `src/lib/decisions.ts`,
`src/components/ReportsView.tsx`.
## 3.12 Pensionsalter anpassen
> Roadmap Nr. 44. Bedienung im Szenario-Profil, Abschnitt «Pensionsalter anpassen».
### 3.12.1 Warum das kein Zahlenfeld ist
Das Pensionsalter bestimmt, **wo eine Lebensphase endet**: `maxPhaseDuration` kappt jede
Phasendauer beim nächsten Pensionierungsereignis, jede Pensionierung liegt deshalb zwangsläufig
auf einer Phasengrenze. Ein frei änderbares Alter hätte diese Zusammengehörigkeit zerrissen
die Phasen blieben, wo sie sind, und der Phasentyp (Erwerb/Misch/Pension) käme mitten in einer
Phase ins Rutschen.
Deshalb ändert die Bedienung nicht das Alter, sondern verschiebt **die Grenze**:
| | vorher | nachher (4 Jahre) |
|---|---|---|
| Phase 3 «Misch» | 10 Jahre | **6 Jahre** |
| Phase 4 «Pension» | 15 Jahre | **19 Jahre** |
| Gesamtdauer | 60 Jahre | 60 Jahre |
Die Gesamtdauer des Plans bleibt immer gleich es wird Zeit umverteilt, nicht hinzugefügt.
### 3.12.2 Spielraum und Sperren
Angezeigt wird je Person das aktuelle Pensionsalter, Knöpfe für ±1 Jahr, Kurzwahl-Chips für
grössere Schritte und eine **Hilfebox**, die den Spielraum benennt. Ein deaktivierter Knopf
ohne Erklärung wirkt wie ein Fehler; die Box sagt deshalb immer, was möglich ist **und warum
nicht mehr**.
Der Spielraum ist `(Dauer der Vorphase 1)` bis `+(Dauer der Folgephase 1)`: Eine
Lebensphase muss mindestens ein Jahr dauern.
Gesperrt wird ganz, wenn:
| Situation | Meldung |
|---|---|
| Person ist bei Planbeginn schon pensioniert | es gibt keine Grenze zu verschieben |
| Pensionierung liegt am oder nach dem Planende | zuerst eine Lebensphase anhängen |
| Pensionierung liegt nicht auf einer Phasengrenze | zuerst die Lebensphasen anpassen (Altdaten) |
| **Beide Personen teilen dieselbe Grenze** | zuerst eine Lebensphase einfügen, um sie zu trennen |
Der letzte Fall ist der Kehrseite der Zusammenlegung: Sind zwei Pensionierungen auf demselben
Zeitpunkt, liesse sich die eine nicht bewegen, ohne die andere mitzunehmen.
### 3.12.3 Zusammenlegung: wenn eine Lebensphase entfällt
Genau ein Jahr über den Spielraum hinaus fällt die angrenzende Phase auf 0 und **verschwindet**.
Das ist erlaubt es ist der Fall «beide werden gleichzeitig pensioniert, die Mischphase gibt es
nicht mehr» , wird aber **vorher bestätigt**, weil dabei zwei Übergänge zu einem werden.
Regel für die Zusammenführung (überlebende Grenze ist die des **Vorgängers** der entfallenden
Phase):
| | Regel | Begründung |
|---|---|---|
| Element-Entscheide | Was am überlebenden Übergang schon entschieden ist, **bleibt**. Nur leere Felder werden aus dem entfallenden ergänzt. | Ein bewusster Entscheid darf nie von einem anderen überschrieben werden. |
| Einmalige Cash-Beträge | werden **addiert** | Beide Ereignisse finden weiterhin statt, nur zum selben Zeitpunkt. |
| Steuersatz auf dem Zufluss | **betragsgewichteter** Mischsatz | Nur so bleibt der Netto-Zufluss derselbe wie vorher. |
| Bezeichnungen | mit « + » verbunden | damit nachvollziehbar bleibt, woraus die Summe entstand |
Die Verschiebung ist **nicht** über die Undo-Funktion rückgängig zu machen sie erzeugt wie
jede Änderung eine neue Nebenversion, aus der sich der alte Stand wiederherstellen lässt
([3.8](#38-versionierung-und-änderungshistorie)).
### 3.12.4 Punkt A: «Aus Vorphase übernehmen»
Wiederkehr-Parameter Teuerungsausgleich, erwartete Rendite, jährliche Einzahlung, Bezugsrate,
Amortisation, Wertsteigerung, Hypothekarzins, Tilgung galten bisher als **Kopie**: Beim
Anlegen einer Phase wurde der Wert der Vorphase hineingeschrieben. Änderte man die Vorphase
später, blieb die Kopie stehen.
Neu ist das Feld in Folgephasen standardmässig **leer** und wird live geerbt; darunter steht ein
angehaktes Kästchen **«Aus Vorphase übernehmen»** mit dem geerbten Wert daneben. Ein Häkchen
weg macht das Feld editierbar und den Wert phasen-eigen.
Nicht vererbt werden bewusst: **Ausfalljahre** (sie gelten für genau eine Phase ein geerbter
Wert würde eine Lücke erfinden), der **Kaufpreis** einer Immobilie (eine Tatsache, keine
Annahme) und die **Zins-Behandlung** (ein Schalter ohne Zahlenwert).
Bestehende Pläne verhalten sich unverändert: Dort sind die Werte gespeichert und gewinnen daher
gegen die Vererbung, bis man das Häkchen aktiv setzt.
### 3.12.5 Punkt C: Verwendung des Kapitalzuflusses
Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a,
Verkaufserlös). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Im
Cash-Übergang lässt sich deshalb erfassen, wie viel **in Prozent** in die Amortisation der
Hypothek und in eine Anlage fliesst; der Rest bleibt Cash.
**Warum Prozent und nicht Franken:** Verschiebt man das Pensionsalter, ändert sich das bezogene
Kapital. Ein Frankenbetrag müsste von Hand nachgezogen werden und würde bis dahin still eine
falsche Aufteilung rechnen. Eine Quote skaliert mit.
Die Amortisations-Quote ist am Restsaldo der Hypothek gekappt; ist sie grösser, bleibt der Rest
Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive).
Beide sind mechanisch nichts Neues die eine wirkt wie eine Sonderamortisation, die andere wie
eine Zusatzinvestition, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken.
Referenz: `src/lib/retirement.ts`, `src/components/RetirementAdjuster.tsx`,
`src/components/FormField.tsx` (`InheritableField`).
---
# 4. Berechnungsmodell
> Dies ist der fachliche Kern des Tools. Der gesamte Abschnitt beschreibt
> `src/lib/calculations.ts`, Funktion `computePlan(plan: PlanInput): PlanComputed`.
> Die Funktion ist **rein** (keine I/O, keine Datenbank) und dadurch isoliert testbar.
## 4.1 Ablauf im Überblick
```
für jede Phase i (nach sequenceNumber sortiert):
1. Personen-Infos berechnen (Alter, erwerbstätig?), Phasentyp ableiten
2. AHV: Ausfalljahre kumulieren, Renten der Pensionierten berechnen, plafonieren
3. Element-Setup: je Element Startwerte/Raten bestimmen, in Arbeitslisten einordnen
4. Investitionen vom Cash abziehen → cashStart
5. Jahresschleife t = 1..duration:
Flows indexieren, Vermögen verzinsen, Bezugsraten entnehmen,
Cash fortschreiben, Ruin prüfen, YearPoint anfügen
6. Endwerte je Element setzen, Phasen-Kennzahlen zusammenstellen
7. Übergang: Entscheide anwenden, Carry aktualisieren, Cash der Folgephase bilden
```
## 4.2 Zustand über Phasengrenzen: der Carry
Zwischen den Phasen wird pro Element ein `Carry`-Objekt fortgeschrieben:
| Feld | Bedeutung |
|---|---|
| `status` | `ACTIVE` / `SOLD` / `SETTLED` |
| `value` | Aktiven-Saldo (PK / 3a / Sonstiges Vermögen) am Ende der Vorphase |
| `mortgage` | Immobilie: Resthypothek |
| `owed` | Schulden: Restschuld (positiv geführt) |
| `pkPensionAnnual` | PK: jährliche Rente nach Verrentung |
| `flowBasis` | Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase |
| `hasCarry` | ob überhaupt eine Vorphase existiert (steuert read-only vs. Eingabe im UI) |
Zusätzlich laufen phasenübergreifend mit: `cashCarryIn`, `cumulativeInflation`,
`gapYearsByPerson`, `yearsBefore`, `ruinAge`, `incomingInflow`, `incomingImmediateRepay`.
Referenz: `src/lib/calculations.ts` Zeilen 103115, 132147.
## 4.3 Personen und Phasentyp
Für jede Person und jede Phase:
```
startAge = person.age + yearsBefore
endAge = startAge + duration
working = startAge < retirementAge
retiresAtStart = startAge === retirementAge
```
`yearsBefore` ist die Summe der Dauern aller vorangehenden Phasen. **Alter wird also relativ
gezählt** es gibt keine Kalenderdaten im Modell, nur „Jahre ab Planbeginn".
`maxDurationYears` = Minimum über `retirementAge startAge` aller noch erwerbstätigen Personen
(nur positive Werte), sonst `null`.
## 4.4 AHV-Rente
Die Rente hängt an zwei Achsen: der **Beitragsdauer** (Rentenskala 44) und dem **massgebenden
durchschnittlichen Jahreseinkommen** (mdJE).
### 4.4.1 Die amtliche Rentenformel (Skala 44)
Alle Schwellen sind Vielfache von `R0`, dem Mindestbetrag der vollen Monatsrente
(`AHV_MIN_MONTHLY_FULL = 1'260`). Mit `x = mdJE / (12 × R0)`:
```
mdJE ≤ 12 × R0 (= 15'120) → Rente = R0 (Mindestrente 1'260)
12 × R0 < mdJE ≤ 36 × R0 → Rente = R0 × (0.74 + 0.26 × x) Formel 1
36 × R0 < mdJE < 72 × R0 → Rente = R0 × (1.04 + 0.16 × x) Formel 2
mdJE ≥ 72 × R0 (= 90'720) → Rente = 2 × R0 (Maximalrente 2'520)
```
Der Wendepunkt liegt bei `36 × R0 = 45'360` → 1'915/Monat; beide Formelteile sind dort stetig.
**Quelle und Verifikation:** BSV/MAS „Berechnungsvorschriften der AHV/IV-Renten", gültig ab
1.1.2026 (liefert die Schwellenstruktur `12/36/72 × R0`), und die amtliche Tabelle
`318.117.1 Monatliche Vollrenten, Skala 44`. Die implementierte Formel reproduziert **alle 51
Zeilen dieser Tabelle exakt**; Stützstellen sind als Golden Tests hinterlegt (Kap. 8.2).
Die Funktion `ahvMonthlyFullPension(mdJE)` rechnet bewusst **stetig**. Amtlich wird das mdJE auf
Vielfache von `1.2 × R0` gestuft (daher die 51 Tabellenzeilen); für eine Planung ist der stetige
Wert näher an der Wahrheit, die Abweichung liegt unter 20/Monat.
### 4.4.2 Beitragskarriere und mdJE
Pro Person wird über die Phasen hinweg akkumuliert (`AhvCareer`):
| Feld | Bedeutung |
|---|---|
| `plannedAvgGrossIncome` | reales **Brutto**-Durchschnittseinkommen der Beitragsjahre **im Plan** (siehe 4.4.5) |
| `planYears` | Beitragsjahre im Plan = Σ (Phasendauer Ausfalljahre der Phase) |
| `yearsBeforePlan` | `max(0, Alter bei Planbeginn 21)` |
| `gapYearsInPlan` | Summe der Ausfalljahre im Plan |
Das mdJE ist der **gewichtete Mittelwert über alle Beitragsjahre**:
```
yearsBefore = max(0, yearsBeforePlan gapYearsBefore)
mdJE = (avgIncomeBefore × yearsBefore + plannedAvgIncome × planYears)
/ (yearsBefore + planYears)
```
Ausfalljahre reduzieren die **Gewichtung** (und die Skala), nicht das Durchschnittseinkommen
genau wie in der echten AHV: Wer zwei Jahre aussetzt, hat deswegen kein tieferes
Durchschnittseinkommen, aber weniger Beitragsjahre.
### 4.4.3 Warum real gerechnet wird
Sämtliche Einkommen gehen **real** (Kaufkraft bei Planbeginn) in das mdJE ein, und die
Schwellen sind heutige Werte. Das ist kein Vereinfachungs-, sondern ein Genauigkeitsentscheid:
Die echte AHV **wertet vergangene Einkommen auf** (Lohnindex) **und indexiert die Schwellen**
(Mischindex). Beide Bewegungen heben sich in realer Betrachtung weitgehend auf wer nominal
mittelt und gegen heutige Schwellen hält, vergleicht Franken von 2046 mit Schwellen von 2026 und
überschätzt die Rente systematisch.
> **Grössenordnung:** 45-jährig, 85'000 Lohn, +1.5 %/Jahr bei 2 % Inflation, 20 Erwerbsjahre.
> Nominal gemittelt: mdJE 98'276 → Maximalrente 32'760. Real gemittelt: mdJE 81'156 → 31'096.
> Differenz 1'664/Jahr, über 25 Rentenjahre rund 41'600 und der Fehler geht immer nach oben.
Die reale Berechnung eines Phasen-Durchschnitts erfolgt analytisch (`avgRealFlow`) als
geometrische Reihe mit `q = (1 + Lohnerhöhung) / (1 + Inflation)`.
**Bekannte Unschärfe:** Die Schwellen folgen dem Mischindex, die Aufwertung dem Lohnindex. Da
Löhne langfristig schneller steigen als Preise, ist die Deflationierung mit der Preisinflation
leicht **konservativ**. Bewusst in Kauf genommen, statt eine dritte Indexannahme einzuführen.
### 4.4.4 Woher die Karriere-Werte kommen
| Situation | Quelle |
|---|---|
| Person retires innerhalb des Plans | `transitionValues` des AHV-Elements am Pensions-Übergang |
| Person bei Planbeginn bereits pensioniert | `phaseValues` des AHV-Elements in der ersten Phase |
| Prüfung noch nicht erfolgt | Fallback: `avgIncomeBefore = plannedAvgIncome` |
Der Fallback ist bewusst gewählt: Ohne erfassten Wert gilt der geplante Durchschnitt als
Schätzung für die Jahre davor exakt der Wert, den der Dialog vorbelegt. Ein Fallback auf 0
würde die Rente still und massiv zu tief rechnen (bei einer 45-jährigen Person auf rund 45 %).
Einkommen wird einer Person nur zugerechnet, wenn das `INCOME`-Element ihr zugeordnet ist. Bei
einem **Einzelplan** zählt „Gemeinsam" (`HOUSEHOLD`) zur Person A; bei einem **Paar-Plan** nicht
(siehe [9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv)).
### 4.4.5 Netto-Brutto-Umrechnung für die AHV
Das Tool erfasst das Einkommen **netto**, die AHV bemisst sich am **Bruttolohn**. Beim Aufbau
der Karriere wird deshalb hochgerechnet:
```
plannedAvgGrossIncome = avgRealFlow(...) × AHV_GROSS_FROM_NET_FACTOR // = 1.12
```
Sämtliche Werte in `AhvCareer`, im Prüf-Dialog und im mdJE sind damit **brutto** auch das
Feld „Durchschnittliches Bruttoeinkommen vor Planbeginn", das der Benutzer aus der
Rentenvorausberechnung übernimmt (die ohnehin brutto-basiert ist). Eine Einheit im ganzen
Ablauf, keine Umrechnung an der Feldgrenze.
**Herleitung des Faktors** (Arbeitnehmer-Abzüge in % des Bruttolohns):
| Abzug | Satz | Bemerkung |
|---|---|---|
| AHV/IV/EO | 5.30 % | 10.6 % total, hälftig geteilt |
| ALV | 1.10 % | 2.2 % total, hälftig geteilt, bis 148'200 |
| NBU | ~1.00 % | variiert 0.41.6 %, teils vom Arbeitgeber getragen |
| PK | ~25 % | BVG-Altersgutschrift 7/10/15/18 % auf den **koordinierten** Lohn (Brutto 26'460, max. 90'720), Arbeitnehmer ≥ die Hälfte |
Total ~913 % je nach Alter und Lohn → Faktor `1/(1q)` = **1.10 bis 1.16**, Mittel **1.12**.
**Warum ein fixer Faktor genügt:** Das mdJE ist selbst ein Durchschnitt über die ganze
Beitragskarriere (44 Jahre). Der altersabhängige PK-Satz mittelt sich dabei heraus ein
Karriere-Faktor gegen einen Karriere-Durchschnitt ist konzeptionell stimmig, nicht bloss eine
Näherung. Die Restunschärfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung
behebt (~11 %, siehe 9.13).
**Grenzen:** Überobligatorische PK-Pläne, vom Arbeitgeber getragene NBU, Selbstständige und
Löhne über 148'200 (ALV sinkt auf 0.5 %) weichen ab. Der Faktor ist heute eine nicht
sichtbare Konstante; mit Roadmap Nr. 41 wird er in der Formel-Erklärung ausgewiesen.
Quellen: BSV „Beträge gültig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
„keine Änderung gegenüber 2025"); AHV-Merkblätter 2.01 (AHV/IV/EO) und 2.08 (ALV).
### 4.4.6 Jahresrente, Skala und Plafonierung
```
factor = max(0, (44 Ausfalljahre total) / 44) // Rentenskala 44
rente = round(ahvMonthlyFullPension(mdJE) × 13 × factor)
```
`AHV_PENSION_MONTHS = 13` seit 1.1.2026 gibt es die **13. Altersrente** (Art. 34bis AHVG).
Die Formel liefert Monatsrenten; der Jahresbetrag ist deshalb `× 13`, nicht `× 12`.
`AHV_MAX_ANNUAL_SINGLE` ist neu abgeleitet: `2 × R0 × 13 = 32'760`.
**Ehepaar-Plafonierung** nur bei `householdType = COUPLE` **und** wenn für **beide** Personen
eine Rente vorliegt:
```
cap = 32'760 × 1.5 = 49'140
falls (renteA + renteB) > cap:
beide Renten proportional kürzen: rente × cap / summe
```
Die Rente ist danach **nominal fix** sie wird über die Phasen hinweg nicht indexiert und
verliert damit real an Kaufkraft (siehe [9.11](#911-ahv-rente-wird-nach-der-pensionierung-nicht-indexiert)).
**Wann** sie fliesst, entscheidet seit 0.26 nicht mehr der Phasentyp, sondern das Alter im
jeweiligen Jahr siehe 4.4.7.
Referenz: `src/lib/calculations.ts` (`ahvMonthlyFullPension`, `ahvMdje`, `ahvAnnualPension`),
`src/lib/constants.ts`.
### 4.4.7 Referenzalter: Die Rente beginnt mit 65, nicht mit der Pensionierung
Bis Version 0.25 galt implizit «pensioniert = Rente». Sobald sich das Pensionsalter verschieben
lässt ([4.16](#416-pensionsalter-verschieben)), ist das falsch: Die AHV-Rente hängt am
**Referenzalter** (`AHV_REFERENCE_AGE = 65`), nicht daran, wann jemand aufhört zu arbeiten.
Daraus folgen drei Fälle:
| Fall | Was passiert |
|---|---|
| Pensionierung **mit 65** | unverändert die Rente fliesst ab Beginn der Pensionsphase |
| Pensionierung **nach 65** | Die Erwerbsphase deckt das Referenzalter ab. Die Rente fliesst ab 65 **zusätzlich zum Lohn**. Erfasst werden nur noch die Ausfalljahre bis 65. |
| Pensionierung **vor 65** | Die Pensionsphase beginnt vor 65. Bis dahin ist die Person **beitragspflichtig als Nichterwerbstätige(r)**; der Beitrag (`ahvContribution`) ist eine laufende Ausgabe. Mit 65 fällt er weg und die Rente setzt ein. |
Beide Wechsel können **innerhalb derselben Lebensphase** stattfinden. Die AHV wird deshalb
**jahresweise** ausgewertet statt als Phasenkonstante:
```
für jedes Jahr t der Phase:
alterImJahr = alterZuPhasenbeginn + t 1
alterImJahr ≥ 65 → Rente fliesst (Einkommen)
sonst, wenn pensioniert → Beitrag fällt an (Ausgabe, wirkt auf die Verzehrquote)
sonst → nichts
```
Der Verlaufspunkt des AHV-Elements zeigt den Beitrag als **negativen** Wert so ist in der
Grafik zu sehen, dass die AHV in diesen Jahren Geld kostet, statt welches zu bringen.
**Ein Aufschub der Rente wird nicht abgebildet.** Wer über 65 hinaus arbeitet, könnte den Bezug
aufschieben und erhielte dafür einen Zuschlag. Das Tool lässt die Rente stattdessen fliessen
die vorsichtigere Annahme, und eine, die keinen zusätzlichen Entscheid verlangt.
**Der Beitrag als Nichterwerbstätige(r)** bemisst sich am Vermögen und am Renteneinkommen, nicht
am Lohn. Die Bandbreite ist entsprechend enorm: vom Mindestbeitrag von rund **530 CHF/Jahr** bis
zum Höchstbeitrag von rund **26'500 CHF/Jahr**. Deshalb gibt es hier **keinen Default** ein
stiller Vorschlag würde nicht hinterfragt (vgl. [9.15](#915-defaults-für-annahmen-sind-gefährlich)).
Der Hilfetext nennt die Bandbreite ausdrücklich, damit auch jemand ohne Vorwissen ein Gefühl für
die Grössenordnung bekommt.
**Beitragsjahre:** Wer den Beitrag zahlt, hat keine Ausfalljahre die Rentenskala bleibt
unberührt. Das Modell kürzt die Rente bei Frühpensionierung deshalb nicht; eine Kürzung entsteht
nur, wenn man die Jahre ausdrücklich als Ausfalljahre erfasst.
Referenz: `src/lib/calculations.ts` (`ahvItems` in der Jahresschleife), `AHV_REFERENCE_AGE` in
`src/lib/constants.ts`.
## 4.5 Nominal, real und die Deflatoren
### 4.5.1 Das V5-Modell
| Grösse | Erfassung | Indexierung über die Phasenjahre |
|---|---|---|
| Einkommen | **nominal** | `basis × (1 + Lohnerhöhung)^(t1)` |
| Renten (AHV, PK) | **nominal** | keine konstant |
| Ausgaben | **real** | real: `basis × (1 + reale Mehrausgaben)^(t1)`, dann **× kumulierte Inflation** |
Die Inflation ist seit V5 **plan-weit** (`plan.inflationRateDefault`) und gilt einheitlich für
alle Phasen; eine phasenspezifische Überschreibung existiert nicht (mehr).
### 4.5.2 Kumulierte Inflation
```
cumInflStart(Phase 1) = 1
cumInflStart(Phase n) = cumInflStart(Phase n1) × (1 + infl/100)^duration(n1)
```
### 4.5.3 Die drei Deflatoren
Ein subtiler, aber wichtiger Punkt: Bestandswerte und Flow-Werte haben am Phasenende **nicht
denselben** Deflator, weil ein Flow im Jahr `duration` anfällt, ein Bestand aber **nach** dem
Jahr `duration` gemessen wird.
| Deflator | Formel | Verwendung |
|---|---|---|
| `cumulativeInflationStart` | s. o. | Bestände zu Phasenbeginn, Flows im Jahr 1 |
| `cumulativeInflationEnd` | `cumInflStart × (1+infl)^duration` | Bestände am Phasenende (Cash, Vermögen) |
| `flowDeflatorEnd` | `cumInflStart × (1+infl)^(duration1)` | Flow-Endwerte (Einkommen, Ausgaben, Quote) **eine Kaufkraft-Stufe weniger** |
Referenz: `src/lib/calculations.ts` Zeilen 427429; Anwendung in `src/components/PlanView.tsx` Zeilen 686687, 716717.
## 4.6 Element-Setup je Kategorie
Für jedes Element (sortiert nach `orderIndex`) wird ein `ElementPhaseComputed` erzeugt.
Vorab-Abbruch: Ist der Carry-Status `SOLD` → Notiz „Verkauft"; ist er `SETTLED` und die Kategorie
`OTHER_DEBT` → „Getilgt". Solche Elemente werden nicht weiter gerechnet.
### 4.6.1 INCOME / EXPENSE
```
idx = phaseData.teuerungsausgleich ?? carry.rates.teuerungsausgleich ?? 0 // Punkt A
baseValue = hasCarry ? round(carry.flowBasis) : round(phaseData.amount)
basis = !hasCarry → round(phaseData.amount)
phaseData.amount ist Zahl → round(phaseData.amount) // bewusster Override
sonst → baseValue // live vererbt
// Punkt B: Besitzer ist eine PERSON und in dieser Phase pensioniert,
// und es ist kein Betrag erfasst → basis = 0
// NACH der Jahresschleife (siehe 4.16.6):
carry.flowBasis = basis × (1 + idx/100)^duration // für die Folgephase
```
**Die Vererbungsregel** (`src/components/ElementDetail.tsx` Zeilen 480490): Beim Speichern wird
das Feld `amount` **gelöscht**, wenn es exakt dem fortgeschriebenen Wert entspricht. Dadurch
bleibt der Wert „live vererbt" eine spätere Änderung in einer früheren Phase wirkt sich weiter
durch. Nur ein bewusst abweichender Wert wird fix gespeichert.
Man beachte den Exponenten-Unterschied: der **Endwert** der Phase nutzt `duration 1`
(letztes Jahr), der **Carry** für die Folgephase nutzt `duration` (ein Jahr weiter).
Die Fortschreibung passiert seit 0.26 **nach** der Jahresschleife sonst kämen effektive Werte
nie in der Folgephase an ([4.16.6](#4166-fehlerbehebung-fortgeschriebener-basiswert-und-effektive-werte)).
### 4.6.2 AHV
Seit 0.26 **jahresweise** statt phasenweise ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)):
Das Element meldet Rente, Beitrag und Startalter des Besitzers an die Jahresschleife an, die je
Jahr entscheidet, was fliesst. `startValue` / `endValue` zeigen den Stand im ersten bzw. letzten
Jahr der Phase; die Zusammenfassung nennt beide Zustände, wenn das Referenzalter mitten in der
Phase liegt («Beitrag … → Rente …»). Die Rente läuft **nicht** mehr über `renteTotal`, weil sie
innerhalb einer Phase einsetzen kann.
### 4.6.3 PENSION_FUND
| Fall | Verhalten |
|---|---|
| pensioniert, `pkPensionAnnual > 0` | Rente: `startValue = endValue = pkPensionAnnual`, Summand in `renteTotal` |
| pensioniert, keine Rente | Notiz „Vollständig bezogen" |
| erwerbstätig | `base = hasCarry ? carry.value : phaseData.currentValue`; `topUp = hasCarry ? additionalInvestment : 0`; `start = base + topUp`; Rate = `annualContribution` |
Die PK-Rate wird **nicht** zu `fixedRatesTotal` addiert sie belastet das Cash nicht.
`topUp` wird ab Phase 2 zu `investmentsFromCash` addiert.
### 4.6.4 PILLAR_3A
Identisch zu PK, mit zwei Unterschieden:
- Die Rate **wird** zu `fixedRatesTotal` addiert (belastet das Cash).
- Ist der Besitzer pensioniert, gibt es nie eine Rente, nur „Vollständig bezogen".
### 4.6.5 REAL_ESTATE (Immobilie)
```
purchase = hasCarry ? carry.propertyPurchase : round(phaseData.purchasePrice) // Kaufpreis
valueStart = hasCarry ? carry.propertyValue : round(phaseData.purchasePrice) // Verkehrswert
mortgage = hasCarry ? carry.mortgage : round(phaseData.mortgage)
amort = round(phaseData.amortization) // wird JÄHRLICH am Restsaldo gekappt
equity = valueStart mortgage → startValue, wealthStart
```
**Kaufpreis und Verkehrswert laufen getrennt.** Der Verkehrswert wächst mit `valueGrowth`, der
Kaufpreis bleibt der ursprüngliche er ist die Basis der Grundstückgewinnsteuer beim Verkauf
(4.9.4). Beide werden über die Phasen fortgeschrieben (`carry.propertyValue`,
`carry.propertyPurchase`); die Hypothek läuft als Saldo in der Jahresschleife (4.7).
**Die Wertsteigerung wirkt auf die Liegenschaft, nicht auf das Eigenkapital.** Das ist der
Hebel, der Wohneigentum finanziell auszeichnet: 1 % von 1'000'000 sind 10'000 im Jahr bei
einem Eigenkapital von 100'000 also 10 % darauf. Würde man die Wertsteigerung auf das
Eigenkapital rechnen, ergäbe dieselbe Annahme nur 1'000 im Jahr:
> Kaufpreis 1'000'000 · Hypothek 900'000 · EK 100'000 · 1 %/J. · Amortisation 10'000/J. · 10 J.
> → korrekt **304'622**; auf das Eigenkapital gerechnet nur **210'462**. Differenz **94'160**,
> und sie wächst mit dem Belehnungsgrad.
Der angezeigte Elementwert bleibt das **Eigenkapital** (`Verkehrswert Restschuld`); nur die
Basis der Verzinsung ist die Liegenschaft.
Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase):
`investmentsFromCash += max(0, equity)` das Eigenkapital wird aus dem Cash finanziert.
### 4.6.6 OTHER_ASSET
```
base = hasCarry ? carry.value : round(phaseData.startValue)
topUp = hasCarry ? round(phaseData.additionalInvestment) : 0
start = base + topUp
rate = round(phaseData.annualContribution) → fixedRatesTotal += rate
withdrawal = round(phaseData.annualWithdrawal) → plannedWithdrawTotal += withdrawal
```
### 4.6.7 OTHER_DEBT
```
owedStart = hasCarry ? carry.owed : round(phaseData.startValue)
repay = round(phaseData.annualRepayment) // wird JÄHRLICH am Restsaldo gekappt
startValue = owedStart // negatives Vorzeichen im Vermögen
```
Wie bei der Immobilie ist die Restschuld ein **laufender Saldo**; ist sie getilgt, entfällt die
Tilgungsrate.
## 4.7 Die Jahresschleife
Zunächst wird der Cash-Startwert gebildet:
```
cash = cashCarryIn (isFirstPhase ? 0 : investmentsFromCash)
cashStart = cash
```
Die Investitionen werden also **am Phasenanfang** abgezogen. Grund (Kommentar im Code, Fix in
Commit `f768e01`): Der Cash-Startwert zeigt damit den Bestand **nach** den Investitionen die
investierten Mittel erscheinen im Vermögen und nicht doppelt auch im Cash.
Dann für `t = 1 .. duration`:
```
// 1. Einkommen (nominal)
incomeFlow = renteTotal + Σ (inc.basis × (1 + inc.idx/100)^(t1))
// 2. Ausgaben (real → nominal) + Hypothekarzins
inflFactor = cumInflStart × (1 + infl/100)^(t1)
expenseRealBase = Σ (exp.basis × (1 + exp.idx/100)^(t1))
interestNominal = Σ (re.mortgage × re.interestRate/100) // nur wo interestHandling = ADD
expenseNominal = expenseRealBase × inflFactor + interestNominal
expenseReal = expenseRealBase + interestNominal / inflFactor
// 3. Quote
quote = incomeFlow expenseNominal
// 4. YearPoint anlegen (year, age Person A, income, expenseNominal, expenseReal);
// wealthNominal/wealthReal werden nach Schritt 8 nachgetragen
// 5. Vermögen: verzinsen, Sparbeitrag, Bezugsrate
für jedes Asset a:
grown = a.value × (1 + a.r/100) + a.rate // Zins zuerst, dann Einzahlung (nachschüssig)
w = min(a.withdrawal, max(0, grown)) // Bezug am Bestand gekappt
a.value = grown w
cashFromWithdraw += w
// 6. Amortisation und Tilgung jeweils am Restsaldo gekappt
debtRates = 0
für jede Immobilie re:
pay = min(re.amort, re.mortgage) // nie mehr als die Restschuld
re.mortgage = pay
debtRates += pay
re.value ×= (1 + re.growth/100) // Wertsteigerung auf die LIEGENSCHAFT
für jede Schuld d:
pay = min(d.repay, d.owed)
d.owed = pay
debtRates += pay
falls t === 1 → plannedSaveRate = fixedRatesTotal + debtRates
// 7. Cash fortschreiben
cash += quote fixedRatesTotal debtRates + cashFromWithdraw
falls cash < 0 → cashNegative = true
// 8. Ruin prüfen (Gesamtvermögen zum Jahresende)
total = cash + Σ asset.value + Σ (re.value re.mortgage) + Σ (d.owed)
yearPoint.wealthNominal = round(total) // Grundlage des Verlaufs je Jahr
yearPoint.wealthReal = round(total / cumInfl[Jahr])
falls ruinAge === null und total < 0 → ruinAge = age(Person A) + yearsBefore + t
```
**Wichtige Details:**
- Verzinsung ist **nachschüssig**: der Sparbeitrag des Jahres wird nicht mitverzinst.
- **Amortisation und Tilgung enden mit der Schuld.** Hypothek und Restschuld sind laufende
Salden; die Rate ist pro Jahr auf den Restsaldo gekappt (`min(rate, saldo)`). Ist die Schuld
abbezahlt, fliesst kein Franken mehr ab weder aus dem Cash noch in die Sparrate. Im letzten
Zahlungsjahr wird nur noch der Restbetrag fällig, nicht die volle Rate.
- `plannedSaveRate` (Kopf-Kennzahl) ist die tatsächliche Rate des **ersten** Phasenjahres. In
späteren Jahren kann sie tiefer liegen, wenn eine Schuld ausläuft.
- `cashNegative` wird gesetzt, sobald der Cash-Bestand **irgendwann innerhalb** der Phase unter 0
fällt auch wenn er am Phasenende wieder positiv ist.
- Der Ruin bezieht sich auf das **Gesamtvermögen inkl. Immobilien**, nicht auf das Cash.
- `ruinAge` wird nur **einmal** gesetzt (erstes Auftreten, plan-weit).
Referenz: `src/lib/calculations.ts` Zeilen 365425.
## 4.8 Endwerte und Phasen-Kennzahlen
```
Einkommen: startValue = basis
endValue = basis × (1 + idx/100)^(duration1)
Ausgaben: startValue = basis × cumInflStart
endValue = basis × (1 + idx/100)^(duration1) × flowDeflatorEnd
Assets: endValue = a.value (nach der Jahresschleife)
Immobilie: endValue = re.value re.mortgage (Verkehrswert inkl. Wertsteigerung, minus Restschuld)
Schulden: endValue = d.owed (0, falls getilgt; + Notiz „Wird getilgt")
```
Aggregate:
```
startWealthNominal = Σ Element-Startwerte + cashStart
endWealthNominal = Σ Element-Endwerte + cashEnd
endWealthReal = endWealthNominal / cumulativeInflationEnd
isConsumption = quotaStart < 0
incomplete = cashNegative // „roter Status" = Liquiditätslücke
capitalInflow = incomingInflow // aus dem Übergang IN diese Phase
capitalInvest = investmentsFromCash + incomingImmediateRepay
oneOffInflow = incomingOneOffInflow // einmaliger Zufluss (netto nach Steuer)
oneOffOutflow = incomingOneOffOutflow // einmalige Kosten (nominal)
```
## 4.9 Der Übergang
Nach jeder Phase (auch nach der letzten) läuft die Übergangs-Logik. Sie liest
`transitionValues[phase.id]` der Übergang ist also am **Von**-Phasen-Schlüssel gespeichert.
```
ownerRetiresNext = owner existiert
∧ nextPhase existiert
∧ owner ist in DIESER Phase erwerbstätig
∧ owner.age + yearsBefore + duration >= owner.retirementAge
```
`INCOME`/`EXPENSE` sowie nicht-aktive Elemente überspringen die Logik (nur `hasCarry = true`).
### 4.9.1 PENSION_FUND
**Pensions-Übergang** (`ownerRetiresNext`), `value = ec.endValue`, Default-Modus `PENSION`:
| `payoutMode` | Wirkung |
|---|---|
| `CAPITAL` | `txInflow += round(value × (1 capitalTaxRate/100))`; `carry.value = 0`; `pkPensionAnnual = 0` |
| `PENSION` | `carry.pkPensionAnnual = round(value × conversionRate / 100)`; `carry.value = 0` |
| `COMBI` | `capital = min(value, capitalAmount)`; `txInflow += round(capital × (1 tax/100))`; `pkPensionAnnual = round((value capital) × conversionRate / 100)`; `carry.value = 0` |
**Normaler Übergang (Vorbezug)** brutto entnommen, netto ins Cash:
```
withdrawal = min(ec.endValue, round(td.withdrawal)) // brutto
carry.value = ec.endValue withdrawal
txInflow += round(withdrawal × (1 capitalTaxRate/100))
```
### 4.9.2 PILLAR_3A
**Pensions-Übergang**: immer vollständiger Bezug
`txInflow += round(ec.endValue × (1 capitalTaxRate/100))`; `carry.value = 0`.
**Normaler Übergang (Vorbezug)**: wie PK Bruttoentnahme, Netto-Zufluss nach
Kapitalbezugssteuer.
### 4.9.3 OTHER_ASSET
| `decision` | Wirkung |
|---|---|
| `SELL` | `txInflow += ec.endValue`; `carry.status = "SOLD"` (kein Steuerabzug) |
| `PARTIAL` | `part = min(ec.endValue, partialSaleAmount)`; `txInflow += part`; `carry.value = ec.endValue part` Rest bleibt **aktiv** und wächst weiter |
| `HOLD` / sonst | `carry.value = ec.endValue` |
Der Teilverkauf (Roadmap Nr. 42) fliesst über `txInflow` ins Cash der Folgephase und erscheint
dort im Phasenkopf als **„Kapitalzufluss"** (`capitalInflow`) das „zu investierende Kapital".
Er ist die **einmalige** Entnahme am Übergang; die laufende Entnahme *während* einer Phase
bleibt die jährliche Bezugsrate (`annualWithdrawal`, 4.6.6). Beide dürfen koexistieren.
### 4.9.4 REAL_ESTATE
Gelesen werden die laufenden Werte aus der Jahresschleife (`re.value`, `re.mortgage`,
`re.purchase`).
`decision = "SELL"`:
```
gain = max(0, salePrice re.purchase) // URSPRÜNGLICHER Kaufpreis, nicht der Verkehrswert
tax = gain × (saleTaxRate / 100)
txInflow += round(salePrice re.mortgage tax)
carry.status = "SOLD"
```
Der Nettoerlös ist Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer. Ein
Verlustverkauf erzeugt keine Steuer (`gain` bei 0 geklammert).
**Die Steuer bemisst sich am ursprünglichen Kaufpreis**, nicht am zwischenzeitlich gestiegenen
Verkehrswert deshalb führt das Modell beide getrennt (4.6.5). Nicht modelliert sind
wertvermehrende Investitionen und die Haltedauer-Abstufung (Roadmap Nr. 23).
**Halten (`decision ≠ SELL`)** mit optionaler **Sonderamortisation** (Roadmap Nr. 15):
```
extra = min(re.mortgage, extraAmortization) // am Restsaldo gekappt
txImmediateRepay += extra // Einmaltilgung aus dem Cash
carry.mortgage = re.mortgage extra
carry.propertyValue / carry.propertyPurchase // fortgeschrieben
```
Die Einmaltilgung bringt `REAL_ESTATE` auf Augenhöhe mit `OTHER_DEBT` (dort schon
`immediateRepayment`, 4.9.5): Sie senkt die Restschuld und damit die künftige Zinslast und
erscheint im Phasenkopf als **„Kapitalinvestition"** (`capitalInvest`). Damit lässt sich die
**indirekte Amortisation** mechanisch nachbilden 3a wachsen lassen, bei Pensionierung ins
Cash beziehen, dann per Sonderamortisation die Hypothek tilgen.
Die **Steuerwirkung** der indirekten Amortisation (3a-Abzug, erhaltener Zinsabzug) ist bewusst
**nicht** modelliert; sie gehört zum zurückgestellten Steuer-Bündel (siehe 9.14).
### 4.9.5 OTHER_DEBT
```
carry.owed = ec.endValue
immediate = min(carry.owed, round(td.immediateRepayment))
falls immediate > 0:
carry.owed = immediate
txImmediateRepay += immediate
falls carry.owed === 0 → carry.status = "SETTLED"
```
### 4.9.6 Cash: einmalige Sonderein-/ausgaben
Gelesen wird `phase.cashTransition` der Entscheid hängt an der **Von**-Phase. Er wird nur
ausgewertet, **wenn eine Folgephase existiert**; nach der letzten Phase gibt es keinen Übergang,
ein dort erfasster Betrag bleibt wirkungslos.
Der Umrechnungskurs zwischen real und nominal ist an dieser Grenze `cumulativeInflation`, also
der **Bestands-Deflator am Phasenende** (vgl. [4.5.3](#453-die-drei-deflatoren)) denn Cash ist
ein Bestand, und das Ereignis fällt exakt auf die Grenze.
```
mode = cashTransition.mode ?? "NONE"
falls mode ∈ {INFLOW, BOTH}: // nominal erfasst
brutto = round(inflowAmount)
txOneOffInflow = round(brutto × (1 inflowTaxRate/100))
falls mode ∈ {OUTFLOW, BOTH}: // real erfasst
txOneOffOutflow = round(outflowAmount × cumulativeInflation)
```
Beide Grössen fliessen ausschliesslich ins Cash der Folgephase (4.9.7) und **nicht** in die
Jahresschleife damit bleiben sie per Konstruktion aus Einkommen, Ausgaben und Quote heraus.
Ein Zufluss/eine Kostenposition, die das Cash unter 0 drückt, wird über den bestehenden
Startwert-Check der Folgephase (`cashNegative = cash < 0`) automatisch als Liquiditätslücke
erkannt.
### 4.9.7 Abschluss des Übergangs
```
cashCarryIn = cashEnd + txInflow + txOneOffInflow txImmediateRepay txOneOffOutflow
incomingInflow = txInflow // Kopf-Kennzahlen der Folgephase
incomingImmediateRepay = txImmediateRepay
incomingOneOffInflow = txOneOffInflow // inkl. Bezeichnung
incomingOneOffOutflow = txOneOffOutflow // inkl. Bezeichnung
yearsBefore += duration
```
## 4.10 Ergebnisstruktur
```ts
PlanComputed {
phases: PhaseComputed[] // alle Kennzahlen je Phase, inkl. elements[]
yearly: YearPoint[] // ein Punkt pro Jahr über alle Phasen (inkl. Vermögen je Jahr)
nachlass: number // = endWealthNominal der letzten Phase, sonst 0
ruinAge: number | null // Alter Person A beim ersten Gesamtvermögen < 0
}
```
## 4.11 Systemparameter
Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bundesanpassungen ändern:
| Konstante | Wert | Bedeutung |
|---|---|---|
| `AHV_MIN_MONTHLY_FULL` | 1'260 | **R0** Mindestbetrag der vollen Monatsrente (Skala 44). Alle Schwellen der Rentenformel sind Vielfache davon |
| `AHV_PENSION_MONTHS` | 13 | Rentenzahlungen pro Jahr 13. Altersrente ab 1.1.2026 |
| `AHV_CONTRIBUTION_START_AGE` | 21 | Beitragspflicht ab 1. Januar nach dem 20. Geburtstag |
| `AHV_MAX_ANNUAL_SINGLE` | 32'760 | **abgeleitet**: `2 × R0 × 13` |
| `AHV_GROSS_FROM_NET_FACTOR` | 1.12 | Netto → Brutto für die AHV; Herleitung siehe [4.4.5](#445-netto-brutto-umrechnung-für-die-ahv) |
| `AHV_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente |
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
| `AHV_REFERENCE_AGE` | 65 | Referenzalter ab hier fliesst die Rente, **unabhängig vom Pensionsalter** ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) |
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
| `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz |
| `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer |
| `DEFAULT_PROPERTY_GAINS_TAX_RATE` | 20 % | Grundstückgewinnsteuer |
Die drei Default-Sätze werden **sowohl als UI-Vorschlag als auch in der Berechnung als Fallback**
verwendet (`num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)`). Grund laut Code-Kommentar: Damit
ein nicht angetippter Wert nicht fälschlich als 0 gerechnet wird.
## 4.12 Monte-Carlo-Simulation
Die deterministische Berechnung nimmt pro Anlage *eine* feste Rendite und *eine* feste Inflation
an. Real schwanken beide. Die Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A, `montecarlo.ts`)
würfelt viele tausend mögliche Verläufe und weist die **Erfolgswahrscheinlichkeit** des Plans aus.
### 4.12.1 Die Nahtstelle in `computePlan`
`computePlan(plan, sample?)` nimmt optional ein `PlanSample`:
```ts
interface PlanSample {
inflation: number[]; // Inflation %/Jahr (Index 0 = Jahr 1)
assetReturn: (elementId: string, year: number) => number; // Rendite %/Jahr (1-basiert)
}
```
Ohne `sample` rechnet die Funktion **exakt wie bisher** (die geplanten Annahmen). Mit `sample`
liefert sie einen einzelnen simulierten Pfad. Voraussetzung war eine Umstellung der Inflation
auf ein **kumulatives Deflator-Array** `cumInfl[]` (statt der geschlossenen `(1+i)^t`-Formel),
damit die Inflation pro Jahr variieren kann deterministisch bitgenau identisch, durch die
Golden Tests abgesichert. Die AHV-Karriere bleibt bewusst auf der festen Plan-Inflation (sie
ist eine Real-Grösse auf Planungsbasis, sie wird nicht mitgewürfelt).
**Architektur-Vorteil:** `computePlan` ist eine reine Funktion ohne Server-Abhängigkeiten und
läuft damit **im Browser**. Die gesamte Simulation rechnet client-seitig null Serverlast.
~10'000 Läufe in rund 1 Sekunde; die Ausführung gibt alle 500 Läufe die Kontrolle ab
(Fortschrittsbalken, keine eingefrorene Oberfläche).
### 4.12.2 Das statistische Modell
Pro Jahr ein **gemeinsamer Marktschock** `z_markt`; je Element und Jahr:
```
rendite = mittelwert + σ × (ρ × z_markt + √(1−ρ²) × z_eigen)
```
- `z_markt`, `z_eigen`: **standardisierte Student-t** (ν = 5) „fette Ränder", damit
Extremcrashs realistisch häufig auftreten. Eine Normalverteilung macht ein 40%-Jahr zu einem
1-in-250-Ereignis; real ist es ~1-in-15. Standardisiert auf Einheitsvarianz → die eingegebene
Standardabweichung σ bleibt die tatsächliche.
- `ρ = 0.7` → Korrelation zweier riskanter Anlagen ≈ 0.5: **alle riskanten Anlagen fallen im
Crash gemeinsam.** Unabhängiges Würfeln würde das Absturzrisiko systematisch unterschätzen.
- **Böden:** `max(0, rendite)` für PK/3a (schreiben keine negative Rendite gut),
`max(100, rendite)` sonst.
Die Inflation wird analog gezogen (eigener Student-t-Schock, Mittelwert + σ), unabhängig vom
Marktschock. Der Zufallsgenerator ist **seedbar** (reproduzierbare Läufe).
### 4.12.3 Zwei Renditezahlen — und warum
Pro renditetragendem Element (PK, 3a, Sonstiges Vermögen, Immobilie) gibt es im Simulations-Dialog
**zwei** Renditen mit verschiedenen Rollen:
| Zahl | Rolle |
|---|---|
| **Geplante Rendite** (im Plan) | zeichnet die deterministische Linie = der *Zielbalken* |
| **Historische Ø-Rendite** (im Dialog, Pflicht) | der Mittelpunkt, um den die Simulation streut |
Ohne diese Trennung wäre die Kennzahl „P(erreiche mein geplantes Endvermögen)" **immer ~50 %**,
egal welche Rendite man annimmt (der Zielbetrag wüchse ja mit). Erst weil die Simulation um die
*historische* Rendite streut, während der Zielbalken auf der *geplanten* steht, wird ein
konservativer Plan (tiefe Planannahme) korrekt mit einer höheren Erfolgsquote belohnt als ein
optimistischer. Analog auf Plan-Ebene für die Inflation.
**Ehrliche Grenze (im Dialog ausgewiesen):** Die Simulation misst das Risiko *um deine Annahmen
herum* sie beurteilt **nicht**, ob deine Mittelwerte realistisch sind. Ein zu optimistischer
Mittelwert liefert eine beruhigende, aber unrealistische Erfolgsquote (siehe 9.15).
### 4.12.4 Streuungsstufen
| Stufe (Rendite) | σ | Beispiele | | Stufe (Inflation) | σ |
|---|---|---|---|---|---|
| Sehr niedrig | 3 % | Staatsanleihen, Geldmarkt | | Sehr niedrig | 1 % |
| Niedrig | 6 % | Immobilien, defensive Mischportfolios | | Niedrig | 2 % |
| Moderat | 15 % | breit diversifizierte Aktien-ETFs/Fonds | | Manuell | frei |
| Hoch | 25 % | Einzelaktien, Branchen-/Schwellenländerfonds | | | |
| Sehr hoch | 55 % | Kryptowährungen, hochspekulative Anlagen | | | |
| Manuell | frei | eigene Eingabe | | | |
Default-Stufe je Typ: PK → sehr niedrig · 3a/Immobilie → niedrig · Sonstiges Vermögen → moderat.
Bei Inflation gibt es bewusst nur zwei Stufen (höhere wären Hyperinflations-Annahmen).
Quellen der σ-Werte: Anleihen ~6 %, globale Aktien ~1518 %, Schweizer Immobilien(fonds) ~2 %,
Bitcoin ~54 %, Schweizer Inflation SD der letzten 20 J. ~1 %. Belege: BSV/Weltbank sowie
Markt-/Volatilitätsstatistiken (recherchiert 2026-07-17).
### 4.12.5 Ergebnis
| Kennzahl | Bedeutung |
|---|---|
| **Ruinwahrscheinlichkeit** | Anteil der Läufe mit `ruinAge !== null` (Vermögen fällt vor Planende unter 0) |
| **Erfolgswahrscheinlichkeit** | Anteil der Läufe mit Endvermögen ≥ Zielbetrag (nominal, vorbelegt mit dem geplanten Nachlass) |
| **Fächer** | je Alterspunkt (Phasengrenzen) das 10-/50-/90-Perzentil des Vermögens; dazu die deterministische Planungslinie |
Der Median liegt typischerweise **unter** der deterministischen Linie der „Volatilitäts-Drag"
(`geometrisch ≈ arithmetisch σ²/2`) macht sichtbar, dass die glatte Ein-Zahl-Planung schon
leicht zu optimistisch ist. Pflichtfelder: ohne die historischen Ø-Werte startet die Simulation
nicht.
### 4.12.6 Mehrere Szenarien im Vergleich
Der Dialog rechnet auf Wunsch **mehrere Szenarien desselben Plans in einem Lauf**. Drei
Entscheide machen den Vergleich überhaupt aussagekräftig.
**(1) Eine Parametereingabe je logischem Element.** Die MC-Parameter hängen an der `elementId`,
und Element-IDs sind szenario-spezifisch eine Kopie bekommt neue IDs. Ohne Zuordnung müsste
dieselbe Anlage pro Szenario erneut erfasst werden. Das wäre nicht nur mühsam, es würde den
Vergleich **zerstören**: Mit 5 % im einen und 6 % im anderen Szenario vergleicht man die
Eingaben statt der Szenarien.
Die Zuordnung läuft über die Herkunfts-Kette `sourceElementId` dieselbe Grundlage wie beim
Diff ([3.2.6](#326-abweichungs-markierung-diff)). `resolveRootElementId` folgt ihr bis zum
Ursprung; alle Elemente mit derselben Wurzel bilden eine **Gruppe** und teilen einen
Parametersatz. Deshalb lädt der Dialog beim Öffnen **alle** Szenarien des Plans, nicht nur die
ausgewählten: Nur so löst sich die Kette auch über ein übersprungenes Zwischen-Szenario auf
(Basis → S1 → S2 bei Auswahl von Basis und S2). Ein Element, das es nur in einem Szenario gibt,
bildet eine eigene Gruppe und wird im Dialog entsprechend gekennzeichnet.
**(2) Gemeinsamer Seed.** Alle Szenarien eines Laufs verwenden denselben Zufalls-Seed
(*Common Random Numbers*). Ohne das wären kleine Unterschiede blosses Rauschen: Bei 1'000 Läufen
beträgt der Standardfehler der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte zwei identische
Szenarien könnten 87 % und 90 % zeigen. Mit gemeinsamem Seed teilen **strukturgleiche** Szenarien
exakt dieselben Marktpfade, und die Unterschiede sind rein strukturell. Einschränkung: Die Pfade
sind nur dort identisch, wo die Struktur es ist abweichende Laufzeit oder Elementzahl verschiebt
die Ziehungsreihenfolge.
**(3) Zielbetrag je Szenario.** Der Zielbetrag ist bewusst **nicht** gemeinsam, sondern je
Szenario mit dessen geplantem Endvermögen vorbelegt (einzeln editierbar). Damit misst die
Erfolgswahrscheinlichkeit, wie oft ein Szenario **sein eigenes Versprechen** hält.
> **Warum das der entscheidende Punkt ist:** In der Simulation wird die *geplante* Rendite
> vollständig durch die gewürfelte ersetzt. Unterscheiden sich zwei Szenarien **nur** in der
> geplanten Rendite (5 % vs. 6 %), sind ihre simulierten Verteilungen **identisch** gleicher
> Median, gleicher Fächer, gleiche Ruinwahrscheinlichkeit. Der einzige Unterschied ist der
> Zielbetrag. Mit einem gemeinsamen Zielbetrag zeigte der Vergleich zwei identische Zeilen; mit
> szenario-eigenem Zielbetrag zeigt er die eigentliche Aussage: Das pessimistisch geplante
> Szenario erreicht sein tieferes Ziel häufiger und ist damit das belastbarere. Durch einen Test
> abgedeckt (Kap. 8.2).
Folge für die Darstellung: Die **Ruinwahrscheinlichkeit** ist zielbetrags-unabhängig und damit
die direkt vergleichbare Kennzahl; die Erfolgswahrscheinlichkeit bezieht sich je Zeile auf eine
andere Messlatte. Deshalb steht der Zielbetrag als **eigene Spalte** in der Vergleichstabelle.
**Darstellung:** eine Vergleichstabelle (Szenario, Ziel, Erfolg, Ruin, P10/Median/P90) als
Hauptinstrument, dazu ein Chart mit der **Median-Linie je Szenario**. Übereinandergelegte
1090 %-Bänder wären unlesbar; der vollständige Fächer inklusive deterministischer Linie erscheint
deshalb nur, wenn **genau ein** Szenario ausgewählt ist dann verhält sich der Dialog exakt wie
zuvor.
**Laufzeit:** Die Szenarien laufen sequenziell, der Fortschritt weist Szenario und Gesamtanteil
aus. Die Schätzung skaliert mit der Anzahl Szenarien.
Referenz: `src/lib/montecarlo.ts` (`resolveRootElementId`, `buildElementGroups`,
`paramsForScenario`, `runMonteCarloMulti`), `src/components/MonteCarloDialog.tsx`.
### 4.12.7 Zwei Welten, vier Fälle
Die Simulation läuft **immer zweimal** einmal um die **historischen** Renditen, einmal um die
**geplanten** Werte des Plans. Aus jeder der beiden Verteilungen werden **zwei Schwellen**
abgelesen: der **Plan-Endbetrag** (read-only, der geplante Nachlass des Szenarios) und der
**Zielbetrag** (manuell, einer für alle Szenarien). Das ergibt vier Zahlen:
| | Schwelle **Plan-Endbetrag** | Schwelle **Zielbetrag** |
|---|---|---|
| Welt **historisch** (Eingabe) | **Fall 1** Realismus der Planung | **Fall 3** Ziel, wenn der Markt wie früher läuft |
| Welt **geplant** (aus dem Plan) | **Fall 2** Nullpunkt für das Urteil | **Fall 4** Ziel, wenn der Markt wie geplant läuft |
Das baut auf der **Trennung der zwei Renditezahlen** aus [4.12.3](#4123-zwei-renditezahlen--und-warum)
auf. Die **Streuung ist in beiden Welten dieselbe**; nur der Mittelpunkt unterscheidet sie. In der
geplanten Welt kommt der Mittelpunkt je Element aus `plannedReturnOf` (erste Phase, bei Immobilien
die Wertsteigerung) und die Inflation aus `plan.inflationRateDefault`.
**Warum zwei Läufe und nicht vier.** Eine Wahrscheinlichkeit P(Endvermögen ≥ X) hängt nur von der
Verteilung und von X ab. Wird das gesamte Endvermögen eines Laufs sortiert aufbewahrt
(`finalWealthSorted`), lässt sich jede Schwelle nachträglich per Binärsuche ablesen
(`probabilityAtLeast`). Zwei Läufe genügen also für vier Zahlen halbe Rechenzeit, und vor allem:
**Fall 1 und Fall 3 stammen aus derselben Verteilung.** Liegt der Zielbetrag unter dem
Plan-Endbetrag, ist Fall 3 damit **zwingend ≥ Fall 1**. Der Widerspruch, der die alte Darstellung
unlesbar machte («Planung 69 %, Ziel 3 Mio nur 41 %» zwei verschiedene Welten gegeneinander
gestellt), ist strukturell nicht mehr möglich. Ein Test sichert das ab.
**Der Nullpunkt ist Fall 2, nicht 50 %.** Naiv erwartet man, dass der eigene Plan-Endbetrag in der
eigenen geplanten Welt in ~50 % der Fälle erreicht wird. Das ist falsch: Der Volatilitäts-Drag
drückt den Median unter den deterministischen Pfad, und zwar umso stärker, je höher die Streuung
(gemessen: 37 % bei σ = 15 %, 27 % bei σ = 25 %; siehe [9.26](#926-fall-2-ist-der-nullpunkt--nicht-50-)).
Fall 2 misst genau diesen Nullpunkt und wird deshalb als **Referenz** ausgewiesen, gegen die Fall 1
verglichen wird beides derselbe Schwellwert, nur andere Renditeannahme.
**Urteil** (Toleranzband ± 5 Prozentpunkte):
| Fall 1 vs. Fall 2 | Urteil |
|---|---|
| mehr als 5 pp höher | Planung **zurückhaltend** gerechnet |
| innerhalb ± 5 pp | Planung **realistisch** gerechnet |
| mehr als 5 pp tiefer | Planung tendenziell **zu optimistisch** |
**Darstellung** (bei einem einzelnen Szenario):
- **Prominent:** Fall 1 als grosse Prozentzahl mit dem Urteil.
- **Untergeordnet:** Fall 3 und 4 als Satzpaar, dessen Wortwahl automatisch dreht, je nachdem
welche Welt besser dasteht («Wenn der Markt so schlecht läuft, wie du es in deiner Planung
vorgibst, … bei X %. Läuft er so gut wie in der Vergangenheit, … bei Y %.»).
- **Kleingedruckt:** Fall 2 als Referenz plus der Median je Welt.
Die Tabelle zeigt je Szenario `Fall 1 (Fall 2)` und `Fall 3 (Fall 4)`. **Ruin**, **Bandbreite** und
**Fächer** stammen immer aus der historischen Welt, weil das das ehrliche Risikobild ist.
**Eingaben:** Da beide Welten immer laufen, sind die historischen Mittelwerte **und** der
Zielbetrag stets Pflichtfelder. Der frühere Drei-Wege-Umschalter entfällt.
**Technik:** kein Eingriff in den Rechenkern. Beide Durchgänge laufen mit **demselben Seed**
(Common Random Numbers), damit der Unterschied zwischen den Welten nicht Rauschen ist.
Referenz: `src/lib/montecarlo.ts` (`finalWealthSorted`, `probabilityAtLeast`, `plannedReturnOf`),
`src/components/MonteCarloDialog.tsx`.
## 4.13 Sensitivitätsanalyse (Tornado)
Die Monte-Carlo-Simulation würfelt alle Unsicherheiten gleichzeitig und beantwortet „wie
wahrscheinlich geht mein Plan auf?". Die Sensitivitätsanalyse (Roadmap Nr. 20, `sensitivity.ts`)
beantwortet die komplementäre Frage: **„Welche meiner Annahmen entscheidet überhaupt über das
Ergebnis?"**
### 4.13.1 Verfahren
**One-at-a-time (OAT):**
```
base = Zielgrösse(Plan)
für jeden ausgewählten Treiber d:
lowResult = Zielgrösse(applyDriver(Plan, d, d.low))
highResult = Zielgrösse(applyDriver(Plan, d, d.high))
swing = |highResult lowResult|
sortiere absteigend nach swing → Trichterform, längster Balken zuoberst
```
Alle übrigen Parameter bleiben dabei auf dem Planwert. Das sind 2 Aufrufe je Treiber bei
sieben Treibern 14 `computePlan`-Aufrufe, also Millisekunden. Wie die Monte-Carlo-Simulation
läuft alles **im Browser**; `applyDriver` ist rein und lässt den Ausgangsplan unberührt.
**Zielgrösse** ist das Endvermögen der letzten Phase, wahlweise **real** (Default,
kaufkraftbereinigt) oder nominal. Das Ruinalter wäre als Balkengrösse untauglich, weil es in
vielen Plänen `null` ist.
### 4.13.2 Die Treiber und ihre Einheiten
Die Einheit ist je Treiber verschieden und lässt sich nicht vereinheitlichen, ohne fachlich
falsch zu werden:
| Treiber | Einheit | Wirkung |
|---|---|---|
| Ausgaben | **relativ %** | skaliert `amount` aller `EXPENSE`-Elemente |
| Rendite (PK, 3a, Sonstiges Vermögen) | **Δ Prozentpunkte** | verschiebt `expectedReturn` |
| Lebensdauer | **Δ Jahre** | verlängert/verkürzt die **letzte** Phase (min. 1 Jahr) |
| Inflation | **absolut %** | setzt `inflationRateDefault` |
| Einkommen | **relativ %** | skaliert `amount` aller `INCOME`-Elemente |
| Lohnentwicklung | **Δ Prozentpunkte** | verschiebt `teuerungsausgleich` der `INCOME`-Elemente |
| Wertsteigerung der Immobilie | **Δ Prozentpunkte** | verschiebt `valueGrowth` |
| Pensionsalter Person A / B | **Δ Jahre** | verschiebt die Phasengrenze der Pensionierung ([4.16](#416-pensionsalter-verschieben)) |
Die Begründungen im Einzelnen:
- **Absolut** nur bei der Inflation es gibt genau einen plan-weiten Wert.
- **Δ Prozentpunkte** bei den Renditen, weil die Elemente je eigene Sätze tragen. Ein absolutes
„3 % bis 7 %" würde die PK auf ETF-Rendite plätten.
- **Relativ %** bei Einkommen und Ausgaben, weil die Elemente je eigene Beträge tragen.
- Immobilien-Wertsteigerung ist ein **eigener** Treiber und nicht Teil von „Rendite", damit sie
nicht doppelt zählt.
Die Skalierung von Einkommen/Ausgaben greift nur dort, wo `amount` gesetzt ist. Das ist korrekt
und beabsichtigt: Ab Phase 2 ist der Wert in der Regel live vererbt ([4.6.1](#461-income--expense)),
und die Fortschreibung leitet ihn aus dem skalierten Basiswert ab die Skalierung wirkt damit
automatisch über alle Folgephasen.
Ein Treiber erscheint nur, wenn der Plan passende Elemente enthält (`applies`).
### 4.13.3 Bandbreiten sind Pflicht ohne Default
Je Treiber gibt der Benutzer eine tiefe und eine hohe Ausprägung an; **Vorgabewerte gibt es
bewusst nicht**. Grund: Die Balkenlänge hängt direkt von diesen Bandbreiten ab. Ein stiller
Default würde nicht hinterfragt, und das Ranking wäre dann eine Aussage über unsere Vorgabe
statt über den Plan dieselbe Begründung wie bei den Monte-Carlo-Mittelwerten
([9.15](#915-monte-carlo-misst-risiko-um-die-annahmen-nicht-deren-richtigkeit)).
Die Hilfe-Bubble je Treiber nennt stattdessen plausible Grössenordnungen. Entscheidend ist, die
Bandbreiten **ähnlich plausibel** zu wählen, nicht ähnlich gross: „±10 % Inflation" (1.5 → 1.65 %)
und „±10 % Ausgaben" sind völlig ungleich wahrscheinlich.
Zwei weitere Regeln: mindestens **zwei** Treiber (ein Tornado ist eine Rangliste ein einzelner
Balken ordnet nichts), und tiefer und hoher Wert dürfen nicht identisch sein (Spannweite 0).
### 4.13.4 Darstellung
Waagrechtes Balkendiagramm, je Balken die Spanne `min…max` der Zielgrösse, senkrechte
Referenzlinie beim Basisfall, sortiert nach Spannweite. Darunter eine Tabelle mit der
eingegebenen Bandbreite, den beiden Ergebniswerten und der Spannweite.
Die **Richtung kann sich umkehren** tiefe Ausgaben ergeben ein hohes Endvermögen. Der Balken
spannt deshalb über `min…max`; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
Referenz: `src/lib/sensitivity.ts`, `src/components/SensitivityDialog.tsx`.
### 4.13.5 Wirkungslose Treiber werden erklärt
Ein Balken mit Spannweite 0 ohne Erklärung ist die schlechteste Antwort der Benutzer hält
ihn für einen Fehler. `computeTornado` hängt deshalb an jeden Nullbalken eine Begründung
(`ineffectiveReason`).
Der wichtigste Fall ist die **Immobilien-Wertsteigerung bei einem Verkauf**. Der Verkaufserlös
ist `Verkaufspreis Hypothek Grundstückgewinnsteuer` und hängt damit am erfassten
Verkaufspreis, **nicht** am modellierten Verkehrswert. Wird die Immobilie vor Planende
verkauft, wird die aufgelaufene Wertsteigerung an dieser Stelle verworfen der Treiber kann
das Endvermögen dann rechnerisch nicht mehr beeinflussen.
Erkannt wird das daran, dass in der letzten Phase **alle** `REAL_ESTATE`-Elemente den Status
`SOLD` tragen. Andernfalls greift ein allgemeiner Hinweis. Durch Tests abgedeckt: gehalten →
Spannweite > 0 ohne Hinweis; verkauft → Spannweite 0 mit Begründung.
Verwandt: Der Verkaufspreis-Abgleich im Übergangs-Dialog ([3.5.8](#358-verkaufspreis-und-modellierter-verkehrswert))
setzt an derselben Stelle an, nur früher er verhindert, dass die Annahmen überhaupt
auseinanderlaufen.
## 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 ist die eingebaute Selbstkontrolle ist die
Zerlegung vollständig und richtig, muss er **exakt 0** sein.
`src/lib/bridges.test.ts` nagelt das über **sieben Plankonstellationen** fest (Ansparen mit 3a
und Schuldentilgung, Pensionierung mit Verrentung und 3a-Bezug, PK-Kapitalbezug, Immobilie
gehalten, Immobilie verkauft, einmalige Sonderein-/ausgaben, Sofort-Tilgung mit
Sonderamortisation) je Phase für beide Brücken, zusätzlich der Abgleich der Kontrollpunkte
gegen `startWealthNominal` / `endWealthNominal` / `cashStart` / `cashEnd`.
Im UI wird ein Restposten über 2 Franken als **Fehlermeldung** ausgewiesen, nicht als beiläufige
Rundungsnotiz: Eine Brücke, die nicht aufgeht, ist ein Rechenfehler und kein Darstellungsproblem.
### 4.14.2.1 Darstellung der Wasserfälle
Die Wasserfälle werden **nicht mit Recharts** gezeichnet. Ein Wasserfall lebt von drei Dingen,
die dort nicht ohne Weiteres zu bekommen sind:
- **Verbindungslinien** zwischen den Balken ohne sie sieht man nicht, dass jeder Balken dort
ansetzt, wo der vorherige aufhört, und die Grafik zerfällt in unverbundene Rechtecke.
- **Wertbeschriftung** an jedem Schritt, statt Beträge aus der Achse zu schätzen.
- **Unterscheidung von Zwischenstand und Veränderung.** Ein Zwischenstand („Vermögen
Phasenbeginn") ist ein absoluter Wert ab Null, eine Veränderung („Kapitalerträge") setzt auf dem
laufenden Saldo auf. Sehen beide gleich aus, ist die Grafik nicht lesbar.
Die Darstellung ist deshalb eine eigene HTML/CSS-Konstruktion und **liegend** statt stehend die
Beschriftungen sind lang und müssten stehend gedreht werden; liegend ist es ausserdem konsistent
zum Tornado. Abschnitts-Überschriften trennen „Am Übergang in diese Phase" von „Innerhalb der
Phase". Posten mit Wert 0 werden gar nicht erst gezeichnet.
Darunter steht aufklappbar eine **Tabelle mit laufendem Zwischenstand**. Bei sieben bis zwölf
Schritten mit stark unterschiedlichen Grössenordnungen ist sie der Grafik schlicht überlegen
die Grafik zeigt das Verhältnis, die Tabelle die Zahl.
### 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`.
## 4.15 Live-Simulation (Was-wäre-wenn-Regler)
Roadmap Nr. 22. Beantwortet weder «welche Annahme entscheidet» (das ist der Tornado,
[4.13](#413-sensitivitätsanalyse-tornado)) noch «wie wahrscheinlich ist das» (das ist
Monte-Carlo, [4.12](#412-monte-carlo-simulation)), sondern schlicht: **«Wie sieht mein Plan aus,
wenn ich hier drehe?»** sofort, und ohne für jede Variante eine Szenario-Kopie anzulegen.
Eigener Button **«Live-Simulation»** in der Szenario-Leiste, Dialog als Zweispalter: links die
Regler, rechts die Grafik, darüber eine Kennzahlenleiste.
### 4.15.1 Keine eigene Rechenlogik
Die Regler benutzen **dieselben Transformationen wie der Tornado** (`applyDriver`). Damit kann
die Live-Simulation gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse beide
bewegen den Plan identisch. Es entsteht kein zweiter, potenziell abweichender Rechenweg.
Neu hinzu kommt nur `applyElementDriver(plan, elementId, deltaPp)`: dieselbe Verschiebung, aber
auf **ein einzelnes** Element statt auf eine ganze Kategorie (bei Immobilien auf `valueGrowth`
statt `expectedReturn`). Element-IDs sind innerhalb eines Szenarios eindeutig; die
Herkunfts-Verkettung `sourceElementId` aus der Monte-Carlo-Simulation braucht es hier **nicht**,
weil die Live-Simulation immer nur auf **einem** Szenario läuft.
### 4.15.2 Sammelregler und Aufschlüsselung
Standardmässig gibt es **einen** Rendite-Regler für alle Anlagen das hält das Panel ruhig und
entspricht dem Tornado. Ein Klick auf **«Renditen einzeln aufschlüsseln»** ersetzt ihn durch je
einen Regler pro renditetragendem Element (PK, 3a, Sonstiges Vermögen, Immobilie). Erst dann
lässt sich die eigentliche Spielfrage stellen: *Was, wenn mein ETF schlechter läuft, die PK aber
wie geplant?*
Der Sammelregler wird beim Aufklappen **entfernt**, nicht bloss ergänzt sonst würde eine
Bewegung doppelt zählen. Aus demselben Grund werden die Rendite-Regler beim Umschalten
zurückgesetzt. Ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben: Alle
Elemente einzeln um +1 pp zu heben ergibt exakt dasselbe Endvermögen wie der Sammelregler auf
+1 pp.
**Bewusst nicht aufschlüsselbar sind Ausgaben und Einkommen.** «Alle Ausgaben ±20 %» ist die
Frage, die man tatsächlich stellt; Ausgaben-Elemente sind typischerweise viele kleine Posten,
deren Einzelregler das Panel fluten würden, ohne eine bessere Frage zu ermöglichen.
**Das Pensionsalter ist seit 0.26 als Regler dabei** (je Person einer, sofern überhaupt
Spielraum besteht). Sein Bereich ist **plan-abhängig**: Er endet dort, wo eine angrenzende
Lebensphase unter ein Jahr fiele ein Regler, der stumm an seiner Grenze klebt, wäre schlechter
als keiner. Der Treiber **Lebensdauer** bleibt daneben bestehen; er beantwortet die andere Frage
(wie lange muss es reichen, statt wann höre ich auf).
### 4.15.3 Referenz und Kennzahlen
Eine wandernde Linie ohne Anker ist wertlos «ist 2.9 Mio jetzt viel oder wenig?». Deshalb:
- Der **unveränderte Plan** wird im Vermögensverlauf als blasse Referenzlinie mitgezeichnet.
- Darüber steht eine **Kennzahlenleiste** mit Endvermögen nominal und real, jeweils mit der
Differenz zum Plan (`3'660'683 → 2'880'100, 780'583`).
- Eine dritte Karte meldet, ob das **Kapital reicht** oder in welchem Alter es aufgebraucht ist.
Das ist die wichtigste Einzelinformation und einer Verlaufslinie nicht zuverlässig anzusehen:
Ein Plan kann optisch plausibel aussehen und trotzdem zwischendurch unter null fallen.
Die Grafik zeigt **wann** sich etwas ändert, die Leiste **wie viel**.
Rechts stehen drei Grafiken zur Wahl: **Vermögensverlauf** (mit Referenzlinie),
**Vermögensaufteilung** je Phase und **Einkommen vs. Ausgaben**. Die beiden letzteren zeigen nur
den simulierten Stand ein zweiter gestapelter Balkensatz wäre nicht mehr lesbar; darauf weist
der Dialog hin.
### 4.15.4 Laufzeit: synchron, ohne Debounce
Gemessen an einem Plan über 60 Jahre mit 10 Elementen braucht `computePlan` rund **0.2 ms**.
Bei 60 fps stehen 16 ms je Bild zur Verfügung die Rechnung kostet also etwa **1 %** des
Budgets. Deshalb wird bei **jeder** Reglerbewegung synchron neu gerechnet: kein Debounce, kein
Web Worker, keine Ladeanzeige. Der Engpass ist das Neuzeichnen der Grafik, nicht die Mathematik.
### 4.15.5 Nichts wird gespeichert
Die Live-Simulation **schreibt nicht** keine API, keine Datenbank, kein Schreibpfad. Genau das
ist der Punkt der Roadmap-Anforderung («ohne für jede Variante eine Szenario-Kopie anzulegen»).
Ein **«Als neues Szenario speichern»** ist bewusst **noch nicht** umgesetzt: Reglerwerte in echte
Element- und Phasenwerte zurückzuschreiben hiesse viele einzelne Schreibvorgänge und einen neuen
Schreibpfad eine eigene Ausbaustufe. Als Behelf zeigt der Dialog die **aktive Einstellung** als
lesbare Zeile («Rendite 1.5 pp · Ausgaben +10 % · Lebensdauer +5 J.»), die sich von Hand in ein
echtes Szenario übertragen lässt.
Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
`tunableElements`), `src/components/LiveSimDialog.tsx`, `src/components/AllocationChart.tsx`.
## 4.16 Pensionsalter verschieben
> Modul `src/lib/retirement.ts`. Rein: Es entscheidet nur, **was** geschehen soll; das
> Schreiben übernimmt der Aufrufer (Panel: API-Aufrufe; Tornado/Live-Simulation: reine
> Plan-Kopie).
### 4.16.1 Die tragende Invariante
`maxPhaseDuration(persons, yearsBefore)` kappt jede Phasendauer beim nächsten
Pensionierungsereignis. Daraus folgt: **Jede Pensionierung liegt auf einer Phasengrenze.** Genau
das macht die Anpassung überhaupt erst möglich «Pensionsalter ändern» ist gleichbedeutend mit
«diese eine Grenze verschieben».
`retirementBoundaries(plan)` liefert je Person:
| Feld | Bedeutung |
|---|---|
| `planYear` | Planjahr der Pensionierung (`retirementAge age`) |
| `phaseIndex` | Index der Phase **vor** der Grenze |
| `minDelta` / `maxDelta` | Spielraum, ohne dass eine Nachbarphase unter 1 Jahr fällt |
| `mergeDeltaDown` / `mergeDeltaUp` | genau der Wert, bei dem eine Phase entfällt |
| `blocked` | Erklärtext, wenn gar keine Anpassung möglich ist (siehe 3.12.2) |
### 4.16.2 `shiftRetirement(plan, role, delta)`
```
Phase[i].dauer += delta // vor der Grenze
Phase[i+1].dauer = delta // nach der Grenze
person.retirementAge += delta
```
Fällt eine der beiden auf 0, wird sie entfernt und die `sequenceNumber` lückenlos neu vergeben.
Die Übergangsdaten der entfallenden Phase werden nach der Regel aus 3.12.3 in den Vorgänger
gezogen (`mergeTransition`, `mergeCashTransition`). Ausserhalb des erlaubten Bereichs liefert
die Funktion `null` sie klemmt nicht still.
### 4.16.3 Als Treiber und Regler
`applyDriver(plan, "retirementA" | "retirementB", jahre)` benutzt dieselbe Funktion. Zwei
bewusste Abweichungen gegenüber der Bedienung:
* Der Bereich endet bei `minDelta`/`maxDelta`, **ohne** Zusammenlegung. Eine Zusammenlegung
verändert den Plan inhaltlich (zwei Übergänge werden einer) dafür ist eine
Was-wäre-wenn-Betrachtung der falsche Ort.
* Eine zu weite Eingabe wird auf das Mögliche **gekürzt** statt verworfen: Eine Bandbreite von
±5 Jahren soll auch dann etwas zeigen, wenn nur ±2 möglich sind. Bleibt gar kein Spielraum,
erklärt `ineffectiveReason` den Nullbalken.
Der Hebel ist doppelt länger Einkommen **und** kürzer Verzehr und deshalb meist einer der
grössten im Tornado.
### 4.16.4 Punkt B: Einkommen endet mit der Pensionierung
Bis 0.25 lief ein Erwerbseinkommen stillschweigend in die Pensionsphase weiter (der Basiswert
wird ja fortgeschrieben). Bei fixem Pensionsalter fiel das kaum auf; sobald sich die Grenze
verschieben lässt, ist es ein handfester Fehler «drei Jahre früher aufhören» hätte sonst gar
keine Wirkung gehabt.
```
Kategorie INCOME, Element ist EINER PERSON zugeordnet,
Person in dieser Phase pensioniert, kein ausdrücklicher Betrag erfasst
→ Basiswert = 0
```
Drei bewusste Einschränkungen:
* **Nur personenzugeordnete Einkommen.** «Gemeinsam» (Mieterträge, Ausschüttungen) hängt nicht
an der Erwerbstätigkeit einer Person und läuft weiter.
* **Ein ausdrücklich erfasster Betrag gewinnt.** Sonst liesse sich ein Teilzeitpensum oder eine
Überbrückungsrente nach der Pensionierung nicht abbilden.
* Das Element zeigt in dieser Phase den Hinweis, dass es wegen der Pensionierung auf 0 steht.
### 4.16.5 Vererbte Wiederkehr-Parameter (Punkt A)
Der `Carry` führt neu eine Tabelle `rates` mit den zuletzt verwendeten Werten. Ist ein Feld in
einer Phase nicht gesetzt, gilt der Wert aus der Vorphase; der verwendete Wert wird wieder
abgelegt, sodass die Kette über beliebig viele Phasen trägt. Betroffen sind
`teuerungsausgleich`, `expectedReturn`, `annualContribution`, `annualWithdrawal`,
`amortization`, `valueGrowth`, `interestRate` und `annualRepayment`.
### 4.16.6 Fehlerbehebung: fortgeschriebener Basiswert und effektive Werte
Der Basiswert der Folgephase (`carry.flowBasis`) wurde beim **Element-Setup** berechnet, also
**vor** der Jahresschleife. Ein effektiver Wert setzt den Basiswert aber erst **in** der
Jahresschleife neu (`rebaseFlow`). Folge: Ein für 2031 erfasster Lohn wirkte bis zum Ende seiner
Phase und fiel an der Phasengrenze stillschweigend auf den geplanten Wert zurück.
Die Fortschreibung passiert neu **nach** der Jahresschleife, gemeinsam mit den Endwerten. Zwei
Regressionstests in `actuals.test.ts` halten den Fall fest.
---
# 5. Technische Spezifikation
## 5.1 Technologie-Stack
| Bereich | Technologie | Version |
|---|---|---|
| Framework | Next.js (App Router) | 16.2.10 |
| UI | React | 19.2.4 |
| Sprache | TypeScript | ^5 |
| Styling | Tailwind CSS | ^4 (via `@tailwindcss/postcss`) |
| Icons | lucide-react | ^1.24.0 |
| Charts | recharts | ^3.9.2 |
| ORM | Prisma | ^7.8.0 (Client-Output nach `src/generated/prisma`) |
| Datenbank | PostgreSQL | 16-alpine |
| DB-Treiber | `pg` + `@prisma/adapter-pg` | ^8.22.0 / ^7.8.0 |
| Validierung | Zod | ^4.4.3 |
| Auth | jose (JWT) + bcryptjs | ^6.2.3 / ^3.0.3 |
| Tests | Vitest | ^4.1.10 |
| Build | Docker (multi-stage), `output: "standalone"` | |
> **Hinweis für Entwickler:** Gemäss `AGENTS.md` weicht diese Next.js-Version von verbreiteten
> Konventionen ab. Vor Änderungen ist der relevante Guide unter `node_modules/next/dist/docs/`
> zu konsultieren.
## 5.2 Verzeichnisstruktur
```
FPT/
├── prisma/
│ ├── schema.prisma Datenmodell
│ └── migrations/ 17 Migrationen (chronologisch, siehe 5.4.6)
├── src/
│ ├── app/
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
│ │ ├── login/page.tsx Login-/Registrierseite
│ │ ├── 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/ 23 React-Komponenten (alle "use client")
│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren)
│ ├── lib/ Domänenlogik (siehe 5.3)
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
├── Info Dateien/ Fachdokumente, Roadmap (historisch)
├── docker-compose.yml, Dockerfile, docker-entrypoint.sh
└── .gitea/workflows/deploy.yaml CI/CD
```
## 5.3 Schichtenmodell und `src/lib`
Ein bewusster Entkopplungs-Entscheid (`src/lib/types.ts` Zeilen 14): Die Berechnungslogik
arbeitet auf **eigenen Domänentypen**, nicht auf den generierten Prisma-Typen. Dadurch ist
`computePlan` ohne Datenbank testbar.
```
Prisma-Modelle (DB)
│ toPlanInput() ← queries.ts: parst + validiert die JSON-Felder
PlanInput (types.ts) ← reine Domänentypen
│ computePlan() ← calculations.ts: pure function
PlanComputed ← an den Client geliefert
```
| Datei | Verantwortung |
|---|---|
| `calculations.ts` | Berechnungskern + CSV-Export. Keine I/O. |
| `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` |
| `types.ts` | Domänentypen für API und Berechnung |
| `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`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. |
| `actuals.ts` | Effektive Werte: Zuordnung auf die Szenario-Elemente über die Herkunfts-Kette, Einspielen in den Rechenkern, Bestand/Fluss. Rein. |
| `report.ts` | Berichtsmodell: Kennzahlen, Annahmen, Vergleich, Plan/Ist -- rein, ohne PDF-Kenntnisse (Kap. 3.11). |
| `report-pdf.ts` | Zeichnet das Berichtsmodell mit pdfkit. Nur serverseitig. |
| `decisions.ts` | Offene Übergangs-Entscheide -- von Matrix UND Bericht benutzt, damit beide dieselbe Zahl nennen. |
| `analyses.ts` | Gespeicherte Analysen: Typen, generische Ergebnis-Form, Speicher-Helfer (Kap. 3.10). |
| `dataview.ts` | Bündelt Plan-Sicht und Ist-Sicht für Matrix, Grafiken und Analysewerkzeuge. Rein. |
| `ratefields.ts` | Ratenfelder je Kategorie, Reichweite der Übernahme über Lebensphasen, Aufbau der Schreibvorgänge (Kap. 3.6.11). Rein. |
| `versioning.ts` | Versionierung: Nummerierung A.B, Zusammenfassung je Sitzung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien. Rein, ohne I/O. |
| `versioning-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. |
| `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. |
| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. |
| `retirement.ts` | Pensionsalter verschieben: Spielraum je Person, Verschiebung der Phasengrenze, Zusammenlegung zweier Übergänge (Kap. 4.16). Rein, ohne I/O. |
| `transitions.ts` | Reine Übergangs-Regeln (Vorbelegung, «beantwortet?», Cash-Zusammenfassung). Liegt hier und nicht in einer Komponente, weil auch der Server sie braucht -- ein Import aus `src/components` bricht erst in der Produktion. |
| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. |
| `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) |
| `auth.ts` | JWT erzeugen/prüfen (Edge-kompatibel via `jose`) |
| `session.ts` | `getCurrentUserId()` aus dem Cookie (Node-Runtime) |
| `users.ts` | Registrierung, Credential-Prüfung, Passwortwechsel (bcrypt). **Nie aus der Middleware importieren** Edge-Runtime hat keinen DB-Zugriff. |
| `format.ts` | `formatChf` / `parseChfInput` |
| `theme.ts` | Theme-Verwaltung (localStorage + `data-theme`) |
| `api-client.ts` | Typisierter `fetch`-Wrapper mit einheitlicher Fehlerextraktion |
## 5.4 Datenmodell
### 5.4.1 Tabellen
**User**
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `username` | String | **unique** |
| `passwordHash` | String | bcrypt, Cost 12 |
| `createdAt` | DateTime | `now()` |
**Person**
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `scenarioId` | String | FK → Scenario, **Cascade** |
| `role` | `PersonRole` | `PERSON_A` \| `PERSON_B` |
| `name` | String? | optional |
| `age` | Int | aktuelles Alter |
| `retirementAge` | Int | **szenario-eigenes** Pensionsalter |
| | | `@@unique([scenarioId, role])` |
**Plan** (Behälter trägt keine Finanzdaten)
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `userId` | String | FK → User, **Cascade** |
| `name` | String | |
| `createdAt` / `updatedAt` | DateTime | |
**Scenario** (die berechenbare Einheit)
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `planId` | String | FK → Plan, **Cascade** |
| `name` | String | |
| `isBase` | Boolean | Default false; genau eines je Plan ist `true` |
| `parentScenarioId` | String? | FK → Scenario (Self-Relation „ScenarioTree"), **SetNull** Baum **und** Vergleichsbasis |
| `householdType` | `HouseholdType` | `SINGLE` \| `COUPLE` |
| `inflationRateDefault` | Float | szenario-weite Inflation in % |
| `initialCash` | Float | Default 0 |
| `startYear` | Int? | Kalenderjahr des Planbeginns **nur Darstellung** (siehe 3.2.7) |
| `createdAt` / `updatedAt` | DateTime | |
**Phase**
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `scenarioId` | String | FK → Scenario, **Cascade** |
| `sequenceNumber` | Int | 1-basiert, lückenlos |
| `name` | String | |
| `durationYears` | Int | 180 |
| `cashTransition` | Json? | Cash-Entscheid beim Übergang **nach** dieser Phase (siehe 5.4.5) |
| `sourcePhaseId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) Diff-Grundlage |
| `createdAt` / `updatedAt` | DateTime | |
| | | `@@unique([scenarioId, sequenceNumber])` |
`cashTransition` liegt an der Phase und nicht in `ElementTransitionValue`, weil Cash kein
`FinancialElement` ist und damit keine `elementId` besitzt. Die Verschlüsselung folgt derselben
Logik wie dort: Der Übergang gehört der **Von**-Phase.
**FinancialElement**
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `scenarioId` | String | FK → Scenario, **Cascade** |
| `category` | `ElementCategory` | 8 Werte |
| `name` | String | |
| `ownerRole` | `OwnerRole?` | `PERSON_A` \| `PERSON_B` \| `HOUSEHOLD` |
| `orderIndex` | Int | Default 0 |
| `sourceElementId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) Diff-Grundlage |
| `createdAt` | DateTime | |
Die Herkunfts-Verweise sind bewusst **lose** (kein Fremdschlüssel): Wird das Gegenstück in der
Vorlage gelöscht, soll die Kopie bestehen bleiben und im Diff einfach als „neu" gelten ein
Cascade wäre hier falsch.
**ElementPhaseValue**
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `elementId` | String | FK → FinancialElement, **Cascade** |
| `phaseId` | String | FK → Phase, **Cascade** |
| `data` | Json | Payload gemäss `PhaseData` |
| | | `@@unique([elementId, phaseId])` |
**ElementTransitionValue**
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `elementId` | String | FK → FinancialElement, **Cascade** |
| `fromPhaseId` | String | FK → Phase, **Cascade** |
| `data` | Json | Payload gemäss `TransitionData` |
| | | `@@unique([elementId, fromPhaseId])` |
### 5.4.2 Warum JSON?
Die kategoriespezifischen Felder liegen als JSON, weil sich sonst pro Kategorie eine eigene
Tabelle (oder eine breite Sparse-Tabelle) ergäbe. Die Typisierung und Validierung findet in der
**Applikationsschicht** statt (`src/lib/elements.ts`): `PhaseData` / `TransitionData` als
TypeScript-Interfaces, `phaseDataSchema` / `transitionDataSchema` als Zod-Schemas an der
API-Grenze.
Die Interfaces sind bewusst **tolerant** getippt (alle Felder optional): die Berechnung liest
defensiv über `num(value, fallback)`, das UI zeigt kontextabhängig nur die relevanten Felder.
Referenz: `prisma/schema.prisma` Zeilen 46, `src/lib/elements.ts` Zeilen 4851.
### 5.4.3 JSON-Payload `PhaseData`
| Feld | Kategorien | Zod-Regel |
|---|---|---|
| `amount` | INCOME, EXPENSE | ≥ 0 |
| `teuerungsausgleich` | INCOME, EXPENSE | 20 bis 50 |
| `gapYears` | AHV | Integer ≥ 0 |
| `ahvContribution` | AHV | ≥ 0 Beitrag als Nichterwerbstätige(r) bis zum Referenzalter ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) |
| `avgIncomeBefore` | AHV nur wenn bei Planbeginn **bereits pensioniert** | ≥ 0, **real** |
| `gapYearsBefore` | AHV dito | Integer 050 |
| `currentValue` | PENSION_FUND, PILLAR_3A | ≥ 0 |
| `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 |
| `expectedReturn` | PK, 3a, OTHER_ASSET | 50 bis 100 |
| `annualContribution` | PK, 3a, OTHER_ASSET | ≥ 0 |
| `annualWithdrawal` | OTHER_ASSET | ≥ 0 |
| `additionalInvestment` | PK, 3a, OTHER_ASSET (ab Phase 2) | ≥ 0 |
| `purchasePrice` | REAL_ESTATE | ≥ 0 |
| `mortgage` | REAL_ESTATE | ≥ 0 |
| `amortization` | REAL_ESTATE | ≥ 0 |
| `interestRate` | REAL_ESTATE Hypothekarzins %/Jahr | 020 |
| `interestHandling` | REAL_ESTATE Doppelzählungs-Schalter | `INCLUDED` (Default) \| `ADD` |
| `valueGrowth` | REAL_ESTATE Wertsteigerung %/Jahr auf die Liegenschaft | 20 bis 20 |
| `annualRepayment` | OTHER_DEBT | ≥ 0 |
**Vererbbare Felder (Punkt A, Kap. 3.12.4):** `teuerungsausgleich`, `expectedReturn`,
`annualContribution`, `annualWithdrawal`, `amortization`, `valueGrowth`, `interestRate` und
`annualRepayment` sind ab Phase 2 in der Regel **nicht gesetzt** die Berechnung übernimmt dann
den Wert der Vorphase. Alle übrigen Felder bedeuten «nicht gesetzt = 0» wie bisher.
### 5.4.4 JSON-Payload `TransitionData`
| Feld | Kategorien | Zod-Regel |
|---|---|---|
| `reviewed` | AHV (Pensions-Übergang) Beitragskarriere geprüft | Boolean |
| `avgIncomeBefore` | AHV (Pensions-Übergang) | ≥ 0, **real** |
| `gapYearsBefore` | AHV (Pensions-Übergang) | Integer 050 |
| `withdrawalMode` | PK, 3a (normal) | `NONE` \| `AMOUNT` |
| `withdrawal` | PK, 3a (normal) | ≥ 0, **brutto** |
| `payoutMode` | PK (Pensionierung) | `CAPITAL` \| `PENSION` \| `COMBI` |
| `capitalAmount` | PK (COMBI) | ≥ 0 |
| `conversionRate` | PK | 020 |
| `capitalTaxRate` | PK + 3a, **sowohl Vorbezug als auch Pensionierung** | 0100 |
| `decision` | REAL_ESTATE, OTHER_ASSET | `HOLD` \| `SELL` \| `PARTIAL` |
| `salePrice` | REAL_ESTATE | ≥ 0 |
| `partialSaleAmount` | OTHER_ASSET (Teilverkauf) | ≥ 0 |
| `extraAmortization` | REAL_ESTATE (Sonderamortisation) | ≥ 0 |
| `saleTaxRate` | REAL_ESTATE | 0100 |
| `immediateRepayment` | OTHER_DEBT | ≥ 0 |
### 5.4.5 JSON-Payload `CashTransitionData`
Liegt in `Phase.cashTransition`. Validierung über `cashTransitionSchema`.
| Feld | Bedeutung | Zod-Regel |
|---|---|---|
| `mode` | Entscheid | `NONE` \| `INFLOW` \| `OUTFLOW` \| `BOTH` |
| `inflowLabel` | Bezeichnung des Zuflusses (z. B. „Erbschaft") | ≤ 120 Zeichen |
| `inflowAmount` | Betrag **nominal** | ≥ 0 |
| `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0100 |
| `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen |
| `outflowAmount` | Betrag **real** (heutige Kaufkraft) | ≥ 0 |
| `capitalUseAmortizationPct` | Anteil des Kapitalzuflusses in die Amortisation (Kap. 3.12.5) | 0100 |
| `capitalUseInvestPct` | Anteil des Kapitalzuflusses in eine Anlage | 0100 |
| `capitalUseTargetElementId` | Ziel der Anlage-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen |
Pro Übergang ist **genau ein** Zufluss und **eine** Kostenposition möglich siehe
[9.7](#97-nur-ein-zufluss-und-eine-kostenposition-pro-übergang).
Alle Schemas verwenden `.strip()` **unbekannte Felder werden verworfen**, nicht abgelehnt.
Beim Lesen aus der DB gilt zusätzlich: schlägt `safeParse` fehl, wird `{}` zurückgegeben
(`parsePhaseData` / `parseTransitionData`) korrupte Daten führen also nie zu einem Absturz,
sondern zu leeren Werten.
### 5.4.6 Migrationshistorie
| Migration | Inhalt |
|---|---|
| `20260708171600_init` | Initiales Schema |
| `20260709010000_add_transition_automation` | Übergangs-Automatik |
| `20260709120000_rework_realestate` | Immobilien-Überarbeitung |
| `20260711090000_multi_user` | User-Entität, Ownership |
| `20260713100000_element_model_rework` | Elemente auf Plan-Ebene, Phase-/Transition-Werte als JSON |
| `20260713150000_profile_to_plan_v3` | Grundprofil von Household auf Plan verschoben |
| `20260714120000_person_name` | `Person.name` |
| `20260715120000_plan_initial_cash` | `Plan.initialCash` |
| `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) |
| `20260716230000_phase_cash_transition` | `Phase.cashTransition` (JSONB) für einmalige Sonderein-/ausgaben |
| `20260718090000_plan_scenario_hierarchy` | **V6**: `Plan``Scenario` (IDs erhalten), neuer Behälter `Plan`, `planId``scenarioId`, Herkunfts-Verweise |
| `20260718140000_scenario_start_year` | `Scenario.startYear` (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt |
| `20260719210000_scenario_versioning` | Tabelle `ScenarioVersion` (Snapshot als JSONB, A.B eindeutig je Szenario) und `Scenario.currentMajor` |
| `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) |
| `20260720140000_plan_level_profile` | **V7**: Haushaltsform, Personen (Name/Alter) und Startjahr vom Szenario auf den Plan; neue Tabelle `PlanPerson`; `Person` behält nur das Pensionsalter. Datenübernahme aus dem Basisszenario. |
| `20260721090000_reports` | Tabelle `Report`: gewählte Parameter, eingefrorenes Modell und die PDF-Datei als `BYTEA` |
| `20260720160000_saved_analyses` | Tabelle `SavedAnalysis` (Eingaben + Ergebnis als JSONB, denormalisierte Kerndaten) |
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um dadurch
bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige
Wurzel-Element entsteht ein neuer Behälter (`'plan_' || id`, deterministisch ableitbar, daher
ohne Hilfstabelle); der bisherige Plan-Name wandert dorthin, das Szenario heisst „Basisszenario".
Bestehende Szenarien werden per rekursivem CTE demselben Behälter zugeordnet auch mehrfach
verschachtelte. Ein Sicherheitsnetz fängt verwaiste Szenarien ab und macht sie eigenständig.
Die Migration wurde **vor dem Deploy gegen echtes PostgreSQL verifiziert** (PGlite, in-process):
alle Vorgänger-Migrationen einspielen, realistische Daten inkl. verschachtelter Szenarien
anlegen, migrieren, Ergebnis und Cascade prüfen.
## 5.5 Frontend-Architektur
### 5.5.1 Datenfluss
```
page.tsx (Client)
└─ GET /api/auth/me → username
└─ AppShell
├─ GET /api/plans → Planliste (Sidebar, Kacheln)
├─ GET /api/plans/<id> → { plan: PlanInput, computed: PlanComputed }
│ └─ PlanView (Matrix, Dialoge) → onChanged() → silent reload
│ └─ Dashboard (Kennzahlen, 3 Grafiken, Vergleich, Export)
└─ Dialoge: PlanDialog, ScenarioDialog
```
Es gibt **keinen State-Management-Layer** (kein Redux/Zustand/React Query). Der Zustand lebt in
`AppShell` (Planliste, ausgewählter Plan, Detail) und lokal in den Dialogen. Nach jeder Mutation
wird der Plan neu geladen; die Berechnung kommt immer vom Server.
### 5.5.2 Komponenten
| Komponente | Zeilen | Rolle |
|---|---|---|
| `AppShell` | 433 | Layout, Sidebar, Planliste, Laden, Plan-/Szenario-Dialoge |
| `PlanView` | 1304 | Matrix, Spaltenaufbau, Kontextbildung, alle Zell-Dialoge |
| `ElementDetail` | 548 | Feldgruppen je Kategorie/Kontext, Speicherlogik, Übergangs-Defaults |
| `Dashboard` | 187 | Kennzahlkarten, 3 Grafiken, Planvergleich, CSV-Link |
| `FormField` | 275 | `NumberField`, `MoneyInput`/`MoneyField`, `TextField`, `SelectField` |
| `ProfileMenu` | 166 | Benutzer, Theme-Wahl, Passwortwechsel, Logout |
| `Timeline` | 108 | Zeitachse mit Pensions- und Ruin-Markern |
| `WealthChart` | 106 | Vermögensverlauf, mehrere Serien |
| `PlanProfileFields` | 101 | Wiederverwendete Grundprofil-Felder |
| `PhaseDetail` | 95 | Phase bearbeiten/löschen |
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
| `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
| `VersionHistoryDialog` | ~290 | Änderungshistorie: Liste, Hauptversion festlegen, Anzeigen, Wiederherstellen mit Warnung ([3.8](#38-versionierung-und-änderungshistorie)) |
| `VersionMatrix` | ~110 | Nur-Lese-Matrix eines festgehaltenen Standes |
| `PlanViews` | ~350 | Plan-Dashboard, Szenario-Liste, Analysen-Kacheln + gespeicherte Liste (Kap. 3.10) |
| `SavedAnalysisView` | ~180 | Nur-Lese-Ansicht einer gespeicherten Analyse |
| `SaveAnalysisButton` | ~90 | Speichern-Knopf mit Namensvorschlag |
| `VersionPicker` | ~140 | Wahl der Berechnungsgrundlage in den vier Analysewerkzeugen |
| `LiveSimDialog` | ~330 | Live-Simulation: Regler links, Grafikwahl rechts, Kennzahlenleiste mit Differenz zum Plan ([4.15](#415-live-simulation-was-wäre-wenn-regler)) |
| `AllocationChart` | ~80 | Gestapelte Vermögensaufteilung je Phase; aus dem Dashboard herausgelöst, damit die Live-Simulation sie mitbenutzen kann |
| `SensitivityDialog` | ~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle |
| `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 |
| `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) |
| `PlanWizard` | ~560 | Geführter Plan-Assistent in fünf Schritten, abschnittsbasierte Phasenplanung ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
| `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) |
| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) |
| `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) |
| `Sparkline` | ~45 | Mini-Verlaufskurve je Element-Zeile ([3.7.9](#379-befehls-palette-und-sparklines)) |
### 5.5.3 Wiederverwendungsmuster
`ElementPhaseFields` und `ElementTransitionFields` sind aus `ElementDetail` **exportiert** und
werden an drei Stellen wiederverwendet: im Zell-Dialog, im Erstell-Dialog („Werte (erste
Lebensphase)") und im geführten Übergangs-Review. Dadurch gibt es genau eine Definition der
Felder je Kategorie.
Der `CellContext` ist die einheitliche Kontext-Schnittstelle dieser Feldgruppen; er wird in
`PlanView` durch `buildPhaseContext` / `buildTransitionContext` aus der Berechnung befüllt.
### 5.5.4 React-Detail: der `key` auf Dialogen
Zell-Dialoge tragen einen `key` aus `elementId` + `phaseId`. Grund (Kommentar Zeilen 479480):
Beim Wechsel von Zelle zu Zelle wird ein Neuaufbau erzwungen, damit der lokale Formularzustand
nicht vom vorher geöffneten Element übrig bleibt.
## 5.6 Sicherheit
| Aspekt | Umsetzung |
|---|---|
| Passwortspeicherung | bcrypt, Cost 12 |
| Session | JWT HS256, 30 Tage, HttpOnly-Cookie, `sameSite=lax`, `secure` in Produktion |
| Secret | `SESSION_SECRET` aus der Umgebung; Fehlen wirft beim ersten Zugriff |
| Autorisierung | Middleware (grob) + Ownership-Query je Route (fein) |
| Information Disclosure | Fremde/nicht existierende Ressourcen → einheitlich 404; Login-Fehler unspezifisch |
| Eingabevalidierung | Zod an jeder API-Grenze; `.strip()` gegen Mass-Assignment |
| SQL-Injection | Prisma (parametrisiert) |
| XSS | React-Escaping; einziges `dangerouslySetInnerHTML` ist das statische Theme-Init-Script |
| CSRF | Kein Token. Schutz beruht allein auf `sameSite=lax` siehe 9.4 |
## 5.7 Konfiguration
| Variable | Zweck |
|---|---|
| `DATABASE_URL` | Postgres-Connection-String |
| `SESSION_SECRET` | JWT-Signaturschlüssel (z. B. `openssl rand -hex 32`) |
| `POSTGRES_PASSWORD` | Nur für docker-compose: Passwort des `db`-Containers |
| `NODE_ENV` | Steuert u. a. das `secure`-Flag des Cookies und den Prisma-Global-Cache |
Es gibt **kein** konfiguriertes Login-Passwort: Konten werden über die Registrierung angelegt.
---
# 6. API-Referenz
Alle Routen liefern JSON. Fehlerformat einheitlich: `{ "error": "<Meldung>" }` (bei
Zod-Fehlern in `POST /api/plans`: `{ "error": <flatten()-Objekt> }`).
Alle Routen ausser `login`/`register` erfordern ein gültiges Session-Cookie.
## 6.1 Authentifizierung
| Methode | Pfad | Body | Antwort |
|---|---|---|---|
| POST | `/api/auth/register` | `{ username, password }` | 201 `{ ok, username }` + Cookie · 400 Validierung · 409 Name vergeben |
| POST | `/api/auth/login` | `{ username, password }` | 200 `{ ok, username }` + Cookie · 400 · 401 |
| POST | `/api/auth/logout` | | 200 `{ ok }`, Cookie gelöscht |
| GET | `/api/auth/me` | | 200 `{ user: { id, username, createdAt } }` · 401 · 404 |
| POST | `/api/auth/change-password` | `{ currentPassword, newPassword }` | 200 `{ ok }` · 400 · 401 |
## 6.2 Pläne
### `GET /api/plans`
Liste der eigenen Pläne, sortiert nach `createdAt` aufsteigend.
```json
{ "plans": [ { "id", "name", "parentPlanId", "branchFromPhaseId", "createdAt",
"phases": [ { "id", "name", "sequenceNumber" } ] } ] }
```
### `POST /api/plans`
```json
{ "name": "Basisplan", "householdType": "COUPLE", "inflationRateDefault": 1.5,
"persons": [ { "role": "PERSON_A", "name": "Anna", "age": 40, "retirementAge": 65 },
{ "role": "PERSON_B", "name": null, "age": 38, "retirementAge": 64 } ] }
```
Validierung: `name` 1120; `inflationRateDefault` 20…50; `persons` 12 Einträge;
`age` 0120; `retirementAge` 30100; `name` je Person ≤ 60. Zusätzlich Konsistenzregel
SINGLE=1 / COUPLE=2 Personen. Legt Plan **und Basisszenario** an.
→ 201 `{ plan: { id }, scenario: { id } }`
`GET /api/plans` liefert die Pläne inkl. Szenario-Kopfdaten:
```json
{ "plans": [ { "id", "name", "createdAt",
"scenarios": [ { "id", "planId", "name", "isBase", "parentScenarioId" } ] } ] }
```
### `PATCH /api/plans/<planId>`
`{ name }` der Plan trägt nur noch den Namen. → 200 `{ plan: { id, name } }`
### `DELETE /api/plans/<planId>`
→ 200 `{ ok: true }`, Cascade über alle Szenarien (inkl. Versionen und Ist-Sätzen).
### `GET /api/plans/<planId>/actuals`
Alle erfassten Ist-Sätze, neueste zuerst ([3.9](#39-effektive-werte-plan-ist-vergleich)):
```json
{ "sets": [ { "id", "recordedOn": "2026-08-18", "year": 2026, "comment",
"cash", "values": { "<rootElementId>": { "value", "mortgage" } },
"author", "createdAt" } ] }
```
### `POST /api/plans/<planId>/actuals`
`{ recordedOn: "JJJJ-MM-TT", comment?, cash?, values }` `year` wird aus dem Datum abgeleitet.
→ 201 `{ set: { id, year } }`
### `DELETE /api/plans/<planId>/actuals/<setId>`
→ 200 `{ ok: true }`. Ein Ist-Satz ist eine Beobachtung es gibt weder Versionierung noch
Wiederherstellung.
### `GET /api/plans/<planId>/dashboard`
Gebündelte Kennzahlen fürs Plan-Dashboard und die Szenario-Liste ([3.10](#310-navigation-auf-plan-ebene-und-gespeicherte-analysen)):
Haushaltsdaten, Zähler (Szenarien/Ist-Sätze/Analysen), Basisszenario-Kennzahlen inkl.
Ist-Abweichung, und je Szenario Version/Elementzahl/Endvermögen/Ruinalter.
### `GET/POST /api/plans/<planId>/analyses`
Gespeicherte Analysen GET listet die Kopfdaten (ohne die grossen JSON-Felder), POST legt eine
Momentaufnahme an (`{ name, type, metric, source, scenarioName?, versionLabel?, inputs, result }`).
### `GET/DELETE /api/plans/<planId>/analyses/<analysisId>`
GET liefert die vollständige Analyse inkl. der eingefrorenen Zahlen (Eingaben + Ergebnis), DELETE
entfernt sie. Read-only es wird nichts neu gerechnet.
## 6.3 Szenarien
### `GET /api/scenarios/<scenarioId>`
Liefert Eingabe, Berechnung **und die Vergleichsbasis** in einem Zug:
```json
{ "plan": <PlanInput>, "computed": <PlanComputed>,
"base": <PlanInput|null>,
"meta": { "id", "planId", "planName", "name", "isBase", "parentScenarioId" } }
```
`base` ist das Eltern-Szenario (null beim Basisszenario) daraus rechnet der Client den Diff.
Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd.
### `POST /api/scenarios/<scenarioId>/retirement`
Verschiebt das Pensionsalter einer Person und damit die zugehörige Phasengrenze
([3.12](#312-pensionsalter-anpassen)):
```json
{ "role": "PERSON_A", "delta": -4, "confirmMerge": false }
```
Bewusst kein `PATCH` auf `retirementAge`: Die Änderung betrifft immer **zwei** Phasendauern
gleichzeitig und kann eine Phase entfallen lassen.
→ 200 `{ ok, removedPhaseId, retirementAge }` ·
→ 400 mit Erklärtext, wenn gesperrt oder ausserhalb des Spielraums ·
**409** `{ needsMergeConfirmation: true, removedPhaseId, removedPhaseName, mergedIntoPhaseId }`,
wenn dabei eine Lebensphase entfiele und `confirmMerge` nicht gesetzt ist. Der Client fragt
vorher selbst (er kennt den Plan), der 409 ist die serverseitige Absicherung.
### `PATCH /api/scenarios/<scenarioId>`
Akzeptiert eine **Union** von zwei Formen:
1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` ersetzt
die Personen in einer Transaktion.
2. Teilaktualisierung: `{ name?, initialCash? }` `initialCash` 0…1'000'000'000, gerundet.
→ 200 `{ scenario: { id, name } }`
### `DELETE /api/scenarios/<scenarioId>`
→ 200 `{ ok: true }` · 400 wenn es das **Basisszenario** ist.
### `POST /api/scenarios/<scenarioId>/copy`
`{ name }` vollständige Kopie; setzt `parentScenarioId` sowie die Herkunfts-Verweise.
→ 201 `{ scenarioId: "<neue Id>" }`
### `GET /api/scenarios/<scenarioId>/export`
`text/csv; charset=utf-8`, `Content-Disposition: attachment`. Liefert immer den **aktuellen**
Stand, auch wenn im Client eine ältere Version betrachtet wird ([3.8.4](#384-analyse-auf-einer-bestimmten-version)).
### `GET /api/scenarios/<scenarioId>/versions`
Änderungshistorie, neueste zuerst **ohne** die Snapshots (je zig Kilobyte).
```json
{ "currentMajor": 2,
"versions": [ { "id", "major", "minor", "comment", "isMajor",
"createdAt", "updatedAt", "author" } ] }
```
### `POST /api/scenarios/<scenarioId>/versions`
`{ comment }` (Pflicht, ≥ 3 Zeichen) legt den aktuellen Stand als **Hauptversion** fest und
setzt `Scenario.currentMajor`. → 201 `{ version: { major, minor } }` · 400 ohne Kommentar.
### `GET /api/scenarios/<scenarioId>/versions/<versionId>`
Ein einzelner Stand samt Berechnung und der Vorwarnung für das Wiederherstellen:
```json
{ "version": { "id", "major", "minor", "comment", "isMajor", "createdAt" },
"plan": <PlanInput>, "computed": <PlanComputed>,
"impact": { "lostElementIds": [], "lostPhaseIds": [], "affectedChildren": [] } }
```
`impact` benennt die Kind-Szenarien, die durch ein Wiederherstellen ihre Diff-Basis verlören.
### `POST /api/scenarios/<scenarioId>/versions/<versionId>`
Setzt das Szenario auf diesen Stand zurück **IDs bleiben erhalten**, und der wiederhergestellte
Stand wird selbst als neue Version festgehalten («Wiederhergestellt aus A.B»).
→ 200 `{ version: { major, minor } }`
## 6.4 Phasen
### `POST /api/scenarios/<scenarioId>/phases`
Body optional: `{ name?, durationYears? }`.
Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbelegte
`ElementPhaseValue` für alle aktiven Elemente an (alles in einer Transaktion).
→ 201 `{ phase: { id } }`
### `PUT /api/phases/<phaseId>`
`{ name?, durationYears? }` `durationYears` 180, wird gekappt.
→ 200 `{ phase: { id } }`
### `DELETE /api/phases/<phaseId>`
Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann gelöscht werden."
### `PUT /api/phases/<phaseId>/cash-transition`
Body = `CashTransitionData` (siehe 5.4.5). Speichert den Cash-Entscheid für den Übergang **nach**
dieser Phase (einmalige Sonderein-/ausgaben). Schreibt die Spalte `Phase.cashTransition`.
→ 200 `{ ok }` · 404 wenn die Phase nicht dem Benutzer gehört.
## 6.5 Elemente
### `POST /api/scenarios/<scenarioId>/elements`
`{ category, name, ownerRole? }` → 201 `{ element: { id } }`
- `PERSON_ONLY_CATEGORIES` ohne Person → 400
- fehlendes `ownerRole` sonst → `HOUSEHOLD`
- `orderIndex` = Max + 1
### `PATCH /api/elements/<elementId>`
`{ name }` (1120). → 200 `{ ok }`
### `DELETE /api/elements/<elementId>`
→ 200 `{ ok }`, Cascade auf alle Phasen-/Übergangswerte.
### `PUT /api/elements/<elementId>/phase/<phaseId>`
Body = `PhaseData`. **Upsert** auf `@@unique([elementId, phaseId])`. Prüft zusätzlich, dass die
Phase zum selben Plan gehört wie das Element. → 200 `{ ok }`
### `PUT /api/elements/<elementId>/transition/<fromPhaseId>`
Body = `TransitionData`. **Upsert** auf `@@unique([elementId, fromPhaseId])`. → 200 `{ ok }`
---
# 7. Betrieb und Deployment
## 7.1 Container
**Dockerfile** vierstufiger Multi-Stage-Build auf `node:20-alpine`:
1. `base` Arbeitsverzeichnis `/app`
2. `deps` `npm ci` (mit `prisma/` für den `postinstall`-Hook `prisma generate`)
3. `builder` `npx prisma generate` + `npm run build`
4. `runner` nur Laufzeit-Artefakte; `ENTRYPOINT ./docker-entrypoint.sh`, `CMD npm start`, Port 3000
**`docker-entrypoint.sh`** führt vor dem Start `npx prisma migrate deploy` aus Migrationen
laufen also automatisch bei jedem Container-Start.
## 7.2 docker-compose
Zwei Services:
- **`app`** hängt an zwei Netzwerken: `agent-net` (extern, Traefik) und `internal` (DB).
- **`db`** `postgres:16-alpine`, nur im `internal`-Netz, persistiert auf Volume `fpt_db_data`.
Die Datenbank ist von aussen **nicht** erreichbar.
**Traefik-Labels:**
```
traefik.enable=true
traefik.docker.network=agent-net ← kritisch, siehe unten
traefik.http.routers.fpt.rule=Host(`fpt.aicds.ch`)
traefik.http.routers.fpt.entrypoints=websecure
traefik.http.routers.fpt.tls.certresolver=myresolver
traefik.http.services.fpt.loadbalancer.server.port=3000
```
Zwei projektspezifische Fallstricke sind im Code dokumentiert bzw. durch die Konventionen gesetzt:
1. **`traefik.docker.network=agent-net` ist zwingend.** Der Container hängt an zwei Netzwerken;
ohne diese Angabe wählt Traefik zufällig eines landet es im internen DB-Netzwerk, ist das
Backend unerreichbar (Timeout). (Kommentar in `docker-compose.yml`.)
2. **Der Router-Name muss projektspezifisch sein** (hier `fpt`, nicht `app`), sonst kollidiert er
mit anderen Projekten auf derselben VM.
## 7.3 CI/CD
`.gitea/workflows/deploy.yaml`: **Push auf `main` = automatisches Live-Deployment**
(bewusst kein Review-Gate). Ablauf:
1. Checkout
2. `.env` aus den Gitea-Secrets `SESSION_SECRET` und `POSTGRES_PASSWORD` schreiben
3. Code nach `/opt/aicds/apps/FPT/` kopieren
4. `docker compose down``docker compose up -d --build``docker image prune -f`
Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gitea unter
`git.aicds.ch`.
---
# 8. Qualitätssicherung
## 8.1 Teststrategie
Getestet wird ausschliesslich der Berechnungskern bewusst, da dort die Fachlogik und das
Regressionsrisiko liegen. Ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`,
Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests.
| Datei | Tests | Schwerpunkt |
|---|---|---|
| `calculations.test.ts` | 53 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, AHV-Referenzalter (Beitrag vor 65, Rente ab 65), Einkommen endet mit der Pensionierung, Vererbung der Wiederkehr-Parameter, „V5 Golden Tests" |
| `sensitivity.test.ts` | 20 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber, Pensionsalter als Treiber |
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
| `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % |
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
| `actuals.test.ts` | 24 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung, Wirkung über die Phasengrenze hinaus |
| `dataview.test.ts` | 7 | beide Sichten in einem Zug, Rückfall ohne Ist-Werte, Abweichungsmessung |
| `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen |
| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
| `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus |
| `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei |
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen |
| `bridges.test.ts` | 13 | Vermögens- und Cash-Brücke gehen über acht Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus; Kapitalverwendung nach Quote (Punkt C) |
| `retirement.test.ts` | 16 | Spielraum und Sperren je Person, Verschiebung ohne Änderung der Gesamtdauer, Wegfall einer Phase, Zusammenführung der Übergangs-Entscheide |
| `server-boundary.test.ts` | 1 | statischer Wächter: kein Modul unter `src/lib` importiert aus `src/components` |
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP |
| **Total** | **267** | |
## 8.2 Testfälle
| Test | Prüft |
|---|---|
| **Amtliche Tabelle 318.117.1** | `ahvMonthlyFullPension` reproduziert 11 Stützstellen der amtlichen Rentenskala exakt (Mindestrente, Formel 1, Wendepunkt 45'360 → 1'915, Formel 2, Maximalrente) |
| **Kappung / Stetigkeit** | unter 15'120 → 1'260, über 90'720 → 2'520; kein Sprung am Wendepunkt |
| **AHV volle Karriere** | mdJE 100'000 → Maximalrente 32'760 |
| **AHV abgestuft / Mindestrente** | mdJE 60'000 → Formel 2; mdJE 10'000 → 1'260 × 13 |
| **AHV Vorgeschichte dominiert** | 39 Jahre à 40'000 + 5 Jahre à 200'000 → mdJE 58'182 |
| **AHV Ausfalljahre** | 4 Ausfalljahre → `32'760 × 40/44`; Ausfalljahre im Plan senken nur die Skala, nicht das mdJE |
| **AHV ohne Prüfung** | ohne erfassten Wert gilt der geplante Durchschnitt (nicht 0) |
| **AHV bereits pensioniert** | Karriere aus der Phasenzelle der ersten Phase |
| **AHV Plafonierung** | zwei Maximalrenten im Paar-Plan → gekappt auf `32'760 × 1.5` |
| **AHV Netto → Brutto** | mdJE = `70'000 × 1.12`, nicht 70'000 |
| **Immobilie: Hebel** | 1 Mio / 900k Hypothek / 1 % / 10 J. → Endwert 304'622; deutlich mehr als eine Wertsteigerung auf das Eigenkapital ergäbe |
| **Immobilie: ohne Wertsteigerung** | Verhalten unverändert (1 Mio 800k = 200'000) |
| **Immobilie: Zins-Schalter** | `INCLUDED` → kein Cash-Abzug; `ADD` → 10 × 1 % von 900'000 = 90'000 |
| **Immobilie: Zins sinkt** | Jahr 1: 10'000, Jahr 10: 9'100; schlägt auf die Quote durch |
| **Immobilie: Verkauf** | Grundstückgewinnsteuer auf `Verkaufspreis Kaufpreis`, nicht auf den Verkehrswert |
| **Teilverkauf (42)** | Betrag ins Cash (→ `capitalInflow`), Rest bleibt aktiv; am Endwert gekappt; Halten/Vollverkauf unverändert |
| **Sonderamortisation (15)** | Einmaltilgung senkt Restschuld, belastet Cash (→ `capitalInvest`), am Restsaldo gekappt |
| **MC: Determinismus** | Streuung 0 reproduziert exakt das deterministische Ergebnis (Bänder kollabieren) |
| **MC: Volatilität / Vol-Drag** | σ > 0 spreizt p10<median<p90; Median unter dem deterministischen Wert |
| **MC: Erfolg / Reproduzierbarkeit** | P(≥ Ziel) fällt mit steigendem Ziel; gleicher Seed → identisches Ergebnis |
| **MC: Boden / Ruin** | 0%-Boden hält PK/3a ≥ Startwert; sicherer Verzehr → Ruinwahrscheinlichkeit 100 % |
| **MC: Herkunfts-Kette** | `resolveRootElementId` folgt der Kette bis zum Ursprung; Verweis ins Leere → eigenes Element ist Wurzel; defekte Kette terminiert |
| **MC: Element-Gruppen** | Kopie und Original ergeben **eine** Gruppe (nicht zwei); Auflösung auch über ein nicht ausgewähltes Zwischen-Szenario; ein nur in einem Szenario neues Element bildet eine eigene Gruppe |
| **MC: Parameter-Übersetzung** | `paramsForScenario` bildet die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios ab |
| **MC: Szenario-Vergleich** | Zwei Szenarien, die sich nur in der **geplanten** Rendite unterscheiden: identischer Median/P10/Ruin (gleicher Seed, gleiche Struktur), aber **höhere Erfolgswahrscheinlichkeit** beim pessimistisch geplanten der einzige Unterschied ist der Zielbetrag |
| **Tornado: Reinheit** | `applyDriver` lässt den Ausgangsplan unverändert |
| **Tornado: Einheiten** | Inflation absolut gesetzt; Rendite/Lohnentwicklung in pp verschoben; Ausgaben relativ skaliert (Einkommen unberührt) |
| **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 |
| **Tornado: wirkungslose Treiber** | jeder Nullbalken trägt eine Begründung; verkaufte Immobilie → Wertsteigerung wirkungslos mit konkretem Hinweis, gehaltene Immobilie → Spannweite > 0 ohne Hinweis |
| **Brücken: Restgrösse** | `residual === 0` je Phase für Vermögens- **und** Cash-Brücke über sieben Plankonstellationen (Ansparen, Verrentung, Kapitalbezug, Immobilie gehalten/verkauft, Einmalposten, Sofort-Tilgung mit Sonderamortisation) |
| **Brücken: Kontrollpunkte** | `startWealth`/`endWealth`/`cashStart`/`cashEnd` der Brücken stimmen mit den offiziellen Phasen-Kennzahlen überein |
| **Brücken: Umbuchungen** | Sparraten und Amortisationen erscheinen nur in der Cash-Brücke; die Vermögensänderung erklärt sich exakt aus Quote + Erträgen + Wertsteigerung + PK-Beiträgen |
| **Brücken: Verrentung/Verkauf** | verrentetes PK-Kapital erscheint als Vermögensabgang am Übergang; Verkaufsdifferenz und Grundstückgewinnsteuer nur beim Verkauf, nicht beim Halten |
| **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 |
| Test 2 Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` |
| Test 3 Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ |
| Test 4 Liquiditätslücke | Sparrate 10'000: `cashEnd === -5436`, `cashNegative === true` |
| Ausgaben real → nominal | Jahr 1 nominal = real; Jahr 5 = `100'000 × 1.02^4` |
| Einkommen flach | Lohnerhöhung 0 % → `incomeStart === incomeEnd === 80'000` |
| Bezugsrate | 3 × 10'000 Entnahme → `cashEnd === 30'000`, Asset-Endwert 0, `plannedWithdrawRate === 10'000` |
| Kapitalzufluss/-investition | Verkauf in P1, Reinvestition in P2: `capitalInflow === 10'000`, `capitalInvest === 10'000`, `cashStart === 0`, `startWealthNominal === 10'000` (kein Doppelzählen) |
| Cash-Anfangswert | `initialCash` 50'000 fliesst in Phase 1 ein |
| **Tilgung stoppt** | Schuld 25'000, Tilgung 10'000/J., 5 Jahre: Gesamtabfluss 25'000 (nicht 50'000), `cashEnd === 75'000`, Restschuld 0 |
| **Amortisation stoppt** | Hypothek 15'000, Amortisation 10'000/J., 4 Jahre: `cashEnd === 85'000` (nicht 60'000), Immobilie schuldenfrei |
| **Vorbezugssteuer** | PK-Vorbezug 100'000 brutto @ 8 %: `capitalInflow === 92'000`, Restkapital 200'000 (brutto entnommen) |
| **Einmaliger Zufluss** | 100'000 nominal @ 10 % Steuer → `oneOffInflow === 90'000`, Cash-Start Folgephase +90'000; Phase 1 hat keinen Zufluss |
| **Einmalige Kosten** | 20'000 real, 2 % Inflation, Grenze nach 10 J. → `20'000 × 1.02^10`, entsprechend vom Cash abgezogen |
| **Zufluss + Kosten / NONE** | `BOTH`: +50'000 20'000 → Cash-Start 30'000. `NONE` mit erfassten Beträgen → keine Wirkung |
| **Liquiditätslücke durch Kosten** | Kosten 25'000 bei Cash 10'000 → `cashStart === 15'000`, `cashNegative`, `incomplete` |
| **Letzte Phase** | Cash-Entscheid der letzten Phase bleibt wirkungslos (kein Übergang mehr) |
| Fortschreibung | Einkommens-Basiswert P1 → Startwert P2 = `100'000 × 1.02^5`; `cashStart(P2) === cashEnd(P1)` |
## 8.3 Ausführung
```bash
npm test # vitest run
npm run lint # eslint
npm run build # next build
```
**Verifikationseinschränkung:** Lokal steht keine Datenbank/Docker zur Verfügung. Die
Verifikation erfolgt über `npm run build` und die isolierten Berechnungstests; End-to-End-Prüfung
erst gegen das Deployment (`fpt.aicds.ch`).
---
# 9. Bekannte Einschränkungen und Modellentscheide
Dieser Abschnitt hält fest, was im Code steht und beim Weiterentwickeln bekannt sein muss.
## 9.1 Cash wird nicht automatisch ausgeglichen
Wird das Cash negativ, meldet das Tool eine Liquiditätslücke (rotes Icon, `incomplete = true`),
greift aber nicht ein es wird kein Vermögen automatisch verkauft und kein Kredit aufgenommen.
Negatives Cash geht mit negativem Vorzeichen ins Gesamtvermögen ein.
## 9.2 Verwaiste `PERSON_B`-Elemente
Die Haushaltsform ist eine **Szenario-Eigenschaft** und lässt sich im Dialog „Plan-Einstellungen"
auch bei einem bestehenden Szenario nachträglich ändern (nicht pro Phase innerhalb eines Szenarios
gilt sie durchgehend). Wechselt ein Szenario dabei von `COUPLE` auf `SINGLE`, schneidet
`PlanProfileFields` die Personen auf eine zusammen und `PATCH /api/scenarios/<id>` löscht Person B
aus der Datenbank. Elemente mit `ownerRole = "PERSON_B"` bleiben bestehen. In der Berechnung
liefert `personByRole` dann `null`:
- `AHV`: wird per `if (!owner || …) continue;` übersprungen keine Rente.
- `PENSION_FUND` / `PILLAR_3A`: `owner` ist `null`, deshalb fällt `ownerWorking` auf `anyWorking`
zurück, d. h. das Element folgt dem Erwerbsstatus des Haushalts statt der (nicht mehr
existierenden) Person.
Es gibt keine Bereinigung und keine Warnung im UI. Da die Haushaltsform normalerweise nicht
nachträglich geändert wird, ist der Fall selten er ist aber erreichbar.
## 9.3 Immobilien: was noch fehlt
Seit Version 0.5 kennt das Modul Hypothekarzins und Wertsteigerung. Nicht modelliert sind:
- **Nebenkosten und Unterhalt** (Faustregel ~1 % des Werts). Lassen sich heute als normales
Ausgaben-Element erfassen.
- **Eigenmietwert** ohne echte Steuerlogik (Roadmap Nr. 23) nur halb wirksam.
- **Mieteinnahmen** (Renditeliegenschaften) anderer Anwendungsfall.
- **Wertvermehrende Investitionen** und die **Haltedauer-Abstufung** der
Grundstückgewinnsteuer (kantonal, teils stark degressiv).
- **Zinsänderungsrisiko**: Der Zinssatz gilt für die ganze Phase. Ein Zinsschock lässt sich
nur abbilden, indem man an dieser Stelle eine Phasengrenze zieht und den Satz neu setzt.
## 9.4 Kein CSRF-Token
Zustandsändernde Requests sind allein durch `sameSite=lax` geschützt. Das deckt klassische
Cross-Site-Formular-POSTs ab, ist aber schwächer als ein expliziter Token.
## 9.5 Logout invalidiert das Token nicht serverseitig
Das JWT ist zustandslos und bis zu 30 Tage gültig. `logout` löscht nur das Cookie.
## 9.6 Spar- und Bezugsraten werden nicht indexiert
`annualContribution`, `annualWithdrawal`, `amortization` und `annualRepayment` sind flache
Nominalbeträge, die über die Phasenjahre **konstant** bleiben. Eine Sparrate von 10'000 bleibt
20 Jahre lang 10'000 nominal und verliert dabei real an Gewicht. Wer eine mitwachsende Rate
abbilden will, muss die Phase teilen und den Betrag in der Folgephase erhöhen.
## 9.7 Nur ein Zufluss und eine Kostenposition pro Übergang
`CashTransitionData` hält genau ein Zufluss- und ein Kostenpaar (Bezeichnung + Betrag).
„Erbschaft + Autoverkauf + Poolbau + Küche" am selben Übergang lässt sich nur durch
Zusammenfassen abbilden („Diverses, 45'000") die Aufschlüsselung geht dabei verloren.
Bewusster Entscheid zugunsten eines einfachen UI; erweiterbar auf Listen.
## 9.8 Einmalige Ereignisse nur an Phasengrenzen
Ein Ereignis kann nur an einem Phasenübergang liegen. Ein Poolbau in Jahr 3 einer 10-jährigen
Phase ist nur abbildbar, wenn dort eine Phasengrenze gezogen wird. Nach der letzten Phase gibt
es keinen Übergang ein dort erfasster Betrag bleibt wirkungslos (durch Test abgedeckt).
## 9.9 Gemeinsames Einkommen zählt bei Paaren nicht für die AHV
Das mdJE ist eine **personenbezogene** Grösse. Einkommen wird deshalb nur einer Person
zugerechnet, wenn das `INCOME`-Element ihr zugeordnet ist (`PERSON_A`/`PERSON_B`). Bei einem
**Einzelplan** zählt `HOUSEHOLD` zur Person A es gibt ja nur eine. Bei einem **Paar-Plan**
bleibt `HOUSEHOLD`-Einkommen für die AHV unberücksichtigt.
Wer in einem Paar-Plan den Lohn als „Gemeinsam" erfasst, sieht deshalb im Prüf-Dialog ein
geplantes Durchschnittseinkommen von 0 und bekäme die Mindestrente. Der Dialog zeigt den Wert
prominent an, sodass der Fehler auffällt aber es gibt keine aktive Warnung. Erwerbseinkommen
sollte in Paar-Plänen immer personenscharf erfasst werden.
Ebenfalls nicht modelliert: das **Einkommenssplitting** verheirateter Paare (während der Ehe
werden die Einkommen hälftig geteilt) sowie Erziehungs- und Betreuungsgutschriften. Beides würde
das mdJE real beeinflussen und wäre der nächste Ausbauschritt.
## 9.10 Verhaltensänderung: AHV-Rente bestehender Pläne
Bis Version 0.3 erhielt jede AHV-Position pauschal die **Maximalrente** (32'760), gekürzt nur um
Ausfalljahre unabhängig vom Einkommen. Seit 0.4 folgt sie der Rentenformel. Bestehende Pläne
zeigen dadurch eine **andere, in der Regel tiefere** AHV-Rente, sobald das geplante
Durchschnittseinkommen unter 90'720 liegt. Das ist keine Regression, sondern die Korrektur einer
zu optimistischen Pauschale.
Zwei Fälle brauchen Aufmerksamkeit:
- **Bereits pensionierte Personen** (bei Planbeginn): Ohne erfasstes Durchschnittseinkommen
ergibt das mdJE 0 → **Mindestrente**. Vorher war es die Maximalrente. Die Felder stehen in der
AHV-Phasenzelle der ersten Phase; solange sie leer sind, ist die Rente bewusst konservativ.
- **Paar-Pläne mit `HOUSEHOLD`-Einkommen**: siehe 9.9.
## 9.11 AHV-Rente wird nach der Pensionierung nicht indexiert
Die Rente wird zum Pensionierungszeitpunkt in heutigem Geld berechnet und danach **nominal
eingefroren**. Die echte AHV wird alle zwei Jahre an den Mischindex angepasst. Über 25
Rentenjahre verliert die modellierte Rente damit real spürbar an Wert das Modell ist an dieser
Stelle deutlich konservativ. Bewusster Alt-Entscheid, unabhängig von der Rentenformel; der
grösste verbliebene Hebel im AHV-Modell.
## 9.12 `PILLAR_3A_MAX_ANNUAL` wird nur im UI erzwungen
Das Feld ist per `max`-Prop hart geklammert. Das Zod-Schema kennt für `annualContribution` nur
`≥ 0` ein direkter API-Aufruf kann die Obergrenze überschreiten.
## 9.13 Verhaltensänderung: Nettolohn-Klarstellung und AHV
Bis Version 0.4 war nirgends definiert, ob `INCOME` netto oder brutto ist der Hilfetext sagte
nur „die Zahl auf dem Lohnausweis", und dort stehen beide. Für den Cash-Fluss war das folgenlos,
weil sich beide Konventionen aufheben (`brutto Ausgaben inkl. Abzüge``netto Ausgaben ohne
Abzüge`). Mit der einkommensabhängigen AHV (0.4) hing daran aber plötzlich eine Rente.
Seit 0.5 gilt: **`amount` ist der Nettolohn**, und die AHV rechnet mit `× 1.12` auf brutto hoch.
Konsequenzen:
- **Wer bisher netto erfasst hat** (die dokumentierte Absicht): Die AHV-Rente **steigt** sie
war zuvor um bis zu ~1'900/Jahr zu tief, am stärksten bei mittleren Einkommen (bei 80'000
brutto: 28'974 statt 30'902). Über 90'720 brutto verschwindet der Effekt, weil beide Werte in
die Maximalrente laufen.
- **Wer brutto erfasst hat**: Cash-Fluss und AHV sind nun beide zu hoch. Der Einkommensbetrag
gehört auf netto korrigiert und die Lohnabzüge aus dem Ausgabenbetrag entfernt.
## 9.14 Keine Steuerschätzung
Bewusst **nicht** umgesetzt: eine automatische Schätzung von Einkommens- und Vermögenssteuer.
Die Bemessungsgrundlage ist das *steuerbare* Einkommen, nicht der Nettolohn also brutto minus
PK, 3a, Berufsauslagen, Versicherungs- und Kinderabzüge. Darauf kommen drei Ebenen (Bund,
Kanton, Gemeinde); allein der Gemeindesteuerfuss variiert innerhalb eines Kantons um rund den
Faktor zwei. Dazu Zivilstand, Kinder, Konfession und für die Vermögenssteuer 26 kantonale
Tarife mit eigenen Freibeträgen. Das Tool kennt weder Wohnort noch Kinder.
Eine Schätzung daraus läge im Einzelfall schnell 3050 % daneben bei vielen Haushalten dem
grössten Ausgabenposten. Eine selbst berechnete Zahl wirkt zudem autoritativ und wird nicht
hinterfragt. Der Benutzer kennt seine Steuerrechnung dagegen exakt aus der letzten Veranlagung.
**Heutiger Weg:** ein normales Ausgaben-Element „Steuern" dafür braucht es kein neues Feld.
Echte Steuerlogik ist Roadmap Nr. 23, mit Kanton und Gemeinde als Eingabe.
## 9.15 Monte-Carlo misst Risiko um die Annahmen, nicht deren Richtigkeit
Die Simulation streut um die eingegebenen Mittelwerte (historische Rendite/Inflation). Sie
beurteilt **nicht**, ob diese Mittelwerte realistisch sind. Ein zu optimistischer Mittelwert
liefert eine beruhigende, aber unrealistische Erfolgsquote die präzise Prozentzahl täuscht
dann Sicherheit vor. Der Dialog weist das explizit aus; die Mittelwerte sind Pflichtfelder ohne
Default, damit sie bewusst gesetzt werden.
Weitere bewusste Vereinfachungen der Stufe A: Inflation und Renditen werden **unabhängig**
gezogen (real sind sie negativ korreliert); die Normalverteilungs-Alternative wird gar nicht
angeboten (fette Ränder fest eingebaut); die Simulationsparameter werden **nicht persistiert**
(ephemer im Dialog). Ein historischer Backtest (Stufe B) und korrelierte/vollständigere Modelle
(Stufe C) sind offen.
## 9.16 Diff ist live gegen die Vorlage
Die Abweichungs-Markierung vergleicht **immer den aktuellen Stand** des Eltern-Szenarios. Ändert
man die Vorlage nachträglich, verschiebt sich die Markierung in allen Kindern rückwirkend: Setzt
man in der Vorlage einen Wert auf das, was ein Szenario ohnehin hatte, verschwindet dort die
gelbe Markierung, ohne dass das Szenario angefasst wurde.
Das ist logisch korrekt („weicht ab von der Vorlage"), kann aber überraschen. Die Alternative
ein eingefrorener Snapshot beim Kopieren wäre schnell veraltet und würde Abweichungen anzeigen,
die keine mehr sind. Bewusster Entscheid zugunsten des Live-Vergleichs.
Verwandt: Wird ein Element in der Vorlage gelöscht, gilt das Gegenstück im Kind ab dann als
**neu** (der Herkunfts-Verweis zeigt ins Leere). Auch das folgt aus dem Live-Vergleich.
## 9.17 Kleinere Beobachtungen
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht.
- `Plan.branchFromPhaseId` hat keine Fremdschlüssel-Constraint; die Phase kann gelöscht werden,
ohne dass das Feld bereinigt wird.
- Die Element-Umbenennung ist als API vorhanden, im UI aber nicht erreichbar.
- Die `README.md` ist noch der unveränderte `create-next-app`-Text.
(Der frühere `Selection`-Rest in `PlanView` und der ProfileMenu-Lint-Fehler sind seit 0.13
behoben `npm run lint` läuft fehlerfrei.)
## 9.18 Tornado: was der Chart nicht leistet
**Die Balkenlänge hängt von den eingegebenen Bandbreiten ab.** Wer Renditen mit ±2 Prozentpunkten
und Ausgaben mit ±20 % variiert, misst zu einem Teil die eigene Wahl dieser Bandbreiten. Deshalb
sind sie Pflichteingabe ohne Default und im Ergebnis sichtbar ([4.13.3](#4133-bandbreiten-sind-pflicht--ohne-default)).
Aussagekräftig ist die **Reihenfolge**, nicht der absolute Betrag.
**One-at-a-time sieht keine Wechselwirkungen.** Schlechte Renditen *und* hohe Ausgaben treffen
härter als die Summe der Einzelbalken weil in der Folge Kapital verzehrt wird, das später zur
Verzinsung fehlt. Für Kombinationen ist die Monte-Carlo-Simulation zuständig.
**Das Pensionsalter ist seit 0.26 enthalten** zuvor bewusst nicht, und die Begründung von
damals erklärt, warum die heutige Umsetzung so aussieht, wie sie aussieht.
Ein isoliert verändertes `retirementAge` wäre nicht ungenau gewesen, sondern **irreführend**.
Beispiel: Person 45, Pension mit 65, Phase 1 = 20 Jahre (45→65), Phase 2 = 20 Jahre.
- **Auf 62 gesetzt:** Phase 1 beginnt mit 45, also `45 < 62` → die Phase bliebe vollständig
Erwerbsphase. Die Person arbeitete im Modell weiterhin bis 65. Wirkung: **praktisch null.**
- **Auf 68 gesetzt:** Phase 2 beginnt mit 65, also `65 < 68` → Phase 2 würde zur Erwerbsphase.
Zugleich wäre `ownerRetiresNext` an der Grenze nach Phase 1 falsch, womit der
**Pensions-Übergang komplett entfiele**: keine PK-Verrentung, kein 3a-Bezug. Der Balken wäre
riesig er misst aber den Ausfall der Vorsorgelogik, nicht „drei Jahre länger arbeiten".
Der Treiber verschiebt deshalb `retirementAge` **und** die Phasengrenze gemeinsam
([4.16](#416-pensionsalter-verschieben)). Zwei Eigenheiten bleiben und sind im Dialog benannt:
Der Spielraum endet bei den angrenzenden Phasendauern (eine **Zusammenlegung** findet im Tornado
bewusst nicht statt), und eine zu weite Bandbreite wird auf das Mögliche gekürzt statt verworfen.
Der verwandte Treiber **Lebensdauer** (Dauer der letzten Phase) bleibt daneben bestehen er
beantwortet die andere Frage: nicht „wann höre ich auf", sondern „wie lange muss es reichen".
## 9.19 Simulationsparameter werden nicht gespeichert
Weder die Monte-Carlo-Annahmen noch die Tornado-Bandbreiten werden persistiert; beide leben nur
im geöffneten Dialog. Das ist ein bewusster Entscheid (kein Datenmodell für Annahmen, keine
Migration), hat aber den Preis, dass eine Analyse bei jedem Öffnen neu parametrisiert werden muss.
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.
## 9.22 Verkaufspreis und Verkehrswert bleiben unabhängig
Seit Version 0.12 wird der Verkaufspreis mit dem modellierten Verkehrswert vorbelegt und die
Abweichung ausgewiesen ([3.5.8](#358-verkaufspreis-und-modellierter-verkehrswert)). Die beiden
Grössen bleiben aber **entkoppelt** das Tool erzwingt keine Konsistenz.
Das ist bewusst so: Ein Verkauf unter dem Verkehrswert ist ein realer Fall (Notverkauf, Verkauf
an Nachkommen, Liebhaberobjekt ohne Markt). Eine Zwangskopplung würde diese Fälle unmöglich
machen. Der Preis dafür ist, dass eine unplausible Kombination weiterhin eingebbar bleibt neu
aber nicht mehr unbemerkt.
Eine Folge bleibt bestehen und ist nicht offensichtlich: **Wird die Immobilie vor Planende
verkauft, hat die angenommene Wertsteigerung keinen Einfluss mehr auf das Endvermögen.** Der
Erlös folgt allein dem erfassten Verkaufspreis. Im Tornado führt das zu einem Nullbalken, der
seit 0.12 erklärt wird ([4.13.5](#4135-wirkungslose-treiber-werden-erklärt)); in der
Vermögensbrücke erscheint stattdessen die Differenz `Verkaufspreis Verkehrswert` als eigener
Posten.
## 9.23 Assistent: Teilzustand bei Abbruch
Der Plan-Assistent und der Beispielplan senden am Ende eine **Sequenz** bestehender API-Aufrufe
(Plan → Phase 1 → Elemente → Folgephasen). Bricht die Sequenz mittendrin ab (Netzfehler),
existiert ein **Teil-Plan**. Der ist normal weiterbearbeitbar und der Fehlerhinweis sagt das
auch aber es gibt kein automatisches Rollback. Das wäre nur mit Backend-Unterstützung
(Transaktion über mehrere Requests oder Batch-Endpunkt) sauber lösbar und ist bewusst nicht
gebaut: Der seltene Fehlerfall rechtfertigt keinen neuen Endpunkt.
## 9.24 Tour ohne Spotlight-Engine
Die Tour hebt ihr Ziel per Rahmen-Puls und `scrollIntoView` hervor bewusst ohne
Spotlight-Overlay und Positionierungs-Engine (die Karte sitzt fix unten). Bei stark
verschachtelten Scroll-Situationen kann das Ziel teilweise verdeckt sein. Der Einfachheit
halber in Kauf genommen; eine echte Coach-Mark-Bibliothek wäre der nächste Schritt, wenn die
Tour sich bewährt.
## 9.25 Die Quote ist kein fester Betrag
Die Spar-/Verzehrquote **verändert sich über die Phasenjahre**: Das Einkommen wächst mit der
Lohnerhöhung, die real erfassten Ausgaben mit der Inflation. Bei 0 % Lohnerhöhung und 1.5 %
Inflation sinkt eine Quote von 20'000 über fünf Jahre auf rund 15'100 ohne dass der Nutzer
etwas geändert hätte.
`annualContribution` und die übrigen Raten sind dagegen **flache Jahresbeträge**
([9.6](#96-spar--und-bezugsraten-werden-nicht-indexiert)). Wer die Quote des **ersten** Jahres
als Rate verteilt, erzeugt sich damit in den späteren Jahren eine Liquiditätslücke.
Der Verteil-Dialog begegnet dem auf drei Arten, statt es zu verstecken:
- Er zeigt Quote **erstes Jahr**, **letztes Jahr** und **absolut über die Phase**.
- Er warnt ausdrücklich, wenn die Quote über die Phase sinkt.
- Die Live-Vorschau rechnet den ganzen Plan neu und meldet eine entstehende Liquiditätslücke
sofort, statt sie erst nach dem Speichern sichtbar zu machen.
Bewusst **nicht** umgesetzt ist eine automatische Deckelung: Es gibt legitime Gründe, mehr zu
sparen als die laufende Quote hergibt (etwa wenn ein Cash-Polster aus der Vorphase abgebaut
werden soll). Das Werkzeug informiert, es bevormundet nicht.
## 9.26 Fall 2 ist der Nullpunkt nicht 50 %
Naiv erwartet man, dass eine Planung ihren eigenen Endbetrag in **50 %** der Fälle erreicht, sobald
man um genau die geplanten Werte würfelt. Das ist falsch, und der Fehler ist gross genug, um ein
Urteil zu verderben.
Der Grund ist der **Volatilitäts-Drag** ([4.12.5](#4125-ergebnis)): Der deterministische Pfad
kapitalisiert das arithmetische Mittel, der Median einer Simulation folgt aber näher dem
geometrischen. Gemessen an einem Testplan (800k Startkapital, 30 Jahre, geplante Rendite 5.2 %,
deterministisches Endvermögen 3'457'554):
| Streuung σ | Median-Endvermögen | P(Plan-Endbetrag erreicht) |
|---|---|---|
| 0 % | 3'457'554 (= deterministisch) | |
| 3 % | 3'432'399 | ~48 % |
| 6 % | 3'322'529 | ~45 % |
| 15 % | 2'612'222 | **37 %** |
| 25 % | 1'451'659 | **27 %** |
Eine Planung mit hoher Aktienquote landet also bei **2737 %**, obwohl an ihr nichts falsch ist.
Ein fixer 50-%-Schnitt würde sie als «zu optimistisch» brandmarken.
Deshalb ist der Nullpunkt für das Urteil **Fall 2** dieselbe Schwelle, dieselbe Streuung, nur der
Mittelpunkt aus den eigenen Planannahmen statt aus der Historie
([4.12.7](#4127-zwei-welten-vier-fälle)). Der Vol-Drag steckt dann in **beiden** Zahlen und kürzt
sich weg; übrig bleibt genau die Frage, die interessiert: *Ist meine Renditeannahme optimistischer
als die Vergangenheit?* Das Toleranzband von ± 5 Prozentpunkten fängt Simulationsrauschen ab.
Fall 2 selbst wird nur klein als Referenz gezeigt: als Zahl ist er wenig aussagekräftig (er misst
im Wesentlichen die eigene Streuung), als **Vergleichsanker** dagegen unverzichtbar. Der Hinweis,
dass die Simulation Risiko **um deine Annahmen** misst und nicht deren Richtigkeit
([9.15](#915-monte-carlo-misst-risiko-um-die-annahmen-nicht-deren-richtigkeit)), bleibt bestehen.
## 9.27 Warum die Regler Standardbereiche haben und der Tornado nicht
Zwei Kapitel dieser Spezifikation scheinen sich zu widersprechen:
[9.18](#918-tornado-was-der-chart-nicht-leistet) begründet, warum die
Sensitivitätsanalyse **bewusst keine** Default-Bandbreiten anbietet, während die Live-Simulation
([4.15](#415-live-simulation-was-wäre-wenn-regler)) für jeden Regler einen vorbelegten Bereich
mitbringt. Das ist kein Versehen.
**Beim Tornado bestimmt die Bandbreite das Ergebnis.** Die Balkenlänge ist die Spannweite
zwischen dem tiefen und dem hohen Wert wer «Rendite ±3 pp» gegen «Ausgaben ±5 %» stellt,
erzeugt eine Rangfolge, die er selbst vorgegeben hat. Ein Default wäre dort eine **frei erfundene
Aussage**: Das Werkzeug würde behaupten, ein Treiber sei wichtiger als ein anderer, obwohl der
Unterschied nur aus den voreingestellten Bereichen stammt. Deshalb ist die Bandbreite dort
Pflichteingabe ohne Vorschlag.
**Ein Regler vergleicht nichts.** Er zeigt genau einen Zustand: «bei dieser Rendite kommt dieses
Endvermögen heraus». Der Bereich bestimmt nur, wie weit sich der Schieber bewegen lässt er
verändert das angezeigte Ergebnis an keiner Stelle. Ein Standardbereich erfindet hier also keine
Aussage; er macht den Regler überhaupt erst bedienbar, denn ohne Ober- und Untergrenze gibt es
keinen Schieber.
Die Bereiche sind trotzdem **an beiden Enden editierbar** (Häkchen «Bereiche anpassen»), und
neben jedem Regler steht sein **Neutralpunkt** der Wert, bei dem der Plan unverändert bleibt.
Bei allen Verschiebungs-Treibern ist das 0, bei der Inflation der Planwert selbst, weil sie als
einzige absolut und nicht als Differenz eingegeben wird.
## 9.28 Was die Versionierung nicht leistet
**Das Zeitfenster ist eine Konvention, keine Wahrheit.** Zehn Minuten sind gesetzt, weil FPT
keinen Speichern-Knopf hat und der Nutzer den Schnitt sonst nie selbst zieht
([3.8.1](#381-eine-version-je-sitzung--nicht-je-änderung)). Wer nach acht Minuten Pause
weiterarbeitet, landet in derselben Version; wer nach zwölf Minuten eine Kleinigkeit ändert,
bekommt eine neue. Beides ist gelegentlich nicht das, was man gemeint hätte. Ein
Speichern-Knopf wäre die exaktere Lösung, würde aber die Bedienung des ganzen Werkzeugs
umkrempeln.
**Eine Nebenversion hält den Stand am ENDE der Sitzung fest**, nicht jeden Zwischenschritt
darin. Wer innerhalb einer Sitzung etwas ändert und wieder zurücknimmt, findet den
Zwischenstand nirgends. Die Historie ist eine Folge von Arbeitsständen, kein Undo.
**Es gibt keinen Versionsvergleich.** Zwei Stände lassen sich nur nacheinander ansehen, nicht
nebeneinander stellen. Der bestehende Diff ([3.2.6](#326-abweichungs-markierung-diff))
vergleicht Szenarien gegen ihr Eltern-Szenario, nicht Versionen gegeneinander technisch
wäre beides verwandt, aber es ist bewusst nicht Teil dieser Stufe.
**Die Historie wird nie beschnitten.** Jede Version hält den vollständigen Zustand als JSON
(Grössenordnung 40 KB bei sechs Phasen und zwölf Elementen). Bei intensiver Nutzung wächst
das linear; eine Aufräumregel (etwa: Nebenversionen älter als ein Jahr verdichten, Haupt-
versionen behalten) gibt es noch nicht. Bei den heutigen Datenmengen ist das unkritisch.
**«Wer» unterscheidet heute nichts.** Ein Plan gehört genau einem Benutzer, es gibt keine
Freigabe und keine Rollen die Spalte zeigt also immer denselben Namen. Sie ist Vorbereitung
auf den Finanzberater, und die Logik trennt Benutzer bereits sauber (Änderungen verschiedener
Benutzer werden nie in einer Version zusammengefasst).
**Der Plan selbst ist nicht versioniert**, nur seine Szenarien. Wird ein Szenario gelöscht,
verschwindet seine Historie mit ihm (Cascade). Das ist gewollt: Eine Historie ohne das Objekt,
das sie beschreibt, wäre nicht wiederherstellbar.
## 9.29 Acht Zahlen je Zelle und wie wir sie vermeiden
Mit den effektiven Werten ([3.9](#39-effektive-werte-plan-ist-vergleich)) bekommt die Matrix
eine zweite Achse. Naiv kombiniert ergibt das je Zelle: nominal **und** real, Plan **und** Ist,
Phasenbeginn **und** Phasenende **acht Zahlen**. Das ist keine Tabelle mehr, das ist ein
Zahlenfeld.
Zwei Entscheide halten es lesbar, und sie fallen an den zwei Orten **verschieden** aus.
**In der Matrix: Wert und Abweichung statt zweier Rohwerte.** Es gibt nur «Plan» oder
«Effektiv», kein «beide». Im Ist-Modus steht der Ist-Wert und daneben klein die Differenz zum
Plan, grün oder rot. Das beantwortet auch die bessere Frage: nicht «wie lauteten die zwei
Zahlen», sondern «wie weit bin ich weg». Nominal/real bleibt dort bei drei Möglichkeiten es
sind Zahlen in einer Zelle, keine Linien in einem Bild.
**Nur die Abweichung trägt Farbe.** Würde man die Beträge selbst einfärben, entstünde ein
Ampelteppich, in dem die eigentliche Aussage untergeht.
**In den Grafiken: nominal/real wird zur Einfachauswahl.** Der Vermögensverlauf zeichnete
bisher je Serie **zwei** Linien (nominal durchgezogen, real gestrichelt). Mit Plan/Ist wären es
vier, bei zwei Szenarien acht. Das Stilbudget geht deshalb an die **wichtigere** Unterscheidung:
Plan gestrichelt, Ist durchgezogen genau die «Plan-Linie vs. Ist-Linie», die die Roadmap
verlangt. Wer real sehen will, schaltet um, statt eine zweite Linie dazuzubekommen.
**Nicht jede Grafik verträgt beides.** «Plan und Ist gleichzeitig» gibt es nur beim
Vermögensverlauf. Die Vermögensaufteilung zeigt schon Beginn **und** Ende je Phase als
gestapelte Balken Plan und Ist daneben vervierfachte sie. Und die Grafik «Einkommen vs.
Ausgaben» lebt vom Band zwischen zwei Linien; ein zweites Paar darüber macht genau diese
Aussage unkenntlich. Beide zeigen deshalb nur die gewählte Grundlage.
**Serienobergrenze vier.** Szenario mal Version mal Datenquelle wächst schnell; darüber hinaus
hilft keine Farbpalette mehr.
## 9.30 Warum der Haushalt am Plan hängt
Bis V6 trug jedes **Szenario** sein eigenes Grundprofil: Haushaltsform, Personen (Name, Alter,
Pensionsalter) und Startjahr. Das war zu grosszügig. Zwei Szenarien desselben Plans konnten so
verschiedene Startjahre oder Haushaltsformen tragen und niemand hätte es bemerkt, bis eine
Auswertung Unsinn ergab: Derselbe erfasste Ist-Satz für 2031 wäre je Szenario auf einem anderen
Planjahr gelandet.
**V7 zieht die Trennlinie neu.** Was den **Haushalt** beschreibt, gehört an den Plan; was eine
**Planungsvariante** ausmacht, ans Szenario:
| Am Plan (für alle Szenarien) | Am Szenario (variantenspezifisch) |
|---|---|
| Haushaltsform | **Pensionsalter** je Person |
| Personen: Name, Alter | Inflationsannahme |
| Planstartjahr | Cash-Anfangswert |
| | Phasen, Elemente, Werte |
Das **Pensionsalter** bleibt bewusst unten es ist der Kern jedes Früh- oder
Spätpensionierungs-Szenarios. Wollte man Name oder Alter einer Person unterschiedlich planen,
wäre das kein Szenario mehr, sondern ein **anderer Plan**.
**Der Rechenkern merkt nichts davon.** `toPlanInput()` fügt die beiden Ebenen wieder zu einem
unveränderten `PlanInput` zusammen; `computePlan` und die 43 Golden Tests bleiben unberührt. Die
Verlagerung ist eine Frage der Datenhaltung, nicht der Berechnung.
**Preis der Klarheit:** Der Profil-Dialog muss jetzt je Feld anzeigen, ob es plan-weit gilt oder
nur das Szenario betrifft sonst änderte man beim Bearbeiten eines Nebenszenarios unbemerkt den
ganzen Plan. Diese Beschriftung ist der sichtbare Teil der Entscheidung.
---
# 10. Glossar
| Begriff | Bedeutung im FPT |
|---|---|
| **Plan** | Selbsttragende Planungseinheit: Grundprofil + Phasenkette + Elemente |
| **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 |
| **Finanzielles Element** | Plan-weite Entität einer der 8 Kategorien, über alle Phasen identisch |
| **Übergang** | Grenze zwischen zwei Phasen; Ort der einmaligen Entscheide |
| **Einmalige Sonderein-/ausgabe** | Ereignis am Übergang (Erbschaft, Poolbau), das direkt aufs Cash wirkt und nicht in die Quote eingeht |
| **Pensions-Übergang** | Übergang, bei dem der Besitzer des Elements pensioniert wird |
| **Carry / Fortschreibung** | Live-Übertragung des Endwerts einer Phase in die nächste |
| **Cash** | Systemseitiges Ausgleichskonto; darf negativ werden (Liquiditätslücke) |
| **Quote** | Einkommen nominale Ausgaben eines Jahres; negativ = **Verzehr** |
| **Geplante Sparrate** | 3a-Beiträge + Sparbeiträge + Amortisationen + Tilgungen |
| **Geplante Verzehrrate** | Summe der Bezugsraten aus Sonstigem Vermögen |
| **Kapitalzufluss** | Verkaufserlöse + PK-/3a-Bezüge aus dem Übergang in die Phase |
| **Kapitalinvestition** | Zusatzinvestitionen + Sofort-Tilgungen |
| **Nominal** | Betrag in Franken des jeweiligen Jahres |
| **Real** | Kaufkraftbereinigt auf den Planbeginn (`nominal / Deflator`) |
| **Deflator** | Kumulierte Inflation seit Planbeginn |
| **Ausfalljahr** | Jahr ohne AHV-Beiträge; kürzt die Rente um 1/44 |
| **mdJE** | Massgebendes durchschnittliches Jahreseinkommen Mittel der Beitragsjahre, bestimmt die Rentenhöhe |
| **R0** | Mindestbetrag der vollen AHV-Monatsrente (1'260); alle Schwellen sind Vielfache davon |
| **Wendepunkt** | mdJE = 36 × R0 = 45'360; dort wechselt die Rentenformel von Teil 1 auf Teil 2 |
| **Plafonierung** | Deckelung der Ehepaar-AHV auf 150 % der Einzel-Maximalrente |
| **Umwandlungssatz** | Prozentsatz zur Verrentung des PK-Kapitals |
| **Ruin(alter)** | Alter von Person A, in dem das Gesamtvermögen erstmals unter 0 fällt |
| **Nachlass** | Endvermögen der letzten Phase (nominal) |
---
*Ende der Spezifikation v0.16*