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, transitionValues: Record = {} ) { 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[]; 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); }); });