This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import { AHV_COUPLE_CAP_FACTOR, AHV_MAX_PENSION_PER_YEAR } from "@/lib/constants";
|
||||
import type { HouseholdInput, PhaseInput, 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 interface RealEstateComputed {
|
||||
id: string;
|
||||
name: string;
|
||||
startNet: number;
|
||||
endNetIfKept: number;
|
||||
sold: boolean;
|
||||
saleNetProceeds: number | null;
|
||||
taxAmount: number;
|
||||
endContribution: number; // was tatsaechlich in die Endvermoegens-Summe der Phase einfliesst
|
||||
marketValues: number[];
|
||||
mortgages: number[];
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface PhaseComputed {
|
||||
id: string;
|
||||
name: string;
|
||||
sequenceNumber: number;
|
||||
durationYears: number;
|
||||
incomeFromEntries: 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;
|
||||
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[];
|
||||
}
|
||||
|
||||
export interface PlanComputed {
|
||||
phases: PhaseComputed[];
|
||||
nachlass: number;
|
||||
totalSavingsWarnings: number;
|
||||
}
|
||||
|
||||
export function computeSecurityYearlyValues(
|
||||
startValue: number,
|
||||
expectedReturn: number,
|
||||
annualContribution: number,
|
||||
durationYears: number
|
||||
): number[] {
|
||||
const values = [startValue];
|
||||
for (let year = 1; year <= durationYears; year++) {
|
||||
const previous = values[year - 1];
|
||||
values.push(previous * (1 + expectedReturn / 100) + annualContribution);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function computeRealEstateYearly(
|
||||
marketValue: number,
|
||||
mortgage: number,
|
||||
valueGrowth: number,
|
||||
amortization: number,
|
||||
durationYears: number
|
||||
): { marketValues: number[]; mortgages: number[] } {
|
||||
const marketValues = [marketValue];
|
||||
const mortgages = [mortgage];
|
||||
for (let year = 1; year <= durationYears; year++) {
|
||||
marketValues.push(marketValues[year - 1] * (1 + valueGrowth / 100));
|
||||
mortgages.push(Math.max(0, mortgages[year - 1] - amortization));
|
||||
}
|
||||
return { marketValues, 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: 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
|
||||
): PhaseComputed {
|
||||
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;
|
||||
const allocatedSavings = phase.securities.reduce((sum, s) => sum + s.annualContribution, 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 { marketValues, mortgages } = computeRealEstateYearly(
|
||||
re.marketValue,
|
||||
re.mortgage,
|
||||
re.valueGrowth,
|
||||
re.amortization,
|
||||
phase.durationYears
|
||||
);
|
||||
const startNet = marketValues[0] - mortgages[0];
|
||||
const endNetIfKept = marketValues[phase.durationYears] - mortgages[phase.durationYears];
|
||||
const sold = re.salePrice != null;
|
||||
let saleNetProceeds: number | null = null;
|
||||
let taxAmount = 0;
|
||||
if (sold) {
|
||||
// Vereinfachung gemaess TDD 3.3: Gewinn = Verkaufspreis - urspruenglich erfasster Startwert
|
||||
const gain = Math.max(0, re.salePrice! - re.marketValue);
|
||||
taxAmount = gain * (re.saleTaxRate / 100);
|
||||
saleNetProceeds = re.salePrice! - taxAmount;
|
||||
}
|
||||
return {
|
||||
id: re.id,
|
||||
name: re.name,
|
||||
startNet,
|
||||
endNetIfKept,
|
||||
sold,
|
||||
saleNetProceeds,
|
||||
taxAmount,
|
||||
endContribution: sold ? saleNetProceeds! : endNetIfKept,
|
||||
marketValues,
|
||||
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.endContribution, 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.marketValues[year] - re.mortgages[year]), 0);
|
||||
if (year === phase.durationYears) {
|
||||
// Einmalige Ereignisse, Verkaufserloese und Kapitalbezuege schlagen erst am Ende
|
||||
// der Phase zu Buche (siehe Phasenuebergang, TDD Kapitel 10).
|
||||
value += oneTimeNet + (retirement?.lumpSumNetTotal ?? 0);
|
||||
const soldReplacement = realEstates.reduce(
|
||||
(sum, re) => sum + (re.sold ? re.saleNetProceeds! - (re.marketValues[year] - re.mortgages[year]) : 0),
|
||||
0
|
||||
);
|
||||
value += soldReplacement;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function computePlan(plan: PlanInput, household: HouseholdInput): PlanComputed {
|
||||
const orderedPhases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
|
||||
let cumulativeInflation = 1;
|
||||
const phases: PhaseComputed[] = [];
|
||||
for (const phase of orderedPhases) {
|
||||
const computed = computePhase(phase, household, cumulativeInflation);
|
||||
cumulativeInflation = computed.cumulativeInflationEnd;
|
||||
phases.push(computed);
|
||||
}
|
||||
|
||||
const nachlass = phases.length > 0 ? phases[phases.length - 1].endWealthNominal : 0;
|
||||
const totalSavingsWarnings = phases.filter((p) => p.savingsWarning).length;
|
||||
|
||||
return { phases, nachlass, totalSavingsWarnings };
|
||||
}
|
||||
|
||||
export function planToCsv(plan: PlanInput, planComputed: PlanComputed): string {
|
||||
const header = [
|
||||
"Phase",
|
||||
"Dauer (Jahre)",
|
||||
"Startvermoegen (nominal)",
|
||||
"Endvermoegen (nominal)",
|
||||
"Endvermoegen (real)",
|
||||
"Einkommen",
|
||||
"Ausgaben",
|
||||
"Sparquote",
|
||||
"Verplante Sparbeitraege",
|
||||
"Einmalige Ereignisse (netto)",
|
||||
];
|
||||
const rows = planComputed.phases.map((p) => [
|
||||
p.name,
|
||||
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),
|
||||
]);
|
||||
return [header, ...rows].map((r) => r.join(";")).join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user