PDF-Berichte (Roadmap 11)
Deploy App / deploy (push) Successful in 3m5s

Neuer Unterpunkt "Berichte" je Plan: Liste plus Assistent (Titel, Notiz,
nominal ODER real, Plan-/Ist-Daten, bis zu drei Szenarien, gespeicherte
Analysen). Layout immer gleich, Auswahl bestimmt nur die Bausteine.

Die PDF-Datei wird ALS DATEI abgelegt (BYTEA in Postgres, nicht im
Container-Dateisystem): Ein Bericht muss in drei Jahren byte-identisch
wieder herunterladbar sein -- eine Neuerzeugung koennte das nach
Aenderungen an Plan, Rechenkern oder Layout nicht garantieren.

Kennzahlen je Szenario inkl. offener Entscheide. Deren Zaehlung liegt neu
als reine Funktion in decisions.ts, die Matrix UND Bericht benutzen --
sonst nennen beide verschiedene Zahlen.

Zu jeder Kennzahl ihre Grundlage als Verweis; die vollstaendigen Annahmen
einmal je Szenario. Haftungsausschluss ist verpflichtend (per Test).

Technik: pdfkit in der Node-Runtime statt Headless-Browser;
@react-pdf/renderer bricht mit React 19. Als externes Paket deklariert,
weil pdfkit Font-Metriken ueber Dateipfade laedt.

Spezifikation 0.25 (3.11 neu), 9 Tests (212 -> 221).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 08:30:18 +02:00
parent 489390a4a3
commit d9ef980edf
17 changed files with 1988 additions and 47 deletions
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect } from "vitest";
import { buildReport, MAX_REPORT_SCENARIOS } from "@/lib/report";
import { renderReportPdf } from "@/lib/report-pdf";
import type { ActualsSetInput } from "@/lib/actuals";
import type { PlanInput } from "@/lib/types";
// Paar, 45/43, 20 Jahre Erwerb + 20 Jahre Pension, PK + ETF-Depot.
function plan(name: string, ret = 4): PlanInput {
return {
id: "s1",
name,
householdType: "COUPLE",
inflationRateDefault: 1.5,
initialCash: 20000,
startYear: 2026,
persons: [
{ id: "A", role: "PERSON_A", name: "Anna", age: 45, retirementAge: 65 },
{ id: "B", role: "PERSON_B", name: "Beat", age: 43, retirementAge: 64 },
],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{
id: "inc", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1,
phaseValues: { p1: { amount: 140000, teuerungsausgleich: 1 } }, transitionValues: {}, sourceElementId: null,
},
{
id: "exp", category: "EXPENSE", name: "Lebenshaltung", ownerRole: "HOUSEHOLD", orderIndex: 2,
phaseValues: { p1: { amount: 90000 }, p2: { amount: 80000 } }, transitionValues: {}, sourceElementId: null,
},
{
id: "pk", category: "PENSION_FUND", name: "Pensionskasse", ownerRole: "PERSON_A", orderIndex: 3,
phaseValues: { p1: { currentValue: 320000, expectedReturn: 2, annualContribution: 12000 } },
transitionValues: {}, sourceElementId: null,
},
{
id: "etf", category: "OTHER_ASSET", name: "ETF-Depot", ownerRole: "HOUSEHOLD", orderIndex: 4,
phaseValues: {
p1: { startValue: 180000, expectedReturn: ret, annualContribution: 9000 },
p2: { expectedReturn: ret, annualWithdrawal: 24000 },
},
transitionValues: {}, sourceElementId: null,
},
],
} as unknown as PlanInput;
}
function input(over: Partial<Parameters<typeof buildReport>[0]> = {}) {
return {
planName: "Meine Planung",
author: "kelle",
createdAt: new Date("2026-07-21T10:00:00Z"),
config: {
title: "Finanzplanung 2026",
metric: "nominal" as const,
source: "PLAN" as const,
scenarioIds: ["s1"],
analysisIds: [] as string[],
comment: null,
},
scenarios: [{ name: "Basisszenario", isBase: true, plan: plan("Basisszenario") }],
household: {
householdType: "COUPLE" as const,
startYear: 2026,
persons: [
{ name: "Anna", age: 45, role: "PERSON_A" },
{ name: "Beat", age: 43, role: "PERSON_B" },
],
},
actuals: [] as ActualsSetInput[],
origins: [] as { id: string; sourceElementId?: string | null }[],
analyses: [] as { name: string; type: string; result: unknown }[],
...over,
};
}
describe("buildReport", () => {
it("stellt die Zusammenfassung aus dem Basisszenario zusammen", () => {
const m = buildReport(input());
expect(m.summary.figures.length).toBeGreaterThan(3);
// Die drei Kernaussagen: Endvermögen, Reichweite, offene Entscheide.
expect(m.summary.statements).toHaveLength(3);
expect(m.summary.statements[0]).toContain("Endvermögen");
});
it("nennt zu JEDER Kennzahl die zugrunde liegende Annahme", () => {
// Der Kern der Anforderung: kein Ergebnis ohne seine Grundlage.
const m = buildReport(input());
for (const f of m.scenarios[0].keyFigures) {
expect(f.basis, `Kennzahl «${f.label}» ohne Basis-Angabe`).toBeTruthy();
}
});
it("führt die Annahmen zentral je Szenario -- mit den echten Startwerten", () => {
const m = buildReport(input());
const flat = m.scenarios[0].assumptions.flatMap((g) => g.rows.map(([k, v]) => `${k}: ${v}`)).join(" | ");
expect(flat).toContain("Inflation");
expect(flat).toContain("Pensionsalter Anna");
// Startwert und Rendite des Depots müssen ablesbar sein.
expect(flat).toMatch(/ETF-Depot.*Start.*Rendite 4 %/);
});
it("deckelt die Szenarien und vergleicht erst ab zwei", () => {
const one = buildReport(input());
expect(one.comparison).toBeUndefined();
const many = buildReport(
input({
scenarios: [
{ name: "Basis", isBase: true, plan: plan("Basis") },
{ name: "B", isBase: false, plan: plan("B", 3) },
{ name: "C", isBase: false, plan: plan("C", 2) },
{ name: "D", isBase: false, plan: plan("D", 1) },
],
})
);
expect(many.scenarios).toHaveLength(MAX_REPORT_SCENARIOS);
expect(many.comparison!.rows).toHaveLength(MAX_REPORT_SCENARIOS);
});
it("zeigt den Plan/Ist-Block nur, wenn effektive Werte gewählt UND vorhanden sind", () => {
const sets: ActualsSetInput[] = [
{ id: "a1", recordedOn: "2030-08-18", year: 2030, cash: null, values: { etf: { value: 400000 } } },
];
const p = plan("Basisszenario");
const origins = p.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }));
// Gewählt, aber keine Sätze -> kein Block.
expect(buildReport(input({ config: { ...input().config, source: "ACTUAL" } })).actuals).toBeUndefined();
// Sätze vorhanden, aber Plandaten gewählt -> kein Block.
expect(buildReport(input({ actuals: sets, origins })).actuals).toBeUndefined();
// Beides -> Block mit Abweichung.
const m = buildReport(input({ config: { ...input().config, source: "ACTUAL" }, actuals: sets, origins }));
expect(m.actuals).toBeDefined();
expect(m.actuals!.rows.map(([k]) => k)).toContain("Abweichung");
});
it("rechnet real, wenn real gewählt ist", () => {
const nominal = buildReport(input());
const real = buildReport(input({ config: { ...input().config, metric: "real" } }));
const val = (m: ReturnType<typeof buildReport>) =>
m.scenarios[0].keyFigures.find((f) => f.label.startsWith("Endvermögen"))!.value;
// Bei positiver Inflation liegt der Realwert unter dem nominalen.
expect(val(real)).not.toBe(val(nominal));
expect(real.meta.metricLabel).toContain("real");
});
it("trägt immer einen Haftungsausschluss", () => {
// Ein formal aussehendes PDF wird als Beratung gelesen -- das muss dagegenstehen.
const m = buildReport(input());
expect(m.disclaimer.length).toBeGreaterThan(2);
expect(m.disclaimer.join(" ")).toContain("keine Anlage-");
});
});
describe("renderReportPdf", () => {
it("erzeugt eine gültige, vollständige PDF-Datei", async () => {
const m = buildReport(
input({
scenarios: [
{ name: "Basisszenario", isBase: true, plan: plan("Basisszenario") },
{ name: "Tiefere Rendite", isBase: false, plan: plan("Tiefere Rendite", 2) },
],
analyses: [
{
name: "Monte-Carlo Basis",
type: "MONTE_CARLO",
result: {
params: [{ label: "Zielbetrag", value: "3'000'000" }],
table: { columns: ["Szenario", "Realismus"], rows: [["Basis", "63 %"]] },
},
},
],
})
);
const pdf = await renderReportPdf(m);
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
// Ohne sauberes Dateiende lässt sich das PDF nicht öffnen.
expect(pdf.subarray(-1024).toString("latin1")).toContain("%%EOF");
expect(pdf.length).toBeGreaterThan(5000);
}, 30000);
it("kommt auch mit einem leeren Plan zurecht, statt zu werfen", async () => {
// Robustheit: Ein Plan ohne Phasen darf keinen Absturz erzeugen.
const leer = { ...plan("Leer"), phases: [], elements: [] } as unknown as PlanInput;
const m = buildReport(input({ scenarios: [{ name: "Leer", isBase: true, plan: leer }] }));
const pdf = await renderReportPdf(m);
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
}, 30000);
});