Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Deploy App / deploy (push) Successful in 1m10s
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:
+62
-40
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user