9252f7188d
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>
212 lines
8.3 KiB
TypeScript
212 lines
8.3 KiB
TypeScript
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);
|
|
});
|
|
});
|