Einkommen {valStr(phase.incomeStart, dS, mode)} → {valStr(phase.incomeEnd, dE, mode)}
- Ausgaben {valStr(phase.expenseStart, dS, mode)} → {valStr(phase.expenseEnd, dE, mode)}
+ Einkommen {valStr(phase.incomeStart, dS, mode)} → {valStr(phase.incomeEnd, dF, mode)}
+ Ausgaben {valStr(phase.expenseStart, dS, mode)} → {valStr(phase.expenseEnd, dF, mode)}
- {quotaLabel} {valStr(phase.quotaStart, dS, mode)} → {valStr(phase.quotaEnd, dE, mode)}
+ {quotaLabel} {valStr(phase.quotaStart, dS, mode)} → {valStr(phase.quotaEnd, dF, mode)}
Cash {valStr(phase.cashStart, dS, mode)} → {valStr(phase.cashEnd, dE, mode)}
@@ -842,7 +845,8 @@ function AddElementDialog({
carriedEndValue: 0,
carried: false,
derivedStart: 0,
- phaseInflation: plan.phases.find((p) => p.id === firstPhase.id)?.inflationRate ?? plan.inflationRateDefault,
+ phaseInflation: plan.inflationRateDefault,
+ deflatorStart: firstPhase.cumulativeInflationStart,
};
async function create() {
diff --git a/src/components/SparquoteChart.tsx b/src/components/SparquoteChart.tsx
new file mode 100644
index 0000000..d54b23b
--- /dev/null
+++ b/src/components/SparquoteChart.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import {
+ Area,
+ CartesianGrid,
+ ComposedChart,
+ Legend,
+ Line,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { formatChf } from "@/lib/format";
+import type { PlanComputed } from "@/lib/calculations";
+
+const INCOME_COLOR = "#16a34a";
+const EXPENSE_COLOR = "#dc2626";
+const REAL_COLOR = "#9ca3af";
+
+// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Flaeche
+// dazwischen ist die Spar-/Verzehrquote (gruen = Sparen, rot = Verzehr). Reale Ausgaben als
+// blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst".
+export function SparquoteChart({ computed }: { computed: PlanComputed }) {
+ if (computed.yearly.length === 0) return
Noch keine Phasen vorhanden.
;
+
+ const data = computed.yearly.map((y) => {
+ const surplus = y.income >= y.expenseNominal;
+ return {
+ year: y.year,
+ income: y.income,
+ expenseNominal: y.expenseNominal,
+ expenseReal: y.expenseReal,
+ // Range-Flaechen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Luecke).
+ sparBand: surplus ? [y.expenseNominal, y.income] : undefined,
+ verzehrBand: surplus ? undefined : [y.income, y.expenseNominal],
+ };
+ });
+
+ return (
+
+
+
+
+ `J. ${v}`} />
+ Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
+ />
+ [typeof v === "number" ? formatChf(v) : v, name]}
+ labelFormatter={(v) => `Jahr ${v}`}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts
index efde012..0fc6aaf 100644
--- a/src/lib/calculations.test.ts
+++ b/src/lib/calculations.test.ts
@@ -39,48 +39,48 @@ function plan(opts: {
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%)", () => {
+// 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: 2 } }),
+ el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 0 } }),
],
});
- const r = computePlan(p);
- const ph = r.phases[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: Gesamtvermoegen kippt, Ruin Alter 94", () => {
+ 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: 2 } }),
+ el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 900000, expectedReturn: 3, annualContribution: 0 } }),
],
});
- const r = computePlan(p);
- expect(r.ruinAge).toBe(94);
+ expect(computePlan(p).ruinAge).toBe(94);
});
- it("Test 3 – Cash-Ausgleich, Rate 6'364 (unter Anfangsquote), Cash nie negativ", () => {
+ 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: 2 } }),
+ el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 0 } }),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 6364 } }),
],
});
@@ -89,14 +89,14 @@ describe("V4 Golden Tests", () => {
expect(ph.cashNegative).toBe(false);
});
- it("Test 4 – Cash-Ausgleich, Rate 10'000 (ueber Endquote), Cash wird negativ", () => {
+ 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: 2 } }),
+ el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 0 } }),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 10000 } }),
],
});
@@ -105,23 +105,19 @@ describe("V4 Golden Tests", () => {
expect(ph.cashNegative).toBe(true);
});
- it("Cash-Anfangswert fliesst in die erste Phase ein", () => {
+ it("Ausgaben real -> nominal: reale Basis 100'000, 2% Inflation, 5 J.", () => {
const p = plan({
age: 40,
retirementAge: 60,
- 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 } }),
- ],
+ phases: [{ id: "p1", durationYears: 5 }],
+ elements: [el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } })],
});
const ph = computePlan(p).phases[0];
- expect(ph.cashStart).toBe(50000);
- expect(ph.cashEnd).toBe(50000); // Quote 0, keine Raten -> Cash unveraendert
+ expect(ph.expenseStart).toBe(100000); // Jahr 1 nominal = real
+ expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243
});
- it("indexRate 0 -> Einkommen bleibt nominal flach", () => {
+ it("Einkommen nominal flach bei Lohnerhoehung 0%", () => {
const p = plan({
age: 40,
retirementAge: 60,
@@ -133,7 +129,24 @@ describe("V4 Golden Tests", () => {
expect(ph.incomeEnd).toBe(80000);
});
- it("Phasen-Fortschreibung: indexierter Endwert Phase 1 = Startwert Phase 2; Cash laeuft fort", () => {
+ 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("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
const p = plan({
age: 40,
retirementAge: 70,
@@ -143,7 +156,7 @@ describe("V4 Golden Tests", () => {
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 2 }, p2: {} }),
- el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 2 }, p2: {} }),
+ el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
],
});
const r = computePlan(p);
diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts
index f155cf9..2dd1d85 100644
--- a/src/lib/calculations.ts
+++ b/src/lib/calculations.ts
@@ -62,13 +62,24 @@ export interface PhaseComputed {
elements: ElementPhaseComputed[];
startWealthNominal: number; // inkl. Cash
endWealthNominal: number; // inkl. Cash
- cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Startwerte)
- cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Endwerte)
+ cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Bestandswerte)
+ cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
+ flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`)
endWealthReal: number;
}
+// Ein Datenpunkt pro Jahr (ueber alle Phasen), fuer die Verlaufsgrafik.
+export interface YearPoint {
+ year: number; // 1-basiert ab Planbeginn
+ age: number; // Alter Person A am Jahresende
+ income: number; // Einkommen inkl. Renten (nominal)
+ expenseNominal: number;
+ expenseReal: number;
+}
+
export interface PlanComputed {
phases: PhaseComputed[];
+ yearly: YearPoint[];
nachlass: number;
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
}
@@ -123,6 +134,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
for (const e of plan.elements) carries.set(e.id, emptyCarry());
const result: PhaseComputed[] = [];
+ const yearly: YearPoint[] = [];
let yearsBefore = 0;
let cumulativeInflation = 1;
let cashCarryIn = Math.round(plan.initialCash || 0);
@@ -133,7 +145,9 @@ export function computePlan(plan: PlanInput): PlanComputed {
const nextPhase = phases[i + 1];
const isFirstPhase = i === 0;
const duration = Math.max(1, phase.durationYears);
- const phaseInflation = phase.inflationRate ?? plan.inflationRateDefault;
+ // Inflation ist plan-weit (V5): keine Phasen-Ueberschreibung mehr.
+ const infl = plan.inflationRateDefault;
+ const cumInflStart = cumulativeInflation; // Kaufkraft-Deflator zu Phasenbeginn
const personInfos: PersonPhaseInfo[] = persons.map((p) => {
const ra = retirementAge.get(p.id)!;
@@ -231,9 +245,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
switch (e.category) {
case "INCOME":
case "EXPENSE": {
- const idx = num(pd.teuerungsausgleich, phaseInflation);
- // Basiswert: Phase 1 = eingegebener Betrag; ab Phase 2 = fortgeschriebener Endwert
- // der Vorphase, ausser der Nutzer hat ihn bewusst geaendert (pd.amount gesetzt).
+ // Einkommen: Basis + Rate sind NOMINAL (Lohnerhoehung). Ausgaben: Basis + Rate sind
+ // REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu.
+ // Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase
+ // (nominal fuer Einkommen, real fuer Ausgaben), ausser bewusst geaendert (pd.amount).
+ const idx = num(pd.teuerungsausgleich, 0);
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
const basis = !carry.hasCarry
? Math.round(num(pd.amount))
@@ -241,6 +257,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
? Math.round(pd.amount)
: ec.baseValue;
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
+ // Basiswert der Folgephase fortschreiben (nominal fuer Einkommen, real fuer Ausgaben).
+ carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
break;
}
case "AHV": {
@@ -347,20 +365,32 @@ export function computePlan(plan: PlanInput): PlanComputed {
let quotaEnd = 0;
for (let t = 1; t <= duration; t++) {
+ // Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
let incomeFlow = renteTotal;
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
- let expenseFlow = 0;
- for (const exp of expenses) expenseFlow += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
- const quote = incomeFlow - expenseFlow;
+ // Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
+ const inflFactor = cumInflStart * Math.pow(1 + infl / 100, t - 1);
+ let expenseReal = 0;
+ for (const exp of expenses) expenseReal += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
+ const expenseNominal = expenseReal * inflFactor;
+ const quote = incomeFlow - expenseNominal;
+
+ yearly.push({
+ year: yearsBefore + t,
+ age: personA.age + yearsBefore + t,
+ income: Math.round(incomeFlow),
+ expenseNominal: Math.round(expenseNominal),
+ expenseReal: Math.round(expenseReal),
+ });
if (t === 1) {
incomeStart = incomeFlow;
- expenseStart = expenseFlow;
+ expenseStart = expenseNominal;
quotaStart = quote;
}
if (t === duration) {
incomeEnd = incomeFlow;
- expenseEnd = expenseFlow;
+ expenseEnd = expenseNominal;
quotaEnd = quote;
}
@@ -376,15 +406,19 @@ export function computePlan(plan: PlanInput): PlanComputed {
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
}
- // Endwerte je Element setzen + Endvermoegen bilden.
+ // Flow-Deflator fuer den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
+ // Bestands-Deflator am Phasenende.
+ const flowDeflatorEnd = cumInflStart * Math.pow(1 + infl / 100, duration - 1);
+
+ // Endwerte je Element setzen (Einkommen/Ausgaben nominal; Ausgaben-Nominal = real x Infl.).
for (const inc of incomes) {
inc.ec.startValue = Math.round(inc.basis);
inc.ec.endValue = Math.round(inc.basis * Math.pow(1 + inc.idx / 100, duration - 1));
inc.ec.summary = fmt(inc.ec.startValue);
}
for (const exp of expenses) {
- exp.ec.startValue = Math.round(exp.basis);
- exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1));
+ exp.ec.startValue = Math.round(exp.basis * cumInflStart);
+ exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1) * flowDeflatorEnd);
exp.ec.summary = fmt(exp.ec.startValue);
}
for (const a of assets) {
@@ -409,8 +443,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const cashEnd = Math.round(cash);
const startWealthNominal = Math.round(wealthStart + cashStart);
const endWealthNominal = Math.round(wealthEnd + cashEnd);
- const cumulativeInflationStart = cumulativeInflation;
- cumulativeInflation = cumulativeInflation * Math.pow(1 + phaseInflation / 100, duration);
+ cumulativeInflation = cumInflStart * Math.pow(1 + infl / 100, duration);
result.push({
id: phase.id,
@@ -435,8 +468,9 @@ export function computePlan(plan: PlanInput): PlanComputed {
elements: orderedElements.map((e) => ecById.get(e.id)!),
startWealthNominal,
endWealthNominal,
- cumulativeInflationStart,
+ cumulativeInflationStart: cumInflStart,
cumulativeInflationEnd: cumulativeInflation,
+ flowDeflatorEnd,
endWealthReal: endWealthNominal / cumulativeInflation,
});
@@ -452,10 +486,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true &&
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration);
- // Einkommen/Ausgaben: indexierten Basiswert fortschreiben.
+ // Einkommen/Ausgaben: Basiswert wurde bereits im Element-Setup fortgeschrieben.
if (e.category === "INCOME" || e.category === "EXPENSE") {
- const idx = num(pd.teuerungsausgleich, phaseInflation);
- carry.flowBasis = ec.startValue * Math.pow(1 + idx / 100, duration);
carry.hasCarry = true;
continue;
}
@@ -547,7 +579,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
}
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
- return { phases: result, nachlass, ruinAge };
+ return { phases: result, yearly, nachlass, ruinAge };
}
function retiresInPhase(