Detailansichten, Wasserfaelle und vollstaendige Rechenweg-Offenlegung
Deploy App / deploy (push) Successful in 57s

Roadmap Nr. 43 (Detailansichten) und Nr. 41 (Berechnungslogiken offenlegen).

Systemparameter-Ansicht:
- SYSTEM_PARAMETERS in constants.ts: Wert, Bedeutung, Herleitung, Quelle,
  Stand -- aus derselben Datei, aus der gerechnet wird

Detailansichten (nur lesen, per Expand-Icon):
- Element: Verlauf ueber ALLE Planjahre (neu ElementPhaseComputed.yearly),
  bei Immobilien Verkehrswert/Restschuld/Eigenkapital getrennt
- Phase: Vermoegensaufteilung + zwei Wasserfaelle

Zwei getrennte Wasserfaelle (WealthBridge / CashBridge):
- Sparraten, Amortisationen und Investitionen sind UMBUCHUNGEN und erscheinen
  nur im Cash-Wasserfall -- als Vermoegensabgang gezeichnet wuerden sie einen
  Verlust vortaeuschen, den es nicht gibt
- PK-Beitraege dagegen sind ein echter Vermoegenszugang (belasten kein Cash),
  Verrentung ein echter Abgang (Kapital verlaesst die Bilanz)
- residual als Kontrollgroesse fuer die Vollstaendigkeit der Zerlegung

Rechenweg-Protokoll, vollstaendige Abdeckung:
- computePlan(plan, sample?, { explain }) protokolliert die Schritte, die es
  ohnehin ausfuehrt -- die Erklaerung IST die Rechnung, statt einer zweiten
  Formel-Implementierung im UI, die still abdriften koennte
- standardmaessig aus (Monte Carlo bleibt unberuehrt)
- Arithmetik nicht umgestellt: Zwischengroessen werden als Differenz
  abgeleitet, damit die 43 Golden Tests bitgleich bleiben
- jeder Trace verlinkt in die SPEZIFIKATION; ein Test prueft gegen die echte
  Datei, dass alle Verweise eine existierende Ueberschrift treffen

SPEZIFIKATION auf 0.11: neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21.
12 Tests ergaenzt (80 -> 92). Keine DB-Aenderung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 17:12:55 +02:00
parent 1836cad7f3
commit 4791dccf93
9 changed files with 2066 additions and 33 deletions
+70 -8
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import {
BarChart3,
BookOpen,
Copy,
Dices,
FileText,
@@ -12,6 +13,7 @@ import {
Menu,
PiggyBank,
Plus,
SlidersHorizontal,
Tornado,
Trash2,
X,
@@ -21,6 +23,9 @@ import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
import { computePlan } from "@/lib/calculations";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
@@ -47,6 +52,17 @@ export function AppShell({ username }: { username: string }) {
const [showCharts, setShowCharts] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false);
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
function openSpecAt(anchor: string) {
setSpecAnchor(anchor);
setShowSpec(true);
setShowSystemParams(false);
setSelectedScenarioId(null);
}
const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
@@ -86,6 +102,7 @@ export function AppShell({ username }: { username: string }) {
function openScenario(id: string) {
setSelectedScenarioId(id);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}
@@ -127,10 +144,11 @@ export function AppShell({ username }: { username: string }) {
onClick={() => {
setSelectedScenarioId(null);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
selectedScenarioId === null && !showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
@@ -176,11 +194,28 @@ export function AppShell({ username }: { username: string }) {
</div>
))}
<div className="mt-4 border-t border-border pt-3">
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
<button
type="button"
onClick={() => {
setShowSystemParams(true);
setShowSpec(false);
setSelectedScenarioId(null);
setSidebarOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<SlidersHorizontal className="h-4 w-4 shrink-0" />
Systemparameter
</button>
<button
type="button"
onClick={() => {
setShowSpec(true);
setSpecAnchor(null);
setShowSystemParams(false);
setSelectedScenarioId(null);
setSidebarOpen(false);
}}
@@ -230,7 +265,9 @@ export function AppShell({ username }: { username: string }) {
<Menu className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
{showSpec
{showSystemParams
? "Systemparameter"
: showSpec
? "Spezifikation"
: detail
? `${detail.meta.planName} · ${detail.meta.name}`
@@ -240,11 +277,13 @@ export function AppShell({ username }: { username: string }) {
</header>
<main className="flex-1 px-4 py-6 lg:px-8">
{showSpec && <SpecView />}
{showSystemParams && <SystemParametersView />}
{!showSpec && loading && <p className="text-sm text-muted">Laedt</p>}
{showSpec && <SpecView anchor={specAnchor} />}
{!showSpec && !loading && selectedScenarioId === null && (
{!showSpec && !showSystemParams && loading && <p className="text-sm text-muted">Laedt</p>}
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
<DashboardHome
username={username}
plans={plans}
@@ -254,7 +293,7 @@ export function AppShell({ username }: { username: string }) {
/>
)}
{!showSpec && !loading && detail && selectedScenarioId && (
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
<button
@@ -301,6 +340,15 @@ export function AppShell({ username }: { username: string }) {
<Tornado className="h-4 w-4" />
Einflussfaktoren berechnen
</button>
<button
type="button"
onClick={() => setShowPlanTraces(true)}
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<BookOpen className="h-4 w-4" />
Rechenwege
</button>
</>
)}
{diff && detail.base && (
@@ -313,7 +361,13 @@ export function AppShell({ username }: { username: string }) {
)}
</div>
<PlanView plan={detail.plan} computed={detail.computed} diff={diff} onChanged={refreshCurrent} />
<PlanView
plan={detail.plan}
computed={detail.computed}
diff={diff}
onChanged={refreshCurrent}
onOpenSpec={openSpecAt}
/>
</div>
)}
</main>
@@ -358,6 +412,14 @@ export function AppShell({ username }: { username: string }) {
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
)}
{showPlanTraces && detail && (
<PlanTraceDialog
computed={computePlan(detail.plan, undefined, { explain: true })}
onClose={() => setShowPlanTraces(false)}
onOpenSpec={openSpecAt}
/>
)}
{copyFrom && (
<CopyScenarioDialog
source={copyFrom}
+511
View File
@@ -0,0 +1,511 @@
"use client";
import { useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { BookOpen, X } from "lucide-react";
import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS } from "@/lib/elements";
import type { ElementCategory } from "@/lib/elements";
import type {
CashBridge,
ElementYearPoint,
PhaseComputed,
PlanComputed,
Trace,
TraceStep,
WealthBridge,
} from "@/lib/calculations";
// --- Wasserfall ---------------------------------------------------------------------------
// Recharts kennt keinen Wasserfall: Er entsteht aus zwei gestapelten Balken -- einem
// unsichtbaren Sockel und dem sichtbaren Delta darueber.
interface WaterfallItem {
label: string;
value: number;
total?: boolean; // Zwischen-/Endsumme: startet bei 0 statt beim laufenden Saldo
}
function waterfallData(items: WaterfallItem[]) {
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 };
}
const start = running;
running += it.value;
return {
label: it.label,
base: Math.min(start, running),
delta: Math.abs(it.value),
value: it.value,
kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg",
};
});
}
const WF_COLOR = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
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>
);
}
function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
const items: WaterfallItem[] = [];
if (!isFirst) {
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true });
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.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss });
}
items.push({ label: "Vermögen Phasenbeginn", value: w.startWealth, total: true });
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 });
if (w.pensionFundContribution) items.push({ label: "PK-Beiträge", value: w.pensionFundContribution });
items.push({ label: "Vermögen Phasenende", value: w.endWealth, total: true });
return items;
}
function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] {
const items: WaterfallItem[] = [];
items.push({ label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase", value: c.openingCash, total: true });
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 });
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 });
if (c.withdrawals) items.push({ label: "Bezugsraten", value: c.withdrawals });
items.push({ label: "Cash Phasenende", value: c.cashEnd, total: true });
return items;
}
// --- Rechenweg ----------------------------------------------------------------------------
function TraceStepRow({ step }: { step: TraceStep }) {
const unit = step.unit ?? "CHF";
const value =
unit === "" ? step.substituted ?? "" : unit === "%" ? `${step.result} %` : unit === "Jahre" ? `${step.result}` : formatChf(step.result);
return (
<tr className="border-t border-border align-top">
<td className="px-3 py-1.5 text-fg">{step.label}</td>
<td className="px-3 py-1.5 text-[11px] text-muted">
{step.formula && <div className="font-mono">{step.formula}</div>}
{step.substituted && unit !== "" && <div className="font-mono text-faint">{step.substituted}</div>}
{step.note && <div className="mt-0.5 italic text-faint">{step.note}</div>}
</td>
<td className="whitespace-nowrap px-3 py-1.5 text-right font-medium text-fg">{value}</td>
</tr>
);
}
export function TraceBlock({ trace, onOpenSpec }: { trace: Trace; onOpenSpec?: (anchor: string) => void }) {
return (
<div className="rounded-xl border border-border">
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface-2 px-3 py-2">
<span className="text-xs font-semibold text-fg">{trace.title}</span>
{trace.specAnchor && onOpenSpec && (
<button
type="button"
onClick={() => onOpenSpec(trace.specAnchor!)}
className="flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-muted hover:bg-surface"
>
<BookOpen className="h-3 w-3" /> in der Spezifikation
</button>
)}
</div>
<table className="w-full border-collapse text-sm">
<tbody>
{trace.steps.map((s, i) => (
<TraceStepRow key={i} step={s} />
))}
</tbody>
</table>
</div>
);
}
// --- Gemeinsame Dialog-Huelle mit Reitern --------------------------------------------------
function DetailShell({
title,
subtitle,
tabs,
onClose,
}: {
title: string;
subtitle?: string;
tabs: { key: string; label: string; content: React.ReactNode }[];
onClose: () => void;
}) {
const [active, setActive] = useState(tabs[0]?.key);
const current = tabs.find((t) => t.key === active) ?? tabs[0];
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
{subtitle && <p className="text-xs text-muted">{subtitle}</p>}
</div>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
<div className="inline-flex w-fit rounded-lg border border-border bg-surface p-0.5 text-xs">
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setActive(t.key)}
className={`rounded-md px-3 py-1 font-medium ${
current?.key === t.key ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{t.label}
</button>
))}
</div>
<div className="flex flex-col gap-4">{current?.content}</div>
<p className="text-[11px] text-faint">Nur-Lese-Ansicht. Werte werden hier nicht verändert.</p>
</div>
</div>
);
}
// --- Element-Detailansicht ----------------------------------------------------------------
const MULTI_SERIES: ElementCategory[] = ["REAL_ESTATE"];
export function ElementDetailDialog({
elementId,
name,
category,
computed,
onClose,
onOpenSpec,
}: {
elementId: string;
name: string;
category: ElementCategory;
computed: PlanComputed;
onClose: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
// Verlauf ueber ALLE Phasen zusammensetzen.
const points = useMemo(() => {
const out: ElementYearPoint[] = [];
for (const ph of computed.phases) {
const ec = ph.elements.find((e) => e.elementId === elementId);
if (ec) out.push(...ec.yearly);
}
return out;
}, [computed, elementId]);
const perPhase = useMemo(
() =>
computed.phases
.map((ph) => ({ phase: ph, ec: ph.elements.find((e) => e.elementId === elementId) }))
.filter((x) => x.ec),
[computed, elementId]
);
const isFlow = category === "INCOME" || category === "EXPENSE";
const valueLabel = isFlow
? category === "INCOME"
? "Einkommen (nominal)"
: "Ausgaben (nominal)"
: category === "OTHER_DEBT"
? "Beitrag zum Vermögen"
: category === "REAL_ESTATE"
? "Eigenkapital"
: "Wert";
const verlauf = (
<>
{points.length === 0 ? (
<p className="text-sm text-muted">Für dieses Element gibt es in diesem Plan keinen Verlauf.</p>
) : (
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v, n) => [typeof v === "number" ? formatChf(v) : v, n]} labelFormatter={(v) => `Alter ${v}`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
{MULTI_SERIES.includes(category) && (
<>
<Line dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
<Line dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
</>
)}
</LineChart>
</ResponsiveContainer>
</div>
)}
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Lebensphase</th>
<th className="px-3 py-2 text-right font-semibold">Beginn</th>
<th className="px-3 py-2 text-right font-semibold">Ende</th>
<th className="px-3 py-2 text-left font-semibold">Hinweis</th>
</tr>
</thead>
<tbody>
{perPhase.map(({ phase, ec }) => (
<tr key={phase.id} className="border-t border-border">
<td className="px-3 py-2 text-fg">{phase.name}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.startValue)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.endValue)}</td>
<td className="px-3 py-2 text-[11px] text-faint">{ec!.note ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
const rechenweg = (
<>
{perPhase.every(({ ec }) => !ec!.trace && !ec!.transitionTrace) && (
<p className="text-sm text-muted">Für dieses Element gibt es keinen eigenen Rechenweg.</p>
)}
{perPhase.map(({ phase, ec }) => (
<div key={phase.id} className="flex flex-col gap-2">
{(ec!.trace || ec!.transitionTrace) && (
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{phase.name}</div>
)}
{ec!.trace && <TraceBlock trace={ec!.trace} onOpenSpec={onOpenSpec} />}
{ec!.transitionTrace && <TraceBlock trace={ec!.transitionTrace} onOpenSpec={onOpenSpec} />}
</div>
))}
</>
);
return (
<DetailShell
title={name}
subtitle={CATEGORY_LABELS[category]}
onClose={onClose}
tabs={[
{ key: "verlauf", label: "Verlauf", content: verlauf },
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
]}
/>
);
}
// --- Phasen-Detailansicht ------------------------------------------------------------------
const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
export function PhaseDetailDialog({
phase,
isFirst,
onClose,
onOpenSpec,
}: {
phase: PhaseComputed;
isFirst: boolean;
onClose: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
const alloc = useMemo(() => {
const rows = phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0));
return [
{ label: "Beginn", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.startValue)])) },
{ label: "Ende", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.endValue)])) },
];
}, [phase]);
const allocEls = useMemo(
() => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)),
[phase]
);
const uebersicht = (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
<Kpi label="Dauer" text={`${phase.durationYears} Jahre`} />
<Kpi label="Vermögen Beginn" text={formatChf(phase.startWealthNominal)} />
<Kpi label="Vermögen Ende" text={formatChf(phase.endWealthNominal)} />
</div>
<section>
<h3 className="mb-1 text-xs font-semibold text-fg">Vermögensaufteilung</h3>
<p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens zu Beginn und am Ende dieser Phase.</p>
{allocEls.length === 0 ? (
<p className="text-sm text-muted">In dieser Phase gibt es kein Anlagevermögen.</p>
) : (
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={alloc} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{allocEls.map((el, i) => (
<Bar key={el.elementId} dataKey={el.elementId} name={el.name} stackId="a" fill={ALLOC_PALETTE[i % ALLOC_PALETTE.length]} isAnimationActive={false} />
))}
</BarChart>
</ResponsiveContainer>
</div>
)}
</section>
</>
);
const wasserfall = (
<>
<section>
<h3 className="mb-1 text-xs font-semibold text-fg">Woher kommt die Vermögensänderung?</h3>
<p className="mb-2 text-[11px] text-muted">
Nur <strong>echte</strong> Zu- und Abgänge. Sparraten, Amortisationen und Zusatzinvestitionen erscheinen hier
bewusst <strong>nicht</strong>: Sie verschieben Geld vom Cash in einen Vermögenswert, ohne das Vermögen zu
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>
)}
</section>
<section>
<h3 className="mb-1 text-xs font-semibold text-fg">Wohin ist das Cash geflossen?</h3>
<p className="mb-2 text-[11px] text-muted">
Hier erscheinen die Umbuchungen: Sparraten, Amortisationen und Investitionen verlassen das Cash-Konto, auch
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>
)}
</section>
</>
);
const rechenweg = (
<>
{(phase.traces ?? []).length === 0 && <p className="text-sm text-muted">Kein Rechenweg verfügbar.</p>}
{(phase.traces ?? []).map((t, i) => (
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
))}
</>
);
return (
<DetailShell
title={phase.name}
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
onClose={onClose}
tabs={[
{ key: "uebersicht", label: "Übersicht", content: uebersicht },
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
]}
/>
);
}
function Kpi({ label, text }: { label: string; text: string }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[11px] text-muted">{label}</div>
<div className="text-sm font-semibold text-fg">{text}</div>
</div>
);
}
// --- Plan-weite Rechenwege (Deflatoren, AHV-Karriere, Ruinalter) ---------------------------
export function PlanTraceDialog({
computed,
onClose,
onOpenSpec,
}: {
computed: PlanComputed;
onClose: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
return (
<DetailShell
title="Rechenwege dieses Szenarios"
subtitle="Plan-weite Grössen, die in alle Phasen hineinwirken"
onClose={onClose}
tabs={[
{
key: "plan",
label: "Plan-Ebene",
content: (
<>
{(computed.traces ?? []).map((t, i) => (
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
))}
</>
),
},
]}
/>
);
}
+79 -6
View File
@@ -10,6 +10,7 @@ import {
CreditCard,
Home,
Landmark,
Maximize2,
PiggyBank,
Plus,
Settings2,
@@ -19,6 +20,7 @@ import {
X,
} from "lucide-react";
import { Timeline } from "@/components/Timeline";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
import {
CashTransitionFields,
ElementDetail,
@@ -46,7 +48,7 @@ import {
type PhaseData,
type TransitionData,
} from "@/lib/elements";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
import type { ScenarioDiff } from "@/lib/diff";
import type { ElementInput, PlanInput } from "@/lib/types";
@@ -92,12 +94,14 @@ export function PlanView({
computed,
diff,
onChanged,
onOpenSpec,
}: {
plan: PlanInput;
computed: PlanComputed;
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
// Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau.
const cellDiff = (elementId: string, phaseId: string) =>
@@ -124,6 +128,16 @@ export function PlanView({
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Oeffnen erzeugt.
const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null);
// Erklaerte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein
// und laeuft im Browser -- es braucht dafuer weder einen API-Aufruf noch eine groessere
// Server-Antwort, und das Ergebnis ist per Konstruktion identisch zum Serverergebnis.
const explained = useMemo(
() => (detailFor ? computePlan(plan, undefined, { explain: true }) : null),
[detailFor, plan]
);
useEffect(() => {
const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null;
@@ -405,6 +419,7 @@ export function PlanView({
mode={valueMode}
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/>
) : (
@@ -499,12 +514,24 @@ export function PlanView({
{!collapsed &&
els.map((el) => (
<tr key={el.id} className="hover:bg-surface-2">
<td className={`sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
<div className="flex items-center gap-1 truncate text-xs font-medium text-fg">
{el.name}
<td className={`group/row sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
<div className="flex items-center gap-1 text-xs font-medium text-fg">
<span className="min-w-0 flex-1 truncate">{el.name}</span>
{diff?.elementRow.get(el.id) === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
<button
type="button"
aria-label={`Detailansicht ${el.name}`}
title="Detailansicht (nur lesen)"
onClick={(e) => {
e.stopPropagation();
setDetailFor({ kind: "element", id: el.id });
}}
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/row:opacity-100"
>
<Maximize2 className="h-3 w-3" />
</button>
</div>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
@@ -706,6 +733,36 @@ export function PlanView({
);
})()}
{detailFor?.kind === "element" && explained && (() => {
const el = plan.elements.find((e) => e.id === detailFor.id);
if (!el) return null;
return (
<ElementDetailDialog
key={`det-${detailFor.id}`}
elementId={el.id}
name={el.name}
category={el.category}
computed={explained}
onClose={() => setDetailFor(null)}
onOpenSpec={onOpenSpec}
/>
);
})()}
{detailFor?.kind === "phase" && explained && (() => {
const ph = explained.phases.find((p) => p.id === detailFor.id);
if (!ph) return null;
return (
<PhaseDetailDialog
key={`detph-${detailFor.id}`}
phase={ph}
isFirst={ph.sequenceNumber === 1}
onClose={() => setDetailFor(null)}
onOpenSpec={onOpenSpec}
/>
);
})()}
{editPhaseCell && (() => {
const element = plan.elements.find((e) => e.id === editPhaseCell.elementId);
const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId);
@@ -854,6 +911,7 @@ function PhaseHeader({
mode,
diffKind,
onClick,
onExpand,
active,
}: {
phase: PhaseComputed;
@@ -861,6 +919,7 @@ function PhaseHeader({
mode: ValueMode;
diffKind: "changed" | "added" | "removed" | null;
onClick: () => void;
onExpand: () => void;
active: boolean;
}) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
@@ -870,7 +929,7 @@ function PhaseHeader({
return (
<th
onClick={onClick}
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
className={`group/ph min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active
? "bg-accent-soft"
: diffKind === "added"
@@ -881,7 +940,7 @@ function PhaseHeader({
}`}
>
<div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{phase.name}</span>
{diffKind === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
@@ -890,6 +949,20 @@ function PhaseHeader({
) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
)}
{/* Der Kopf oeffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event
deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */}
<button
type="button"
aria-label={`Detailansicht ${phase.name}`}
title="Detailansicht (nur lesen)"
onClick={(e) => {
e.stopPropagation();
onExpand();
}}
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/ph:opacity-100"
>
<Maximize2 className="h-3 w-3" />
</button>
</div>
<div className="mt-0.5 flex flex-wrap gap-1">
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
+9 -1
View File
@@ -9,7 +9,7 @@ import { api } from "@/lib/api-client";
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema.
export function SpecView() {
export function SpecView({ anchor }: { anchor?: string | null }) {
const [markdown, setMarkdown] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -20,6 +20,14 @@ export function SpecView() {
.catch((e) => setError(e instanceof Error ? e.message : "Laden fehlgeschlagen."));
}, []);
// Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die
// Ueberschrift mit der von rehype-slug erzeugten id.
useEffect(() => {
if (!anchor || markdown === null) return;
const el = document.getElementById(anchor);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, [anchor, markdown]);
if (error) return <p className="text-sm text-danger">{error}</p>;
if (markdown === null) return <p className="text-sm text-muted">Laedt</p>;
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { SlidersHorizontal } from "lucide-react";
import { formatChf } from "@/lib/format";
import { SYSTEM_PARAMETERS, type ParameterGroup, type SystemParameter } from "@/lib/constants";
const GROUPS: { key: ParameterGroup; title: string; intro: string }[] = [
{
key: "AHV",
title: "AHV",
intro:
"Diese Werte legt der Bund fest. Sie ändern sich periodisch der Stand ist je Parameter ausgewiesen.",
},
{
key: "Vorsorge",
title: "Berufliche und gebundene Vorsorge",
intro: "Höchstbeträge und Umwandlung. Der Umwandlungssatz ist ein Vorschlag und pro Ereignis überschreibbar.",
},
{
key: "Steuern",
title: "Steuersätze",
intro:
"Bewusst pauschale Vorschlagswerte, keine Steuerberechnung: Die tatsächlichen Sätze sind kantonal, progressiv und vom Einzelfall abhängig. Sie lassen sich pro Ereignis überschreiben.",
},
];
function formatValue(p: SystemParameter): string {
switch (p.unit) {
case "CHF":
return `${formatChf(p.value)} CHF`;
case "%":
return `${p.value} %`;
case "Jahre":
return `${p.value} Jahre`;
default:
return String(p.value);
}
}
// Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41).
// Die Eintraege stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
// gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen.
export function SystemParametersView() {
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-accent" />
<h2 className="text-lg font-semibold text-fg">Systemparameter</h2>
</div>
<p className="max-w-3xl text-sm text-muted">
Alle fest hinterlegten Grössen, mit denen das Tool rechnet inklusive Herleitung und Quelle. Sie stammen aus
derselben Datei, aus der auch die Berechnung liest; was hier steht, ist also garantiert das, was gerechnet wird.
</p>
{GROUPS.map((g) => {
const rows = SYSTEM_PARAMETERS.filter((p) => p.group === g.key);
if (rows.length === 0) return null;
return (
<section key={g.key} className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="text-sm font-semibold text-fg">{g.title}</h3>
<p className="mb-3 mt-0.5 text-xs text-muted">{g.intro}</p>
<div className="flex flex-col gap-3">
{rows.map((p) => (
<div key={p.key} className="rounded-lg border border-border bg-surface-2 p-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="text-sm font-medium text-fg">{p.label}</span>
<span className="text-sm font-semibold text-accent">{formatValue(p)}</span>
</div>
<p className="mt-1 text-xs text-muted">{p.meaning}</p>
{p.derivation && (
<p className="mt-1 font-mono text-[11px] text-faint">Herleitung: {p.derivation}</p>
)}
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-faint">
<span>Quelle: {p.source}</span>
<span>Stand: {p.validFrom}</span>
{p.editablePerEvent && <span className="text-accent">pro Ereignis überschreibbar</span>}
</div>
</div>
))}
</div>
</section>
);
})}
<p className="max-w-3xl text-xs text-faint">
Die drei Steuersätze werden sowohl als Vorschlag im Eingabefeld als auch in der Berechnung als Rückfallwert
verwendet damit ein nicht angetippter Wert nicht fälschlich als 0 % gerechnet wird.
</p>
</div>
);
}
+679 -12
View File
@@ -26,6 +26,44 @@ export interface PersonPhaseInfo {
retiresAtStart: boolean;
}
// --- Rechenweg-Protokoll (Roadmap Nr. 41) ------------------------------------------------
// Ein Schritt haelt die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte
// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin laeuft.
// Damit kann die angezeigte Herleitung nicht von der tatsaechlichen Rechnung abdriften --
// eine zweite Formel-Implementierung im UI waere die gefaehrlichste Variante ueberhaupt.
export interface TraceStep {
label: string;
formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhoehung)^(t-1)"
substituted?: string; // mit eingesetzten Zahlen, z. B. "100'000 x (1 + 2%)^4"
result: number;
unit?: "CHF" | "%" | "Jahre" | "";
note?: string; // Vereinfachung/Grenze, die an dieser Stelle gilt
}
export interface Trace {
title: string;
specAnchor?: string; // Sprungmarke in die SPEZIFIKATION (Kapitel 4)
steps: TraceStep[];
}
export interface ComputeOptions {
// Standardmaessig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
// und darf von der Protokollierung nichts merken.
explain?: boolean;
}
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % ueber 20 Jahre waechst
// konvex, eine Gerade von Start zu Ende waere sichtbar falsch (gleiche Ueberlegung wie beim
// Vermoegensverlauf in v0.9).
export interface ElementYearPoint {
year: number; // 1-basiert ab Planbeginn
age: number; // Alter Person A am Jahresende
value: number; // Haupt-Kennzahl (Saldo, Eigenkapital, Flow, Rente)
propertyValue?: number; // nur REAL_ESTATE: Verkehrswert der Liegenschaft
mortgage?: number; // nur REAL_ESTATE: Restschuld
}
export interface ElementPhaseComputed {
elementId: string;
category: ElementCategory;
@@ -41,6 +79,51 @@ export interface ElementPhaseComputed {
endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
summary: string;
note: string | null;
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
transitionTrace?: Trace; // Rechenweg des Uebergangs NACH dieser Phase (nur mit explain)
}
// --- Wasserfall-Zerlegungen (Roadmap Nr. 43) ---------------------------------------------
//
// WICHTIG -- der Unterschied zwischen den beiden: Sparraten, Amortisationen und
// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhoehen im selben Zug
// einen Vermoegenswert bzw. senken eine Schuld; das Vermoegen bleibt unveraendert. Sie
// gehoeren deshalb in die Cash-Bruecke und ausdruecklich NICHT in die Vermoegens-Bruecke --
// dort als Abgang gezeichnet wuerden sie eine Vermoegensminderung suggerieren, die es nicht
// gibt.
export interface WealthBridge {
openingWealth: number; // Endvermoegen der Vorphase (Phase 1: Startvermoegen)
// Uebergang IN diese Phase -- echte Vermoegensaenderungen an der Phasengrenze:
oneOffInflow: number; // einmaliger Zufluss (netto nach Steuer)
oneOffOutflow: number; // einmalige Kosten
transitionTax: number; // Kapitalbezugs- und Grundstueckgewinnsteuer (Abgang)
pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlaesst die Bilanz)
saleGainLoss: number; // Verkaufspreis minus Verkehrswert bei einem Immobilienverkauf
startWealth: number; // Kontrollpunkt = startWealthNominal
// Innerhalb der Phase:
quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) ueber alle Phasenjahre
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermoegen
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
pensionFundContribution: number; // PK-Beitraege: erhoehen das Vermoegen, ohne Cash zu kosten
endWealth: number; // = endWealthNominal
residual: number; // Rundungsdifferenz (Kontrollgroesse, sollte nahe 0 sein)
}
export interface CashBridge {
openingCash: number; // Cash-Endbestand der Vorphase (Phase 1: initialCash)
capitalInflow: number; // Verkaeufe + PK-/3a-Bezuege aus dem Uebergang
oneOffInflow: number;
immediateRepay: number; // Sofort-Tilgungen und Sonderamortisation (Abgang)
oneOffOutflow: number;
investments: number; // Zusatz-/Neuinvestitionen am Phasenanfang (Abgang)
cashStart: number; // Kontrollpunkt
quotaTotal: number;
savingRates: number; // 3a + Sparbeitraege (Abgang)
debtRates: number; // Amortisationen + Tilgungen (Abgang)
withdrawals: number; // Bezugsraten aus Sonstigem Vermoegen (Zugang)
cashEnd: number;
residual: number;
}
export interface PhaseComputed {
@@ -85,6 +168,9 @@ export interface PhaseComputed {
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`)
endWealthReal: number;
wealthBridge: WealthBridge;
cashBridge: CashBridge;
traces?: Trace[]; // Rechenwege der Phasen-Kennzahlen (nur mit explain)
}
// Ein Datenpunkt pro Jahr (ueber alle Phasen), fuer die Verlaufsgrafik.
@@ -106,6 +192,7 @@ export interface PlanComputed {
nachlass: number;
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (fuer die AHV-Pruefung am Uebergang)
traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain)
}
// --- AHV-Rentenformel (Skala 44) ---------------------------------------------------------
@@ -223,7 +310,23 @@ export interface PlanSample {
assetReturn: (elementId: string, year: number) => number;
}
export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed {
// Kurzform fuer einen Rechenschritt. `pct` formatiert Prozentsaetze lesbar (2 statt 2.0000001).
function st(
label: string,
result: number,
formula?: string,
substituted?: string,
note?: string,
unit: TraceStep["unit"] = "CHF"
): TraceStep {
return { label, formula, substituted, result, unit, note };
}
function pct(v: number): string {
return `${Math.round(v * 1000) / 1000} %`;
}
export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed {
const explain = options?.explain === true;
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = plan.persons;
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
@@ -262,6 +365,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
let incomingOneOffInflowLabel: string | null = null;
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
let incomingOneOffOutflowLabel: string | null = null;
// Echte Vermoegensaenderungen am Uebergang IN die Folgephase (fuer die Vermoegens-Bruecke).
let incomingTax = 0; // Kapitalbezugs- + Grundstueckgewinnsteuer
let incomingPensionConversion = 0; // in eine Rente umgewandeltes PK-Kapital
let incomingSaleGainLoss = 0; // Verkaufspreis minus Verkehrswert
let previousEndWealth = 0; // Endvermoegen der Vorphase (Startpunkt der Bruecke)
let previousCashEnd = Math.round(plan.initialCash || 0);
let ruinAge: number | null = null;
for (let i = 0; i < phases.length; i++) {
@@ -345,7 +454,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
const assets: { value: number; rate: number; r: number; withdrawal: number; ec: ElementPhaseComputed }[] = [];
// `isPk` fuer die Vermoegens-Bruecke: PK-Beitraege verlassen das Cash NICHT (sie sind im
// Nettolohn bereits abgezogen), erhoehen aber das Vermoegen -- sie sind also ein echter
// Zugang und keine Umbuchung.
const assets: {
value: number;
rate: number;
r: number;
withdrawal: number;
isPk: boolean;
ec: ElementPhaseComputed;
}[] = [];
// mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der
// Liegenschaft (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis.
@@ -387,6 +506,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
endValue: 0,
summary: "",
note: null,
yearly: [],
};
ecById.set(e.id, ec);
@@ -470,7 +590,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, ec });
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: true, ec });
}
break;
}
@@ -488,7 +608,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, ec });
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: false, ec });
}
break;
}
@@ -504,7 +624,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, ec });
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, isPk: false, ec });
break;
}
case "REAL_ESTATE": {
@@ -570,6 +690,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
let quotaStart = 0;
let quotaEnd = 0;
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr
// Summanden der beiden Wasserfall-Bruecken (ueber alle Phasenjahre kumuliert).
let quotaTotal = 0;
let investmentReturnTotal = 0;
let propertyAppreciationTotal = 0;
let pensionFundContributionTotal = 0;
let savingRatesTotal = 0;
let debtRatesTotal = 0;
let withdrawalsTotal = 0;
for (let t = 1; t <= duration; t++) {
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
@@ -621,10 +749,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
let cashFromWithdraw = 0;
for (const a of assets) {
const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r;
// Arithmetik unveraendert lassen; die Rendite wird als Differenz abgeleitet, damit
// die Protokollierung das Ergebnis nicht (per Gleitkomma-Umformung) verschiebt.
const grown = a.value * (1 + r / 100) + a.rate;
const growth = grown - a.rate - a.value;
const w = Math.min(a.withdrawal, Math.max(0, grown));
a.value = grown - w;
cashFromWithdraw += w;
investmentReturnTotal += growth;
if (a.isPk) pensionFundContributionTotal += a.rate;
}
// Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
@@ -637,7 +770,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
// Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital -- das ist der
// Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000.
const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth;
re.value *= 1 + g / 100;
const valueBefore = re.value;
re.value *= 1 + g / 100; // Arithmetik unveraendert
propertyAppreciationTotal += re.value - valueBefore;
}
for (const d of debts) {
const pay = Math.min(d.repay, d.owed);
@@ -649,6 +784,43 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
if (cash < 0) cashNegative = true;
quotaTotal += quote;
savingRatesTotal += fixedRatesTotal;
debtRatesTotal += debtRates;
withdrawalsTotal += cashFromWithdraw;
// Verlaufspunkt je Element (Detailansicht). Bewusst NACH Verzinsung und Tilgung, also
// der Stand am Jahresende -- konsistent zum Gesamtvermoegen im YearPoint.
const age = personA.age + yearsBefore + t;
const yr = yearsBefore + t;
for (const inc of incomes) {
inc.ec.yearly.push({ year: yr, age, value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)) });
}
for (const exp of expenses) {
exp.ec.yearly.push({
year: yr,
age,
value: Math.round(exp.basis * Math.pow(1 + exp.idx / 100, t - 1) * inflFactor),
});
}
for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value) });
for (const re of realEstates) {
re.ec.yearly.push({
year: yr,
age,
value: Math.round(re.value - re.mortgage), // Eigenkapital
propertyValue: Math.round(re.value),
mortgage: Math.round(re.mortgage),
});
}
for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) });
// Renten (AHV, verrentete PK) laufen nominal fix durch die Phase.
for (const ec of ecById.values()) {
if ((ec.category === "AHV" || ec.category === "PENSION_FUND") && ec.startValue > 0 && ec.yearly.length < t) {
ec.yearly.push({ year: yr, age, value: ec.startValue });
}
}
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
let total = cash;
for (const a of assets) total += a.value;
@@ -692,11 +864,287 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
if (d.owed === 0) d.ec.note = "Wird getilgt";
}
// --- Rechenweg je Element (Roadmap Nr. 41) ---
// Bewusst HIER, nach der Jahresschleife: erst jetzt stehen die Endwerte fest.
if (explain) {
for (const inc of incomes) {
inc.ec.trace = {
title: `Einkommen «${inc.ec.name}» in dieser Lebensphase`,
specAnchor: "461-income--expense",
steps: [
st(
inc.ec.carried ? "Basiswert (aus der Vorphase fortgeschrieben)" : "Basiswert (erfasst)",
Math.round(inc.basis),
undefined,
undefined,
inc.ec.carried
? "Der Endwert der Vorphase wird fortgeschrieben, solange kein abweichender Wert erfasst ist."
: "Erfasst wird das NETTO-Einkommen, nominal."
),
st("Nominale Lohnerhöhung", inc.idx, undefined, undefined, undefined, "%"),
st(
`Einkommen im letzten Phasenjahr (Jahr ${duration})`,
inc.ec.endValue,
"Basis × (1 + Lohnerhöhung)^(Dauer 1)",
`${fmt(inc.basis)} × (1 + ${pct(inc.idx)})^${duration - 1}`
),
st(
"Basiswert für die Folgephase",
Math.round(inc.basis * Math.pow(1 + inc.idx / 100, duration)),
"Basis × (1 + Lohnerhöhung)^Dauer",
`${fmt(inc.basis)} × (1 + ${pct(inc.idx)})^${duration}`,
"Ein Jahr weiter als der Endwert die Folgephase beginnt nach dem letzten Jahr dieser Phase."
),
],
};
}
for (const exp of expenses) {
const realEnd = exp.basis * Math.pow(1 + exp.idx / 100, duration - 1);
exp.ec.trace = {
title: `Ausgaben «${exp.ec.name}» in dieser Lebensphase`,
specAnchor: "461-income--expense",
steps: [
st(
exp.ec.carried ? "Basiswert real (fortgeschrieben)" : "Basiswert real (erfasst)",
Math.round(exp.basis),
undefined,
undefined,
"Ausgaben werden REAL erfasst, also in heutiger Kaufkraft."
),
st("Reale Mehrausgaben pro Jahr", exp.idx, undefined, undefined, "Zusätzlich zur Inflation.", "%"),
st(
"Nominal im ersten Phasenjahr",
exp.ec.startValue,
"Basis × kumulierte Inflation (Phasenbeginn)",
`${fmt(exp.basis)} × ${Math.round(cumInflStart * 10000) / 10000}`
),
st(
`Real im letzten Phasenjahr (Jahr ${duration})`,
Math.round(realEnd),
"Basis × (1 + reale Mehrausgaben)^(Dauer 1)",
`${fmt(exp.basis)} × (1 + ${pct(exp.idx)})^${duration - 1}`
),
st(
`Nominal im letzten Phasenjahr (Jahr ${duration})`,
exp.ec.endValue,
"real × Flow-Deflator am Phasenende",
`${fmt(realEnd)} × ${Math.round(flowDeflatorEnd * 10000) / 10000}`,
"Der Flow-Deflator liegt eine Kaufkraft-Stufe unter dem Bestands-Deflator: ein Flow fällt IM Jahr `Dauer` an, ein Bestand wird NACH diesem Jahr gemessen."
),
],
};
}
for (const a of assets) {
const isPk = a.isPk;
a.ec.trace = {
title: `${isPk ? "Pensionskasse" : "Kapitalanlage"} «${a.ec.name}» in dieser Lebensphase`,
specAnchor: isPk ? "463-pension_fund" : "466-other_asset",
steps: [
st(a.ec.carried ? "Startwert (fortgeschrieben)" : "Startwert (erfasst)", a.ec.baseValue),
...(a.ec.startValue !== a.ec.baseValue
? [st("Zusatzeinlage aus Kapital", a.ec.startValue - a.ec.baseValue, undefined, undefined, "Wird dem Cash entnommen.")]
: []),
st("Erwartete Rendite", a.r, undefined, undefined, undefined, "%"),
st(
"Jährliche Einzahlung",
a.rate,
undefined,
undefined,
isPk
? "Arbeitnehmer- und Arbeitgeberbeitrag. Belastet das Cash NICHT die Beiträge sind im Nettolohn bereits abgezogen."
: "Verlässt das Cash und zählt zur geplanten Sparrate."
),
...(a.withdrawal > 0
? [st("Jährliche Bezugsrate", a.withdrawal, undefined, undefined, "Entnahme aus dem Vermögen ins Cash, am Bestand gekappt.")]
: []),
st(
"Jahresschritt",
a.ec.endValue,
"Bestand × (1 + Rendite) + Einzahlung Bezugsrate",
`über ${duration} Jahre, beginnend bei ${fmt(a.ec.startValue)}`,
"Verzinsung ist nachschüssig: die Einzahlung des laufenden Jahres wird noch nicht mitverzinst."
),
st("Endwert der Phase", a.ec.endValue),
],
};
}
for (const re of realEstates) {
const interestStart = Math.round((re.ec.mortgageStart * re.interestRate) / 100);
const interestEnd = Math.round((re.ec.mortgageEnd * re.interestRate) / 100);
re.ec.trace = {
title: `Immobilie «${re.ec.name}» in dieser Lebensphase`,
specAnchor: "465-real_estate-immobilie",
steps: [
st("Verkehrswert zu Phasenbeginn", re.ec.startValue + re.ec.mortgageStart),
st("Resthypothek zu Phasenbeginn", re.ec.mortgageStart),
st("Eigenkapital zu Phasenbeginn", re.ec.startValue, "Verkehrswert Resthypothek", `${fmt(re.ec.startValue + re.ec.mortgageStart)} ${fmt(re.ec.mortgageStart)}`),
st("Ursprünglicher Kaufpreis", re.purchase, undefined, undefined, "Basis der Grundstückgewinnsteuer beim Verkauf läuft getrennt vom Verkehrswert."),
st("Wertsteigerung pro Jahr", re.growth, undefined, undefined, "Wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital daher der Hebel.", "%"),
st("Amortisation pro Jahr", re.amort, undefined, undefined, "Am Restsaldo gekappt: ist die Hypothek getilgt, entfällt die Rate."),
st("Hypothekarzins", re.interestRate, undefined, undefined, re.addInterest ? "Wird zu den Ausgaben dazugerechnet." : "Gilt als bereits im Ausgaben-Element enthalten und wird NICHT zusätzlich abgezogen.", "%"),
st("Zinsbetrag im ersten Jahr", interestStart, "Restschuld × Zinssatz", `${fmt(re.ec.mortgageStart)} × ${pct(re.interestRate)}`),
st("Zinsbetrag im letzten Jahr", interestEnd, "Restschuld × Zinssatz", `${fmt(re.ec.mortgageEnd)} × ${pct(re.interestRate)}`, "Sinkt automatisch mit der Amortisation."),
st("Verkehrswert am Phasenende", re.ec.endValue + re.ec.mortgageEnd),
st("Resthypothek am Phasenende", re.ec.mortgageEnd),
st("Eigenkapital am Phasenende", re.ec.endValue, "Verkehrswert Resthypothek", `${fmt(re.ec.endValue + re.ec.mortgageEnd)} ${fmt(re.ec.mortgageEnd)}`),
],
};
}
for (const d of debts) {
d.ec.trace = {
title: `Schuld «${d.ec.name}» in dieser Lebensphase`,
specAnchor: "467-other_debt",
steps: [
st("Restschuld zu Phasenbeginn", -d.ec.startValue),
st("Jährliche Tilgung", d.repay, undefined, undefined, "Am Restsaldo gekappt im letzten Zahlungsjahr wird nur der Restbetrag fällig."),
st("Restschuld am Phasenende", d.owed, `min(Tilgung, Restschuld) über ${duration} Jahre`),
st("Beitrag zum Vermögen", d.ec.endValue, undefined, undefined, "Schulden gehen mit negativem Vorzeichen ins Vermögen ein."),
],
};
}
// AHV-/PK-Renten: der Rechenweg der Rentenhoehe selbst liegt auf Plan-Ebene (mdJE).
for (const e of orderedElements) {
const ec = ecById.get(e.id)!;
if (ec.trace || ec.startValue === 0) continue;
if (e.category === "AHV") {
const owner = e.ownerRole ? personByRole(persons, e.ownerRole) : null;
const gap = owner ? gapYearsByPerson.get(owner.id) ?? 0 : 0;
const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 } : { avg: 0, gap: 0 };
const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null;
const mdJE = career ? ahvMdje(career, before.avg, before.gap) : 0;
ec.trace = {
title: `AHV-Rente «${ec.name}»`,
specAnchor: "44-ahv-rente",
steps: [
st("Massgebendes durchschnittliches Jahreseinkommen (mdJE)", Math.round(mdJE), undefined, undefined, "Gewichtetes Mittel über alle Beitragsjahre, REAL gerechnet und auf den Bruttolohn hochgerechnet."),
st("Volle Monatsrente zum mdJE", Math.round(ahvMonthlyFullPension(mdJE)), "Rentenformel Skala 44", `mdJE ${fmt(mdJE)} → Skala 44`),
st("Ausfalljahre total", gap + before.gap, undefined, undefined, "Jedes Ausfalljahr kürzt die Rente um 1/44.", "Jahre"),
st(
"Rentenskala-Faktor",
Math.round(((AHV_FULL_CONTRIBUTION_YEARS - (gap + before.gap)) / AHV_FULL_CONTRIBUTION_YEARS) * 1000) / 10,
"(44 Ausfalljahre) / 44",
`(${AHV_FULL_CONTRIBUTION_YEARS} ${gap + before.gap}) / ${AHV_FULL_CONTRIBUTION_YEARS}`,
undefined,
"%"
),
st(
"Jahresrente",
ec.startValue,
"Monatsrente × 13 × Skalafaktor",
`${fmt(ahvMonthlyFullPension(mdJE))} × ${AHV_PENSION_MONTHS} × Faktor`,
plan.householdType === "COUPLE"
? "Bei Ehepaaren zusätzlich auf 150 % der Einzel-Maximalrente plafoniert. Die Rente wird danach nominal eingefroren und nicht indexiert."
: "Die Rente wird danach nominal eingefroren und nicht indexiert (bewusste Vereinfachung)."
),
],
};
} else if (e.category === "PENSION_FUND") {
ec.trace = {
title: `PK-Rente «${ec.name}»`,
specAnchor: "491-pension_fund",
steps: [
st("Jährliche Rente", ec.startValue, "verrentetes Kapital × Umwandlungssatz", undefined, "Der Umwandlungssatz wurde beim Pensions-Übergang gewählt. Die Rente ist danach nominal fix."),
],
};
}
}
}
const cashEnd = Math.round(cash);
const startWealthNominal = Math.round(wealthStart + cashStart);
const endWealthNominal = Math.round(wealthEnd + cashEnd);
cumulativeInflation = cumInfl[yearsBefore + duration];
// --- Rechenweg der Phasen-Kennzahlen ---
const phaseTraces: Trace[] = [];
if (explain) {
phaseTraces.push({
title: "Spar- bzw. Verzehrquote",
specAnchor: "47-die-jahresschleife",
steps: [
st("Einkommen im ersten Jahr (inkl. Renten)", Math.round(incomeStart)),
st("Ausgaben im ersten Jahr (nominal)", Math.round(expenseStart)),
st("Quote im ersten Jahr", Math.round(quotaStart), "Einkommen nominale Ausgaben", `${fmt(incomeStart)} ${fmt(expenseStart)}`,
quotaStart < 0 ? "Negativ das ist eine Verzehrphase: die laufenden Ausgaben übersteigen das Einkommen." : undefined),
st("Quote im letzten Jahr", Math.round(quotaEnd), "Einkommen nominale Ausgaben", `${fmt(incomeEnd)} ${fmt(expenseEnd)}`),
st("Summe über alle Phasenjahre", Math.round(quotaTotal), undefined, undefined, "Diese Summe ist der Beitrag der laufenden Flows zur Vermögensentwicklung."),
],
});
phaseTraces.push({
title: "Geplante Spar- und Verzehrrate",
specAnchor: "47-die-jahresschleife",
steps: [
st("Feste Sparraten (3a + Sparbeiträge)", fixedRatesTotal, undefined, undefined, "PK-Beiträge zählen NICHT dazu sie sind im Nettolohn bereits abgezogen."),
st("Amortisationen und Tilgungen im ersten Jahr", plannedSaveRate - fixedRatesTotal),
st("Geplante Sparrate (erstes Jahr)", plannedSaveRate, "Sparraten + Amortisationen + Tilgungen", `${fmt(fixedRatesTotal)} + ${fmt(plannedSaveRate - fixedRatesTotal)}`,
"Bewusst das erste Phasenjahr: läuft eine Schuld während der Phase aus, liegt die Rate später tiefer."),
st("Geplante Verzehrrate", plannedWithdrawTotal, undefined, undefined, "Summe der Bezugsraten aus Sonstigem Vermögen."),
],
});
phaseTraces.push({
title: "Cash-Fortschreibung",
specAnchor: "47-die-jahresschleife",
steps: [
st(isFirstPhase ? "Cash-Anfangswert" : "Cash-Endbestand der Vorphase", isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd),
...(incomingInflow ? [st("+ Kapitalzufluss aus dem Übergang", Math.round(incomingInflow), undefined, undefined, "Verkäufe und PK-/3a-Bezüge, netto nach Steuer.")] : []),
...(incomingOneOffInflow ? [st("+ Einmaliger Zufluss", Math.round(incomingOneOffInflow))] : []),
...(incomingImmediateRepay ? [st(" Sofort-Tilgung / Sonderamortisation", -Math.round(incomingImmediateRepay))] : []),
...(incomingOneOffOutflow ? [st(" Einmalige Kosten", -Math.round(incomingOneOffOutflow))] : []),
...(!isFirstPhase && investmentsFromCash ? [st(" Investitionen am Phasenanfang", -Math.round(investmentsFromCash), undefined, undefined, "Werden vorab abgezogen, damit sie nicht doppelt (im Cash UND im Vermögen) zählen.")] : []),
st("= Cash zu Phasenbeginn", Math.round(cashStart)),
st("+ Summe der Quoten", Math.round(quotaTotal)),
st(" Sparraten", -Math.round(savingRatesTotal)),
st(" Amortisationen und Tilgungen", -Math.round(debtRatesTotal)),
st("+ Bezugsraten", Math.round(withdrawalsTotal)),
st("= Cash am Phasenende", cashEnd, undefined, undefined,
cashNegative ? "Der Cash-Bestand fällt in dieser Phase unter 0 das ist eine Liquiditätslücke. Sie wird gemeldet, aber nicht automatisch ausgeglichen." : undefined),
],
});
phaseTraces.push({
title: "Vermögen zu Beginn und am Ende",
specAnchor: "48-endwerte-und-phasen-kennzahlen",
steps: [
st("Summe der Element-Startwerte", Math.round(wealthStart)),
st("+ Cash zu Phasenbeginn", Math.round(cashStart)),
st("= Vermögen zu Phasenbeginn", startWealthNominal),
st("Summe der Element-Endwerte", Math.round(wealthEnd)),
st("+ Cash am Phasenende", cashEnd),
st("= Vermögen am Phasenende (nominal)", endWealthNominal),
st("Bestands-Deflator am Phasenende", Math.round(cumulativeInflation * 10000) / 10000, undefined, undefined, undefined, ""),
st("= Vermögen am Phasenende (real)", Math.round(endWealthNominal / cumulativeInflation),
"nominal / kumulierte Inflation", `${fmt(endWealthNominal)} / ${Math.round(cumulativeInflation * 10000) / 10000}`,
"Real heisst: kaufkraftbereinigt auf den Planbeginn."),
],
});
phaseTraces.push({
title: "Phasentyp und Alter",
specAnchor: "43-personen-und-phasentyp",
steps: [
...personInfos.map((p) =>
st(
`${p.role === "PERSON_A" ? "Person A" : "Person B"}: Alter zu Phasenbeginn`,
p.startAge,
"aktuelles Alter + Jahre vor dieser Phase",
`${persons.find((x) => x.id === p.personId)?.age ?? 0} + ${yearsBefore}`,
p.working ? "erwerbstätig (Alter < Pensionsalter)" : "pensioniert",
"Jahre"
)
),
st("Phasentyp", 0, undefined, type === "ERWERB" ? "alle erwerbstätig" : type === "PENSION" ? "alle pensioniert" : "gemischt",
"Der Phasentyp wird nie gespeichert, sondern in jeder Berechnung neu abgeleitet.", ""),
],
});
}
result.push({
id: phase.id,
name: phase.name,
@@ -731,11 +1179,69 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
cumulativeInflationEnd: cumulativeInflation,
flowDeflatorEnd,
endWealthReal: endWealthNominal / cumulativeInflation,
wealthBridge: {
openingWealth: isFirstPhase ? startWealthNominal : previousEndWealth,
oneOffInflow: Math.round(incomingOneOffInflow),
oneOffOutflow: Math.round(incomingOneOffOutflow),
transitionTax: Math.round(incomingTax),
pensionConversion: Math.round(incomingPensionConversion),
saleGainLoss: Math.round(incomingSaleGainLoss),
startWealth: startWealthNominal,
quotaTotal: Math.round(quotaTotal),
investmentReturn: Math.round(investmentReturnTotal),
propertyAppreciation: Math.round(propertyAppreciationTotal),
pensionFundContribution: Math.round(pensionFundContributionTotal),
endWealth: endWealthNominal,
residual:
endWealthNominal -
((isFirstPhase ? startWealthNominal : previousEndWealth) +
Math.round(incomingOneOffInflow) -
Math.round(incomingOneOffOutflow) -
Math.round(incomingTax) -
Math.round(incomingPensionConversion) +
Math.round(incomingSaleGainLoss) +
Math.round(quotaTotal) +
Math.round(investmentReturnTotal) +
Math.round(propertyAppreciationTotal) +
Math.round(pensionFundContributionTotal)),
},
cashBridge: {
openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd,
capitalInflow: Math.round(incomingInflow),
oneOffInflow: Math.round(incomingOneOffInflow),
immediateRepay: Math.round(incomingImmediateRepay),
oneOffOutflow: Math.round(incomingOneOffOutflow),
investments: isFirstPhase ? 0 : Math.round(investmentsFromCash),
cashStart: Math.round(cashStart),
quotaTotal: Math.round(quotaTotal),
savingRates: Math.round(savingRatesTotal),
debtRates: Math.round(debtRatesTotal),
withdrawals: Math.round(withdrawalsTotal),
cashEnd,
residual:
cashEnd -
(Math.round(cashStart) +
Math.round(quotaTotal) -
Math.round(savingRatesTotal) -
Math.round(debtRatesTotal) +
Math.round(withdrawalsTotal)),
},
traces: explain ? phaseTraces : undefined,
});
previousEndWealth = endWealthNominal;
previousCashEnd = cashEnd;
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
let txInflow = 0;
let txImmediateRepay = 0;
// Echte Vermoegensaenderungen an dieser Grenze (fuer die Bruecke der Folgephase).
// Verkaeufe, Bezuege und Tilgungen sind fuer sich Umbuchungen -- vermoegenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlaesst die Bilanz) und die Differenz
// zwischen Verkaufspreis und Verkehrswert.
let txTax = 0;
let txPensionConversion = 0;
let txSaleGainLoss = 0;
// Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase
// existiert -- nach der letzten Phase gibt es keinen Uebergang. Der Wechselkurs zwischen
@@ -796,22 +1302,31 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
continue;
}
const txStepsBefore = txInflow;
const taxBefore = txTax;
switch (e.category) {
case "PENSION_FUND": {
if (ownerRetiresNext) {
const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") {
txInflow += Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += value - net;
carry.value = 0;
carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") {
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value;
carry.value = 0;
} else {
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
txInflow += Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += capital - net;
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value - capital;
carry.value = 0;
}
} else {
@@ -819,18 +1334,24 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += withdrawal - net;
}
break;
}
case "PILLAR_3A": {
if (ownerRetiresNext) {
txInflow += Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += ec.endValue - net;
carry.value = 0;
} else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
const net = Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += withdrawal - net;
}
break;
}
@@ -858,6 +1379,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
const gain = Math.max(0, salePrice - re.purchase);
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
txInflow += Math.round(salePrice - re.mortgage - tax);
txTax += Math.round(tax);
// Der Verkaufspreis kann vom fortgeschriebenen Verkehrswert abweichen -- diese
// Differenz ist ein echter Vermoegensgewinn bzw. -verlust an der Grenze.
txSaleGainLoss += Math.round(salePrice - re.value);
carry.status = "SOLD";
} else {
// Sonderamortisation: Einmaltilgung der Hypothek aus dem Cash, am Restsaldo gekappt.
@@ -883,6 +1408,76 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
default:
break;
}
// --- Rechenweg des Uebergangs je Element ---
if (explain && nextPhase) {
const inflowHere = txInflow - txStepsBefore;
const taxHere = txTax - taxBefore;
const steps: TraceStep[] = [st("Wert am Ende der Vorphase", ec.endValue)];
let title = `Übergang «${ec.name}»`;
let anchor: string | undefined;
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE";
title = `Übergang «${ec.name}» ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`;
if (ownerRetiresNext && e.category === "PENSION_FUND") {
steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, ""));
if (mode === "PENSION" || mode === "COMBI") {
steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
}
}
if (taxHere > 0) {
steps.push(st("Kapitalbezugssteuer", num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE), undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
steps.push(st("Steuerbetrag", -taxHere));
}
if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug Kapitalbezugssteuer"));
} else if (e.category === "REAL_ESTATE") {
anchor = "494-real_estate";
const re = realEstates.find((r) => r.ec.elementId === e.id);
if (td.decision === "SELL") {
const salePrice = Math.round(num(td.salePrice));
title = `Verkauf «${ec.name}»`;
steps.push(st("Verkaufspreis", salePrice));
steps.push(st("Ursprünglicher Kaufpreis", re?.purchase ?? 0, undefined, undefined, "Bemessungsgrundlage der Grundstückgewinnsteuer nicht der zwischenzeitliche Verkehrswert."));
steps.push(st("Steuerbarer Gewinn", Math.max(0, salePrice - (re?.purchase ?? 0)), "max(0, Verkaufspreis Kaufpreis)", `max(0, ${fmt(salePrice)} ${fmt(re?.purchase ?? 0)})`, "Ein Verlustverkauf erzeugt keine Steuer."));
steps.push(st("Grundstückgewinnsteuer", num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE), undefined, undefined, "Die Haltedauer-Abstufung ist nicht modelliert.", "%"));
steps.push(st("Steuerbetrag", -taxHere));
steps.push(st("Hypothekenablösung", -(re?.mortgage ?? 0)));
steps.push(st("Netto ins Cash", inflowHere, "Verkaufspreis Hypothek Steuer", `${fmt(salePrice)} ${fmt(re?.mortgage ?? 0)} ${fmt(taxHere)}`));
} else {
const extra = Math.min(re?.mortgage ?? 0, Math.round(num(td.extraAmortization)));
steps.push(st("Entscheid", 0, undefined, "Halten", undefined, ""));
if (extra > 0) {
steps.push(st("Sonderamortisation", -extra, undefined, undefined, "Einmaltilgung aus dem Cash der Folgephase; senkt Restschuld und künftige Zinsen."));
steps.push(st("Resthypothek danach", carry.mortgage));
}
}
} else if (e.category === "OTHER_ASSET") {
anchor = "493-other_asset";
if (td.decision === "SELL") {
steps.push(st("Entscheid", 0, undefined, "Vollverkauf", undefined, ""));
steps.push(st("Erlös ins Cash", inflowHere, undefined, undefined, "Kein Steuerabzug private Kapitalgewinne sind in der Schweiz steuerfrei."));
} else if (td.decision === "PARTIAL") {
steps.push(st("Entscheid", 0, undefined, "Teilverkauf", undefined, ""));
steps.push(st("Betrag ins Cash", inflowHere, "min(Endwert, gewünschter Betrag)"));
steps.push(st("Rest bleibt investiert", carry.value, undefined, undefined, "Der verbleibende Teil wächst in der Folgephase weiter."));
} else {
steps.push(st("Entscheid", 0, undefined, "Halten", undefined, ""));
steps.push(st("Wert in der Folgephase", carry.value));
}
} else if (e.category === "OTHER_DEBT") {
anchor = "495-other_debt";
const immediate = Math.round(num(td.immediateRepayment));
steps.push(st("Restschuld", -ec.endValue));
if (immediate > 0) steps.push(st("Sofortige Tilgung", -Math.min(-ec.endValue, immediate), undefined, undefined, "Wird dem Cash der Folgephase entnommen."));
steps.push(st("Restschuld in der Folgephase", carry.owed, undefined, undefined, carry.owed === 0 ? "Vollständig getilgt." : undefined));
}
if (steps.length > 1) ec.transitionTrace = { title, specAnchor: anchor, steps };
}
carry.hasCarry = true;
}
@@ -893,12 +1488,84 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
incomingOneOffInflowLabel = txOneOffInflowLabel;
incomingOneOffOutflow = txOneOffOutflow;
incomingOneOffOutflowLabel = txOneOffOutflowLabel;
incomingTax = txTax;
incomingPensionConversion = txPensionConversion;
incomingSaleGainLoss = txSaleGainLoss;
// Rechenweg des Cash-Uebergangs (einmalige Sonderein-/ausgaben). Wird nachtraeglich an
// die Trace-Liste dieser Phase gehaengt -- `result` haelt die Referenz auf das Array.
if (explain && nextPhase && (txOneOffInflow !== 0 || txOneOffOutflow !== 0)) {
const ct = phase.cashTransition ?? {};
const steps: TraceStep[] = [];
if (txOneOffInflow !== 0) {
const gross = Math.round(num(ct.inflowAmount));
steps.push(st(`Zufluss «${ct.inflowLabel?.trim() || "ohne Bezeichnung"}» brutto`, gross, undefined, undefined, "Nominal erfasst der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt."));
steps.push(st("Steuersatz", num(ct.inflowTaxRate, 0), undefined, undefined, "Default 0 % Erbschaften an direkte Nachkommen sind in den meisten Kantonen steuerfrei.", "%"));
steps.push(st("Netto ins Cash", txOneOffInflow, "brutto × (1 Steuersatz)", `${fmt(gross)} × (1 ${pct(num(ct.inflowTaxRate, 0))})`));
}
if (txOneOffOutflow !== 0) {
steps.push(st(`Kosten «${ct.outflowLabel?.trim() || "ohne Bezeichnung"}» real`, Math.round(num(ct.outflowAmount)), undefined, undefined, "Real erfasst, in heutiger Kaufkraft."));
steps.push(st("Bestands-Deflator an der Phasengrenze", Math.round(cumulativeInflation * 10000) / 10000, undefined, undefined, undefined, ""));
steps.push(st("Nominal vom Cash abgezogen", -txOneOffOutflow, "real × kumulierte Inflation", `${fmt(num(ct.outflowAmount))} × ${Math.round(cumulativeInflation * 10000) / 10000}`));
}
steps.push(st("Wirkung auf die Quote", 0, undefined, "keine", "Einmalposten sind keine laufenden Flows sie wirken ausschliesslich aufs Cash.", ""));
phaseTraces.push({ title: "Cash-Übergang: einmalige Sonderein-/ausgaben", specAnchor: "496-cash-einmalige-sonderein-ausgaben", steps });
}
yearsBefore += duration;
}
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson));
return { phases: result, yearly, nachlass, ruinAge, ahvCareer };
// --- Plan-weite Rechenwege ---
const planTraces: Trace[] = [];
if (explain) {
planTraces.push({
title: "Inflation und die drei Deflatoren",
specAnchor: "45-nominal-real-und-die-deflatoren",
steps: [
st("Angenommene Inflationsrate", plan.inflationRateDefault, undefined, undefined, "Gilt plan-weit für alle Lebensphasen; eine phasenspezifische Rate gibt es nicht.", "%"),
st("Planlaufzeit", totalYears, undefined, undefined, undefined, "Jahre"),
st("Kumulierte Inflation am Planende", Math.round(cumInfl[totalYears] * 10000) / 10000, "Produkt von (1 + Inflation) über alle Jahre", `(1 + ${pct(plan.inflationRateDefault)})^${totalYears}`, undefined, ""),
st("Kaufkraft eines heutigen Frankens am Planende", Math.round((1 / (cumInfl[totalYears] || 1)) * 10000) / 10000, "1 / kumulierte Inflation", undefined,
"Bestände am Phasenende, Bestände zu Phasenbeginn und Flow-Endwerte nutzen drei UNTERSCHIEDLICHE Deflatoren ein Flow fällt IM Jahr an, ein Bestand wird NACH dem Jahr gemessen.", ""),
],
});
for (const career of ahvCareer) {
const before = ahvBeforeByPerson.get(career.personId) ?? { avg: 0, gap: 0 };
const mdJE = ahvMdje(career, before.avg, before.gap);
planTraces.push({
title: `AHV-Beitragskarriere ${career.role === "PERSON_A" ? "Person A" : "Person B"}`,
specAnchor: "442-beitragskarriere-und-mdje",
steps: [
st("Beitragsjahre im Plan", career.planYears, "Summe (Phasendauer Ausfalljahre)", undefined, undefined, "Jahre"),
st("Durchschnittliches Bruttoeinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
"Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."),
st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn 21 Ausfalljahre davor", undefined, undefined, "Jahre"),
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined,
before.avg === 0 && career.yearsBeforePlan > 0 ? "Noch nicht erfasst am Pensions-Übergang zu prüfen." : "Aus der AHV-Rentenvorausberechnung übernommen."),
st("Massgebendes durchschnittliches Jahreseinkommen", Math.round(mdJE),
"(Einkommen davor × Jahre davor + Einkommen im Plan × Jahre im Plan) / Total Jahre", undefined,
"REAL gerechnet: die echte AHV wertet vergangene Einkommen auf UND indexiert die Schwellen beides hebt sich real weitgehend auf."),
st("Ausfalljahre total", (gapYearsByPerson.get(career.personId) ?? 0) + before.gap, undefined, undefined, undefined, "Jahre"),
],
});
}
planTraces.push({
title: "Ruinalter",
specAnchor: "47-die-jahresschleife",
steps: [
st("Ruinalter", ruinAge ?? 0, undefined, ruinAge === null ? "kein Ruin das Gesamtvermögen bleibt über die ganze Planung positiv" : `Alter ${ruinAge}`,
"Bezieht sich auf das GESAMTvermögen inklusive Immobilien, nicht auf das Cash. Wird nur beim ersten Auftreten gesetzt.", ruinAge === null ? "" : "Jahre"),
st("Endvermögen (Nachlass, nominal)", nachlass, undefined, undefined, "Entspricht dem nominalen Endvermögen der letzten Lebensphase."),
],
});
}
return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
}
// Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit
+149
View File
@@ -54,3 +54,152 @@ export const PILLAR_3A_MAX_ANNUAL = 7258;
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jaehrliche Rente = Kapital x Satz)
export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug)
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstueckgewinnsteuer
// --- Strukturierte Fassung fuer die Systemparameter-Ansicht (Roadmap Nr. 41) -------------
//
// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberflaeche
// Wert, Bedeutung, Herleitung und Quelle anzeigen, OHNE dass die Zahlen ein zweites Mal
// gepflegt werden muessen -- die Eintraege referenzieren die Konstanten direkt und koennen
// deshalb nicht von ihnen abdriften.
export type ParameterGroup = "AHV" | "Vorsorge" | "Steuern";
export interface SystemParameter {
key: string;
label: string;
value: number;
unit: "CHF" | "%" | "Jahre" | "Anzahl";
meaning: string;
derivation?: string; // nur bei abgeleiteten Werten
source: string;
validFrom: string;
group: ParameterGroup;
editablePerEvent?: boolean; // Default-Satz, pro Ereignis ueberschreibbar
}
export const SYSTEM_PARAMETERS: SystemParameter[] = [
{
key: "AHV_MIN_MONTHLY_FULL",
label: "Mindestbetrag der vollen Monatsrente (R0)",
value: AHV_MIN_MONTHLY_FULL,
unit: "CHF",
meaning:
"Ankerwert der ganzen Rentenskala 44. Sämtliche Schwellen der Rentenformel sind Vielfache von R0 ändert sich R0, verschiebt sich die gesamte Skala.",
source: 'BSV/MAS «Berechnungsvorschriften der AHV/IV-Renten»; amtliche Tabelle 318.117.1 «Monatliche Vollrenten, Skala 44»',
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_PENSION_MONTHS",
label: "Rentenzahlungen pro Jahr",
value: AHV_PENSION_MONTHS,
unit: "Anzahl",
meaning:
"13 statt 12, weil seit 1.1.2026 die 13. Altersrente ausgerichtet wird. Die Rentenformel liefert Monatsbeträge; der Jahresbetrag ist deshalb × 13.",
source: "Art. 34bis AHVG (13. Altersrente)",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_MAX_ANNUAL_SINGLE",
label: "Maximale einfache Altersrente pro Jahr",
value: AHV_MAX_ANNUAL_SINGLE,
unit: "CHF",
meaning: "Obergrenze der Einzelrente. Wird ab einem massgebenden Durchschnittseinkommen von 90'720 erreicht.",
derivation: `2 × R0 × ${AHV_PENSION_MONTHS} = 2 × ${AHV_MIN_MONTHLY_FULL} × ${AHV_PENSION_MONTHS} = ${AHV_MAX_ANNUAL_SINGLE}`,
source: "abgeleitet aus R0 und der Anzahl Rentenzahlungen",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_FULL_CONTRIBUTION_YEARS",
label: "Volle Beitragsdauer (Rentenskala)",
value: AHV_FULL_CONTRIBUTION_YEARS,
unit: "Jahre",
meaning: "Für eine ungekürzte Rente nötige Beitragsjahre. Jedes fehlende Jahr kürzt die Rente um 1/44.",
source: "AHVG, Rentenskala 44",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_CONTRIBUTION_START_AGE",
label: "Beginn der Beitragspflicht",
value: AHV_CONTRIBUTION_START_AGE,
unit: "Jahre",
meaning:
"Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag, faktisch ab Alter 21. Bestimmt, wie viele Beitragsjahre vor dem Planbeginn liegen.",
source: "AHVG",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_COUPLE_CAP_FACTOR",
label: "Ehepaar-Plafonierung",
value: AHV_COUPLE_CAP_FACTOR * 100,
unit: "%",
meaning:
"Die Summe beider Einzelrenten ist auf 150 % der Einzel-Maximalrente begrenzt. Bei Überschreitung werden beide Renten proportional gekürzt.",
derivation: `${AHV_MAX_ANNUAL_SINGLE} × ${AHV_COUPLE_CAP_FACTOR} = ${AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR} CHF pro Jahr für das Paar`,
source: "AHVG",
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "AHV_GROSS_FROM_NET_FACTOR",
label: "Umrechnung Netto- auf Bruttolohn (für die AHV)",
value: AHV_GROSS_FROM_NET_FACTOR,
unit: "Anzahl",
meaning:
"Das Tool erfasst das Einkommen netto (so stimmt der Cash-Fluss), die AHV bemisst sich am Bruttolohn. Dieser Faktor rechnet für die Rentenberechnung zurück.",
derivation:
"Arbeitnehmer-Abzüge: AHV/IV/EO 5.30 % + ALV 1.10 % + NBU ~1.00 % + PK ~25 % = ~913 % → 1/(1q) = 1.10 bis 1.16, Mittel 1.12",
source: 'BSV «Beträge gültig ab 1.1.2026»; AHV-Merkblätter 2.01 und 2.08',
validFrom: "1.1.2026",
group: "AHV",
},
{
key: "PILLAR_3A_MAX_ANNUAL",
label: "Maximaler Säule-3a-Beitrag pro Jahr",
value: PILLAR_3A_MAX_ANNUAL,
unit: "CHF",
meaning: "Höchstbetrag für Personen mit Pensionskasse. Im Eingabefeld hart geklammert.",
source: 'BSV «Beträge gültig ab 1.1.2026»',
validFrom: "1.1.2026",
group: "Vorsorge",
},
{
key: "DEFAULT_PK_CONVERSION_RATE",
label: "Umwandlungssatz Pensionskasse (Vorschlag)",
value: DEFAULT_PK_CONVERSION_RATE,
unit: "%",
meaning: "Jährliche Rente = verrentetes Kapital × Umwandlungssatz. Der tatsächliche Satz steht im PK-Reglement.",
source: "Vorschlagswert; überobligatorische Sätze liegen oft tiefer",
validFrom: "—",
group: "Vorsorge",
editablePerEvent: true,
},
{
key: "DEFAULT_CAPITAL_TAX_RATE",
label: "Kapitalbezugssteuer (Vorschlag)",
value: DEFAULT_CAPITAL_TAX_RATE,
unit: "%",
meaning:
"Steuer auf dem Kapitalbezug aus PK und Säule 3a. Kantonal sehr unterschiedlich und progressiv dieser Pauschalsatz ersetzt keine Steuerberechnung.",
source: "Vorschlagswert; kantonal stark abweichend",
validFrom: "—",
group: "Steuern",
editablePerEvent: true,
},
{
key: "DEFAULT_PROPERTY_GAINS_TAX_RATE",
label: "Grundstückgewinnsteuer (Vorschlag)",
value: DEFAULT_PROPERTY_GAINS_TAX_RATE,
unit: "%",
meaning:
"Steuer auf dem Gewinn beim Immobilienverkauf (Verkaufspreis ursprünglicher Kaufpreis). Die Haltedauer-Abstufung ist nicht modelliert.",
source: "Vorschlagswert; kantonal stark abweichend, teils degressiv nach Haltedauer",
validFrom: "—",
group: "Steuern",
editablePerEvent: true,
},
];
+255
View File
@@ -0,0 +1,255 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import type { Trace } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// Reichhaltiger Plan: Erwerbsphase mit Einkommen, Ausgaben, PK, 3a, Immobilie (Hypothek,
// Amortisation, Wertsteigerung) und einer Schuld; danach Pensionsphase mit Verzehr.
// Am Uebergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten.
function richPlan(): PlanInput {
return {
id: "p",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 1.5,
initialCash: 50000,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 55, retirementAge: 65 }],
phases: [
{
id: "p1",
sequenceNumber: 1,
name: "Erwerb",
durationYears: 10,
cashTransition: { mode: "BOTH", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10, outflowLabel: "Pool", outflowAmount: 20000 },
},
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 15, cashTransition: {} },
],
elements: [
{
id: "inc", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1,
phaseValues: { p1: { amount: 120000, teuerungsausgleich: 1.5 } }, transitionValues: {},
},
{
id: "exp", category: "EXPENSE", name: "Leben", ownerRole: "HOUSEHOLD", orderIndex: 2,
phaseValues: { p1: { amount: 80000 }, p2: { amount: 70000 } }, transitionValues: {},
},
{
id: "ahv", category: "AHV", name: "AHV", ownerRole: "PERSON_A", orderIndex: 3,
phaseValues: { p1: { gapYears: 0 } },
transitionValues: { p1: { reviewed: true, avgIncomeBefore: 90000, gapYearsBefore: 0 } },
},
{
id: "pk", category: "PENSION_FUND", name: "PK", ownerRole: "PERSON_A", orderIndex: 4,
phaseValues: { p1: { currentValue: 400000, annualContribution: 20000, expectedReturn: 2 } },
transitionValues: { p1: { payoutMode: "PENSION", conversionRate: 6 } },
},
{
id: "s3a", category: "PILLAR_3A", name: "3a", ownerRole: "PERSON_A", orderIndex: 5,
phaseValues: { p1: { currentValue: 80000, annualContribution: 7000, expectedReturn: 3 } },
transitionValues: { p1: { capitalTaxRate: 8 } },
},
{
id: "re", category: "REAL_ESTATE", name: "Haus", ownerRole: "HOUSEHOLD", orderIndex: 6,
phaseValues: {
p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 15000, interestRate: 1.5, interestHandling: "ADD", valueGrowth: 1 },
p2: { amortization: 0, interestRate: 1.5, interestHandling: "ADD", valueGrowth: 1 },
},
transitionValues: { p1: { decision: "HOLD", extraAmortization: 50000 } },
},
{
id: "asset", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 7,
phaseValues: {
p1: { startValue: 200000, expectedReturn: 5, annualContribution: 10000 },
p2: { expectedReturn: 4, annualWithdrawal: 30000 },
},
transitionValues: {},
},
{
id: "debt", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 8,
phaseValues: { p1: { startValue: 60000, annualRepayment: 8000 } },
transitionValues: { p1: { immediateRepayment: 5000 } },
},
],
};
}
describe("Verlaufswerte je Element", () => {
it("liefert einen Punkt pro Phasenjahr", () => {
const c = computePlan(richPlan());
for (const ph of c.phases) {
for (const ec of ph.elements) {
if (ec.status !== "ACTIVE" || ec.note) continue;
if (ec.yearly.length === 0) continue;
expect(ec.yearly.length).toBe(ph.durationYears);
}
}
});
it("bildet Zinseszins ab, nicht eine Gerade zwischen den Phasengrenzen", () => {
// 200'000 @ 5 % + 10'000 Sparbeitrag: Jahr 1 = 200'000 x 1.05 + 10'000 = 220'000.
const c = computePlan(richPlan());
const etf = c.phases[0].elements.find((e) => e.elementId === "asset")!;
expect(etf.yearly[0].value).toBe(220000);
expect(etf.yearly[1].value).toBe(Math.round(220000 * 1.05 + 10000));
// Konvexitaet: die Jahreszuwaechse werden von Jahr zu Jahr GROESSER. Eine Gerade
// zwischen den Phasengrenzen haette konstante Zuwaechse -- genau der Fehler, den die
// per-Jahr-Werte vermeiden.
const deltas = etf.yearly.slice(1).map((p, i) => p.value - etf.yearly[i].value);
for (let i = 1; i < deltas.length; i++) {
expect(deltas[i]).toBeGreaterThan(deltas[i - 1]);
}
});
it("Immobilie fuehrt Verkehrswert und Restschuld getrennt mit", () => {
const c = computePlan(richPlan());
const re = c.phases[0].elements.find((e) => e.elementId === "re")!;
const y0 = re.yearly[0];
expect(y0.propertyValue).toBe(Math.round(1000000 * 1.01));
expect(y0.mortgage).toBe(700000 - 15000);
expect(y0.value).toBe(y0.propertyValue! - y0.mortgage!); // Eigenkapital
});
});
describe("Wasserfall-Bruecken", () => {
it("Vermoegens-Bruecke geht auf (Restposten ~ 0)", () => {
const c = computePlan(richPlan());
for (const ph of c.phases) {
// Rundungen auf ganze Franken je Summand -> ein paar Franken Toleranz.
expect(Math.abs(ph.wealthBridge.residual)).toBeLessThanOrEqual(5);
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
}
});
it("Cash-Bruecke geht auf (Restposten ~ 0)", () => {
const c = computePlan(richPlan());
for (const ph of c.phases) {
expect(Math.abs(ph.cashBridge.residual)).toBeLessThanOrEqual(5);
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
expect(ph.cashBridge.cashEnd).toBe(ph.cashEnd);
}
});
it("Sparraten erscheinen in der CASH-, nicht in der Vermoegens-Bruecke", () => {
// Reiner Umbuchungs-Plan: 12'000 Sparbeitrag pro Jahr, 0 % Rendite, keine Flows.
const plan: PlanInput = {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 100000,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
elements: [
{
id: "a", category: "OTHER_ASSET", name: "Spar", ownerRole: "HOUSEHOLD", orderIndex: 1,
phaseValues: { p1: { startValue: 0, expectedReturn: 0, annualContribution: 12000 } }, transitionValues: {},
},
],
};
const ph = computePlan(plan).phases[0];
// Cash: 5 x 12'000 fliessen ab.
expect(ph.cashBridge.savingRates).toBe(60000);
expect(ph.cashEnd).toBe(100000 - 60000);
// Vermoegen: unveraendert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote.
expect(ph.wealthBridge.quotaTotal).toBe(0);
expect(ph.wealthBridge.investmentReturn).toBe(0);
expect(ph.startWealthNominal).toBe(100000);
expect(ph.endWealthNominal).toBe(100000);
});
it("PK-Beitraege sind ein Vermoegenszugang, ohne das Cash zu belasten", () => {
const plan: PlanInput = {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 4, cashTransition: {} }],
elements: [
{
id: "pk", category: "PENSION_FUND", name: "PK", ownerRole: "PERSON_A", orderIndex: 1,
phaseValues: { p1: { currentValue: 0, annualContribution: 10000, expectedReturn: 0 } }, transitionValues: {},
},
],
};
const ph = computePlan(plan).phases[0];
expect(ph.wealthBridge.pensionFundContribution).toBe(40000);
expect(ph.cashBridge.savingRates).toBe(0); // belastet das Cash NICHT
expect(ph.endWealthNominal).toBe(40000);
});
it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermoegens-Bruecke der Folgephase", () => {
const c = computePlan(richPlan());
const p2 = c.phases[1];
expect(p2.wealthBridge.pensionConversion).toBeGreaterThan(0); // PK wurde verrentet
expect(p2.wealthBridge.transitionTax).toBeGreaterThan(0); // 8 % auf den 3a-Bezug
expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzueglich 10 % Steuer
expect(p2.wealthBridge.oneOffOutflow).toBeGreaterThan(20000); // real 20'000, nominal aufgewertet
});
});
describe("Rechenweg (explain)", () => {
it("ist standardmaessig aus und aendert das Ergebnis nicht", () => {
const plain = computePlan(richPlan());
const explained = computePlan(richPlan(), undefined, { explain: true });
expect(plain.traces).toBeUndefined();
expect(plain.phases[0].traces).toBeUndefined();
expect(plain.phases[0].elements[0].trace).toBeUndefined();
// Die Protokollierung darf die Zahlen nicht verschieben.
expect(explained.nachlass).toBe(plain.nachlass);
expect(explained.ruinAge).toBe(plain.ruinAge);
for (let i = 0; i < plain.phases.length; i++) {
expect(explained.phases[i].endWealthNominal).toBe(plain.phases[i].endWealthNominal);
expect(explained.phases[i].cashEnd).toBe(plain.phases[i].cashEnd);
}
});
it("liefert Rechenwege auf Element-, Phasen- und Plan-Ebene", () => {
const c = computePlan(richPlan(), undefined, { explain: true });
expect((c.traces ?? []).length).toBeGreaterThan(0);
expect((c.phases[0].traces ?? []).length).toBeGreaterThan(0);
const withTrace = c.phases[0].elements.filter((e) => e.trace);
// Einkommen, Ausgaben, PK, 3a, Immobilie, Vermoegen, Schuld
expect(withTrace.length).toBeGreaterThanOrEqual(7);
const withTxTrace = c.phases[0].elements.filter((e) => e.transitionTrace);
expect(withTxTrace.length).toBeGreaterThan(0);
});
it("setzt in jedem Schritt eingesetzte Zahlen statt nur einer abstrakten Formel", () => {
const c = computePlan(richPlan(), undefined, { explain: true });
const inc = c.phases[0].elements.find((e) => e.elementId === "inc")!;
const endStep = inc.trace!.steps.find((s) => s.label.startsWith("Einkommen im letzten"))!;
expect(endStep.formula).toContain("Lohnerhöhung");
expect(endStep.substituted).toContain("120'000");
expect(endStep.result).toBe(inc.endValue);
});
// Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Ueberschrift umbenannt,
// zeigen die Verweise ins Leere -- und zwar still. Dieser Test faengt das ab.
it("alle Spezifikations-Verweise treffen eine existierende Ueberschrift", () => {
const md = readFileSync(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8");
// Slug-Regel von github-slugger (das rehype-slug verwendet) nachgebildet.
const slug = (s: string) =>
s
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}\p{M}\p{Pc}\- ]/gu, "")
.replace(/ /g, "-");
const headings = new Set<string>();
for (const line of md.split("\n")) {
const m = line.match(/^#{1,6}\s+(.*)$/);
if (m) headings.add(slug(m[1].replace(/[*`]/g, "")));
}
const c = computePlan(richPlan(), undefined, { explain: true });
const traces: Trace[] = [
...(c.traces ?? []),
...c.phases.flatMap((p) => p.traces ?? []),
...c.phases.flatMap((p) => p.elements.flatMap((e) => [e.trace, e.transitionTrace])).filter((t): t is Trace => !!t),
];
const anchors = [...new Set(traces.map((t) => t.specAnchor).filter((a): a is string => !!a))];
expect(anchors.length).toBeGreaterThan(5);
const missing = anchors.filter((a) => !headings.has(a));
expect(missing).toEqual([]);
});
});