Drei Berechnungs-Fixes: Phaseninflation, Tilgungsraten, Vorbezugssteuer
- Phase.inflationRate ersatzlos entfernt (inkl. Migration). Die Inflation liegt seit V5 plan-weit; das Feld wurde von computePlan nie gelesen und war wirkungslos. - Amortisation und Tilgung stoppen, sobald Hypothek bzw. Schuld abbezahlt sind: Hypothek/Restschuld sind neu laufende Salden, die Rate ist pro Jahr am Restsaldo gekappt. Belastet danach weder Cash noch Sparquote. - Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezuegen vor der Pensionierung. Der Bezug wird brutto dem Kapital entnommen, netto ins Cash gebucht. plannedSaveRate ist neu die Rate des ersten Phasenjahres. Drei Regressionstests ergaenzt (10 -> 13). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,7 @@ function plan(opts: {
|
||||
inflationRateDefault: opts.inflation ?? 2,
|
||||
initialCash: opts.initialCash ?? 0,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
||||
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears, inflationRate: null })),
|
||||
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears })),
|
||||
elements: opts.elements,
|
||||
};
|
||||
}
|
||||
@@ -185,6 +185,72 @@ describe("V5 Golden Tests", () => {
|
||||
expect(ph.cashEnd).toBe(50000);
|
||||
});
|
||||
|
||||
it("Tilgung stoppt, sobald die Schuld getilgt ist (belastet Cash und Sparrate nicht weiter)", () => {
|
||||
// Schuld 25'000, Tilgung 10'000/Jahr, 5 Jahre: getilgt im Jahr 3 (10'000 + 10'000 + 5'000).
|
||||
// Gesamtabfluss = 25'000, NICHT 50'000. Einkommen = Ausgaben, damit nur die Tilgung wirkt.
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
inflation: 0,
|
||||
initialCash: 100000,
|
||||
phases: [{ id: "p1", durationYears: 5 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 50000, teuerungsausgleich: 0 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000, teuerungsausgleich: 0 } }),
|
||||
el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 25000, annualRepayment: 10000 } }),
|
||||
],
|
||||
});
|
||||
const ph = computePlan(p).phases[0];
|
||||
expect(ph.cashEnd).toBe(75000); // 100'000 - 25'000
|
||||
expect(ph.plannedSaveRate).toBe(10000); // erstes Jahr volle Rate
|
||||
const debt = ph.elements.find((e) => e.category === "OTHER_DEBT")!;
|
||||
expect(debt.endValue).toBe(0);
|
||||
});
|
||||
|
||||
it("Amortisation stoppt, sobald die Hypothek abbezahlt ist", () => {
|
||||
// Hypothek 15'000, Amortisation 10'000/Jahr, 4 Jahre: abbezahlt im Jahr 2 (10'000 + 5'000).
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
inflation: 0,
|
||||
initialCash: 100000,
|
||||
phases: [{ id: "p1", durationYears: 4 }],
|
||||
elements: [
|
||||
el("REAL_ESTATE", "HOUSEHOLD", { p1: { purchasePrice: 500000, mortgage: 15000, amortization: 10000 } }),
|
||||
],
|
||||
});
|
||||
const ph = computePlan(p).phases[0];
|
||||
expect(ph.cashEnd).toBe(85000); // 100'000 - 15'000 (nicht - 40'000)
|
||||
const re = ph.elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
expect(re.endValue).toBe(500000); // schuldenfrei
|
||||
});
|
||||
|
||||
it("Vorbezug PK vor Pensionierung: Kapitalbezugssteuer wird abgezogen", () => {
|
||||
// Bezug 100'000 brutto bei 8% Steuer -> 92'000 netto ins Cash der Folgephase.
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 1 },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 300000, expectedReturn: 0, annualContribution: 0 }, p2: {} },
|
||||
{ p1: { withdrawalMode: "AMOUNT", withdrawal: 100000, capitalTaxRate: 8 } }
|
||||
),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
expect(r.phases[1].capitalInflow).toBe(92000); // netto nach 8% Steuer
|
||||
expect(r.phases[1].cashStart).toBe(92000);
|
||||
const pk = r.phases[1].elements.find((e) => e.category === "PENSION_FUND")!;
|
||||
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
|
||||
});
|
||||
|
||||
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
|
||||
+41
-21
@@ -54,7 +54,10 @@ export interface PhaseComputed {
|
||||
quotaStart: number;
|
||||
quotaEnd: number;
|
||||
isConsumption: boolean;
|
||||
plannedSaveRate: number; // geplante Sparrate: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. + Tilgung
|
||||
// Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. +
|
||||
// Tilgung. Amortisation/Tilgung entfallen, sobald Hypothek/Schuld abbezahlt sind -- die Rate
|
||||
// kann in spaeteren Phasenjahren also tiefer liegen.
|
||||
plannedSaveRate: number;
|
||||
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
|
||||
@@ -208,9 +211,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
||||
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; // Sparraten (verlassen das Cash): 3a + Sonstiges Vermoegen + Amort. + Tilgung
|
||||
// mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
|
||||
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt).
|
||||
const realEstates: { purchase: number; mortgage: number; amort: number; ec: ElementPhaseComputed }[] = [];
|
||||
const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
|
||||
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen
|
||||
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen
|
||||
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
|
||||
let wealthStart = 0;
|
||||
@@ -312,7 +317,7 @@ 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));
|
||||
plannedRatesTotal += rate;
|
||||
fixedRatesTotal += rate;
|
||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||
ec.baseValue = base;
|
||||
ec.startValue = start;
|
||||
@@ -327,7 +332,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const start = base + topUp;
|
||||
const rate = Math.round(num(pd.annualContribution));
|
||||
const withdrawal = Math.round(num(pd.annualWithdrawal));
|
||||
plannedRatesTotal += rate;
|
||||
fixedRatesTotal += rate;
|
||||
plannedWithdrawTotal += withdrawal;
|
||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||
ec.baseValue = base;
|
||||
@@ -340,23 +345,21 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const purchase = Math.round(num(pd.purchasePrice));
|
||||
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
|
||||
const amort = Math.round(num(pd.amortization));
|
||||
plannedRatesTotal += amort;
|
||||
const equity = purchase - mortgageStart;
|
||||
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
|
||||
ec.baseValue = equity;
|
||||
ec.startValue = equity;
|
||||
wealthStart += equity;
|
||||
realEstates.push({ purchase, mortgageStart, amort, ec });
|
||||
realEstates.push({ purchase, mortgage: mortgageStart, amort, ec });
|
||||
break;
|
||||
}
|
||||
case "OTHER_DEBT": {
|
||||
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
|
||||
const repay = Math.round(num(pd.annualRepayment));
|
||||
plannedRatesTotal += repay;
|
||||
ec.baseValue = -owedStart;
|
||||
ec.startValue = -owedStart;
|
||||
wealthStart += -owedStart;
|
||||
debts.push({ owedStart, repay, ec });
|
||||
debts.push({ owed: owedStart, repay, ec });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -374,6 +377,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
let expenseEnd = 0;
|
||||
let quotaStart = 0;
|
||||
let quotaEnd = 0;
|
||||
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr
|
||||
|
||||
for (let t = 1; t <= duration; t++) {
|
||||
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
|
||||
@@ -413,14 +417,30 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
a.value = grown - w;
|
||||
cashFromWithdraw += w;
|
||||
}
|
||||
cash += quote - plannedRatesTotal + cashFromWithdraw;
|
||||
|
||||
// Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
|
||||
// Hypothek/Schuld abbezahlt, entfaellt die Rate -- sie belastet weder Cash noch Sparquote.
|
||||
let debtRates = 0;
|
||||
for (const re of realEstates) {
|
||||
const pay = Math.min(re.amort, re.mortgage);
|
||||
re.mortgage -= pay;
|
||||
debtRates += pay;
|
||||
}
|
||||
for (const d of debts) {
|
||||
const pay = Math.min(d.repay, d.owed);
|
||||
d.owed -= pay;
|
||||
debtRates += pay;
|
||||
}
|
||||
if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates;
|
||||
|
||||
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
|
||||
if (cash < 0) cashNegative = true;
|
||||
|
||||
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
|
||||
let total = cash;
|
||||
for (const a of assets) total += a.value;
|
||||
for (const re of realEstates) total += re.purchase - Math.max(0, re.mortgageStart - re.amort * t);
|
||||
for (const d of debts) total += -Math.max(0, d.owedStart - d.repay * t);
|
||||
for (const re of realEstates) total += re.purchase - re.mortgage;
|
||||
for (const d of debts) total += -d.owed;
|
||||
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
|
||||
}
|
||||
|
||||
@@ -445,17 +465,15 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
wealthEnd += a.ec.endValue;
|
||||
}
|
||||
for (const re of realEstates) {
|
||||
const mortgageEnd = Math.max(0, re.mortgageStart - re.amort * duration);
|
||||
re.ec.endValue = re.purchase - mortgageEnd;
|
||||
re.ec.endValue = re.purchase - re.mortgage;
|
||||
re.ec.summary = fmt(re.ec.endValue);
|
||||
wealthEnd += re.ec.endValue;
|
||||
}
|
||||
for (const d of debts) {
|
||||
const owedEnd = Math.max(0, d.owedStart - d.repay * duration);
|
||||
d.ec.endValue = -owedEnd;
|
||||
d.ec.endValue = d.owed > 0 ? -d.owed : 0;
|
||||
d.ec.summary = fmt(d.ec.endValue);
|
||||
wealthEnd += d.ec.endValue;
|
||||
if (owedEnd === 0) d.ec.note = "Wird getilgt";
|
||||
if (d.owed === 0) d.ec.note = "Wird getilgt";
|
||||
}
|
||||
|
||||
const cashEnd = Math.round(cash);
|
||||
@@ -478,7 +496,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
quotaStart: Math.round(quotaStart),
|
||||
quotaEnd: Math.round(quotaEnd),
|
||||
isConsumption: quotaStart < 0,
|
||||
plannedSaveRate: plannedRatesTotal,
|
||||
plannedSaveRate,
|
||||
plannedWithdrawRate: plannedWithdrawTotal,
|
||||
capitalInflow: Math.round(incomingInflow),
|
||||
capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay),
|
||||
@@ -538,9 +556,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
carry.value = 0;
|
||||
}
|
||||
} else {
|
||||
// Vorbezug (z. B. Wohneigentum/Selbststaendigkeit): ebenfalls kapitalbezugssteuerpflichtig.
|
||||
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
|
||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||
carry.value = ec.endValue - withdrawal;
|
||||
txInflow += withdrawal;
|
||||
txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -551,7 +571,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
} else {
|
||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||
carry.value = ec.endValue - withdrawal;
|
||||
txInflow += withdrawal;
|
||||
txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
inflationRate: phase.inflationRate,
|
||||
})),
|
||||
elements: plan.elements.map((e) => {
|
||||
const phaseValues: Record<string, PhaseData> = {};
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface PhaseInput {
|
||||
sequenceNumber: number;
|
||||
name: string;
|
||||
durationYears: number;
|
||||
inflationRate: number | null;
|
||||
}
|
||||
|
||||
export interface ElementInput {
|
||||
|
||||
Reference in New Issue
Block a user