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
+35
View File
@@ -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
View File
@@ -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;
}
+3
View File
@@ -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(),