diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index e0a6e38..7ed48f1 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.33 | +| **Version** | 0.34 | | **Datum** | 2026-07-25 | | **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 | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | 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/` 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). | @@ -1190,6 +1191,11 @@ bestehende Felder** über die bestehenden Endpunkte – an der Berechnung änder | Immobilie | `extraAmortization` | 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 Zusatzeinlage ist ein Phasenwert, Sonderamortisation und Sofort-Tilgung sind Übergangs-Entscheide. 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 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 jährliche Raten: `annualContribution` (3a, Sonstiges Vermögen), `annualWithdrawal` (Sonstiges 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 > 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 – 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)). @@ -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 **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. +Zeile «Zusatzinvestition aus Kapitalbezug»), in der Cash-Brücke als **Investition** und im +Dialog «Kapital verteilen» (dort über dem Eingabefeld, das nur den **manuell** erfassten Teil +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 (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 | | `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 | -| `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 | | `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** | **288** | | +| **Total** | **292** | | ## 8.2 Testfälle diff --git a/src/components/DistributionDialogs.tsx b/src/components/DistributionDialogs.tsx index a41c736..2c1a091 100644 --- a/src/components/DistributionDialogs.tsx +++ b/src/components/DistributionDialogs.tsx @@ -21,7 +21,14 @@ import { CarryWarning } from "@/components/ElementDetail"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; 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 { applyPhasePatches, applyTransitionPatches, @@ -66,6 +73,7 @@ function SummaryRow({ interface CapitalTarget { elementId: string; name: string; + owner: string; category: string; kind: "investment" | "amortization" | "repayment"; 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 prevCe = prevPhase?.elements.find((x) => x.elementId === e.id); 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") { out.push({ elementId: e.id, name: e.name, + owner: ownerLabel(plan.persons, e.ownerRole), category: e.category, kind: "investment", max: undefined, @@ -116,6 +129,7 @@ export function CapitalDistributionDialog({ out.push({ elementId: e.id, name: e.name, + owner: ownerLabel(plan.persons, e.ownerRole), category: e.category, kind: "amortization", max: rest, @@ -128,6 +142,7 @@ export function CapitalDistributionDialog({ out.push({ elementId: e.id, name: e.name, + owner: ownerLabel(plan.persons, e.ownerRole), category: e.category, kind: "repayment", max: owed, @@ -237,24 +252,59 @@ export function CapitalDistributionDialog({

) : (
- {targets.map((t) => ( -
-
- {t.name} - {CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]} - - {t.kind === "investment" ? "Zusatzeinlage" : t.kind === "amortization" ? "Sonderamortisation" : "Sofortige Tilgung"} - - + {targets.map((t) => { + const pc = preview.elements.find((x) => x.elementId === t.elementId); + const sources = pc?.capitalFromTransferSources ?? []; + const fromTransfer = pc?.capitalFromTransfer ?? 0; + const manual = draft[t.elementId] ?? 0; + return ( +
+
+ {t.name} + {t.owner} + + {CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]} + + + {t.kind === "investment" + ? "Zusatzeinlage" + : t.kind === "amortization" + ? "Sonderamortisation" + : "Sofortige Tilgung"} + + +
+ {/* 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 && ( +
+ + {sources.map((src) => ( +
+ aus {src.name} + {formatChf(src.amount)} +
+ ))} +
+ )} + 0 ? "Zusätzlich aus dem Kapital (CHF)" : "Betrag (CHF)"} + value={manual} + max={t.max} + onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))} + /> + {fromTransfer > 0 && ( +
+ +
+ )}
- setDraft((prev) => ({ ...prev, [t.elementId]: v }))} - /> -
- ))} + ); + })}
)} @@ -322,13 +372,6 @@ interface RateTarget { 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, @@ -349,34 +392,38 @@ export function RateDistributionDialog({ for (const e of plan.elements) { const ce = phase?.elements.find((x) => x.elementId === e.id); 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 // 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, 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", 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, 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", hint: "Fliesst jährlich vom Cash ins Vermögen.", }); 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", 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, 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", 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, 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", hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.", }); diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 0653e18..83f98f5 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -73,6 +73,7 @@ export interface CellContext { investTargets: { id: string; name: string }[]; // Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C). capitalFromTransfer: number; + capitalFromTransferSources: { name: string; amount: number }[]; } interface Props { @@ -804,13 +805,30 @@ export function ElementPhaseFields({ onChange={(v) => setP({ additionalInvestment: v })} /> {/* 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 && ( - +
+ +
+ {formatChf(context.capitalFromTransfer)} + {context.capitalFromTransferSources.length > 0 && ( +
+ {context.capitalFromTransferSources.map((src) => ( +
+ aus {src.name} + {formatChf(src.amount)} +
+ ))} +
+ )} +
+
)} ) : ( diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index 45baa11..0dea8b2 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -60,6 +60,7 @@ import { PERSON_ONLY_CATEGORIES, inheritedPhaseValues, num, + ownerLabel, type CashTransitionData, type ElementCategory, type PhaseData, @@ -284,6 +285,7 @@ export function PlanView({ ownerAgeStart: ownerAgeAtPhaseStart(phase, element), investTargets: [], capitalFromTransfer: ce?.capitalFromTransfer ?? 0, + capitalFromTransferSources: ce?.capitalFromTransferSources ?? [], }; } @@ -309,8 +311,9 @@ export function PlanView({ inheritedValues: {}, ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element), // Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C). - investTargets: investTargetsOf(toPhase), + investTargets: investTargetsOf(plan, toPhase), capitalFromTransfer: 0, + capitalFromTransferSources: [], }; } @@ -1657,6 +1660,7 @@ function AddElementDialog({ ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0, investTargets: [], capitalFromTransfer: 0, + capitalFromTransferSources: [], }; async function create() { @@ -1806,10 +1810,15 @@ function AddPhaseDialog({ // Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle // 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 ?? []) .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) --- diff --git a/src/lib/bridges.test.ts b/src/lib/bridges.test.ts index 34cb48b..2095d7b 100644 --- a/src/lib/bridges.test.ts +++ b/src/lib/bridges.test.ts @@ -424,3 +424,85 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => { 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); + }); +}); diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 9a26b2f..e8ae668 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -92,6 +92,12 @@ export interface ElementPhaseComputed { // 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; + 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 trace?: Trace; // Rechenweg der Phasenwerte (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 // Verteil-Dialog, statt unsichtbar im Bestand zu verschwinden. 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, // Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase. rates: Record; @@ -318,6 +328,7 @@ function emptyCarry(): Carry { pkPensionAnnual: 0, flowBasis: 0, capitalIn: 0, + capitalInSources: [], rates: {}, hasCarry: false, }; @@ -330,6 +341,18 @@ function fmt(v: number): string { } // 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(persons: T[], role: string): T | 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.endValue = carry.pkPensionAnnual; ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`; + ec.acceptsCapital = false; } else if (!ownerWorking) { ec.note = "Vollständig bezogen"; ec.summary = "Bezogen"; + ec.acceptsCapital = false; } else { const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); 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) { ec.note = "Vollständig bezogen"; ec.summary = "Bezogen"; + ec.acceptsCapital = false; } else { const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); 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 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 + 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 rate = Math.round(inherited("annualContribution")); const withdrawal = Math.round(inherited("annualWithdrawal")); @@ -1427,7 +1456,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp 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 }[] = []; + const capitalUses: { net: number; td: TransitionData; sourceName: string }[] = []; // 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 @@ -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)); txInflow += net; txTax += value - net; - capitalUses.push({ net, td }); + capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) }); carry.value = 0; carry.pkPensionAnnual = 0; } 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)); txInflow += 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); txPensionConversion += value - capital; 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)); txInflow += net; txTax += ec.endValue - net; - capitalUses.push({ net, td }); + capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) }); carry.value = 0; } else { 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"); // 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; + if (target) { + const c = carries.get(target.id)!; + c.capitalIn += investBudget; + c.capitalInSources.push({ name: use.sourceName, amount: investBudget }); + } } } diff --git a/src/lib/elements.ts b/src/lib/elements.ts index ad35cce..746e577 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -242,6 +242,19 @@ export const INHERITABLE_KEYS = [ 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` // muss in Phasenreihenfolge vorliegen. export function inheritedPhaseValues(