1865db5de7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
// 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,
|
||
};
|
||
}
|