Einmalige Sonderein-/ausgaben am Cash-Uebergang (Roadmap Nr. 1)
Deploy App / deploy (push) Successful in 1m51s

Der Cash-Uebergang zwischen zwei Phasen ist neu ein eigener Entscheid:
1:1 uebernehmen / einmaliger Zufluss / einmalige Kosten / beides. Die Betraege
gehen direkt aufs Cash-Konto und bleiben aus der Spar-/Verzehrquote heraus.

- Zufluss NOMINAL erfasst, real angezeigt (wie Einkommen), optionaler Steuersatz
  (Default 0 %). Kosten REAL erfasst, nominal angezeigt (wie Ausgaben).
  Umrechnung ueber den Bestands-Deflator an der Phasengrenze.
- Entscheid startet unbeantwortet und zaehlt im "offen"-Badge mit; eine neue Phase
  erzeugt damit automatisch einen offenen Cash-Entscheid am neuen Uebergang.
- Eigene Kopf-Kennzahlen statt Vermischung mit Kapitalzufluss/-investitionen:
  eine Erbschaft ist kein Verkaufserloes, ein Poolbau keine Investition.
- Cash ist kein FinancialElement -> der Entscheid haengt als JSON an der Von-Phase
  (neue Spalte Phase.cashTransition + Migration). Szenario-Kopie nimmt ihn mit.

Fuenf Regressionstests ergaenzt (13 -> 18). Spezifikation auf v0.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 08:14:44 +02:00
parent 87e6a5f564
commit 9f5bd754eb
12 changed files with 665 additions and 30 deletions
+98 -3
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
// --- kleine Bau-Helfer ---
@@ -21,7 +21,7 @@ function plan(opts: {
retirementAge: number;
inflation?: number;
initialCash?: number;
phases: { id: string; durationYears: number }[];
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
elements: ReturnType<typeof el>[];
household?: "SINGLE" | "COUPLE";
}): PlanInput {
@@ -32,7 +32,13 @@ function plan(opts: {
inflationRateDefault: opts.inflation ?? 2,
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 })),
phases: opts.phases.map((p, i) => ({
id: p.id,
sequenceNumber: i + 1,
name: p.id,
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
elements: opts.elements,
};
}
@@ -251,6 +257,95 @@ describe("V5 Golden Tests", () => {
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
});
it("Einmaliger Zufluss am Uebergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
const p = plan({
age: 40,
retirementAge: 70,
inflation: 0,
initialCash: 1000,
phases: [
{ id: "p1", durationYears: 2, cashTransition: { mode: "INFLOW", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const r = computePlan(p);
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzueglich 10% Steuer
expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft");
expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Uebergang
});
it("Einmalige Kosten am Uebergang: real erfasst, mit Inflation aufgewertet", () => {
// Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380.
const p = plan({
age: 40,
retirementAge: 70,
inflation: 2,
initialCash: 100000,
phases: [
{ id: "p1", durationYears: 10, cashTransition: { mode: "OUTFLOW", outflowLabel: "Poolbau", outflowAmount: 20000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const r = computePlan(p);
const erwartet = Math.round(20000 * Math.pow(1.02, 10));
expect(r.phases[1].oneOffOutflow).toBe(erwartet);
expect(r.phases[1].oneOffOutflowLabel).toBe("Poolbau");
expect(r.phases[1].cashStart).toBe(100000 - erwartet);
});
it("Zufluss und Kosten zusammen (BOTH); Modus NONE bleibt wirkungslos", () => {
const beide = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
{ id: "p1", durationYears: 1, cashTransition: { mode: "BOTH", inflowAmount: 50000, outflowAmount: 20000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const r1 = computePlan(beide);
expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000
// Betraege sind erfasst, aber der Entscheid lautet "1:1 uebernehmen" -> keine Wirkung.
const keine = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
{ id: "p1", durationYears: 1, cashTransition: { mode: "NONE", inflowAmount: 50000, outflowAmount: 20000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
expect(computePlan(keine).phases[1].cashStart).toBe(0);
});
it("Einmalige Kosten koennen eine Liquiditaetsluecke ausloesen", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 10000,
phases: [
{ id: "p1", durationYears: 1, cashTransition: { mode: "OUTFLOW", outflowAmount: 25000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const p2 = computePlan(p).phases[1];
expect(p2.cashStart).toBe(-15000);
expect(p2.cashNegative).toBe(true);
expect(p2.incomplete).toBe(true);
});
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Uebergang mehr)", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 5000,
phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }],
elements: [],
});
const r = computePlan(p);
expect(r.phases[0].cashEnd).toBe(5000);
expect(r.nachlass).toBe(5000);
});
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
const p = plan({
age: 40,