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
Deploy App / deploy (push) Successful in 1m57s
This commit is contained in:
+510
-264
@@ -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");
|
||||
}
|
||||
|
||||
+16
-5
@@ -1,7 +1,18 @@
|
||||
// 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;
|
||||
// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch
|
||||
// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt.
|
||||
|
||||
// Faktor fuer die Plafonierung der AHV-Rente bei Ehepaaren (TDD Kapitel 3.4).
|
||||
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente (2'520/Monat × 13 = 32'760).
|
||||
// Quelle: BSV / AHV-IV 2026.
|
||||
export const AHV_MAX_ANNUAL_SINGLE = 32760;
|
||||
|
||||
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
|
||||
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
|
||||
export const AHV_COUPLE_CAP_FACTOR = 1.5;
|
||||
|
||||
// Volle Beitragsdauer fuer eine ungekuerzte AHV-Rente (Rentenskala 44). Pro fehlendes
|
||||
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekuerzt.
|
||||
export const AHV_FULL_CONTRIBUTION_YEARS = 44;
|
||||
|
||||
// Maximaler jaehrlicher Saeule-3a-Beitrag fuer PK-Versicherte (2026). Wird in
|
||||
// 100er-Schritten erfasst (nicht 1'000er wie andere Betraege).
|
||||
export const PILLAR_3A_MAX_ANNUAL = 7258;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Typ- und Validierungs-Layer fuer die finanziellen Elemente. Die kategorie- und
|
||||
// kontextspezifischen Felder liegen in der DB als JSON; hier werden sie typisiert und
|
||||
// (an der API-Grenze) mit Zod validiert.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export type ElementCategory =
|
||||
| "INCOME"
|
||||
| "EXPENSE"
|
||||
| "AHV"
|
||||
| "PENSION_FUND"
|
||||
| "PILLAR_3A"
|
||||
| "REAL_ESTATE"
|
||||
| "OTHER_ASSET"
|
||||
| "OTHER_DEBT";
|
||||
|
||||
export type OwnerRole = "PERSON_A" | "PERSON_B" | "HOUSEHOLD";
|
||||
|
||||
// Kategorien, deren Element zwingend genau einer Person zugeordnet ist.
|
||||
export const PERSON_ONLY_CATEGORIES: ElementCategory[] = ["INCOME", "AHV", "PENSION_FUND", "PILLAR_3A"];
|
||||
|
||||
// Kategorien, die gemeinsam ODER pro Person erfasst werden koennen.
|
||||
export const OWNER_OPTIONAL_CATEGORIES: ElementCategory[] = ["EXPENSE", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT"];
|
||||
|
||||
export const CATEGORY_LABELS: Record<ElementCategory, string> = {
|
||||
INCOME: "Einkommen",
|
||||
EXPENSE: "Ausgaben",
|
||||
AHV: "AHV",
|
||||
PENSION_FUND: "Pensionskasse",
|
||||
PILLAR_3A: "Saeule 3a",
|
||||
REAL_ESTATE: "Immobilie",
|
||||
OTHER_ASSET: "Sonstiges Vermoegen",
|
||||
OTHER_DEBT: "Sonstige Schulden",
|
||||
};
|
||||
|
||||
// Reihenfolge der Kategorien in der Matrix (Gruppierung der Zeilen).
|
||||
export const CATEGORY_ORDER: ElementCategory[] = [
|
||||
"INCOME",
|
||||
"EXPENSE",
|
||||
"AHV",
|
||||
"PENSION_FUND",
|
||||
"PILLAR_3A",
|
||||
"REAL_ESTATE",
|
||||
"OTHER_ASSET",
|
||||
"OTHER_DEBT",
|
||||
];
|
||||
|
||||
// --- Roh-Payloads (JSON in der DB) ---
|
||||
// Bewusst tolerant getippt (alle Felder optional): die Berechnung liest defensiv mit
|
||||
// Defaults, das UI zeigt je nach Kontext nur die relevanten Felder.
|
||||
|
||||
export interface PhaseData {
|
||||
// INCOME / EXPENSE
|
||||
amount?: number;
|
||||
// AHV
|
||||
gapYears?: number;
|
||||
// PENSION_FUND / PILLAR_3A / OTHER_ASSET
|
||||
currentValue?: number;
|
||||
startValue?: number;
|
||||
expectedReturn?: number;
|
||||
annualContribution?: number;
|
||||
// REAL_ESTATE
|
||||
purchasePrice?: number;
|
||||
mortgage?: number;
|
||||
amortization?: number;
|
||||
// OTHER_DEBT
|
||||
annualRepayment?: number;
|
||||
}
|
||||
|
||||
export type TransitionDecision = "HOLD" | "SELL";
|
||||
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
|
||||
|
||||
export interface TransitionData {
|
||||
// PENSION_FUND / PILLAR_3A (normaler Uebergang)
|
||||
withdrawal?: number;
|
||||
// PENSION_FUND (Pensions-Uebergang)
|
||||
payoutMode?: PkPayoutMode;
|
||||
capitalAmount?: number;
|
||||
conversionRate?: number;
|
||||
// PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Uebergang) / REAL_ESTATE
|
||||
capitalTaxRate?: number;
|
||||
saleTaxRate?: number;
|
||||
// REAL_ESTATE / OTHER_ASSET
|
||||
decision?: TransitionDecision;
|
||||
salePrice?: number;
|
||||
// OTHER_DEBT
|
||||
immediateRepayment?: number;
|
||||
}
|
||||
|
||||
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
|
||||
|
||||
const nonNeg = z.number().min(0);
|
||||
|
||||
export const phaseDataSchema = z
|
||||
.object({
|
||||
amount: nonNeg.optional(),
|
||||
gapYears: z.number().int().min(0).optional(),
|
||||
currentValue: nonNeg.optional(),
|
||||
startValue: nonNeg.optional(),
|
||||
expectedReturn: z.number().min(-50).max(100).optional(),
|
||||
annualContribution: nonNeg.optional(),
|
||||
purchasePrice: nonNeg.optional(),
|
||||
mortgage: nonNeg.optional(),
|
||||
amortization: nonNeg.optional(),
|
||||
annualRepayment: nonNeg.optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export const transitionDataSchema = z
|
||||
.object({
|
||||
withdrawal: nonNeg.optional(),
|
||||
payoutMode: z.enum(["CAPITAL", "PENSION", "COMBI"]).optional(),
|
||||
capitalAmount: nonNeg.optional(),
|
||||
conversionRate: z.number().min(0).max(20).optional(),
|
||||
capitalTaxRate: z.number().min(0).max(100).optional(),
|
||||
saleTaxRate: z.number().min(0).max(100).optional(),
|
||||
decision: z.enum(["HOLD", "SELL"]).optional(),
|
||||
salePrice: nonNeg.optional(),
|
||||
immediateRepayment: nonNeg.optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export function num(value: number | undefined | null, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
+41
-60
@@ -1,25 +1,18 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
||||
import type { PhaseData, TransitionData } from "@/lib/elements";
|
||||
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" },
|
||||
phases: { orderBy: { sequenceNumber: "asc" } },
|
||||
elements: {
|
||||
orderBy: { orderIndex: "asc" },
|
||||
include: { phaseValues: true, transitionValues: true },
|
||||
},
|
||||
} 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 {
|
||||
@@ -36,63 +29,44 @@ export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInpu
|
||||
};
|
||||
}
|
||||
|
||||
function parsePhaseData(raw: unknown): PhaseData {
|
||||
const parsed = phaseDataSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : {};
|
||||
}
|
||||
|
||||
function parseTransitionData(raw: unknown): TransitionData {
|
||||
const parsed = transitionDataSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : {};
|
||||
}
|
||||
|
||||
export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
return {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
parentPlanId: plan.parentPlanId,
|
||||
branchFromPhaseId: plan.branchFromPhaseId,
|
||||
retirementAgeA: plan.retirementAgeA,
|
||||
retirementAgeB: plan.retirementAgeB,
|
||||
phases: plan.phases.map((phase) => ({
|
||||
id: phase.id,
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
inflationRate: phase.inflationRate,
|
||||
incomeMode: phase.incomeMode,
|
||||
incomingCapital: phase.incomingCapital,
|
||||
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,
|
||||
carriedBaseValue: s.carriedBaseValue,
|
||||
})),
|
||||
realEstates: phase.realEstates.map((re) => ({
|
||||
id: re.id,
|
||||
name: re.name,
|
||||
purchasePrice: re.purchasePrice,
|
||||
mortgage: re.mortgage,
|
||||
amortization: re.amortization,
|
||||
})),
|
||||
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,
|
||||
})),
|
||||
})),
|
||||
elements: plan.elements.map((e) => {
|
||||
const phaseValues: Record<string, PhaseData> = {};
|
||||
for (const pv of e.phaseValues) phaseValues[pv.phaseId] = parsePhaseData(pv.data);
|
||||
const transitionValues: Record<string, TransitionData> = {};
|
||||
for (const tv of e.transitionValues) transitionValues[tv.fromPhaseId] = parseTransitionData(tv.data);
|
||||
return {
|
||||
id: e.id,
|
||||
category: e.category,
|
||||
name: e.name,
|
||||
ownerRole: e.ownerRole,
|
||||
orderIndex: e.orderIndex,
|
||||
phaseValues,
|
||||
transitionValues,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,7 +75,7 @@ export async function getHouseholdOrNull(userId: string): Promise<HouseholdWithP
|
||||
return prisma.household.findFirst({ where: { userId }, include: { persons: true } });
|
||||
}
|
||||
|
||||
// Laedt einen Plan inkl. aller Phasen, aber nur wenn er dem Benutzer gehoert.
|
||||
// Laedt einen Plan inkl. Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
|
||||
export async function getOwnedPlan(planId: string, userId: string) {
|
||||
return prisma.plan.findFirst({
|
||||
where: { id: planId, household: { userId } },
|
||||
@@ -115,3 +89,10 @@ export async function getOwnedPhase(phaseId: string, userId: string) {
|
||||
where: { id: phaseId, plan: { household: { userId } } },
|
||||
});
|
||||
}
|
||||
|
||||
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
|
||||
export async function getOwnedElement(elementId: string, userId: string) {
|
||||
return prisma.financialElement.findFirst({
|
||||
where: { id: elementId, plan: { household: { userId } } },
|
||||
});
|
||||
}
|
||||
|
||||
+19
-70
@@ -1,19 +1,16 @@
|
||||
// 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.
|
||||
// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten
|
||||
// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt.
|
||||
|
||||
import type { ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
|
||||
|
||||
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;
|
||||
// Bereits aufgeloestes Pensionsalter (Plan-Override oder Profil-Default).
|
||||
retirementAge: number;
|
||||
}
|
||||
|
||||
@@ -24,78 +21,30 @@ export interface HouseholdInput {
|
||||
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;
|
||||
// Baseline-Wert bei automatischer Uebernahme aus der Vorphase (0 bei manuell angelegten
|
||||
// Wertschriften). Siehe PhaseInput.incomingCapital fuer den Kontext.
|
||||
carriedBaseValue: number;
|
||||
}
|
||||
|
||||
export interface RealEstateInput {
|
||||
id: string;
|
||||
name: string;
|
||||
purchasePrice: number;
|
||||
mortgage: number;
|
||||
amortization: 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;
|
||||
// Aus Verkaeufen im Uebergang aus der Vorphase verfuegbares Startkapital (automatisch
|
||||
// gesetzt beim Speichern des Uebergangs der Vorphase).
|
||||
incomingCapital: number;
|
||||
incomeEntries: IncomeEntryInput[];
|
||||
expenseEntries: ExpenseEntryInput[];
|
||||
securities: SecurityInput[];
|
||||
realEstates: RealEstateInput[];
|
||||
oneTimeEvents: OneTimeEventInput[];
|
||||
retirementInfos: RetirementInfoInput[];
|
||||
}
|
||||
|
||||
export interface ElementInput {
|
||||
id: string;
|
||||
category: ElementCategory;
|
||||
name: string;
|
||||
ownerRole: OwnerRole | null;
|
||||
orderIndex: number;
|
||||
// Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId).
|
||||
phaseValues: Record<string, PhaseData>;
|
||||
transitionValues: Record<string, TransitionData>;
|
||||
}
|
||||
|
||||
export interface PlanInput {
|
||||
id: string;
|
||||
name: string;
|
||||
parentPlanId: string | null;
|
||||
branchFromPhaseId: string | null;
|
||||
retirementAgeA: number | null;
|
||||
retirementAgeB: number | null;
|
||||
phases: PhaseInput[];
|
||||
elements: ElementInput[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user