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
+274
View File
@@ -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(" ");
}