From 9252f7188dc53de5065f0c50015b5059fb54ed6e Mon Sep 17 00:00:00 2001 From: kelle Date: Sun, 19 Jul 2026 12:30:36 +0200 Subject: [PATCH] Phasenkopf-Ueberarbeitung: zweizeilige Werte, Verfuegbares Kapital, Verteil-Werkzeuge Rein an der Oberflaeche und als neue Bearbeitungswerkzeuge -- keine Aenderung an Berechnung, Datenmodell oder API. Beide Verteil-Dialoge schreiben nur bestehende Felder ueber bestehende Endpunkte. 1) Zweizeilige Wertdarstellung im Modus "Beide": Realwert in Klammern in eigener Zeile UNTER dem nominalen Wert (Kopf + Matrix-Zellen), Pfeil auf beiden Zeilen. Dadurch schmalere Spalten und jede Kennzahl umbruchfrei. 2) "Sparquote" / "Verzehrquote" statt "Quote" / "Verzehr". 3) Neuer Kopf-Block "Verfuegbares Kapital" (ab Phase 2, nur wenn > 0): Topf, davon verteilt, Rest auf Cash -- vollstaendig aus der Cash-Bruecke abgeleitet (capitalPot). 4) Zwei Verteil-Popups mit Live-Vorschau (erneutes computePlan im Browser): - "Kapital verteilen": Zusatzeinlage (PK/3a/Vermoegen, Phasenwert) + Sonderamortisation/Sofort-Tilgung (Uebergangswert der Vorphase); Rest bleibt automatisch auf Cash, Ueberverteilung wird als Luecke gemeldet - "Sparquote/Bezug verteilen": jaehrliche Raten; zeigt Quote erstes Jahr, letztes Jahr UND absolut ueber die Phase; warnt, wenn die Quote sinkt (flache Rate wuerde spaeter Cash-Loch reissen). PK bewusst ausgeschlossen (Beitrag aus Bruttolohn, belastet Cash nicht). Neues reines Modul distribution.ts (capitalPot, quotaSummary, applyPatches). 8 Tests (103 -> 111) -- u.a. residual-Kontrolle gegen die Cash-Bruecke und Nachweis, dass die Quote ueber die Phase sinkt. SPEZIFIKATION auf 0.14: neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3 ueberarbeitet. Co-Authored-By: Claude Opus 4.8 --- SPEZIFIKATION.md | 128 +++++- src/components/DistributionDialogs.tsx | 550 +++++++++++++++++++++++++ src/components/PlanView.tsx | 192 ++++++++- src/lib/distribution.test.ts | 211 ++++++++++ src/lib/distribution.ts | 144 +++++++ 5 files changed, 1199 insertions(+), 26 deletions(-) create mode 100644 src/components/DistributionDialogs.tsx create mode 100644 src/lib/distribution.test.ts create mode 100644 src/lib/distribution.ts diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 4303290..5a46d19 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.13 | +| **Version** | 0.14 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `e1f74fc` inkl. UI-Gesamtumbau (Pakete A–D) und geführtem Onboarding (Branch `main`) | +| **Codestand** | Arbeitsstand nach `c2fb82b` inkl. Phasenkopf-Überarbeitung und Verteil-Werkzeugen (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.14 | 2026-07-18 | Claude (Opus 4.8) | **Phasenkopf überarbeitet und zwei Verteil-Werkzeuge.** (1) **Zweizeilige Wertdarstellung:** Im Anzeigemodus «Beide» steht der Realwert neu in Klammern in einer **eigenen Zeile** unter dem nominalen Wert statt daneben – im Phasenkopf *und* in den Matrix-Zellen. Der Pfeil wiederholt sich auf der zweiten Zeile, damit der Bezug Start → Ende erhalten bleibt. Nebeneffekt: Die Spalten werden schmaler, wodurch **jede Kennzahl umbruchfrei** (`whitespace-nowrap`) dargestellt werden kann. (2) Die Kennzahl heisst korrekt **«Sparquote»** bzw. **«Verzehrquote»** statt «Quote»/«Verzehr». (3) Neuer Block **«Verfügbares Kapital»** im Phasenkopf (ab Phase 2, nur wenn > 0): der beim Übergang zugeflossene Topf mit «davon verteilt» und «Rest auf Cash». (4) Zwei neue Werkzeuge als eigene Popups: **«Kapital verteilen»** (Zusatzeinlagen in PK/3a/Vermögen, Sonderamortisation, Sofort-Tilgung) und **«Sparquote/Bezug verteilen»** (jährliche Raten), beide mit **Live-Vorschau** über eine erneute `computePlan`-Rechnung im Browser – die angezeigte Wirkung ist dadurch per Konstruktion exakt die spätere, inklusive aller Kappungen. Der Quoten-Dialog weist neben erstem und letztem Jahr die **absolute Quote über die ganze Phase** aus und warnt, wenn die Quote über die Phase sinkt. Neues reines Modul `distribution.ts`. Neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3 überarbeitet. 8 Tests ergänzt (103 → 111). **Keine Änderung an Berechnung, Datenmodell oder API** – beide Werkzeuge schreiben ausschliesslich bestehende Felder über bestehende Endpunkte. | | 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** – rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person – räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen – die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.6–3.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben – `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. | | 0.12 | 2026-07-18 | Claude (Opus 4.8) | **Lesbarkeit der Wasserfälle, Verkaufspreis-Abgleich und Erklärung wirkungsloser Tornado-Treiber.** (1) Die beiden Wasserfälle werden **nicht mehr mit Recharts** gezeichnet, sondern als eigene liegende Darstellung: Verbindungslinien zwischen den Balken, Wertbeschriftung an jedem Schritt, Abschnitts-Überschriften („Am Übergang" / „Innerhalb der Phase") und eine aufklappbare Tabelle mit **laufendem Zwischenstand**. Anlass war, dass die bisherige Darstellung faktisch nicht lesbar war – die Zahlen waren korrekt, die Grafik nicht. (2) Der Restposten beider Brücken wird bei Abweichung neu als **Fehlermeldung** ausgewiesen statt als beiläufige „Rundungsdifferenz"; eine nicht aufgehende Zerlegung ist ein Rechenfehler und kein Schönheitsproblem. (3) **Verkaufspreis einer Immobilie** wird beim Wechsel auf „Verkaufen" neu mit dem **modellierten Verkehrswert** vorbelegt; der Dialog weist Verkehrswert und Abweichung aus und warnt ab 10 % Differenz (Kap. 3.5.8, 9.22). Damit fällt auf, wenn angenommene Wertsteigerung und erwarteter Verkaufspreis nicht zusammenpassen. (4) Der Tornado erklärt neu **Nullbalken** statt sie stumm zu zeigen – insbesondere den Fall, dass die Immobilien-Wertsteigerung bei einem Verkauf nachweislich wirkungslos ist (`ineffectiveReason`, Kap. 4.13.5). Neue Kapitel 3.5.8, 4.13.5, 9.22; 11 Tests ergänzt (92 → 103), darunter die Invariante `residual === 0` über sieben Plankonstellationen. Keine DB-Änderung, keine Änderung an der Berechnung. | | 0.11 | 2026-07-18 | Claude (Opus 4.8) | **Detailansichten (Roadmap Nr. 43)** und **vollständige Offenlegung der Berechnungslogiken (Roadmap Nr. 41)**. (1) Neue **Systemparameter-Ansicht** in der Seitenleiste: alle fest hinterlegten Grössen mit Wert, Bedeutung, Herleitung, Quelle und Stand – als strukturierte Daten aus `constants.ts`, also aus derselben Quelle, aus der gerechnet wird. (2) **Nur-Lese-Detailansicht** je Element und je Lebensphase über ein Expand-Icon: Element mit Verlaufsgrafik über **alle Planjahre** (dafür führt `computePlan` neu `ElementPhaseComputed.yearly` je Element mit), Phase mit Vermögensaufteilung und **zwei Wasserfällen**. (3) Die **Wasserfälle** sind bewusst getrennt: Der Vermögens-Wasserfall zeigt nur echte Zu- und Abgänge (Quote, Kapitalerträge, Wertsteigerung, PK-Beiträge, Steuern, Verrentung, Einmalposten); Sparraten, Amortisationen und Investitionen sind **Umbuchungen** und erscheinen ausschliesslich im Cash-Wasserfall – als Vermögensabgang gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. Neue Strukturen `WealthBridge` / `CashBridge` inkl. Restposten als Kontrollgrösse. (4) **Rechenweg-Protokoll**: `computePlan(plan, sample?, { explain })` protokolliert die Schritte, die es ohnehin ausführt – Formel, eingesetzte Zahlen, Ergebnis und Hinweis auf geltende Vereinfachungen. Abdeckung über **alle** Ebenen (Element je Phase, Element je Übergang, Phasen-Kennzahlen, Plan-Ebene). Standardmässig aus, damit die Monte-Carlo-Simulation unberührt bleibt. Jeder Rechenweg verlinkt in das passende Kapitel dieser Spezifikation; ein Test prüft, dass alle Verweise eine existierende Überschrift treffen. Neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21; 12 Tests ergänzt (80 → 92). Keine DB-Änderung; die 43 Golden Tests laufen unverändert. | @@ -805,9 +806,23 @@ Phasenköpfen: | Modus | Darstellung | |---|---| | Nominal | `1'234'567` | -| Beide | `1'234'567 (890'123)` – nominal, real in Klammern | +| Beide | nominal oben, real in Klammern **darunter** (siehe unten) | | Real | `890'123` | +Im Modus **Beide** steht der Realwert seit 0.14 in einer **eigenen Zeile** unter dem nominalen +Wert, nicht mehr daneben. Bei Start-/Endwerten wiederholt sich der Pfeil, damit der zeitliche +Bezug erhalten bleibt: + +``` +30'000 → 10'000 +(29'557) → (7'430) +``` + +Grund: Nebeneinander wird die Zeile so lang, dass die Spalten unnötig breit werden und +Kennzahlen umbrechen. Untereinander bleiben die Spalten schmal – erst dadurch lässt sich jede +Kennzahl **umbruchfrei** darstellen. Die Regel gilt im Phasenkopf **und** in den Matrix-Zellen; +in den Modi «Nominal» und «Real» bleibt alles einzeilig. + „real" bedeutet kaufkraftbereinigt auf den **Planbeginn**: `nominal / Deflator`. Die Wahl wird in `localStorage` unter `fpt-value-mode` gespeichert. @@ -840,10 +855,14 @@ niemand (Progressive Disclosure): | Name + Status-Icon | grünes Häkchen oder rotes Warnsymbol (Liquiditätslücke) | | Typ-Badge + Dauer | Erwerb / Pension / Misch, „N J." | | Alter je Person | ` ` | -| **Quote** bzw. **Verzehr** | Einkommen − Ausgaben; Label wechselt auf „Verzehr", wenn Jahr 1 negativ; rot bei Verzehr | +| **Verfügbares Kapital** | ab Phase 2 und nur wenn > 0: Topf, davon verteilt, Rest auf Cash ([3.6.9](#369-verfügbares-kapital-im-phasenkopf)) | +| **Sparquote** bzw. **Verzehrquote** | Einkommen − Ausgaben; Label wechselt auf „Verzehrquote", wenn Jahr 1 negativ; rot bei Verzehr | | Vermögen | Start → Ende (inkl. Cash), hervorgehoben | | Einmalposten | nur als Kurzhinweis (Bezeichnung), wenn vorhanden | +Die beiden Blöcke «Verfügbares Kapital» und «Sparquote» tragen je einen Knopf, der das passende +Verteil-Werkzeug öffnet ([3.6.10](#3610-verteil-werkzeuge)). + Alles Weitere – Einkommen/Ausgaben Jahr 1 → letztes Jahr, geplante Spar-/Verzehrrate, Kapitalzufluss und -investitionen, die vollen Einmalposten – wohnt in der **Phasen-Detailansicht** (seit 0.11, [3.6.7](#367-detailansichten-je-element-und-je-lebensphase)), @@ -958,6 +977,77 @@ Herleitung (`2 × R0 × 13`) statt nur das Ergebnis. Referenz: `src/components/SystemParametersView.tsx`, `src/lib/constants.ts`. +### 3.6.9 Verfügbares Kapital im Phasenkopf + +Für die Planung einer Phase ist die zentrale Frage: **Wie viel Kapital steht überhaupt zur +Verfügung?** Der Phasenkopf weist das ab Phase 2 als eigenen Block aus (nur wenn > 0): + +| Zeile | Bedeutung | +|---|---| +| **Verfügbares Kapital** | der gesamte Topf beim Übergang in diese Phase | +| davon verteilt | Zusatzeinlagen + Sonderamortisation + Sofort-Tilgung | +| Rest auf Cash | was auf dem Cash-Konto liegen bleibt (= `cashStart`) | + +Der Topf ist vollständig aus der **Cash-Brücke** ([4.14.2](#4142-die-beiden-wasserfälle)) +ableitbar – es braucht keine zusätzliche Berechnung: + +``` +Topf = Cash-Ende der Vorphase + Kapitalzufluss + einmaliger Zufluss − einmalige Kosten + = cashStart + Investitionen + Sofort-Tilgungen +``` + +**Nur ab Phase 2:** In der ersten Phase ignoriert die Berechnung `additionalInvestment` – dort +tragen die Elemente ihren Startwert direkt ([4.6.3](#463-pension_fund)). Ein «Verteilen» hätte +dort eine andere Bedeutung, deshalb wird es gar nicht erst angeboten. + +Referenz: `src/lib/distribution.ts` (`capitalPot`). + +### 3.6.10 Verteil-Werkzeuge + +Zwei Popups, erreichbar über je einen Knopf im Phasenkopf. Beide schreiben **ausschliesslich +bestehende Felder** über die bestehenden Endpunkte – an der Berechnung ändert sich nichts. + +**«Kapital verteilen»** verteilt den Topf aus 3.6.9 auf: + +| Ziel | geschriebenes Feld | liegt an | +|---|---|---| +| PK, Säule 3a, Sonstiges Vermögen | `additionalInvestment` | **dieser** Phase | +| Immobilie | `extraAmortization` | dem **Übergang davor** | +| Sonstige Schulden | `immediateRepayment` | dem **Übergang davor** | + +Dass zwei verschiedene Objekte beschrieben werden, ist eine Folge des Datenmodells: Die +Zusatzeinlage ist ein Phasenwert, Sonderamortisation und Sofort-Tilgung sind Übergangs-Entscheide. +Beide zehren aber vom selben Topf. Betragsfelder werden dabei in die bestehenden Daten +**hineingemischt** – vorhandene Entscheide (`decision`, `salePrice`, `payoutMode` …) bleiben +erhalten. + +Was nicht verteilt wird, **bleibt automatisch auf dem Cash** – dafür braucht es keine Logik, das +ist das Verhalten des Modells. Wird mehr verteilt als vorhanden, startet die Folgephase mit +negativem Cash; der Dialog weist das als Liquiditätslücke aus. + +**«Sparquote verteilen»** (bzw. **«Bezug verteilen»** bei Verzehr) verteilt die laufende Quote auf +jährliche Raten: `annualContribution` (3a, Sonstiges Vermögen), `annualWithdrawal` (Sonstiges +Vermögen), `amortization` (Immobilie), `annualRepayment` (Schulden). + +> **Die Pensionskasse fehlt hier bewusst.** Ihr Beitrag stammt aus dem Bruttolohn und belastet +> das Cash-Konto nicht ([4.6.3](#463-pension_fund)) – er lässt sich also gar nicht aus der Quote +> verteilen. + +Der Dialog weist **drei** Bezugsgrössen aus: Quote im ersten Jahr, im letzten Jahr und – +entscheidend – die **absolute Quote über die ganze Phase**. Letztere ist die Grösse, gegen die +sich eine flache Jahresrate sinnvoll verteilen lässt (siehe [9.25](#925-die-quote-ist-kein-fester-betrag)). + +**Live-Vorschau:** Beide Dialoge kopieren den Plan mit den Entwurfswerten und rechnen ihn erneut +durch `computePlan` – im Browser, ohne API-Aufruf. Die angezeigte Wirkung ist dadurch **per +Konstruktion exakt die spätere**, inklusive aller Kappungen (Bezugsrate am Bestand, Amortisation +an der Restschuld). Eine Nebenrechnung im UI hätte hier dieselbe Driftgefahr wie bei den +Rechenwegen ([4.14.3](#4143-rechenweg-protokoll)). + +Beide Dialoge zeigen den **Fortschreibungs-Warnhinweis** ([3.5.7](#357-warnhinweis-bei-änderungen-in-früheren-phasen)), +wenn Folgephasen existieren. + +Referenz: `src/components/DistributionDialogs.tsx`, `src/lib/distribution.ts`. + ## 3.7 Bedienoberfläche ### 3.7.1 Layout @@ -2147,7 +2237,7 @@ FPT/ │ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell) │ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata │ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes) -│ ├── components/ 22 React-Komponenten (alle "use client") +│ ├── components/ 23 React-Komponenten (alle "use client") │ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren) │ ├── lib/ Domänenlogik (siehe 5.3) │ └── middleware.ts Zugriffsschutz (Edge-Runtime) @@ -2180,6 +2270,7 @@ PlanComputed ← an den Client geliefert | `constants.ts` | Schweizer Systemparameter | | `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. | | `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`. Rein, läuft im Browser. | +| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. | | `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand – Grundlage der Systemparameter-Ansicht | | `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) | | `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen | @@ -2444,6 +2535,7 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server. | `PlanWizard` | ~400 | Geführter Plan-Assistent in fünf Schritten ([3.2.8](#328-geführter-assistent-und-beispielplan)) | | `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) | | `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) | +| `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) | | `Sparkline` | ~45 | Mini-Verlaufskurve je Element-Zeile ([3.7.9](#379-befehls-palette-und-sparklines)) | ### 5.5.3 Wiederverwendungsmuster @@ -2675,10 +2767,11 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `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 | +| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder | | `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** | **103** | | +| **Total** | **111** | | ## 8.2 Testfälle @@ -3059,6 +3152,27 @@ verschachtelten Scroll-Situationen kann das Ziel teilweise verdeckt sein. Der Ei halber in Kauf genommen; eine echte Coach-Mark-Bibliothek wäre der nächste Schritt, wenn die Tour sich bewährt. +## 9.25 Die Quote ist kein fester Betrag + +Die Spar-/Verzehrquote **verändert sich über die Phasenjahre**: Das Einkommen wächst mit der +Lohnerhöhung, die real erfassten Ausgaben mit der Inflation. Bei 0 % Lohnerhöhung und 1.5 % +Inflation sinkt eine Quote von 20'000 über fünf Jahre auf rund 15'100 – ohne dass der Nutzer +etwas geändert hätte. + +`annualContribution` und die übrigen Raten sind dagegen **flache Jahresbeträge** +([9.6](#96-spar--und-bezugsraten-werden-nicht-indexiert)). Wer die Quote des **ersten** Jahres +als Rate verteilt, erzeugt sich damit in den späteren Jahren eine Liquiditätslücke. + +Der Verteil-Dialog begegnet dem auf drei Arten, statt es zu verstecken: +- Er zeigt Quote **erstes Jahr**, **letztes Jahr** und **absolut über die Phase**. +- Er warnt ausdrücklich, wenn die Quote über die Phase sinkt. +- Die Live-Vorschau rechnet den ganzen Plan neu und meldet eine entstehende Liquiditätslücke + sofort, statt sie erst nach dem Speichern sichtbar zu machen. + +Bewusst **nicht** umgesetzt ist eine automatische Deckelung: Es gibt legitime Gründe, mehr zu +sparen als die laufende Quote hergibt (etwa wenn ein Cash-Polster aus der Vorphase abgebaut +werden soll). Das Werkzeug informiert, es bevormundet nicht. + --- # 10. Glossar @@ -3101,4 +3215,4 @@ Tour sich bewährt. --- -*Ende der Spezifikation v0.13* +*Ende der Spezifikation v0.14* diff --git a/src/components/DistributionDialogs.tsx b/src/components/DistributionDialogs.tsx new file mode 100644 index 0000000..f5b49e6 --- /dev/null +++ b/src/components/DistributionDialogs.tsx @@ -0,0 +1,550 @@ +"use client"; + +// Zwei Verteil-Werkzeuge im Phasenkopf: +// 1. "Kapital verteilen" -- der beim Übergang zugeflossene Topf (Verkäufe, Bezüge, +// Erbschaft, Cash der Vorphase) auf Zusatzeinlagen, Sonderamortisation und Sofort- +// Tilgung verteilen; der Rest bleibt automatisch auf dem Cash. +// 2. "Sparquote verteilen" -- die laufende Spar- bzw. Verzehrquote auf jährliche Raten +// verteilen. +// +// Beide schreiben ausschliesslich BESTEHENDE Felder über die bestehenden Endpunkte. Die +// Live-Vorschau entsteht, indem der Plan mit den Entwurfswerten kopiert und erneut durch +// computePlan geschickt wird -- die angezeigte Wirkung ist dadurch per Konstruktion exakt +// die spätere, inklusive aller Kappungen. + +import { useMemo, useState } from "react"; +import { AlertTriangle, Coins, PiggyBank } from "lucide-react"; +import { Button, Modal } from "@/components/ui"; +import { MoneyField } from "@/components/FormField"; +import { InfoBubble } from "@/components/InfoBubble"; +import { CarryWarning } from "@/components/ElementDetail"; +import { api } from "@/lib/api-client"; +import { formatChf } from "@/lib/format"; +import { computePlan } from "@/lib/calculations"; +import { CATEGORY_LABELS, num, type PhaseData, type TransitionData } from "@/lib/elements"; +import { + applyPhasePatches, + applyTransitionPatches, + capitalPot, + quotaSummary, + type PhasePatch, + type TransitionPatch, +} from "@/lib/distribution"; +import type { PlanInput } from "@/lib/types"; + +// --- gemeinsame Bausteine ----------------------------------------------------------------- + +function SummaryRow({ + label, + value, + help, + strong, + tone, +}: { + label: string; + value: number; + help?: string; + strong?: boolean; + tone?: "danger" | "success" | "muted"; +}) { + const color = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : strong ? "text-fg" : "text-muted"; + return ( +
+ + {label} + {help && } + + + {formatChf(value)} + +
+ ); +} + +// --- 1. Kapital verteilen ----------------------------------------------------------------- + +interface CapitalTarget { + elementId: string; + name: string; + category: string; + kind: "investment" | "amortization" | "repayment"; + max: number | undefined; // Kappung (Restschuld); bei Investitionen unbegrenzt + hint: string; +} + +export function CapitalDistributionDialog({ + plan, + computed, + phaseId, + onClose, + onSaved, +}: { + plan: PlanInput; + computed: ReturnType; + phaseId: string; + onClose: () => void; + onSaved: () => void; +}) { + const phaseIndex = computed.phases.findIndex((p) => p.id === phaseId); + const phase = computed.phases[phaseIndex]; + const prevPhase = computed.phases[phaseIndex - 1]; + + // Ziele bestimmen. Zusatzeinlagen liegen in DIESER Phase, Sonderamortisation und + // Sofort-Tilgung dagegen am ÜBERGANG davor (also an der Vorphase) -- deshalb werden beim + // Speichern zwei verschiedene Endpunkte angesprochen. + const targets = useMemo(() => { + const out: CapitalTarget[] = []; + for (const e of plan.elements) { + const ce = phase?.elements.find((x) => x.elementId === e.id); + const prevCe = prevPhase?.elements.find((x) => x.elementId === e.id); + if (!ce || ce.status !== "ACTIVE") continue; + + if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A" || e.category === "OTHER_ASSET") { + out.push({ + elementId: e.id, + name: e.name, + category: e.category, + kind: "investment", + max: undefined, + hint: "Zusatzeinlage aus dem verfügbaren Kapital – erhöht den Startwert in dieser Phase.", + }); + } else if (e.category === "REAL_ESTATE") { + const rest = prevCe?.mortgageEnd ?? 0; + // Beim Verkauf gibt es nichts mehr zu amortisieren. + const sold = e.transitionValues[prevPhase?.id ?? ""]?.decision === "SELL"; + if (rest > 0 && !sold) { + out.push({ + elementId: e.id, + name: e.name, + category: e.category, + kind: "amortization", + max: rest, + hint: `Sonderamortisation: Einmaltilgung der Hypothek am Übergang. Maximal ${formatChf(rest)} (Resthypothek).`, + }); + } + } else if (e.category === "OTHER_DEBT") { + const owed = prevCe ? Math.abs(Math.min(0, prevCe.endValue)) : 0; + if (owed > 0) { + out.push({ + elementId: e.id, + name: e.name, + category: e.category, + kind: "repayment", + max: owed, + hint: `Sofortige Tilgung am Übergang. Maximal ${formatChf(owed)} (Restschuld).`, + }); + } + } + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps -- phase/prevPhase folgen phaseId + }, [plan.elements, phaseId]); + + // Entwurf mit den aktuell gespeicherten Werten vorbelegen. + const [draft, setDraft] = useState>(() => { + const d: Record = {}; + for (const t of targets) { + const e = plan.elements.find((x) => x.id === t.elementId)!; + d[t.elementId] = + t.kind === "investment" + ? Math.round(num(e.phaseValues[phaseId]?.additionalInvestment)) + : t.kind === "amortization" + ? Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.extraAmortization)) + : Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.immediateRepayment)); + } + return d; + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Live-Vorschau: Plan mit den Entwurfswerten neu rechnen. + const preview = useMemo(() => { + const phasePatches: PhasePatch[] = targets + .filter((t) => t.kind === "investment") + .map((t) => ({ elementId: t.elementId, field: "additionalInvestment" as const, value: draft[t.elementId] ?? 0 })); + const transPatches: TransitionPatch[] = targets + .filter((t) => t.kind !== "investment") + .map((t) => ({ + elementId: t.elementId, + field: (t.kind === "amortization" ? "extraAmortization" : "immediateRepayment") as keyof TransitionData, + value: draft[t.elementId] ?? 0, + })); + let next = applyPhasePatches(plan, phaseId, phasePatches); + if (prevPhase) next = applyTransitionPatches(next, prevPhase.id, transPatches); + const c = computePlan(next); + return c.phases.find((p) => p.id === phaseId)!; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draft, plan, phaseId, targets]); + + const pot = capitalPot(preview); + const laterPhases = computed.phases.length - (phase?.sequenceNumber ?? 0); + + async function save() { + setSaving(true); + setError(null); + try { + for (const t of targets) { + const e = plan.elements.find((x) => x.id === t.elementId)!; + const value = draft[t.elementId] ?? 0; + if (t.kind === "investment") { + // Bestehende Felder der Phase erhalten -- der Endpunkt ersetzt den ganzen Satz. + const merged: PhaseData = { ...(e.phaseValues[phaseId] ?? {}), additionalInvestment: value }; + await api.put(`/api/elements/${e.id}/phase/${phaseId}`, merged); + } else if (prevPhase) { + const existing: TransitionData = e.transitionValues[prevPhase.id] ?? {}; + const merged: TransitionData = + t.kind === "amortization" + ? { ...existing, extraAmortization: value } + : { ...existing, immediateRepayment: value }; + await api.put(`/api/elements/${e.id}/transition/${prevPhase.id}`, merged); + } + } + onSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); + setSaving(false); + } + } + + return ( + + {/* Herkunft des Topfs */} +
+
+ Woher das Kapital kommt +
+
+ + {pot.capitalInflow !== 0 && ( + + )} + {pot.oneOffInflow !== 0 && } + {pot.oneOffOutflow !== 0 && } +
+ +
+
+ + {targets.length === 0 ? ( +

+ In dieser Phase gibt es keine Ziele, auf die sich Kapital verteilen lässt. Das Kapital bleibt vollständig + auf dem Cash-Konto. +

+ ) : ( +
+ {targets.map((t) => ( +
+
+ {t.name} + {CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]} + + {t.kind === "investment" ? "Zusatzeinlage" : t.kind === "amortization" ? "Sonderamortisation" : "Sofortige Tilgung"} + + +
+ setDraft((prev) => ({ ...prev, [t.elementId]: v }))} + /> +
+ ))} +
+ )} + + {/* Live-Bilanz */} +
+
+ + {pot.allocatedInvestments !== 0 && } + {pot.allocatedRepayments !== 0 && ( + + )} +
+ +
+ {pot.rest < 0 && ( +

+ + Du verteilst mehr, als zur Verfügung steht. Das Cash-Konto startet negativ – das Tool meldet die Phase + als Liquiditätslücke. +

+ )} +
+ + {laterPhases > 0 && ( +
+ +
+ )} + + {error &&

{error}

} +
+ + +
+ + ); +} + +// --- 2. Spar-/Verzehrquote verteilen ------------------------------------------------------ + +interface RateTarget { + elementId: string; + name: string; + category: string; + field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment"; + label: string; + direction: "out" | "in"; + hint: string; +} + +export function RateDistributionDialog({ + plan, + computed, + phaseId, + onClose, + onSaved, +}: { + plan: PlanInput; + computed: ReturnType; + phaseId: string; + onClose: () => void; + onSaved: () => void; +}) { + const phase = computed.phases.find((p) => p.id === phaseId)!; + + const targets = useMemo(() => { + const out: RateTarget[] = []; + for (const e of plan.elements) { + const ce = phase?.elements.find((x) => x.elementId === e.id); + if (!ce || ce.status !== "ACTIVE") continue; + // Die PK ist bewusst NICHT dabei: Ihr Beitrag stammt aus dem Bruttolohn und belastet + // das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen. + if (e.category === "PILLAR_3A") { + out.push({ + elementId: e.id, name: e.name, category: e.category, field: "annualContribution", + label: "Jährliche Einzahlung", direction: "out", + hint: "Fliesst jährlich vom Cash in die Säule 3a.", + }); + } else if (e.category === "OTHER_ASSET") { + out.push({ + elementId: e.id, name: e.name, category: e.category, field: "annualContribution", + label: "Jährlicher Sparbeitrag", direction: "out", + hint: "Fliesst jährlich vom Cash ins Vermögen.", + }); + out.push({ + elementId: e.id, name: e.name, category: e.category, field: "annualWithdrawal", + label: "Jährliche Bezugsrate", direction: "in", + hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.", + }); + } else if (e.category === "REAL_ESTATE") { + out.push({ + elementId: e.id, name: e.name, category: e.category, field: "amortization", + label: "Amortisation pro Jahr", direction: "out", + hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.", + }); + } else if (e.category === "OTHER_DEBT") { + out.push({ + elementId: e.id, name: e.name, category: e.category, field: "annualRepayment", + label: "Tilgung pro Jahr", direction: "out", + hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.", + }); + } + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId + }, [plan.elements, phaseId]); + + const key = (t: RateTarget) => `${t.elementId}:${t.field}`; + + const [draft, setDraft] = useState>(() => { + const d: Record = {}; + for (const t of targets) { + const e = plan.elements.find((x) => x.id === t.elementId)!; + d[key(t)] = Math.round(num((e.phaseValues[phaseId] ?? {})[t.field])); + } + return d; + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const preview = useMemo(() => { + const patches: PhasePatch[] = targets.map((t) => ({ + elementId: t.elementId, + field: t.field, + value: draft[key(t)] ?? 0, + })); + return computePlan(applyPhasePatches(plan, phaseId, patches)).phases.find((p) => p.id === phaseId)!; + }, [draft, plan, phaseId, targets]); + + const q = quotaSummary(preview); + const laterPhases = computed.phases.length - phase.sequenceNumber; + const titel = q.isConsumption ? "Verzehrquote verteilen" : "Sparquote verteilen"; + + async function save() { + setSaving(true); + setError(null); + try { + // Je Element EIN Aufruf, auch wenn mehrere Felder betroffen sind. + const byElement = new Map(); + for (const t of targets) { + const e = plan.elements.find((x) => x.id === t.elementId)!; + const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) }; + (merged as Record)[t.field] = draft[key(t)] ?? 0; + byElement.set(t.elementId, merged); + } + for (const [elementId, data] of byElement) { + await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data); + } + onSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); + setSaving(false); + } + } + + return ( + + {/* Die drei Bezugsgrössen */} +
+
+ {q.isConsumption ? "Deine Verzehrquote" : "Deine Sparquote"} +
+
+ + +
+ +
+ {!q.isConsumption && q.end < q.start && ( +

+ Achtung: Die Quote sinkt von {formatChf(q.start)} auf{" "} + {formatChf(q.end)}. Eine flache Jahresrate über {formatChf(q.end)} lässt sich in den späteren Jahren + nicht mehr aus der laufenden Quote decken – sie zehrt dann am Cash-Bestand. +

+ )} +
+ + {targets.length === 0 ? ( +

+ In dieser Phase gibt es keine Elemente mit jährlichen Raten. Die ganze Quote läuft aufs Cash-Konto. +

+ ) : ( +
+ {targets.map((t) => ( +
+
+ {t.name} + {CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]} + + {t.direction === "in" ? "ins Cash" : "vom Cash"} + + +
+ setDraft((prev) => ({ ...prev, [key(t)]: v }))} + /> +
+ ))} +
+ )} + + {/* Live-Bilanz über die ganze Phase */} +
+
+ + {q.allocatedOut !== 0 && ( + + )} + {q.allocatedIn !== 0 && ( + + )} +
+ + +
+ {preview.cashNegative && ( +

+ + Mit dieser Verteilung fällt das Cash-Konto in dieser Phase unter 0 – das ist eine Liquiditätslücke. +

+ )} +
+ + {laterPhases > 0 && ( +
+ +
+ )} + + {error &&

{error}

} +
+ + +
+ + ); +} diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index c526d7a..f086a6f 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -24,6 +24,8 @@ import { } from "lucide-react"; import { Timeline } from "@/components/Timeline"; import { Sparkline } from "@/components/Sparkline"; +import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs"; +import { capitalPot } from "@/lib/distribution"; import { Tour, TOUR_DONE_KEY } from "@/components/Tour"; import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui"; import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView"; @@ -141,6 +143,9 @@ export function PlanView({ const [showAddPhase, setShowAddPhase] = useState(false); const [reviewFromPhaseId, setReviewFromPhaseId] = useState(null); const [showTour, setShowTour] = useState(false); + // Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup, + // weil beide mehrere Elemente auf einmal bearbeiten. + const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null); const [valueMode, setValueMode] = useState("nominal"); // Tour beim ersten Besuch eines Plans mit Phasen automatisch starten. @@ -484,6 +489,10 @@ export function PlanView({ diffKind={diff?.phaseHeader.get(col.phase.id) ?? null} onClick={() => setPanel({ kind: "phase", phaseId: col.phase.id })} onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })} + onDistributeCapital={ + col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null + } + onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })} active={panel?.kind === "phase" && panel.phaseId === col.phase.id} /> ) : ( @@ -768,6 +777,36 @@ export function PlanView({ ); })()} + {distribute?.kind === "capital" && ( + setDistribute(null)} + onSaved={() => { + setDistribute(null); + toast("success", "Kapital verteilt."); + onChanged(); + }} + /> + )} + + {distribute?.kind === "rates" && ( + setDistribute(null)} + onSaved={() => { + setDistribute(null); + toast("success", "Raten gespeichert."); + onChanged(); + }} + /> + )} + {showTour && setShowTour(false)} />}
); @@ -932,12 +971,60 @@ const VALUE_MODE_KEY = "fpt-value-mode"; function realOf(nominal: number, deflator: number): number { return Math.round(nominal / (deflator || 1)); } + +// Reiner Text (fuer Titel/Tooltips und einzeilige Faelle). function valStr(nominal: number, deflator: number, mode: ValueMode): string { if (mode === "real") return formatChf(realOf(nominal, deflator)); if (mode === "both") return `${formatChf(nominal)} (${formatChf(realOf(nominal, deflator))})`; return formatChf(nominal); } +// Im Modus "Beide" steht der Realwert in Klammern in einer EIGENEN Zeile unter dem nominalen +// Wert -- nebeneinander wird die Zeile zu lang und die Spalten unnoetig breit. Der Pfeil +// wiederholt sich auf der zweiten Zeile, damit der Zeitbezug Start -> Ende erhalten bleibt. +function ValuePair({ + start, + end, + deflatorStart, + deflatorEnd, + mode, +}: { + start: number; + end: number; + deflatorStart: number; + deflatorEnd: number; + mode: ValueMode; +}) { + const arrow = ; + return ( + + + {mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "} + {mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)} + + {mode === "both" && ( + + ({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))}) + + )} + + ); +} + +// Einzelwert, gleiche Konvention. +function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) { + return ( + + + {mode === "real" ? formatChf(realOf(value, deflator)) : formatChf(value)} + + {mode === "both" && ( + ({formatChf(realOf(value, deflator))}) + )} + + ); +} + function phaseCellContent( ce: ReturnType | undefined, phase: PhaseComputed, @@ -949,14 +1036,17 @@ function phaseCellContent( const dS = phase.cumulativeInflationStart; const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd; if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) { - return ( - - {valStr(ce.startValue, dS, mode)} {valStr(ce.endValue, dE, mode)} - - ); + return ; } if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) { - return `Rente ${valStr(ce.startValue, dS, mode)}`; + return ( + + Rente {mode === "real" ? formatChf(realOf(ce.startValue, dS)) : formatChf(ce.startValue)} + {mode === "both" && ( + ({formatChf(realOf(ce.startValue, dS))}) + )} + + ); } return ce.summary || "–"; } @@ -968,6 +1058,8 @@ function PhaseHeader({ diffKind, onClick, onExpand, + onDistributeCapital, + onDistributeRates, active, }: { phase: PhaseComputed; @@ -976,9 +1068,14 @@ function PhaseHeader({ diffKind: "changed" | "added" | "removed" | null; onClick: () => void; onExpand: () => void; + // null = Phase 1: dort gibt es kein verteilbares Übergangs-Kapital (additionalInvestment + // wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert). + onDistributeCapital: (() => void) | null; + onDistributeRates: () => void; active: boolean; }) { - const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote"; + const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote"; + const pot = capitalPot(phase); const dS = phase.cumulativeInflationStart; const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen) const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote) @@ -1026,27 +1123,84 @@ function PhaseHeader({ {phase.durationYears} J.
-
- {phase.persons.map((p) => ( -
- {personLabel(p.role)} {p.startAge} → {p.endAge} +
+
+ {phase.persons.map((p) => ( +
+ {personLabel(p.role)} {p.startAge} → {p.endAge} +
+ ))} +
+ + {/* Verfügbares Kapital -- die Grösse, die man für die Planung DIESER Phase braucht. + Nur zeigen, wenn überhaupt etwas da ist. */} + {onDistributeCapital && pot.total > 0 && ( +
+
+ Verfügbares Kapital + +
+ {pot.allocatedInvestments + pot.allocatedRepayments > 0 && ( +
+ davon verteilt + +
+ )} +
+ Rest auf Cash + +
+
- ))} -
- {quotaLabel} {valStr(phase.quotaStart, dS, mode)} → {valStr(phase.quotaEnd, dF, mode)} + )} + + {/* Spar- bzw. Verzehrquote */} +
+
+ {quotaLabel} + +
+
-
- Vermögen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)} + +
+ Vermögen +
+ {(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && ( -
phase.oneOffInflow ? "text-danger" : "text-success"}> +
phase.oneOffInflow ? "text-danger" : "text-success"}`}> {phase.oneOffInflow > 0 ? `+ ${phase.oneOffInflowLabel ?? "Zufluss"}` : ""} {phase.oneOffInflow > 0 && phase.oneOffOutflow > 0 ? " · " : ""} {phase.oneOffOutflow > 0 ? `− ${phase.oneOffOutflowLabel ?? "Kosten"}` : ""}
)} - {/* Alles Weitere (Einkommen, Ausgaben, Raten, Kapitalflüsse) steht in der - Detailansicht -- erreichbar über das Expand-Icon oben. */} + {/* Alles Weitere (Einkommen, Ausgaben, Raten im Detail, Kapitalinvestitionen) steht + in der Detailansicht -- erreichbar über das Expand-Icon oben. */}
); diff --git a/src/lib/distribution.test.ts b/src/lib/distribution.test.ts new file mode 100644 index 0000000..c253534 --- /dev/null +++ b/src/lib/distribution.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from "vitest"; +import { computePlan } from "@/lib/calculations"; +import { + applyPhasePatches, + applyTransitionPatches, + capitalPot, + quotaSummary, +} from "@/lib/distribution"; +import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; +import type { PlanInput } from "@/lib/types"; + +let idc = 0; +const nid = () => `d${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; + initialCash?: number; + phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[]; + elements: ReturnType[]; +}): PlanInput { + return { + id: "plan", + name: "T", + householdType: "SINGLE", + inflationRateDefault: 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, + }; +} + +// Zwei Phasen; am Übergang wird Vermögen verkauft und eine Erbschaft fliesst zu -- damit +// entsteht in Phase 2 ein verteilbarer Kapitaltopf. +function basePlan() { + const asset = el("OTHER_ASSET", "HOUSEHOLD", { + p1: { startValue: 200000, expectedReturn: 0 }, + p2: { expectedReturn: 0 }, + }, { p1: { decision: "SELL" } }); + const debt = el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000 }, p2: {} }, { p1: {} }); + const invest = el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: { expectedReturn: 0 } }); + return { + p: plan({ + age: 40, + retirementAge: 70, + initialCash: 10000, + phases: [ + { id: "p1", durationYears: 5, cashTransition: { mode: "INFLOW", inflowAmount: 100000, inflowTaxRate: 0 } }, + { id: "p2", durationYears: 5 }, + ], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }), + asset, + debt, + invest, + ], + }), + assetId: asset.id, + debtId: debt.id, + investId: invest.id, + }; +} + +describe("Verteilung: verfügbares Kapital", () => { + it("Topf setzt sich aus Vorphasen-Cash, Kapitalzufluss und Einmalzufluss zusammen", () => { + const { p } = basePlan(); + const r = computePlan(p); + const pot = capitalPot(r.phases[1]); + + // Der Topf ist die Summe der Zuflüsse minus einmaliger Kosten. + expect(pot.total).toBe(pot.fromPreviousCash + pot.capitalInflow + pot.oneOffInflow - pot.oneOffOutflow); + expect(pot.capitalInflow).toBe(200000); // Verkauf des Vermögens + expect(pot.oneOffInflow).toBe(100000); // Erbschaft, steuerfrei + // Nichts verteilt -> alles bleibt auf dem Cash. + expect(pot.allocatedInvestments).toBe(0); + expect(pot.allocatedRepayments).toBe(0); + expect(pot.rest).toBe(pot.total); + expect(pot.rest).toBe(r.phases[1].cashStart); + }); + + it("Zusatzeinlage verschiebt den Topf von Cash ins Vermögen, ohne ihn zu verändern", () => { + const { p, investId } = basePlan(); + const vorher = capitalPot(computePlan(p).phases[1]); + + const nachher = capitalPot( + computePlan(applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 150000 }])) + .phases[1] + ); + + expect(nachher.total).toBe(vorher.total); // der Topf selbst bleibt gleich gross + expect(nachher.allocatedInvestments).toBe(150000); + expect(nachher.rest).toBe(vorher.rest - 150000); + // Kontrolle: Topf = verteilt + Rest + expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total); + }); + + it("Sofort-Tilgung am Übergang zählt als verteiltes Kapital", () => { + const { p, debtId } = basePlan(); + const nachher = capitalPot( + computePlan(applyTransitionPatches(p, "p1", [{ elementId: debtId, field: "immediateRepayment", value: 40000 }])) + .phases[1] + ); + expect(nachher.allocatedRepayments).toBe(40000); + expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total); + }); + + it("Überverteilung drückt den Rest ins Minus (Liquiditätslücke)", () => { + const { p, investId } = basePlan(); + const r = computePlan( + applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 999999 }]) + ); + const pot = capitalPot(r.phases[1]); + expect(pot.rest).toBeLessThan(0); + expect(r.phases[1].cashNegative).toBe(true); + }); +}); + +describe("Verteilung: Spar-/Verzehrquote", () => { + it("absolute Quote ist die Summe über alle Phasenjahre -- und die Quote SINKT", () => { + const { p } = basePlan(); + const r = computePlan(p); + const q = quotaSummary(r.phases[0]); + + // Genau der Grund, warum eine flache Jahresrate gefährlich ist: Das Einkommen bleibt bei + // 0 % Lohnerhöhung nominal konstant, die real erfassten Ausgaben wachsen aber mit der + // Inflation. Die Quote schrumpft dadurch über die Phase. + expect(q.start).toBe(20000); + expect(q.end).toBeLessThan(q.start); + expect(q.end).toBe(Math.round(100000 - 80000 * Math.pow(1.015, 4))); + + // Die absolute Quote liegt folgerichtig zwischen "5 x letztes Jahr" und "5 x erstes Jahr". + expect(q.total).toBeGreaterThan(5 * q.end); + expect(q.total).toBeLessThan(5 * q.start); + // Ohne verteilte Raten geht die ganze Quote aufs Cash. + expect(q.netToCash).toBe(q.total); + expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart); + expect(q.isConsumption).toBe(false); + }); + + it("verteilte Sparraten mindern den Rest, der aufs Cash geht", () => { + const { p, investId } = basePlan(); + const r = computePlan( + applyPhasePatches(p, "p1", [{ elementId: investId, field: "annualContribution", value: 15000 }]) + ); + const q = quotaSummary(r.phases[0]); + expect(q.allocatedOut).toBe(75000); // 15'000 x 5 Jahre + expect(q.netToCash).toBe(q.total - q.allocatedOut + q.allocatedIn); + // Kontrolle gegen die offiziellen Kennzahlen der Phase. + expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart); + }); + + it("Bezugsraten zählen als Zufluss und werden am Bestand gekappt", () => { + // Verzehrphase: Ausgaben über Einkommen, finanziert aus dem Vermögen. + const p = plan({ + age: 66, + retirementAge: 65, + initialCash: 0, + phases: [{ id: "p1", durationYears: 5 }], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 40000, teuerungsausgleich: 0 } }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 70000, teuerungsausgleich: 0 } }), + el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0, annualWithdrawal: 30000 } }), + ], + }); + const r = computePlan(p); + const q = quotaSummary(r.phases[0]); + expect(q.isConsumption).toBe(true); + expect(q.total).toBeLessThan(0); + // Der Bestand von 50'000 lässt nur 50'000 Entnahme zu, nicht 5 x 30'000. + expect(q.allocatedIn).toBe(50000); + expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart); + }); +}); + +describe("Verteilung: Entwurfswerte anwenden", () => { + it("lässt den Ausgangsplan unberührt und erhält bestehende Felder", () => { + const { p, assetId, investId } = basePlan(); + const snapshot = JSON.stringify(p); + + const geaendert = applyPhasePatches(p, "p2", [ + { elementId: investId, field: "additionalInvestment", value: 1000 }, + ]); + expect(JSON.stringify(p)).toBe(snapshot); + // Bestehendes Feld derselben Phase bleibt erhalten. + expect(geaendert.elements.find((e) => e.id === investId)!.phaseValues.p2.expectedReturn).toBe(0); + + // Übergangs-Entscheide dürfen nicht verloren gehen. + const mitTilgung = applyTransitionPatches(p, "p1", [ + { elementId: assetId, field: "partialSaleAmount", value: 500 }, + ]); + expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.decision).toBe("SELL"); + expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.partialSaleAmount).toBe(500); + }); +}); diff --git a/src/lib/distribution.ts b/src/lib/distribution.ts new file mode 100644 index 0000000..3af740a --- /dev/null +++ b/src/lib/distribution.ts @@ -0,0 +1,144 @@ +// Verteilung von verfügbarem Kapital und laufender Spar-/Verzehrquote. +// +// Reine Orchestrierung: Beide Werkzeuge schreiben ausschliesslich Felder, die es längst gibt +// (additionalInvestment, extraAmortization, immediateRepayment, annualContribution, +// annualWithdrawal, amortization, annualRepayment). An der Berechnung ändert sich nichts -- +// die Vorschau entsteht, indem der Plan mit den Entwurfswerten kopiert und erneut durch +// computePlan geschickt wird. Dadurch ist die angezeigte Wirkung per Konstruktion exakt die +// spätere, inklusive aller Kappungen (z. B. Bezugsrate am Bestand, Amortisation an der +// Restschuld). + +import type { PhaseComputed } from "@/lib/calculations"; +import type { PhaseData, TransitionData } from "@/lib/elements"; +import type { PlanInput } from "@/lib/types"; + +// --- Verfügbares Kapital ------------------------------------------------------------------ +// +// Der "Topf" ist das Kapital, das beim Übergang IN diese Phase zur Verfügung steht -- also +// bevor etwas davon investiert oder in Schulden gesteckt wurde. Alle Bestandteile stammen aus +// der Cash-Brücke (seit 0.11), es braucht keine zusätzliche Berechnung: +// +// Topf = Cash-Ende der Vorphase + Kapitalzufluss + einmaliger Zufluss − einmalige Kosten +// = cashStart + Investitionen + Sofort-Tilgungen +// +// Die zweite Form ist die, die hier verwendet wird: Sie zerlegt den Topf direkt in +// "schon verteilt" und "Rest auf Cash". +export interface CapitalPot { + total: number; // gesamter verteilbarer Betrag + fromPreviousCash: number; // Cash-Endbestand der Vorphase + capitalInflow: number; // Verkäufe, PK-/3a-Bezüge + oneOffInflow: number; // Erbschaft o. ä. (netto nach Steuer) + oneOffOutflow: number; // einmalige Kosten (mindern den Topf) + allocatedInvestments: number; // Zusatzeinlagen in PK/3a/Vermögen + allocatedRepayments: number; // Sonderamortisation + Sofort-Tilgung + rest: number; // bleibt auf dem Cash-Konto (= cashStart) +} + +export function capitalPot(phase: PhaseComputed): CapitalPot { + const c = phase.cashBridge; + return { + total: c.cashStart + c.investments + c.immediateRepay, + fromPreviousCash: c.openingCash, + capitalInflow: c.capitalInflow, + oneOffInflow: c.oneOffInflow, + oneOffOutflow: c.oneOffOutflow, + allocatedInvestments: c.investments, + allocatedRepayments: c.immediateRepay, + rest: c.cashStart, + }; +} + +// --- Spar-/Verzehrquote ------------------------------------------------------------------- +// +// Die Quote ist KEIN fester Betrag: Einkommen wächst mit der Lohnerhöhung, Ausgaben mit der +// Inflation. Deshalb werden drei Grössen ausgewiesen -- erstes Jahr, letztes Jahr und die +// Summe über die ganze Phase ("absolute Quote"). Die Summe ist die Grösse, gegen die sich +// eine flache Jahresrate sinnvoll verteilen lässt. +// +// Hinweis zur Einheit: `total` summiert nominale Franken verschiedener Jahre -- exakt so, wie +// das Cash-Konto im Modell funktioniert. Für die Frage "wie viel Cash steht über die Phase +// zur Verfügung" ist das die richtige Grösse, als Kaufkraft-Aussage taugt sie nicht. +export interface QuotaSummary { + start: number; // Quote im ersten Phasenjahr + end: number; // Quote im letzten Phasenjahr + total: number; // Summe über alle Phasenjahre + allocatedOut: number; // Sparbeiträge + Amortisationen + Tilgungen, über die Phase summiert + allocatedIn: number; // Bezugsraten, über die Phase summiert + netToCash: number; // was unter dem Strich aufs Cash geht (= cashEnd − cashStart) + isConsumption: boolean; +} + +export function quotaSummary(phase: PhaseComputed): QuotaSummary { + const c = phase.cashBridge; + const allocatedOut = c.savingRates + c.debtRates; + return { + start: phase.quotaStart, + end: phase.quotaEnd, + total: c.quotaTotal, + allocatedOut, + allocatedIn: c.withdrawals, + netToCash: c.quotaTotal - allocatedOut + c.withdrawals, + isConsumption: c.quotaTotal < 0, + }; +} + +// --- Entwurfswerte auf einen Plan anwenden (für die Live-Vorschau) ------------------------ +// Beide Funktionen sind rein und lassen den Ausgangsplan unberührt. + +export interface PhasePatch { + elementId: string; + field: keyof PhaseData; + value: number; +} + +export function applyPhasePatches(plan: PlanInput, phaseId: string, patches: PhasePatch[]): PlanInput { + if (patches.length === 0) return plan; + const byElement = new Map(); + for (const p of patches) { + const list = byElement.get(p.elementId) ?? []; + list.push(p); + byElement.set(p.elementId, list); + } + return { + ...plan, + elements: plan.elements.map((e) => { + const list = byElement.get(e.id); + if (!list) return e; + const merged: PhaseData = { ...(e.phaseValues[phaseId] ?? {}) }; + for (const p of list) (merged as Record)[p.field as string] = p.value; + return { ...e, phaseValues: { ...e.phaseValues, [phaseId]: merged } }; + }), + }; +} + +export interface TransitionPatch { + elementId: string; + field: keyof TransitionData; + value: number; +} + +export function applyTransitionPatches( + plan: PlanInput, + fromPhaseId: string, + patches: TransitionPatch[] +): PlanInput { + if (patches.length === 0) return plan; + const byElement = new Map(); + for (const p of patches) { + const list = byElement.get(p.elementId) ?? []; + list.push(p); + byElement.set(p.elementId, list); + } + return { + ...plan, + elements: plan.elements.map((e) => { + const list = byElement.get(e.id); + if (!list) return e; + // Bestehende Entscheide (decision, salePrice, payoutMode …) bleiben erhalten -- hier + // werden nur die Betragsfelder überschrieben. + const merged: TransitionData = { ...(e.transitionValues[fromPhaseId] ?? {}) }; + for (const p of list) (merged as Record)[p.field as string] = p.value; + return { ...e, transitionValues: { ...e.transitionValues, [fromPhaseId]: merged } }; + }), + }; +}