Phasenkopf-Ueberarbeitung: zweizeilige Werte, Verfuegbares Kapital, Verteil-Werkzeuge
Deploy App / deploy (push) Successful in 1m5s
Deploy App / deploy (push) Successful in 1m5s
Rein an der Oberflaeche und als neue Bearbeitungswerkzeuge -- keine Aenderung
an Berechnung, Datenmodell oder API. Beide Verteil-Dialoge schreiben nur
bestehende Felder ueber bestehende Endpunkte.
1) Zweizeilige Wertdarstellung im Modus "Beide": Realwert in Klammern in
eigener Zeile UNTER dem nominalen Wert (Kopf + Matrix-Zellen), Pfeil auf
beiden Zeilen. Dadurch schmalere Spalten und jede Kennzahl umbruchfrei.
2) "Sparquote" / "Verzehrquote" statt "Quote" / "Verzehr".
3) Neuer Kopf-Block "Verfuegbares Kapital" (ab Phase 2, nur wenn > 0):
Topf, davon verteilt, Rest auf Cash -- vollstaendig aus der Cash-Bruecke
abgeleitet (capitalPot).
4) Zwei Verteil-Popups mit Live-Vorschau (erneutes computePlan im Browser):
- "Kapital verteilen": Zusatzeinlage (PK/3a/Vermoegen, Phasenwert) +
Sonderamortisation/Sofort-Tilgung (Uebergangswert der Vorphase);
Rest bleibt automatisch auf Cash, Ueberverteilung wird als Luecke gemeldet
- "Sparquote/Bezug verteilen": jaehrliche Raten; zeigt Quote erstes Jahr,
letztes Jahr UND absolut ueber die Phase; warnt, wenn die Quote sinkt
(flache Rate wuerde spaeter Cash-Loch reissen). PK bewusst ausgeschlossen
(Beitrag aus Bruttolohn, belastet Cash nicht).
Neues reines Modul distribution.ts (capitalPot, quotaSummary, applyPatches).
8 Tests (103 -> 111) -- u.a. residual-Kontrolle gegen die Cash-Bruecke und
Nachweis, dass die Quote ueber die Phase sinkt.
SPEZIFIKATION auf 0.14: neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3
ueberarbeitet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import {
|
||||
applyPhasePatches,
|
||||
applyTransitionPatches,
|
||||
capitalPot,
|
||||
quotaSummary,
|
||||
} from "@/lib/distribution";
|
||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
let idc = 0;
|
||||
const nid = () => `d${idc++}`;
|
||||
|
||||
function el(
|
||||
category: ElementCategory,
|
||||
ownerRole: string | null,
|
||||
phaseValues: Record<string, PhaseData>,
|
||||
transitionValues: Record<string, TransitionData> = {}
|
||||
) {
|
||||
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
|
||||
}
|
||||
|
||||
function plan(opts: {
|
||||
age: number;
|
||||
retirementAge: number;
|
||||
initialCash?: number;
|
||||
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
|
||||
elements: ReturnType<typeof el>[];
|
||||
}): PlanInput {
|
||||
return {
|
||||
id: "plan",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 1.5,
|
||||
initialCash: opts.initialCash ?? 0,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
||||
phases: opts.phases.map((p, i) => ({
|
||||
id: p.id,
|
||||
sequenceNumber: i + 1,
|
||||
name: p.id,
|
||||
durationYears: p.durationYears,
|
||||
cashTransition: p.cashTransition ?? {},
|
||||
})),
|
||||
elements: opts.elements,
|
||||
};
|
||||
}
|
||||
|
||||
// Zwei Phasen; am Übergang wird Vermögen verkauft und eine Erbschaft fliesst zu -- damit
|
||||
// entsteht in Phase 2 ein verteilbarer Kapitaltopf.
|
||||
function basePlan() {
|
||||
const asset = el("OTHER_ASSET", "HOUSEHOLD", {
|
||||
p1: { startValue: 200000, expectedReturn: 0 },
|
||||
p2: { expectedReturn: 0 },
|
||||
}, { p1: { decision: "SELL" } });
|
||||
const debt = el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000 }, p2: {} }, { p1: {} });
|
||||
const invest = el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: { expectedReturn: 0 } });
|
||||
return {
|
||||
p: plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
initialCash: 10000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5, cashTransition: { mode: "INFLOW", inflowAmount: 100000, inflowTaxRate: 0 } },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
asset,
|
||||
debt,
|
||||
invest,
|
||||
],
|
||||
}),
|
||||
assetId: asset.id,
|
||||
debtId: debt.id,
|
||||
investId: invest.id,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Verteilung: verfügbares Kapital", () => {
|
||||
it("Topf setzt sich aus Vorphasen-Cash, Kapitalzufluss und Einmalzufluss zusammen", () => {
|
||||
const { p } = basePlan();
|
||||
const r = computePlan(p);
|
||||
const pot = capitalPot(r.phases[1]);
|
||||
|
||||
// Der Topf ist die Summe der Zuflüsse minus einmaliger Kosten.
|
||||
expect(pot.total).toBe(pot.fromPreviousCash + pot.capitalInflow + pot.oneOffInflow - pot.oneOffOutflow);
|
||||
expect(pot.capitalInflow).toBe(200000); // Verkauf des Vermögens
|
||||
expect(pot.oneOffInflow).toBe(100000); // Erbschaft, steuerfrei
|
||||
// Nichts verteilt -> alles bleibt auf dem Cash.
|
||||
expect(pot.allocatedInvestments).toBe(0);
|
||||
expect(pot.allocatedRepayments).toBe(0);
|
||||
expect(pot.rest).toBe(pot.total);
|
||||
expect(pot.rest).toBe(r.phases[1].cashStart);
|
||||
});
|
||||
|
||||
it("Zusatzeinlage verschiebt den Topf von Cash ins Vermögen, ohne ihn zu verändern", () => {
|
||||
const { p, investId } = basePlan();
|
||||
const vorher = capitalPot(computePlan(p).phases[1]);
|
||||
|
||||
const nachher = capitalPot(
|
||||
computePlan(applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 150000 }]))
|
||||
.phases[1]
|
||||
);
|
||||
|
||||
expect(nachher.total).toBe(vorher.total); // der Topf selbst bleibt gleich gross
|
||||
expect(nachher.allocatedInvestments).toBe(150000);
|
||||
expect(nachher.rest).toBe(vorher.rest - 150000);
|
||||
// Kontrolle: Topf = verteilt + Rest
|
||||
expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total);
|
||||
});
|
||||
|
||||
it("Sofort-Tilgung am Übergang zählt als verteiltes Kapital", () => {
|
||||
const { p, debtId } = basePlan();
|
||||
const nachher = capitalPot(
|
||||
computePlan(applyTransitionPatches(p, "p1", [{ elementId: debtId, field: "immediateRepayment", value: 40000 }]))
|
||||
.phases[1]
|
||||
);
|
||||
expect(nachher.allocatedRepayments).toBe(40000);
|
||||
expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total);
|
||||
});
|
||||
|
||||
it("Überverteilung drückt den Rest ins Minus (Liquiditätslücke)", () => {
|
||||
const { p, investId } = basePlan();
|
||||
const r = computePlan(
|
||||
applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 999999 }])
|
||||
);
|
||||
const pot = capitalPot(r.phases[1]);
|
||||
expect(pot.rest).toBeLessThan(0);
|
||||
expect(r.phases[1].cashNegative).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verteilung: Spar-/Verzehrquote", () => {
|
||||
it("absolute Quote ist die Summe über alle Phasenjahre -- und die Quote SINKT", () => {
|
||||
const { p } = basePlan();
|
||||
const r = computePlan(p);
|
||||
const q = quotaSummary(r.phases[0]);
|
||||
|
||||
// Genau der Grund, warum eine flache Jahresrate gefährlich ist: Das Einkommen bleibt bei
|
||||
// 0 % Lohnerhöhung nominal konstant, die real erfassten Ausgaben wachsen aber mit der
|
||||
// Inflation. Die Quote schrumpft dadurch über die Phase.
|
||||
expect(q.start).toBe(20000);
|
||||
expect(q.end).toBeLessThan(q.start);
|
||||
expect(q.end).toBe(Math.round(100000 - 80000 * Math.pow(1.015, 4)));
|
||||
|
||||
// Die absolute Quote liegt folgerichtig zwischen "5 x letztes Jahr" und "5 x erstes Jahr".
|
||||
expect(q.total).toBeGreaterThan(5 * q.end);
|
||||
expect(q.total).toBeLessThan(5 * q.start);
|
||||
// Ohne verteilte Raten geht die ganze Quote aufs Cash.
|
||||
expect(q.netToCash).toBe(q.total);
|
||||
expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart);
|
||||
expect(q.isConsumption).toBe(false);
|
||||
});
|
||||
|
||||
it("verteilte Sparraten mindern den Rest, der aufs Cash geht", () => {
|
||||
const { p, investId } = basePlan();
|
||||
const r = computePlan(
|
||||
applyPhasePatches(p, "p1", [{ elementId: investId, field: "annualContribution", value: 15000 }])
|
||||
);
|
||||
const q = quotaSummary(r.phases[0]);
|
||||
expect(q.allocatedOut).toBe(75000); // 15'000 x 5 Jahre
|
||||
expect(q.netToCash).toBe(q.total - q.allocatedOut + q.allocatedIn);
|
||||
// Kontrolle gegen die offiziellen Kennzahlen der Phase.
|
||||
expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart);
|
||||
});
|
||||
|
||||
it("Bezugsraten zählen als Zufluss und werden am Bestand gekappt", () => {
|
||||
// Verzehrphase: Ausgaben über Einkommen, finanziert aus dem Vermögen.
|
||||
const p = plan({
|
||||
age: 66,
|
||||
retirementAge: 65,
|
||||
initialCash: 0,
|
||||
phases: [{ id: "p1", durationYears: 5 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 40000, teuerungsausgleich: 0 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 70000, teuerungsausgleich: 0 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0, annualWithdrawal: 30000 } }),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
const q = quotaSummary(r.phases[0]);
|
||||
expect(q.isConsumption).toBe(true);
|
||||
expect(q.total).toBeLessThan(0);
|
||||
// Der Bestand von 50'000 lässt nur 50'000 Entnahme zu, nicht 5 x 30'000.
|
||||
expect(q.allocatedIn).toBe(50000);
|
||||
expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verteilung: Entwurfswerte anwenden", () => {
|
||||
it("lässt den Ausgangsplan unberührt und erhält bestehende Felder", () => {
|
||||
const { p, assetId, investId } = basePlan();
|
||||
const snapshot = JSON.stringify(p);
|
||||
|
||||
const geaendert = applyPhasePatches(p, "p2", [
|
||||
{ elementId: investId, field: "additionalInvestment", value: 1000 },
|
||||
]);
|
||||
expect(JSON.stringify(p)).toBe(snapshot);
|
||||
// Bestehendes Feld derselben Phase bleibt erhalten.
|
||||
expect(geaendert.elements.find((e) => e.id === investId)!.phaseValues.p2.expectedReturn).toBe(0);
|
||||
|
||||
// Übergangs-Entscheide dürfen nicht verloren gehen.
|
||||
const mitTilgung = applyTransitionPatches(p, "p1", [
|
||||
{ elementId: assetId, field: "partialSaleAmount", value: 500 },
|
||||
]);
|
||||
expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.decision).toBe("SELL");
|
||||
expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.partialSaleAmount).toBe(500);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user