import { AHV_COUPLE_CAP_FACTOR, AHV_FULL_CONTRIBUTION_YEARS, AHV_MAX_ANNUAL_SINGLE, } from "@/lib/constants"; import { num } from "@/lib/elements"; import type { ElementCategory } from "@/lib/elements"; import type { PersonRole, PlanInput } from "@/lib/types"; 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 ElementPhaseComputed { elementId: string; category: ElementCategory; name: string; ownerRole: string | null; status: ElementStatus; locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar carried: boolean; // Startwert wird aus der Vorphase fortgeschrieben (Phase >= 2) 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/Vermoegen/Amort./Tilgung) 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 { id: string; name: string; sequenceNumber: number; durationYears: number; type: PhaseType; persons: PersonPhaseInfo[]; maxDurationYears: number | null; // Kappung ans naechste Pensionsereignis (null = unbegrenzt) incomeTotal: number; expenseTotal: number; quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0) isConsumption: boolean; quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege quotaRemaining: number; // |quota| - quotaAllocated (offener Rest, kann negativ = ueberzogen) quotaComplete: boolean; availableCapital: number | null; // null in der ersten Phase availableCapitalUsed: number; availableCapitalRemaining: number; // 0 in der ersten Phase availableCapitalComplete: boolean; incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt) elements: ElementPhaseComputed[]; startWealthNominal: number; endWealthNominal: number; cumulativeInflationEnd: number; endWealthReal: number; } export interface PlanComputed { phases: PhaseComputed[]; nachlass: number; } // 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 }[], yearsBefore: number ): number | null { const caps: number[] = []; for (const p of persons) { const startAge = p.age + yearsBefore; if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge); } return caps.length > 0 ? Math.min(...caps) : null; } // 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 }; } // Zinseszins mit jaehrlichem Beitrag; kein Zwischen-Runden mehr (1'000er-Konzept entfernt), // nur das Endresultat wird auf ganze Franken gerundet. function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number { let v = startValue; for (let y = 0; y < years; y++) { v = v * (1 + expectedReturn / 100) + annual; } return Math.max(0, Math.round(v)); } export function computePlan(plan: PlanInput): PlanComputed { const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); const persons = plan.persons; // Pensionsalter je Person (liegt direkt am plan-eigenen Personensatz). const retirementAge = new Map(); for (const p of persons) retirementAge.set(p.id, p.retirementAge); // Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert). const gapYearsByPerson = new Map(); // Carry-Zustand je Element. const carries = new Map(); for (const e of plan.elements) carries.set(e.id, emptyCarry()); 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(); 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, Math.round(AHV_MAX_ANNUAL_SINGLE * factor)); } const ahvFinal = new Map(ahvUncapped); if (plan.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, Math.round(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", carried: carry.hasCarry, 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 = Math.round(num(pd.amount)); ec.incomeContribution = amount; incomeTotal += amount; ec.summary = fmt(amount); break; } case "EXPENSE": { const amount = Math.round(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 base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; const contribution = Math.round(num(pd.annualContribution)); const r = num(pd.expectedReturn); ec.startValue = start; ec.endValue = growAsset(start, r, contribution, phase.durationYears); ec.capitalUse = topUp; capitalUsed += topUp; // 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 base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; const contribution = Math.round(num(pd.annualContribution)); const r = num(pd.expectedReturn); ec.startValue = start; ec.endValue = growAsset(start, r, contribution, phase.durationYears); ec.capitalUse = topUp; capitalUsed += topUp; ec.quotaUse = contribution; // zaehlt gegen die Sparquote quotaAllocated += contribution; ec.summary = fmt(ec.endValue); } break; } case "OTHER_ASSET": { const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; const contribution = Math.round(num(pd.annualContribution)); const r = num(pd.expectedReturn); ec.startValue = start; ec.capitalUse = topUp; capitalUsed += topUp; // In Erwerbsphasen Sparbeitrag, in Verzehrphasen Bezugsrate -- beides zaehlt gegen // die Quote (Vorzeichen ergibt sich aus der Phasenquote). ec.quotaUse = contribution; quotaAllocated += contribution; ec.endValue = growAsset(start, r, contribution, phase.durationYears); ec.summary = fmt(ec.endValue); break; } case "REAL_ESTATE": { const purchase = Math.round(num(pd.purchasePrice)); const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage)); const amort = Math.round(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; } // Amortisation ist quotenwirksam (jaehrlicher Budgetbetrag). ec.quotaUse = amort; quotaAllocated += amort; carry.mortgage = mortgageEnd; // fuer Uebergang ec.summary = fmt(ec.endValue); break; } case "OTHER_DEBT": { const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue)); const repay = Math.round(num(pd.annualRepayment)); const owedEnd = Math.max(0, owedStart - repay * phase.durationYears); ec.startValue = -owedStart; ec.endValue = -owedEnd; carry.owed = owedEnd; // Tilgung ist quotenwirksam (jaehrlicher Budgetbetrag). ec.quotaUse = repay; quotaAllocated += repay; 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 quotaRemaining = quotaTarget - quotaAllocated; const quotaComplete = Math.abs(quotaRemaining) < 1; const availableCapital = incomingCapital; const availableCapitalUsed = capitalUsed; const availableCapitalRemaining = availableCapital === null ? 0 : availableCapital - availableCapitalUsed; const availableCapitalComplete = availableCapital === null || Math.abs(availableCapitalRemaining) < 1; const incomplete = !quotaComplete || !availableCapitalComplete; const inflationRate = phase.inflationRate ?? plan.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, quotaRemaining, quotaComplete, availableCapital, availableCapitalUsed, availableCapitalRemaining, 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, 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 = Math.round(value * (1 - num(td.capitalTaxRate) / 100)); outgoing += net; carry.value = 0; carry.pkPensionAnnual = 0; } else if (mode === "PENSION") { carry.pkPensionAnnual = Math.round((value * num(td.conversionRate)) / 100); carry.value = 0; } else { const capital = Math.min(value, Math.round(num(td.capitalAmount))); const net = Math.round(capital * (1 - num(td.capitalTaxRate) / 100)); outgoing += net; carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate)) / 100); carry.value = 0; } } else { const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); carry.value = ec.endValue - withdrawal; outgoing += withdrawal; } break; } case "PILLAR_3A": { if (ownerRetiresNext) { const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate) / 100)); outgoing += net; carry.value = 0; } else { const withdrawal = Math.min(ec.endValue, Math.round(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 = Math.round(num(e.phaseValues[phase.id]?.purchasePrice)); const salePrice = Math.round(num(td.salePrice)); const gain = Math.max(0, salePrice - purchase); const tax = gain * (num(td.saleTaxRate) / 100); outgoing += Math.round(salePrice - carry.mortgage - tax); carry.status = "SOLD"; } // HOLD: carry.mortgage bereits gesetzt. break; } case "OTHER_DEBT": { const immediate = Math.min(carry.owed, Math.round(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 ? Math.round(outgoing) : null; yearsBefore += phase.durationYears; } const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0; return { phases: result, nachlass }; } function personByRole(persons: { id: string; role: PersonRole }[], role: string) { return persons.find((p) => p.role === role) ?? null; } // Prueft, ob eine Person mit dem gegebenen Jahres-Offset (zu Beginn der Folgephase) pensioniert // ist, obwohl sie in der Vorphase noch erwerbend war. function retiresInPhase( personId: string, persons: { id: string; role: PersonRole; age: number }[], retirementAge: Map, 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 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", "Typ", "Dauer", "Einkommen", "Ausgaben", "Spar-/Verzehrquote", "Verfuegbares Kapital", "Endvermoegen (nominal)", "Endvermoegen (real)", ]; const rows = computed.phases.map((p) => [ p.name, p.type, String(p.durationYears), 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"); }