Initial commit: FPT Financial Planning Tool
Deploy App / deploy (push) Successful in 3m3s

This commit is contained in:
2026-07-08 19:19:39 +02:00
commit 4f990c9686
60 changed files with 12436 additions and 0 deletions
+311
View File
@@ -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");
}