diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index a124aac..e0a6e38 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.32 | +| **Version** | 0.33 | | **Datum** | 2026-07-25 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `b42f9b3` inkl. Modul-Review 3 (Nachbesserungen) (Branch `main`) | +| **Codestand** | Arbeitsstand nach `0761f6b` inkl. Modul-Review 4 (Matrix) (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.33 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4 (Matrix: Phasen und Elemente).** (1) **Kapitalverwendung neu am Vorsorge-Element** (Punkt C aus Roadmap Nr. 44): Die Prozent-Aufteilung des bezogenen Alterskapitals hing am **Cash-Übergang** – dem falschen Ort, denn mit zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. Sie steht jetzt beim **Bezugs-Entscheid** der Pensionskasse (nur bei Kapitalbezug) bzw. der **Säule 3a**. Beide Dialoge führen neu **brutto → Steuersatz → netto** und darunter die Verteilung. Der zugeteilte Betrag fliesst über den regulären Weg (`Carry.capitalIn` → Zusatzeinlage der Folgephase) und ist damit **überall sichtbar**: am Ziel-Element, in der Cash-Brücke als Investition und im «Kapital verteilen»-Dialog. Vorher erhöhte er still den Bestand, weshalb Element und Dialog eine **0** zeigten. Die **Säule 3a** ist am Pensions-Übergang neu ein **offener Entscheid** (Steuersatz und Verwendung); vorher galt sie als automatisch beantwortet. (2) **Phasendauer: die Folgephase gleicht aus** (Kap. 3.3.2). Bis 0.32 prüfte die Kappung nur die **bearbeitete** Phase – wurde Phase 1 von 10 auf 12 Jahre verlängert, überspannte danach Phase 2 die Pensionierung, und die tragende Invariante aus Roadmap Nr. 44 kippte. Neu trägt die Folgephase die Differenz (Gesamtdauer bleibt gleich, wie beim Verschieben des Pensionsalters); passt sie nicht, wird blockiert; vorher erscheint eine Rückfrage. Neue reine Funktion `planDurationChange`. (3) **Element und Phase direkt bedienbar:** In der Matrix tragen Element-Zeile und Phasenkopf neu **Stift** (umbenennen, beim Element inkl. **Zuordnung**) und **Papierkorb**; das Expand-Symbol ist **immer** sichtbar statt nur bei Mouseover. `PATCH /api/elements/` nimmt dafür neu auch `ownerRole` (bleibt für AHV/PK/3a personengebunden). (4) **Hilfetexte** werden über ein **Portal** gezeichnet – in scrollenden Dialogen schnitt der Container sie vorher ab; sie klappen nach oben, wenn unten kein Platz ist. (5) **Verteil-Dialoge:** Zeilen zeigen die **Zuordnung** (Person A/B/Gemeinsam) und sind nach **«vom Cash»/«ins Cash»** gruppiert; die Vorbelegung nutzt neu den **effektiven** Wert inklusive Vererbung aus der Vorphase – ein geerbter 3a-Beitrag erschien vorher als 0. (6) **Matrix:** alle Phasenspalten **gleich breit**, bei vielen Phasen wird horizontal gescrollt; **«Alle auf-/zuklappen»**; eine zugeklappte Kategorie zeigt je Phase die **Summe** ihrer Elemente. (7) **Phasen-Detailansicht** nutzt die neue Aufteilungs-Grafik (Fläche + Ring) statt der alten Balken. (8) **Übersicht:** «Leer starten» steht neu auch im leeren Zustand zur Wahl. (9) Nebenbei: dritte vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3äd`) repariert, das Phasen-Panel nutzt den eigenen Bestätigungs-Dialog statt `window.confirm`. 10 Tests ergänzt (278 → 288). | | 0.32 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3, Nachbesserungen – darunter ein gravierender Rechenfehler bei den effektiven Werten.** (1) **Immobilien-Bugfix (Kap. 3.9):** Der Ist-Wizard belegte den Immobilienwert mit dem **Eigenkapital** vor (`ElementYearPoint.value`), während Erfassung und Rechenkern den **Verkehrswert** erwarten. Der Rechenkern setzte den vorbelegten Wert als Verkehrswert ein, liess die Hypothek aber stehen – das Eigenkapital brach im Ist-Jahr schlagartig ein, typischerweise ins Negative. Sichtbar wurde das als **negative Gesamt-Abweichung, obwohl nur ein Lohn erhöht** wurde, und als «wegbrechendes» Wohneigentum in der Vermögensaufteilung. Neu wird `propertyValue` vorbelegt; das Feld ist als «Verkehrswert + Restschuld» beschriftet. Drei Regressionstests. (2) **Ist-Datensätze bearbeitbar:** Ein Klick auf die Zeile (oder «Bearbeiten») öffnet den erfassten Satz erneut; neuer Endpunkt `PUT /api/plans//actuals/`. Beim Bearbeiten überschreiben die Planwerte die erfassten Zahlen nicht mehr. (3) **Ring-Klick in der Vermögensaufteilung repariert:** Recharts 3 reicht im Klick-Parameter **kein `activePayload`** mehr durch (nur noch `activeIndex`) – der Handler feuerte nie, der Ring zeigte immer das Planende. (4) **Seitenleiste sauber dreistufig:** Ebene 1 Pläne, Ebene 2 die vier Bereiche (Szenarien, Effektive Werte, Analysen, Berichte) mit **bündigen Symbolen**, Ebene 3 nur die Szenarien – verschachtelt nach Herkunft. (5) Die **Szenario-Liste** zeigt neben der Version deren **Kommentar**. | | 0.31 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3 (Plan-/Szenario-Struktur, Dashboard, Grafiken).** (1) **Versionierung startet bei 0.1** statt 1.0 (Kap. 3.8): Ein Szenario läuft in der 0er-Reihe (0.1, 0.2, … 0.137), bis eine **Hauptversion** gesetzt wird – erst dann entsteht 1.0. Vorher begann jedes Szenario bereits bei 1.0, wodurch die Hauptversion ihre Bedeutung verlor. Die Szenario-Liste zeigt neu die **echte** Version statt «1.x», dazu eine Spalte **Phasen**. (2) **Plan-Dashboard:** Kacheln sind **anklickbar** und führen in ihren Bereich, neu inkl. **Berichte**; der Plan lässt sich über ein Stift-Symbol **umbenennen**; die Ist-Abweichung nennt das **Jahr** des jüngsten Ist-Datensatzes und ist bei einer positiven Abweichung **grün** statt rot. (3) **Szenario-Liste:** Ein Klick auf die **Zeile** öffnet die Matrix (der «Matrix»-Knopf entfällt), dazu je Zeile **Kopie** und **Löschen**; die Kopiervorlage ist damit frei wählbar und nicht mehr auf das Basisszenario festgelegt. Nach einer Löschung lädt die Liste neu (zeigte vorher den alten Stand). (4) **Seitenleiste:** Szenarien werden wieder **verschachtelt** dargestellt (Tiefe = Herkunftskette); «Effektive Werte», «Analysen» und «Berichte» stehen neu **bündig zum Knoten «Szenarien»** statt auf Höhe der einzelnen Szenarien. (5) **CSV-Export vollständig neu** (Kap. 3.6.5, neues Modul `csv.ts`): vier Blöcke – Kopf, Lebensphasen, **die ganze Matrix** (Elemente × Phasen mit Beginn/Ende und den Übergangs-Entscheiden im Klartext) und **Jahreswerte**; mit BOM, damit Excel die Umlaute erkennt. Vorher enthielt die Datei kein einziges finanzielles Element. (6) **Grafiken:** Ein **Szenario-Wähler** gilt neu für **alle drei** Grafiken (vorher nur der Vermögensverlauf, und der nur additiv). Der **Vergleichs-Fehler** ist behoben: `WealthChart` benutzte den Szenario-**Namen** als Datenschlüssel, wodurch sich gleichnamige Szenarien gegenseitig überschrieben (Legende zeigte beide, der Chart nur eine) – neu die **ID**; die stille Deckelung auf vier Serien entfällt. Die **Legende** ist eigenständig, erlaubt eine **freie Farbwahl je Serie** und erklärt den Linienstil (gestrichelt = Plan, durchgezogen = effektiv). Die **Vermögensaufteilung** ist neu eine **gestapelte Fläche über alle Planjahre** plus ein **Ring** für die relative Aufteilung zu einem wählbaren Zeitpunkt (vorher gestapelte Balken je Phase mit schräger Beschriftung). **Alle Diagrammfarben** kommen aus neuen Theme-Tokens (`--chart-1` … `--chart-6`, `--chart-grid`) statt fester Hex-Werte. (7) **Dokumentation nachgezogen:** Die Kapitel 2.1, 3.2.2–3.2.7 und 3.10 beschrieben noch den Stand **vor V7** (Grundprofil am Szenario, `parentPlanId`, `Scenario.startYear`, `window.confirm`, drei Sidebar-Unterpunkte). (8) Nebenbei: verstümmelte Hex-Farbe `--danger-soft` im Warm-Schema repariert, deutsche Plural-/Umlautfehler in den Übersichts-Kacheln, Dateiname des CSV-Exports transliteriert Umlaute statt sie zu `_` zu machen. 8 Tests ergänzt (267 → 275). | | 0.30 | 2026-07-24 | Claude (Opus 4.8) | **Tour-Korrekturen und 3a-Verschiebung.** (1) Der Tour-**Spotlight** wird neu aus **vier fixed-Flächen** um die Bounding-Box des Ziels gezeichnet (Kap. 9.24) statt aus einem `box-shadow`-Trick. Grund: Der Schatten liess sticky Matrix-Köpfe (hoher z-index) hell durchscheinen und wurde im Matrix-Scrollbereich abgeschnitten (dann blieb fast alles hell). Die vier Flächen funktionieren unabhängig von z-index und overflow und folgen dem Ziel per `requestAnimationFrame`. (2) **Tour-Schritte** überarbeitet: neu erklärt sind **Zeitachse** und **Endvermögen**; der vormals «Analysen»-Schritt beschreibt jetzt korrekt die **obere Funktions-Leiste** (die Analyse-Werkzeuge sind dort nicht mehr), und ein neuer Schritt zeigt das **linke Menü** (Analysen, Berichte, Effektive Werte). Unsichtbare Ziele (z. B. das Menü auf schmalen Screens) werden übersprungen. (3) Der Schalter **«Selbstständig – grosse Säule 3a»** wandert im Assistenten von Schritt 4 zu **Schritt 5**, weil er die 3a-Einzahlung (also die Sparraten-Verteilung) betrifft. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. | @@ -528,32 +529,47 @@ und die AHV-/PK-Renten am Phasenbeginn ausgewertet werden. mindestens eine Person pensioniert ist, sonst „Erwerbsphase". **Vorbelegung der Elemente:** Beim Anlegen einer Phase wird für jedes noch aktive Element -(nicht `SOLD`, nicht `SETTLED`) ein `ElementPhaseValue` mit den **editierbaren** Feldern der -Vorphase erzeugt (`buildCarryData`): +(nicht `SOLD`, nicht `SETTLED`) ein `ElementPhaseValue` erzeugt – seit 0.28 aber fast **leer** +(`buildCarryData`): -| Kategorie | Übernommene Felder | +| Kategorie | Kopierte Felder | |---|---| -| `INCOME`, `EXPENSE` | nur `teuerungsausgleich` (Basis wird live fortgeschrieben) | -| `AHV` | `gapYears: 0` | -| `PENSION_FUND`, `PILLAR_3A`, `OTHER_ASSET` | `annualContribution`, `expectedReturn` | -| `REAL_ESTATE` | `purchasePrice`, `amortization` (Resthypothek wird live fortgeschrieben) | -| `OTHER_DEBT` | `annualRepayment` | +| `AHV` | `gapYears: 0` – Ausfalljahre gelten für genau eine Phase, ein geerbter Wert würde eine Lücke erfinden | +| `REAL_ESTATE` | `purchasePrice` (eine Tatsache, Basis der Grundstückgewinnsteuer) und `interestHandling` (ein Schalter ohne Zahlenwert) | +| alle übrigen | **nichts** | -Bestände (PK-/3a-/Vermögenswert, Resthypothek, Restschuld) werden **bewusst nicht als Snapshot -gespeichert**, sondern in jeder Berechnung live aus der Vorphase fortgeschrieben. Damit wirken -sich nachträgliche Änderungen an frühen Phasen automatisch auf alle Folgephasen aus. +Alles andere wird **nicht kopiert, sondern live vererbt**: Beträge und Bestände werden aus der +Vorphase fortgeschrieben, die **Wiederkehr-Parameter** (Raten, Beiträge, Amortisation, +Wertsteigerung, Zinssatz) gelten weiter, solange das Feld leer bleibt +([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)). Der Unterschied zeigt sich, sobald man eine +frühe Phase nachträglich ändert: Eine Kopie bliebe stehen, die Vererbung zieht mit. -Referenz: `src/app/api/plans/[planId]/phases/route.ts`. +Referenz: `src/app/api/scenarios/[scenarioId]/phases/route.ts`. ### 3.3.2 Phase bearbeiten -Klick auf einen Phasenkopf öffnet ein **Popup** („Lebensphase: ") mit Bezeichnung und -Dauer – konsistent zu allen anderen Eingaben (Element-Zellen, Übergänge). Speichern schliesst -das Popup. Die Dauer wird auch hier gekappt. Eine phasenspezifische Inflationsrate gibt es nicht; -das Panel weist darauf hin: „Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen -gesetzt." +Klick auf einen Phasenkopf (oder auf dessen **Stift-Symbol**) öffnet das Panel „Lebensphase: +" mit Bezeichnung und Dauer. Eine phasenspezifische Inflationsrate gibt es nicht; das +Panel weist darauf hin. -Referenz: `src/components/PhaseDetail.tsx`. +**Die Folgephase gleicht eine geänderte Dauer aus.** Wird eine Phase um N Jahre verlängert, wird +die **nächste** um N Jahre kürzer – die Gesamtdauer des Plans bleibt gleich. Das Panel zeigt die +Auswirkung live und fragt vor dem Speichern nach. + +Warum das nötig ist: Bis 0.32 kappte das Tool nur die **bearbeitete** Phase am nächsten +Pensionsereignis. Das ist richtig, aber nicht ausreichend. Beispiel: Phase 1 (10 J.) + Phase 2 +(10 J.), Pensionierung im Jahr 20. Eine Verlängerung von Phase 1 auf 12 Jahre ist für Phase 1 +zulässig – danach lief Phase 2 aber von Jahr 12 bis 22 und **überspannte die Pensionierung**. +Da `computePlan` die Dauer nicht nachkappt und den Phasentyp am Phasen*beginn* ableitet, wurde +die Pensionierung faktisch übersprungen; die tragende Invariante aus +[4.16.1](#4161-die-tragende-invariante) war verletzt. + +Passt der Ausgleich nicht (die Folgephase fiele unter ein Jahr), wird die Änderung **blockiert** +mit dem Hinweis, um wie viel sie höchstens möglich wäre. Die **letzte** Phase hat keine +Nachfolgerin – sie verlängert oder verkürzt den Plan tatsächlich. + +Referenz: `planDurationChange` in `src/lib/phaseplan.ts`, `src/components/PhaseDetail.tsx`, +`src/app/api/phases/[phaseId]/route.ts` (setzt beide Dauern in einer Transaktion). ### 3.3.3 Phase löschen @@ -561,7 +577,7 @@ Referenz: `src/components/PhaseDetail.tsx`. Phase kann gelöscht werden."). Damit bleibt die Kette der `sequenceNumber` lückenlos. Der Löschen-Button erscheint im Detail-Panel nur bei der letzten Phase. -Referenz: `src/app/api/phases/[phaseId]/route.ts` Zeilen 55–77. +Referenz: `src/app/api/phases/[phaseId]/route.ts`. ## 3.4 Finanzielle Elemente @@ -584,7 +600,7 @@ Kategorien kein `PERSON_A`/`PERSON_B` übergeben → HTTP 400. Für die übrigen Kategorien gilt: fehlt die Zuordnung, wird serverseitig `HOUSEHOLD` gesetzt. -Referenz: `src/lib/elements.ts` Zeilen 19–23, `src/app/api/plans/[planId]/elements/route.ts` Zeilen 43–54. +Referenz: `src/lib/elements.ts` (`PERSON_ONLY_CATEGORIES`), `src/app/api/scenarios/[scenarioId]/elements/route.ts`. ### 3.4.2 Element anlegen @@ -595,17 +611,28 @@ Bezeichnungs-Default ist das Kategorie-Label. `orderIndex` = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe. -Referenz: `src/components/PlanView.tsx` Zeilen 799–947. +Referenz: `AddElementDialog` in `src/components/PlanView.tsx`. ### 3.4.3 Element bearbeiten und löschen -Ein Klick auf eine **Phasenzelle** öffnet den Dialog „Lebensphase: " mit den -kategorie- und kontextabhängigen Feldern (siehe [3.4.4](#344-feldkatalog-je-kategorie)) sowie -dem Button „Element löschen". Löschen entfernt das Element **aus allen Phasen** -(Browser-`confirm()`, dann Cascade auf `ElementPhaseValue` und `ElementTransitionValue`). +Ein Klick auf eine **Phasenzelle** öffnet das Panel mit den kategorie- und kontextabhängigen +Feldern (siehe [3.4.4](#344-feldkatalog-je-kategorie)) – dort stehen die Werte **dieser Phase**. -Umbenennen ist per API möglich (`PATCH /api/elements/`), im aktuellen UI aber nicht -angebunden. +Die Eigenschaften des **Elements selbst** (Bezeichnung, Zuordnung) sind phasenunabhängig und +haben deshalb ihren eigenen Ort: In der Element-Zeile ganz links stehen drei Symbole – +**Stift** (umbenennen und Zuordnung ändern), **Papierkorb** (löschen) und **Expand** +(Detailansicht). Alle drei sind **immer** sichtbar, nicht erst bei Mouseover; auf einem +Touch-Gerät gäbe es sonst keinen Weg dorthin. Denselben Aufbau trägt der **Phasenkopf**. + +Die Zuordnung läuft über `ownerRole`, nicht über eine Person-ID – deshalb übersteht sie auch +das Neuanlegen der Personen im Profil-Dialog. Für AHV, Pensionskasse und Säule 3a bleibt sie +zwingend personengebunden (HTTP 400 bei „Gemeinsam"). + +Löschen entfernt das Element **aus allen Phasen** (eigener Bestätigungs-Dialog, dann Cascade auf +`ElementPhaseValue` und `ElementTransitionValue`). + +Referenz: `PATCH /api/elements/` (Felder `name`, `ownerRole`), `ElementMetaPanel` in +`src/components/PlanView.tsx`. ### 3.4.4 Feldkatalog je Kategorie @@ -614,7 +641,7 @@ Die angezeigten Felder hängen von drei Kontextgrössen ab: - **`ownerWorking`** – ob der zugeordnete Besitzer in dieser Phase erwerbstätig ist - **`durationYears`** – Phasendauer (begrenzt z. B. die Ausfalljahre) -Referenz: `src/components/ElementDetail.tsx` Zeilen 124–340. +Referenz: `ElementPhaseFields` in `src/components/ElementDetail.tsx`. #### INCOME (Einkommen) @@ -669,7 +696,7 @@ Ausgaben beruecksichtigt)" – Lohnabzüge sind im Nettoeinkommen bereits weg. Wie PK, aber: - Die Einzahlung **zählt** zur Sparquote (verlässt das Cash). -- Das Feld ist auf `PILLAR_3A_MAX_ANNUAL` = **7'258 CHF** (2026, mit PK) hart geklammert. +- Das Feld ist auf `PILLAR_3A_MAX_ANNUAL` = **7'258 CHF** (2026, mit PK) hart geklammert – mit dem Schalter «Selbstständig ohne PK» auf `PILLAR_3A_MAX_SELF_EMPLOYED` (Feld `selfEmployed3a`, siehe 4.11). - Bei Pensionierung: „Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen." #### REAL_ESTATE (Immobilie) @@ -947,7 +974,7 @@ in den Modi «Nominal» und «Real» bleibt alles einzeilig. Es werden zwei verschiedene Deflatoren verwendet – siehe [4.5.3](#453-die-drei-deflatoren). -Referenz: `src/components/PlanView.tsx` Zeilen 665–699. +Referenz: `src/components/PlanView.tsx` (Anzeige-Umschalter über der Matrix). ### 3.6.2 Zeitachse @@ -1922,12 +1949,18 @@ Annahme) und die **Zins-Behandlung** (ein Schalter ohne Zahlenwert). Bestehende Pläne verhalten sich unverändert: Dort sind die Werte gespeichert und gewinnen daher gegen die Vererbung, bis man das Häkchen aktiv setzt. -### 3.12.5 Punkt C: Verwendung des Kapitalzuflusses +### 3.12.5 Punkt C: Verwendung des bezogenen Alterskapitals -Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a, -Verkaufserlös). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Im -Cash-Übergang lässt sich deshalb erfassen, wie viel **in Prozent** in die Amortisation der -Hypothek und in eine Anlage fliesst; der Rest bleibt Cash. +Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a). +Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Beim **Bezugs-Entscheid des +jeweiligen Guthabens** lässt sich deshalb erfassen, wie viel **in Prozent** in die Amortisation +der Hypothek und in eine Anlage fliesst; der Rest bleibt Cash. + +Die Frage steht seit 0.33 dort, wo der Bezug entschieden wird – bei der **Pensionskasse** (nur +wenn Kapital bezogen wird) und bei der **Säule 3a**. Bis 0.32 hing sie am **Cash-Übergang**: +Das war der falsche Ort, weil sich mit zwei Guthaben nicht getrennt festlegen liess, welches +wohin fliesst. Beide Dialoge führen deshalb neu in der Reihenfolge, in der man tatsächlich +entscheidet: **Bezugsart → Betrag brutto → Steuersatz → Betrag netto → Verteilung**. **Warum Prozent und nicht Franken:** Verschiebt man das Pensionsalter, ändert sich das bezogene Kapital. Ein Frankenbetrag müsste von Hand nachgezogen werden – und würde bis dahin still eine @@ -1936,7 +1969,17 @@ falsche Aufteilung rechnen. Eine Quote skaliert mit. Die Amortisations-Quote ist am Restsaldo der Hypothek gekappt; ist sie grösser, bleibt der Rest Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive). Beide sind mechanisch nichts Neues – die eine wirkt wie eine Sonderamortisation, die andere wie -eine Zusatzinvestition, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken. +eine Zusatzeinlage, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken. + +**Der zugeteilte Betrag ist überall sichtbar.** Er wandert über `Carry.capitalIn` in die +**Zusatzeinlage der Folgephase** und erscheint dadurch am Ziel-Element (als eigene, read-only +Zeile «Davon aus Kapitalbezug»), in der Cash-Brücke als **Investition** und im Dialog «Kapital +verteilen». Bis 0.32 erhöhte die Verteilung direkt den internen Bestand – Element und Dialog +zeigten deshalb eine **0**, obwohl das Geld geflossen war. + +**Die Säule 3a ist am Pensions-Übergang ein offener Entscheid.** Der Bezug selbst steht fest +(sie wird immer ausbezahlt), zu entscheiden sind Steuersatz und Verwendung. Vorher galt der +Übergang als automatisch beantwortet, wodurch die Verwendungsfrage nie gestellt wurde. Referenz: `src/lib/retirement.ts`, `src/components/RetirementAdjuster.tsx`, `src/components/FormField.tsx` (`InheritableField`). @@ -3529,6 +3572,9 @@ den Wert der Vorphase. Alle übrigen Felder bedeuten «nicht gesetzt = 0» wie b | `extraAmortization` | REAL_ESTATE (Sonderamortisation) | ≥ 0 | | `saleTaxRate` | REAL_ESTATE | 0–100 | | `immediateRepayment` | OTHER_DEBT | ≥ 0 | +| `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A – Anteil des bezogenen Kapitals in die Amortisation | 0–100 | +| `capitalUseInvestPct` | dito – Anteil in eine Anlage | 0–100 | +| `capitalUseTargetElementId` | Ziel der Anlage-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen | ### 5.4.5 JSON-Payload `CashTransitionData` @@ -3542,9 +3588,6 @@ Liegt in `Phase.cashTransition`. Validierung über `cashTransitionSchema`. | `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0–100 | | `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen | | `outflowAmount` | Betrag **real** (heutige Kaufkraft) | ≥ 0 | -| `capitalUseAmortizationPct` | Anteil des Kapitalzuflusses in die Amortisation (Kap. 3.12.5) | 0–100 | -| `capitalUseInvestPct` | Anteil des Kapitalzuflusses in eine Anlage | 0–100 | -| `capitalUseTargetElementId` | Ziel der Anlage-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen | Pro Übergang ist **genau ein** Zufluss und **eine** Kostenposition möglich – siehe [9.7](#97-nur-ein-zufluss-und-eine-kostenposition-pro-übergang). @@ -3950,15 +3993,15 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus | | `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei | | `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung | -| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen | -| `bridges.test.ts` | 13 | Vermögens- und Cash-Brücke gehen über acht Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus; Kapitalverwendung nach Quote (Punkt C) | +| `phaseplan.test.ts` | 14 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen | +| `bridges.test.ts` | 17 | Vermögens- und Cash-Brücke gehen über acht Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus; Kapitalverwendung nach Quote (Punkt C) | | `retirement.test.ts` | 16 | Spielraum und Sperren je Person, Verschiebung ohne Änderung der Gesamtdauer, Wegfall einer Phase, Zusammenführung der Übergangs-Entscheide | | `server-boundary.test.ts` | 1 | statischer Wächter: kein Modul unter `src/lib` importiert aus `src/components` | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) | | `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP | | `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` | -| **Total** | **278** | | +| **Total** | **288** | | ## 8.2 Testfälle diff --git a/src/app/api/elements/[elementId]/route.ts b/src/app/api/elements/[elementId]/route.ts index 5a6d53e..a5ef4ae 100644 --- a/src/app/api/elements/[elementId]/route.ts +++ b/src/app/api/elements/[elementId]/route.ts @@ -4,8 +4,14 @@ import { prisma } from "@/lib/db"; import { getOwnedElement } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { touchScenario } from "@/lib/versioning-db"; +import { PERSON_ONLY_CATEGORIES } from "@/lib/elements"; -const patchSchema = z.object({ name: z.string().min(1).max(120) }); +// Name UND Zuordnung sind änderbar. Die Zuordnung läuft über `ownerRole` (nicht über eine +// Person-ID), deshalb überlebt sie auch ein Neuanlegen der Personen im Profil-Dialog. +const patchSchema = z.object({ + name: z.string().min(1).max(120).optional(), + ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).optional(), +}); export async function PATCH( request: NextRequest, @@ -22,7 +28,20 @@ export async function PATCH( const parsed = patchSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); - await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } }); + // AHV, PK und 3a hängen am individuellen Pensionierungszeitpunkt und müssen deshalb einer + // Person gehören -- «Gemeinsam» wäre dort fachlich sinnlos. + const nextOwner = parsed.data.ownerRole; + if (nextOwner === "HOUSEHOLD" && PERSON_ONLY_CATEGORIES.includes(element.category)) { + return NextResponse.json( + { error: "Diese Kategorie muss einer Person zugeordnet bleiben." }, + { status: 400 } + ); + } + + await prisma.financialElement.update({ + where: { id: element.id }, + data: { name: parsed.data.name ?? undefined, ownerRole: nextOwner ?? undefined }, + }); await touchScenario(element.scenarioId, userId); return NextResponse.json({ ok: true }); } diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts index 8c10da5..afaf98d 100644 --- a/src/app/api/phases/[phaseId]/route.ts +++ b/src/app/api/phases/[phaseId]/route.ts @@ -5,6 +5,7 @@ import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { touchScenario } from "@/lib/versioning-db"; import { maxPhaseDuration } from "@/lib/calculations"; +import { planDurationChange } from "@/lib/phaseplan"; const updatePhaseSchema = z.object({ name: z.string().min(1).max(120).optional(), @@ -27,29 +28,45 @@ export async function PUT( if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); let duration = parsed.data.durationYears; + // Die Folgephase gleicht eine geänderte Dauer aus -- sonst verschieben sich alle späteren + // Phasengrenzen und eine davon überspannt eine Pensionierung (siehe lib/phaseplan.ts). + let neighbour: { id: string; durationYears: number } | null = null; + if (duration != null) { - // Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase). const scenario = await getOwnedScenario(existing.scenarioId, userId); if (scenario) { const planInput = toPlanInput(scenario); + // Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase). const yearsBefore = planInput.phases .filter((p) => p.sequenceNumber < existing.sequenceNumber) .reduce((s, p) => s + p.durationYears, 0); const cap = maxPhaseDuration(planInput.persons, yearsBefore); if (cap != null) duration = Math.min(duration, cap); duration = Math.max(1, duration); + + const change = planDurationChange(planInput.phases, phaseId, duration); + if (!change.ok) return NextResponse.json({ error: change.error }, { status: 400 }); + if (change.neighbourId && change.neighbourNewDuration != null) { + neighbour = { id: change.neighbourId, durationYears: change.neighbourNewDuration }; + } } } - const phase = await prisma.phase.update({ - where: { id: phaseId }, - data: { - name: parsed.data.name ?? undefined, - durationYears: duration ?? undefined, - }, + const phase = await prisma.$transaction(async (tx) => { + const updated = await tx.phase.update({ + where: { id: phaseId }, + data: { + name: parsed.data.name ?? undefined, + durationYears: duration ?? undefined, + }, + }); + if (neighbour) { + await tx.phase.update({ where: { id: neighbour.id }, data: { durationYears: neighbour.durationYears } }); + } + return updated; }); await touchScenario(existing.scenarioId, userId); - return NextResponse.json({ phase: { id: phase.id } }); + return NextResponse.json({ phase: { id: phase.id }, adjustedNeighbour: neighbour }); } // Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt). diff --git a/src/components/AllocationChart.tsx b/src/components/AllocationChart.tsx index 4ac8207..9a0ad04 100644 --- a/src/components/AllocationChart.tsx +++ b/src/components/AllocationChart.tsx @@ -48,12 +48,26 @@ const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"]; // Phasengrenzen als feine senkrechte Linien, // * daneben ein RING für ein wählbares Jahr, der die RELATIVE Aufteilung zeigt. // Ein Klick in die Fläche wählt das Jahr des Rings. -export function AllocationChart({ computed, height = 300 }: { computed: PlanComputed; height?: number }) { +export function AllocationChart({ + computed, + height = 300, + // Auf eine einzelne Lebensphase einschraenken (Detailansicht der Phase). Ohne Angabe wird + // der ganze Plan gezeigt. + onlyPhaseId, +}: { + computed: PlanComputed; + height?: number; + onlyPhaseId?: string; +}) { + const phases = useMemo( + () => (onlyPhaseId ? computed.phases.filter((p) => p.id === onlyPhaseId) : computed.phases), + [computed, onlyPhaseId] + ); // Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen // positiven Wert haben -- in Reihenfolge ihres ersten Auftretens. const assetEls = useMemo(() => { const info = new Map(); - for (const phase of computed.phases) { + for (const phase of phases) { for (const el of phase.elements) { if (!ASSET_CATS.includes(el.category)) continue; const cur = info.get(el.elementId) ?? { name: el.name, any: false }; @@ -63,14 +77,14 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp } } return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name })); - }, [computed]); + }, [phases]); // Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in // `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet. const areaData = useMemo(() => { const byYear = new Map>(); const ageOf = new Map(); - for (const phase of computed.phases) { + for (const phase of phases) { for (const el of phase.elements) { if (!ASSET_CATS.includes(el.category)) continue; for (const y of el.yearly) { @@ -84,10 +98,11 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp return [...byYear.entries()] .sort(([a], [b]) => a - b) .map(([year, values]) => ({ year, age: ageOf.get(year) ?? 0, ...values })); - }, [computed]); + }, [phases]); - // Phasengrenzen (kumulierte Dauer) für die Trennlinien. + // Phasengrenzen (kumulierte Dauer) für die Trennlinien. Bei einer einzelnen Phase gibt es keine. const boundaryAges = useMemo(() => { + if (onlyPhaseId) return []; const out: number[] = []; let acc = 0; for (let i = 0; i < computed.phases.length - 1; i++) { @@ -96,7 +111,7 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp if (typeof age === "number") out.push(age); } return out; - }, [computed, areaData]); + }, [computed, areaData, onlyPhaseId]); // Gewähltes Jahr für den Ring -- Vorgabe: das letzte (Endzustand). const [pickedYear, setPickedYear] = useState(null); @@ -119,7 +134,8 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp {/* Verlauf */}
- Verlauf über alle Planjahre · Klick wählt das Jahr für den Ring + {onlyPhaseId ? "Verlauf in dieser Lebensphase" : "Verlauf über alle Planjahre"}{" "} + · Klick wählt das Jahr für den Ring
diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 8b20c3b..34f19d7 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -635,6 +635,7 @@ function AppShellInner({ username }: { username: string }) { onOpenPlan={(id) => openPlanTab(id, "dashboard")} onCreateGuided={() => setShowWizard(true)} onCreateChooser={() => setShowNewPlanChooser(true)} + onCreateEmpty={() => setShowEmptyPlanDialog(true)} onCreateDemo={handleCreateDemo} onDelete={handleDeletePlan} /> @@ -1060,6 +1061,7 @@ function DashboardHome({ onCreateGuided, onCreateChooser, onCreateDemo, + onCreateEmpty, onDelete, }: { username: string; @@ -1069,6 +1071,7 @@ function DashboardHome({ onCreateGuided: () => void; onCreateChooser: () => void; onCreateDemo: () => void; + onCreateEmpty: () => void; onDelete: (id: string) => void; }) { return ( @@ -1093,6 +1096,10 @@ function DashboardHome({ + {/* Dritter Weg -- sonst gaebe es "Leer starten" nur ueber das Plus in der Seitenleiste. */} + ) : (
diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index 31cabe5..60043b4 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -2,8 +2,6 @@ import { useMemo, useState } from "react"; import { - Bar, - BarChart, CartesianGrid, Legend, Line, @@ -14,6 +12,7 @@ import { YAxis, } from "recharts"; import { BookOpen, X } from "lucide-react"; +import { AllocationChart } from "@/components/AllocationChart"; import { formatChf } from "@/lib/format"; import { CATEGORY_LABELS } from "@/lib/elements"; import type { ElementCategory } from "@/lib/elements"; @@ -523,26 +522,21 @@ export function ElementDetailDialog({ // --- Phasen-Detailansicht ------------------------------------------------------------------ const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"]; -const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"]; export function PhaseDetailDialog({ phase, + computed, isFirst, onClose, onOpenSpec, }: { phase: PhaseComputed; + // Der ganze Plan -- die Aufteilungs-Grafik zeichnet daraus die Jahreswerte dieser Phase. + computed: PlanComputed; isFirst: boolean; onClose: () => void; onOpenSpec?: (anchor: string) => void; }) { - const alloc = useMemo(() => { - const rows = phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)); - return [ - { label: "Beginn", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.startValue)])) }, - { label: "Ende", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.endValue)])) }, - ]; - }, [phase]); const allocEls = useMemo( () => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)), [phase] @@ -559,24 +553,12 @@ export function PhaseDetailDialog({

Vermögensaufteilung

-

Zusammensetzung des Anlagevermögens zu Beginn und am Ende dieser Phase.

+

Zusammensetzung des Anlagevermögens über die Jahre dieser Phase; rechts die relative Aufteilung zu einem wählbaren Zeitpunkt.

{allocEls.length === 0 ? (

In dieser Phase gibt es kein Anlagevermögen.

) : ( -
- - - - - Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> - (typeof v === "number" ? formatChf(v) : v)} /> - - {allocEls.map((el, i) => ( - - ))} - - -
+ // Dieselbe Darstellung wie im Grafiken-Bereich, nur auf diese Phase eingeschränkt. + )}
diff --git a/src/components/DistributionDialogs.tsx b/src/components/DistributionDialogs.tsx index f5b49e6..a41c736 100644 --- a/src/components/DistributionDialogs.tsx +++ b/src/components/DistributionDialogs.tsx @@ -21,7 +21,7 @@ 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 { CATEGORY_LABELS, inheritedPhaseValues, num, type PhaseData, type TransitionData } from "@/lib/elements"; import { applyPhasePatches, applyTransitionPatches, @@ -313,12 +313,22 @@ interface RateTarget { elementId: string; name: string; category: string; + // Zuordnung als Klartext -- ohne sie sind zwei gleichnamige Elemente derselben Kategorie + // (z. B. "ETF" bei Person A und Person B) in der Liste nicht unterscheidbar. + owner: string; field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment"; label: string; direction: "out" | "in"; hint: string; } +// Zuordnung eines Elements als Klartext. +function ownerLabel(plan: PlanInput, role: string | null): string { + if (role !== "PERSON_A" && role !== "PERSON_B") return "Gemeinsam"; + const p = plan.persons.find((x) => x.role === role); + return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B"); +} + export function RateDistributionDialog({ plan, computed, @@ -343,30 +353,30 @@ export function RateDistributionDialog({ // 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", + elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), 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", + elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), 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", + elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), 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", + elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), 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", + elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualRepayment", label: "Tilgung pro Jahr", direction: "out", hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.", }); @@ -378,11 +388,21 @@ export function RateDistributionDialog({ const key = (t: RateTarget) => `${t.elementId}:${t.field}`; + // Vorbelegung mit dem EFFEKTIVEN Wert -- also inklusive der Vererbung aus der Vorphase + // (Roadmap Nr. 44, Punkt A). Bis 0.32 wurde nur der roh gespeicherte Wert der Phase + // gelesen: Eine 3a-Einzahlung von 7'000, die aus Phase 1 geerbt wurde, erschien hier als 0, + // obwohl die Rechnung korrekt mit 7'000 lief. + const orderedPhaseIds = useMemo( + () => [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber).map((p) => p.id), + [plan.phases] + ); 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])); + const own = (e.phaseValues[phaseId] ?? {})[t.field]; + const inherited = inheritedPhaseValues(e.phaseValues, orderedPhaseIds, phaseId)[t.field]; + d[key(t)] = Math.round(typeof own === "number" ? own : num(inherited)); } return d; }); @@ -464,28 +484,41 @@ export function RateDistributionDialog({ 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]} - + {(["out", "in"] as const).map((dir) => { + const rows = targets.filter((t) => t.direction === dir); + if (rows.length === 0) return null; + return ( +
+
- {t.direction === "in" ? "ins Cash" : "vom Cash"} - - + {dir === "in" ? "Ins Cash (Entnahmen)" : "Vom Cash (Einzahlungen und Tilgungen)"} +
+ {rows.map((t) => ( +
+
+ {t.name} + + {CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]} + + {t.owner} + +
+ setDraft((prev) => ({ ...prev, [key(t)]: v }))} + /> +
+ ))}
- setDraft((prev) => ({ ...prev, [key(t)]: v }))} - /> -
- ))} + ); + })}
)} diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 247e7cd..0653e18 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -68,6 +68,11 @@ export interface CellContext { // Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob // das Referenzalter in diese Phase fällt (Kap. 4.4.6). ownerAgeStart: number; + // Mögliche Ziele der Anlage-Quote beim Kapitalbezug (Punkt C): die in der FOLGEphase noch + // aktiven Vermögens-Elemente. + investTargets: { id: string; name: string }[]; + // Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C). + capitalFromTransfer: number; } interface Props { @@ -253,18 +258,10 @@ export function CashTransitionFields({ ct, setC, deflatorEnd, - // Roadmap Nr. 44, Punkt C: Nur am Pensions-Übergang gefragt -- dort kommt das Kapital - // (PK, 3a, Immobilienverkauf) auf einmal herein und will verwendet werden. - showCapitalUse = false, - capitalInflow = 0, - investTargets = [], }: { ct: CashTransitionData; setC: (patch: Partial) => void; deflatorEnd: number; // Bestands-Deflator an der Phasengrenze - showCapitalUse?: boolean; - capitalInflow?: number; - investTargets?: { id: string; name: string }[]; }) { const mode = ct.mode ?? "NONE"; const showIn = mode === "INFLOW" || mode === "BOTH"; @@ -365,39 +362,41 @@ export function CashTransitionFields({ )} - {showCapitalUse && } ); } -// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C). +// Verwendung des BEZOGENEN ALTERSKAPITALS (Roadmap Nr. 44, Punkt C). +// +// Steht am Bezugs-Entscheid des jeweiligen Guthabens (PK bzw. Säule 3a) -- bis 0.32 hing die +// Frage am Cash-Übergang, wo sie sich mit zwei Guthaben gar nicht getrennt beantworten liess. // // In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene // Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin // still eine falsche Aufteilung rechnen. Eine Quote skaliert mit. -function CapitalUseFields({ - ct, - setC, - inflow, +export function CapitalUseFields({ + td, + setT, + netAmount, targets, }: { - ct: CashTransitionData; - setC: (patch: Partial) => void; - inflow: number; + td: TransitionData; + setT: (patch: Partial) => void; + // Netto-Auszahlung, auf die sich die Prozente beziehen. + netAmount: number; targets: { id: string; name: string }[]; }) { - const amort = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct))); - const invest = Math.max(0, Math.min(100 - amort, num(ct.capitalUseInvestPct))); + const amort = Math.max(0, Math.min(100, num(td.capitalUseAmortizationPct))); + const invest = Math.max(0, Math.min(100 - amort, num(td.capitalUseInvestPct))); const cash = Math.max(0, 100 - amort - invest); return ( <> -
- Verwendung des Kapitalzuflusses (PK-Kapital, Säule 3a, - Verkaufserlös). Bei diesem Übergang fliessen{" "} - {formatChf(inflow)} netto herein. Erfasst wird die - Aufteilung in Prozent – so bleibt sie richtig, wenn du das Pensionsalter verschiebst und - sich der Betrag dadurch ändert. Was nicht zugeteilt ist, bleibt Cash. +
+ Wohin fliesst das bezogene Kapital? Von den{" "} + {formatChf(netAmount)} netto teilst du hier in Prozent zu – + so bleibt die Aufteilung richtig, wenn sich der Betrag durch ein anderes Pensionsalter ändert. + Was nicht zugeteilt ist, bleibt auf dem Cash-Konto.
setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })} + onChange={(v) => setT({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })} /> setC({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })} + onChange={(v) => setT({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })} /> {invest > 0 && targets.length > 0 && (
setC({ capitalUseTargetElementId: v })} + value={td.capitalUseTargetElementId ?? targets[0].id} + onChange={(v: string) => setT({ capitalUseTargetElementId: v })} options={targets.map((t) => ({ value: t.id, label: t.name }))} />
)} + {invest > 0 && ( + + )} @@ -795,9 +801,17 @@ export function ElementPhaseFields({ label="Zusatzinvestition aus Kapital (CHF)" help="Neuinvestition aus dem verfügbaren Kapital dieser Phase." value={num(pd.additionalInvestment)} - onChange={(v) => setP({ additionalInvestment: v })} /> + {/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier + erfasst, sondern dort als Quote entschieden (Punkt C). */} + {context.capitalFromTransfer > 0 && ( + + )} ) : ( setP({ startValue: v })} /> @@ -873,9 +887,16 @@ export function ElementTransitionFields({ Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft.

); - case "PENSION_FUND": + case "PENSION_FUND": { if (context.isRetirementTransition) { const mode = td.payoutMode ?? "PENSION"; + const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE); + // Brutto = ganzes Guthaben (Kapitalbezug) bzw. der gewählte Teil (Kombination). + const brutto = + mode === "COMBI" + ? Math.min(context.carriedEndValue, Math.round(num(td.capitalAmount))) + : context.carriedEndValue; + const netto = Math.round(brutto * (1 - taxRate / 100)); return ( <> setT({ conversionRate: v })} /> )} - {(mode === "CAPITAL" || mode === "COMBI") && ( - setT({ capitalTaxRate: v })} /> - )} {mode === "COMBI" && ( setT({ capitalAmount: v })} /> )} + {/* Erst der Betrag, dann die Steuer, dann die Verwendung -- in der Reihenfolge, + in der man die Entscheidung tatsächlich trifft. */} + {(mode === "CAPITAL" || mode === "COMBI") && ( + <> + + setT({ capitalTaxRate: v })} + /> + + + + )} ); } return ; - case "PILLAR_3A": + } + case "PILLAR_3A": { if (context.isRetirementTransition) { + // Die Säule 3a wird bei der Pensionierung IMMER vollständig bezogen -- entschieden + // wird hier der Steuersatz und die Verwendung des Geldes. + const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE); + const brutto = context.carriedEndValue; + const netto = Math.round(brutto * (1 - taxRate / 100)); return ( - setT({ capitalTaxRate: v })} - /> + <> +

+ Die Säule 3a wird bei der Pensionierung vollständig bezogen. Zu + entscheiden sind der Steuersatz und die Verwendung des Geldes. +

+ + setT({ capitalTaxRate: v })} + /> + + + ); } return ; + } case "REAL_ESTATE": { const decision = td.decision ?? "HOLD"; const marktwert = Math.round(context.propertyValueEnd); diff --git a/src/components/InfoBubble.tsx b/src/components/InfoBubble.tsx index 21e2b71..d01b296 100644 --- a/src/components/InfoBubble.tsx +++ b/src/components/InfoBubble.tsx @@ -1,28 +1,97 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Info } from "lucide-react"; +// Hilfetext-Bubble. +// +// Der Text wird über ein PORTAL an den gehängt und `fixed` positioniert. Vorher lag er +// als `absolute` Kind im Fluss -- in einem scrollenden Dialog (Verteil-Dialoge, Assistent) +// schnitt dessen `overflow` die Box ab, teilweise nach wenigen Zeilen. Fix positioniert +// bezieht sich auf den Viewport und entkommt damit jedem Overflow-Container. +// +// Zusätzlich: Die Box klappt nach OBEN, wenn unten kein Platz mehr ist, und wird an den +// Bildschirmrändern geklemmt, damit sie nie halb ausserhalb steht. + +const WIDTH = 256; // entspricht w-64 +const MARGIN = 8; + export function InfoBubble({ text }: { text: string }) { const [open, setOpen] = useState(false); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const btnRef = useRef(null); + const boxRef = useRef(null); + + // Position aus der Lage des Knopfes berechnen -- erst wenn geöffnet, und erneut, sobald die + // tatsächliche Höhe der Box bekannt ist (für das Hochklappen). + useEffect(() => { + if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Position gilt nur im offenen Zustand + setPos(null); + return; + } + const place = () => { + const btn = btnRef.current; + if (!btn) return; + const r = btn.getBoundingClientRect(); + const height = boxRef.current?.offsetHeight ?? 0; + const spaceBelow = window.innerHeight - r.bottom; + const above = height > 0 && spaceBelow < height + MARGIN && r.top > height + MARGIN; + const left = Math.min( + Math.max(MARGIN, r.left + r.width / 2 - WIDTH / 2), + Math.max(MARGIN, window.innerWidth - WIDTH - MARGIN) + ); + setPos({ top: above ? r.top - height - 6 : r.bottom + 6, left }); + }; + place(); + // Nach dem ersten Zeichnen erneut -- dann steht die Höhe fest. + const raf = requestAnimationFrame(place); + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [open]); return ( - {open && ( - - {text} - - )} + {open && + typeof document !== "undefined" && + createPortal( +
+ {text} +
, + document.body + )}
); } diff --git a/src/components/PhaseDetail.tsx b/src/components/PhaseDetail.tsx index 34894f8..50f70e3 100644 --- a/src/components/PhaseDetail.tsx +++ b/src/components/PhaseDetail.tsx @@ -4,11 +4,14 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { NumberField, TextField } from "@/components/FormField"; import { CarryWarning } from "@/components/ElementDetail"; +import { Button, useConfirm, useToast } from "@/components/ui"; import { api } from "@/lib/api-client"; +import { planDurationChange } from "@/lib/phaseplan"; import type { PhaseInput } from "@/lib/types"; export function PhaseDetail({ phase, + allPhases, maxDurationYears, isLast, laterPhaseCount, @@ -16,6 +19,9 @@ export function PhaseDetail({ onDeleted, }: { phase: PhaseInput; + // Alle Phasen des Szenarios -- nötig, um die Auswirkung einer geänderten Dauer auf die + // Folgephase VORHER zu zeigen (siehe planDurationChange). + allPhases: PhaseInput[]; maxDurationYears: number | null; isLast: boolean; laterPhaseCount: number; @@ -26,10 +32,32 @@ export function PhaseDetail({ const [durationYears, setDurationYears] = useState(phase.durationYears); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const confirm = useConfirm(); + const toast = useToast(); const cap = maxDurationYears; + // Vorschau: Was passiert mit der Folgephase? Wird live berechnet, damit die Auswirkung + // sichtbar ist, BEVOR gespeichert wird. + const change = planDurationChange(allPhases, phase.id, durationYears); async function save() { + // Verschiebt sich eine Folgephase, wird das vorher bestätigt -- sonst ändert ein Klick + // stillschweigend zwei Phasen. + if (change.ok && change.neighbourId && change.delta !== 0) { + const jahre = Math.abs(change.delta); + const ok = await confirm({ + title: "Folgephase wird angepasst", + message: + `Wenn du «${phase.name}» um ${jahre} Jahr${jahre === 1 ? "" : "e"} ` + + `${change.delta > 0 ? "verlängerst" : "verkürzst"}, wird «${change.neighbourName}» automatisch auf ` + + `${change.neighbourNewDuration} Jahr${change.neighbourNewDuration === 1 ? "" : "e"} ` + + `${change.delta > 0 ? "gekürzt" : "verlängert"} – die Gesamtdauer des Plans bleibt gleich. ` + + `Ohne diesen Ausgleich würde eine spätere Lebensphase eine Pensionierung überspannen.`, + confirmLabel: "Fortfahren", + }); + if (!ok) return; + } + setSaving(true); setError(null); try { @@ -43,21 +71,25 @@ export function PhaseDetail({ } async function remove() { - if (!confirm(`Phase "${phase.name}" wirklich löschen?`)) return; + const ok = await confirm({ + title: "Lebensphase löschen?", + message: `«${phase.name}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`, + confirmLabel: "Endgültig löschen", + danger: true, + }); + if (!ok) return; try { await api.delete(`/api/phases/${phase.id}`); onDeleted(); } catch (e) { - alert(e instanceof Error ? e.message : "Löschen fehlgeschlagen."); + toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen."); } } return (
-
- Lebensphase -
+
Lebensphase
{isLast && (
-

Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen gesetzt.

+ + {/* Auswirkung auf die Folgephase -- sichtbar, bevor gespeichert wird. */} + {change.error && ( +

{change.error}

+ )} + {change.ok && change.neighbourId && change.delta !== 0 && ( +

+ «{change.neighbourName}» wird dadurch auf {change.neighbourNewDuration} Jahre{" "} + {change.delta > 0 ? "gekürzt" : "verlängert"} – die Gesamtdauer des Plans bleibt gleich. +

+ )} + +

+ Die Inflationsrate gilt für das ganze Szenario und wird im Grundprofil gesetzt. +

{error &&

{error}

}
- +
); diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index e0a4683..45baa11 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -12,12 +12,14 @@ import { Home, Landmark, ListTodo, + Pencil, Maximize2, PiggyBank, Plus, Settings2, ShoppingCart, Tornado, + Trash2, TrendingUp, Wallet, } from "lucide-react"; @@ -48,7 +50,7 @@ import { import { PhaseDetail } from "@/components/PhaseDetail"; import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields"; import { RetirementAdjuster } from "@/components/RetirementAdjuster"; -import { MoneyField } from "@/components/FormField"; +import { MoneyField, SelectField, TextField } from "@/components/FormField"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview"; @@ -97,6 +99,7 @@ type Column = // Genau ein Panel kann offen sein -- deshalb eine Union statt sechs einzelner Zustände. type Panel = | { kind: "phase"; phaseId: string } + | { kind: "elementMeta"; elementId: string } | { kind: "cell"; elementId: string; phaseId: string } | { kind: "transition"; elementId: string; fromPhaseId: string } | { kind: "cashTransition"; fromPhaseId: string } @@ -279,6 +282,8 @@ export function PlanView({ ahvCareer: careerFor(element), inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id), ownerAgeStart: ownerAgeAtPhaseStart(phase, element), + investTargets: [], + capitalFromTransfer: ce?.capitalFromTransfer ?? 0, }; } @@ -303,6 +308,9 @@ export function PlanView({ ahvCareer: careerFor(element), inheritedValues: {}, ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element), + // Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C). + investTargets: investTargetsOf(toPhase), + capitalFromTransfer: 0, }; } @@ -500,6 +508,26 @@ export function PlanView({ > Lebensphase + {/* Alle Kategoriegruppen auf einen Schlag auf- oder zuklappen. */} +
)} @@ -525,10 +553,13 @@ export function PlanView({ beim Scrollen sichtbar bleiben. */} {hasPhases && (
- + {/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen + Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt. + Ein w-full haette die Spalten je nach Anzahl unterschiedlich gedehnt. */} +
- {columns.map((col) => @@ -545,6 +576,11 @@ export function PlanView({ col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null } onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })} + onDelete={ + col.phase.sequenceNumber === computed.phases.length + ? () => void deletePhase(col.phase.id, col.phase.name) + : null + } active={panel?.kind === "phase" && panel.phaseId === col.phase.id} /> ) : ( @@ -634,7 +670,37 @@ export function PlanView({ {CATEGORY_LABELS[cat]} - + ) : ( + {!collapsed && els.map((el) => ( @@ -645,6 +711,33 @@ export function PlanView({ {diff?.elementRow.get(el.id) === "added" && ( neu )} + {/* Aktionen zum Element selbst (phasenunabhängig): umbenennen, + löschen, Detailansicht -- bewusst IMMER sichtbar, damit sie + auch ohne Maus (Touch) erreichbar sind. */} + + @@ -822,6 +915,7 @@ export function PlanView({ setDetailFor(null)} onOpenSpec={onOpenSpec} @@ -862,6 +956,43 @@ export function PlanView({ ); // Nächste Phasen-Kappung (für das Phase-Popup). + // Element loeschen -- neu auch direkt aus der Zeile heraus. + async function deleteElement(elementId: string, elementName: string) { + const ok = await confirmDialog({ + title: "Element löschen?", + message: `«${elementName}» wird aus ALLEN Lebensphasen entfernt, samt Werten und Übergangs-Entscheiden.`, + confirmLabel: "Endgültig löschen", + danger: true, + }); + if (!ok) return; + try { + await api.delete(`/api/elements/${elementId}`); + setPanel(null); + onChanged(); + toast("success", "Element gelöscht."); + } catch (e) { + toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen."); + } + } + + async function deletePhase(phaseId: string, phaseName: string) { + const ok = await confirmDialog({ + title: "Lebensphase löschen?", + message: `«${phaseName}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`, + confirmLabel: "Endgültig löschen", + danger: true, + }); + if (!ok) return; + try { + await api.delete(`/api/phases/${phaseId}`); + setPanel(null); + onChanged(); + toast("success", "Lebensphase gelöscht."); + } catch (e) { + toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen."); + } + } + function nextPhaseCap(): number | null { // Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern). const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0); @@ -881,6 +1012,21 @@ export function PlanView({ }; switch (panel.kind) { + case "elementMeta": { + const el = plan.elements.find((e) => e.id === panel.elementId); + if (!el) return null; + return ( + + deleteElement(el.id, el.name)} + /> + + ); + } case "phase": { const phase = computed.phases.find((p) => p.id === panel.phaseId); const phaseInput = plan.phases.find((p) => p.id === panel.phaseId); @@ -889,6 +1035,7 @@ export function PlanView({ { setPanel(null); - deleteElement(element.id); + deleteElement(element.id, element.name); }} /> @@ -966,19 +1113,6 @@ export function PlanView({ } } - async function deleteElement(id: string) { - const ok = await confirmDialog({ - title: "Element löschen?", - message: "Das Element wird aus ALLEN Lebensphasen entfernt – samt aller Werte und Übergangs-Entscheide.", - confirmLabel: "Endgültig löschen", - danger: true, - }); - if (!ok) return; - await api.delete(`/api/elements/${id}`); - toast("success", "Element gelöscht."); - setPanel(null); - onChanged(); - } function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string { const td = el.transitionValues[fromPhase.id] ?? {}; @@ -1138,6 +1272,7 @@ function PhaseHeader({ onExpand, onDistributeCapital, onDistributeRates, + onDelete, active, }: { phase: PhaseComputed; @@ -1150,6 +1285,8 @@ function PhaseHeader({ // wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert). onDistributeCapital: (() => void) | null; onDistributeRates: () => void; + // null = nicht die letzte Phase (nur die letzte lässt sich löschen). + onDelete: (() => void) | null; active: boolean; }) { const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote"; @@ -1160,7 +1297,7 @@ function PhaseHeader({ return (
+ Finanzielle Elemente + {/* Zugeklappt: Summe der Elementwerte je Phase -- sonst verschwindet die + Information beim Zuklappen. Aufgeklappt bleibt die Zeile leer, weil + man die Einzelwerte darunter sieht. */} + {collapsed + ? columns.map((col) => + col.kind === "phase" ? ( + + {(() => { + const rows = els + .map((e) => computedElement(col.phase.id, e.id)) + .filter((ce): ce is NonNullable => !!ce && ce.status === "ACTIVE"); + if (rows.length === 0) return ""; + const start = rows.reduce((sum, ce) => sum + ce.startValue, 0); + const end = rows.reduce((sum, ce) => sum + ce.endValue, 0); + return ( + + {valStr(start, col.phase.cumulativeInflationStart, valueMode)}{" "} + {" "} + {valStr(end, col.phase.cumulativeInflationEnd, valueMode)} + + ); + })()} + + ) + ) + : }
)} - {/* Der Kopf öffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event - deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */} + {/* Aktionen zur Phase. Der Kopf selbst öffnet ebenfalls das Bearbeiten-Panel; die + Knöpfe müssen das Event deshalb stoppen, sonst gingen zwei Dinge gleichzeitig auf. + Bewusst IMMER sichtbar (Touch hat kein Hover). */} + + {onDelete && ( + + )} @@ -1290,7 +1454,7 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: @@ -1491,6 +1655,8 @@ function AddElementDialog({ // Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere. inheritedValues: {}, ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0, + investTargets: [], + capitalFromTransfer: 0, }; async function create() { @@ -1763,9 +1929,6 @@ function TransitionReviewDialog({ ct={ct} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={fromPhase.cumulativeInflationEnd} - showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0} - capitalInflow={toPhase?.capitalInflow ?? 0} - investTargets={investTargetsOf(toPhase)} /> @@ -1857,9 +2020,6 @@ function CashTransitionPanel({ ct={ct} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={fromPhase.cumulativeInflationEnd} - showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0} - capitalInflow={toPhase?.capitalInflow ?? 0} - investTargets={investTargetsOf(toPhase)} /> {error &&

{error}

} @@ -2024,3 +2184,98 @@ function PanelActions({ ); } + +// --- Panel: Element umbenennen und zuordnen ---------------------------------------------- +// Beides sind Eigenschaften des ELEMENTS, nicht einer Phase -- deshalb ein eigenes Panel, +// erreichbar über das Stift-Symbol in der Element-Zeile. +function ElementMetaPanel({ + element, + plan, + personLabel, + onSaved, + onDelete, +}: { + element: ElementInput; + plan: PlanInput; + personLabel: (role: string) => string; + onSaved: () => void; + onDelete: () => void; +}) { + const [name, setName] = useState(element.name); + const [ownerRole, setOwnerRole] = useState(element.ownerRole ?? "HOUSEHOLD"); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const needsPerson = PERSON_ONLY_CATEGORIES.includes(element.category); + const isCouple = plan.householdType === "COUPLE"; + const options = needsPerson + ? isCouple + ? [ + { value: "PERSON_A", label: personLabel("PERSON_A") }, + { value: "PERSON_B", label: personLabel("PERSON_B") }, + ] + : [{ value: "PERSON_A", label: personLabel("PERSON_A") }] + : isCouple + ? [ + { value: "HOUSEHOLD", label: "Gemeinsam" }, + { value: "PERSON_A", label: personLabel("PERSON_A") }, + { value: "PERSON_B", label: personLabel("PERSON_B") }, + ] + : [ + { value: "HOUSEHOLD", label: "Gemeinsam" }, + { value: "PERSON_A", label: personLabel("PERSON_A") }, + ]; + + async function save() { + setSaving(true); + setError(null); + try { + await api.patch(`/api/elements/${element.id}`, { name: name.trim() || element.name, ownerRole }); + onSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+ {CATEGORY_LABELS[element.category]} +
+ +
+ + + +

+ Die Werte je Lebensphase bearbeitest du direkt in den Zellen der Matrix. +

+ + {error &&

{error}

} +
+ +
+
+ ); +} diff --git a/src/lib/bridges.test.ts b/src/lib/bridges.test.ts index 34a3209..34cb48b 100644 --- a/src/lib/bridges.test.ts +++ b/src/lib/bridges.test.ts @@ -230,7 +230,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ retirementAge: 65, initialCash: 50000, phases: [ - { id: "p1", durationYears: 5, cashTransition: { capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } }, + { id: "p1", durationYears: 5 }, { id: "p2", durationYears: 10 }, ], elements: [ @@ -240,7 +240,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ "PENSION_FUND", "PERSON_A", { p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} }, - { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } + { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } } ), el( "REAL_ESTATE", @@ -310,10 +310,17 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", () it("leitet den Zufluss gemaess Quote in Hypothek und Anlage statt ins Cash", () => { const mit = computePlan(withQuoten.build()); + // Vergleichsplan OHNE Verwendungs-Quoten: Die Quoten haengen seit 0.33 am PK-Element, + // nicht mehr am Cash-Uebergang. + const raw = withQuoten.build(); const ohne = computePlan({ - ...withQuoten.build(), - phases: withQuoten.build().phases.map((p) => ({ ...p, cashTransition: {} })), - }); + ...raw, + elements: raw.elements.map((e) => + e.category === "PENSION_FUND" + ? { ...e, transitionValues: { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } } + : e + ), + } as PlanInput); const p2Mit = mit.phases[1]; const p2Ohne = ohne.phases[1]; @@ -345,3 +352,75 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", () expect(b.mortgageStart).toBeLessThan(a.mortgageStart); }); }); + +describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => { + // Genau der gemeldete Fehler: 100 % des PK-Kapitals auf den ETF -- danach zeigte das + // Element als Zusatzinvestition eine 0, obwohl der Betrag eingeflossen war. + function p(): PlanInput { + return plan({ + age: 62, + retirementAge: 65, + initialCash: 0, + phases: [ + { id: "p1", durationYears: 3 }, + { id: "p2", durationYears: 10 }, + ], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 100000 } }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }), + el( + "PENSION_FUND", + "PERSON_A", + { p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} }, + { p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } } + ), + el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }), + ], + }); + } + + it("weist den umgeleiteten Betrag am Ziel-Element aus", () => { + const c = computePlan(p()); + const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!; + // 400'000 brutto, 0 % Steuer -> 400'000 netto, davon 100 % in den ETF. + expect(etf.capitalFromTransfer).toBe(400000); + expect(etf.startValue).toBe(450000); // 50'000 fortgeschrieben + 400'000 + }); + + it("zaehlt ihn als Investition in der Cash-Bruecke (nicht als Tilgung)", () => { + const c = computePlan(p()); + expect(c.phases[1].cashBridge.investments).toBe(400000); + expect(c.phases[1].cashBridge.residual).toBe(0); + }); + + it("fliesst NUR einmal -- nicht in jeder Folgephase erneut", () => { + const base = p(); + const drei: PlanInput = { + ...base, + phases: [...base.phases, { id: "p3", sequenceNumber: 3, name: "p3", durationYears: 5, cashTransition: {} }], + } as PlanInput; + const c = computePlan(drei); + const etfP3 = c.phases[2].elements.find((e) => e.category === "OTHER_ASSET")!; + expect(etfP3.capitalFromTransfer ?? 0).toBe(0); + }); + + it("verteilt zwei Guthaben getrennt (PK und 3a je eigene Quote)", () => { + const base = p(); + const mit3a: PlanInput = { + ...base, + elements: [ + ...base.elements, + el( + "PILLAR_3A", + "PERSON_A", + { p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} }, + { p1: { capitalTaxRate: 0, capitalUseInvestPct: 50 } } + ), + ], + } as PlanInput; + const c = computePlan(mit3a); + const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!; + // PK: 100 % von 400'000 = 400'000; 3a: 50 % von 100'000 = 50'000. + expect(etf.capitalFromTransfer).toBe(450000); + }); +}); diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index d9b8681..9a26b2f 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -13,7 +13,7 @@ import { } from "@/lib/constants"; import { num } from "@/lib/elements"; import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals"; -import type { ElementCategory } from "@/lib/elements"; +import type { ElementCategory, TransitionData } from "@/lib/elements"; import type { PersonRole, PlanInput } from "@/lib/types"; export type PhaseType = "ERWERB" | "PENSION" | "MIXED"; @@ -89,6 +89,9 @@ export interface ElementPhaseComputed { endValue: number; // Wert/Flow am Phasenende (letztes Jahr) summary: string; note: string | null; + // Betrag, der aus einem Kapitalbezug (PK/3a) des vorigen Übergangs in dieses Element + // umgeleitet wurde (Punkt C). Nur bei OTHER_ASSET und nur ab Phase 2 > 0. + capitalFromTransfer?: number; yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain) transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain) @@ -293,6 +296,11 @@ interface Carry { owed: number; // Schulden: Restschuld (positiv) pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase + // Betrag, der am letzten Übergang aus einem Kapitalbezug (PK/3a) in dieses Element + // umgeleitet wurde (Roadmap Nr. 44, Punkt C). Wird in der Folgephase wie eine + // Zusatzeinlage behandelt -- dadurch erscheint er im Element, in der Cash-Brücke und im + // Verteil-Dialog, statt unsichtbar im Bestand zu verschwinden. + capitalIn: number; // Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge, // Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase. rates: Record; @@ -309,6 +317,7 @@ function emptyCarry(): Carry { owed: 0, pkPensionAnnual: 0, flowBasis: 0, + capitalIn: 0, rates: {}, hasCarry: false, }; @@ -697,7 +706,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp } case "OTHER_ASSET": { const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue)); - const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; + // Zusatzeinlage = manuell erfasst PLUS das am Übergang umgeleitete Alterskapital. + // Beides ist mechanisch dasselbe (Cash -> Vermögen), deshalb derselbe Weg. + const fromTransfer = carry.hasCarry ? carry.capitalIn : 0; + const topUp = (carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0) + fromTransfer; + ec.capitalFromTransfer = fromTransfer; + carry.capitalIn = 0; // verbraucht -- sonst flösse er in jeder Folgephase erneut const start = base + topUp; const rate = Math.round(inherited("annualContribution")); const withdrawal = Math.round(inherited("annualWithdrawal")); @@ -1411,6 +1425,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp // --- Übergang: Carry aktualisieren, Cash der Folgephase bilden --- let txInflow = 0; let txImmediateRepay = 0; + // Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND + // der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt. + const capitalUses: { net: number; td: TransitionData }[] = []; // Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase). // Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind // nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz @@ -1490,6 +1507,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); txInflow += net; txTax += value - net; + capitalUses.push({ net, td }); carry.value = 0; carry.pkPensionAnnual = 0; } else if (mode === "PENSION") { @@ -1501,6 +1519,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); txInflow += net; txTax += capital - net; + capitalUses.push({ net, td }); carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); txPensionConversion += value - capital; carry.value = 0; @@ -1521,6 +1540,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); txInflow += net; txTax += ec.endValue - net; + capitalUses.push({ net, td }); carry.value = 0; } else { const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); @@ -1657,48 +1677,50 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp carry.hasCarry = true; } - // --- Punkt C (Roadmap Nr. 44): Verwendung des Kapitalzuflusses ----------------------- + // --- Punkt C (Roadmap Nr. 44): Verwendung des bezogenen Alterskapitals --------------- // - // Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, 3a, - // Immobilienverkauf). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. - // Die Verwendung wird deshalb als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert - // sich der Betrag -- die Aufteilung skaliert mit, statt still falsch zu werden. + // Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, Säule 3a). + // Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Die Verwendung wird + // als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert sich der Betrag -- die + // Aufteilung skaliert mit, statt still falsch zu werden. + // + // Seit 0.33 hängt die Quote am jeweiligen VORSORGE-ELEMENT (PK bzw. 3a) statt am + // Cash-Übergang: Nur so lassen sich zwei Guthaben getrennt verwenden, und die Frage steht + // dort, wo der Bezugs-Entscheid fällt. // // Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die - // Anlage-Quote wie eine Zusatzinvestition. Beide sind schon heute Cash-Abflüsse an der - // Grenze und laufen damit korrekt durch beide Brücken. - if (txInflow > 0) { - const ct = phase.cashTransition ?? {}; - const amortPct = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct))); - const investPct = Math.max(0, Math.min(100 - amortPct, num(ct.capitalUseInvestPct))); - if (amortPct > 0 || investPct > 0) { - let amortBudget = Math.round((txInflow * amortPct) / 100); - for (const e of orderedElements) { - if (amortBudget <= 0) break; - if (e.category !== "REAL_ESTATE") continue; - const c = carries.get(e.id)!; - if (c.status !== "ACTIVE" || c.mortgage <= 0) continue; - const pay = Math.min(amortBudget, c.mortgage); - c.mortgage -= pay; - amortBudget -= pay; - txImmediateRepay += pay; - } + // Anlage-Quote wie eine Zusatzeinlage in der Folgephase. Beide sind Cash-Abflüsse und + // laufen damit korrekt durch beide Brücken. + for (const use of capitalUses) { + const amortPct = Math.max(0, Math.min(100, num(use.td.capitalUseAmortizationPct))); + const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct))); + if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue; - const investBudget = Math.round((txInflow * investPct) / 100); - if (investBudget > 0) { - const target = - orderedElements.find( - (e) => - e.id === ct.capitalUseTargetElementId && - e.category === "OTHER_ASSET" && - carries.get(e.id)!.status === "ACTIVE" - ) ?? - orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE"); - if (target) { - carries.get(target.id)!.value += investBudget; - txImmediateRepay += investBudget; - } - } + let amortBudget = Math.round((use.net * amortPct) / 100); + for (const e of orderedElements) { + if (amortBudget <= 0) break; + if (e.category !== "REAL_ESTATE") continue; + const c = carries.get(e.id)!; + if (c.status !== "ACTIVE" || c.mortgage <= 0) continue; + const pay = Math.min(amortBudget, c.mortgage); + c.mortgage -= pay; + amortBudget -= pay; + txImmediateRepay += pay; + } + + const investBudget = Math.round((use.net * investPct) / 100); + if (investBudget > 0) { + const target = + orderedElements.find( + (e) => + e.id === use.td.capitalUseTargetElementId && + e.category === "OTHER_ASSET" && + carries.get(e.id)!.status === "ACTIVE" + ) ?? + orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE"); + // Der Betrag wandert NICHT direkt in den Bestand, sondern über `capitalIn` in die + // Zusatzeinlage der Folgephase -- dadurch wird er im UI überall sichtbar. + if (target) carries.get(target.id)!.capitalIn += investBudget; } } diff --git a/src/lib/elements.ts b/src/lib/elements.ts index d38c39a..ad35cce 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -122,6 +122,20 @@ export interface TransitionData { extraAmortization?: number; // OTHER_DEBT immediateRepayment?: number; + // Verwendung des BEZOGENEN KAPITALS (Roadmap Nr. 44, Punkt C). + // + // Gilt für PENSION_FUND (nur bei Kapitalbezug) und PILLAR_3A am Pensions-Übergang: Wohin + // fliesst das ausbezahlte Alterskapital? Bewusst in PROZENT und nicht in Franken -- wird + // das Pensionsalter verschoben, ändert sich der Betrag, und eine Quote skaliert mit, + // während eine Frankenzahl still falsch würde. Der nicht zugeteilte Rest bleibt Cash. + // + // Bis 0.32 lagen diese Felder am CASH-Übergang. Das war der falsche Ort: Die Frage gehört + // zum Bezugs-Entscheid des jeweiligen Vorsorgeguthabens, nicht zum Cash-Konto -- und mit + // zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. + capitalUseAmortizationPct?: number; + capitalUseInvestPct?: number; + // Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen». + capitalUseTargetElementId?: string; } // --- Cash-Übergang: einmalige Sonderein-/ausgaben --- @@ -140,14 +154,6 @@ export interface CashTransitionData { // Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft). outflowLabel?: string; outflowAmount?: number; - // Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C). - // Bewusst in PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich - // das bezogene Kapital -- eine Franken-Angabe müsste man dann von Hand nachziehen, eine - // Quote skaliert mit. Der nicht zugeteilte Rest bleibt Cash. - capitalUseAmortizationPct?: number; - capitalUseInvestPct?: number; - // Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen». - capitalUseTargetElementId?: string; } // --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) --- @@ -162,9 +168,6 @@ export const cashTransitionSchema = z inflowTaxRate: z.number().min(0).max(100).optional(), outflowLabel: z.string().max(120).optional(), outflowAmount: nonNeg.optional(), - capitalUseAmortizationPct: z.number().min(0).max(100).optional(), - capitalUseInvestPct: z.number().min(0).max(100).optional(), - capitalUseTargetElementId: z.string().max(60).optional(), }) .strip(); @@ -210,6 +213,9 @@ export const transitionDataSchema = z partialSaleAmount: nonNeg.optional(), extraAmortization: nonNeg.optional(), immediateRepayment: nonNeg.optional(), + capitalUseAmortizationPct: z.number().min(0).max(100).optional(), + capitalUseInvestPct: z.number().min(0).max(100).optional(), + capitalUseTargetElementId: z.string().max(60).optional(), }) .strip(); diff --git a/src/lib/phaseplan.test.ts b/src/lib/phaseplan.test.ts index 1fdeeff..e9916a7 100644 --- a/src/lib/phaseplan.test.ts +++ b/src/lib/phaseplan.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { planSegments, defaultOpenDuration } from "@/lib/phaseplan"; +import { planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan"; describe("planSegments", () => { it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => { @@ -78,3 +78,52 @@ describe("planSegments", () => { expect(defaultOpenDuration([{ age: 50 }, { age: 60 }], 5)).toBe(25); // ältester zählt: 90-(60+5) }); }); + +describe("planDurationChange", () => { + const phases = [ + { id: "p1", name: "Erwerb", sequenceNumber: 1, durationYears: 10 }, + { id: "p2", name: "Erwerb 2", sequenceNumber: 2, durationYears: 10 }, + { id: "p3", name: "Pension", sequenceNumber: 3, durationYears: 25 }, + ]; + + it("laesst die Folgephase den Ausgleich tragen", () => { + // Genau der gemeldete Fall: Phase 1 von 10 auf 12 -- ohne Ausgleich wuerde Phase 2 die + // Pensionierung ueberspannen. + const r = planDurationChange(phases, "p1", 12); + expect(r.ok).toBe(true); + expect(r.delta).toBe(2); + expect(r.neighbourId).toBe("p2"); + expect(r.neighbourNewDuration).toBe(8); + }); + + it("verlaengert die Folgephase beim Verkuerzen", () => { + const r = planDurationChange(phases, "p1", 7); + expect(r.neighbourNewDuration).toBe(13); + }); + + it("blockt, wenn die Folgephase unter ein Jahr fiele", () => { + const r = planDurationChange(phases, "p1", 20); // p2 haette dann 0 + expect(r.ok).toBe(false); + expect(r.error).toContain("Erwerb 2"); + }); + + it("laesst die LETZTE Phase frei -- sie verlaengert den Plan", () => { + const r = planDurationChange(phases, "p3", 30); + expect(r.ok).toBe(true); + expect(r.neighbourId).toBeNull(); + }); + + it("meldet keine Aenderung bei gleicher Dauer", () => { + const r = planDurationChange(phases, "p2", 10); + expect(r.ok).toBe(true); + expect(r.delta).toBe(0); + expect(r.neighbourId).toBeNull(); + }); + + it("haelt die Gesamtdauer konstant", () => { + const before = phases.reduce((s, p) => s + p.durationYears, 0); + const r = planDurationChange(phases, "p1", 15); + const after = 15 + r.neighbourNewDuration! + phases[2].durationYears; + expect(after).toBe(before); + }); +}); diff --git a/src/lib/phaseplan.ts b/src/lib/phaseplan.ts index 703f0a8..d57fbb8 100644 --- a/src/lib/phaseplan.ts +++ b/src/lib/phaseplan.ts @@ -48,3 +48,66 @@ export function defaultOpenDuration(persons: { age: number }[], fromYear: number const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65; return Math.max(5, 90 - (oldestNow + fromYear)); } + +// --- Phasendauer ändern: die Folgephase gleicht aus -------------------------------------- +// +// Wird eine Phase verlängert, muss die NÄCHSTE um denselben Betrag kürzer werden -- sonst +// verschieben sich alle folgenden Phasengrenzen, und eine davon überspannt am Ende eine +// Pensionierung. Genau das passierte bis 0.32: Die Kappung prüfte nur die BEARBEITETE Phase +// («Phase 1 darf höchstens bis zur Pensionierung laufen»), nicht die Folgen für Phase 2. +// +// Dieselbe Mechanik wie beim Verschieben des Pensionsalters (lib/retirement.ts): Die +// Gesamtdauer des Plans bleibt gleich, es wird nur Zeit umverteilt. Nur die LETZTE Phase hat +// keine Nachfolgerin -- sie verlängert oder verkürzt den Plan tatsächlich. + +export interface DurationChange { + ok: boolean; + // Die Folgephase, die den Ausgleich trägt (null bei der letzten Phase). + neighbourId: string | null; + neighbourName: string | null; + neighbourNewDuration: number | null; + delta: number; // Änderung der bearbeiteten Phase in Jahren + error: string | null; +} + +export function planDurationChange( + phases: { id: string; name: string; sequenceNumber: number; durationYears: number }[], + phaseId: string, + newDuration: number +): DurationChange { + const sorted = [...phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); + const i = sorted.findIndex((p) => p.id === phaseId); + const none: DurationChange = { + ok: false, neighbourId: null, neighbourName: null, neighbourNewDuration: null, delta: 0, error: null, + }; + if (i < 0) return { ...none, error: "Diese Lebensphase gibt es nicht." }; + + const delta = Math.round(newDuration) - sorted[i].durationYears; + if (newDuration < 1) return { ...none, delta, error: "Eine Lebensphase muss mindestens ein Jahr dauern." }; + if (delta === 0) return { ...none, ok: true }; + + const next = sorted[i + 1]; + // Letzte Phase: kein Ausgleich nötig, der Plan wird einfach länger oder kürzer. + if (!next) return { ...none, ok: true, delta }; + + const neighbourNewDuration = next.durationYears - delta; + if (neighbourNewDuration < 1) { + return { + ...none, + delta, + error: + `«${next.name}» dauert nur ${next.durationYears} Jahr${next.durationYears === 1 ? "" : "e"} und müsste den ` + + `Ausgleich tragen. Verlängere diese Phase um höchstens ${next.durationYears - 1} Jahr` + + `${next.durationYears - 1 === 1 ? "" : "e"} – oder passe zuerst die Folgephase an.`, + }; + } + + return { + ok: true, + neighbourId: next.id, + neighbourName: next.name, + neighbourNewDuration, + delta, + error: null, + }; +} diff --git a/src/lib/transitions.ts b/src/lib/transitions.ts index dc1d098..cc6a29d 100644 --- a/src/lib/transitions.ts +++ b/src/lib/transitions.ts @@ -6,6 +6,7 @@ // the client.» Deshalb liegen sie hier, wo beide Seiten sie benutzen dürfen. import { num } from "@/lib/elements"; +import { DEFAULT_CAPITAL_TAX_RATE } from "@/lib/constants"; import { formatChf } from "@/lib/format"; import type { CashTransitionData, ElementCategory, TransitionData } from "@/lib/elements"; @@ -29,7 +30,14 @@ export function withTransitionDefaults( out.withdrawalMode = "NONE"; } } else if (category === "PILLAR_3A") { - if (!isRetirement && out.withdrawalMode === undefined) out.withdrawalMode = "NONE"; + if (isRetirement) { + // Der Bezug selbst steht fest (die 3a wird bei Pensionierung immer ausbezahlt) -- zu + // entscheiden sind Steuersatz und Verwendung. Ein Speichern setzt den Satz und + // markiert den Entscheid damit als getroffen. + if (out.capitalTaxRate === undefined) out.capitalTaxRate = DEFAULT_CAPITAL_TAX_RATE; + } else if (out.withdrawalMode === undefined) { + out.withdrawalMode = "NONE"; + } } return out; } @@ -50,7 +58,9 @@ export function isTransitionAnswered( case "PENSION_FUND": return isRetirement ? td.payoutMode !== undefined : td.withdrawalMode !== undefined; case "PILLAR_3A": - return isRetirement ? true : td.withdrawalMode !== undefined; + // Bei der Pensionierung gilt der Entscheid als getroffen, sobald der Steuersatz + // erfasst ist -- vorher steht die Verwendung des Kapitals offen (Punkt C). + return isRetirement ? td.capitalTaxRate !== undefined : td.withdrawalMode !== undefined; default: return true; }