dfebbeb397
- 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>
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import { computePlan } from "@/lib/calculations";
|
||
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||
import type { PlanInput } from "@/lib/types";
|
||
|
||
// --- kleine Bau-Helfer ---
|
||
let idc = 0;
|
||
const nid = () => `id${idc++}`;
|
||
|
||
function el(
|
||
category: ElementCategory,
|
||
ownerRole: string | null,
|
||
phaseValues: Record<string, PhaseData>,
|
||
transitionValues: Record<string, TransitionData> = {}
|
||
) {
|
||
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
|
||
}
|
||
|
||
function plan(opts: {
|
||
age: number;
|
||
retirementAge: number;
|
||
inflation?: number;
|
||
initialCash?: number;
|
||
phases: { id: string; durationYears: number }[];
|
||
elements: ReturnType<typeof el>[];
|
||
household?: "SINGLE" | "COUPLE";
|
||
}): PlanInput {
|
||
return {
|
||
id: "plan",
|
||
name: "T",
|
||
householdType: opts.household ?? "SINGLE",
|
||
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 })),
|
||
elements: opts.elements,
|
||
};
|
||
}
|
||
|
||
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
||
|
||
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
|
||
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
|
||
describe("V5 Golden Tests", () => {
|
||
it("Test 1 – Ansparen (Einkommen +2% nominal, Ausgaben real flach -> nominal +2%)", () => {
|
||
const p = plan({
|
||
age: 40,
|
||
retirementAge: 60,
|
||
phases: [{ id: "p1", durationYears: 15 }],
|
||
elements: [
|
||
el("INCOME", "PERSON_A", { p1: { amount: 120000, teuerungsausgleich: 2 } }),
|
||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 0 } }),
|
||
],
|
||
});
|
||
const ph = computePlan(p).phases[0];
|
||
expect(within(ph.endWealthNominal, 761654, 0.01)).toBe(true);
|
||
expect(within(ph.endWealthReal, 565928, 0.01)).toBe(true);
|
||
expect(ph.cashNegative).toBe(false);
|
||
});
|
||
|
||
it("Test 2 – Verzehr/Ruin: Ausgaben nominal +2%, Rente fix -> Ruin Alter 94", () => {
|
||
const p = plan({
|
||
age: 65,
|
||
retirementAge: 65,
|
||
phases: [{ id: "p1", durationYears: 35 }],
|
||
elements: [
|
||
el("INCOME", "PERSON_A", { p1: { amount: 60000, teuerungsausgleich: 0 } }), // Rente nominal fix
|
||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 900000, expectedReturn: 3, annualContribution: 0 } }),
|
||
],
|
||
});
|
||
expect(computePlan(p).ruinAge).toBe(94);
|
||
});
|
||
|
||
it("Test 3 – Cash-Ausgleich, Rate 6'364, Cash nie negativ", () => {
|
||
const p = plan({
|
||
age: 40,
|
||
retirementAge: 60,
|
||
phases: [{ id: "p1", durationYears: 3 }],
|
||
elements: [
|
||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 0 } }),
|
||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 6364 } }),
|
||
],
|
||
});
|
||
const ph = computePlan(p).phases[0];
|
||
expect(ph.cashEnd).toBe(5472);
|
||
expect(ph.cashNegative).toBe(false);
|
||
});
|
||
|
||
it("Test 4 – Cash-Ausgleich, Rate 10'000, Cash wird negativ", () => {
|
||
const p = plan({
|
||
age: 40,
|
||
retirementAge: 60,
|
||
phases: [{ id: "p1", durationYears: 3 }],
|
||
elements: [
|
||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 0 } }),
|
||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 10000 } }),
|
||
],
|
||
});
|
||
const ph = computePlan(p).phases[0];
|
||
expect(ph.cashEnd).toBe(-5436);
|
||
expect(ph.cashNegative).toBe(true);
|
||
});
|
||
|
||
it("Ausgaben real -> nominal: reale Basis 100'000, 2% Inflation, 5 J.", () => {
|
||
const p = plan({
|
||
age: 40,
|
||
retirementAge: 60,
|
||
phases: [{ id: "p1", durationYears: 5 }],
|
||
elements: [el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } })],
|
||
});
|
||
const ph = computePlan(p).phases[0];
|
||
expect(ph.expenseStart).toBe(100000); // Jahr 1 nominal = real
|
||
expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243
|
||
});
|
||
|
||
it("Einkommen nominal flach bei Lohnerhoehung 0%", () => {
|
||
const p = plan({
|
||
age: 40,
|
||
retirementAge: 60,
|
||
phases: [{ id: "p1", durationYears: 10 }],
|
||
elements: [el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 } })],
|
||
});
|
||
const ph = computePlan(p).phases[0];
|
||
expect(ph.incomeStart).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 r = computePlan(p);
|
||
const p2 = r.phases[1];
|
||
expect(p2.capitalInflow).toBe(10000); // Verkaufserloes aus dem Uebergang
|
||
expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition
|
||
expect(p2.cashStart).toBe(0); // Startwert bereits nach Abzug der Investition
|
||
expect(p2.cashEnd).toBe(0);
|
||
// Startvermoegen Phase 2: Cash(0) + Vermoegen B(10'000) = 10'000 (kein Doppelzaehlen).
|
||
expect(p2.startWealthNominal).toBe(10000);
|
||
});
|
||
|
||
it("Cash-Anfangswert fliesst in die erste Phase ein", () => {
|
||
const p = plan({
|
||
age: 40,
|
||
retirementAge: 60,
|
||
inflation: 0,
|
||
initialCash: 50000,
|
||
phases: [{ id: "p1", durationYears: 3 }],
|
||
elements: [
|
||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||
],
|
||
});
|
||
const ph = computePlan(p).phases[0];
|
||
expect(ph.cashStart).toBe(50000);
|
||
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,
|
||
retirementAge: 70,
|
||
phases: [
|
||
{ id: "p1", durationYears: 5 },
|
||
{ id: "p2", durationYears: 5 },
|
||
],
|
||
elements: [
|
||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 2 }, p2: {} }),
|
||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
|
||
],
|
||
});
|
||
const r = computePlan(p);
|
||
const expectedBasis = Math.round(100000 * Math.pow(1.02, 5));
|
||
const incomeP2 = r.phases[1].elements.find((e) => e.category === "INCOME")!;
|
||
expect(incomeP2.startValue).toBe(expectedBasis);
|
||
expect(r.phases[1].cashStart).toBe(r.phases[0].cashEnd);
|
||
});
|
||
});
|