e901d23970
Deploy App / deploy (push) Successful in 59s
Roadmap Nr. 3: Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) ueber das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente. - Formel aus den amtlichen Randbedingungen hergeleitet und gegen die Tabelle 318.117.1 verifiziert: 51/51 Zeilen exakt. Schwellen sind Vielfache von R0=1'260 (12/36/72 x R0 = 15'120 / 45'360 / 90'720). Stuetzstellen als Golden Tests. - Alles REAL gerechnet: die AHV wertet vergangene Einkommen auf UND indexiert die Schwellen -- real hebt sich das auf. Nominal wuerde die Rente systematisch zu hoch ausfallen (Beispiel: faelschlich Maximalrente, ~41'600 ueber 25 Rentenjahre). - Pruefung der Beitragskarriere am Pensions-Uebergang; Zusatzfelder fuer die Jahre vor Planbeginn nur, wenn der Plan nicht bis Alter 21 zurueckreicht. - Sonderfall "bei Planbeginn bereits pensioniert": Felder in der Phasenzelle. - Ohne Pruefung gilt der geplante Durchschnitt (nicht 0) -- sonst waere die Rente still viel zu tief. - 13. Altersrente: Jahresbetrag = Monatsrente x 13. Roadmap Nr. 4: Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren -- Werte schreiben sich fort und wirken bis ans Planende durch. Verhaltensaenderung fuer bestehende Plaene: siehe SPEZIFIKATION 9.10. Zwoelf Regressionstests (18 -> 30). Spezifikation auf v0.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
534 lines
21 KiB
TypeScript
534 lines
21 KiB
TypeScript
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<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; cashTransition?: CashTransitionData }[];
|
||
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,
|
||
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);
|
||
});
|
||
});
|