Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Deploy App / deploy (push) Successful in 1m10s

Kapitalverwendung (Punkt C) am richtigen Ort:
- Prozent-Aufteilung des bezogenen Alterskapitals wandert vom Cash-Uebergang
  zum Bezugs-Entscheid der PK (nur bei Kapitalbezug) bzw. der Saeule 3a --
  mit zwei Guthaben liess sie sich vorher gar nicht getrennt beantworten
- Dialoge fuehren neu brutto -> Steuersatz -> netto -> Verteilung
- BUGFIX: Der zugeteilte Betrag erhoehte still den internen Bestand, deshalb
  zeigten Ziel-Element und "Kapital verteilen" eine 0. Er laeuft jetzt ueber
  Carry.capitalIn als Zusatzeinlage der Folgephase und ist ueberall sichtbar
- Saeule 3a ist am Pensions-Uebergang neu ein offener Entscheid

Phasendauer (gemeldeter Fehler):
- Die Folgephase gleicht eine geaenderte Dauer aus; Gesamtdauer bleibt gleich
- Vorher kappte das Tool nur die bearbeitete Phase -> Phase 2 ueberspannte
  danach die Pensionierung und die Invariante aus Punkt 44 kippte
- Rueckfrage vorher, Blockade wenn die Folgephase unter 1 Jahr fiele
- neue reine Funktion planDurationChange

Bedienung:
- Element-Zeile und Phasenkopf: Stift (umbenennen, beim Element inkl.
  Zuordnung), Papierkorb, Expand -- alle immer sichtbar
- PATCH /api/elements/<id> nimmt neu auch ownerRole
- Hilfetexte via Portal (wurden in scrollenden Dialogen abgeschnitten)
- Verteil-Dialoge: Zuordnung je Zeile, nach vom/ins Cash gruppiert,
  Vorbelegung mit dem EFFEKTIVEN Wert inkl. Vererbung (zeigte vorher 0)
- Matrix: gleiche Spaltenbreiten + horizontales Scrollen, "Alle auf-/
  zuklappen", Kategorie-Summe in der zugeklappten Zeile
- Phasen-Detailansicht nutzt die neue Aufteilungs-Grafik
- Uebersicht: "Leer starten" auch im leeren Zustand

Nebenbei: dritte verstuemmelte Hex-Farbe (#7c3aed) repariert, Phasen-Panel
nutzt den eigenen Bestaetigungs-Dialog statt window.confirm; mehrere veraltete
Referenzen und die buildCarryData-Tabelle in der Spez nachgezogen.

SPEZIFIKATION 0.33. 278 -> 288 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:09:03 +02:00
parent 0761f6b3e2
commit 0953880a80
17 changed files with 1040 additions and 267 deletions
+62 -40
View File
@@ -13,7 +13,7 @@ import {
} from "@/lib/constants";
import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import type { ElementCategory } from "@/lib/elements";
import type { ElementCategory, TransitionData } from "@/lib/elements";
import type { PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
@@ -89,6 +89,9 @@ export interface ElementPhaseComputed {
endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
summary: string;
note: string | null;
// Betrag, der aus einem Kapitalbezug (PK/3a) des vorigen Übergangs in dieses Element
// umgeleitet wurde (Punkt C). Nur bei OTHER_ASSET und nur ab Phase 2 > 0.
capitalFromTransfer?: number;
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain)
@@ -293,6 +296,11 @@ interface Carry {
owed: number; // Schulden: Restschuld (positiv)
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
// Betrag, der am letzten Übergang aus einem Kapitalbezug (PK/3a) in dieses Element
// umgeleitet wurde (Roadmap Nr. 44, Punkt C). Wird in der Folgephase wie eine
// Zusatzeinlage behandelt -- dadurch erscheint er im Element, in der Cash-Brücke und im
// Verteil-Dialog, statt unsichtbar im Bestand zu verschwinden.
capitalIn: number;
// Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge,
// Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase.
rates: Record<string, number>;
@@ -309,6 +317,7 @@ function emptyCarry(): Carry {
owed: 0,
pkPensionAnnual: 0,
flowBasis: 0,
capitalIn: 0,
rates: {},
hasCarry: false,
};
@@ -697,7 +706,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
case "OTHER_ASSET": {
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
// Zusatzeinlage = manuell erfasst PLUS das am Übergang umgeleitete Alterskapital.
// Beides ist mechanisch dasselbe (Cash -> Vermögen), deshalb derselbe Weg.
const fromTransfer = carry.hasCarry ? carry.capitalIn : 0;
const topUp = (carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0) + fromTransfer;
ec.capitalFromTransfer = fromTransfer;
carry.capitalIn = 0; // verbraucht -- sonst flösse er in jeder Folgephase erneut
const start = base + topUp;
const rate = Math.round(inherited("annualContribution"));
const withdrawal = Math.round(inherited("annualWithdrawal"));
@@ -1411,6 +1425,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// --- Übergang: Carry aktualisieren, Cash der Folgephase bilden ---
let txInflow = 0;
let txImmediateRepay = 0;
// Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND
// der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt.
const capitalUses: { net: number; td: TransitionData }[] = [];
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
// Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
@@ -1490,6 +1507,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += value - net;
capitalUses.push({ net, td });
carry.value = 0;
carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") {
@@ -1501,6 +1519,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += capital - net;
capitalUses.push({ net, td });
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value - capital;
carry.value = 0;
@@ -1521,6 +1540,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += ec.endValue - net;
capitalUses.push({ net, td });
carry.value = 0;
} else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
@@ -1657,48 +1677,50 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
carry.hasCarry = true;
}
// --- Punkt C (Roadmap Nr. 44): Verwendung des Kapitalzuflusses -----------------------
// --- Punkt C (Roadmap Nr. 44): Verwendung des bezogenen Alterskapitals ---------------
//
// Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, 3a,
// Immobilienverkauf). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht.
// Die Verwendung wird deshalb als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert
// sich der Betrag -- die Aufteilung skaliert mit, statt still falsch zu werden.
// Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, Säule 3a).
// Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Die Verwendung wird
// als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert sich der Betrag -- die
// Aufteilung skaliert mit, statt still falsch zu werden.
//
// Seit 0.33 hängt die Quote am jeweiligen VORSORGE-ELEMENT (PK bzw. 3a) statt am
// Cash-Übergang: Nur so lassen sich zwei Guthaben getrennt verwenden, und die Frage steht
// dort, wo der Bezugs-Entscheid fällt.
//
// Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die
// Anlage-Quote wie eine Zusatzinvestition. Beide sind schon heute Cash-Abflüsse an der
// Grenze und laufen damit korrekt durch beide Brücken.
if (txInflow > 0) {
const ct = phase.cashTransition ?? {};
const amortPct = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
const investPct = Math.max(0, Math.min(100 - amortPct, num(ct.capitalUseInvestPct)));
if (amortPct > 0 || investPct > 0) {
let amortBudget = Math.round((txInflow * amortPct) / 100);
for (const e of orderedElements) {
if (amortBudget <= 0) break;
if (e.category !== "REAL_ESTATE") continue;
const c = carries.get(e.id)!;
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue;
const pay = Math.min(amortBudget, c.mortgage);
c.mortgage -= pay;
amortBudget -= pay;
txImmediateRepay += pay;
}
// Anlage-Quote wie eine Zusatzeinlage in der Folgephase. Beide sind Cash-Abflüsse und
// laufen damit korrekt durch beide Brücken.
for (const use of capitalUses) {
const amortPct = Math.max(0, Math.min(100, num(use.td.capitalUseAmortizationPct)));
const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct)));
if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue;
const investBudget = Math.round((txInflow * investPct) / 100);
if (investBudget > 0) {
const target =
orderedElements.find(
(e) =>
e.id === ct.capitalUseTargetElementId &&
e.category === "OTHER_ASSET" &&
carries.get(e.id)!.status === "ACTIVE"
) ??
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
if (target) {
carries.get(target.id)!.value += investBudget;
txImmediateRepay += investBudget;
}
}
let amortBudget = Math.round((use.net * amortPct) / 100);
for (const e of orderedElements) {
if (amortBudget <= 0) break;
if (e.category !== "REAL_ESTATE") continue;
const c = carries.get(e.id)!;
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue;
const pay = Math.min(amortBudget, c.mortgage);
c.mortgage -= pay;
amortBudget -= pay;
txImmediateRepay += pay;
}
const investBudget = Math.round((use.net * investPct) / 100);
if (investBudget > 0) {
const target =
orderedElements.find(
(e) =>
e.id === use.td.capitalUseTargetElementId &&
e.category === "OTHER_ASSET" &&
carries.get(e.id)!.status === "ACTIVE"
) ??
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
// Der Betrag wandert NICHT direkt in den Bestand, sondern über `capitalIn` in die
// Zusatzeinlage der Folgephase -- dadurch wird er im UI überall sichtbar.
if (target) carries.get(target.id)!.capitalIn += investBudget;
}
}