Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s

Groesste Umstrukturierung bisher. Der PLAN ist neu ein schlanker Behaelter ohne
Finanzdaten; die berechenbare Einheit ist das SZENARIO.

Modell:
- Jeder Plan bekommt beim Anlegen automatisch ein Basisszenario (isBase).
- Das Grundprofil liegt am Szenario, nicht am Plan -- nur so sind Szenarien mit
  abweichendem PENSIONSALTER moeglich (Fruehpensionierung), das in Person steckt.
- Neue Szenarien sind vollstaendige Kopien eines BELIEBIGEN Szenarios und haengen
  als Baum darunter (parentScenarioId); die Seitenleiste rueckt sie ein.
- Kopierte Phasen/Elemente tragen Herkunfts-Verweise (sourcePhaseId,
  sourceElementId). Ueber den Namen zu matchen waere fragil gewesen.

Abweichungs-Markierung (Diff gegen das Eltern-Szenario, live):
- geaendert = gelb, neu = gruen + Badge, entfernt = graue Geisterzeile.
- Markiert: Phasen-/Uebergangszellen, Element-Zeilen, Phasenkoepfe, Cash-Anfangswert,
  Cash-Uebergaenge, Grundprofil. Zaehler ueber der Matrix.
- Eigene Theme-Tokens fuer Hell/Dunkel/Warm -- ein fester Gelbwert waere im
  Dunkelschema unbrauchbar.

Charts vergleichen neu die Geschwister-Szenarien statt fremder Plaene.

Datenmodell/Migration:
- Neue Tabelle Plan; bisheriger Plan -> Scenario (IDs erhalten, damit alle
  Kind-Fremdschluessel gueltig bleiben); planId -> scenarioId in Person/Phase/
  FinancialElement. Bestehende Szenarien werden per rekursivem CTE demselben
  Behaelter zugeordnet, auch mehrfach verschachtelte.
- Migration VOR dem Deploy gegen echtes PostgreSQL verifiziert (PGlite, in-process),
  inkl. verschachtelter Szenarien und Cascade. Der Test ist als
  migrations.test.ts committet und sichert kuenftige Migrationen ab.

API neu unter /api/scenarios/*. 10 neue Tests (48 -> 58). Spezifikation auf v0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 09:51:44 +02:00
parent 71137f7cea
commit a5d4868c58
25 changed files with 1336 additions and 486 deletions
@@ -1,69 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
const createSchema = z.object({
category: z.enum([
"INCOME",
"EXPENSE",
"AHV",
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
]),
name: z.string().min(1).max(120),
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).nullable().optional(),
});
// Legt ein neues finanzielles Element (plan-weit) an. Personen-Pflicht je Kategorie.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const { category, name } = parsed.data;
let ownerRole = parsed.data.ownerRole ?? null;
if (PERSON_ONLY_CATEGORIES.includes(category)) {
if (ownerRole !== "PERSON_A" && ownerRole !== "PERSON_B") {
return NextResponse.json(
{ error: "Diese Kategorie muss einer Person zugeordnet werden." },
{ status: 400 }
);
}
} else if (ownerRole == null) {
ownerRole = "HOUSEHOLD";
}
const maxOrder = await prisma.financialElement.aggregate({
where: { planId: plan.id },
_max: { orderIndex: true },
});
const element = await prisma.financialElement.create({
data: {
planId: plan.id,
category,
name,
ownerRole,
orderIndex: (maxOrder._max.orderIndex ?? 0) + 1,
},
});
return NextResponse.json({ element: { id: element.id } }, { status: 201 });
}
@@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan, planToCsv } from "@/lib/calculations";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const { planId } = await params;
const plan = await getOwnedPlan(planId, userId);
if (!plan) {
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
}
const planInput = toPlanInput(plan);
const computed = computePlan(planInput);
const csv = planToCsv(planInput, computed);
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${plan.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`,
},
});
}
-110
View File
@@ -1,110 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPlan, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { Prisma } from "@/generated/prisma/client";
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
import { num, type PhaseData } from "@/lib/elements";
const createPhaseSchema = z.object({
name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(),
});
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const body = await request.json().catch(() => ({}));
const parsed = createPhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const planInput = toPlanInput(plan);
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
let duration = parsed.data.durationYears ?? (cap ?? 10);
if (cap != null) duration = Math.min(duration, cap);
duration = Math.max(1, duration);
const nextSequence = planInput.phases.length + 1;
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
const defaultName =
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren).
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
const lastPhaseId = planInput.phases.at(-1)?.id;
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
const prevStatusById = new Map((prevPhase?.elements ?? []).map((e) => [e.elementId, e.status]));
const phase = await prisma.$transaction(async (tx) => {
const created = await tx.phase.create({
data: {
planId: plan.id,
sequenceNumber: nextSequence,
name: defaultName,
durationYears: duration,
},
});
// Vorbelegung der editierbaren Felder bestehender Elemente.
for (const e of planInput.elements) {
if ((prevStatusById.get(e.id) ?? "ACTIVE") !== "ACTIVE") continue; // verkauft/getilgt
const prevData: PhaseData = e.phaseValues[lastPhaseId ?? ""] ?? {};
const data: PhaseData = buildCarryData(e.category, prevData);
await tx.elementPhaseValue.create({
data: { elementId: e.id, phaseId: created.id, data: data as Prisma.InputJsonValue },
});
}
return created;
});
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
}
// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start-
// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
function buildCarryData(category: string, prev: PhaseData): PhaseData {
switch (category) {
case "INCOME":
case "EXPENSE":
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird uebernommen.
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
case "AHV":
return { gapYears: 0 };
case "PENSION_FUND":
case "PILLAR_3A":
case "OTHER_ASSET":
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
case "REAL_ESTATE":
// purchasePrice + amortization bleiben; Resthypothek und Verkehrswert werden live
// fortgeschrieben. Zinssatz, Zins-Behandlung und Wertsteigerung gelten weiter.
return {
purchasePrice: num(prev.purchasePrice),
amortization: num(prev.amortization),
interestRate: num(prev.interestRate),
interestHandling: prev.interestHandling ?? "INCLUDED",
valueGrowth: num(prev.valueGrowth),
};
case "OTHER_DEBT":
return { annualRepayment: num(prev.annualRepayment) };
default:
return {};
}
}
+9 -66
View File
@@ -1,41 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
// Der Plan ist nur noch der Behaelter: er traegt den Namen; die Finanzdaten liegen im Szenario.
const patchSchema = z.object({ name: z.string().min(1).max(120) });
const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const planInput = toPlanInput(plan);
const computed = computePlan(planInput);
return NextResponse.json({ plan: planInput, computed });
}
// Name/Cash-Anfangswert aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation).
const patchSchema = z.union([
planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
z.object({
name: z.string().min(1).max(120).optional(),
initialCash: z.number().min(0).max(1_000_000_000).optional(),
}),
]);
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
@@ -43,50 +14,22 @@ export async function PATCH(
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const body = await request.json();
const parsed = patchSchema.safeParse(body);
const parsed = patchSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const data = parsed.data;
const hasProfile = "householdType" in data;
if (hasProfile) {
const error = validatePersonsForType(data);
if (error) return NextResponse.json({ error }, { status: 400 });
const updated = await prisma.$transaction(async (tx) => {
await tx.person.deleteMany({ where: { planId: plan.id } });
return tx.plan.update({
where: { id: plan.id },
data: {
name: data.name ?? undefined,
householdType: data.householdType,
inflationRateDefault: data.inflationRateDefault,
persons: { create: data.persons },
},
});
});
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
const updated = await prisma.plan.update({
where: { id: plan.id },
data: {
name: "name" in data ? data.name : undefined,
initialCash: "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
},
});
const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
// Loescht den Plan inkl. aller Szenarien (Cascade).
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
await prisma.plan.delete({ where: { id: plan.id } });
return NextResponse.json({ ok: true });
}
@@ -1,102 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
const scenarioSchema = z.object({
name: z.string().min(1).max(120),
branchFromPhaseId: z.string().min(1),
});
// Erstellt ein Szenario als Deep-Copy eines Plans bis zur Verzweigungsphase (inkl.).
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const body = await request.json();
const parsed = scenarioSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const source = await getOwnedPlan(planId, userId);
if (!source) return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
const branchPhase = source.phases.find((p) => p.id === parsed.data.branchFromPhaseId);
if (!branchPhase) return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 });
const copiedPhases = source.phases
.filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber)
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const copiedPhaseIds = new Set(copiedPhases.map((p) => p.id));
const newPlanId = await prisma.$transaction(async (tx) => {
const newPlan = await tx.plan.create({
data: {
userId,
name: parsed.data.name,
householdType: source.householdType,
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
parentPlanId: source.id,
persons: {
create: source.persons.map((p) => ({ role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge })),
},
},
});
// Phasen kopieren (alte -> neue Id).
const phaseIdMap = new Map<string, string>();
let lastNewPhaseId = "";
for (const phase of copiedPhases) {
const created = await tx.phase.create({
data: {
planId: newPlan.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
// Cash-Entscheid (einmalige Sonderein-/ausgaben) mitkopieren.
cashTransition: phase.cashTransition ?? undefined,
},
});
phaseIdMap.set(phase.id, created.id);
lastNewPhaseId = created.id;
}
// Elemente + deren Phasen-/Uebergangswerte kopieren.
for (const el of source.elements) {
const newEl = await tx.financialElement.create({
data: {
planId: newPlan.id,
category: el.category,
name: el.name,
ownerRole: el.ownerRole,
orderIndex: el.orderIndex,
},
});
for (const pv of el.phaseValues) {
const newPhaseId = phaseIdMap.get(pv.phaseId);
if (!newPhaseId) continue;
await tx.elementPhaseValue.create({
data: { elementId: newEl.id, phaseId: newPhaseId, data: pv.data as object },
});
}
for (const tv of el.transitionValues) {
if (!copiedPhaseIds.has(tv.fromPhaseId)) continue;
const newFromId = phaseIdMap.get(tv.fromPhaseId);
if (!newFromId) continue;
await tx.elementTransitionValue.create({
data: { elementId: newEl.id, fromPhaseId: newFromId, data: tv.data as object },
});
}
}
await tx.plan.update({ where: { id: newPlan.id }, data: { branchFromPhaseId: lastNewPhaseId } });
return newPlan.id;
});
return NextResponse.json({ planId: newPlanId }, { status: 201 });
}
+28 -24
View File
@@ -10,18 +10,17 @@ const personSchema = z.object({
retirementAge: z.number().int().min(30).max(100),
});
// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation).
export const planProfileSchema = z.object({
// Das Grundprofil liegt am SZENARIO (nicht am Plan) -- dadurch kann ein Szenario z. B. ein
// anderes Pensionsalter tragen als das Basisszenario (Frühpensionierungs-Szenario).
export const scenarioProfileSchema = z.object({
householdType: z.enum(["SINGLE", "COUPLE"]),
inflationRateDefault: z.number().min(-20).max(50),
persons: z.array(personSchema).min(1).max(2),
});
const createPlanSchema = z
.object({ name: z.string().min(1).max(120) })
.and(planProfileSchema);
const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scenarioProfileSchema);
export function validatePersonsForType(data: z.infer<typeof planProfileSchema>): string | null {
export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): string | null {
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
return "Einzelperson-Plan benoetigt genau eine Person.";
}
@@ -31,40 +30,35 @@ export function validatePersonsForType(data: z.infer<typeof planProfileSchema>):
return null;
}
// Liste der Plaene mit ihrem Szenario-Baum (nur Kopfdaten).
export async function GET() {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const plans = await prisma.plan.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
parentPlanId: true,
branchFromPhaseId: true,
createdAt: true,
phases: {
select: { id: true, name: true, sequenceNumber: true },
orderBy: { sequenceNumber: "asc" },
scenarios: {
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true },
},
},
});
return NextResponse.json({ plans });
}
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil traegt.
export async function POST(request: NextRequest) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const body = await request.json();
const parsed = createPlanSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const error = validatePersonsForType(parsed.data);
if (error) return NextResponse.json({ error }, { status: 400 });
@@ -72,11 +66,21 @@ export async function POST(request: NextRequest) {
data: {
userId,
name: parsed.data.name,
householdType: parsed.data.householdType,
inflationRateDefault: parsed.data.inflationRateDefault,
persons: { create: parsed.data.persons },
scenarios: {
create: {
name: "Basisszenario",
isBase: true,
householdType: parsed.data.householdType,
inflationRateDefault: parsed.data.inflationRateDefault,
persons: { create: parsed.data.persons },
},
},
},
include: { scenarios: true },
});
return NextResponse.json({ plan: { id: plan.id } }, { status: 201 });
return NextResponse.json(
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
{ status: 201 }
);
}