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
+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 });
}
}
}