Kapitalbezug: Personenzuordnung, Herkunft und Filter fuer bezogene Guthaben
Deploy App / deploy (push) Successful in 1m9s
Deploy App / deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+37
-7
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.33 |
|
| **Version** | 0.34 |
|
||||||
| **Datum** | 2026-07-25 |
|
| **Datum** | 2026-07-25 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `0761f6b` inkl. Modul-Review 4 (Matrix) (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `0953880` inkl. Modul-Review 4 (Nachbesserungen) (Branch `main`) |
|
||||||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **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` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| Version | Datum | Autor | Änderung |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe – ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» – irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war – das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a – die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). |
|
||||||
| 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/<id>` 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.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/<id>` 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/<id>/actuals/<setId>`. 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.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/<id>/actuals/<setId>`. 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.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). |
|
||||||
@@ -1190,6 +1191,11 @@ bestehende Felder** über die bestehenden Endpunkte – an der Berechnung änder
|
|||||||
| Immobilie | `extraAmortization` | dem **Übergang davor** |
|
| Immobilie | `extraAmortization` | dem **Übergang davor** |
|
||||||
| Sonstige Schulden | `immediateRepayment` | dem **Übergang davor** |
|
| Sonstige Schulden | `immediateRepayment` | dem **Übergang davor** |
|
||||||
|
|
||||||
|
Jede Zeile nennt neben dem Namen die **Zuordnung** (Person A / Person B / Gemeinsam) – ohne sie
|
||||||
|
sind zwei gleichnamige Guthaben nicht unterscheidbar. **Bezogene** PK- und 3a-Guthaben erscheinen
|
||||||
|
gar nicht mehr (Kennzeichen `acceptsCapital`, siehe 3.12.5): Die Rechnung ignoriert dort jede
|
||||||
|
Einlage, ein Eingabefeld wäre also folgenlos.
|
||||||
|
|
||||||
Dass zwei verschiedene Objekte beschrieben werden, ist eine Folge des Datenmodells: Die
|
Dass zwei verschiedene Objekte beschrieben werden, ist eine Folge des Datenmodells: Die
|
||||||
Zusatzeinlage ist ein Phasenwert, Sonderamortisation und Sofort-Tilgung sind Übergangs-Entscheide.
|
Zusatzeinlage ist ein Phasenwert, Sonderamortisation und Sofort-Tilgung sind Übergangs-Entscheide.
|
||||||
Beide zehren aber vom selben Topf. Betragsfelder werden dabei in die bestehenden Daten
|
Beide zehren aber vom selben Topf. Betragsfelder werden dabei in die bestehenden Daten
|
||||||
@@ -1200,6 +1206,12 @@ Was nicht verteilt wird, **bleibt automatisch auf dem Cash** – dafür braucht
|
|||||||
ist das Verhalten des Modells. Wird mehr verteilt als vorhanden, startet die Folgephase mit
|
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.
|
negativem Cash; der Dialog weist das als Liquiditätslücke aus.
|
||||||
|
|
||||||
|
**Bereits zugeteiltes Alterskapital steht read-only darüber.** Das Eingabefeld führt nur den
|
||||||
|
hier erfassten Teil; was aus der Prozent-Quote des Bezugs-Entscheids stammt
|
||||||
|
([3.12.5](#3125-punkt-c-verwendung-des-bezogenen-alterskapitals)), liegt daneben und ist nach
|
||||||
|
Quelle aufgeschlüsselt. Bis 0.33 fehlte diese Zeile: Man sah eine **0** und hielt die Quote für
|
||||||
|
wirkungslos.
|
||||||
|
|
||||||
**«Sparquote verteilen»** (bzw. **«Bezug verteilen»** bei Verzehr) verteilt die laufende Quote auf
|
**«Sparquote verteilen»** (bzw. **«Bezug verteilen»** bei Verzehr) verteilt die laufende Quote auf
|
||||||
jährliche Raten: `annualContribution` (3a, Sonstiges Vermögen), `annualWithdrawal` (Sonstiges
|
jährliche Raten: `annualContribution` (3a, Sonstiges Vermögen), `annualWithdrawal` (Sonstiges
|
||||||
Vermögen), `amortization` (Immobilie), `annualRepayment` (Schulden).
|
Vermögen), `amortization` (Immobilie), `annualRepayment` (Schulden).
|
||||||
@@ -1208,6 +1220,10 @@ Vermögen), `amortization` (Immobilie), `annualRepayment` (Schulden).
|
|||||||
> das Cash-Konto nicht ([4.6.3](#463-pension_fund)) – er lässt sich also gar nicht aus der Quote
|
> das Cash-Konto nicht ([4.6.3](#463-pension_fund)) – er lässt sich also gar nicht aus der Quote
|
||||||
> verteilen.
|
> verteilen.
|
||||||
|
|
||||||
|
Auch hier gilt der Filter über `acceptsCapital`: Eine **bezogene Säule 3a** nimmt keine Einzahlung
|
||||||
|
mehr auf und erscheint deshalb nicht. Bis 0.33 stand sie in der Liste – mitsamt dem aus der
|
||||||
|
Vorphase geerbten Beitrag, der als aktive Rate aussah, obwohl die Rechnung ihn längst verwarf.
|
||||||
|
|
||||||
Der Dialog weist **drei** Bezugsgrössen aus: Quote im ersten Jahr, im letzten Jahr und –
|
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
|
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)).
|
sich eine flache Jahresrate sinnvoll verteilen lässt (siehe [9.25](#925-die-quote-ist-kein-fester-betrag)).
|
||||||
@@ -1973,9 +1989,23 @@ eine Zusatzeinlage, und beide laufen dadurch korrekt durch die zwei Wasserfall-B
|
|||||||
|
|
||||||
**Der zugeteilte Betrag ist überall sichtbar.** Er wandert über `Carry.capitalIn` in die
|
**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
|
**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
|
Zeile «Zusatzinvestition aus Kapitalbezug»), in der Cash-Brücke als **Investition** und im
|
||||||
verteilen». Bis 0.32 erhöhte die Verteilung direkt den internen Bestand – Element und Dialog
|
Dialog «Kapital verteilen» (dort über dem Eingabefeld, das nur den **manuell** erfassten Teil
|
||||||
zeigten deshalb eine **0**, obwohl das Geld geflossen war.
|
führt). Bis 0.32 erhöhte die Verteilung direkt den internen Bestand – Element und Dialog
|
||||||
|
zeigten deshalb eine **0**, obwohl das Geld geflossen war; bis 0.33 fehlte sie im Dialog.
|
||||||
|
|
||||||
|
**Die Herkunft wird mitgeführt.** `Carry.capitalIn` hat als Gegenstück `capitalInSources` –
|
||||||
|
Betrag je Bezug, benannt mit Element **und** Person; am Ziel-Element liegt das Ergebnis als
|
||||||
|
`capitalFromTransferSources`. Ohne diese Aufschlüsselung ist bei zwei Guthaben (PK und 3a, ggf.
|
||||||
|
beider Personen) im selben Ziel nicht prüfbar, ob alles angekommen ist – der Grund, weshalb
|
||||||
|
das **Ziel-Dropdown** die Zuordnung nennen muss: Zwei gleichnamige Depots sind sonst nicht
|
||||||
|
unterscheidbar, und ein Fehlgriff leitet das Alterskapital an die falsche Person.
|
||||||
|
|
||||||
|
**Bezogene Guthaben nehmen nichts mehr auf.** Nach der Pensionierung ignoriert die Rechnung
|
||||||
|
Beiträge und Zusatzeinlagen in PK und Säule 3a. Damit die Verteil-Dialoge diese Regel nicht ein
|
||||||
|
zweites Mal nachbauen (und dabei abweichen), setzt die Rechnung das Kennzeichen
|
||||||
|
`ElementPhaseComputed.acceptsCapital = false`; die Dialoge blenden solche Zeilen aus. Der
|
||||||
|
frühere Filter auf `status === "ACTIVE"` griff nicht: Der Status bleibt nach dem Bezug bestehen.
|
||||||
|
|
||||||
**Die Säule 3a ist am Pensions-Übergang ein offener Entscheid.** Der Bezug selbst steht fest
|
**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
|
(sie wird immer ausbezahlt), zu entscheiden sind Steuersatz und Verwendung. Vorher galt der
|
||||||
@@ -3994,14 +4024,14 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
|||||||
| `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei |
|
| `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 |
|
| `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` | 14 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen |
|
| `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) |
|
| `bridges.test.ts` | 21 | 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) inkl. Herkunft je Quelle und `acceptsCapital` |
|
||||||
| `retirement.test.ts` | 16 | Spielraum und Sperren je Person, Verschiebung ohne Änderung der Gesamtdauer, Wegfall einer Phase, Zusammenführung der Übergangs-Entscheide |
|
| `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` |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `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 `"` |
|
| `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** | **288** | |
|
| **Total** | **292** | |
|
||||||
|
|
||||||
## 8.2 Testfälle
|
## 8.2 Testfälle
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,14 @@ import { CarryWarning } from "@/components/ElementDetail";
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
import { CATEGORY_LABELS, inheritedPhaseValues, num, type PhaseData, type TransitionData } from "@/lib/elements";
|
import {
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
inheritedPhaseValues,
|
||||||
|
num,
|
||||||
|
ownerLabel,
|
||||||
|
type PhaseData,
|
||||||
|
type TransitionData,
|
||||||
|
} from "@/lib/elements";
|
||||||
import {
|
import {
|
||||||
applyPhasePatches,
|
applyPhasePatches,
|
||||||
applyTransitionPatches,
|
applyTransitionPatches,
|
||||||
@@ -66,6 +73,7 @@ function SummaryRow({
|
|||||||
interface CapitalTarget {
|
interface CapitalTarget {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
owner: string;
|
||||||
category: string;
|
category: string;
|
||||||
kind: "investment" | "amortization" | "repayment";
|
kind: "investment" | "amortization" | "repayment";
|
||||||
max: number | undefined; // Kappung (Restschuld); bei Investitionen unbegrenzt
|
max: number | undefined; // Kappung (Restschuld); bei Investitionen unbegrenzt
|
||||||
@@ -98,11 +106,16 @@ export function CapitalDistributionDialog({
|
|||||||
const ce = phase?.elements.find((x) => x.elementId === e.id);
|
const ce = phase?.elements.find((x) => x.elementId === e.id);
|
||||||
const prevCe = prevPhase?.elements.find((x) => x.elementId === e.id);
|
const prevCe = prevPhase?.elements.find((x) => x.elementId === e.id);
|
||||||
if (!ce || ce.status !== "ACTIVE") continue;
|
if (!ce || ce.status !== "ACTIVE") continue;
|
||||||
|
// Die Rechnung ignoriert Einlagen in eine bezogene PK bzw. 3a. Solche Zeilen dürfen
|
||||||
|
// deshalb gar nicht erst erscheinen -- sonst trägt man Beträge ein, die folgenlos
|
||||||
|
// bleiben, und der Entscheid aus dem Übergang wirkt hier scheinbar nicht.
|
||||||
|
if (ce.acceptsCapital === false) continue;
|
||||||
|
|
||||||
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A" || e.category === "OTHER_ASSET") {
|
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A" || e.category === "OTHER_ASSET") {
|
||||||
out.push({
|
out.push({
|
||||||
elementId: e.id,
|
elementId: e.id,
|
||||||
name: e.name,
|
name: e.name,
|
||||||
|
owner: ownerLabel(plan.persons, e.ownerRole),
|
||||||
category: e.category,
|
category: e.category,
|
||||||
kind: "investment",
|
kind: "investment",
|
||||||
max: undefined,
|
max: undefined,
|
||||||
@@ -116,6 +129,7 @@ export function CapitalDistributionDialog({
|
|||||||
out.push({
|
out.push({
|
||||||
elementId: e.id,
|
elementId: e.id,
|
||||||
name: e.name,
|
name: e.name,
|
||||||
|
owner: ownerLabel(plan.persons, e.ownerRole),
|
||||||
category: e.category,
|
category: e.category,
|
||||||
kind: "amortization",
|
kind: "amortization",
|
||||||
max: rest,
|
max: rest,
|
||||||
@@ -128,6 +142,7 @@ export function CapitalDistributionDialog({
|
|||||||
out.push({
|
out.push({
|
||||||
elementId: e.id,
|
elementId: e.id,
|
||||||
name: e.name,
|
name: e.name,
|
||||||
|
owner: ownerLabel(plan.persons, e.ownerRole),
|
||||||
category: e.category,
|
category: e.category,
|
||||||
kind: "repayment",
|
kind: "repayment",
|
||||||
max: owed,
|
max: owed,
|
||||||
@@ -237,24 +252,59 @@ export function CapitalDistributionDialog({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{targets.map((t) => (
|
{targets.map((t) => {
|
||||||
<div key={t.elementId} className="rounded-xl border border-border p-3">
|
const pc = preview.elements.find((x) => x.elementId === t.elementId);
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
const sources = pc?.capitalFromTransferSources ?? [];
|
||||||
<span className="font-semibold text-fg">{t.name}</span>
|
const fromTransfer = pc?.capitalFromTransfer ?? 0;
|
||||||
<span className="text-xs text-faint">{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}</span>
|
const manual = draft[t.elementId] ?? 0;
|
||||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
|
return (
|
||||||
{t.kind === "investment" ? "Zusatzeinlage" : t.kind === "amortization" ? "Sonderamortisation" : "Sofortige Tilgung"}
|
<div key={t.elementId} className="rounded-xl border border-border p-3">
|
||||||
</span>
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||||
<InfoBubble text={t.hint} />
|
<span className="font-semibold text-fg">{t.name}</span>
|
||||||
|
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
|
||||||
|
<span className="text-xs text-faint">
|
||||||
|
{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
|
||||||
|
{t.kind === "investment"
|
||||||
|
? "Zusatzeinlage"
|
||||||
|
: t.kind === "amortization"
|
||||||
|
? "Sonderamortisation"
|
||||||
|
: "Sofortige Tilgung"}
|
||||||
|
</span>
|
||||||
|
<InfoBubble text={t.hint} />
|
||||||
|
</div>
|
||||||
|
{/* Was der Übergang bereits hierher geleitet hat. Ohne diese Zeile sieht man im
|
||||||
|
Feld eine 0 und hält die prozentuale Zuteilung für wirkungslos. */}
|
||||||
|
{fromTransfer > 0 && (
|
||||||
|
<div className="mb-2 rounded-lg border border-dashed border-border bg-surface-2 p-2">
|
||||||
|
<SummaryRow
|
||||||
|
label="Bereits aus Kapitalbezug zugeteilt"
|
||||||
|
value={fromTransfer}
|
||||||
|
help="Stammt aus der prozentualen Verteilung, die du beim Bezug der Pensionskasse bzw. der Säule 3a festgelegt hast. Der Betrag ist hier nicht editierbar – ändere ihn dort."
|
||||||
|
/>
|
||||||
|
{sources.map((src) => (
|
||||||
|
<div key={src.name} className="flex justify-between gap-3 pl-3 text-[11px] text-faint">
|
||||||
|
<span>aus {src.name}</span>
|
||||||
|
<span className="tabular-nums">{formatChf(src.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MoneyField
|
||||||
|
label={fromTransfer > 0 ? "Zusätzlich aus dem Kapital (CHF)" : "Betrag (CHF)"}
|
||||||
|
value={manual}
|
||||||
|
max={t.max}
|
||||||
|
onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))}
|
||||||
|
/>
|
||||||
|
{fromTransfer > 0 && (
|
||||||
|
<div className="mt-1 pl-1">
|
||||||
|
<SummaryRow label="Zusatzeinlage total" value={fromTransfer + manual} strong />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<MoneyField
|
);
|
||||||
label="Betrag (CHF)"
|
})}
|
||||||
value={draft[t.elementId] ?? 0}
|
|
||||||
max={t.max}
|
|
||||||
onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -322,13 +372,6 @@ interface RateTarget {
|
|||||||
hint: string;
|
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({
|
export function RateDistributionDialog({
|
||||||
plan,
|
plan,
|
||||||
computed,
|
computed,
|
||||||
@@ -349,34 +392,38 @@ export function RateDistributionDialog({
|
|||||||
for (const e of plan.elements) {
|
for (const e of plan.elements) {
|
||||||
const ce = phase?.elements.find((x) => x.elementId === e.id);
|
const ce = phase?.elements.find((x) => x.elementId === e.id);
|
||||||
if (!ce || ce.status !== "ACTIVE") continue;
|
if (!ce || ce.status !== "ACTIVE") continue;
|
||||||
|
// Eine bezogene Säule 3a nimmt keine Einzahlung mehr auf; die Rechnung ignoriert sie.
|
||||||
|
// Die Zeile hier trotzdem anzubieten hiess, den Entscheid aus dem Übergang zu
|
||||||
|
// widersprechen -- inklusive eines geerbten Betrags, der wirkungslos weiterläuft.
|
||||||
|
if (ce.acceptsCapital === false) continue;
|
||||||
// Die PK ist bewusst NICHT dabei: Ihr Beitrag stammt aus dem Bruttolohn und belastet
|
// 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.
|
// das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen.
|
||||||
if (e.category === "PILLAR_3A") {
|
if (e.category === "PILLAR_3A") {
|
||||||
out.push({
|
out.push({
|
||||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualContribution",
|
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
|
||||||
label: "Jährliche Einzahlung", direction: "out",
|
label: "Jährliche Einzahlung", direction: "out",
|
||||||
hint: "Fliesst jährlich vom Cash in die Säule 3a.",
|
hint: "Fliesst jährlich vom Cash in die Säule 3a.",
|
||||||
});
|
});
|
||||||
} else if (e.category === "OTHER_ASSET") {
|
} else if (e.category === "OTHER_ASSET") {
|
||||||
out.push({
|
out.push({
|
||||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualContribution",
|
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
|
||||||
label: "Jährlicher Sparbeitrag", direction: "out",
|
label: "Jährlicher Sparbeitrag", direction: "out",
|
||||||
hint: "Fliesst jährlich vom Cash ins Vermögen.",
|
hint: "Fliesst jährlich vom Cash ins Vermögen.",
|
||||||
});
|
});
|
||||||
out.push({
|
out.push({
|
||||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualWithdrawal",
|
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualWithdrawal",
|
||||||
label: "Jährliche Bezugsrate", direction: "in",
|
label: "Jährliche Bezugsrate", direction: "in",
|
||||||
hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.",
|
hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.",
|
||||||
});
|
});
|
||||||
} else if (e.category === "REAL_ESTATE") {
|
} else if (e.category === "REAL_ESTATE") {
|
||||||
out.push({
|
out.push({
|
||||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "amortization",
|
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "amortization",
|
||||||
label: "Amortisation pro Jahr", direction: "out",
|
label: "Amortisation pro Jahr", direction: "out",
|
||||||
hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.",
|
hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.",
|
||||||
});
|
});
|
||||||
} else if (e.category === "OTHER_DEBT") {
|
} else if (e.category === "OTHER_DEBT") {
|
||||||
out.push({
|
out.push({
|
||||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualRepayment",
|
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualRepayment",
|
||||||
label: "Tilgung pro Jahr", direction: "out",
|
label: "Tilgung pro Jahr", direction: "out",
|
||||||
hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.",
|
hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface CellContext {
|
|||||||
investTargets: { id: string; name: string }[];
|
investTargets: { id: string; name: string }[];
|
||||||
// Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C).
|
// Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C).
|
||||||
capitalFromTransfer: number;
|
capitalFromTransfer: number;
|
||||||
|
capitalFromTransferSources: { name: string; amount: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -804,13 +805,30 @@ export function ElementPhaseFields({
|
|||||||
onChange={(v) => setP({ additionalInvestment: v })}
|
onChange={(v) => setP({ additionalInvestment: v })}
|
||||||
/>
|
/>
|
||||||
{/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier
|
{/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier
|
||||||
erfasst, sondern dort als Quote entschieden (Punkt C). */}
|
erfasst, sondern dort als Quote entschieden (Punkt C). Kommt der Betrag aus
|
||||||
|
mehreren Bezügen (PK und 3a, ggf. beider Personen), wird er einzeln
|
||||||
|
aufgeschlüsselt: sonst liesse sich nicht pruefen, ob wirklich alles
|
||||||
|
angekommen ist. */}
|
||||||
{context.capitalFromTransfer > 0 && (
|
{context.capitalFromTransfer > 0 && (
|
||||||
<DerivedField
|
<div>
|
||||||
label="Davon aus Kapitalbezug (PK/3a)"
|
<FieldLabel
|
||||||
value={context.capitalFromTransfer}
|
label="Zusatzinvestition aus Kapitalbezug"
|
||||||
help="Beim letzten Übergang wurde ein Teil des bezogenen Alterskapitals hierher umgeleitet. Die Quote änderst du im Bezugs-Entscheid der Pensionskasse bzw. der Säule 3a."
|
help="Beim letzten Übergang wurde ein Teil des bezogenen Alterskapitals hierher umgeleitet. Die Quote änderst du im Bezugs-Entscheid der Pensionskasse bzw. der Säule 3a."
|
||||||
/>
|
/>
|
||||||
|
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||||
|
{formatChf(context.capitalFromTransfer)}
|
||||||
|
{context.capitalFromTransferSources.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-col gap-0.5 border-t border-border pt-1 text-[11px] text-faint">
|
||||||
|
{context.capitalFromTransferSources.map((src) => (
|
||||||
|
<div key={src.name} className="flex justify-between gap-3">
|
||||||
|
<span>aus {src.name}</span>
|
||||||
|
<span className="tabular-nums">{formatChf(src.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
PERSON_ONLY_CATEGORIES,
|
PERSON_ONLY_CATEGORIES,
|
||||||
inheritedPhaseValues,
|
inheritedPhaseValues,
|
||||||
num,
|
num,
|
||||||
|
ownerLabel,
|
||||||
type CashTransitionData,
|
type CashTransitionData,
|
||||||
type ElementCategory,
|
type ElementCategory,
|
||||||
type PhaseData,
|
type PhaseData,
|
||||||
@@ -284,6 +285,7 @@ export function PlanView({
|
|||||||
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
|
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
|
||||||
investTargets: [],
|
investTargets: [],
|
||||||
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
|
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
|
||||||
|
capitalFromTransferSources: ce?.capitalFromTransferSources ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,8 +311,9 @@ export function PlanView({
|
|||||||
inheritedValues: {},
|
inheritedValues: {},
|
||||||
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
|
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
|
||||||
// Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C).
|
// Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C).
|
||||||
investTargets: investTargetsOf(toPhase),
|
investTargets: investTargetsOf(plan, toPhase),
|
||||||
capitalFromTransfer: 0,
|
capitalFromTransfer: 0,
|
||||||
|
capitalFromTransferSources: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1657,6 +1660,7 @@ function AddElementDialog({
|
|||||||
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
|
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
|
||||||
investTargets: [],
|
investTargets: [],
|
||||||
capitalFromTransfer: 0,
|
capitalFromTransfer: 0,
|
||||||
|
capitalFromTransferSources: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
@@ -1806,10 +1810,15 @@ function AddPhaseDialog({
|
|||||||
|
|
||||||
// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle
|
// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle
|
||||||
// Vermögens-Elemente, die in der Folgephase noch aktiv sind.
|
// Vermögens-Elemente, die in der Folgephase noch aktiv sind.
|
||||||
function investTargetsOf(toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
|
// Der Name allein genuegt hier nicht: Zwei Personen nennen ihr Wertschriftendepot beide "ETF",
|
||||||
|
// und im Dropdown waehlt man dann blind das falsche -- was erst Phasen spaeter auffaellt.
|
||||||
|
function investTargetsOf(plan: PlanInput, toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
|
||||||
return (toPhase?.elements ?? [])
|
return (toPhase?.elements ?? [])
|
||||||
.filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE")
|
.filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE")
|
||||||
.map((e) => ({ id: e.elementId, name: e.name }));
|
.map((e) => {
|
||||||
|
const owner = plan.elements.find((x) => x.id === e.elementId)?.ownerRole ?? null;
|
||||||
|
return { id: e.elementId, name: `${e.name} · ${ownerLabel(plan.persons, owner)}` };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---
|
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---
|
||||||
|
|||||||
@@ -424,3 +424,85 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
|
|||||||
expect(etf.capitalFromTransfer).toBe(450000);
|
expect(etf.capitalFromTransfer).toBe(450000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => {
|
||||||
|
// Der gemeldete Fall: PK und 3a fliessen beide in denselben ETF. Ohne Aufschluesselung
|
||||||
|
// sieht man dort nur eine Summe und kann nicht pruefen, ob wirklich beide angekommen sind.
|
||||||
|
function zweiQuellen(): 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: 300000, expectedReturn: 0 }, p2: {} },
|
||||||
|
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } }
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
"PILLAR_3A",
|
||||||
|
"PERSON_A",
|
||||||
|
{ p1: { currentValue: 200000, expectedReturn: 0 }, p2: {} },
|
||||||
|
{ p1: { capitalTaxRate: 0, capitalUseInvestPct: 100 } }
|
||||||
|
),
|
||||||
|
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: {} }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("weist beide Quellen einzeln aus", () => {
|
||||||
|
const c = computePlan(zweiQuellen());
|
||||||
|
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||||
|
expect(etf.capitalFromTransfer).toBe(500000);
|
||||||
|
const src = etf.capitalFromTransferSources ?? [];
|
||||||
|
expect(src).toHaveLength(2);
|
||||||
|
expect(src.reduce((a, x) => a + x.amount, 0)).toBe(500000);
|
||||||
|
// Der Name traegt die Person -- sonst sind zwei gleichnamige Guthaben nicht trennbar.
|
||||||
|
expect(src.every((x) => x.name.includes("Person A"))).toBe(true);
|
||||||
|
expect(src.map((x) => x.amount).sort((a, b) => a - b)).toEqual([200000, 300000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt die Person beim Namen, wenn eine erfasst ist", () => {
|
||||||
|
const base = zweiQuellen();
|
||||||
|
const benannt: PlanInput = {
|
||||||
|
...base,
|
||||||
|
persons: base.persons.map((x) => ({ ...x, name: "Jeanine" })),
|
||||||
|
};
|
||||||
|
const etf = computePlan(benannt).phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||||
|
expect((etf.capitalFromTransferSources ?? []).every((x) => x.name.includes("Jeanine"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("markiert bezogene PK und 3a als nicht mehr aufnahmefaehig", () => {
|
||||||
|
// Grundlage der Verteil-Dialoge: Eine bezogene 3a darf dort keine Einzahlungszeile mehr
|
||||||
|
// anbieten -- die Rechnung ignoriert solche Betraege, der Nutzer haelt sie fuer wirksam.
|
||||||
|
const c = computePlan(zweiQuellen());
|
||||||
|
const p2 = c.phases[1];
|
||||||
|
expect(p2.elements.find((e) => e.category === "PILLAR_3A")!.acceptsCapital).toBe(false);
|
||||||
|
expect(p2.elements.find((e) => e.category === "PENSION_FUND")!.acceptsCapital).toBe(false);
|
||||||
|
// Vor der Pensionierung dagegen unbeschraenkt.
|
||||||
|
const p1 = c.phases[0];
|
||||||
|
expect(p1.elements.find((e) => e.category === "PILLAR_3A")!.acceptsCapital).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignoriert eine Einzahlung in die bezogene 3a auch rechnerisch", () => {
|
||||||
|
// Die Zusicherung hinter dem Dialog-Filter: Selbst wenn der Wert in den Daten steht,
|
||||||
|
// aendert er nichts -- Filter und Rechnung sagen dasselbe.
|
||||||
|
const base = zweiQuellen();
|
||||||
|
const mitRate: PlanInput = {
|
||||||
|
...base,
|
||||||
|
elements: base.elements.map((e) =>
|
||||||
|
e.category === "PILLAR_3A"
|
||||||
|
? { ...e, phaseValues: { ...e.phaseValues, p2: { annualContribution: 7000, additionalInvestment: 50000 } } }
|
||||||
|
: e
|
||||||
|
),
|
||||||
|
};
|
||||||
|
expect(computePlan(mitRate).phases[1].endWealth).toBe(computePlan(base).phases[1].endWealth);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+39
-6
@@ -92,6 +92,12 @@ export interface ElementPhaseComputed {
|
|||||||
// Betrag, der aus einem Kapitalbezug (PK/3a) des vorigen Übergangs in dieses Element
|
// 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.
|
// umgeleitet wurde (Punkt C). Nur bei OTHER_ASSET und nur ab Phase 2 > 0.
|
||||||
capitalFromTransfer?: number;
|
capitalFromTransfer?: number;
|
||||||
|
capitalFromTransferSources?: { name: string; amount: number }[];
|
||||||
|
// Nimmt dieses Element in DIESER Phase überhaupt noch Geld auf? PK und Säule 3a nicht mehr,
|
||||||
|
// sobald der Besitzer pensioniert ist -- die Rechnung ignoriert dort Beiträge und
|
||||||
|
// Zusatzeinlagen. Die Verteil-Dialoge dürfen solche Zeilen deshalb gar nicht erst anbieten
|
||||||
|
// (sonst tippt man Beträge ein, die wirkungslos bleiben). Fehlt das Feld: nimmt auf.
|
||||||
|
acceptsCapital?: boolean;
|
||||||
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
|
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
|
||||||
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
|
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
|
||||||
transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain)
|
transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain)
|
||||||
@@ -301,6 +307,10 @@ interface Carry {
|
|||||||
// Zusatzeinlage behandelt -- dadurch erscheint er im Element, in der Cash-Brücke und im
|
// Zusatzeinlage behandelt -- dadurch erscheint er im Element, in der Cash-Brücke und im
|
||||||
// Verteil-Dialog, statt unsichtbar im Bestand zu verschwinden.
|
// Verteil-Dialog, statt unsichtbar im Bestand zu verschwinden.
|
||||||
capitalIn: number;
|
capitalIn: number;
|
||||||
|
// Woher der Betrag stammt (PK bzw. 3a, je Person) -- damit im UI nachvollziehbar bleibt,
|
||||||
|
// welcher Bezug wohin geflossen ist. Bei zwei Personen fliessen sonst mehrere Beträge
|
||||||
|
// ununterscheidbar in dasselbe Element.
|
||||||
|
capitalInSources: { name: string; amount: number }[];
|
||||||
// Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge,
|
// Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge,
|
||||||
// Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase.
|
// Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase.
|
||||||
rates: Record<string, number>;
|
rates: Record<string, number>;
|
||||||
@@ -318,6 +328,7 @@ function emptyCarry(): Carry {
|
|||||||
pkPensionAnnual: 0,
|
pkPensionAnnual: 0,
|
||||||
flowBasis: 0,
|
flowBasis: 0,
|
||||||
capitalIn: 0,
|
capitalIn: 0,
|
||||||
|
capitalInSources: [],
|
||||||
rates: {},
|
rates: {},
|
||||||
hasCarry: false,
|
hasCarry: false,
|
||||||
};
|
};
|
||||||
@@ -330,6 +341,18 @@ function fmt(v: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behält.
|
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behält.
|
||||||
|
// Sprechende Herkunft eines Kapitalbezugs: Elementname plus Person. Ohne die Person sind zwei
|
||||||
|
// gleichnamige Guthaben (z. B. beide «Säule 3a») im UI nicht auseinanderzuhalten.
|
||||||
|
function sourceLabelOf(
|
||||||
|
e: { name: string; ownerRole: string | null },
|
||||||
|
persons: { role: PersonRole; name: string | null }[]
|
||||||
|
): string {
|
||||||
|
if (e.ownerRole !== "PERSON_A" && e.ownerRole !== "PERSON_B") return e.name;
|
||||||
|
const p = persons.find((x) => x.role === e.ownerRole);
|
||||||
|
const who = p?.name?.trim() || (e.ownerRole === "PERSON_A" ? "Person A" : "Person B");
|
||||||
|
return `${e.name} (${who})`;
|
||||||
|
}
|
||||||
|
|
||||||
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
|
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
|
||||||
return persons.find((p) => p.role === role) ?? null;
|
return persons.find((p) => p.role === role) ?? null;
|
||||||
}
|
}
|
||||||
@@ -670,9 +693,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
ec.startValue = carry.pkPensionAnnual;
|
ec.startValue = carry.pkPensionAnnual;
|
||||||
ec.endValue = carry.pkPensionAnnual;
|
ec.endValue = carry.pkPensionAnnual;
|
||||||
ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`;
|
ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`;
|
||||||
|
ec.acceptsCapital = false;
|
||||||
} else if (!ownerWorking) {
|
} else if (!ownerWorking) {
|
||||||
ec.note = "Vollständig bezogen";
|
ec.note = "Vollständig bezogen";
|
||||||
ec.summary = "Bezogen";
|
ec.summary = "Bezogen";
|
||||||
|
ec.acceptsCapital = false;
|
||||||
} else {
|
} else {
|
||||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
@@ -690,6 +715,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
if (!ownerWorking) {
|
if (!ownerWorking) {
|
||||||
ec.note = "Vollständig bezogen";
|
ec.note = "Vollständig bezogen";
|
||||||
ec.summary = "Bezogen";
|
ec.summary = "Bezogen";
|
||||||
|
ec.acceptsCapital = false;
|
||||||
} else {
|
} else {
|
||||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
@@ -711,7 +737,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
const fromTransfer = carry.hasCarry ? carry.capitalIn : 0;
|
const fromTransfer = carry.hasCarry ? carry.capitalIn : 0;
|
||||||
const topUp = (carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0) + fromTransfer;
|
const topUp = (carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0) + fromTransfer;
|
||||||
ec.capitalFromTransfer = fromTransfer;
|
ec.capitalFromTransfer = fromTransfer;
|
||||||
carry.capitalIn = 0; // verbraucht -- sonst flösse er in jeder Folgephase erneut
|
ec.capitalFromTransferSources = carry.hasCarry ? carry.capitalInSources : [];
|
||||||
|
// Verbraucht -- sonst flösse derselbe Betrag in jeder Folgephase erneut.
|
||||||
|
carry.capitalIn = 0;
|
||||||
|
carry.capitalInSources = [];
|
||||||
const start = base + topUp;
|
const start = base + topUp;
|
||||||
const rate = Math.round(inherited("annualContribution"));
|
const rate = Math.round(inherited("annualContribution"));
|
||||||
const withdrawal = Math.round(inherited("annualWithdrawal"));
|
const withdrawal = Math.round(inherited("annualWithdrawal"));
|
||||||
@@ -1427,7 +1456,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
let txImmediateRepay = 0;
|
let txImmediateRepay = 0;
|
||||||
// Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND
|
// Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND
|
||||||
// der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt.
|
// der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt.
|
||||||
const capitalUses: { net: number; td: TransitionData }[] = [];
|
const capitalUses: { net: number; td: TransitionData; sourceName: string }[] = [];
|
||||||
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
|
// 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
|
// 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
|
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
|
||||||
@@ -1507,7 +1536,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||||
txInflow += net;
|
txInflow += net;
|
||||||
txTax += value - net;
|
txTax += value - net;
|
||||||
capitalUses.push({ net, td });
|
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) });
|
||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
carry.pkPensionAnnual = 0;
|
carry.pkPensionAnnual = 0;
|
||||||
} else if (mode === "PENSION") {
|
} else if (mode === "PENSION") {
|
||||||
@@ -1519,7 +1548,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||||
txInflow += net;
|
txInflow += net;
|
||||||
txTax += capital - net;
|
txTax += capital - net;
|
||||||
capitalUses.push({ net, td });
|
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) });
|
||||||
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
|
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
|
||||||
txPensionConversion += value - capital;
|
txPensionConversion += value - capital;
|
||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
@@ -1540,7 +1569,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));
|
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||||
txInflow += net;
|
txInflow += net;
|
||||||
txTax += ec.endValue - net;
|
txTax += ec.endValue - net;
|
||||||
capitalUses.push({ net, td });
|
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) });
|
||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
} else {
|
} else {
|
||||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||||
@@ -1720,7 +1749,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
orderedElements.find((e) => 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
|
// Der Betrag wandert NICHT direkt in den Bestand, sondern über `capitalIn` in die
|
||||||
// Zusatzeinlage der Folgephase -- dadurch wird er im UI überall sichtbar.
|
// Zusatzeinlage der Folgephase -- dadurch wird er im UI überall sichtbar.
|
||||||
if (target) carries.get(target.id)!.capitalIn += investBudget;
|
if (target) {
|
||||||
|
const c = carries.get(target.id)!;
|
||||||
|
c.capitalIn += investBudget;
|
||||||
|
c.capitalInSources.push({ name: use.sourceName, amount: investBudget });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,19 @@ export const INHERITABLE_KEYS = [
|
|||||||
|
|
||||||
export type InheritableKey = (typeof INHERITABLE_KEYS)[number];
|
export type InheritableKey = (typeof INHERITABLE_KEYS)[number];
|
||||||
|
|
||||||
|
// Zuordnung eines Elements als Klartext ("Jeanine", "Person B", "Gemeinsam"). Ueberall dort
|
||||||
|
// noetig, wo Elemente nur ueber ihren Namen angeboten werden: Zwei Personen benennen ihre
|
||||||
|
// Guthaben typischerweise gleich ("Saeule 3a", "ETF"), und ohne die Person waehlt man im
|
||||||
|
// Dropdown blind das falsche.
|
||||||
|
export function ownerLabel(
|
||||||
|
persons: { role: string; name: string | null }[],
|
||||||
|
role: string | null | undefined
|
||||||
|
): string {
|
||||||
|
if (role !== "PERSON_A" && role !== "PERSON_B") return "Gemeinsam";
|
||||||
|
const p = persons.find((x) => x.role === role);
|
||||||
|
return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B");
|
||||||
|
}
|
||||||
|
|
||||||
// Wert, der in `phaseId` gelten wuerde, wenn das Feld dort leer bleibt. `orderedPhaseIds`
|
// Wert, der in `phaseId` gelten wuerde, wenn das Feld dort leer bleibt. `orderedPhaseIds`
|
||||||
// muss in Phasenreihenfolge vorliegen.
|
// muss in Phasenreihenfolge vorliegen.
|
||||||
export function inheritedPhaseValues(
|
export function inheritedPhaseValues(
|
||||||
|
|||||||
Reference in New Issue
Block a user