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
+77 -30
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) => (
<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.kind === "investment" ? "Zusatzeinlage" : t.kind === "amortization" ? "Sonderamortisation" : "Sofortige Tilgung"}
</span>
<InfoBubble text={t.hint} />
{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="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"}
</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={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>
<MoneyField
label="Betrag (CHF)"
value={draft[t.elementId] ?? 0}
max={t.max}
onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))}
/>
</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.",
});
+24 -6
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}
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>
<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(