Lesbare Wasserfaelle, Verkaufspreis-Abgleich, Erklaerung wirkungsloser Treiber
Deploy App / deploy (push) Successful in 59s
Deploy App / deploy (push) Successful in 59s
Die Wasserfall-Zahlen waren korrekt (residual exakt 0), die Darstellung nicht lesbar. Neu als eigene liegende HTML/CSS-Darstellung statt Recharts: - Verbindungslinien zwischen den Balken (ohne sie zerfaellt der Wasserfall in unverbundene Rechtecke) - Wertbeschriftung an jedem Schritt - Zwischenstand und Veraenderung optisch unterschieden - Abschnitte "Am Uebergang" / "Innerhalb der Phase" - aufklappbare Tabelle mit laufendem Zwischenstand - Nullposten werden nicht gezeichnet - Restposten neu als Fehlermeldung statt beilaeufiger Rundungsnotiz Verkaufspreis einer Immobilie wird beim Wechsel auf "Verkaufen" mit dem modellierten Verkehrswert vorbelegt (nur wenn noch keiner erfasst ist); Verkehrswert und Abweichung werden ausgewiesen, ab 10 % rot abgesetzt. Die beiden Groessen bleiben bewusst entkoppelt -- ein Verkauf unter Verkehrswert ist ein realer Fall. Tornado erklaert Nullbalken statt sie stumm zu zeigen. Wichtigster Fall: Wird die Immobilie vor Planende verkauft, ist die Wertsteigerung nachweislich wirkungslos, weil der Erloes am erfassten Verkaufspreis haengt und nicht am Verkehrswert. 11 Tests ergaenzt (92 -> 103), darunter residual === 0 ueber sieben Plankonstellationen. SPEZIFIKATION auf 0.12, neue Kapitel 3.5.8, 4.13.5, 4.14.2.1, 9.22. Keine Aenderung an der Berechnung. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+108
-9
@@ -4,10 +4,10 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.11 |
|
||||
| **Version** | 0.12 |
|
||||
| **Datum** | 2026-07-18 |
|
||||
| **Status** | Lebendes Dokument |
|
||||
| **Codestand** | Arbeitsstand nach `1836cad` inkl. Detailansichten, Wasserfall-Zerlegungen und vollständiger Rechenweg-Offenlegung (Branch `main`) |
|
||||
| **Codestand** | Arbeitsstand nach `4791dcc` inkl. lesbarer Wasserfälle und Verkaufspreis-Abgleich (Branch `main`) |
|
||||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
| Version | Datum | Autor | Änderung |
|
||||
|---|---|---|---|
|
||||
| 0.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). |
|
||||
@@ -733,6 +734,32 @@ 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
|
||||
@@ -1806,6 +1833,26 @@ spannt deshalb über `min…max`; welche Eingabe zu welchem Ende gehört, zeigt
|
||||
|
||||
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 –
|
||||
@@ -1871,10 +1918,38 @@ Cash Ende Vorphase (Phase 1: Cash-Anfangswert)
|
||||
```
|
||||
|
||||
Beide Strukturen führen einen **Restposten** (`residual`) mit: die Differenz zwischen dem
|
||||
gerechneten Endwert und der Summe der Summanden. Er entsteht nur durch die Rundung der einzelnen
|
||||
Posten auf ganze Franken und liegt im einstelligen Bereich; ein grösserer Wert wäre ein Hinweis
|
||||
auf eine unvollständige Zerlegung. Zwei Tests prüfen ihn über einen Plan, der alle Element-Arten
|
||||
und Übergangs-Entscheide enthält.
|
||||
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
|
||||
|
||||
@@ -2487,12 +2562,13 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
||||
| Datei | Tests | Schwerpunkt |
|
||||
|---|---|---|
|
||||
| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" |
|
||||
| `sensitivity.test.ts` | 14 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung |
|
||||
| `sensitivity.test.ts` | 15 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber |
|
||||
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
|
||||
| `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich |
|
||||
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
|
||||
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
||||
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
||||
| **Total** | **92** | |
|
||||
| **Total** | **103** | |
|
||||
|
||||
## 8.2 Testfälle
|
||||
|
||||
@@ -2528,6 +2604,11 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
||||
| **Tornado: Lebensdauer** | verschiebt nur die letzte Phase; Kappung bei mindestens 1 Jahr |
|
||||
| **Tornado: Verfügbarkeit** | Treiber ohne passende Elemente werden ausgeblendet (z. B. Immobilien-Wertsteigerung ohne Immobilie) |
|
||||
| **Tornado: Sortierung/Richtung** | absteigend nach Spannweite; Treiber ohne Bandbreite hat Spannweite 0 und steht zuunterst; höhere Ausgaben → tieferes, höhere Rendite → höheres Endvermögen |
|
||||
| **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 |
|
||||
@@ -2835,6 +2916,24 @@ Konkret uneindeutig sind zwei Fälle:
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
# 10. Glossar
|
||||
@@ -2877,4 +2976,4 @@ Der Restposten (`residual`) ist die Kontrollgrösse dafür, dass die gewählte Z
|
||||
|
||||
---
|
||||
|
||||
*Ende der Spezifikation v0.11*
|
||||
*Ende der Spezifikation v0.12*
|
||||
|
||||
+166
-48
@@ -5,7 +5,6 @@ import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
@@ -29,77 +28,197 @@ import type {
|
||||
} from "@/lib/calculations";
|
||||
|
||||
// --- Wasserfall ---------------------------------------------------------------------------
|
||||
// Recharts kennt keinen Wasserfall: Er entsteht aus zwei gestapelten Balken -- einem
|
||||
// unsichtbaren Sockel und dem sichtbaren Delta darueber.
|
||||
// Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt
|
||||
// von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken
|
||||
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhoert),
|
||||
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenstaenden
|
||||
// und Veraenderungen.
|
||||
//
|
||||
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
|
||||
// muessten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
|
||||
|
||||
interface WaterfallItem {
|
||||
label: string;
|
||||
value: number;
|
||||
total?: boolean; // Zwischen-/Endsumme: startet bei 0 statt beim laufenden Saldo
|
||||
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veraenderung
|
||||
section?: string; // optionale Abschnitts-Ueberschrift VOR diesem Eintrag
|
||||
}
|
||||
|
||||
function waterfallData(items: WaterfallItem[]) {
|
||||
interface WaterfallRow {
|
||||
label: string;
|
||||
section?: string;
|
||||
from: number;
|
||||
to: number;
|
||||
value: number;
|
||||
running: number; // Stand NACH diesem Schritt
|
||||
kind: "total" | "pos" | "neg";
|
||||
}
|
||||
|
||||
function waterfallRows(items: WaterfallItem[]): WaterfallRow[] {
|
||||
let running = 0;
|
||||
return items.map((it) => {
|
||||
if (it.total) {
|
||||
running = it.value;
|
||||
return { label: it.label, base: 0, delta: Math.abs(it.value), value: it.value, kind: "total" as const };
|
||||
return { label: it.label, section: it.section, from: 0, to: it.value, value: it.value, running, kind: "total" as const };
|
||||
}
|
||||
const start = running;
|
||||
const from = running;
|
||||
running += it.value;
|
||||
return {
|
||||
label: it.label,
|
||||
base: Math.min(start, running),
|
||||
delta: Math.abs(it.value),
|
||||
section: it.section,
|
||||
from,
|
||||
to: running,
|
||||
value: it.value,
|
||||
running,
|
||||
kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const WF_COLOR = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
|
||||
const WF_FILL = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
|
||||
|
||||
const ROW_H = 34;
|
||||
const BAR_H = 20;
|
||||
|
||||
function Waterfall({ items }: { items: WaterfallItem[] }) {
|
||||
const rows = useMemo(() => waterfallRows(items), [items]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const lo = Math.min(0, ...rows.map((r) => Math.min(r.from, r.to)));
|
||||
const hi = Math.max(0, ...rows.map((r) => Math.max(r.from, r.to)));
|
||||
const span = hi - lo || 1;
|
||||
const pos = (v: number) => ((v - lo) / span) * 100;
|
||||
|
||||
function Waterfall({ items, height = 300 }: { items: WaterfallItem[]; height?: number }) {
|
||||
const data = useMemo(() => waterfallData(items), [items]);
|
||||
if (data.length === 0) return null;
|
||||
return (
|
||||
<div className="w-full" style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-32} textAnchor="end" height={70} />
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--surface-2)" }}
|
||||
formatter={(_v, _n, p) => [formatChf(p?.payload?.value ?? 0), p?.payload?.label ?? ""]}
|
||||
labelFormatter={() => ""}
|
||||
/>
|
||||
<Bar dataKey="base" stackId="w" fill="transparent" isAnimationActive={false} />
|
||||
<Bar dataKey="delta" stackId="w" isAnimationActive={false} radius={[2, 2, 0, 0]}>
|
||||
{data.map((d, i) => (
|
||||
<Cell key={i} fill={WF_COLOR[d.kind]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
{rows.map((r, i) => {
|
||||
const left = pos(Math.min(r.from, r.to));
|
||||
const width = Math.max(0.4, Math.abs(pos(r.to) - pos(r.from)));
|
||||
const isLast = i === rows.length - 1;
|
||||
return (
|
||||
<div key={`${r.label}-${i}`}>
|
||||
{r.section && (
|
||||
<div className="border-b border-border bg-surface-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
|
||||
{r.section}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-stretch ${r.kind === "total" ? "bg-surface-2" : ""}`}>
|
||||
<div
|
||||
className={`w-44 shrink-0 border-r border-border px-3 py-2 text-[11px] leading-tight ${
|
||||
r.kind === "total" ? "font-semibold text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</div>
|
||||
<div className="relative min-w-0 flex-1" style={{ height: ROW_H }}>
|
||||
{/* Nulllinie */}
|
||||
<div
|
||||
className="absolute top-0 h-full border-l border-dashed border-border"
|
||||
style={{ left: `${pos(0)}%` }}
|
||||
/>
|
||||
{/* Balken */}
|
||||
<div
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
top: (ROW_H - BAR_H) / 2,
|
||||
height: BAR_H,
|
||||
backgroundColor: WF_FILL[r.kind],
|
||||
opacity: r.kind === "total" ? 0.85 : 1,
|
||||
}}
|
||||
title={`${r.label}: ${formatChf(r.value)}`}
|
||||
/>
|
||||
{/* Verbindungslinie zum naechsten Balken: auf dem Stand NACH diesem Schritt */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className="absolute border-l border-dotted border-faint"
|
||||
style={{
|
||||
left: `${pos(r.to)}%`,
|
||||
top: (ROW_H + BAR_H) / 2,
|
||||
height: (ROW_H - BAR_H) / 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`w-28 shrink-0 border-l border-border px-3 py-2 text-right text-[11px] tabular-nums ${
|
||||
r.kind === "total" ? "font-semibold text-fg" : r.kind === "neg" ? "text-danger" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{r.kind === "total" ? formatChf(r.value) : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Groessenordnungen
|
||||
ist die Tabelle der Grafik ueberlegen. */}
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
|
||||
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-[10px] uppercase tracking-wide text-faint">
|
||||
<th className="px-3 py-1.5 text-left font-semibold">Schritt</th>
|
||||
<th className="px-3 py-1.5 text-right font-semibold">Betrag</th>
|
||||
<th className="px-3 py-1.5 text-right font-semibold">Zwischenstand</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className={`border-t border-border ${r.kind === "total" ? "bg-surface-2 font-semibold" : ""}`}>
|
||||
<td className="px-3 py-1.5 text-fg">{r.label}</td>
|
||||
<td
|
||||
className={`px-3 py-1.5 text-right tabular-nums ${
|
||||
r.kind === "total" ? "text-faint" : r.kind === "neg" ? "text-danger" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{r.kind === "total" ? "—" : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-fg">{formatChf(r.running)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Kontrollgroesse: Ist die Zerlegung vollstaendig, muss die Differenz zwischen Endwert und
|
||||
// der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der
|
||||
// nicht aufgeht, ist ein Fehler und kein Schoenheitsproblem.
|
||||
function ResidualNote({ residual }: { residual: number }) {
|
||||
if (Math.abs(residual) <= 2) return null;
|
||||
return (
|
||||
<p className="mt-2 rounded-lg border border-danger bg-danger-soft px-3 py-2 text-[11px] text-danger">
|
||||
<strong>Die Zerlegung geht nicht auf.</strong> Nicht zugeordnete Differenz: {formatChf(residual)}. Bitte melden –
|
||||
das ist ein Fehler in der Berechnung, nicht in der Darstellung.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
|
||||
const items: WaterfallItem[] = [];
|
||||
if (!isFirst) {
|
||||
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true });
|
||||
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true, section: "Am Übergang in diese Phase" });
|
||||
if (w.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: w.oneOffInflow });
|
||||
if (w.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -w.oneOffOutflow });
|
||||
if (w.transitionTax) items.push({ label: "Steuern am Übergang", value: -w.transitionTax });
|
||||
if (w.pensionConversion) items.push({ label: "PK verrentet", value: -w.pensionConversion });
|
||||
if (w.pensionConversion) items.push({ label: "PK in Rente umgewandelt", value: -w.pensionConversion });
|
||||
if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss });
|
||||
}
|
||||
items.push({ label: "Vermögen Phasenbeginn", value: w.startWealth, total: true });
|
||||
items.push({
|
||||
label: "Vermögen Phasenbeginn",
|
||||
value: w.startWealth,
|
||||
total: true,
|
||||
section: isFirst ? undefined : "Innerhalb der Phase",
|
||||
});
|
||||
if (w.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: w.quotaTotal });
|
||||
if (w.investmentReturn) items.push({ label: "Kapitalerträge", value: w.investmentReturn });
|
||||
if (w.propertyAppreciation) items.push({ label: "Wertsteigerung Immobilie", value: w.propertyAppreciation });
|
||||
@@ -110,13 +229,18 @@ function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
|
||||
|
||||
function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] {
|
||||
const items: WaterfallItem[] = [];
|
||||
items.push({ label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase", value: c.openingCash, total: true });
|
||||
items.push({
|
||||
label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase",
|
||||
value: c.openingCash,
|
||||
total: true,
|
||||
section: isFirst ? undefined : "Am Übergang in diese Phase",
|
||||
});
|
||||
if (c.capitalInflow) items.push({ label: "Kapitalzufluss", value: c.capitalInflow });
|
||||
if (c.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: c.oneOffInflow });
|
||||
if (c.immediateRepay) items.push({ label: "Sofort-Tilgung", value: -c.immediateRepay });
|
||||
if (c.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -c.oneOffOutflow });
|
||||
if (c.investments) items.push({ label: "Investitionen", value: -c.investments });
|
||||
items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true });
|
||||
items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true, section: "Innerhalb der Phase" });
|
||||
if (c.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: c.quotaTotal });
|
||||
if (c.savingRates) items.push({ label: "Sparraten", value: -c.savingRates });
|
||||
if (c.debtRates) items.push({ label: "Amort./Tilgung", value: -c.debtRates });
|
||||
@@ -424,11 +548,7 @@ export function PhaseDetailDialog({
|
||||
verändern – als Balken gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt.
|
||||
</p>
|
||||
<Waterfall items={bridgeItems(phase.wealthBridge, isFirst)} />
|
||||
{Math.abs(phase.wealthBridge.residual) > 2 && (
|
||||
<p className="text-[11px] text-faint">
|
||||
Rundungsdifferenz: {formatChf(phase.wealthBridge.residual)}
|
||||
</p>
|
||||
)}
|
||||
<ResidualNote residual={phase.wealthBridge.residual} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -438,9 +558,7 @@ export function PhaseDetailDialog({
|
||||
wenn sie das Vermögen nicht mindern.
|
||||
</p>
|
||||
<Waterfall items={cashItems(phase.cashBridge, isFirst)} />
|
||||
{Math.abs(phase.cashBridge.residual) > 2 && (
|
||||
<p className="text-[11px] text-faint">Rundungsdifferenz: {formatChf(phase.cashBridge.residual)}</p>
|
||||
)}
|
||||
<ResidualNote residual={phase.cashBridge.residual} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface CellContext {
|
||||
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
|
||||
derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn
|
||||
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (fuer die Sonderamortisation)
|
||||
// nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek),
|
||||
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert fuer den Verkaufspreis.
|
||||
propertyValueEnd: number;
|
||||
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
||||
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
|
||||
laterPhaseCount: number;
|
||||
@@ -769,12 +772,24 @@ export function ElementTransitionFields({
|
||||
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />;
|
||||
case "REAL_ESTATE": {
|
||||
const decision = td.decision ?? "HOLD";
|
||||
const marktwert = Math.round(context.propertyValueEnd);
|
||||
const preis = num(td.salePrice);
|
||||
// Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide
|
||||
// Groessen sind unabhaengig erfassbar -- ohne diesen Vergleich koennte man 2 %
|
||||
// Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken.
|
||||
const abweichung = preis - marktwert;
|
||||
const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0;
|
||||
const deutlich = marktwert > 0 && Math.abs(abweichungPct) >= 10;
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
label="Entscheidung"
|
||||
value={decision === "SELL" ? "SELL" : "HOLD"}
|
||||
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
|
||||
onChange={(v: "HOLD" | "SELL") =>
|
||||
// Beim Wechsel auf "Verkaufen" den Verkaufspreis mit dem modellierten
|
||||
// Verkehrswert vorbelegen -- aber nur, wenn noch keiner erfasst ist.
|
||||
setT(v === "SELL" && td.salePrice === undefined ? { decision: v, salePrice: marktwert } : { decision: v })
|
||||
}
|
||||
options={[
|
||||
{ value: "HOLD", label: "Halten" },
|
||||
{ value: "SELL", label: "Verkaufen" },
|
||||
@@ -782,7 +797,42 @@ export function ElementTransitionFields({
|
||||
/>
|
||||
{decision === "SELL" && (
|
||||
<>
|
||||
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
|
||||
<MoneyField
|
||||
label="Verkaufspreis (CHF)"
|
||||
help="Vorbelegt mit dem modellierten Verkehrswert am Phasenende. Bewusst änderbar – der erfasste Preis ist massgebend, nicht der modellierte Wert."
|
||||
value={preis}
|
||||
onChange={(v) => setT({ salePrice: v })}
|
||||
/>
|
||||
<DerivedField
|
||||
label="Modellierter Verkehrswert (Phasenende)"
|
||||
value={marktwert}
|
||||
help="Kaufpreis zuzüglich der bis hierhin aufgelaufenen Wertsteigerung. Nur zum Vergleich – für den Erlös zählt der oben erfasste Verkaufspreis."
|
||||
/>
|
||||
{marktwert > 0 && (
|
||||
<p
|
||||
className={`col-span-full rounded-lg px-3 py-2 text-xs ${
|
||||
deutlich ? "border border-danger bg-danger-soft text-danger" : "bg-surface-2 text-muted"
|
||||
}`}
|
||||
>
|
||||
{Math.abs(abweichung) < 1 ? (
|
||||
<>Verkaufspreis und modellierter Verkehrswert stimmen überein.</>
|
||||
) : (
|
||||
<>
|
||||
Der Verkaufspreis liegt <strong>{formatChf(Math.abs(abweichung))} CHF</strong> (
|
||||
{abweichung > 0 ? "+" : "−"}
|
||||
{Math.abs(Math.round(abweichungPct * 10) / 10)} %) {abweichung > 0 ? "über" : "unter"} dem
|
||||
modellierten Verkehrswert.
|
||||
{deutlich && (
|
||||
<>
|
||||
{" "}
|
||||
Das ist eine deutliche Abweichung – prüfe, ob sie gewollt ist oder ob die angenommene
|
||||
Wertsteigerung nicht zum erwarteten Verkaufspreis passt.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -226,6 +226,8 @@ export function PlanView({
|
||||
derivedStart: ce?.baseValue ?? 0,
|
||||
derivedMortgage: ce?.mortgageStart ?? 0,
|
||||
mortgageEnd: ce?.mortgageEnd ?? 0,
|
||||
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
|
||||
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
|
||||
deflatorStart: phase.cumulativeInflationStart,
|
||||
laterPhaseCount: laterPhaseCount(phase),
|
||||
ahvCareer: careerFor(element),
|
||||
@@ -246,6 +248,8 @@ export function PlanView({
|
||||
derivedStart: 0,
|
||||
derivedMortgage: 0,
|
||||
mortgageEnd: ce?.mortgageEnd ?? 0,
|
||||
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
|
||||
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
|
||||
deflatorStart: fromPhase.cumulativeInflationStart,
|
||||
laterPhaseCount: laterPhaseCount(fromPhase),
|
||||
ahvCareer: careerFor(element),
|
||||
@@ -1107,6 +1111,7 @@ function AddElementDialog({
|
||||
derivedStart: 0,
|
||||
derivedMortgage: 0,
|
||||
mortgageEnd: 0,
|
||||
propertyValueEnd: 0,
|
||||
deflatorStart: firstPhase.cumulativeInflationStart,
|
||||
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
|
||||
ahvCareer: null,
|
||||
|
||||
@@ -298,7 +298,10 @@ function TornadoResults({
|
||||
<tbody>
|
||||
{result.bars.map((b) => (
|
||||
<tr key={b.id} className="border-t border-border">
|
||||
<td className="px-3 py-2 font-medium text-fg">{b.label}</td>
|
||||
<td className="px-3 py-2 font-medium text-fg">
|
||||
{b.label}
|
||||
{b.note && <div className="mt-0.5 text-[11px] font-normal text-muted">{b.note}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Die zentrale Invariante beider Wasserfall-Bruecken: `residual` ist die Differenz zwischen
|
||||
// dem tatsaechlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
|
||||
// vollstaendig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, waere ein
|
||||
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb ueber moeglichst
|
||||
// verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel.
|
||||
|
||||
let idc = 0;
|
||||
const nid = () => `b${idc++}`;
|
||||
|
||||
function el(
|
||||
category: ElementCategory,
|
||||
ownerRole: string | null,
|
||||
phaseValues: Record<string, PhaseData>,
|
||||
transitionValues: Record<string, TransitionData> = {}
|
||||
) {
|
||||
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
|
||||
}
|
||||
|
||||
function plan(opts: {
|
||||
age: number;
|
||||
retirementAge: number;
|
||||
inflation?: number;
|
||||
initialCash?: number;
|
||||
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
|
||||
elements: ReturnType<typeof el>[];
|
||||
}): PlanInput {
|
||||
return {
|
||||
id: "plan",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: opts.inflation ?? 1.5,
|
||||
initialCash: opts.initialCash ?? 0,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
||||
phases: opts.phases.map((p, i) => ({
|
||||
id: p.id,
|
||||
sequenceNumber: i + 1,
|
||||
name: p.id,
|
||||
durationYears: p.durationYears,
|
||||
cashTransition: p.cashTransition ?? {},
|
||||
})),
|
||||
elements: opts.elements,
|
||||
};
|
||||
}
|
||||
|
||||
const konstellationen: { name: string; build: () => PlanInput }[] = [
|
||||
{
|
||||
name: "Ansparen mit 3a, Sparbeitrag und Schuldentilgung",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
initialCash: 50000,
|
||||
phases: [{ id: "p1", durationYears: 10 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000, teuerungsausgleich: 1 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 } }),
|
||||
el("PILLAR_3A", "PERSON_A", { p1: { currentValue: 50000, annualContribution: 7000, expectedReturn: 3 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 10000 } }),
|
||||
el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000, annualRepayment: 8000 } }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Pensionierung mit PK-Verrentung und 3a-Bezug",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
initialCash: 20000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 20 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 130000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 75000 } }),
|
||||
el("AHV", "PERSON_A", { p1: { gapYears: 0 } }, { p1: { reviewed: true, avgIncomeBefore: 95000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } },
|
||||
{ p1: { payoutMode: "PENSION", conversionRate: 6 } }
|
||||
),
|
||||
el(
|
||||
"PILLAR_3A",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } },
|
||||
{ p1: { capitalTaxRate: 8 } }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", {
|
||||
p1: { startValue: 300000, expectedReturn: 4 },
|
||||
p2: { expectedReturn: 4, annualWithdrawal: 30000 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "PK-Kapitalbezug statt Rente (Steuer am Uebergang)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 62,
|
||||
retirementAge: 65,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 3 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 110000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 70000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } },
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 8 } }
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Immobilie gehalten (Wertsteigerung und Amortisation)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 45,
|
||||
retirementAge: 70,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
|
||||
el("REAL_ESTATE", "HOUSEHOLD", {
|
||||
p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
|
||||
p2: { amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstueckgewinnsteuer)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 45,
|
||||
retirementAge: 70,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
"HOUSEHOLD",
|
||||
{ p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 } },
|
||||
{ p1: { decision: "SELL", salePrice: 1400000, saleTaxRate: 20 } }
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Einmalige Sonderein-/ausgaben am Uebergang",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 50,
|
||||
retirementAge: 70,
|
||||
initialCash: 30000,
|
||||
phases: [
|
||||
{
|
||||
id: "p1",
|
||||
durationYears: 5,
|
||||
cashTransition: { mode: "BOTH", inflowAmount: 250000, inflowTaxRate: 10, outflowAmount: 40000 },
|
||||
},
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 85000 }, p2: { amount: 85000 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", {
|
||||
p1: { startValue: 100000, expectedReturn: 4 },
|
||||
p2: { expectedReturn: 4, additionalInvestment: 50000 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Sofort-Tilgung und Sonderamortisation am Uebergang",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 50,
|
||||
retirementAge: 70,
|
||||
initialCash: 200000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }),
|
||||
el(
|
||||
"OTHER_DEBT",
|
||||
"HOUSEHOLD",
|
||||
{ p1: { startValue: 80000, annualRepayment: 5000 }, p2: { annualRepayment: 5000 } },
|
||||
{ p1: { immediateRepayment: 30000 } }
|
||||
),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
"HOUSEHOLD",
|
||||
{
|
||||
p1: { purchasePrice: 800000, mortgage: 500000, amortization: 10000, valueGrowth: 1 },
|
||||
p2: { amortization: 10000, valueGrowth: 1 },
|
||||
},
|
||||
{ p1: { decision: "HOLD", extraAmortization: 50000 } }
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
describe("Wasserfall-Bruecken", () => {
|
||||
for (const k of konstellationen) {
|
||||
it(`${k.name}: beide Bruecken gehen ohne Restgroesse auf`, () => {
|
||||
const r = computePlan(k.build());
|
||||
expect(r.phases.length).toBeGreaterThan(0);
|
||||
for (const ph of r.phases) {
|
||||
expect(ph.wealthBridge.residual, `Vermoegensbruecke ${ph.name}`).toBe(0);
|
||||
expect(ph.cashBridge.residual, `Cash-Bruecke ${ph.name}`).toBe(0);
|
||||
// Die Kontrollpunkte muessen den offiziellen Kennzahlen entsprechen.
|
||||
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
|
||||
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
|
||||
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
|
||||
expect(ph.cashBridge.cashEnd).toBe(ph.cashEnd);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("Umbuchungen erscheinen NICHT in der Vermoegensbruecke", () => {
|
||||
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermoegenswert, ohne
|
||||
// das Vermoegen zu aendern. Sie duerfen deshalb nur in der Cash-Bruecke auftauchen; die
|
||||
// Vermoegensaenderung erklaert sich allein aus Quote, Ertraegen und PK-Beitraegen.
|
||||
const r = computePlan(konstellationen[0].build());
|
||||
const ph = r.phases[0];
|
||||
expect(ph.cashBridge.savingRates).toBeGreaterThan(0);
|
||||
expect(ph.cashBridge.debtRates).toBeGreaterThan(0);
|
||||
const w = ph.wealthBridge;
|
||||
expect(w.endWealth - w.startWealth).toBe(
|
||||
w.quotaTotal + w.investmentReturn + w.propertyAppreciation + w.pensionFundContribution
|
||||
);
|
||||
});
|
||||
|
||||
it("PK-Verrentung erscheint als Vermoegensabgang am Uebergang", () => {
|
||||
// Das verrentete Kapital verlaesst die Bilanz und wird zum Rentenstrom -- in der
|
||||
// Vermoegensbruecke ein echter Abgang, kein Umbuchungsposten.
|
||||
const r = computePlan(konstellationen[1].build());
|
||||
const pension = r.phases[1];
|
||||
expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0);
|
||||
expect(pension.wealthBridge.startWealth).toBeLessThan(pension.wealthBridge.openingWealth);
|
||||
});
|
||||
|
||||
it("Verkaufsdifferenz und Steuer erscheinen nur beim Verkauf", () => {
|
||||
const gehalten = computePlan(konstellationen[3].build()).phases[1].wealthBridge;
|
||||
const verkauft = computePlan(konstellationen[4].build()).phases[1].wealthBridge;
|
||||
expect(gehalten.saleGainLoss).toBe(0);
|
||||
expect(gehalten.transitionTax).toBe(0);
|
||||
expect(verkauft.transitionTax).toBeGreaterThan(0);
|
||||
// Verkaufspreis ueber dem modellierten Verkehrswert -> positive Differenz.
|
||||
expect(verkauft.saleGainLoss).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,29 @@ function basePlan(): PlanInput {
|
||||
};
|
||||
}
|
||||
|
||||
// Plan mit Immobilie -- wahlweise gehalten oder am Uebergang verkauft.
|
||||
function planMitImmobilie(verkaufen: boolean): PlanInput {
|
||||
const p = basePlan();
|
||||
return {
|
||||
...p,
|
||||
elements: [
|
||||
...p.elements,
|
||||
{
|
||||
id: "haus",
|
||||
category: "REAL_ESTATE",
|
||||
name: "Haus",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 4,
|
||||
phaseValues: {
|
||||
p1: { purchasePrice: 900000, mortgage: 600000, amortization: 10000, valueGrowth: 1 },
|
||||
p2: { amortization: 0, valueGrowth: 1 },
|
||||
},
|
||||
transitionValues: verkaufen ? { p1: { decision: "SELL", salePrice: 1100000, saleTaxRate: 20 } } : {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Sensitivitaet: applyDriver", () => {
|
||||
it("laesst den Ausgangsplan unberuehrt (rein)", () => {
|
||||
const p = basePlan();
|
||||
@@ -160,6 +183,22 @@ describe("Sensitivitaet: Tornado", () => {
|
||||
it("identische Bandbreite ergibt Spannweite 0", () => {
|
||||
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
|
||||
expect(bar.swing).toBe(0);
|
||||
expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklaerung
|
||||
});
|
||||
|
||||
it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklaert", () => {
|
||||
// Beim Verkauf ist der Erloes `Verkaufspreis - Hypothek - Steuer` und haengt am ERFASSTEN
|
||||
// Preis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird damit
|
||||
// verworfen -- der Treiber kann das Endvermoegen nicht mehr bewegen.
|
||||
const gehalten = computeTornado(planMitImmobilie(false), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
|
||||
const verkauft = computeTornado(planMitImmobilie(true), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
|
||||
|
||||
expect(gehalten.bars[0].swing).toBeGreaterThan(0);
|
||||
expect(gehalten.bars[0].note).toBeUndefined();
|
||||
|
||||
expect(verkauft.bars[0].swing).toBe(0);
|
||||
expect(verkauft.bars[0].note).toContain("verkauft");
|
||||
expect(verkauft.bars[0].note).toContain("Verkaufspreis");
|
||||
});
|
||||
|
||||
it("real und nominal unterscheiden sich um den Deflator", () => {
|
||||
|
||||
+23
-1
@@ -221,6 +221,7 @@ export interface TornadoBar {
|
||||
id: DriverId;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
note?: string; // Erklaerung, wenn der Treiber das Ergebnis nachweislich nicht bewegt
|
||||
unit: DriverUnit;
|
||||
low: number; // eingegebene Bandbreite
|
||||
high: number;
|
||||
@@ -236,6 +237,25 @@ export interface TornadoResult {
|
||||
bars: TornadoBar[];
|
||||
}
|
||||
|
||||
// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklaerung ist die
|
||||
// schlechteste Antwort -- der Nutzer haelt ihn fuer einen Fehler.
|
||||
//
|
||||
// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserloes
|
||||
// ist `Verkaufspreis - Hypothek - Grundstueckgewinnsteuer` und haengt damit am ERFASSTEN
|
||||
// Verkaufspreis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim
|
||||
// Verkauf also verworfen -- der Treiber kann das Endvermoegen nicht mehr beeinflussen.
|
||||
export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
|
||||
if (id === "propertyGrowth") {
|
||||
const computed = computePlan(plan);
|
||||
const last = computed.phases[computed.phases.length - 1];
|
||||
const realEstate = (last?.elements ?? []).filter((e) => e.category === "REAL_ESTATE");
|
||||
if (realEstate.length > 0 && realEstate.every((e) => e.status === "SOLD")) {
|
||||
return "Wirkungslos, weil die Immobilie vor Planende verkauft wird: Der Verkaufserlös ergibt sich aus dem erfassten Verkaufspreis, nicht aus dem modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim Verkauf verworfen.";
|
||||
}
|
||||
}
|
||||
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
|
||||
}
|
||||
|
||||
// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
|
||||
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
|
||||
const computed = computePlan(plan);
|
||||
@@ -257,10 +277,12 @@ export function computeTornado(
|
||||
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
|
||||
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt
|
||||
// deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle.
|
||||
const swing = Math.abs(highResult - lowResult);
|
||||
return {
|
||||
id: input.id,
|
||||
label: def.label,
|
||||
shortLabel: def.shortLabel,
|
||||
note: swing === 0 ? ineffectiveReason(plan, input.id) : undefined,
|
||||
unit: def.unit,
|
||||
low: input.low,
|
||||
high: input.high,
|
||||
@@ -268,7 +290,7 @@ export function computeTornado(
|
||||
highResult,
|
||||
min: Math.min(lowResult, highResult),
|
||||
max: Math.max(lowResult, highResult),
|
||||
swing: Math.abs(highResult - lowResult),
|
||||
swing,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user