import { describe, it, expect } from "vitest"; import { computePlan } from "@/lib/calculations"; import { applyPhasePatches, applyTransitionPatches, capitalPot, quotaSummary, } from "@/lib/distribution"; import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; import type { PlanInput } from "@/lib/types"; let idc = 0; const nid = () => `d${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; initialCash?: number; phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[]; elements: ReturnType[]; }): PlanInput { return { id: "plan", name: "T", householdType: "SINGLE", inflationRateDefault: 1.5, 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, }; } // Zwei Phasen; am Übergang wird Vermögen verkauft und eine Erbschaft fliesst zu -- damit // entsteht in Phase 2 ein verteilbarer Kapitaltopf. function basePlan() { const asset = el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 0 }, p2: { expectedReturn: 0 }, }, { p1: { decision: "SELL" } }); const debt = el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000 }, p2: {} }, { p1: {} }); const invest = el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: { expectedReturn: 0 } }); return { p: plan({ age: 40, retirementAge: 70, initialCash: 10000, phases: [ { id: "p1", durationYears: 5, cashTransition: { mode: "INFLOW", inflowAmount: 100000, inflowTaxRate: 0 } }, { id: "p2", durationYears: 5 }, ], elements: [ el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }), el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }), asset, debt, invest, ], }), assetId: asset.id, debtId: debt.id, investId: invest.id, }; } describe("Verteilung: verfügbares Kapital", () => { it("Topf setzt sich aus Vorphasen-Cash, Kapitalzufluss und Einmalzufluss zusammen", () => { const { p } = basePlan(); const r = computePlan(p); const pot = capitalPot(r.phases[1]); // Der Topf ist die Summe der Zuflüsse minus einmaliger Kosten. expect(pot.total).toBe(pot.fromPreviousCash + pot.capitalInflow + pot.oneOffInflow - pot.oneOffOutflow); expect(pot.capitalInflow).toBe(200000); // Verkauf des Vermögens expect(pot.oneOffInflow).toBe(100000); // Erbschaft, steuerfrei // Nichts verteilt -> alles bleibt auf dem Cash. expect(pot.allocatedInvestments).toBe(0); expect(pot.allocatedRepayments).toBe(0); expect(pot.rest).toBe(pot.total); expect(pot.rest).toBe(r.phases[1].cashStart); }); it("Zusatzeinlage verschiebt den Topf von Cash ins Vermögen, ohne ihn zu verändern", () => { const { p, investId } = basePlan(); const vorher = capitalPot(computePlan(p).phases[1]); const nachher = capitalPot( computePlan(applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 150000 }])) .phases[1] ); expect(nachher.total).toBe(vorher.total); // der Topf selbst bleibt gleich gross expect(nachher.allocatedInvestments).toBe(150000); expect(nachher.rest).toBe(vorher.rest - 150000); // Kontrolle: Topf = verteilt + Rest expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total); }); it("Sofort-Tilgung am Übergang zählt als verteiltes Kapital", () => { const { p, debtId } = basePlan(); const nachher = capitalPot( computePlan(applyTransitionPatches(p, "p1", [{ elementId: debtId, field: "immediateRepayment", value: 40000 }])) .phases[1] ); expect(nachher.allocatedRepayments).toBe(40000); expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total); }); it("Überverteilung drückt den Rest ins Minus (Liquiditätslücke)", () => { const { p, investId } = basePlan(); const r = computePlan( applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 999999 }]) ); const pot = capitalPot(r.phases[1]); expect(pot.rest).toBeLessThan(0); expect(r.phases[1].cashNegative).toBe(true); }); }); describe("Verteilung: Spar-/Verzehrquote", () => { it("absolute Quote ist die Summe über alle Phasenjahre -- und die Quote SINKT", () => { const { p } = basePlan(); const r = computePlan(p); const q = quotaSummary(r.phases[0]); // Genau der Grund, warum eine flache Jahresrate gefährlich ist: Das Einkommen bleibt bei // 0 % Lohnerhöhung nominal konstant, die real erfassten Ausgaben wachsen aber mit der // Inflation. Die Quote schrumpft dadurch über die Phase. expect(q.start).toBe(20000); expect(q.end).toBeLessThan(q.start); expect(q.end).toBe(Math.round(100000 - 80000 * Math.pow(1.015, 4))); // Die absolute Quote liegt folgerichtig zwischen "5 x letztes Jahr" und "5 x erstes Jahr". expect(q.total).toBeGreaterThan(5 * q.end); expect(q.total).toBeLessThan(5 * q.start); // Ohne verteilte Raten geht die ganze Quote aufs Cash. expect(q.netToCash).toBe(q.total); expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart); expect(q.isConsumption).toBe(false); }); it("verteilte Sparraten mindern den Rest, der aufs Cash geht", () => { const { p, investId } = basePlan(); const r = computePlan( applyPhasePatches(p, "p1", [{ elementId: investId, field: "annualContribution", value: 15000 }]) ); const q = quotaSummary(r.phases[0]); expect(q.allocatedOut).toBe(75000); // 15'000 x 5 Jahre expect(q.netToCash).toBe(q.total - q.allocatedOut + q.allocatedIn); // Kontrolle gegen die offiziellen Kennzahlen der Phase. expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart); }); it("Bezugsraten zählen als Zufluss und werden am Bestand gekappt", () => { // Verzehrphase: Ausgaben über Einkommen, finanziert aus dem Vermögen. const p = plan({ age: 66, retirementAge: 65, initialCash: 0, phases: [{ id: "p1", durationYears: 5 }], elements: [ el("INCOME", "PERSON_A", { p1: { amount: 40000, teuerungsausgleich: 0 } }), el("EXPENSE", "HOUSEHOLD", { p1: { amount: 70000, teuerungsausgleich: 0 } }), el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0, annualWithdrawal: 30000 } }), ], }); const r = computePlan(p); const q = quotaSummary(r.phases[0]); expect(q.isConsumption).toBe(true); expect(q.total).toBeLessThan(0); // Der Bestand von 50'000 lässt nur 50'000 Entnahme zu, nicht 5 x 30'000. expect(q.allocatedIn).toBe(50000); expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart); }); }); describe("Verteilung: Entwurfswerte anwenden", () => { it("lässt den Ausgangsplan unberührt und erhält bestehende Felder", () => { const { p, assetId, investId } = basePlan(); const snapshot = JSON.stringify(p); const geaendert = applyPhasePatches(p, "p2", [ { elementId: investId, field: "additionalInvestment", value: 1000 }, ]); expect(JSON.stringify(p)).toBe(snapshot); // Bestehendes Feld derselben Phase bleibt erhalten. expect(geaendert.elements.find((e) => e.id === investId)!.phaseValues.p2.expectedReturn).toBe(0); // Übergangs-Entscheide dürfen nicht verloren gehen. const mitTilgung = applyTransitionPatches(p, "p1", [ { elementId: assetId, field: "partialSaleAmount", value: 500 }, ]); expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.decision).toBe("SELL"); expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.partialSaleAmount).toBe(500); }); });