V4-Rechenmodell: Flow-Indexierung, Cash-Ausgleichstopf, Ruin-Erkennung
Deploy App / deploy (push) Successful in 3m6s

Behebt die Nominal/Real-Inkonsistenz (Bug #1): Einkommen/Ausgaben werden neu Jahr fuer
Jahr indexiert (eigenes Feld teuerungsausgleich je Element, Default = Phaseninflation;
Renten nominal fix = 0%). Vermoegen verzinst weiterhin nominal.

Cash: neues systemseitiges, immer sichtbares Element (0% Verzinsung, kein DB-Row -
synthetisch in computePlan). Ist der Ausgleichstopf = "verfuegbares Kapital fuer
Investments": cash_delta(t) = quote(t) - geplante flache Jahresraten (3a/Vermoegen/Amort./
Tilgung); Cash laeuft ueber Phasen fort, darf negativ werden (rot). Der Verteilzwang und
die harten Sparraten-Caps entfallen.

Ruin: Gesamtvermoegen (inkl. Cash) je Jahr; erstes Unterschreiten von 0 -> Ruin-Alter
(Person A), Anzeige als Banner + Zeitachsen-Marker.

Phasenkopf neu: Einkommen/Ausgaben Start->Ende, Quote Beginn/Ende, Cash Start->Ende,
Vermoegen inkl. Cash, Realwert. Inflation-Deflator neu pro Jahr (Math.pow ^Dauer).

Golden Tests (vitest) 1-4 + Renten-0% + Fortschreibung gruen (Test1 761'654/565'928,
Test2 Ruin Alter 94).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 09:24:13 +02:00
parent 9506f43b67
commit 5e17ce4afb
10 changed files with 1349 additions and 299 deletions
+48 -25
View File
@@ -22,10 +22,9 @@ export interface CellContext {
durationYears: number;
isRetirementTransition: boolean;
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
carried: boolean; // Phase >= 2: Startwert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basis-Startwert (read-only Anzeige)
quotaRateMax: number; // Max fuer eine Sparrate/Verzehrrate dieses Elements
capitalMax?: number; // Max fuer Startkapital/Neuinvestition (undefined = kein Cap)
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
phaseInflation: number; // Default fuer Teuerungsausgleich
}
interface Props {
@@ -135,9 +134,38 @@ export function ElementPhaseFields({
const carried = context.carried;
switch (element.category) {
case "INCOME":
return <MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />;
case "EXPENSE":
return <MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />;
case "EXPENSE": {
const isIncome = element.category === "INCOME";
const teuerung = (
<NumberField
label="Teuerungsausgleich (%/Jahr)"
help="Indexiert den Betrag ueber die Phasenjahre (Jahr t = Basis x (1+Satz)^(t-1)). Default = Phaseninflation. 0% = nominal flach (real sinkend)."
step={0.1}
value={num(pd.teuerungsausgleich, context.phaseInflation)}
onChange={(v) => setP({ teuerungsausgleich: v })}
/>
);
if (carried) {
return (
<>
<DerivedField label="Basiswert (fortgeschrieben)" value={context.derivedStart} help="Indexierter Wert aus der Vorphase (Jahr 1 dieser Phase)." />
<MoneyField
label="Uebersteuern (optional, CHF)"
help="Setzt den Basiswert dieser Phase neu (z. B. Einkommensknick bei Teilzeit/Fruehpension). Leer = fortgeschrieben."
value={num(pd.amountOverride)}
onChange={(v) => setP({ amountOverride: v })}
/>
{teuerung}
</>
);
}
return (
<>
<MoneyField label={isIncome ? "Jahreseinkommen (CHF, erstes Jahr)" : "Jahresausgaben (CHF, erstes Jahr)"} value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
{teuerung}
</>
);
}
case "AHV":
if (!context.ownerWorking) {
return (
@@ -175,12 +203,12 @@ export function ElementPhaseFields({
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} />
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
@@ -204,18 +232,18 @@ export function ElementPhaseFields({
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} />
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und hoechstens die Sparquote. Wird von der Sparquote abgezogen.`}
value={num(pd.annualContribution)}
max={Math.min(PILLAR_3A_MAX_ANNUAL, context.quotaRateMax)}
max={PILLAR_3A_MAX_ANNUAL}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
@@ -230,7 +258,7 @@ export function ElementPhaseFields({
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
value={num(pd.amortization)}
max={context.quotaRateMax}
onChange={(v) => setP({ amortization: v })}
/>
</>
@@ -238,13 +266,13 @@ export function ElementPhaseFields({
}
return (
<>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} max={context.capitalMax != null ? context.capitalMax + num(pd.mortgage) : undefined} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
value={num(pd.amortization)}
max={context.quotaRateMax}
onChange={(v) => setP({ amortization: v })}
/>
</>
@@ -259,23 +287,18 @@ export function ElementPhaseFields({
label="Zusatzinvestition aus Kapital (CHF)"
help="Neuinvestition aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} max={context.capitalMax} onChange={(v) => setP({ startValue: v })} />
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"}
help={
context.isConsumption
? "In dieser Verzehrphase deckt dieser Betrag die Verzehrquote (max. die Verzehrquote)."
: "Wird von der Sparquote abgezogen (max. die Sparquote)."
}
label="Jaehrlicher Sparbeitrag (CHF)"
help="Flacher Jahresbetrag, der ins Vermoegen fliesst und vom Cash abgezogen wird. Zum Entnehmen im Alter das Element im Uebergang verkaufen."
value={num(pd.annualContribution)}
max={context.quotaRateMax}
onChange={(v) => setP({ annualContribution: v })}
/>
</>
@@ -292,7 +315,7 @@ export function ElementPhaseFields({
label="Jaehrliche Tilgung (CHF)"
help="Zaehlt gegen die Sparquote (max. die Sparquote)."
value={num(pd.annualRepayment)}
max={context.quotaRateMax}
onChange={(v) => setP({ annualRepayment: v })}
/>
</>
+57 -33
View File
@@ -141,20 +141,17 @@ export function PlanView({
return !!before?.working && !!after && !after.working;
}
// Baut den Kontext (inkl. Live-Caps) fuer eine Phasenzelle.
function phaseInflationFor(phaseId: string): number {
return plan.phases.find((p) => p.id === phaseId)?.inflationRate ?? plan.inflationRateDefault;
}
// Baut den Kontext fuer eine Phasenzelle.
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
const ownerWorking =
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
: phase.type !== "PENSION";
const otherQuota = phase.quotaAllocated - (ce?.quotaUse ?? 0);
const quotaRateMax = Math.max(0, Math.abs(phase.quota) - otherQuota);
const capitalMax =
phase.availableCapital == null
? undefined
: Math.max(0, phase.availableCapital - (phase.availableCapitalUsed - (ce?.capitalUse ?? 0)));
const derivedStart = (ce?.startValue ?? 0) - (ce?.capitalUse ?? 0);
return {
kind: "phase",
phaseId: phase.id,
@@ -164,9 +161,8 @@ export function PlanView({
isRetirementTransition: false,
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart,
quotaRateMax,
capitalMax,
derivedStart: ce?.baseValue ?? 0,
phaseInflation: phaseInflationFor(phase.id),
};
}
@@ -182,8 +178,7 @@ export function PlanView({
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart: 0,
quotaRateMax: 0,
capitalMax: undefined,
phaseInflation: phaseInflationFor(fromPhase.id),
};
}
@@ -242,7 +237,7 @@ export function PlanView({
return (
<div className="flex flex-col gap-5">
<Timeline phases={computed.phases} persons={personAxes} />
<Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
{/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
@@ -294,6 +289,12 @@ export function PlanView({
</div>
)}
{computed.ruinAge !== null && (
<div className="flex items-center gap-2 rounded-xl border border-danger bg-danger-soft px-4 py-2 text-sm font-medium text-danger">
<AlertCircle className="h-4 w-4 shrink-0" /> Kapital aufgebraucht mit Alter {computed.ruinAge} das Gesamtvermoegen (inkl. Cash) faellt danach unter 0.
</div>
)}
{/* Matrix */}
{hasPhases && (
<div className="overflow-x-auto rounded-xl border border-border bg-surface shadow-sm">
@@ -323,6 +324,33 @@ export function PlanView({
</tr>
</thead>
<tbody>
{/* Cash-Zeile (systemseitig, immer sichtbar, read-only). */}
<tr className="bg-surface-2/50">
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<span className="flex items-center gap-1.5 text-xs font-semibold text-fg">
<Wallet className="h-4 w-4 text-accent" /> Cash
</span>
<span className="text-[10px] text-faint">verfuegbares Kapital</span>
</td>
{columns.map((col) =>
col.kind === "phase" ? (
<td
key={`cash-${col.phase.id}`}
className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${
col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"
}`}
>
<span className="whitespace-nowrap">
{formatChf(col.phase.cashStart)} <span className="text-faint"></span> {formatChf(col.phase.cashEnd)}
</span>
</td>
) : (
<td key={`cash-t-${col.fromPhase.id}`} className="border-b border-r border-border px-2 py-1.5 text-center text-[11px] text-faint">
</td>
)
)}
</tr>
{CATEGORY_ORDER.map((cat) => {
const els = elementsByCategory.get(cat)!;
if (els.length === 0) return null;
@@ -584,10 +612,12 @@ export function PlanView({
}
// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl.
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"];
function phaseCellContent(ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined): React.ReactNode {
if (!ce) return "";
if (ce.note) return ce.note;
if (VALUE_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return (
<span className="whitespace-nowrap">
{formatChf(ce.startValue)} <span className="text-faint"></span> {formatChf(ce.endValue)}
@@ -608,20 +638,17 @@ function PhaseHeader({
onClick: () => void;
active: boolean;
}) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
const quotaTarget = Math.abs(phase.quota);
const quotaPct = quotaTarget > 0 ? Math.round((phase.quotaAllocated / quotaTarget) * 100) : 100;
const distributedWord = phase.isConsumption ? "gedeckt" : "verteilt";
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
return (
<th
onClick={onClick}
className={`min-w-40 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-accent-soft" : "bg-surface"
}`}
>
<div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
{phase.incomplete ? (
{phase.cashNegative ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
@@ -639,20 +666,18 @@ function PhaseHeader({
{personLabel(p.role)} {p.startAge} {p.endAge}
</div>
))}
<div>Einkommen {formatChf(phase.incomeTotal)}</div>
<div>Ausgaben {formatChf(phase.expenseTotal)}</div>
<div className={phase.quotaComplete ? "text-success" : "text-danger"}>
{quotaLabel} {formatChf(quotaTarget)} ({quotaPct}% {distributedWord})
<div>Einkommen {formatChf(phase.incomeStart)} {formatChf(phase.incomeEnd)}</div>
<div>Ausgaben {formatChf(phase.expenseStart)} {formatChf(phase.expenseEnd)}</div>
<div>
{quotaLabel} {formatChf(phase.quotaStart)} {formatChf(phase.quotaEnd)}
</div>
<div className={phase.cashNegative ? "text-danger font-medium" : ""}>
Cash {formatChf(phase.cashStart)} {formatChf(phase.cashEnd)}
</div>
<div>
Vermoegen {formatChf(phase.startWealthNominal)} {formatChf(phase.endWealthNominal)}
</div>
<div className={phase.availableCapitalComplete ? "" : "text-danger"}>
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
{phase.availableCapital !== null && !phase.availableCapitalComplete && (
<span> · offen {formatChf(Math.max(0, phase.availableCapitalRemaining))}</span>
)}
</div>
<div className="text-faint">real {formatChf(phase.endWealthReal)}</div>
</div>
</th>
);
@@ -745,8 +770,7 @@ function AddElementDialog({
carriedEndValue: 0,
carried: false,
derivedStart: 0,
quotaRateMax: Math.max(0, Math.abs(firstPhase.quota) - firstPhase.quotaAllocated),
capitalMax: firstPhase.availableCapital == null ? undefined : Math.max(0, firstPhase.availableCapital - firstPhase.availableCapitalUsed),
phaseInflation: plan.phases.find((p) => p.id === firstPhase.id)?.inflationRate ?? plan.inflationRateDefault,
};
async function create() {
+23 -1
View File
@@ -13,7 +13,15 @@ interface PersonAxis {
// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem
// Pensionsalter je Person und Trennlinien an den Phasengrenzen.
export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons: PersonAxis[] }) {
export function Timeline({
phases,
persons,
ruinAge,
}: {
phases: PhaseComputed[];
persons: PersonAxis[];
ruinAge?: number | null;
}) {
if (phases.length === 0 || persons.length === 0) return null;
const totalYears = phases.reduce((s, p) => s + p.durationYears, 0);
@@ -64,6 +72,20 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons
) : null
)}
{/* Ruin-Marker (Person A) */}
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
<div
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: pct(ruinAge) }}
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
>
<span className="text-[10px] font-semibold" style={{ color: "var(--danger)" }}>
Ruin {ruinAge}
</span>
<div className="mt-0.5 h-4 w-px" style={{ backgroundColor: "var(--danger)" }} />
</div>
)}
{/* Achse */}
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent">
{boundaries.slice(1).map((b) => (