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:
@@ -0,0 +1,274 @@
|
||||
// Pensionsalter anpassen (Roadmap Nr. 44).
|
||||
//
|
||||
// Bis hierher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Phase endet
|
||||
// (`maxPhaseDuration` kappt jede Phasendauer beim nächsten Pensionierungsereignis), also
|
||||
// liegt JEDE Pensionierung zwangsläufig auf einer Phasengrenze. Genau das macht die
|
||||
// Anpassung überhaupt erst möglich: «Pensionsalter ändern» heisst «diese eine Grenze
|
||||
// verschieben» -- die Phase davor wird länger, die danach kürzer, die Gesamtdauer des Plans
|
||||
// bleibt gleich.
|
||||
//
|
||||
// Reines Modul ohne I/O: Es entscheidet nur, WAS geschehen soll. Das Schreiben übernimmt der
|
||||
// Aufrufer (Panel: API-Aufrufe; Tornado/Live-Simulation: reine Plan-Kopie).
|
||||
|
||||
import type { CashTransitionData, TransitionData } from "@/lib/elements";
|
||||
import type { PersonRole, PlanInput } from "@/lib/types";
|
||||
|
||||
export interface RetirementBoundary {
|
||||
role: PersonRole;
|
||||
personId: string;
|
||||
retirementAge: number;
|
||||
// Planjahr, in dem die Pensionierung liegt (1-basiert: Ende von Phase `phaseIndex`).
|
||||
planYear: number;
|
||||
phaseIndex: number; // Phase VOR der Grenze
|
||||
// Um so viele Jahre lässt sich das Pensionsalter senken bzw. erhöhen, ohne dass eine
|
||||
// angrenzende Phase unter 1 Jahr fällt.
|
||||
minDelta: number;
|
||||
maxDelta: number;
|
||||
// Bei genau diesen Werten verschwindet eine Phase (Zusammenlegung, siehe unten).
|
||||
mergeDeltaDown: number | null;
|
||||
mergeDeltaUp: number | null;
|
||||
// Warum ist keine Anpassung möglich?
|
||||
blocked: string | null;
|
||||
}
|
||||
|
||||
// Kumulierte Jahresgrenzen: Index i = Planjahr, an dem Phase i endet.
|
||||
function boundaries(plan: PlanInput): number[] {
|
||||
const sorted = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
const out: number[] = [];
|
||||
let acc = 0;
|
||||
for (const p of sorted) {
|
||||
acc += p.durationYears;
|
||||
out.push(acc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sortedPhases(plan: PlanInput) {
|
||||
return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
}
|
||||
|
||||
// Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt.
|
||||
export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] {
|
||||
const phases = sortedPhases(plan);
|
||||
const bounds = boundaries(plan);
|
||||
const total = bounds[bounds.length - 1] ?? 0;
|
||||
|
||||
return plan.persons.map((p) => {
|
||||
const planYear = p.retirementAge - p.age;
|
||||
const base: RetirementBoundary = {
|
||||
role: p.role,
|
||||
personId: p.id,
|
||||
retirementAge: p.retirementAge,
|
||||
planYear,
|
||||
phaseIndex: -1,
|
||||
minDelta: 0,
|
||||
maxDelta: 0,
|
||||
mergeDeltaDown: null,
|
||||
mergeDeltaUp: null,
|
||||
blocked: null,
|
||||
};
|
||||
|
||||
if (planYear <= 0) {
|
||||
return { ...base, blocked: "Diese Person ist bei Planbeginn bereits pensioniert – es gibt keine Grenze zu verschieben." };
|
||||
}
|
||||
if (planYear >= total) {
|
||||
return { ...base, blocked: "Die Pensionierung liegt am oder nach dem Planende – dafür müsstest du zuerst eine Lebensphase anhängen." };
|
||||
}
|
||||
|
||||
const idx = bounds.indexOf(planYear);
|
||||
if (idx < 0 || idx >= phases.length - 1) {
|
||||
return {
|
||||
...base,
|
||||
blocked: "Die Pensionierung liegt nicht auf einer Phasengrenze. Passe zuerst die Lebensphasen an.",
|
||||
};
|
||||
}
|
||||
|
||||
// Teilen sich zwei Personen dieselbe Grenze, liesse sich eine davon nicht verschieben,
|
||||
// ohne die andere mitzunehmen -- dafür müsste eine Phase eingefügt werden.
|
||||
const shared = plan.persons.some((q) => q.id !== p.id && q.retirementAge - q.age === planYear);
|
||||
if (shared) {
|
||||
return {
|
||||
...base,
|
||||
phaseIndex: idx,
|
||||
blocked:
|
||||
"Beide Personen werden zum selben Zeitpunkt pensioniert. Um sie zu trennen, füge zuerst eine Lebensphase ein.",
|
||||
};
|
||||
}
|
||||
|
||||
const before = phases[idx].durationYears;
|
||||
const after = phases[idx + 1].durationYears;
|
||||
|
||||
return {
|
||||
...base,
|
||||
phaseIndex: idx,
|
||||
// Die Phase davor darf auf 1 schrumpfen, die danach ebenso.
|
||||
minDelta: -(before - 1),
|
||||
maxDelta: after - 1,
|
||||
// Ein Schritt weiter lässt die jeweilige Phase ganz verschwinden.
|
||||
mergeDeltaDown: -before,
|
||||
mergeDeltaUp: after,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface ShiftResult {
|
||||
plan: PlanInput;
|
||||
// Index der Phase, die dabei entfällt (Zusammenlegung) -- sonst null.
|
||||
removedPhaseIndex: number | null;
|
||||
removedPhaseId: string | null;
|
||||
// Phase, in deren Übergang die Entscheide der entfallenden Phase gezogen wurden.
|
||||
mergedIntoPhaseId: string | null;
|
||||
}
|
||||
|
||||
// Verschiebt die Pensionierungs-Grenze einer Person um `delta` Jahre.
|
||||
//
|
||||
// `delta > 0` = später in Pension: die Phase davor wird länger, die danach kürzer.
|
||||
// Fällt eine der beiden auf 0, verschwindet sie (Zusammenlegung) -- das muss der Aufrufer
|
||||
// vorher bestätigen lassen, weil dabei Übergangs-Entscheide zusammengeführt werden.
|
||||
export function shiftRetirement(plan: PlanInput, role: PersonRole, delta: number): ShiftResult | null {
|
||||
const info = retirementBoundaries(plan).find((b) => b.role === role);
|
||||
if (!info || info.blocked || info.phaseIndex < 0) return null;
|
||||
if (delta === 0) return { plan, removedPhaseIndex: null, removedPhaseId: null, mergedIntoPhaseId: null };
|
||||
|
||||
const allowedDown = info.mergeDeltaDown ?? info.minDelta;
|
||||
const allowedUp = info.mergeDeltaUp ?? info.maxDelta;
|
||||
if (delta < allowedDown || delta > allowedUp) return null;
|
||||
|
||||
const phases = sortedPhases(plan);
|
||||
const i = info.phaseIndex;
|
||||
const beforeNew = phases[i].durationYears + delta;
|
||||
const afterNew = phases[i + 1].durationYears - delta;
|
||||
if (beforeNew < 0 || afterNew < 0) return null;
|
||||
|
||||
const nextPhases = phases.map((p, k) =>
|
||||
k === i ? { ...p, durationYears: beforeNew } : k === i + 1 ? { ...p, durationYears: afterNew } : { ...p }
|
||||
);
|
||||
|
||||
// Genau eine der beiden kann 0 werden -- diese Phase entfällt.
|
||||
let removedIndex: number | null = null;
|
||||
if (beforeNew === 0) removedIndex = i;
|
||||
else if (afterNew === 0) removedIndex = i + 1;
|
||||
|
||||
const removedPhaseId = removedIndex === null ? null : nextPhases[removedIndex].id;
|
||||
// Die überlebende Grenze ist die des Vorgängers -- dorthin wandern die Entscheide.
|
||||
const mergedIntoPhaseId =
|
||||
removedIndex === null || removedIndex === 0 ? null : nextPhases[removedIndex - 1].id;
|
||||
|
||||
let kept = removedIndex === null ? nextPhases : nextPhases.filter((_, k) => k !== removedIndex);
|
||||
let elements = plan.elements;
|
||||
|
||||
if (removedPhaseId && mergedIntoPhaseId) {
|
||||
kept = kept.map((p) =>
|
||||
p.id === mergedIntoPhaseId
|
||||
? {
|
||||
...p,
|
||||
cashTransition: mergeCashTransition(
|
||||
p.cashTransition ?? {},
|
||||
nextPhases[removedIndex!].cashTransition ?? {}
|
||||
),
|
||||
}
|
||||
: p
|
||||
);
|
||||
elements = plan.elements.map((e) => {
|
||||
const removedTd = e.transitionValues?.[removedPhaseId];
|
||||
if (!removedTd) return e;
|
||||
const rest = { ...e.transitionValues };
|
||||
delete rest[removedPhaseId];
|
||||
return {
|
||||
...e,
|
||||
transitionValues: {
|
||||
...rest,
|
||||
[mergedIntoPhaseId]: mergeTransition(e.transitionValues[mergedIntoPhaseId] ?? {}, removedTd),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
plan: {
|
||||
...plan,
|
||||
persons: plan.persons.map((p) => (p.role === role ? { ...p, retirementAge: p.retirementAge + delta } : p)),
|
||||
// Sequenznummern lückenlos neu vergeben, damit die Kette intakt bleibt.
|
||||
phases: kept.map((p, k) => ({ ...p, sequenceNumber: k + 1 })),
|
||||
elements,
|
||||
},
|
||||
removedPhaseIndex: removedIndex,
|
||||
removedPhaseId,
|
||||
mergedIntoPhaseId,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Zusammenlegung zweier Übergänge ----------------------------------------------------
|
||||
//
|
||||
// Fällt eine Phase weg, verschwindet auch einer der beiden Übergänge -- die dort getroffenen
|
||||
// Entscheide dürfen aber nicht stillschweigend untergehen. Die überlebende Grenze ist immer
|
||||
// die des VORGÄNGERS der entfallenden Phase; deren Daten werden in ihn hineingezogen.
|
||||
//
|
||||
// Regel (mit dem Nutzer abgestimmt):
|
||||
// * Element-Entscheide: Was am überlebenden Übergang schon entschieden ist, bleibt. Nur
|
||||
// leere Felder werden aus dem entfallenden Übergang aufgefüllt -- ein bewusster
|
||||
// Entscheid soll nie von einem anderen überschrieben werden.
|
||||
// * Einmalige Cash-Beträge: werden ADDIERT. Beide Ereignisse finden ja weiterhin statt,
|
||||
// nur zum selben Zeitpunkt.
|
||||
|
||||
// Feldweises Auffüllen: `keep` gewinnt, `removed` füllt die Lücken.
|
||||
export function mergeTransition(keep: TransitionData, removed: TransitionData): TransitionData {
|
||||
const out: TransitionData = { ...keep };
|
||||
for (const [k, v] of Object.entries(removed) as [keyof TransitionData, unknown][]) {
|
||||
if (v === undefined || v === null) continue;
|
||||
if (out[k] === undefined || out[k] === null) (out as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeCashTransition(keep: CashTransitionData, removed: CashTransitionData): CashTransitionData {
|
||||
// "NONE" bedeutet: bewusst nichts. Beträge daraus zählen nicht mit.
|
||||
const active = (c: CashTransitionData) => c.mode && c.mode !== "NONE";
|
||||
const inflow = (c: CashTransitionData) =>
|
||||
active(c) && (c.mode === "INFLOW" || c.mode === "BOTH") ? c.inflowAmount ?? 0 : 0;
|
||||
const outflow = (c: CashTransitionData) =>
|
||||
active(c) && (c.mode === "OUTFLOW" || c.mode === "BOTH") ? c.outflowAmount ?? 0 : 0;
|
||||
|
||||
const inA = inflow(keep);
|
||||
const inB = inflow(removed);
|
||||
const outA = outflow(keep);
|
||||
const outB = outflow(removed);
|
||||
const inSum = inA + inB;
|
||||
const outSum = outA + outB;
|
||||
|
||||
const join = (a: string | undefined, b: string | undefined) =>
|
||||
[a, b].filter((x) => x && x.trim()).join(" + ") || undefined;
|
||||
|
||||
return {
|
||||
mode: inSum > 0 && outSum > 0 ? "BOTH" : inSum > 0 ? "INFLOW" : outSum > 0 ? "OUTFLOW" : "NONE",
|
||||
inflowLabel: inSum > 0 ? join(inA > 0 ? keep.inflowLabel : undefined, inB > 0 ? removed.inflowLabel : undefined) : undefined,
|
||||
inflowAmount: inSum > 0 ? inSum : undefined,
|
||||
// Zwei Zuflüsse mit verschiedenen Steuersätzen ergeben zusammen einen betragsgewichteten
|
||||
// Mischsatz -- nur so bleibt der Netto-Zufluss derselbe wie vor der Zusammenlegung.
|
||||
inflowTaxRate:
|
||||
inSum > 0
|
||||
? Math.round((((keep.inflowTaxRate ?? 0) * inA + (removed.inflowTaxRate ?? 0) * inB) / inSum) * 100) / 100
|
||||
: undefined,
|
||||
outflowLabel: outSum > 0 ? join(outA > 0 ? keep.outflowLabel : undefined, outB > 0 ? removed.outflowLabel : undefined) : undefined,
|
||||
outflowAmount: outSum > 0 ? outSum : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Erklärtext für die Hilfebox: was ist möglich, und warum nicht mehr.
|
||||
export function limitsText(b: RetirementBoundary, personLabel: string): string {
|
||||
if (b.blocked) return b.blocked;
|
||||
const down = Math.abs(b.minDelta);
|
||||
const up = b.maxDelta;
|
||||
const parts = [
|
||||
`Das Pensionsalter von ${personLabel} lässt sich um maximal ${down} Jahr${down === 1 ? "" : "e"} senken und ` +
|
||||
`${up} Jahr${up === 1 ? "" : "e"} erhöhen, weil die angrenzenden Lebensphasen mindestens 1 Jahr dauern müssen.`,
|
||||
];
|
||||
if (b.mergeDeltaDown !== null || b.mergeDeltaUp !== null) {
|
||||
parts.push(
|
||||
"Ein Jahr darüber hinaus fällt die angrenzende Lebensphase ganz weg – das ist möglich, wird aber vorher " +
|
||||
"bestätigt, weil dabei zwei Übergänge zusammengelegt werden."
|
||||
);
|
||||
}
|
||||
parts.push("Brauchst du mehr Spielraum, passe zuerst die Dauer der Lebensphasen an.");
|
||||
return parts.join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user