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:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user