import { describe, it, expect } from "vitest"; import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations"; import type { CashTransitionData, 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; cashTransition?: CashTransitionData }[]; 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, cashTransition: p.cashTransition ?? {}, })), elements: opts.elements, }; } const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct; // Stuetzstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44" // (BSV, gueltig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente, // Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab. const AHV_AMTLICHE_TABELLE: [number, number][] = [ [15120, 1260], // Mindestrente (12 x R0) [16632, 1293], [22680, 1424], [30240, 1588], [43848, 1882], [45360, 1915], // Wendepunkt (36 x R0) [46872, 1935], [60480, 2117], [75600, 2318], [89208, 2500], [90720, 2520], // Maximalrente (72 x R0) ]; describe("AHV-Rentenformel (Skala 44)", () => { it("reproduziert die amtliche Tabelle 318.117.1 exakt", () => { for (const [mdJE, erwartet] of AHV_AMTLICHE_TABELLE) { expect(Math.round(ahvMonthlyFullPension(mdJE)), `mdJE ${mdJE}`).toBe(erwartet); } }); it("kappt ausserhalb der Schwellen", () => { expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente expect(ahvMonthlyFullPension(10000)).toBe(1260); expect(ahvMonthlyFullPension(150000)).toBe(2520); // ueber 72 x R0 -> Maximalrente }); it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => { const links = ahvMonthlyFullPension(45360 - 0.01); const rechts = ahvMonthlyFullPension(45360 + 0.01); expect(Math.abs(links - rechts)).toBeLessThan(0.01); }); }); // V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis + // reale Mehrausgaben, nominal = real x (plan-weite Inflation). describe("AHV einkommensabhaengig", () => { // 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan. function ahvPlan(opts: { age?: number; income: number; avgIncomeBefore?: number; gapYearsBefore?: number; gapYearsInPlan?: number; reviewed?: boolean; }) { const age = opts.age ?? 60; return plan({ age, retirementAge: 65, inflation: 0, // real = nominal, damit die Erwartungswerte von Hand pruefbar bleiben phases: [ { id: "p1", durationYears: 65 - age }, { id: "p2", durationYears: 10 }, ], elements: [ el("INCOME", "PERSON_A", { p1: { amount: opts.income, teuerungsausgleich: 0 }, p2: {} }), el( "AHV", "PERSON_A", { p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} }, { p1: { reviewed: opts.reviewed ?? true, avgIncomeBefore: opts.avgIncomeBefore ?? 0, gapYearsBefore: opts.gapYearsBefore ?? 0, }, } ), ], }); } const renteIn = (p: PlanInput, phaseIdx: number) => computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.startValue; it("volle Karriere auf Maximalniveau -> Maximalrente 32'760", () => { const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000 }); expect(renteIn(p, 1)).toBe(32760); // 2'520 x 13 }); it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => { // mdJE = 60'000 -> 1'260 x (1.04 + 0.16 x 60'000/15'120) = 2'118.1/Monat x 13 = 27'535 const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 }); const erwartet = Math.round(ahvMonthlyFullPension(60000) * 13); expect(renteIn(p, 1)).toBe(erwartet); expect(renteIn(p, 1)).toBeLessThan(32760); }); it("tiefes Einkommen -> Mindestrente 1'260 x 13", () => { const p = ahvPlan({ income: 10000, avgIncomeBefore: 10000 }); expect(renteIn(p, 1)).toBe(1260 * 13); }); it("Einkommen vor Planbeginn dominiert bei kurzer Restlaufzeit", () => { // 39 Jahre vor Planbeginn zu 40'000, nur 5 Jahre im Plan zu 200'000. const p = ahvPlan({ income: 200000, avgIncomeBefore: 40000 }); const mdJE = (40000 * 39 + 200000 * 5) / 44; // = 58'181.8 expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13)); }); it("Ausfalljahre kuerzen die Rente ueber die Skala 44", () => { const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 }); const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 }); expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger expect(renteIn(mit, 1)).toBeLessThan(renteIn(ohne, 1)); }); it("Ausfalljahre im Plan senken die Beitragsjahre, nicht das Durchschnittseinkommen", () => { // Gleiches Einkommen, aber 2 Ausfalljahre im Plan -> nur die Skala sinkt. const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsInPlan: 2 }); expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44))); }); it("ohne Pruefung gilt der geplante Durchschnitt auch fuer die Jahre vor Planbeginn", () => { // Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%). const p = plan({ age: 60, retirementAge: 65, inflation: 0, phases: [ { id: "p1", durationYears: 5 }, { id: "p2", durationYears: 5 }, ], elements: [ el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }), el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Uebergangsdaten ], }); const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue; expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000) * 13)); // mdJE = 80'000, nicht 9'091 }); it("bereits bei Planbeginn pensioniert: Karriere kommt aus der Phasenzelle", () => { const p = plan({ age: 66, retirementAge: 65, inflation: 0, phases: [{ id: "p1", durationYears: 10 }], elements: [el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 60000, gapYearsBefore: 0 } })], }); const rente = computePlan(p).phases[0].elements.find((e) => e.category === "AHV")!.startValue; expect(rente).toBe(Math.round(ahvMonthlyFullPension(60000) * 13)); }); it("Ehepaar-Plafonierung greift weiterhin (150% der Maximalrente)", () => { const p: PlanInput = { id: "plan", name: "T", householdType: "COUPLE", inflationRateDefault: 0, initialCash: 0, persons: [ { id: "A", role: "PERSON_A", name: null, age: 66, retirementAge: 65 }, { id: "B", role: "PERSON_B", name: null, age: 66, retirementAge: 65 }, ], phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }], elements: [ el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 100000 } }), el("AHV", "PERSON_B", { p1: { avgIncomeBefore: 100000 } }), ], }; const ph = computePlan(p).phases[0]; const summe = ph.elements.filter((e) => e.category === "AHV").reduce((s, e) => s + e.startValue, 0); expect(summe).toBe(Math.round(32760 * 1.5)); // plafoniert, nicht 2 x 32'760 }); }); 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("Einmaliger Zufluss am Uebergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => { const p = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 1000, phases: [ { id: "p1", durationYears: 2, cashTransition: { mode: "INFLOW", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10 } }, { id: "p2", durationYears: 1 }, ], elements: [], }); const r = computePlan(p); expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzueglich 10% Steuer expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft"); expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000 expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Uebergang }); it("Einmalige Kosten am Uebergang: real erfasst, mit Inflation aufgewertet", () => { // Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380. const p = plan({ age: 40, retirementAge: 70, inflation: 2, initialCash: 100000, phases: [ { id: "p1", durationYears: 10, cashTransition: { mode: "OUTFLOW", outflowLabel: "Poolbau", outflowAmount: 20000 } }, { id: "p2", durationYears: 1 }, ], elements: [], }); const r = computePlan(p); const erwartet = Math.round(20000 * Math.pow(1.02, 10)); expect(r.phases[1].oneOffOutflow).toBe(erwartet); expect(r.phases[1].oneOffOutflowLabel).toBe("Poolbau"); expect(r.phases[1].cashStart).toBe(100000 - erwartet); }); it("Zufluss und Kosten zusammen (BOTH); Modus NONE bleibt wirkungslos", () => { const beide = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 0, phases: [ { id: "p1", durationYears: 1, cashTransition: { mode: "BOTH", inflowAmount: 50000, outflowAmount: 20000 } }, { id: "p2", durationYears: 1 }, ], elements: [], }); const r1 = computePlan(beide); expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000 // Betraege sind erfasst, aber der Entscheid lautet "1:1 uebernehmen" -> keine Wirkung. const keine = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 0, phases: [ { id: "p1", durationYears: 1, cashTransition: { mode: "NONE", inflowAmount: 50000, outflowAmount: 20000 } }, { id: "p2", durationYears: 1 }, ], elements: [], }); expect(computePlan(keine).phases[1].cashStart).toBe(0); }); it("Einmalige Kosten koennen eine Liquiditaetsluecke ausloesen", () => { const p = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 10000, phases: [ { id: "p1", durationYears: 1, cashTransition: { mode: "OUTFLOW", outflowAmount: 25000 } }, { id: "p2", durationYears: 1 }, ], elements: [], }); const p2 = computePlan(p).phases[1]; expect(p2.cashStart).toBe(-15000); expect(p2.cashNegative).toBe(true); expect(p2.incomplete).toBe(true); }); it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Uebergang mehr)", () => { const p = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 5000, phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }], elements: [], }); const r = computePlan(p); expect(r.phases[0].cashEnd).toBe(5000); expect(r.nachlass).toBe(5000); }); 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); }); });