Rework core model: financial elements as plan-wide entities across phases; derived phase types (Erwerb/Pension/Misch) with retirement-capped durations and per-plan retirement age; AHV (gap years + couple ceiling), PK payout/annuity, 3a, real estate, other assets/debts; horizontal timeline with retirement markers; phase x element matrix with detail panel; savings/consumption quota + available-capital key figures with red status
Deploy App / deploy (push) Successful in 1m57s

This commit is contained in:
2026-07-13 07:55:58 +02:00
parent b3a3b565ac
commit b775ab77cb
27 changed files with 2407 additions and 2166 deletions
+510 -264
View File
@@ -1,39 +1,41 @@
import { AHV_COUPLE_CAP_FACTOR, AHV_MAX_PENSION_PER_YEAR } from "@/lib/constants";
import {
AHV_COUPLE_CAP_FACTOR,
AHV_FULL_CONTRIBUTION_YEARS,
AHV_MAX_ANNUAL_SINGLE,
} from "@/lib/constants";
import { floorToThousand } from "@/lib/format";
import type { HouseholdInput, PhaseInput, PlanInput } from "@/lib/types";
import { num } from "@/lib/elements";
import type { ElementCategory } from "@/lib/elements";
import type { HouseholdInput, PersonRole, PlanInput } from "@/lib/types";
export interface SecurityComputed {
id: string;
name: string;
ownerTag: string;
startValue: number;
endValue: number;
yearly: number[]; // Index 0 = Startwert, Index durationYears = Endwert
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
export interface PersonPhaseInfo {
personId: string;
role: PersonRole;
startAge: number;
endAge: number;
working: boolean;
// Wird diese Person genau zu Beginn dieser Phase pensioniert (erste Pensionsphase)?
retiresAtStart: boolean;
}
export interface RealEstateComputed {
id: string;
export interface ElementPhaseComputed {
elementId: string;
category: ElementCategory;
name: string;
purchasePrice: number; // fix ueber die Haltedauer, keine Wertsteigerung im vereinfachten Modell
startNet: number;
endNet: number;
mortgages: number[]; // Index 0 = Start, Index durationYears = Ende
}
export interface RetirementComputed {
perPerson: {
personId: string;
ahvAmount: number;
pkPensionAmount: number;
lumpSumAmount: number;
lumpSumNet: number;
}[];
combinedAhv: number;
ahvCapped: boolean;
pkTotal: number;
totalPensionIncome: number; // combinedAhv + pkTotal, fliesst als Einkommen in die Phase ein
lumpSumGrossTotal: number;
lumpSumNetTotal: number; // fliesst als Einmalbetrag in das Endvermoegen der Phase ein
ownerRole: string | null;
status: ElementStatus;
locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar
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/Sonstiges Vermoegen)
capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition)
summary: string; // Kennzahl fuer die eingeklappte Zelle
note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen"
}
export interface PhaseComputed {
@@ -41,271 +43,515 @@ export interface PhaseComputed {
name: string;
sequenceNumber: number;
durationYears: number;
incomeFromEntries: number;
type: PhaseType;
persons: PersonPhaseInfo[];
maxDurationYears: number | null; // Kappung ans naechste Pensionsereignis (null = unbegrenzt)
incomeTotal: number;
expenseTotal: number;
retirement: RetirementComputed | null;
effectiveIncome: number; // incomeFromEntries + retirement.totalPensionIncome
savingsQuota: number; // effectiveIncome - expenseTotal
allocatedSavings: number; // Summe der jaehrlichen Sparbeitraege auf Wertschriften
savingsWarning: boolean;
securities: SecurityComputed[];
realEstates: RealEstateComputed[];
oneTimeNet: number;
quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0)
isConsumption: boolean;
quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege
quotaComplete: boolean;
availableCapital: number | null; // null in der ersten Phase
availableCapitalUsed: number;
availableCapitalComplete: boolean;
incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt)
elements: ElementPhaseComputed[];
startWealthNominal: number;
endWealthNominal: number;
cumulativeInflationStart: number;
cumulativeInflationEnd: number;
startWealthReal: number;
endWealthReal: number;
yearlyNominal: number[]; // Laenge durationYears, Werte am Ende von Jahr 1..durationYears
yearlyReal: number[];
// Alter der Personen zu Beginn und am Ende dieser Phase (Grundprofil-Alter +
// kumulierte Dauer der Vorphasen).
ages: PersonAgeRange[];
}
export interface PersonAgeRange {
personId: string;
role: string; // PERSON_A | PERSON_B
startAge: number;
endAge: number;
}
export interface PlanComputed {
phases: PhaseComputed[];
nachlass: number;
totalSavingsWarnings: number;
}
// Alle Zwischen- und Endwerte werden auf ein Vielfaches von 1'000 abgerundet (siehe
// lib/format.ts): nur so bleiben Betraege, die spaeter bei einem Verkauf oder Uebergang
// auf Wertschriften verteilt werden muessen, ueberhaupt vollstaendig verteilbar.
export function computeSecurityYearlyValues(
startValue: number,
expectedReturn: number,
annualContribution: number,
durationYears: number
): number[] {
const values = [floorToThousand(startValue)];
for (let year = 1; year <= durationYears; year++) {
const previous = values[year - 1];
values.push(floorToThousand(previous * (1 + expectedReturn / 100) + annualContribution));
// Loest das effektive Pensionsalter einer Person auf (Plan-Override vor Profil-Default).
export function resolveRetirementAge(
role: PersonRole,
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
profileDefault: number
): number {
const override = role === "PERSON_A" ? plan.retirementAgeA : plan.retirementAgeB;
return override ?? profileDefault;
}
// Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet:
// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt).
export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[],
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
yearsBefore: number
): number | null {
const caps: number[] = [];
for (const p of persons) {
const ra = resolveRetirementAge(p.role, plan, p.retirementAge);
const startAge = p.age + yearsBefore;
if (startAge < ra) caps.push(ra - startAge);
}
return values;
return caps.length > 0 ? Math.min(...caps) : null;
}
// Vereinfachtes Modell (keine Wertsteigerung): der Kaufpreis bleibt ueber die ganze
// Haltedauer fix, nur die Hypothek sinkt jaehrlich um die Amortisationsrate.
export function computeMortgageYearly(
mortgage: number,
amortization: number,
durationYears: number
): number[] {
const mortgages = [floorToThousand(mortgage)];
for (let year = 1; year <= durationYears; year++) {
mortgages.push(floorToThousand(Math.max(0, mortgages[year - 1] - amortization)));
// 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?
}
function emptyCarry(): Carry {
return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false };
}
function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number {
let v = floorToThousand(startValue);
for (let y = 0; y < years; y++) {
v = floorToThousand(v * (1 + expectedReturn / 100) + annual);
}
return mortgages;
}
function computeRetirement(
household: HouseholdInput,
phase: PhaseInput
): RetirementComputed | null {
if (phase.retirementInfos.length === 0) return null;
const perPerson = phase.retirementInfos.map((info) => ({
personId: info.personId,
ahvAmount: info.ahvAmount,
pkPensionAmount: info.pkPensionAmount,
lumpSumAmount: info.lumpSumAmount,
lumpSumNet: floorToThousand(info.lumpSumAmount * (1 - info.lumpSumTaxRate / 100)),
}));
const ahvSum = perPerson.reduce((sum, p) => sum + p.ahvAmount, 0);
const ahvCap = AHV_MAX_PENSION_PER_YEAR * AHV_COUPLE_CAP_FACTOR;
const isCoupleBothRetired = household.householdType === "COUPLE" && phase.retirementInfos.length === 2;
const combinedAhv = isCoupleBothRetired ? Math.min(ahvSum, ahvCap) : ahvSum;
const ahvCapped = isCoupleBothRetired && ahvSum > ahvCap;
const pkTotal = perPerson.reduce((sum, p) => sum + p.pkPensionAmount, 0);
const lumpSumGrossTotal = perPerson.reduce((sum, p) => sum + p.lumpSumAmount, 0);
const lumpSumNetTotal = perPerson.reduce((sum, p) => sum + p.lumpSumNet, 0);
return {
perPerson,
combinedAhv,
ahvCapped,
pkTotal,
totalPensionIncome: combinedAhv + pkTotal,
lumpSumGrossTotal,
lumpSumNetTotal,
};
}
function computePhase(
phase: PhaseInput,
household: HouseholdInput,
cumulativeInflationStart: number,
yearsBeforePhase: number
): PhaseComputed {
const ages: PersonAgeRange[] = household.persons.map((p) => ({
personId: p.id,
role: p.role,
startAge: p.age + yearsBeforePhase,
endAge: p.age + yearsBeforePhase + phase.durationYears,
}));
const incomeFromEntries = phase.incomeEntries.reduce((sum, e) => sum + e.amount, 0);
const expenseTotal = phase.expenseEntries.reduce((sum, e) => sum + e.amount, 0);
const retirement = computeRetirement(household, phase);
const effectiveIncome = incomeFromEntries + (retirement?.totalPensionIncome ?? 0);
const savingsQuota = effectiveIncome - expenseTotal;
// Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der
// Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf).
const allocatedSavings =
phase.securities.reduce((sum, s) => sum + s.annualContribution, 0) +
phase.realEstates.reduce((sum, re) => sum + re.amortization, 0);
const savingsWarning = allocatedSavings > savingsQuota;
const securities: SecurityComputed[] = phase.securities.map((s) => {
const yearly = computeSecurityYearlyValues(
s.startValue,
s.expectedReturn,
s.annualContribution,
phase.durationYears
);
return {
id: s.id,
name: s.name,
ownerTag: s.ownerTag,
startValue: yearly[0],
endValue: yearly[phase.durationYears],
yearly,
};
});
const realEstates: RealEstateComputed[] = phase.realEstates.map((re) => {
const mortgages = computeMortgageYearly(re.mortgage, re.amortization, phase.durationYears);
const purchasePrice = floorToThousand(re.purchasePrice);
return {
id: re.id,
name: re.name,
purchasePrice,
startNet: purchasePrice - mortgages[0],
endNet: purchasePrice - mortgages[phase.durationYears],
mortgages,
};
});
const oneTimeNet = phase.oneTimeEvents.reduce(
(sum, e) => sum + (e.type === "INCOME" ? e.amount : -e.amount),
0
);
const startWealthNominal =
securities.reduce((sum, s) => sum + s.startValue, 0) +
realEstates.reduce((sum, re) => sum + re.startNet, 0);
const endWealthNominal =
securities.reduce((sum, s) => sum + s.endValue, 0) +
realEstates.reduce((sum, re) => sum + re.endNet, 0) +
oneTimeNet +
(retirement?.lumpSumNetTotal ?? 0);
const inflationRate = phase.inflationRate ?? household.inflationRateDefault;
// TDD Kapitel 3.5: kumulierte Inflation ist ein Produkt ueber die Phasen (ein Faktor
// pro Phase), nicht ueber einzelne Jahre. Bewusst woertlich gemaess Spezifikation umgesetzt.
const cumulativeInflationEnd = cumulativeInflationStart * (1 + inflationRate / 100);
const yearlyNominal: number[] = [];
for (let year = 1; year <= phase.durationYears; year++) {
let value =
securities.reduce((sum, s) => sum + s.yearly[year], 0) +
realEstates.reduce((sum, re) => sum + (re.purchasePrice - re.mortgages[year]), 0);
if (year === phase.durationYears) {
// Einmalige Ereignisse und Kapitalbezuege schlagen erst am Ende der Phase zu
// Buche (siehe Phasenuebergang, TDD Kapitel 10). Immobilien-/Wertschriften-
// Verkaeufe wirken sich nur auf die naechste Phase aus (incomingCapital), nicht
// mehr auf das Endvermoegen dieser Phase selbst.
value += oneTimeNet + (retirement?.lumpSumNetTotal ?? 0);
}
yearlyNominal.push(value);
}
// Vereinfachung: innerhalb einer Phase wird fuer den Realwert durchgehend die am
// Phasenende gueltige kumulierte Inflation verwendet (siehe cumulativeInflationEnd oben).
const yearlyReal = yearlyNominal.map((v) => v / cumulativeInflationEnd);
return {
id: phase.id,
name: phase.name,
sequenceNumber: phase.sequenceNumber,
durationYears: phase.durationYears,
incomeFromEntries,
expenseTotal,
retirement,
effectiveIncome,
savingsQuota,
allocatedSavings,
savingsWarning,
securities,
realEstates,
oneTimeNet,
startWealthNominal,
endWealthNominal,
cumulativeInflationStart,
cumulativeInflationEnd,
startWealthReal: startWealthNominal / cumulativeInflationStart,
endWealthReal: endWealthNominal / cumulativeInflationEnd,
yearlyNominal,
yearlyReal,
ages,
};
return Math.max(0, v);
}
export function computePlan(plan: PlanInput, household: HouseholdInput): PlanComputed {
const orderedPhases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = household.persons;
let cumulativeInflation = 1;
let yearsBefore = 0;
const phases: PhaseComputed[] = [];
for (const phase of orderedPhases) {
const computed = computePhase(phase, household, cumulativeInflation, yearsBefore);
cumulativeInflation = computed.cumulativeInflationEnd;
yearsBefore += phase.durationYears;
phases.push(computed);
// Pensionsalter je Person (aufgeloest).
const retirementAge = new Map<string, number>();
for (const p of persons) {
retirementAge.set(p.id, resolveRetirementAge(p.role, plan, p.retirementAge));
}
const nachlass = phases.length > 0 ? phases[phases.length - 1].endWealthNominal : 0;
const totalSavingsWarnings = phases.filter((p) => p.savingsWarning).length;
// 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());
return { phases, nachlass, totalSavingsWarnings };
const result: PhaseComputed[] = [];
let yearsBefore = 0;
let cumulativeInflation = 1;
let incomingCapital: number | null = null; // in die aktuelle Phase einfliessendes Startkapital
for (let i = 0; i < phases.length; i++) {
const phase = phases[i];
const nextPhase = phases[i + 1];
// --- 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,
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 ---
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;
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
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, floorToThousand(AHV_MAX_ANNUAL_SINGLE * factor));
}
const ahvFinal = new Map(ahvUncapped);
if (household.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, floorToThousand(v * (cap / sum)));
}
}
// --- Elemente dieser Phase berechnen ---
const elementsComputed: ElementPhaseComputed[] = [];
let incomeTotal = 0;
let expenseTotal = 0;
let quotaAllocated = 0;
let capitalUsed = 0;
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
for (const e of orderedElements) {
const carry = carries.get(e.id)!;
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;
const ec: ElementPhaseComputed = {
elementId: e.id,
category: e.category,
name: e.name,
ownerRole: e.ownerRole,
status: carry.status,
locked: carry.status !== "ACTIVE",
startValue: 0,
endValue: 0,
incomeContribution: 0,
expenseContribution: 0,
quotaUse: 0,
capitalUse: 0,
summary: "",
note: null,
};
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 = floorToThousand(num(pd.amount));
ec.incomeContribution = amount;
incomeTotal += amount;
ec.summary = fmt(amount);
break;
}
case "EXPENSE": {
const amount = floorToThousand(num(pd.amount));
ec.expenseContribution = amount;
expenseTotal += amount;
ec.summary = fmt(amount);
break;
}
case "AHV": {
if (owner && !ownerWorking) {
const pension = ahvFinal.get(owner.id) ?? 0;
ec.incomeContribution = pension;
incomeTotal += pension;
ec.summary = `Rente ${fmt(pension)}`;
} else {
const gap = Math.max(0, Math.round(num(pd.gapYears)));
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
}
break;
}
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)}`;
} else if (!ownerWorking) {
ec.note = "Vollstaendig bezogen";
ec.summary = "Bezogen";
} else {
const start = floorToThousand(num(pd.currentValue));
const contribution = floorToThousand(num(pd.annualContribution));
const r = num(pd.expectedReturn);
ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
ec.capitalUse = Math.max(0, start - carry.value);
capitalUsed += ec.capitalUse;
// PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten).
ec.summary = fmt(ec.endValue);
}
break;
}
case "PILLAR_3A": {
if (!ownerWorking) {
ec.note = "Vollstaendig bezogen";
ec.summary = "Bezogen";
} else {
const start = floorToThousand(num(pd.currentValue));
const contribution = roundToHundred(num(pd.annualContribution));
const r = num(pd.expectedReturn);
ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
ec.capitalUse = Math.max(0, start - carry.value);
capitalUsed += ec.capitalUse;
ec.quotaUse = contribution; // zaehlt gegen die Sparquote
quotaAllocated += contribution;
ec.summary = fmt(ec.endValue);
}
break;
}
case "OTHER_ASSET": {
const start = floorToThousand(num(pd.startValue));
const contribution = floorToThousand(num(pd.annualContribution));
const r = num(pd.expectedReturn);
ec.startValue = start;
ec.capitalUse = Math.max(0, start - carry.value);
capitalUsed += ec.capitalUse;
// In Erwerbsphasen (Sparen) wird eingezahlt, in Verzehrphasen bezogen -- das
// Vorzeichen ergibt sich aus der Phasenquote (siehe unten). Hier immer als
// Beitrag verbucht; die Verzehr-Logik nutzt denselben Betrag als Bezug.
ec.quotaUse = contribution;
quotaAllocated += contribution;
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
ec.summary = fmt(ec.endValue);
break;
}
case "REAL_ESTATE": {
const purchase = floorToThousand(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : floorToThousand(num(pd.mortgage));
const amort = floorToThousand(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;
}
carry.mortgage = mortgageEnd; // fuer Uebergang
ec.summary = fmt(ec.endValue);
break;
}
case "OTHER_DEBT": {
const owedStart = carry.hasCarry ? carry.owed : floorToThousand(num(pd.startValue));
const repay = floorToThousand(num(pd.annualRepayment));
const owedEnd = Math.max(0, owedStart - repay * phase.durationYears);
ec.startValue = -owedStart;
ec.endValue = -owedEnd;
carry.owed = owedEnd;
ec.summary = fmt(ec.endValue);
if (owedEnd === 0) ec.note = "Wird getilgt";
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 quotaComplete = Math.abs(quotaTarget - quotaAllocated) < 1;
const availableCapital = incomingCapital;
const availableCapitalUsed = capitalUsed;
const availableCapitalComplete =
availableCapital === null || Math.abs(availableCapital - availableCapitalUsed) < 1;
const incomplete = !quotaComplete || !availableCapitalComplete;
const inflationRate = phase.inflationRate ?? household.inflationRateDefault;
cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100);
const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0);
const endWealthNominal = elementsComputed.reduce((s, ec) => s + ec.endValue, 0);
result.push({
id: phase.id,
name: phase.name,
sequenceNumber: phase.sequenceNumber,
durationYears: phase.durationYears,
type,
persons: personInfos,
maxDurationYears,
incomeTotal,
expenseTotal,
quota,
isConsumption,
quotaAllocated,
quotaComplete,
availableCapital,
availableCapitalUsed,
availableCapitalComplete,
incomplete,
elements: elementsComputed,
startWealthNominal,
endWealthNominal,
cumulativeInflationEnd: cumulativeInflation,
endWealthReal: endWealthNominal / cumulativeInflation,
});
// --- Uebergang zur naechsten Phase: Carry aktualisieren + Startkapital berechnen ---
let outgoing = 0;
for (const e of orderedElements) {
const carry = carries.get(e.id)!;
const ec = elementsComputed.find((x) => x.elementId === e.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, nextPhase, persons, retirementAge, yearsBefore + phase.durationYears);
if (carry.status !== "ACTIVE") {
carry.hasCarry = true;
continue;
}
switch (e.category) {
case "PENSION_FUND": {
if (ownerRetiresNext) {
const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") {
const net = floorToThousand(value * (1 - num(td.capitalTaxRate) / 100));
outgoing += net;
carry.value = 0;
carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") {
carry.pkPensionAnnual = floorToThousand((value * num(td.conversionRate)) / 100);
carry.value = 0;
} else {
const capital = Math.min(value, floorToThousand(num(td.capitalAmount)));
const net = floorToThousand(capital * (1 - num(td.capitalTaxRate) / 100));
outgoing += net;
carry.pkPensionAnnual = floorToThousand(((value - capital) * num(td.conversionRate)) / 100);
carry.value = 0;
}
} else {
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
outgoing += withdrawal;
}
break;
}
case "PILLAR_3A": {
if (ownerRetiresNext) {
const net = floorToThousand(ec.endValue * (1 - num(td.capitalTaxRate) / 100));
outgoing += net;
carry.value = 0;
} else {
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
outgoing += withdrawal;
}
break;
}
case "OTHER_ASSET": {
if (td.decision === "SELL") {
outgoing += ec.endValue;
carry.status = "SOLD";
} else {
carry.value = ec.endValue;
}
break;
}
case "REAL_ESTATE": {
if (td.decision === "SELL") {
const purchase = floorToThousand(num(e.phaseValues[phase.id]?.purchasePrice));
const salePrice = floorToThousand(num(td.salePrice));
const gain = Math.max(0, salePrice - purchase);
const tax = gain * (num(td.saleTaxRate) / 100);
outgoing += floorToThousand(salePrice - carry.mortgage - tax);
carry.status = "SOLD";
}
// HOLD: carry.mortgage bereits gesetzt.
break;
}
case "OTHER_DEBT": {
const immediate = Math.min(carry.owed, floorToThousand(num(td.immediateRepayment)));
if (immediate > 0) {
carry.owed = Math.max(0, carry.owed - immediate);
outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital
}
if (carry.owed === 0) carry.status = "SETTLED";
break;
}
default:
break;
}
carry.hasCarry = true;
}
incomingCapital = nextPhase ? floorToThousand(outgoing) : null;
yearsBefore += phase.durationYears;
}
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
return { phases: result, nachlass };
}
export function planToCsv(plan: PlanInput, planComputed: PlanComputed): string {
function personByRole(persons: { id: string; role: PersonRole }[], role: string) {
return persons.find((p) => p.role === role) ?? null;
}
// Prueft, ob eine Person in der gegebenen Phase (mit gegebenem Jahres-Offset) pensioniert ist,
// obwohl sie in der Vorphase noch erwerbend war.
function retiresInPhase(
personId: string,
phase: { durationYears: number },
persons: { id: string; role: PersonRole; age: number }[],
retirementAge: Map<string, number>,
yearsBeforeNext: number
): 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 roundToHundred(v: number): number {
return Math.round((v || 0) / 100) * 100;
}
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, "'");
}
// CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen).
export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
const header = [
"Phase",
"Dauer (Jahre)",
"Startvermoegen (nominal)",
"Endvermoegen (nominal)",
"Endvermoegen (real)",
"Typ",
"Dauer",
"Einkommen",
"Ausgaben",
"Sparquote",
"Verplante Sparbeitraege",
"Einmalige Ereignisse (netto)",
"Spar-/Verzehrquote",
"Verfuegbares Kapital",
"Endvermoegen (nominal)",
"Endvermoegen (real)",
];
const rows = planComputed.phases.map((p) => [
const rows = computed.phases.map((p) => [
p.name,
p.type,
String(p.durationYears),
p.startWealthNominal.toFixed(2),
p.endWealthNominal.toFixed(2),
p.endWealthReal.toFixed(2),
p.effectiveIncome.toFixed(2),
p.expenseTotal.toFixed(2),
p.savingsQuota.toFixed(2),
p.allocatedSavings.toFixed(2),
p.oneTimeNet.toFixed(2),
p.incomeTotal.toFixed(0),
p.expenseTotal.toFixed(0),
p.quota.toFixed(0),
p.availableCapital === null ? "n.a." : p.availableCapital.toFixed(0),
p.endWealthNominal.toFixed(0),
p.endWealthReal.toFixed(0),
]);
return [header, ...rows].map((r) => r.join(";")).join("\n");
}