Phasenkopf neu + Bezugsrate (Verzehrrate) beim Sonstigen Vermoegen
Deploy App / deploy (push) Successful in 1m2s

- Sonstiges Vermoegen bekommt "Jaehrliche Bezugsrate (Entnahme)": mindert das Vermoegen
  (gekappt am Bestand) und fliesst jaehrlich ins Cash.
- Phasenkopf neu strukturiert: Einkommen / Ausgaben / Quote, dann Geplante Sparrate
  (3a + Vermoegen-Sparbeitrag + Amortisation + Tilgung) und Geplante Verzehrrate
  (Bezugsraten), dann Kapitalzufluss (Verkaeufe + PK-/3a-Bezuege aus dem Uebergang) und
  Kapitalinvestitionen (Zusatz-/Neuinvestitionen + sofortige Tilgungen), dann Vermoegen.
  Die Cash-Zeile im Kopf entfaellt (die Cash-Zeile in der Matrix bleibt).
- Engine liefert plannedSaveRate/plannedWithdrawRate/capitalInflow/capitalInvest.
- Golden Tests: Bezugsrate ins Cash + Kapitalzufluss/-investitionen (10 gruen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 23:11:40 +02:00
parent 435bef6026
commit 3acac56640
5 changed files with 99 additions and 23 deletions
+7 -1
View File
@@ -307,10 +307,16 @@ export function ElementPhaseFields({
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} /> <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField <MoneyField
label="Jaehrlicher Sparbeitrag (CHF)" label="Jaehrlicher Sparbeitrag (CHF)"
help="Flacher Jahresbetrag, der ins Vermoegen fliesst und vom Cash abgezogen wird. Zum Entnehmen im Alter das Element im Uebergang verkaufen." help="Flacher Jahresbetrag, der ins Vermoegen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
value={num(pd.annualContribution)} value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })} onChange={(v) => setP({ annualContribution: v })}
/> />
<MoneyField
label="Jaehrliche Bezugsrate (CHF)"
help="Entnahme aus dem Vermoegen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermoegen und fliesst jaehrlich ins Cash (geplante Verzehrrate)."
value={num(pd.annualWithdrawal)}
onChange={(v) => setP({ annualWithdrawal: v })}
/>
</> </>
); );
case "OTHER_DEBT": case "OTHER_DEBT":
+13 -3
View File
@@ -747,9 +747,19 @@ function PhaseHeader({
<div> <div>
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} {valStr(phase.quotaEnd, dF, mode)} {quotaLabel} {valStr(phase.quotaStart, dS, mode)} {valStr(phase.quotaEnd, dF, mode)}
</div> </div>
<div className={phase.cashNegative ? "text-danger font-medium" : ""}> <div className="my-1 border-t border-border" />
Cash {valStr(phase.cashStart, dS, mode)} {valStr(phase.cashEnd, dE, mode)} <div>Geplante Sparrate {valStr(phase.plannedSaveRate, dS, mode)}</div>
</div> <div>Geplante Verzehrrate {valStr(phase.plannedWithdrawRate, dS, mode)}</div>
{(phase.capitalInflow > 0 || phase.capitalInvest > 0) && (
<>
<div className="my-1 border-t border-border" />
{phase.capitalInflow > 0 && <div>Kapitalzufluss {valStr(phase.capitalInflow, dS, mode)}</div>}
{phase.capitalInvest > 0 && <div>Kapitalinvestitionen {valStr(phase.capitalInvest, dS, mode)}</div>}
</>
)}
<div className="my-1 border-t border-border" />
<div> <div>
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} {valStr(phase.endWealthNominal, dE, mode)} Vermoegen {valStr(phase.startWealthNominal, dS, mode)} {valStr(phase.endWealthNominal, dE, mode)}
</div> </div>
+35
View File
@@ -129,6 +129,41 @@ describe("V5 Golden Tests", () => {
expect(ph.incomeEnd).toBe(80000); expect(ph.incomeEnd).toBe(80000);
}); });
it("Bezugsrate aus Sonstigem Vermoegen fliesst ins Cash (Verzehrrate)", () => {
const p = plan({
age: 65,
retirementAge: 65,
inflation: 0,
phases: [{ id: "p1", durationYears: 3 }],
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 30000, expectedReturn: 0, annualContribution: 0, annualWithdrawal: 10000 } })],
});
const ph = computePlan(p).phases[0];
expect(ph.plannedWithdrawRate).toBe(10000);
expect(ph.cashEnd).toBe(30000); // 3 x 10'000 Entnahme -> Cash
const asset = ph.elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(0);
});
it("Kapitalzufluss/-investitionen: Verkauf in Phase 1, Reinvestition in Phase 2", () => {
const p = plan({
age: 40,
retirementAge: 70,
inflation: 0,
phases: [
{ id: "p1", durationYears: 1 },
{ id: "p2", durationYears: 1 },
],
elements: [
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 10000, expectedReturn: 0, annualContribution: 0 }, p2: {} }, { p1: { decision: "SELL" } }),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 0 }, p2: { additionalInvestment: 10000, expectedReturn: 0 } }),
],
});
const p2 = computePlan(p).phases[1];
expect(p2.capitalInflow).toBe(10000); // Verkaufserloes aus dem Uebergang
expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition
expect(p2.cashEnd).toBe(0);
});
it("Cash-Anfangswert fliesst in die erste Phase ein", () => { it("Cash-Anfangswert fliesst in die erste Phase ein", () => {
const p = plan({ const p = plan({
age: 40, age: 40,
+41 -19
View File
@@ -54,7 +54,10 @@ export interface PhaseComputed {
quotaStart: number; quotaStart: number;
quotaEnd: number; quotaEnd: number;
isConsumption: boolean; isConsumption: boolean;
plannedRatesTotal: number; // Summe der geplanten flachen Jahresraten (3a, Vermoegen, Amort., Tilgung) plannedSaveRate: number; // geplante Sparrate: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. + Tilgung
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase)
capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen
cashStart: number; cashStart: number;
cashEnd: number; cashEnd: number;
cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke
@@ -138,6 +141,9 @@ export function computePlan(plan: PlanInput): PlanComputed {
let yearsBefore = 0; let yearsBefore = 0;
let cumulativeInflation = 1; let cumulativeInflation = 1;
let cashCarryIn = Math.round(plan.initialCash || 0); let cashCarryIn = Math.round(plan.initialCash || 0);
// Aus dem Uebergang der Vorphase in DIESE Phase fliessende Groessen (Kopf-Kennzahlen).
let incomingInflow = 0; // Brutto-Zufluss: Verkaeufe + PK-/3a-Bezuege
let incomingImmediateRepay = 0; // sofortige Schuldentilgungen (Abfluss)
let ruinAge: number | null = null; let ruinAge: number | null = null;
for (let i = 0; i < phases.length; i++) { for (let i = 0; i < phases.length; i++) {
@@ -201,10 +207,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
let renteTotal = 0; // AHV + PK-Renten (nominal fix) let renteTotal = 0; // AHV + PK-Renten (nominal fix)
const assets: { value: number; rate: number; r: number; ec: ElementPhaseComputed }[] = []; const assets: { value: number; rate: number; r: number; withdrawal: number; ec: ElementPhaseComputed }[] = [];
const realEstates: { purchase: number; mortgageStart: number; amort: number; ec: ElementPhaseComputed }[] = []; const realEstates: { purchase: number; mortgageStart: number; amort: number; ec: ElementPhaseComputed }[] = [];
const debts: { owedStart: number; repay: number; ec: ElementPhaseComputed }[] = []; const debts: { owedStart: number; repay: number; ec: ElementPhaseComputed }[] = [];
let plannedRatesTotal = 0; // R: 3a + Sonstiges Vermoegen + Amortisation + Tilgung let plannedRatesTotal = 0; // Sparraten (verlassen das Cash): 3a + Sonstiges Vermoegen + Amort. + Tilgung
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash) let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
let wealthStart = 0; let wealthStart = 0;
let wealthEnd = 0; // wird nach der Jahresschleife gefuellt let wealthEnd = 0; // wird nach der Jahresschleife gefuellt
@@ -292,7 +299,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
ec.baseValue = base; ec.baseValue = base;
ec.startValue = start; ec.startValue = start;
wealthStart += start; wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), ec }); assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, ec });
} }
break; break;
} }
@@ -310,7 +317,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
ec.baseValue = base; ec.baseValue = base;
ec.startValue = start; ec.startValue = start;
wealthStart += start; wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), ec }); assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, ec });
} }
break; break;
} }
@@ -319,12 +326,14 @@ export function computePlan(plan: PlanInput): PlanComputed {
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp; const start = base + topUp;
const rate = Math.round(num(pd.annualContribution)); const rate = Math.round(num(pd.annualContribution));
const withdrawal = Math.round(num(pd.annualWithdrawal));
plannedRatesTotal += rate; plannedRatesTotal += rate;
plannedWithdrawTotal += withdrawal;
if (!isFirstPhase) investmentsFromCash += topUp; if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base; ec.baseValue = base;
ec.startValue = start; ec.startValue = start;
wealthStart += start; wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), ec }); assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, ec });
break; break;
} }
case "REAL_ESTATE": { case "REAL_ESTATE": {
@@ -394,8 +403,15 @@ export function computePlan(plan: PlanInput): PlanComputed {
quotaEnd = quote; quotaEnd = quote;
} }
cash += quote - plannedRatesTotal; // Vermoegen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
for (const a of assets) a.value = a.value * (1 + a.r / 100) + a.rate; let cashFromWithdraw = 0;
for (const a of assets) {
const grown = a.value * (1 + a.r / 100) + a.rate;
const w = Math.min(a.withdrawal, Math.max(0, grown));
a.value = grown - w;
cashFromWithdraw += w;
}
cash += quote - plannedRatesTotal + cashFromWithdraw;
if (cash < 0) cashNegative = true; if (cash < 0) cashNegative = true;
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung). // Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
@@ -460,7 +476,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
quotaStart: Math.round(quotaStart), quotaStart: Math.round(quotaStart),
quotaEnd: Math.round(quotaEnd), quotaEnd: Math.round(quotaEnd),
isConsumption: quotaStart < 0, isConsumption: quotaStart < 0,
plannedRatesTotal, plannedSaveRate: plannedRatesTotal,
plannedWithdrawRate: plannedWithdrawTotal,
capitalInflow: Math.round(incomingInflow),
capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay),
cashStart: Math.round(cashStart), cashStart: Math.round(cashStart),
cashEnd, cashEnd,
cashNegative, cashNegative,
@@ -475,7 +494,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
}); });
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden --- // --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
let outgoing = 0; let txInflow = 0;
let txImmediateRepay = 0;
for (const e of orderedElements) { for (const e of orderedElements) {
const carry = carries.get(e.id)!; const carry = carries.get(e.id)!;
const ec = ecById.get(e.id)!; const ec = ecById.get(e.id)!;
@@ -503,7 +523,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const value = ec.endValue; const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION"; const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") { if (mode === "CAPITAL") {
outgoing += Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); txInflow += Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
carry.value = 0; carry.value = 0;
carry.pkPensionAnnual = 0; carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") { } else if (mode === "PENSION") {
@@ -511,31 +531,31 @@ export function computePlan(plan: PlanInput): PlanComputed {
carry.value = 0; carry.value = 0;
} else { } else {
const capital = Math.min(value, Math.round(num(td.capitalAmount))); const capital = Math.min(value, Math.round(num(td.capitalAmount)));
outgoing += Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); txInflow += Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
carry.value = 0; carry.value = 0;
} }
} else { } else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal; carry.value = ec.endValue - withdrawal;
outgoing += withdrawal; txInflow += withdrawal;
} }
break; break;
} }
case "PILLAR_3A": { case "PILLAR_3A": {
if (ownerRetiresNext) { if (ownerRetiresNext) {
outgoing += Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); txInflow += Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
carry.value = 0; carry.value = 0;
} else { } else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal; carry.value = ec.endValue - withdrawal;
outgoing += withdrawal; txInflow += withdrawal;
} }
break; break;
} }
case "OTHER_ASSET": { case "OTHER_ASSET": {
if (td.decision === "SELL") { if (td.decision === "SELL") {
outgoing += ec.endValue; txInflow += ec.endValue;
carry.status = "SOLD"; carry.status = "SOLD";
} else { } else {
carry.value = ec.endValue; carry.value = ec.endValue;
@@ -550,7 +570,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const salePrice = Math.round(num(td.salePrice)); const salePrice = Math.round(num(td.salePrice));
const gain = Math.max(0, salePrice - purchase); const gain = Math.max(0, salePrice - purchase);
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100); const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
outgoing += Math.round(salePrice - restMortgage - tax); txInflow += Math.round(salePrice - restMortgage - tax);
carry.status = "SOLD"; carry.status = "SOLD";
} else { } else {
carry.mortgage = restMortgage; carry.mortgage = restMortgage;
@@ -563,7 +583,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment))); const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment)));
if (immediate > 0) { if (immediate > 0) {
carry.owed = Math.max(0, carry.owed - immediate); carry.owed = Math.max(0, carry.owed - immediate);
outgoing -= immediate; txImmediateRepay += immediate;
} }
if (carry.owed === 0) carry.status = "SETTLED"; if (carry.owed === 0) carry.status = "SETTLED";
break; break;
@@ -574,7 +594,9 @@ export function computePlan(plan: PlanInput): PlanComputed {
carry.hasCarry = true; carry.hasCarry = true;
} }
cashCarryIn = cashEnd + outgoing; cashCarryIn = cashEnd + txInflow - txImmediateRepay;
incomingInflow = txInflow;
incomingImmediateRepay = txImmediateRepay;
yearsBefore += duration; yearsBefore += duration;
} }
+3
View File
@@ -63,6 +63,8 @@ export interface PhaseData {
startValue?: number; startValue?: number;
expectedReturn?: number; expectedReturn?: number;
annualContribution?: number; annualContribution?: number;
// OTHER_ASSET: jaehrliche Bezugsrate (Entnahme). Mindert das Vermoegen und fliesst ins Cash.
annualWithdrawal?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusaetzliche Einlage aus dem // PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusaetzliche Einlage aus dem
// verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben. // verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
additionalInvestment?: number; additionalInvestment?: number;
@@ -108,6 +110,7 @@ export const phaseDataSchema = z
startValue: nonNeg.optional(), startValue: nonNeg.optional(),
expectedReturn: z.number().min(-50).max(100).optional(), expectedReturn: z.number().min(-50).max(100).optional(),
annualContribution: nonNeg.optional(), annualContribution: nonNeg.optional(),
annualWithdrawal: nonNeg.optional(),
additionalInvestment: nonNeg.optional(), additionalInvestment: nonNeg.optional(),
purchasePrice: nonNeg.optional(), purchasePrice: nonNeg.optional(),
mortgage: nonNeg.optional(), mortgage: nonNeg.optional(),