Files
FPT/src/lib/calculations.test.ts
T
admGitAICDS dfebbeb397 Drei Berechnungs-Fixes: Phaseninflation, Tilgungsraten, Vorbezugssteuer
- Phase.inflationRate ersatzlos entfernt (inkl. Migration). Die Inflation liegt
  seit V5 plan-weit; das Feld wurde von computePlan nie gelesen und war wirkungslos.
- Amortisation und Tilgung stoppen, sobald Hypothek bzw. Schuld abbezahlt sind:
  Hypothek/Restschuld sind neu laufende Salden, die Rate ist pro Jahr am Restsaldo
  gekappt. Belastet danach weder Cash noch Sparquote.
- Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezuegen vor der Pensionierung.
  Der Bezug wird brutto dem Kapital entnommen, netto ins Cash gebucht.

plannedSaveRate ist neu die Rate des ersten Phasenjahres. Drei Regressionstests
ergaenzt (10 -> 13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:22:34 +02:00

274 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
initialCash?: 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,
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);
});
});