From 7979fed7bd372ddc2a5745dffe962d19bb465929 Mon Sep 17 00:00:00 2001
From: kelle
Date: Sat, 25 Jul 2026 23:33:47 +0200
Subject: [PATCH] Kapitalbezug: Personenzuordnung, Herkunft und Filter fuer
bezogene Guthaben
Co-Authored-By: Claude Opus 5
---
SPEZIFIKATION.md | 44 ++++++++--
src/components/DistributionDialogs.tsx | 107 ++++++++++++++++++-------
src/components/ElementDetail.tsx | 30 +++++--
src/components/PlanView.tsx | 15 +++-
src/lib/bridges.test.ts | 82 +++++++++++++++++++
src/lib/calculations.ts | 45 +++++++++--
src/lib/elements.ts | 13 +++
7 files changed, 284 insertions(+), 52 deletions(-)
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(