Lesbare Wasserfaelle, Verkaufspreis-Abgleich, Erklaerung wirkungsloser Treiber
Deploy App / deploy (push) Successful in 59s
Deploy App / deploy (push) Successful in 59s
Die Wasserfall-Zahlen waren korrekt (residual exakt 0), die Darstellung nicht lesbar. Neu als eigene liegende HTML/CSS-Darstellung statt Recharts: - Verbindungslinien zwischen den Balken (ohne sie zerfaellt der Wasserfall in unverbundene Rechtecke) - Wertbeschriftung an jedem Schritt - Zwischenstand und Veraenderung optisch unterschieden - Abschnitte "Am Uebergang" / "Innerhalb der Phase" - aufklappbare Tabelle mit laufendem Zwischenstand - Nullposten werden nicht gezeichnet - Restposten neu als Fehlermeldung statt beilaeufiger Rundungsnotiz Verkaufspreis einer Immobilie wird beim Wechsel auf "Verkaufen" mit dem modellierten Verkehrswert vorbelegt (nur wenn noch keiner erfasst ist); Verkehrswert und Abweichung werden ausgewiesen, ab 10 % rot abgesetzt. Die beiden Groessen bleiben bewusst entkoppelt -- ein Verkauf unter Verkehrswert ist ein realer Fall. Tornado erklaert Nullbalken statt sie stumm zu zeigen. Wichtigster Fall: Wird die Immobilie vor Planende verkauft, ist die Wertsteigerung nachweislich wirkungslos, weil der Erloes am erfassten Verkaufspreis haengt und nicht am Verkehrswert. 11 Tests ergaenzt (92 -> 103), darunter residual === 0 ueber sieben Plankonstellationen. SPEZIFIKATION auf 0.12, neue Kapitel 3.5.8, 4.13.5, 4.14.2.1, 9.22. Keine Aenderung an der Berechnung. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Die zentrale Invariante beider Wasserfall-Bruecken: `residual` ist die Differenz zwischen
|
||||
// dem tatsaechlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
|
||||
// vollstaendig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, waere ein
|
||||
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb ueber moeglichst
|
||||
// verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel.
|
||||
|
||||
let idc = 0;
|
||||
const nid = () => `b${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>[];
|
||||
}): PlanInput {
|
||||
return {
|
||||
id: "plan",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: opts.inflation ?? 1.5,
|
||||
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 konstellationen: { name: string; build: () => PlanInput }[] = [
|
||||
{
|
||||
name: "Ansparen mit 3a, Sparbeitrag und Schuldentilgung",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
initialCash: 50000,
|
||||
phases: [{ id: "p1", durationYears: 10 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000, teuerungsausgleich: 1 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 } }),
|
||||
el("PILLAR_3A", "PERSON_A", { p1: { currentValue: 50000, annualContribution: 7000, expectedReturn: 3 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 10000 } }),
|
||||
el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000, annualRepayment: 8000 } }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Pensionierung mit PK-Verrentung und 3a-Bezug",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
initialCash: 20000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 20 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 130000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 75000 } }),
|
||||
el("AHV", "PERSON_A", { p1: { gapYears: 0 } }, { p1: { reviewed: true, avgIncomeBefore: 95000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } },
|
||||
{ p1: { payoutMode: "PENSION", conversionRate: 6 } }
|
||||
),
|
||||
el(
|
||||
"PILLAR_3A",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } },
|
||||
{ p1: { capitalTaxRate: 8 } }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", {
|
||||
p1: { startValue: 300000, expectedReturn: 4 },
|
||||
p2: { expectedReturn: 4, annualWithdrawal: 30000 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "PK-Kapitalbezug statt Rente (Steuer am Uebergang)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 62,
|
||||
retirementAge: 65,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 3 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 110000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 70000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } },
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 8 } }
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Immobilie gehalten (Wertsteigerung und Amortisation)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 45,
|
||||
retirementAge: 70,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
|
||||
el("REAL_ESTATE", "HOUSEHOLD", {
|
||||
p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
|
||||
p2: { amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstueckgewinnsteuer)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 45,
|
||||
retirementAge: 70,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
"HOUSEHOLD",
|
||||
{ p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 } },
|
||||
{ p1: { decision: "SELL", salePrice: 1400000, saleTaxRate: 20 } }
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Einmalige Sonderein-/ausgaben am Uebergang",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 50,
|
||||
retirementAge: 70,
|
||||
initialCash: 30000,
|
||||
phases: [
|
||||
{
|
||||
id: "p1",
|
||||
durationYears: 5,
|
||||
cashTransition: { mode: "BOTH", inflowAmount: 250000, inflowTaxRate: 10, outflowAmount: 40000 },
|
||||
},
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 85000 }, p2: { amount: 85000 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", {
|
||||
p1: { startValue: 100000, expectedReturn: 4 },
|
||||
p2: { expectedReturn: 4, additionalInvestment: 50000 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Sofort-Tilgung und Sonderamortisation am Uebergang",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 50,
|
||||
retirementAge: 70,
|
||||
initialCash: 200000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }),
|
||||
el(
|
||||
"OTHER_DEBT",
|
||||
"HOUSEHOLD",
|
||||
{ p1: { startValue: 80000, annualRepayment: 5000 }, p2: { annualRepayment: 5000 } },
|
||||
{ p1: { immediateRepayment: 30000 } }
|
||||
),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
"HOUSEHOLD",
|
||||
{
|
||||
p1: { purchasePrice: 800000, mortgage: 500000, amortization: 10000, valueGrowth: 1 },
|
||||
p2: { amortization: 10000, valueGrowth: 1 },
|
||||
},
|
||||
{ p1: { decision: "HOLD", extraAmortization: 50000 } }
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
describe("Wasserfall-Bruecken", () => {
|
||||
for (const k of konstellationen) {
|
||||
it(`${k.name}: beide Bruecken gehen ohne Restgroesse auf`, () => {
|
||||
const r = computePlan(k.build());
|
||||
expect(r.phases.length).toBeGreaterThan(0);
|
||||
for (const ph of r.phases) {
|
||||
expect(ph.wealthBridge.residual, `Vermoegensbruecke ${ph.name}`).toBe(0);
|
||||
expect(ph.cashBridge.residual, `Cash-Bruecke ${ph.name}`).toBe(0);
|
||||
// Die Kontrollpunkte muessen den offiziellen Kennzahlen entsprechen.
|
||||
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
|
||||
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
|
||||
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
|
||||
expect(ph.cashBridge.cashEnd).toBe(ph.cashEnd);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("Umbuchungen erscheinen NICHT in der Vermoegensbruecke", () => {
|
||||
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermoegenswert, ohne
|
||||
// das Vermoegen zu aendern. Sie duerfen deshalb nur in der Cash-Bruecke auftauchen; die
|
||||
// Vermoegensaenderung erklaert sich allein aus Quote, Ertraegen und PK-Beitraegen.
|
||||
const r = computePlan(konstellationen[0].build());
|
||||
const ph = r.phases[0];
|
||||
expect(ph.cashBridge.savingRates).toBeGreaterThan(0);
|
||||
expect(ph.cashBridge.debtRates).toBeGreaterThan(0);
|
||||
const w = ph.wealthBridge;
|
||||
expect(w.endWealth - w.startWealth).toBe(
|
||||
w.quotaTotal + w.investmentReturn + w.propertyAppreciation + w.pensionFundContribution
|
||||
);
|
||||
});
|
||||
|
||||
it("PK-Verrentung erscheint als Vermoegensabgang am Uebergang", () => {
|
||||
// Das verrentete Kapital verlaesst die Bilanz und wird zum Rentenstrom -- in der
|
||||
// Vermoegensbruecke ein echter Abgang, kein Umbuchungsposten.
|
||||
const r = computePlan(konstellationen[1].build());
|
||||
const pension = r.phases[1];
|
||||
expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0);
|
||||
expect(pension.wealthBridge.startWealth).toBeLessThan(pension.wealthBridge.openingWealth);
|
||||
});
|
||||
|
||||
it("Verkaufsdifferenz und Steuer erscheinen nur beim Verkauf", () => {
|
||||
const gehalten = computePlan(konstellationen[3].build()).phases[1].wealthBridge;
|
||||
const verkauft = computePlan(konstellationen[4].build()).phases[1].wealthBridge;
|
||||
expect(gehalten.saleGainLoss).toBe(0);
|
||||
expect(gehalten.transitionTax).toBe(0);
|
||||
expect(verkauft.transitionTax).toBeGreaterThan(0);
|
||||
// Verkaufspreis ueber dem modellierten Verkehrswert -> positive Differenz.
|
||||
expect(verkauft.saleGainLoss).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,29 @@ function basePlan(): PlanInput {
|
||||
};
|
||||
}
|
||||
|
||||
// Plan mit Immobilie -- wahlweise gehalten oder am Uebergang verkauft.
|
||||
function planMitImmobilie(verkaufen: boolean): PlanInput {
|
||||
const p = basePlan();
|
||||
return {
|
||||
...p,
|
||||
elements: [
|
||||
...p.elements,
|
||||
{
|
||||
id: "haus",
|
||||
category: "REAL_ESTATE",
|
||||
name: "Haus",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 4,
|
||||
phaseValues: {
|
||||
p1: { purchasePrice: 900000, mortgage: 600000, amortization: 10000, valueGrowth: 1 },
|
||||
p2: { amortization: 0, valueGrowth: 1 },
|
||||
},
|
||||
transitionValues: verkaufen ? { p1: { decision: "SELL", salePrice: 1100000, saleTaxRate: 20 } } : {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Sensitivitaet: applyDriver", () => {
|
||||
it("laesst den Ausgangsplan unberuehrt (rein)", () => {
|
||||
const p = basePlan();
|
||||
@@ -160,6 +183,22 @@ describe("Sensitivitaet: Tornado", () => {
|
||||
it("identische Bandbreite ergibt Spannweite 0", () => {
|
||||
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
|
||||
expect(bar.swing).toBe(0);
|
||||
expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklaerung
|
||||
});
|
||||
|
||||
it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklaert", () => {
|
||||
// Beim Verkauf ist der Erloes `Verkaufspreis - Hypothek - Steuer` und haengt am ERFASSTEN
|
||||
// Preis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird damit
|
||||
// verworfen -- der Treiber kann das Endvermoegen nicht mehr bewegen.
|
||||
const gehalten = computeTornado(planMitImmobilie(false), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
|
||||
const verkauft = computeTornado(planMitImmobilie(true), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
|
||||
|
||||
expect(gehalten.bars[0].swing).toBeGreaterThan(0);
|
||||
expect(gehalten.bars[0].note).toBeUndefined();
|
||||
|
||||
expect(verkauft.bars[0].swing).toBe(0);
|
||||
expect(verkauft.bars[0].note).toContain("verkauft");
|
||||
expect(verkauft.bars[0].note).toContain("Verkaufspreis");
|
||||
});
|
||||
|
||||
it("real und nominal unterscheiden sich um den Deflator", () => {
|
||||
|
||||
+23
-1
@@ -221,6 +221,7 @@ export interface TornadoBar {
|
||||
id: DriverId;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
note?: string; // Erklaerung, wenn der Treiber das Ergebnis nachweislich nicht bewegt
|
||||
unit: DriverUnit;
|
||||
low: number; // eingegebene Bandbreite
|
||||
high: number;
|
||||
@@ -236,6 +237,25 @@ export interface TornadoResult {
|
||||
bars: TornadoBar[];
|
||||
}
|
||||
|
||||
// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklaerung ist die
|
||||
// schlechteste Antwort -- der Nutzer haelt ihn fuer einen Fehler.
|
||||
//
|
||||
// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserloes
|
||||
// ist `Verkaufspreis - Hypothek - Grundstueckgewinnsteuer` und haengt damit am ERFASSTEN
|
||||
// Verkaufspreis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim
|
||||
// Verkauf also verworfen -- der Treiber kann das Endvermoegen nicht mehr beeinflussen.
|
||||
export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
|
||||
if (id === "propertyGrowth") {
|
||||
const computed = computePlan(plan);
|
||||
const last = computed.phases[computed.phases.length - 1];
|
||||
const realEstate = (last?.elements ?? []).filter((e) => e.category === "REAL_ESTATE");
|
||||
if (realEstate.length > 0 && realEstate.every((e) => e.status === "SOLD")) {
|
||||
return "Wirkungslos, weil die Immobilie vor Planende verkauft wird: Der Verkaufserlös ergibt sich aus dem erfassten Verkaufspreis, nicht aus dem modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim Verkauf verworfen.";
|
||||
}
|
||||
}
|
||||
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
|
||||
}
|
||||
|
||||
// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
|
||||
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
|
||||
const computed = computePlan(plan);
|
||||
@@ -257,10 +277,12 @@ export function computeTornado(
|
||||
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
|
||||
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt
|
||||
// deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle.
|
||||
const swing = Math.abs(highResult - lowResult);
|
||||
return {
|
||||
id: input.id,
|
||||
label: def.label,
|
||||
shortLabel: def.shortLabel,
|
||||
note: swing === 0 ? ineffectiveReason(plan, input.id) : undefined,
|
||||
unit: def.unit,
|
||||
low: input.low,
|
||||
high: input.high,
|
||||
@@ -268,7 +290,7 @@ export function computeTornado(
|
||||
highResult,
|
||||
min: Math.min(lowResult, highResult),
|
||||
max: Math.max(lowResult, highResult),
|
||||
swing: Math.abs(highResult - lowResult),
|
||||
swing,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user