Kapitalbezug: Personenzuordnung, Herkunft und Filter fuer bezogene Guthaben
Deploy App / deploy (push) Successful in 1m9s
Deploy App / deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user