Kapitalbezug: Personenzuordnung, Herkunft und Filter fuer bezogene Guthaben
Deploy App / deploy (push) Successful in 1m9s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:33:47 +02:00
parent 0953880a80
commit 7979fed7bd
7 changed files with 284 additions and 52 deletions
+37 -7
View File
@@ -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` (v1v5) 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/<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.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.23.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
+66 -19
View File
@@ -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({
</p>
) : (
<div className="flex flex-col gap-2">
{targets.map((t) => (
{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 (
<div key={t.elementId} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{t.name}</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.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"}
{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="Betrag (CHF)"
value={draft[t.elementId] ?? 0}
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>
)}
@@ -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.",
});
+22 -4
View File
@@ -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 && (
<DerivedField
label="Davon aus Kapitalbezug (PK/3a)"
value={context.capitalFromTransfer}
<div>
<FieldLabel
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."
/>
<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>
)}
</>
) : (
+12 -3
View File
@@ -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) ---
+82
View File
@@ -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);
});
});
+39 -6
View File
@@ -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<string, number>;
@@ -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<T extends { role: PersonRole }>(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 });
}
}
}
+13
View File
@@ -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(