Pensionsalter anpassen (Roadmap 44)
Deploy App / deploy (push) Successful in 1m1s

Das Pensionsalter laesst sich neu veraendern: Nicht das Alter wird gesetzt,
sondern die Phasengrenze verschoben -- Vorphase laenger, Folgephase kuerzer,
Gesamtdauer gleich. Faellt eine Phase dabei weg, werden die beiden Uebergaenge
nach Bestaetigung zusammengelegt.

- neues reines Modul lib/retirement.ts + POST /api/scenarios/<id>/retirement
- Pensionsalter als Tornado-Treiber und Live-Simulations-Regler
- AHV-Referenzalter 65: Rente ab 65 unabhaengig vom Pensionsalter; vor 65
  Beitrag als Nichterwerbstaetige(r) (neues Feld ahvContribution).
  AHV wird dafuer jahresweise statt phasenweise gerechnet.
- Punkt B: personenzugeordnetes Einkommen faellt bei Pensionierung auf 0
- Punkt A: Wiederkehr-Parameter werden live aus der Vorphase geerbt,
  sichtbar als "Aus Vorphase uebernehmen"
- Punkt C: Kapitalzufluss am Pensions-Uebergang per Quote auf Amortisation,
  Anlage und Cash verteilbar
- Fix: carry.flowBasis wurde vor der Jahresschleife berechnet, effektive
  Werte kamen deshalb nie in der Folgephase an

SPEZIFIKATION 0.26 (neue Kapitel 3.12, 4.4.7, 4.16). 221 -> 261 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 14:24:40 +02:00
parent c429624635
commit d6855ef1a0
20 changed files with 2057 additions and 184 deletions
@@ -79,33 +79,28 @@ export async function POST(
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
}
// Vorbelegung für eine neue Phase: nur die editierbaren Felder werden übernommen. Start-
// bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden in der Berechnung live aus der
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
// Vorbelegung für eine neue Phase.
//
// Seit Roadmap Nr. 44 (Punkt A) werden die WIEDERKEHR-Parameter (Raten, Beiträge, Amortisation,
// Wertsteigerung, Zinssatz) NICHT mehr als Kopie gespeichert, sondern bewusst leer gelassen:
// Die Berechnung übernimmt sie live aus der Vorphase, und im UI steht dafür sichtbar
// «Aus Vorphase übernehmen» -- angehakt. Der Unterschied zeigt sich, sobald man die Vorphase
// später ändert: Eine Kopie bliebe stehen, die Vererbung zieht mit.
//
// Start- bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden ohnehin live fortgeschrieben.
function buildCarryData(category: string, prev: PhaseData): PhaseData {
switch (category) {
case "INCOME":
case "EXPENSE":
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird übernommen.
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
case "AHV":
// Ausfalljahre sind KEIN Wiederkehr-Parameter: Sie gelten für genau diese Phase, und
// ein geerbter Wert würde eine Lücke erfinden, die es nicht gibt.
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.
// Der Kaufpreis ist eine Tatsache, keine Annahme (Basis der Grundstückgewinnsteuer);
// die Zins-Behandlung ist ein Schalter ohne Zahlenwert -- beide werden weiterhin kopiert.
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 {};
}
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { Prisma } from "@/generated/prisma/client";
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
// Pensionsalter anpassen (Roadmap Nr. 44).
//
// Das ist bewusst KEIN einfaches PATCH auf `retirementAge`: Eine Pensionierung liegt immer
// auf einer Phasengrenze, also verschiebt jede Änderung zwei Phasendauern gleichzeitig --
// und kann eine Phase ganz entfallen lassen. Die Entscheidung darüber trifft `lib/retirement`
// (rein und getestet), hier wird sie nur noch geschrieben.
const bodySchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]),
delta: z.number().int().min(-80).max(80),
// Muss der Aufrufer ausdrücklich setzen, wenn dabei eine Lebensphase wegfällt.
confirmMerge: z.boolean().optional(),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { role, delta, confirmMerge } = parsed.data;
const planInput = toPlanInput(scenario);
const boundary = retirementBoundaries(planInput).find((b) => b.role === role);
if (!boundary) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
if (boundary.blocked) return NextResponse.json({ error: boundary.blocked }, { status: 400 });
if (delta < (boundary.mergeDeltaDown ?? boundary.minDelta) || delta > (boundary.mergeDeltaUp ?? boundary.maxDelta)) {
return NextResponse.json(
{ error: "So weit lässt sich das Pensionsalter hier nicht verschieben. Passe zuerst die Lebensphasen an." },
{ status: 400 }
);
}
const result = shiftRetirement(planInput, role, delta);
if (!result) return NextResponse.json({ error: "Die Verschiebung ist hier nicht möglich." }, { status: 400 });
// Eine Zusammenlegung verändert den Plan inhaltlich (zwei Übergänge werden einer) und muss
// deshalb vom Aufrufer bestätigt sein. 409 = "verstanden, aber so nicht ohne Zustimmung".
if (result.removedPhaseId && !confirmMerge) {
const removed = planInput.phases.find((p) => p.id === result.removedPhaseId);
return NextResponse.json(
{
error: "Bestätigung erforderlich.",
needsMergeConfirmation: true,
removedPhaseId: result.removedPhaseId,
removedPhaseName: removed?.name ?? "",
mergedIntoPhaseId: result.mergedIntoPhaseId,
},
{ status: 409 }
);
}
await prisma.$transaction(async (tx) => {
await tx.person.updateMany({
where: { scenarioId: scenario.id, role },
data: { retirementAge: boundary.retirementAge + delta },
});
if (result.removedPhaseId) {
// Die Übergangsdaten der entfallenden Phase wurden in den Vorgänger gezogen; hier wird
// nur noch der zusammengeführte Stand geschrieben, bevor die Phase verschwindet.
if (result.mergedIntoPhaseId) {
const target = result.plan.phases.find((p) => p.id === result.mergedIntoPhaseId);
if (target) {
await tx.phase.update({
where: { id: target.id },
data: { cashTransition: (target.cashTransition ?? {}) as Prisma.InputJsonValue },
});
}
for (const e of result.plan.elements) {
const td = e.transitionValues?.[result.mergedIntoPhaseId];
if (!td) continue;
await tx.elementTransitionValue.upsert({
where: { elementId_fromPhaseId: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId } },
create: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId, data: td as Prisma.InputJsonValue },
update: { data: td as Prisma.InputJsonValue },
});
}
}
// Löscht die Phase samt ihrer Phasen- und Übergangswerte (Kaskade im Schema).
await tx.phase.delete({ where: { id: result.removedPhaseId } });
}
for (const p of result.plan.phases) {
await tx.phase.update({
where: { id: p.id },
data: { durationYears: p.durationYears, sequenceNumber: p.sequenceNumber },
});
}
});
await touchScenario(scenario.id, userId);
return NextResponse.json({
ok: true,
removedPhaseId: result.removedPhaseId,
retirementAge: boundary.retirementAge + delta,
});
}