// PDF-Bericht (Roadmap Nr. 11) -- das MODELL, ohne PDF-Kenntnisse. // // Diese Datei baut aus Plan, Berechnung und Konfiguration eine vollständig aufgelöste, // druckfertige Struktur aus Zahlen und Texten. Das Zeichnen macht `report-pdf.ts`. // Die Trennung hat zwei Gründe: Das Modell ist ohne PDF-Bibliothek testbar, und es ist // zugleich das, was eingefroren wird -- der Bericht ändert sich nicht mehr, wenn der Plan // später weiterentwickelt wird. // // Grundsätze aus der Recherche (siehe SPEZIFIKATION 3.11): // * Zusammenfassung zuerst, Details danach -- Überkomplexität ist die häufigste Kritik // an Beraterberichten. // * Annahmen EINMAL zentral, nicht bei jeder Kennzahl wiederholt; die Kennzahlen // verweisen darauf. // * Transparenz zählt so viel wie das Ergebnis: Systemparameter und Methodik gehören // in den Anhang, ein Haftungsausschluss ist Pflicht. import { computePlan } from "@/lib/calculations"; import { decisionsText, totalOpenDecisions } from "@/lib/decisions"; import { num } from "@/lib/elements"; import { formatChf } from "@/lib/format"; import { resolveActuals, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals"; import type { PlanComputed } from "@/lib/calculations"; import type { PlanInput } from "@/lib/types"; export type ReportMetric = "nominal" | "real"; export type ReportSource = "PLAN" | "ACTUAL"; // Höchstens drei Szenarien je Bericht: Darüber wird die Vergleichstabelle unlesbar, und // vergleichbare Werkzeuge stellen bewusst nur zwei gegenüber. export const MAX_REPORT_SCENARIOS = 3; export interface ReportConfig { title: string; metric: ReportMetric; source: ReportSource; scenarioIds: string[]; analysisIds: string[]; comment?: string | null; } export interface KeyFigure { label: string; value: string; // Kurzer Hinweis auf die zugrunde liegende Annahme -- der Verweis auf den zentralen // Annahmen-Abschnitt, ohne ihn zu wiederholen. basis?: string; tone?: "danger" | "success"; } export interface ReportTable { columns: string[]; rows: (string | number)[][]; } export interface ReportChart { title: string; series: { label: string; dashed?: boolean; points: { x: number; y: number }[] }[]; xLabel: string; } export interface ReportScenario { name: string; isBase: boolean; keyFigures: KeyFigure[]; phases: ReportTable; chart: ReportChart; assumptions: { title: string; rows: [string, string][] }[]; openDecisions: number; } export interface ReportModel { meta: { planName: string; title: string; createdAt: string; author: string; metricLabel: string; sourceLabel: string; comment?: string | null; }; household: { rows: [string, string][] }; summary: { figures: KeyFigure[]; statements: string[] }; scenarios: ReportScenario[]; comparison?: ReportTable; actuals?: { rows: [string, string][]; note: string }; analyses: { name: string; type: string; params: [string, string][]; table?: ReportTable }[]; disclaimer: string[]; } const DISCLAIMER = [ "Dieser Bericht ist eine rechnerische PROJEKTION auf Basis der von dir erfassten Annahmen. Er ist keine Anlage-, Steuer- oder Vorsorgeberatung und ersetzt keine Fachberatung.", "Alle Zukunftswerte beruhen auf Annahmen zu Renditen, Inflation, Lohnentwicklung und Lebensdauer. Treffen diese nicht ein, weicht das tatsächliche Ergebnis ab – möglicherweise erheblich.", "Die Berechnung vereinfacht bewusst: Steuern werden nur dort berücksichtigt, wo ausgewiesen (Kapitalbezugs- und Grundstückgewinnsteuer). Eine laufende Einkommens- und Vermögenssteuer ist NICHT modelliert.", "Wahrscheinlichkeiten aus der Monte-Carlo-Simulation messen die Streuung UM die getroffenen Annahmen – nicht, ob die Annahmen selbst zutreffen.", ]; // --- Hilfsgrössen ----------------------------------------------------------------------- function pick(computed: PlanComputed, metric: ReportMetric, phaseIndex: number): number { const p = computed.phases[phaseIndex]; if (!p) return 0; return Math.round(metric === "real" ? p.endWealthReal : p.endWealthNominal); } // Vermögen im Jahr der Pensionierung von Person A -- ein Meilenstein, den man in fast jedem // Beratungsbericht findet. function wealthAtRetirement(plan: PlanInput, computed: PlanComputed, metric: ReportMetric): number | null { const a = plan.persons.find((p) => p.role === "PERSON_A") ?? plan.persons[0]; if (!a) return null; const point = computed.yearly.find((y) => y.age >= a.retirementAge); if (!point) return null; return Math.round(metric === "real" ? point.wealthReal : point.wealthNominal); } // Laufende Jahresrente (AHV bzw. verrentete PK) in der letzten Phase. function annualPensionOf(computed: PlanComputed, category: "AHV" | "PENSION_FUND"): number { const last = computed.phases[computed.phases.length - 1]; if (!last) return 0; return Math.round( last.elements.filter((e) => e.category === category).reduce((s, e) => s + Math.max(0, e.startValue), 0) ); } // Das grösste Vorsorgekapital zum Pensionierungszeitpunkt (PK + 3a), nominal. function capitalAtRetirement(plan: PlanInput, computed: PlanComputed): number { const a = plan.persons.find((p) => p.role === "PERSON_A") ?? plan.persons[0]; if (!a) return 0; const year = computed.yearly.find((y) => y.age >= a.retirementAge)?.year; if (!year) return 0; let total = 0; for (const ph of computed.phases) { for (const el of ph.elements) { if (el.category !== "PENSION_FUND" && el.category !== "PILLAR_3A") continue; const pt = el.yearly.find((y) => y.year === year); if (pt) total += Math.max(0, pt.value); } } return Math.round(total); } // --- Annahmen ---------------------------------------------------------------------------- // Zentral je Szenario, damit die Kennzahlen nur noch darauf verweisen müssen. function assumptionsOf(plan: PlanInput): { title: string; rows: [string, string][] }[] { const out: { title: string; rows: [string, string][] }[] = []; const firstPhaseId = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber)[0]?.id; out.push({ title: "Plan-weit", rows: [ ["Inflation", `${plan.inflationRateDefault} % pro Jahr`], ["Cash zu Planbeginn", formatChf(Math.round(plan.initialCash || 0))], ...plan.persons.map( (p) => [ `Pensionsalter ${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")}`, `${p.retirementAge} Jahre`, ] as [string, string] ), ], }); // Startwerte und Raten je Element -- genau die Zahlen, mit denen gerechnet wurde. const rows: [string, string][] = []; for (const el of [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex)) { const pd = firstPhaseId ? el.phaseValues[firstPhaseId] ?? {} : {}; const parts: string[] = []; if (typeof pd.amount === "number") parts.push(`${formatChf(Math.round(pd.amount))}/Jahr`); if (typeof pd.startValue === "number") parts.push(`Start ${formatChf(Math.round(pd.startValue))}`); if (typeof pd.currentValue === "number") parts.push(`Start ${formatChf(Math.round(pd.currentValue))}`); if (typeof pd.purchasePrice === "number") parts.push(`Kaufpreis ${formatChf(Math.round(pd.purchasePrice))}`); if (typeof pd.mortgage === "number") parts.push(`Hypothek ${formatChf(Math.round(pd.mortgage))}`); if (pd.expectedReturn != null) parts.push(`Rendite ${num(pd.expectedReturn)} %`); if (pd.valueGrowth != null) parts.push(`Wertsteigerung ${num(pd.valueGrowth)} %`); if (pd.interestRate != null) parts.push(`Zins ${num(pd.interestRate)} %`); if (pd.teuerungsausgleich != null) parts.push(`Anpassung ${num(pd.teuerungsausgleich)} %/Jahr`); if (pd.annualContribution != null && num(pd.annualContribution) !== 0) parts.push(`Sparrate ${formatChf(Math.round(num(pd.annualContribution)))}`); if (pd.annualWithdrawal != null && num(pd.annualWithdrawal) !== 0) parts.push(`Bezug ${formatChf(Math.round(num(pd.annualWithdrawal)))}`); if (parts.length > 0) rows.push([el.name, parts.join(" · ")]); } if (rows.length > 0) out.push({ title: "Elemente (Werte der ersten Lebensphase)", rows }); return out; } // --- Szenario ---------------------------------------------------------------------------- function buildScenario( plan: PlanInput, computed: PlanComputed, name: string, isBase: boolean, metric: ReportMetric ): ReportScenario { const lastIndex = computed.phases.length - 1; const end = pick(computed, metric, lastIndex); const atRet = wealthAtRetirement(plan, computed, metric); const ahv = annualPensionOf(computed, "AHV"); const pkPension = annualPensionOf(computed, "PENSION_FUND"); const capital = capitalAtRetirement(plan, computed); const open = totalOpenDecisions(plan, computed); const figures: KeyFigure[] = [ { label: `Endvermögen (${metric === "real" ? "real" : "nominal"})`, value: formatChf(end), basis: `Renditen und Inflation ${plan.inflationRateDefault} % laut Annahmen`, }, { label: "Kapital reicht", value: computed.ruinAge === null ? "bis Planende" : `bis Alter ${computed.ruinAge}`, tone: computed.ruinAge === null ? "success" : "danger", basis: "Gesamtvermögen inkl. Cash, abzüglich Schulden", }, ]; if (atRet !== null) { figures.push({ label: "Vermögen bei Pensionierung", value: formatChf(atRet), basis: "Stand im Jahr, in dem Person A das Pensionsalter erreicht", }); } if (capital > 0) { figures.push({ label: "Vorsorgekapital bei Pensionierung", value: formatChf(capital), basis: "PK und Säule 3a, vor Bezug/Verrentung", }); } if (ahv > 0) { figures.push({ label: "AHV-Rente pro Jahr", value: formatChf(ahv), basis: "amtliche Rentenformel (Skala 44) aus der Beitragskarriere", }); } if (pkPension > 0) { figures.push({ label: "PK-Rente pro Jahr", value: formatChf(pkPension), basis: "Umwandlungssatz laut Systemparametern" }); } const openTotal = open.open + open.unconfirmed; figures.push({ label: "Offene Entscheide", value: openTotal === 0 ? "keine" : decisionsText(open), tone: openTotal === 0 ? "success" : undefined, basis: "noch nicht getroffene Übergangs-Entscheide sowie Pensionierungs-Vorgaben, die nie bestätigt wurden", }); const phases: ReportTable = { columns: ["Lebensphase", "Dauer", "Einkommen", "Ausgaben", "Vermögen am Ende"], rows: computed.phases.map((p) => [ p.name, `${p.durationYears} J.`, formatChf(Math.round(p.incomeStart)), formatChf(Math.round(p.expenseStart)), formatChf(Math.round(metric === "real" ? p.endWealthReal : p.endWealthNominal)), ]), }; return { name, isBase, keyFigures: figures, phases, chart: { title: `Vermögensverlauf (${metric === "real" ? "real" : "nominal"})`, xLabel: "Alter", series: [ { label: name, points: computed.yearly.map((y) => ({ x: y.age, y: metric === "real" ? y.wealthReal : y.wealthNominal })), }, ], }, assumptions: assumptionsOf(plan), openDecisions: open.open + open.unconfirmed, }; } // --- Gesamtmodell ------------------------------------------------------------------------ export interface ReportScenarioInput { name: string; isBase: boolean; plan: PlanInput; } export interface BuildReportInput { planName: string; author: string; createdAt: Date; config: ReportConfig; scenarios: ReportScenarioInput[]; household: { householdType: "SINGLE" | "COUPLE"; startYear: number | null; persons: { name: string | null; age: number; role: string }[] }; actuals: ActualsSetInput[]; origins: ElementOrigin[]; analyses: { name: string; type: string; result: unknown }[]; } export function buildReport(input: BuildReportInput): ReportModel { const { config } = input; const chosen = input.scenarios.slice(0, MAX_REPORT_SCENARIOS); // Je Szenario die massgebende Rechnung: mit oder ohne effektive Werte. const computedByScenario = chosen.map((s) => { const resolved = config.source === "ACTUAL" ? resolveActuals(input.actuals, s.plan, input.origins) : []; return { ...s, computed: computePlan(s.plan, undefined, resolved.length > 0 ? { actuals: resolved } : undefined), planOnly: computePlan(s.plan), usedActuals: resolved.length, }; }); const scenarios = computedByScenario.map((s) => buildScenario(s.plan, s.computed, s.name, s.isBase, config.metric)); // Leitszenario für die Zusammenfassung: das Basisszenario, sonst das erste. const lead = computedByScenario.find((s) => s.isBase) ?? computedByScenario[0]; const leadScenario = scenarios.find((s) => s.isBase) ?? scenarios[0]; const statements: string[] = []; if (lead) { const end = pick(lead.computed, config.metric, lead.computed.phases.length - 1); statements.push( `Nach ${lead.computed.phases.reduce((s, p) => s + p.durationYears, 0)} Planjahren ergibt sich im Szenario «${lead.name}» ein Endvermögen von ${formatChf(end)} (${config.metric === "real" ? "real, heutige Kaufkraft" : "nominal"}).` ); statements.push( lead.computed.ruinAge === null ? "Das Kapital reicht über den gesamten Planungszeitraum." : `Achtung: Das Gesamtvermögen fällt im Alter ${lead.computed.ruinAge} unter null – die Planung trägt nicht bis ans Ende.` ); const open = leadScenario?.openDecisions ?? 0; statements.push( open === 0 ? "Alle Übergangs-Entscheide zwischen den Lebensphasen sind getroffen." : `${open} Übergangs-Entscheid${open === 1 ? " ist" : "e sind"} noch offen – bis dahin rechnet das Tool mit Vorgabewerten.` ); } // Vergleichstabelle nur, wenn es überhaupt etwas zu vergleichen gibt. const comparison: ReportTable | undefined = chosen.length > 1 ? { columns: ["Szenario", `Endvermögen (${config.metric === "real" ? "real" : "nominal"})`, "Kapital reicht", "Offene Entscheide"], rows: computedByScenario.map((s, i) => [ s.name + (s.isBase ? " (Basis)" : ""), formatChf(pick(s.computed, config.metric, s.computed.phases.length - 1)), s.computed.ruinAge === null ? "bis Planende" : `bis Alter ${s.computed.ruinAge}`, scenarios[i].openDecisions, ]), } : undefined; // Plan/Ist nur, wenn effektive Werte gewählt UND vorhanden sind. let actualsBlock: ReportModel["actuals"]; if (config.source === "ACTUAL" && lead && lead.usedActuals > 0) { const withActuals = pick(lead.computed, config.metric, lead.computed.phases.length - 1); const planOnly = pick(lead.planOnly, config.metric, lead.planOnly.phases.length - 1); const years = resolveActuals(input.actuals, lead.plan, input.origins).map((r) => r.year); actualsBlock = { rows: [ ["Erfasste Stichtage", years.join(", ")], ["Endvermögen laut Plan", formatChf(planOnly)], ["Endvermögen mit effektiven Werten", formatChf(withActuals)], ["Abweichung", `${withActuals - planOnly >= 0 ? "+" : "−"}${formatChf(Math.abs(withActuals - planOnly))}`], ], note: "Die Berechnung springt in jedem erfassten Jahr auf die tatsächlichen Werte und läuft von dort mit den Planannahmen weiter. Elemente ohne erfassten Wert bleiben auf ihrer Planlinie.", }; } return { meta: { planName: input.planName, title: config.title, createdAt: input.createdAt.toISOString(), author: input.author, metricLabel: config.metric === "real" ? "real (heutige Kaufkraft)" : "nominal", sourceLabel: config.source === "ACTUAL" ? "Plandaten mit effektiven Werten" : "Plandaten", comment: config.comment ?? null, }, household: { rows: [ ["Haushaltsform", input.household.householdType === "COUPLE" ? "Paar" : "Einzelperson"], ...input.household.persons.map( (p) => [p.name || (p.role === "PERSON_A" ? "Person A" : "Person B"), `${p.age} Jahre`] as [string, string] ), ["Planstart", input.household.startYear ? String(input.household.startYear) : "nicht gesetzt"], ], }, summary: { figures: leadScenario ? leadScenario.keyFigures : [], statements }, scenarios, comparison, actuals: actualsBlock, analyses: input.analyses.map((a) => { const r = (a.result ?? {}) as { params?: { label: string; value: string }[]; table?: ReportTable }; return { name: a.name, type: a.type, params: (r.params ?? []).map((p) => [p.label, p.value] as [string, string]), table: r.table, }; }), disclaimer: DISCLAIMER, }; }