From e1f74fca95be6b092b7479b002329775ba16a94e Mon Sep 17 00:00:00 2001 From: kelle Date: Sat, 18 Jul 2026 21:21:37 +0200 Subject: [PATCH] Lesbare Wasserfaelle, Verkaufspreis-Abgleich, Erklaerung wirkungsloser Treiber 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 --- SPEZIFIKATION.md | 117 +++++++++++- src/components/DetailView.tsx | 214 ++++++++++++++++----- src/components/ElementDetail.tsx | 54 +++++- src/components/PlanView.tsx | 5 + src/components/SensitivityDialog.tsx | 5 +- src/lib/bridges.test.ts | 274 +++++++++++++++++++++++++++ src/lib/sensitivity.test.ts | 39 ++++ src/lib/sensitivity.ts | 24 ++- 8 files changed, 671 insertions(+), 61 deletions(-) create mode 100644 src/lib/bridges.test.ts diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index fef5388..796cc97 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -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* diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index f04768a..22054c7 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -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 ( -
- - - - - Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} - /> - [formatChf(p?.payload?.value ?? 0), p?.payload?.label ?? ""]} - labelFormatter={() => ""} - /> - - - {data.map((d, i) => ( - - ))} - - - +
+
+ {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 ( +
+ {r.section && ( +
+ {r.section} +
+ )} +
+
+ {r.label} +
+
+ {/* Nulllinie */} +
+ {/* Balken */} +
+ {/* Verbindungslinie zum naechsten Balken: auf dem Stand NACH diesem Schritt */} + {!isLast && ( +
+ )} +
+
+ {r.kind === "total" ? formatChf(r.value) : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`} +
+
+
+ ); + })} +
+ + {/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Groessenordnungen + ist die Tabelle der Grafik ueberlegen. */} +
+ Zahlen mit Zwischenstand anzeigen +
+ + + + + + + + + + {rows.map((r, i) => ( + + + + + + ))} + +
SchrittBetragZwischenstand
{r.label} + {r.kind === "total" ? "—" : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`} + {formatChf(r.running)}
+
+
); } +// 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 ( +

+ Die Zerlegung geht nicht auf. Nicht zugeordnete Differenz: {formatChf(residual)}. Bitte melden – + das ist ein Fehler in der Berechnung, nicht in der Darstellung. +

+ ); +} + 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.

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

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

- )} +
@@ -438,9 +558,7 @@ export function PhaseDetailDialog({ wenn sie das Vermögen nicht mindern.

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

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

- )} +
); diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index b80af8d..e59b266 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -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 ; 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 ( <> 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" && ( <> - setT({ salePrice: v })} /> + setT({ salePrice: v })} + /> + + {marktwert > 0 && ( +

+ {Math.abs(abweichung) < 1 ? ( + <>Verkaufspreis und modellierter Verkehrswert stimmen überein. + ) : ( + <> + Der Verkaufspreis liegt {formatChf(Math.abs(abweichung))} CHF ( + {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. + + )} + + )} +

+ )} setT({ saleTaxRate: v })} /> )} diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index 10771af..5784429 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -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, diff --git a/src/components/SensitivityDialog.tsx b/src/components/SensitivityDialog.tsx index 6bb0d17..a21d0f0 100644 --- a/src/components/SensitivityDialog.tsx +++ b/src/components/SensitivityDialog.tsx @@ -298,7 +298,10 @@ function TornadoResults({ {result.bars.map((b) => ( - {b.label} + + {b.label} + {b.note &&
{b.note}
} + {formatRange(b.low, b.high, b.unit)} {formatChf(b.lowResult)} {formatChf(b.highResult)} diff --git a/src/lib/bridges.test.ts b/src/lib/bridges.test.ts new file mode 100644 index 0000000..a69702c --- /dev/null +++ b/src/lib/bridges.test.ts @@ -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, + transitionValues: Record = {} +) { + 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[]; +}): 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); + }); +}); diff --git a/src/lib/sensitivity.test.ts b/src/lib/sensitivity.test.ts index adc45f1..cbf5798 100644 --- a/src/lib/sensitivity.test.ts +++ b/src/lib/sensitivity.test.ts @@ -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", () => { diff --git a/src/lib/sensitivity.ts b/src/lib/sensitivity.ts index 2f866f2..2cd792c 100644 --- a/src/lib/sensitivity.ts +++ b/src/lib/sensitivity.ts @@ -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, }; });