V4-Rechenmodell: Flow-Indexierung, Cash-Ausgleichstopf, Ruin-Erkennung
Deploy App / deploy (push) Successful in 3m6s
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:
@@ -86,7 +86,8 @@ function buildCarryData(category: string, prev: PhaseData): PhaseData {
|
||||
switch (category) {
|
||||
case "INCOME":
|
||||
case "EXPENSE":
|
||||
return { amount: num(prev.amount) };
|
||||
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird uebernommen.
|
||||
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
|
||||
case "AHV":
|
||||
return { gapYears: 0 };
|
||||
case "PENSION_FUND":
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// --- kleine Bau-Helfer ---
|
||||
let idc = 0;
|
||||
const nid = () => `id${idc++}`;
|
||||
|
||||
function el(
|
||||
category: ElementCategory,
|
||||
ownerRole: string | null,
|
||||
phaseValues: Record<string, PhaseData>,
|
||||
transitionValues: Record<string, TransitionData> = {}
|
||||
) {
|
||||
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
|
||||
}
|
||||
|
||||
function plan(opts: {
|
||||
age: number;
|
||||
retirementAge: number;
|
||||
inflation?: number;
|
||||
phases: { id: string; durationYears: number }[];
|
||||
elements: ReturnType<typeof el>[];
|
||||
household?: "SINGLE" | "COUPLE";
|
||||
}): PlanInput {
|
||||
return {
|
||||
id: "plan",
|
||||
name: "T",
|
||||
householdType: opts.household ?? "SINGLE",
|
||||
inflationRateDefault: opts.inflation ?? 2,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
||||
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears, inflationRate: null })),
|
||||
elements: opts.elements,
|
||||
};
|
||||
}
|
||||
|
||||
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
||||
|
||||
describe("V4 Golden Tests", () => {
|
||||
it("Test 1 – Ansparphase, Flows wachsen (Cash 0%)", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
phases: [{ id: "p1", durationYears: 15 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000, teuerungsausgleich: 2 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 2 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 0 } }),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
const ph = r.phases[0];
|
||||
expect(within(ph.endWealthNominal, 761654, 0.01)).toBe(true);
|
||||
expect(within(ph.endWealthReal, 565928, 0.01)).toBe(true);
|
||||
expect(ph.cashNegative).toBe(false);
|
||||
});
|
||||
|
||||
it("Test 2 – Verzehr/Ruin: Gesamtvermoegen kippt, Ruin Alter 94", () => {
|
||||
const p = plan({
|
||||
age: 65,
|
||||
retirementAge: 65,
|
||||
phases: [{ id: "p1", durationYears: 35 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 60000, teuerungsausgleich: 0 } }), // Rente nominal fix
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 2 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 900000, expectedReturn: 3, annualContribution: 0 } }),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
expect(r.ruinAge).toBe(94);
|
||||
});
|
||||
|
||||
it("Test 3 – Cash-Ausgleich, Rate 6'364 (unter Anfangsquote), Cash nie negativ", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
phases: [{ id: "p1", durationYears: 3 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 2 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 6364 } }),
|
||||
],
|
||||
});
|
||||
const ph = computePlan(p).phases[0];
|
||||
expect(ph.cashEnd).toBe(5472);
|
||||
expect(ph.cashNegative).toBe(false);
|
||||
});
|
||||
|
||||
it("Test 4 – Cash-Ausgleich, Rate 10'000 (ueber Endquote), Cash wird negativ", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
phases: [{ id: "p1", durationYears: 3 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 2 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 10000 } }),
|
||||
],
|
||||
});
|
||||
const ph = computePlan(p).phases[0];
|
||||
expect(ph.cashEnd).toBe(-5436);
|
||||
expect(ph.cashNegative).toBe(true);
|
||||
});
|
||||
|
||||
it("indexRate 0 -> Einkommen bleibt nominal flach", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
phases: [{ id: "p1", durationYears: 10 }],
|
||||
elements: [el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 } })],
|
||||
});
|
||||
const ph = computePlan(p).phases[0];
|
||||
expect(ph.incomeStart).toBe(80000);
|
||||
expect(ph.incomeEnd).toBe(80000);
|
||||
});
|
||||
|
||||
it("Phasen-Fortschreibung: indexierter Endwert Phase 1 = Startwert Phase 2; Cash laeuft fort", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 2 }, p2: {} }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 2 }, p2: {} }),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
const expectedBasis = Math.round(100000 * Math.pow(1.02, 5));
|
||||
const incomeP2 = r.phases[1].elements.find((e) => e.category === "INCOME")!;
|
||||
expect(incomeP2.startValue).toBe(expectedBasis);
|
||||
expect(r.phases[1].cashStart).toBe(r.phases[0].cashEnd);
|
||||
});
|
||||
});
|
||||
+231
-237
@@ -19,7 +19,6 @@ export interface PersonPhaseInfo {
|
||||
startAge: number;
|
||||
endAge: number;
|
||||
working: boolean;
|
||||
// Wird diese Person genau zu Beginn dieser Phase pensioniert (erste Pensionsphase)?
|
||||
retiresAtStart: boolean;
|
||||
}
|
||||
|
||||
@@ -29,16 +28,13 @@ export interface ElementPhaseComputed {
|
||||
name: string;
|
||||
ownerRole: string | null;
|
||||
status: ElementStatus;
|
||||
locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar
|
||||
carried: boolean; // Startwert wird aus der Vorphase fortgeschrieben (Phase >= 2)
|
||||
startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -)
|
||||
endValue: number; // Netto-Wert am Phasenende
|
||||
incomeContribution: number; // Beitrag zum Phasen-Einkommen
|
||||
expenseContribution: number; // Beitrag zu den Phasen-Ausgaben
|
||||
quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Vermoegen/Amort./Tilgung)
|
||||
capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition)
|
||||
summary: string; // Kennzahl fuer die eingeklappte Zelle
|
||||
note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen"
|
||||
locked: boolean;
|
||||
carried: boolean; // Phase >= 2: Start-/Basiswert wird aus der Vorphase fortgeschrieben
|
||||
baseValue: number; // fortgeschriebener Basiswert (read-only Anzeige ab Phase 2; ohne Zusatzeinlage)
|
||||
startValue: number; // Wert/Flow zu Phasenbeginn (Aktiven +, Schulden -, Einkommen/Ausgaben = Flow Jahr 1)
|
||||
endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
|
||||
summary: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface PhaseComputed {
|
||||
@@ -48,22 +44,24 @@ export interface PhaseComputed {
|
||||
durationYears: number;
|
||||
type: PhaseType;
|
||||
persons: PersonPhaseInfo[];
|
||||
maxDurationYears: number | null; // Kappung ans naechste Pensionsereignis (null = unbegrenzt)
|
||||
incomeTotal: number;
|
||||
expenseTotal: number;
|
||||
quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0)
|
||||
maxDurationYears: number | null;
|
||||
// Einkommen/Ausgaben als indexierte Flows: Wert im ersten und im letzten Phasenjahr.
|
||||
incomeStart: number;
|
||||
incomeEnd: number;
|
||||
expenseStart: number;
|
||||
expenseEnd: number;
|
||||
// Spar-/Verzehrquote zu Phasenbeginn (Jahr 1) und Phasenende (letztes Jahr).
|
||||
quotaStart: number;
|
||||
quotaEnd: number;
|
||||
isConsumption: boolean;
|
||||
quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege
|
||||
quotaRemaining: number; // |quota| - quotaAllocated (offener Rest, kann negativ = ueberzogen)
|
||||
quotaComplete: boolean;
|
||||
availableCapital: number | null; // null in der ersten Phase
|
||||
availableCapitalUsed: number;
|
||||
availableCapitalRemaining: number; // 0 in der ersten Phase
|
||||
availableCapitalComplete: boolean;
|
||||
incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt)
|
||||
plannedRatesTotal: number; // Summe der geplanten flachen Jahresraten (3a, Vermoegen, Amort., Tilgung)
|
||||
cashStart: number;
|
||||
cashEnd: number;
|
||||
cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke
|
||||
incomplete: boolean; // roter Status = Liquiditaetsluecke
|
||||
elements: ElementPhaseComputed[];
|
||||
startWealthNominal: number;
|
||||
endWealthNominal: number;
|
||||
startWealthNominal: number; // inkl. Cash
|
||||
endWealthNominal: number; // inkl. Cash
|
||||
cumulativeInflationEnd: number;
|
||||
endWealthReal: number;
|
||||
}
|
||||
@@ -71,10 +69,10 @@ export interface PhaseComputed {
|
||||
export interface PlanComputed {
|
||||
phases: PhaseComputed[];
|
||||
nachlass: number;
|
||||
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
|
||||
}
|
||||
|
||||
// Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet:
|
||||
// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt).
|
||||
// Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt).
|
||||
export function maxPhaseDuration(
|
||||
persons: { role: PersonRole; age: number; retirementAge: number }[],
|
||||
yearsBefore: number
|
||||
@@ -87,81 +85,78 @@ export function maxPhaseDuration(
|
||||
return caps.length > 0 ? Math.min(...caps) : null;
|
||||
}
|
||||
|
||||
// Interner Zustand, der pro Element von Phase zu Phase weitergetragen wird.
|
||||
interface Carry {
|
||||
status: ElementStatus;
|
||||
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase
|
||||
mortgage: number; // Immobilie: Resthypothek
|
||||
owed: number; // Schulden: Restschuld (positiv)
|
||||
pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung
|
||||
hasCarry: boolean; // gab es eine Vorphase mit diesem Element?
|
||||
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der naechsten Phase
|
||||
hasCarry: boolean;
|
||||
}
|
||||
|
||||
function emptyCarry(): Carry {
|
||||
return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false };
|
||||
return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, flowBasis: 0, hasCarry: false };
|
||||
}
|
||||
|
||||
// Zinseszins mit jaehrlichem Beitrag; kein Zwischen-Runden mehr (1'000er-Konzept entfernt),
|
||||
// nur das Endresultat wird auf ganze Franken gerundet.
|
||||
function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number {
|
||||
let v = startValue;
|
||||
for (let y = 0; y < years; y++) {
|
||||
v = v * (1 + expectedReturn / 100) + annual;
|
||||
}
|
||||
return Math.max(0, Math.round(v));
|
||||
function fmt(v: number): string {
|
||||
const rounded = Math.round(v || 0);
|
||||
const sign = rounded < 0 ? "-" : "";
|
||||
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||
}
|
||||
|
||||
function personByRole(persons: { id: string; role: PersonRole }[], role: string) {
|
||||
return persons.find((p) => p.role === role) ?? null;
|
||||
}
|
||||
|
||||
export function computePlan(plan: PlanInput): PlanComputed {
|
||||
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];
|
||||
|
||||
// Pensionsalter je Person (liegt direkt am plan-eigenen Personensatz).
|
||||
const retirementAge = new Map<string, number>();
|
||||
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
|
||||
|
||||
// Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert).
|
||||
const gapYearsByPerson = new Map<string, number>();
|
||||
// Carry-Zustand je Element.
|
||||
const carries = new Map<string, Carry>();
|
||||
for (const e of plan.elements) carries.set(e.id, emptyCarry());
|
||||
|
||||
const result: PhaseComputed[] = [];
|
||||
let yearsBefore = 0;
|
||||
let cumulativeInflation = 1;
|
||||
let incomingCapital: number | null = null; // in die aktuelle Phase einfliessendes Startkapital
|
||||
let cashCarryIn = 0;
|
||||
let ruinAge: number | null = null;
|
||||
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
const phase = phases[i];
|
||||
const nextPhase = phases[i + 1];
|
||||
const isFirstPhase = i === 0;
|
||||
const duration = Math.max(1, phase.durationYears);
|
||||
const phaseInflation = phase.inflationRate ?? plan.inflationRateDefault;
|
||||
|
||||
// --- Personen-Status in dieser Phase ---
|
||||
const personInfos: PersonPhaseInfo[] = persons.map((p) => {
|
||||
const ra = retirementAge.get(p.id)!;
|
||||
const startAge = p.age + yearsBefore;
|
||||
const working = startAge < ra;
|
||||
return {
|
||||
personId: p.id,
|
||||
role: p.role,
|
||||
startAge,
|
||||
endAge: startAge + phase.durationYears,
|
||||
working,
|
||||
endAge: startAge + duration,
|
||||
working: startAge < ra,
|
||||
retiresAtStart: startAge === ra,
|
||||
};
|
||||
});
|
||||
const anyWorking = personInfos.some((p) => p.working);
|
||||
const anyRetired = personInfos.some((p) => !p.working);
|
||||
const type: PhaseType = anyWorking && anyRetired ? "MIXED" : anyWorking ? "ERWERB" : "PENSION";
|
||||
|
||||
// Maximale Dauer: bis zum naechsten Pensionsereignis einer noch erwerbenden Person.
|
||||
const capsFromWorking = personInfos
|
||||
.filter((p) => p.working)
|
||||
.map((p) => retirementAge.get(p.personId)! - p.startAge)
|
||||
.filter((d) => d > 0);
|
||||
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
|
||||
|
||||
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
|
||||
|
||||
// --- Ausfalljahre der Erwerbsphasen aufsummieren ---
|
||||
// Ausfalljahre kumulieren + AHV-Renten (mit Plafonierung).
|
||||
for (const e of plan.elements) {
|
||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||
const owner = personByRole(persons, e.ownerRole);
|
||||
@@ -169,13 +164,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const gy = Math.max(0, Math.round(num(e.phaseValues[phase.id]?.gapYears)));
|
||||
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
|
||||
}
|
||||
|
||||
// --- AHV-Renten je pensionierter Person (mit Plafonierung) ---
|
||||
const ahvUncapped = new Map<string, number>();
|
||||
for (const e of plan.elements) {
|
||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||
const owner = personByRole(persons, e.ownerRole);
|
||||
if (!owner || workingByPerson.get(owner.id)) continue; // nur pensionierte Personen
|
||||
if (!owner || workingByPerson.get(owner.id)) continue;
|
||||
const gap = gapYearsByPerson.get(owner.id) ?? 0;
|
||||
const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS);
|
||||
ahvUncapped.set(owner.id, Math.round(AHV_MAX_ANNUAL_SINGLE * factor));
|
||||
@@ -184,47 +177,22 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) {
|
||||
const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0);
|
||||
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
|
||||
if (sum > cap && sum > 0) {
|
||||
for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
|
||||
}
|
||||
if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
|
||||
}
|
||||
|
||||
// --- Elemente dieser Phase berechnen ---
|
||||
// --- Element-Laufzeitzustaende aufbauen ---
|
||||
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
|
||||
// --- Vorlauf: Quoten-Vorzeichen bestimmen (Einkommen inkl. Renten minus Ausgaben) ---
|
||||
// Wird VOR der Element-Schleife gebraucht, damit sonstiges Vermoegen in Verzehrphasen
|
||||
// die Rate abzieht (Bezug) statt sie zu addieren (Sparen).
|
||||
let preIncome = 0;
|
||||
let preExpense = 0;
|
||||
for (const e of orderedElements) {
|
||||
const carry = carries.get(e.id)!;
|
||||
if (carry.status !== "ACTIVE") continue;
|
||||
const pd = e.phaseValues[phase.id] ?? {};
|
||||
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
||||
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
|
||||
switch (e.category) {
|
||||
case "INCOME":
|
||||
preIncome += Math.round(num(pd.amount));
|
||||
break;
|
||||
case "EXPENSE":
|
||||
preExpense += Math.round(num(pd.amount));
|
||||
break;
|
||||
case "AHV":
|
||||
if (owner && !ownerWorking) preIncome += ahvFinal.get(owner.id) ?? 0;
|
||||
break;
|
||||
case "PENSION_FUND":
|
||||
if (!ownerWorking && carry.pkPensionAnnual > 0) preIncome += carry.pkPensionAnnual;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isConsumptionPhase = preIncome - preExpense < 0;
|
||||
|
||||
const elementsComputed: ElementPhaseComputed[] = [];
|
||||
let incomeTotal = 0;
|
||||
let expenseTotal = 0;
|
||||
let quotaAllocated = 0;
|
||||
let capitalUsed = 0;
|
||||
const ecById = new Map<string, ElementPhaseComputed>();
|
||||
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; ec: ElementPhaseComputed }[] = [];
|
||||
const realEstates: { purchase: number; mortgageStart: number; amort: number; ec: ElementPhaseComputed }[] = [];
|
||||
const debts: { owedStart: number; repay: number; ec: ElementPhaseComputed }[] = [];
|
||||
let plannedRatesTotal = 0; // R: 3a + Sonstiges Vermoegen + Amortisation + Tilgung
|
||||
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
|
||||
let wealthStart = 0;
|
||||
let wealthEnd = 0; // wird nach der Jahresschleife gefuellt
|
||||
|
||||
for (const e of orderedElements) {
|
||||
const carry = carries.get(e.id)!;
|
||||
@@ -240,50 +208,44 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
status: carry.status,
|
||||
locked: carry.status !== "ACTIVE",
|
||||
carried: carry.hasCarry,
|
||||
baseValue: 0,
|
||||
startValue: 0,
|
||||
endValue: 0,
|
||||
incomeContribution: 0,
|
||||
expenseContribution: 0,
|
||||
quotaUse: 0,
|
||||
capitalUse: 0,
|
||||
summary: "",
|
||||
note: null,
|
||||
};
|
||||
ecById.set(e.id, ec);
|
||||
|
||||
if (carry.status === "SOLD") {
|
||||
ec.note = "Verkauft";
|
||||
ec.summary = "Verkauft";
|
||||
elementsComputed.push(ec);
|
||||
continue;
|
||||
}
|
||||
if (carry.status === "SETTLED" && e.category === "OTHER_DEBT") {
|
||||
ec.note = "Getilgt";
|
||||
ec.summary = "Getilgt";
|
||||
elementsComputed.push(ec);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (e.category) {
|
||||
case "INCOME": {
|
||||
const amount = Math.round(num(pd.amount));
|
||||
ec.incomeContribution = amount;
|
||||
incomeTotal += amount;
|
||||
ec.summary = fmt(amount);
|
||||
break;
|
||||
}
|
||||
case "INCOME":
|
||||
case "EXPENSE": {
|
||||
const amount = Math.round(num(pd.amount));
|
||||
ec.expenseContribution = amount;
|
||||
expenseTotal += amount;
|
||||
ec.summary = fmt(amount);
|
||||
const idx = num(pd.teuerungsausgleich, phaseInflation);
|
||||
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
|
||||
let basis: number;
|
||||
if (!carry.hasCarry) basis = Math.round(num(pd.amount));
|
||||
else if (typeof pd.amountOverride === "number") basis = Math.round(pd.amountOverride);
|
||||
else basis = ec.baseValue;
|
||||
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
|
||||
break;
|
||||
}
|
||||
case "AHV": {
|
||||
if (owner && !ownerWorking) {
|
||||
const pension = ahvFinal.get(owner.id) ?? 0;
|
||||
ec.incomeContribution = pension;
|
||||
incomeTotal += pension;
|
||||
ec.summary = `Rente ${fmt(pension)}`;
|
||||
const rente = ahvFinal.get(owner.id) ?? 0;
|
||||
renteTotal += rente;
|
||||
ec.startValue = rente;
|
||||
ec.endValue = rente;
|
||||
ec.summary = `Rente ${fmt(rente)}`;
|
||||
} else {
|
||||
const gap = Math.max(0, Math.round(num(pd.gapYears)));
|
||||
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
|
||||
@@ -292,11 +254,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
}
|
||||
case "PENSION_FUND": {
|
||||
if (!ownerWorking && carry.pkPensionAnnual > 0) {
|
||||
// Verrentetes PK-Kapital: jaehrliche Rente als Einkommen.
|
||||
const pension = carry.pkPensionAnnual;
|
||||
ec.incomeContribution = pension;
|
||||
incomeTotal += pension;
|
||||
ec.summary = `Rente ${fmt(pension)}`;
|
||||
renteTotal += carry.pkPensionAnnual;
|
||||
ec.startValue = carry.pkPensionAnnual;
|
||||
ec.endValue = carry.pkPensionAnnual;
|
||||
ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`;
|
||||
} else if (!ownerWorking) {
|
||||
ec.note = "Vollstaendig bezogen";
|
||||
ec.summary = "Bezogen";
|
||||
@@ -304,14 +265,12 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||
const start = base + topUp;
|
||||
const contribution = Math.round(num(pd.annualContribution));
|
||||
const r = num(pd.expectedReturn);
|
||||
const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zaehlt NICHT zur Quote
|
||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||
ec.baseValue = base;
|
||||
ec.startValue = start;
|
||||
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
||||
ec.capitalUse = topUp;
|
||||
capitalUsed += topUp;
|
||||
// PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten).
|
||||
ec.summary = fmt(ec.endValue);
|
||||
wealthStart += start;
|
||||
assets.push({ value: start, rate, r: num(pd.expectedReturn), ec });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -323,15 +282,13 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||
const start = base + topUp;
|
||||
const contribution = Math.round(num(pd.annualContribution));
|
||||
const r = num(pd.expectedReturn);
|
||||
const rate = Math.round(num(pd.annualContribution));
|
||||
plannedRatesTotal += rate;
|
||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||
ec.baseValue = base;
|
||||
ec.startValue = start;
|
||||
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
||||
ec.capitalUse = topUp;
|
||||
capitalUsed += topUp;
|
||||
ec.quotaUse = contribution; // zaehlt gegen die Sparquote
|
||||
quotaAllocated += contribution;
|
||||
ec.summary = fmt(ec.endValue);
|
||||
wealthStart += start;
|
||||
assets.push({ value: start, rate, r: num(pd.expectedReturn), ec });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -339,117 +296,163 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
|
||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||
const start = base + topUp;
|
||||
const contribution = Math.round(num(pd.annualContribution));
|
||||
const r = num(pd.expectedReturn);
|
||||
const rate = Math.round(num(pd.annualContribution));
|
||||
plannedRatesTotal += rate;
|
||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||
ec.baseValue = base;
|
||||
ec.startValue = start;
|
||||
ec.capitalUse = topUp;
|
||||
capitalUsed += topUp;
|
||||
// In Erwerbsphasen Sparbeitrag (wird eingezahlt/waechst), in Verzehrphasen
|
||||
// Bezugsrate (wird entnommen/mindert das Vermoegen). Beides zaehlt betragsmaessig
|
||||
// gegen die Quote.
|
||||
ec.quotaUse = contribution;
|
||||
quotaAllocated += contribution;
|
||||
const annual = isConsumptionPhase ? -contribution : contribution;
|
||||
ec.endValue = growAsset(start, r, annual, phase.durationYears);
|
||||
ec.summary = fmt(ec.endValue);
|
||||
wealthStart += start;
|
||||
assets.push({ value: start, rate, r: num(pd.expectedReturn), ec });
|
||||
break;
|
||||
}
|
||||
case "REAL_ESTATE": {
|
||||
const purchase = Math.round(num(pd.purchasePrice));
|
||||
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
|
||||
const amort = Math.round(num(pd.amortization));
|
||||
const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears);
|
||||
ec.startValue = purchase - mortgageStart;
|
||||
ec.endValue = purchase - mortgageEnd;
|
||||
if (!carry.hasCarry) {
|
||||
ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf
|
||||
capitalUsed += ec.capitalUse;
|
||||
}
|
||||
// Amortisation ist quotenwirksam (jaehrlicher Budgetbetrag).
|
||||
ec.quotaUse = amort;
|
||||
quotaAllocated += amort;
|
||||
carry.mortgage = mortgageEnd; // fuer Uebergang
|
||||
ec.summary = fmt(ec.endValue);
|
||||
plannedRatesTotal += amort;
|
||||
const equity = purchase - mortgageStart;
|
||||
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
|
||||
ec.baseValue = equity;
|
||||
ec.startValue = equity;
|
||||
wealthStart += equity;
|
||||
realEstates.push({ purchase, mortgageStart, amort, ec });
|
||||
break;
|
||||
}
|
||||
case "OTHER_DEBT": {
|
||||
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
|
||||
const repay = Math.round(num(pd.annualRepayment));
|
||||
const owedEnd = Math.max(0, owedStart - repay * phase.durationYears);
|
||||
plannedRatesTotal += repay;
|
||||
ec.baseValue = -owedStart;
|
||||
ec.startValue = -owedStart;
|
||||
ec.endValue = -owedEnd;
|
||||
carry.owed = owedEnd;
|
||||
// Tilgung ist quotenwirksam (jaehrlicher Budgetbetrag).
|
||||
ec.quotaUse = repay;
|
||||
quotaAllocated += repay;
|
||||
ec.summary = fmt(ec.endValue);
|
||||
if (owedEnd === 0) ec.note = "Wird getilgt";
|
||||
wealthStart += -owedStart;
|
||||
debts.push({ owedStart, repay, ec });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
elementsComputed.push(ec);
|
||||
}
|
||||
|
||||
// --- Kern-Kennzahlen ---
|
||||
const quota = incomeTotal - expenseTotal;
|
||||
const isConsumption = quota < 0;
|
||||
// Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt,
|
||||
// wenn Bezuege (quotaAllocated) den Fehlbetrag decken.
|
||||
const quotaTarget = Math.abs(quota);
|
||||
const quotaRemaining = quotaTarget - quotaAllocated;
|
||||
const quotaComplete = Math.abs(quotaRemaining) < 1;
|
||||
// --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
|
||||
const cashStart = cashCarryIn;
|
||||
let cash = cashCarryIn - (isFirstPhase ? 0 : investmentsFromCash);
|
||||
let cashNegative = cash < 0;
|
||||
let incomeStart = 0;
|
||||
let incomeEnd = 0;
|
||||
let expenseStart = 0;
|
||||
let expenseEnd = 0;
|
||||
let quotaStart = 0;
|
||||
let quotaEnd = 0;
|
||||
|
||||
const availableCapital = incomingCapital;
|
||||
const availableCapitalUsed = capitalUsed;
|
||||
const availableCapitalRemaining = availableCapital === null ? 0 : availableCapital - availableCapitalUsed;
|
||||
const availableCapitalComplete =
|
||||
availableCapital === null || Math.abs(availableCapitalRemaining) < 1;
|
||||
for (let t = 1; t <= duration; t++) {
|
||||
let incomeFlow = renteTotal;
|
||||
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
|
||||
let expenseFlow = 0;
|
||||
for (const exp of expenses) expenseFlow += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
|
||||
const quote = incomeFlow - expenseFlow;
|
||||
|
||||
const incomplete = !quotaComplete || !availableCapitalComplete;
|
||||
if (t === 1) {
|
||||
incomeStart = incomeFlow;
|
||||
expenseStart = expenseFlow;
|
||||
quotaStart = quote;
|
||||
}
|
||||
if (t === duration) {
|
||||
incomeEnd = incomeFlow;
|
||||
expenseEnd = expenseFlow;
|
||||
quotaEnd = quote;
|
||||
}
|
||||
|
||||
const inflationRate = phase.inflationRate ?? plan.inflationRateDefault;
|
||||
cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100);
|
||||
cash += quote - plannedRatesTotal;
|
||||
for (const a of assets) a.value = a.value * (1 + a.r / 100) + a.rate;
|
||||
if (cash < 0) cashNegative = true;
|
||||
|
||||
const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0);
|
||||
const endWealthNominal = elementsComputed.reduce((s, ec) => s + ec.endValue, 0);
|
||||
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
|
||||
let total = cash;
|
||||
for (const a of assets) total += a.value;
|
||||
for (const re of realEstates) total += re.purchase - Math.max(0, re.mortgageStart - re.amort * t);
|
||||
for (const d of debts) total += -Math.max(0, d.owedStart - d.repay * t);
|
||||
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
|
||||
}
|
||||
|
||||
// Endwerte je Element setzen + Endvermoegen bilden.
|
||||
for (const inc of incomes) {
|
||||
inc.ec.startValue = Math.round(inc.basis);
|
||||
inc.ec.endValue = Math.round(inc.basis * Math.pow(1 + inc.idx / 100, duration - 1));
|
||||
inc.ec.summary = fmt(inc.ec.startValue);
|
||||
}
|
||||
for (const exp of expenses) {
|
||||
exp.ec.startValue = Math.round(exp.basis);
|
||||
exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1));
|
||||
exp.ec.summary = fmt(exp.ec.startValue);
|
||||
}
|
||||
for (const a of assets) {
|
||||
a.ec.endValue = Math.round(a.value);
|
||||
a.ec.summary = fmt(a.ec.endValue);
|
||||
wealthEnd += a.ec.endValue;
|
||||
}
|
||||
for (const re of realEstates) {
|
||||
const mortgageEnd = Math.max(0, re.mortgageStart - re.amort * duration);
|
||||
re.ec.endValue = re.purchase - mortgageEnd;
|
||||
re.ec.summary = fmt(re.ec.endValue);
|
||||
wealthEnd += re.ec.endValue;
|
||||
}
|
||||
for (const d of debts) {
|
||||
const owedEnd = Math.max(0, d.owedStart - d.repay * duration);
|
||||
d.ec.endValue = -owedEnd;
|
||||
d.ec.summary = fmt(d.ec.endValue);
|
||||
wealthEnd += d.ec.endValue;
|
||||
if (owedEnd === 0) d.ec.note = "Wird getilgt";
|
||||
}
|
||||
|
||||
const cashEnd = Math.round(cash);
|
||||
const startWealthNominal = Math.round(wealthStart + cashStart);
|
||||
const endWealthNominal = Math.round(wealthEnd + cashEnd);
|
||||
cumulativeInflation = cumulativeInflation * Math.pow(1 + phaseInflation / 100, duration);
|
||||
|
||||
result.push({
|
||||
id: phase.id,
|
||||
name: phase.name,
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
durationYears: phase.durationYears,
|
||||
durationYears: duration,
|
||||
type,
|
||||
persons: personInfos,
|
||||
maxDurationYears,
|
||||
incomeTotal,
|
||||
expenseTotal,
|
||||
quota,
|
||||
isConsumption,
|
||||
quotaAllocated,
|
||||
quotaRemaining,
|
||||
quotaComplete,
|
||||
availableCapital,
|
||||
availableCapitalUsed,
|
||||
availableCapitalRemaining,
|
||||
availableCapitalComplete,
|
||||
incomplete,
|
||||
elements: elementsComputed,
|
||||
incomeStart: Math.round(incomeStart),
|
||||
incomeEnd: Math.round(incomeEnd),
|
||||
expenseStart: Math.round(expenseStart),
|
||||
expenseEnd: Math.round(expenseEnd),
|
||||
quotaStart: Math.round(quotaStart),
|
||||
quotaEnd: Math.round(quotaEnd),
|
||||
isConsumption: quotaStart < 0,
|
||||
plannedRatesTotal,
|
||||
cashStart: Math.round(cashStart),
|
||||
cashEnd,
|
||||
cashNegative,
|
||||
incomplete: cashNegative,
|
||||
elements: orderedElements.map((e) => ecById.get(e.id)!),
|
||||
startWealthNominal,
|
||||
endWealthNominal,
|
||||
cumulativeInflationEnd: cumulativeInflation,
|
||||
endWealthReal: endWealthNominal / cumulativeInflation,
|
||||
});
|
||||
|
||||
// --- Uebergang zur naechsten Phase: Carry aktualisieren + Startkapital berechnen ---
|
||||
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
|
||||
let outgoing = 0;
|
||||
for (const e of orderedElements) {
|
||||
const carry = carries.get(e.id)!;
|
||||
const ec = elementsComputed.find((x) => x.elementId === e.id)!;
|
||||
const ec = ecById.get(e.id)!;
|
||||
const pd = e.phaseValues[phase.id] ?? {};
|
||||
const td = e.transitionValues[phase.id] ?? {};
|
||||
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
||||
const ownerRetiresNext =
|
||||
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, persons, retirementAge, yearsBefore + phase.durationYears);
|
||||
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true &&
|
||||
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration);
|
||||
|
||||
// Einkommen/Ausgaben: indexierten Basiswert fortschreiben.
|
||||
if (e.category === "INCOME" || e.category === "EXPENSE") {
|
||||
const idx = num(pd.teuerungsausgleich, phaseInflation);
|
||||
carry.flowBasis = ec.startValue * Math.pow(1 + idx / 100, duration);
|
||||
carry.hasCarry = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (carry.status !== "ACTIVE") {
|
||||
carry.hasCarry = true;
|
||||
@@ -462,8 +465,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const value = ec.endValue;
|
||||
const mode = td.payoutMode ?? "PENSION";
|
||||
if (mode === "CAPITAL") {
|
||||
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
outgoing += net;
|
||||
outgoing += Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
carry.value = 0;
|
||||
carry.pkPensionAnnual = 0;
|
||||
} else if (mode === "PENSION") {
|
||||
@@ -471,8 +473,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
carry.value = 0;
|
||||
} else {
|
||||
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
|
||||
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
outgoing += net;
|
||||
outgoing += Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
|
||||
carry.value = 0;
|
||||
}
|
||||
@@ -485,8 +486,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
}
|
||||
case "PILLAR_3A": {
|
||||
if (ownerRetiresNext) {
|
||||
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
outgoing += net;
|
||||
outgoing += Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
carry.value = 0;
|
||||
} else {
|
||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||
@@ -505,22 +505,27 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
break;
|
||||
}
|
||||
case "REAL_ESTATE": {
|
||||
// ec.endValue = Kaufpreis - Resthypothek am Phasenende -> Resthypothek zurueckrechnen.
|
||||
const purchase = Math.round(num(pd.purchasePrice));
|
||||
const restMortgage = purchase - ec.endValue;
|
||||
if (td.decision === "SELL") {
|
||||
const purchase = Math.round(num(e.phaseValues[phase.id]?.purchasePrice));
|
||||
const salePrice = Math.round(num(td.salePrice));
|
||||
const gain = Math.max(0, salePrice - purchase);
|
||||
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
|
||||
outgoing += Math.round(salePrice - carry.mortgage - tax);
|
||||
outgoing += Math.round(salePrice - restMortgage - tax);
|
||||
carry.status = "SOLD";
|
||||
} else {
|
||||
carry.mortgage = restMortgage;
|
||||
}
|
||||
// HOLD: carry.mortgage bereits gesetzt.
|
||||
break;
|
||||
}
|
||||
case "OTHER_DEBT": {
|
||||
const owedEnd = -ec.endValue;
|
||||
carry.owed = owedEnd;
|
||||
const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment)));
|
||||
if (immediate > 0) {
|
||||
carry.owed = Math.max(0, carry.owed - immediate);
|
||||
outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital
|
||||
outgoing -= immediate;
|
||||
}
|
||||
if (carry.owed === 0) carry.status = "SETTLED";
|
||||
break;
|
||||
@@ -531,20 +536,14 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
carry.hasCarry = true;
|
||||
}
|
||||
|
||||
incomingCapital = nextPhase ? Math.round(outgoing) : null;
|
||||
yearsBefore += phase.durationYears;
|
||||
cashCarryIn = cashEnd + outgoing;
|
||||
yearsBefore += duration;
|
||||
}
|
||||
|
||||
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
|
||||
return { phases: result, nachlass };
|
||||
return { phases: result, nachlass, ruinAge };
|
||||
}
|
||||
|
||||
function personByRole(persons: { id: string; role: PersonRole }[], role: string) {
|
||||
return persons.find((p) => p.role === role) ?? null;
|
||||
}
|
||||
|
||||
// Prueft, ob eine Person mit dem gegebenen Jahres-Offset (zu Beginn der Folgephase) pensioniert
|
||||
// ist, obwohl sie in der Vorphase noch erwerbend war.
|
||||
function retiresInPhase(
|
||||
personId: string,
|
||||
persons: { id: string; role: PersonRole; age: number }[],
|
||||
@@ -553,15 +552,7 @@ function retiresInPhase(
|
||||
): boolean {
|
||||
const p = persons.find((x) => x.id === personId);
|
||||
if (!p) return false;
|
||||
const ra = retirementAge.get(personId)!;
|
||||
const startAgeNext = p.age + yearsBeforeNext;
|
||||
return startAgeNext >= ra;
|
||||
}
|
||||
|
||||
function fmt(v: number): string {
|
||||
const rounded = Math.round(v || 0);
|
||||
const sign = rounded < 0 ? "-" : "";
|
||||
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
|
||||
}
|
||||
|
||||
// CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen).
|
||||
@@ -570,10 +561,11 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
|
||||
"Phase",
|
||||
"Typ",
|
||||
"Dauer",
|
||||
"Einkommen",
|
||||
"Ausgaben",
|
||||
"Spar-/Verzehrquote",
|
||||
"Verfuegbares Kapital",
|
||||
"Einkommen (Beginn)",
|
||||
"Ausgaben (Beginn)",
|
||||
"Quote (Beginn)",
|
||||
"Quote (Ende)",
|
||||
"Cash (Ende)",
|
||||
"Endvermoegen (nominal)",
|
||||
"Endvermoegen (real)",
|
||||
];
|
||||
@@ -581,12 +573,14 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
|
||||
p.name,
|
||||
p.type,
|
||||
String(p.durationYears),
|
||||
p.incomeTotal.toFixed(0),
|
||||
p.expenseTotal.toFixed(0),
|
||||
p.quota.toFixed(0),
|
||||
p.availableCapital === null ? "n.a." : p.availableCapital.toFixed(0),
|
||||
p.incomeStart.toFixed(0),
|
||||
p.expenseStart.toFixed(0),
|
||||
p.quotaStart.toFixed(0),
|
||||
p.quotaEnd.toFixed(0),
|
||||
p.cashEnd.toFixed(0),
|
||||
p.endWealthNominal.toFixed(0),
|
||||
p.endWealthReal.toFixed(0),
|
||||
]);
|
||||
if (computed.ruinAge !== null) rows.push([`Ruin: Kapital aufgebraucht mit Alter ${computed.ruinAge}`]);
|
||||
return [header, ...rows].map((r) => r.join(";")).join("\n");
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ export const CATEGORY_ORDER: ElementCategory[] = [
|
||||
export interface PhaseData {
|
||||
// INCOME / EXPENSE
|
||||
amount?: number;
|
||||
// INCOME / EXPENSE: jaehrlicher Teuerungsausgleich (%). Indexiert den Flow ueber die
|
||||
// Phasenjahre (Jahr t = Basis x (1+idx)^(t-1)). Eigenes Feld je Element (Einkommen und
|
||||
// Ausgaben unabhaengig). Default = Phaseninflation.
|
||||
teuerungsausgleich?: number;
|
||||
// INCOME / EXPENSE ab Phase 2: uebersteuert den fortgeschriebenen Basiswert dieser Phase.
|
||||
amountOverride?: number;
|
||||
// AHV
|
||||
gapYears?: number;
|
||||
// PENSION_FUND / PILLAR_3A / OTHER_ASSET
|
||||
@@ -98,6 +104,8 @@ const nonNeg = z.number().min(0);
|
||||
export const phaseDataSchema = z
|
||||
.object({
|
||||
amount: nonNeg.optional(),
|
||||
teuerungsausgleich: z.number().min(-20).max(50).optional(),
|
||||
amountOverride: nonNeg.optional(),
|
||||
gapYears: z.number().int().min(0).optional(),
|
||||
currentValue: nonNeg.optional(),
|
||||
startValue: nonNeg.optional(),
|
||||
|
||||
Reference in New Issue
Block a user