Phasenkopf-Ueberarbeitung: zweizeilige Werte, Verfuegbares Kapital, Verteil-Werkzeuge
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:
2026-07-19 12:30:36 +02:00
parent c2fb82b0de
commit 9252f7188d
5 changed files with 1199 additions and 26 deletions
+144
View File
@@ -0,0 +1,144 @@
// Verteilung von verfügbarem Kapital und laufender Spar-/Verzehrquote.
//
// Reine Orchestrierung: Beide Werkzeuge schreiben ausschliesslich Felder, die es längst gibt
// (additionalInvestment, extraAmortization, immediateRepayment, annualContribution,
// annualWithdrawal, amortization, annualRepayment). An der Berechnung ändert sich nichts --
// die Vorschau entsteht, indem der Plan mit den Entwurfswerten kopiert und erneut durch
// computePlan geschickt wird. Dadurch ist die angezeigte Wirkung per Konstruktion exakt die
// spätere, inklusive aller Kappungen (z. B. Bezugsrate am Bestand, Amortisation an der
// Restschuld).
import type { PhaseComputed } from "@/lib/calculations";
import type { PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
// --- Verfügbares Kapital ------------------------------------------------------------------
//
// Der "Topf" ist das Kapital, das beim Übergang IN diese Phase zur Verfügung steht -- also
// bevor etwas davon investiert oder in Schulden gesteckt wurde. Alle Bestandteile stammen aus
// der Cash-Brücke (seit 0.11), es braucht keine zusätzliche Berechnung:
//
// Topf = Cash-Ende der Vorphase + Kapitalzufluss + einmaliger Zufluss einmalige Kosten
// = cashStart + Investitionen + Sofort-Tilgungen
//
// Die zweite Form ist die, die hier verwendet wird: Sie zerlegt den Topf direkt in
// "schon verteilt" und "Rest auf Cash".
export interface CapitalPot {
total: number; // gesamter verteilbarer Betrag
fromPreviousCash: number; // Cash-Endbestand der Vorphase
capitalInflow: number; // Verkäufe, PK-/3a-Bezüge
oneOffInflow: number; // Erbschaft o. ä. (netto nach Steuer)
oneOffOutflow: number; // einmalige Kosten (mindern den Topf)
allocatedInvestments: number; // Zusatzeinlagen in PK/3a/Vermögen
allocatedRepayments: number; // Sonderamortisation + Sofort-Tilgung
rest: number; // bleibt auf dem Cash-Konto (= cashStart)
}
export function capitalPot(phase: PhaseComputed): CapitalPot {
const c = phase.cashBridge;
return {
total: c.cashStart + c.investments + c.immediateRepay,
fromPreviousCash: c.openingCash,
capitalInflow: c.capitalInflow,
oneOffInflow: c.oneOffInflow,
oneOffOutflow: c.oneOffOutflow,
allocatedInvestments: c.investments,
allocatedRepayments: c.immediateRepay,
rest: c.cashStart,
};
}
// --- Spar-/Verzehrquote -------------------------------------------------------------------
//
// Die Quote ist KEIN fester Betrag: Einkommen wächst mit der Lohnerhöhung, Ausgaben mit der
// Inflation. Deshalb werden drei Grössen ausgewiesen -- erstes Jahr, letztes Jahr und die
// Summe über die ganze Phase ("absolute Quote"). Die Summe ist die Grösse, gegen die sich
// eine flache Jahresrate sinnvoll verteilen lässt.
//
// Hinweis zur Einheit: `total` summiert nominale Franken verschiedener Jahre -- exakt so, wie
// das Cash-Konto im Modell funktioniert. Für die Frage "wie viel Cash steht über die Phase
// zur Verfügung" ist das die richtige Grösse, als Kaufkraft-Aussage taugt sie nicht.
export interface QuotaSummary {
start: number; // Quote im ersten Phasenjahr
end: number; // Quote im letzten Phasenjahr
total: number; // Summe über alle Phasenjahre
allocatedOut: number; // Sparbeiträge + Amortisationen + Tilgungen, über die Phase summiert
allocatedIn: number; // Bezugsraten, über die Phase summiert
netToCash: number; // was unter dem Strich aufs Cash geht (= cashEnd cashStart)
isConsumption: boolean;
}
export function quotaSummary(phase: PhaseComputed): QuotaSummary {
const c = phase.cashBridge;
const allocatedOut = c.savingRates + c.debtRates;
return {
start: phase.quotaStart,
end: phase.quotaEnd,
total: c.quotaTotal,
allocatedOut,
allocatedIn: c.withdrawals,
netToCash: c.quotaTotal - allocatedOut + c.withdrawals,
isConsumption: c.quotaTotal < 0,
};
}
// --- Entwurfswerte auf einen Plan anwenden (für die Live-Vorschau) ------------------------
// Beide Funktionen sind rein und lassen den Ausgangsplan unberührt.
export interface PhasePatch {
elementId: string;
field: keyof PhaseData;
value: number;
}
export function applyPhasePatches(plan: PlanInput, phaseId: string, patches: PhasePatch[]): PlanInput {
if (patches.length === 0) return plan;
const byElement = new Map<string, PhasePatch[]>();
for (const p of patches) {
const list = byElement.get(p.elementId) ?? [];
list.push(p);
byElement.set(p.elementId, list);
}
return {
...plan,
elements: plan.elements.map((e) => {
const list = byElement.get(e.id);
if (!list) return e;
const merged: PhaseData = { ...(e.phaseValues[phaseId] ?? {}) };
for (const p of list) (merged as Record<string, number>)[p.field as string] = p.value;
return { ...e, phaseValues: { ...e.phaseValues, [phaseId]: merged } };
}),
};
}
export interface TransitionPatch {
elementId: string;
field: keyof TransitionData;
value: number;
}
export function applyTransitionPatches(
plan: PlanInput,
fromPhaseId: string,
patches: TransitionPatch[]
): PlanInput {
if (patches.length === 0) return plan;
const byElement = new Map<string, TransitionPatch[]>();
for (const p of patches) {
const list = byElement.get(p.elementId) ?? [];
list.push(p);
byElement.set(p.elementId, list);
}
return {
...plan,
elements: plan.elements.map((e) => {
const list = byElement.get(e.id);
if (!list) return e;
// Bestehende Entscheide (decision, salePrice, payoutMode …) bleiben erhalten -- hier
// werden nur die Betragsfelder überschrieben.
const merged: TransitionData = { ...(e.transitionValues[fromPhaseId] ?? {}) };
for (const p of list) (merged as Record<string, number>)[p.field as string] = p.value;
return { ...e, transitionValues: { ...e.transitionValues, [fromPhaseId]: merged } };
}),
};
}