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
+231 -237
View File
@@ -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");
}