Phasenkopf neu + Bezugsrate (Verzehrrate) beim Sonstigen Vermoegen
Deploy App / deploy (push) Successful in 1m2s
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:
@@ -307,10 +307,16 @@ export function ElementPhaseFields({
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
<MoneyField
|
||||
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)}
|
||||
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":
|
||||
|
||||
@@ -747,9 +747,19 @@ function PhaseHeader({
|
||||
<div>
|
||||
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} → {valStr(phase.quotaEnd, dF, mode)}
|
||||
</div>
|
||||
<div className={phase.cashNegative ? "text-danger font-medium" : ""}>
|
||||
Cash {valStr(phase.cashStart, dS, mode)} → {valStr(phase.cashEnd, dE, mode)}
|
||||
</div>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<div>Geplante Sparrate {valStr(phase.plannedSaveRate, dS, mode)}</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>
|
||||
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+41
-19
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user