diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 935015e..4507469 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -307,10 +307,16 @@ export function ElementPhaseFields({ setP({ expectedReturn: v })} /> setP({ annualContribution: v })} /> + setP({ annualWithdrawal: v })} + /> ); case "OTHER_DEBT": diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index b94784c..d70b3dc 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -747,9 +747,19 @@ function PhaseHeader({
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} → {valStr(phase.quotaEnd, dF, mode)}
-
- Cash {valStr(phase.cashStart, dS, mode)} → {valStr(phase.cashEnd, dE, mode)} -
+
+
Geplante Sparrate {valStr(phase.plannedSaveRate, dS, mode)}
+
Geplante Verzehrrate {valStr(phase.plannedWithdrawRate, dS, mode)}
+ + {(phase.capitalInflow > 0 || phase.capitalInvest > 0) && ( + <> +
+ {phase.capitalInflow > 0 &&
Kapitalzufluss {valStr(phase.capitalInflow, dS, mode)}
} + {phase.capitalInvest > 0 &&
Kapitalinvestitionen {valStr(phase.capitalInvest, dS, mode)}
} + + )} + +
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)}
diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts index 0fc6aaf..a5986d6 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -129,6 +129,41 @@ describe("V5 Golden Tests", () => { 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", () => { const p = plan({ age: 40, diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 2dd1d85..4514934 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -54,7 +54,10 @@ export interface PhaseComputed { quotaStart: number; quotaEnd: number; 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; cashEnd: number; 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 cumulativeInflation = 1; 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; 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 expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; 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 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 wealthStart = 0; let wealthEnd = 0; // wird nach der Jahresschleife gefuellt @@ -292,7 +299,7 @@ export function computePlan(plan: PlanInput): PlanComputed { ec.baseValue = base; ec.startValue = 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; } @@ -310,7 +317,7 @@ export function computePlan(plan: PlanInput): PlanComputed { ec.baseValue = base; ec.startValue = 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; } @@ -319,12 +326,14 @@ export function computePlan(plan: PlanInput): PlanComputed { const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; const rate = Math.round(num(pd.annualContribution)); + const withdrawal = Math.round(num(pd.annualWithdrawal)); plannedRatesTotal += rate; + plannedWithdrawTotal += withdrawal; if (!isFirstPhase) investmentsFromCash += topUp; ec.baseValue = base; ec.startValue = 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; } case "REAL_ESTATE": { @@ -394,8 +403,15 @@ export function computePlan(plan: PlanInput): PlanComputed { quotaEnd = quote; } - cash += quote - plannedRatesTotal; - for (const a of assets) a.value = a.value * (1 + a.r / 100) + a.rate; + // Vermoegen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash. + 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; // Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung). @@ -460,7 +476,10 @@ export function computePlan(plan: PlanInput): PlanComputed { quotaStart: Math.round(quotaStart), quotaEnd: Math.round(quotaEnd), isConsumption: quotaStart < 0, - plannedRatesTotal, + plannedSaveRate: plannedRatesTotal, + plannedWithdrawRate: plannedWithdrawTotal, + capitalInflow: Math.round(incomingInflow), + capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay), cashStart: Math.round(cashStart), cashEnd, cashNegative, @@ -475,7 +494,8 @@ export function computePlan(plan: PlanInput): PlanComputed { }); // --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden --- - let outgoing = 0; + let txInflow = 0; + let txImmediateRepay = 0; for (const e of orderedElements) { const carry = carries.get(e.id)!; const ec = ecById.get(e.id)!; @@ -503,7 +523,7 @@ export function computePlan(plan: PlanInput): PlanComputed { const value = ec.endValue; const mode = td.payoutMode ?? "PENSION"; 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.pkPensionAnnual = 0; } else if (mode === "PENSION") { @@ -511,31 +531,31 @@ export function computePlan(plan: PlanInput): PlanComputed { carry.value = 0; } else { 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.value = 0; } } else { const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); carry.value = ec.endValue - withdrawal; - outgoing += withdrawal; + txInflow += withdrawal; } break; } case "PILLAR_3A": { 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; } else { const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); carry.value = ec.endValue - withdrawal; - outgoing += withdrawal; + txInflow += withdrawal; } break; } case "OTHER_ASSET": { if (td.decision === "SELL") { - outgoing += ec.endValue; + txInflow += ec.endValue; carry.status = "SOLD"; } else { carry.value = ec.endValue; @@ -550,7 +570,7 @@ export function computePlan(plan: PlanInput): PlanComputed { const salePrice = Math.round(num(td.salePrice)); const gain = Math.max(0, salePrice - purchase); 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"; } else { carry.mortgage = restMortgage; @@ -563,7 +583,7 @@ export function computePlan(plan: PlanInput): PlanComputed { const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment))); if (immediate > 0) { carry.owed = Math.max(0, carry.owed - immediate); - outgoing -= immediate; + txImmediateRepay += immediate; } if (carry.owed === 0) carry.status = "SETTLED"; break; @@ -574,7 +594,9 @@ export function computePlan(plan: PlanInput): PlanComputed { carry.hasCarry = true; } - cashCarryIn = cashEnd + outgoing; + cashCarryIn = cashEnd + txInflow - txImmediateRepay; + incomingInflow = txInflow; + incomingImmediateRepay = txImmediateRepay; yearsBefore += duration; } diff --git a/src/lib/elements.ts b/src/lib/elements.ts index 03251ae..ed2ba1f 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -63,6 +63,8 @@ export interface PhaseData { startValue?: number; expectedReturn?: 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 // verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben. additionalInvestment?: number; @@ -108,6 +110,7 @@ export const phaseDataSchema = z startValue: nonNeg.optional(), expectedReturn: z.number().min(-50).max(100).optional(), annualContribution: nonNeg.optional(), + annualWithdrawal: nonNeg.optional(), additionalInvestment: nonNeg.optional(), purchasePrice: nonNeg.optional(), mortgage: nonNeg.optional(),