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
+25
View File
@@ -0,0 +1,25 @@
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
const body = await response.json().catch(() => null);
if (!response.ok) {
const message =
(body && typeof body.error === "string" && body.error) ||
(body && body.error ? JSON.stringify(body.error) : `Fehler ${response.status}`);
throw new Error(message);
}
return body as T;
}
export const api = {
get: <T>(url: string) => request<T>(url),
post: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "POST", body: JSON.stringify(data ?? {}) }),
put: <T>(url: string, data: unknown) =>
request<T>(url, { method: "PUT", body: JSON.stringify(data) }),
patch: <T>(url: string, data: unknown) =>
request<T>(url, { method: "PATCH", body: JSON.stringify(data) }),
delete: <T>(url: string) => request<T>(url, { method: "DELETE" }),
};
+31
View File
@@ -0,0 +1,31 @@
import { SignJWT, jwtVerify } from "jose";
const SESSION_COOKIE_NAME = "fpt_session";
const SESSION_DURATION = "30d";
function getSecretKey() {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("SESSION_SECRET ist nicht gesetzt.");
}
return new TextEncoder().encode(secret);
}
export async function createSessionToken(): Promise<string> {
return new SignJWT({ auth: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(SESSION_DURATION)
.sign(getSecretKey());
}
export async function verifySessionToken(token: string): Promise<boolean> {
try {
const { payload } = await jwtVerify(token, getSecretKey());
return payload.auth === true;
} catch {
return false;
}
}
export { SESSION_COOKIE_NAME };
+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");
}
+7
View File
@@ -0,0 +1,7 @@
// AHV-Maximalrente (Einzelperson, CHF/Jahr). Aendert sich periodisch durch Anpassungen
// des Bundes -- deshalb hier als einzelner konfigurierbarer Systemparameter gefuehrt
// (TDD Kapitel 3.4), nicht hart im Code verteilt.
export const AHV_MAX_PENSION_PER_YEAR = 30240;
// Faktor fuer die Plafonierung der AHV-Rente bei Ehepaaren (TDD Kapitel 3.4).
export const AHV_COUPLE_CAP_FACTOR = 1.5;
+28
View File
@@ -0,0 +1,28 @@
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
// Nur von API-Routes (Node.js-Runtime) verwendet -- niemals von middleware.ts
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist.
// Es gibt genau eine AppCredential-Zeile. Solange keine existiert, ist die App
// "unconfigured" und der naechste Login-Versuch legt das Passwort fest.
export async function getAppCredential() {
return prisma.appCredential.findFirst();
}
export async function setAppPassword(password: string) {
const existing = await getAppCredential();
if (existing) {
// Sollte durch die UI (Passwort-Setup nur beim ersten Login sichtbar) nicht
// vorkommen, wird aber sicherheitshalber serverseitig verhindert.
throw new Error("Es ist bereits ein Passwort gesetzt.");
}
const passwordHash = await bcrypt.hash(password, 12);
return prisma.appCredential.create({ data: { passwordHash } });
}
export async function verifyAppPassword(password: string): Promise<boolean> {
const credential = await getAppCredential();
if (!credential) return false;
return bcrypt.compare(password, credential.passwordHash);
}
+12
View File
@@ -0,0 +1,12 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+102
View File
@@ -0,0 +1,102 @@
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { HouseholdInput, PlanInput } from "@/lib/types";
export const phaseInclude = {
incomeEntries: true,
expenseEntries: true,
securities: true,
realEstates: true,
oneTimeEvents: true,
retirementInfos: true,
} satisfies Prisma.PhaseInclude;
export const planInclude = {
phases: {
include: phaseInclude,
orderBy: { sequenceNumber: "asc" },
},
} satisfies Prisma.PlanInclude;
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>;
export type PhaseWithRelations = Prisma.PhaseGetPayload<{ include: typeof phaseInclude }>;
export type HouseholdWithPersons = Prisma.HouseholdGetPayload<{ include: { persons: true } }>;
export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInput {
return {
id: household.id,
householdType: household.householdType,
inflationRateDefault: household.inflationRateDefault,
persons: household.persons.map((p) => ({
id: p.id,
role: p.role,
age: p.age,
retirementAge: p.retirementAge,
})),
};
}
export function toPlanInput(plan: PlanWithRelations): PlanInput {
return {
id: plan.id,
name: plan.name,
parentPlanId: plan.parentPlanId,
branchFromPhaseId: plan.branchFromPhaseId,
phases: plan.phases.map((phase) => ({
id: phase.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
inflationRate: phase.inflationRate,
incomeMode: phase.incomeMode,
incomeEntries: phase.incomeEntries.map((e) => ({
id: e.id,
personId: e.personId,
label: e.label,
amount: e.amount,
})),
expenseEntries: phase.expenseEntries.map((e) => ({
id: e.id,
label: e.label,
amount: e.amount,
})),
securities: phase.securities.map((s) => ({
id: s.id,
name: s.name,
startValue: s.startValue,
expectedReturn: s.expectedReturn,
annualContribution: s.annualContribution,
ownerTag: s.ownerTag,
saleTaxRate: s.saleTaxRate,
})),
realEstates: phase.realEstates.map((re) => ({
id: re.id,
name: re.name,
marketValue: re.marketValue,
mortgage: re.mortgage,
valueGrowth: re.valueGrowth,
amortization: re.amortization,
salePrice: re.salePrice,
saleTaxRate: re.saleTaxRate,
})),
oneTimeEvents: phase.oneTimeEvents.map((e) => ({
id: e.id,
type: e.type,
amount: e.amount,
description: e.description,
})),
retirementInfos: phase.retirementInfos.map((r) => ({
id: r.id,
personId: r.personId,
ahvAmount: r.ahvAmount,
pkPensionAmount: r.pkPensionAmount,
lumpSumAmount: r.lumpSumAmount,
lumpSumTaxRate: r.lumpSumTaxRate,
})),
})),
};
}
export async function getHouseholdOrNull(): Promise<HouseholdWithPersons | null> {
return prisma.household.findFirst({ include: { persons: true } });
}
+98
View File
@@ -0,0 +1,98 @@
// Domain-Typen fuer die Berechnungslogik (lib/calculations.ts) und die API-Payloads.
// Bewusst von den generierten Prisma-Typen entkoppelt, damit die Berechnungslogik
// unabhaengig von der konkreten DB-Repraesentation testbar bleibt.
export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B";
export type IncomeMode = "PER_PERSON" | "HOUSEHOLD";
export type OwnerTag = "PERSON_A" | "PERSON_B" | "HOUSEHOLD";
export type OneTimeEventType = "INCOME" | "EXPENSE";
export type TransitionDecision = "CARRY_OVER" | "SELL";
export type PositionType = "SECURITY" | "REAL_ESTATE";
export interface PersonInput {
id: string;
role: PersonRole;
age: number;
retirementAge: number;
}
export interface HouseholdInput {
id: string;
householdType: HouseholdType;
inflationRateDefault: number;
persons: PersonInput[];
}
export interface IncomeEntryInput {
id: string;
personId: string | null;
label: string | null;
amount: number;
}
export interface ExpenseEntryInput {
id: string;
label: string | null;
amount: number;
}
export interface SecurityInput {
id: string;
name: string;
startValue: number;
expectedReturn: number;
annualContribution: number;
ownerTag: OwnerTag;
saleTaxRate: number;
}
export interface RealEstateInput {
id: string;
name: string;
marketValue: number;
mortgage: number;
valueGrowth: number;
amortization: number;
salePrice: number | null;
saleTaxRate: number;
}
export interface OneTimeEventInput {
id: string;
type: OneTimeEventType;
amount: number;
description: string | null;
}
export interface RetirementInfoInput {
id: string;
personId: string;
ahvAmount: number;
pkPensionAmount: number;
lumpSumAmount: number;
lumpSumTaxRate: number;
}
export interface PhaseInput {
id: string;
sequenceNumber: number;
name: string;
durationYears: number;
inflationRate: number | null;
incomeMode: IncomeMode;
incomeEntries: IncomeEntryInput[];
expenseEntries: ExpenseEntryInput[];
securities: SecurityInput[];
realEstates: RealEstateInput[];
oneTimeEvents: OneTimeEventInput[];
retirementInfos: RetirementInfoInput[];
}
export interface PlanInput {
id: string;
name: string;
parentPlanId: string | null;
branchFromPhaseId: string | null;
phases: PhaseInput[];
}