V4-Rechenmodell: Flow-Indexierung, Cash-Ausgleichstopf, Ruin-Erkennung
Deploy App / deploy (push) Successful in 3m6s
Deploy App / deploy (push) Successful in 3m6s
Behebt die Nominal/Real-Inkonsistenz (Bug #1): Einkommen/Ausgaben werden neu Jahr fuer Jahr indexiert (eigenes Feld teuerungsausgleich je Element, Default = Phaseninflation; Renten nominal fix = 0%). Vermoegen verzinst weiterhin nominal. Cash: neues systemseitiges, immer sichtbares Element (0% Verzinsung, kein DB-Row - synthetisch in computePlan). Ist der Ausgleichstopf = "verfuegbares Kapital fuer Investments": cash_delta(t) = quote(t) - geplante flache Jahresraten (3a/Vermoegen/Amort./ Tilgung); Cash laeuft ueber Phasen fort, darf negativ werden (rot). Der Verteilzwang und die harten Sparraten-Caps entfallen. Ruin: Gesamtvermoegen (inkl. Cash) je Jahr; erstes Unterschreiten von 0 -> Ruin-Alter (Person A), Anzeige als Banner + Zeitachsen-Marker. Phasenkopf neu: Einkommen/Ausgaben Start->Ende, Quote Beginn/Ende, Cash Start->Ende, Vermoegen inkl. Cash, Realwert. Inflation-Deflator neu pro Jahr (Math.pow ^Dauer). Golden Tests (vitest) 1-4 + Renten-0% + Fortschreibung gruen (Test1 761'654/565'928, Test2 Ruin Alter 94). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
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;
|
||||
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,
|
||||
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 })),
|
||||
elements: opts.elements,
|
||||
};
|
||||
}
|
||||
|
||||
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
||||
|
||||
describe("V4 Golden Tests", () => {
|
||||
it("Test 1 – Ansparphase, Flows wachsen (Cash 0%)", () => {
|
||||
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: 2 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 0 } }),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
const ph = r.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: Gesamtvermoegen kippt, 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: 2 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 900000, expectedReturn: 3, annualContribution: 0 } }),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
expect(r.ruinAge).toBe(94);
|
||||
});
|
||||
|
||||
it("Test 3 – Cash-Ausgleich, Rate 6'364 (unter Anfangsquote), 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: 2 } }),
|
||||
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 (ueber Endquote), 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: 2 } }),
|
||||
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("indexRate 0 -> Einkommen bleibt nominal flach", () => {
|
||||
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("Phasen-Fortschreibung: indexierter Endwert 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: 2 }, 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user