V4-Rechenmodell: Flow-Indexierung, Cash-Ausgleichstopf, Ruin-Erkennung
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:
2026-07-15 09:24:13 +02:00
parent 9506f43b67
commit 5e17ce4afb
10 changed files with 1349 additions and 299 deletions
+137
View File
@@ -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);
});
});