Lesbare Wasserfaelle, Verkaufspreis-Abgleich, Erklaerung wirkungsloser Treiber
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:
2026-07-18 21:21:37 +02:00
parent 4791dccf93
commit e1f74fca95
8 changed files with 671 additions and 61 deletions
+166 -48
View File
@@ -5,7 +5,6 @@ import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
@@ -29,77 +28,197 @@ import type {
} from "@/lib/calculations";
// --- Wasserfall ---------------------------------------------------------------------------
// Recharts kennt keinen Wasserfall: Er entsteht aus zwei gestapelten Balken -- einem
// unsichtbaren Sockel und dem sichtbaren Delta darueber.
// Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt
// von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhoert),
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenstaenden
// und Veraenderungen.
//
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
// muessten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
interface WaterfallItem {
label: string;
value: number;
total?: boolean; // Zwischen-/Endsumme: startet bei 0 statt beim laufenden Saldo
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veraenderung
section?: string; // optionale Abschnitts-Ueberschrift VOR diesem Eintrag
}
function waterfallData(items: WaterfallItem[]) {
interface WaterfallRow {
label: string;
section?: string;
from: number;
to: number;
value: number;
running: number; // Stand NACH diesem Schritt
kind: "total" | "pos" | "neg";
}
function waterfallRows(items: WaterfallItem[]): WaterfallRow[] {
let running = 0;
return items.map((it) => {
if (it.total) {
running = it.value;
return { label: it.label, base: 0, delta: Math.abs(it.value), value: it.value, kind: "total" as const };
return { label: it.label, section: it.section, from: 0, to: it.value, value: it.value, running, kind: "total" as const };
}
const start = running;
const from = running;
running += it.value;
return {
label: it.label,
base: Math.min(start, running),
delta: Math.abs(it.value),
section: it.section,
from,
to: running,
value: it.value,
running,
kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg",
};
});
}
const WF_COLOR = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
const WF_FILL = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
const ROW_H = 34;
const BAR_H = 20;
function Waterfall({ items }: { items: WaterfallItem[] }) {
const rows = useMemo(() => waterfallRows(items), [items]);
if (rows.length === 0) return null;
const lo = Math.min(0, ...rows.map((r) => Math.min(r.from, r.to)));
const hi = Math.max(0, ...rows.map((r) => Math.max(r.from, r.to)));
const span = hi - lo || 1;
const pos = (v: number) => ((v - lo) / span) * 100;
function Waterfall({ items, height = 300 }: { items: WaterfallItem[]; height?: number }) {
const data = useMemo(() => waterfallData(items), [items]);
if (data.length === 0) return null;
return (
<div className="w-full" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-32} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
cursor={{ fill: "var(--surface-2)" }}
formatter={(_v, _n, p) => [formatChf(p?.payload?.value ?? 0), p?.payload?.label ?? ""]}
labelFormatter={() => ""}
/>
<Bar dataKey="base" stackId="w" fill="transparent" isAnimationActive={false} />
<Bar dataKey="delta" stackId="w" isAnimationActive={false} radius={[2, 2, 0, 0]}>
{data.map((d, i) => (
<Cell key={i} fill={WF_COLOR[d.kind]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div>
<div className="overflow-hidden rounded-xl border border-border">
{rows.map((r, i) => {
const left = pos(Math.min(r.from, r.to));
const width = Math.max(0.4, Math.abs(pos(r.to) - pos(r.from)));
const isLast = i === rows.length - 1;
return (
<div key={`${r.label}-${i}`}>
{r.section && (
<div className="border-b border-border bg-surface-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
{r.section}
</div>
)}
<div className={`flex items-stretch ${r.kind === "total" ? "bg-surface-2" : ""}`}>
<div
className={`w-44 shrink-0 border-r border-border px-3 py-2 text-[11px] leading-tight ${
r.kind === "total" ? "font-semibold text-fg" : "text-muted"
}`}
>
{r.label}
</div>
<div className="relative min-w-0 flex-1" style={{ height: ROW_H }}>
{/* Nulllinie */}
<div
className="absolute top-0 h-full border-l border-dashed border-border"
style={{ left: `${pos(0)}%` }}
/>
{/* Balken */}
<div
className="absolute rounded-sm"
style={{
left: `${left}%`,
width: `${width}%`,
top: (ROW_H - BAR_H) / 2,
height: BAR_H,
backgroundColor: WF_FILL[r.kind],
opacity: r.kind === "total" ? 0.85 : 1,
}}
title={`${r.label}: ${formatChf(r.value)}`}
/>
{/* Verbindungslinie zum naechsten Balken: auf dem Stand NACH diesem Schritt */}
{!isLast && (
<div
className="absolute border-l border-dotted border-faint"
style={{
left: `${pos(r.to)}%`,
top: (ROW_H + BAR_H) / 2,
height: (ROW_H - BAR_H) / 2,
}}
/>
)}
</div>
<div
className={`w-28 shrink-0 border-l border-border px-3 py-2 text-right text-[11px] tabular-nums ${
r.kind === "total" ? "font-semibold text-fg" : r.kind === "neg" ? "text-danger" : "text-success"
}`}
>
{r.kind === "total" ? formatChf(r.value) : `${r.value >= 0 ? "+" : ""}${formatChf(Math.abs(r.value))}`}
</div>
</div>
</div>
);
})}
</div>
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Groessenordnungen
ist die Tabelle der Grafik ueberlegen. */}
<details className="mt-2">
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-surface-2 text-[10px] uppercase tracking-wide text-faint">
<th className="px-3 py-1.5 text-left font-semibold">Schritt</th>
<th className="px-3 py-1.5 text-right font-semibold">Betrag</th>
<th className="px-3 py-1.5 text-right font-semibold">Zwischenstand</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className={`border-t border-border ${r.kind === "total" ? "bg-surface-2 font-semibold" : ""}`}>
<td className="px-3 py-1.5 text-fg">{r.label}</td>
<td
className={`px-3 py-1.5 text-right tabular-nums ${
r.kind === "total" ? "text-faint" : r.kind === "neg" ? "text-danger" : "text-success"
}`}
>
{r.kind === "total" ? "—" : `${r.value >= 0 ? "+" : ""}${formatChf(Math.abs(r.value))}`}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-fg">{formatChf(r.running)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
);
}
// Kontrollgroesse: Ist die Zerlegung vollstaendig, muss die Differenz zwischen Endwert und
// der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der
// nicht aufgeht, ist ein Fehler und kein Schoenheitsproblem.
function ResidualNote({ residual }: { residual: number }) {
if (Math.abs(residual) <= 2) return null;
return (
<p className="mt-2 rounded-lg border border-danger bg-danger-soft px-3 py-2 text-[11px] text-danger">
<strong>Die Zerlegung geht nicht auf.</strong> Nicht zugeordnete Differenz: {formatChf(residual)}. Bitte melden
das ist ein Fehler in der Berechnung, nicht in der Darstellung.
</p>
);
}
function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
const items: WaterfallItem[] = [];
if (!isFirst) {
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true });
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true, section: "Am Übergang in diese Phase" });
if (w.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: w.oneOffInflow });
if (w.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -w.oneOffOutflow });
if (w.transitionTax) items.push({ label: "Steuern am Übergang", value: -w.transitionTax });
if (w.pensionConversion) items.push({ label: "PK verrentet", value: -w.pensionConversion });
if (w.pensionConversion) items.push({ label: "PK in Rente umgewandelt", value: -w.pensionConversion });
if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss });
}
items.push({ label: "Vermögen Phasenbeginn", value: w.startWealth, total: true });
items.push({
label: "Vermögen Phasenbeginn",
value: w.startWealth,
total: true,
section: isFirst ? undefined : "Innerhalb der Phase",
});
if (w.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: w.quotaTotal });
if (w.investmentReturn) items.push({ label: "Kapitalerträge", value: w.investmentReturn });
if (w.propertyAppreciation) items.push({ label: "Wertsteigerung Immobilie", value: w.propertyAppreciation });
@@ -110,13 +229,18 @@ function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] {
const items: WaterfallItem[] = [];
items.push({ label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase", value: c.openingCash, total: true });
items.push({
label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase",
value: c.openingCash,
total: true,
section: isFirst ? undefined : "Am Übergang in diese Phase",
});
if (c.capitalInflow) items.push({ label: "Kapitalzufluss", value: c.capitalInflow });
if (c.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: c.oneOffInflow });
if (c.immediateRepay) items.push({ label: "Sofort-Tilgung", value: -c.immediateRepay });
if (c.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -c.oneOffOutflow });
if (c.investments) items.push({ label: "Investitionen", value: -c.investments });
items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true });
items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true, section: "Innerhalb der Phase" });
if (c.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: c.quotaTotal });
if (c.savingRates) items.push({ label: "Sparraten", value: -c.savingRates });
if (c.debtRates) items.push({ label: "Amort./Tilgung", value: -c.debtRates });
@@ -424,11 +548,7 @@ export function PhaseDetailDialog({
verändern als Balken gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt.
</p>
<Waterfall items={bridgeItems(phase.wealthBridge, isFirst)} />
{Math.abs(phase.wealthBridge.residual) > 2 && (
<p className="text-[11px] text-faint">
Rundungsdifferenz: {formatChf(phase.wealthBridge.residual)}
</p>
)}
<ResidualNote residual={phase.wealthBridge.residual} />
</section>
<section>
@@ -438,9 +558,7 @@ export function PhaseDetailDialog({
wenn sie das Vermögen nicht mindern.
</p>
<Waterfall items={cashItems(phase.cashBridge, isFirst)} />
{Math.abs(phase.cashBridge.residual) > 2 && (
<p className="text-[11px] text-faint">Rundungsdifferenz: {formatChf(phase.cashBridge.residual)}</p>
)}
<ResidualNote residual={phase.cashBridge.residual} />
</section>
</>
);
+52 -2
View File
@@ -34,6 +34,9 @@ export interface CellContext {
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (fuer die Sonderamortisation)
// nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek),
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert fuer den Verkaufspreis.
propertyValueEnd: number;
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
laterPhaseCount: number;
@@ -769,12 +772,24 @@ export function ElementTransitionFields({
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />;
case "REAL_ESTATE": {
const decision = td.decision ?? "HOLD";
const marktwert = Math.round(context.propertyValueEnd);
const preis = num(td.salePrice);
// Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide
// Groessen sind unabhaengig erfassbar -- ohne diesen Vergleich koennte man 2 %
// Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken.
const abweichung = preis - marktwert;
const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0;
const deutlich = marktwert > 0 && Math.abs(abweichungPct) >= 10;
return (
<>
<SelectField
label="Entscheidung"
value={decision === "SELL" ? "SELL" : "HOLD"}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
onChange={(v: "HOLD" | "SELL") =>
// Beim Wechsel auf "Verkaufen" den Verkaufspreis mit dem modellierten
// Verkehrswert vorbelegen -- aber nur, wenn noch keiner erfasst ist.
setT(v === "SELL" && td.salePrice === undefined ? { decision: v, salePrice: marktwert } : { decision: v })
}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
@@ -782,7 +797,42 @@ export function ElementTransitionFields({
/>
{decision === "SELL" && (
<>
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
<MoneyField
label="Verkaufspreis (CHF)"
help="Vorbelegt mit dem modellierten Verkehrswert am Phasenende. Bewusst änderbar der erfasste Preis ist massgebend, nicht der modellierte Wert."
value={preis}
onChange={(v) => setT({ salePrice: v })}
/>
<DerivedField
label="Modellierter Verkehrswert (Phasenende)"
value={marktwert}
help="Kaufpreis zuzüglich der bis hierhin aufgelaufenen Wertsteigerung. Nur zum Vergleich für den Erlös zählt der oben erfasste Verkaufspreis."
/>
{marktwert > 0 && (
<p
className={`col-span-full rounded-lg px-3 py-2 text-xs ${
deutlich ? "border border-danger bg-danger-soft text-danger" : "bg-surface-2 text-muted"
}`}
>
{Math.abs(abweichung) < 1 ? (
<>Verkaufspreis und modellierter Verkehrswert stimmen überein.</>
) : (
<>
Der Verkaufspreis liegt <strong>{formatChf(Math.abs(abweichung))} CHF</strong> (
{abweichung > 0 ? "+" : ""}
{Math.abs(Math.round(abweichungPct * 10) / 10)} %) {abweichung > 0 ? "über" : "unter"} dem
modellierten Verkehrswert.
{deutlich && (
<>
{" "}
Das ist eine deutliche Abweichung prüfe, ob sie gewollt ist oder ob die angenommene
Wertsteigerung nicht zum erwarteten Verkaufspreis passt.
</>
)}
</>
)}
</p>
)}
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
</>
)}
+5
View File
@@ -226,6 +226,8 @@ export function PlanView({
derivedStart: ce?.baseValue ?? 0,
derivedMortgage: ce?.mortgageStart ?? 0,
mortgageEnd: ce?.mortgageEnd ?? 0,
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
deflatorStart: phase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(phase),
ahvCareer: careerFor(element),
@@ -246,6 +248,8 @@ export function PlanView({
derivedStart: 0,
derivedMortgage: 0,
mortgageEnd: ce?.mortgageEnd ?? 0,
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
deflatorStart: fromPhase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(fromPhase),
ahvCareer: careerFor(element),
@@ -1107,6 +1111,7 @@ function AddElementDialog({
derivedStart: 0,
derivedMortgage: 0,
mortgageEnd: 0,
propertyValueEnd: 0,
deflatorStart: firstPhase.cumulativeInflationStart,
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
ahvCareer: null,
+4 -1
View File
@@ -298,7 +298,10 @@ function TornadoResults({
<tbody>
{result.bars.map((b) => (
<tr key={b.id} className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">{b.label}</td>
<td className="px-3 py-2 font-medium text-fg">
{b.label}
{b.note && <div className="mt-0.5 text-[11px] font-normal text-muted">{b.note}</div>}
</td>
<td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td>
+274
View File
@@ -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);
});
});
+39
View File
@@ -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
View File
@@ -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,
};
});