Files
FPT/src/lib/elements.ts
T
admGitAICDS 0953880a80
Deploy App / deploy (push) Successful in 1m10s
Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Kapitalverwendung (Punkt C) am richtigen Ort:
- Prozent-Aufteilung des bezogenen Alterskapitals wandert vom Cash-Uebergang
  zum Bezugs-Entscheid der PK (nur bei Kapitalbezug) bzw. der Saeule 3a --
  mit zwei Guthaben liess sie sich vorher gar nicht getrennt beantworten
- Dialoge fuehren neu brutto -> Steuersatz -> netto -> Verteilung
- BUGFIX: Der zugeteilte Betrag erhoehte still den internen Bestand, deshalb
  zeigten Ziel-Element und "Kapital verteilen" eine 0. Er laeuft jetzt ueber
  Carry.capitalIn als Zusatzeinlage der Folgephase und ist ueberall sichtbar
- Saeule 3a ist am Pensions-Uebergang neu ein offener Entscheid

Phasendauer (gemeldeter Fehler):
- Die Folgephase gleicht eine geaenderte Dauer aus; Gesamtdauer bleibt gleich
- Vorher kappte das Tool nur die bearbeitete Phase -> Phase 2 ueberspannte
  danach die Pensionierung und die Invariante aus Punkt 44 kippte
- Rueckfrage vorher, Blockade wenn die Folgephase unter 1 Jahr fiele
- neue reine Funktion planDurationChange

Bedienung:
- Element-Zeile und Phasenkopf: Stift (umbenennen, beim Element inkl.
  Zuordnung), Papierkorb, Expand -- alle immer sichtbar
- PATCH /api/elements/<id> nimmt neu auch ownerRole
- Hilfetexte via Portal (wurden in scrollenden Dialogen abgeschnitten)
- Verteil-Dialoge: Zuordnung je Zeile, nach vom/ins Cash gruppiert,
  Vorbelegung mit dem EFFEKTIVEN Wert inkl. Vererbung (zeigte vorher 0)
- Matrix: gleiche Spaltenbreiten + horizontales Scrollen, "Alle auf-/
  zuklappen", Kategorie-Summe in der zugeklappten Zeile
- Phasen-Detailansicht nutzt die neue Aufteilungs-Grafik
- Uebersicht: "Leer starten" auch im leeren Zustand

Nebenbei: dritte verstuemmelte Hex-Farbe (#7c3aed) repariert, Phasen-Panel
nutzt den eigenen Bestaetigungs-Dialog statt window.confirm; mehrere veraltete
Referenzen und die buildCarryData-Tabelle in der Spez nachgezogen.

SPEZIFIKATION 0.33. 278 -> 288 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:09:03 +02:00

263 lines
10 KiB
TypeScript

// Typ- und Validierungs-Layer für die finanziellen Elemente. Die kategorie- und
// kontextspezifischen Felder liegen in der DB als JSON; hier werden sie typisiert und
// (an der API-Grenze) mit Zod validiert.
import { z } from "zod";
export type ElementCategory =
| "INCOME"
| "EXPENSE"
| "AHV"
| "PENSION_FUND"
| "PILLAR_3A"
| "REAL_ESTATE"
| "OTHER_ASSET"
| "OTHER_DEBT";
export type OwnerRole = "PERSON_A" | "PERSON_B" | "HOUSEHOLD";
// Kategorien, deren Element zwingend genau einer Person zugeordnet ist.
export const PERSON_ONLY_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"];
// Kategorien, die gemeinsam ODER pro Person erfasst werden können (inkl. Einkommen).
export const OWNER_OPTIONAL_CATEGORIES: ElementCategory[] = ["INCOME", "EXPENSE", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT"];
export const CATEGORY_LABELS: Record<ElementCategory, string> = {
INCOME: "Einkommen",
EXPENSE: "Ausgaben",
AHV: "AHV",
PENSION_FUND: "Pensionskasse",
PILLAR_3A: "Säule 3a",
REAL_ESTATE: "Immobilie",
OTHER_ASSET: "Sonstiges Vermögen",
OTHER_DEBT: "Sonstige Schulden",
};
// Reihenfolge der Kategorien in der Matrix (Gruppierung der Zeilen).
export const CATEGORY_ORDER: ElementCategory[] = [
"INCOME",
"EXPENSE",
"AHV",
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
];
// --- Roh-Payloads (JSON in der DB) ---
// Bewusst tolerant getippt (alle Felder optional): die Berechnung liest defensiv mit
// Defaults, das UI zeigt je nach Kontext nur die relevanten Felder.
export interface PhaseData {
// INCOME / EXPENSE
amount?: number;
// INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die
// Phasenjahre (Jahr t = Basis x (1+idx)^(t-1)). Eigenes Feld je Element (Einkommen und
// Ausgaben unabhängig). Default = Phaseninflation.
teuerungsausgleich?: number;
// AHV
gapYears?: number;
// Nur AHV, nur in Phasen NACH einer Fruehpensionierung: jaehrlicher Beitrag als
// Nichterwerbstaetige(r) bis zum Referenzalter. Faellt als Kosten an (Kap. 4.4.6).
ahvContribution?: number;
// AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen
// Pensions-Übergang, an dem die Karriere geprüft werden könnte): Beitragskarriere.
// avgIncomeBefore ist REAL (heutige Kaufkraft).
avgIncomeBefore?: number;
gapYearsBefore?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET
currentValue?: number;
startValue?: number;
expectedReturn?: number;
annualContribution?: number;
// PILLAR_3A: «grosse Säule 3a» für Selbstständige ohne PK -> höhere Beitrags-Obergrenze.
selfEmployed3a?: boolean;
// OTHER_ASSET: jährliche Bezugsrate (Entnahme). Mindert das Vermögen und fliesst ins Cash.
annualWithdrawal?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusätzliche Einlage aus dem
// verfügbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
additionalInvestment?: number;
// REAL_ESTATE
purchasePrice?: number;
mortgage?: number;
amortization?: number;
// Hypothekarzins in % der Restschuld. Der Zinsbetrag sinkt dadurch mit der Amortisation.
interestRate?: number;
// Steuert die Doppelzählung: Sind die Zinsen im Ausgaben-Element bereits enthalten
// (INCLUDED, Default -- Verhalten bisheriger Pläne) oder soll das Tool sie dazurechnen (ADD)?
interestHandling?: "INCLUDED" | "ADD";
// Geschätzte jährliche Wertveränderung der LIEGENSCHAFT (nicht des Eigenkapitals).
valueGrowth?: number;
// OTHER_DEBT
annualRepayment?: number;
}
export type TransitionDecision = "HOLD" | "SELL" | "PARTIAL";
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
export interface TransitionData {
// AHV (Pensions-Übergang): Prüfung der Beitragskarriere. Das geplante Durchschnitts-
// einkommen kommt aus dem Plan; reicht der Plan nicht bis zum Beitragsbeginn (Alter 21)
// zurück, ergänzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL.
reviewed?: boolean;
avgIncomeBefore?: number;
gapYearsBefore?: number;
// PENSION_FUND / PILLAR_3A (normaler Übergang): expliziter Bezugs-Entscheid.
withdrawalMode?: "NONE" | "AMOUNT";
withdrawal?: number;
// PENSION_FUND (Pensions-Übergang)
payoutMode?: PkPayoutMode;
capitalAmount?: number;
conversionRate?: number;
// PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Übergang) / REAL_ESTATE
capitalTaxRate?: number;
saleTaxRate?: number;
// REAL_ESTATE / OTHER_ASSET
decision?: TransitionDecision;
salePrice?: number;
// OTHER_ASSET (Teilverkauf): Betrag, der am Übergang ins Cash fliesst; der Rest bleibt aktiv.
partialSaleAmount?: number;
// REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Übergang, aus dem Cash.
extraAmortization?: number;
// OTHER_DEBT
immediateRepayment?: number;
// Verwendung des BEZOGENEN KAPITALS (Roadmap Nr. 44, Punkt C).
//
// Gilt für PENSION_FUND (nur bei Kapitalbezug) und PILLAR_3A am Pensions-Übergang: Wohin
// fliesst das ausbezahlte Alterskapital? Bewusst in PROZENT und nicht in Franken -- wird
// das Pensionsalter verschoben, ändert sich der Betrag, und eine Quote skaliert mit,
// während eine Frankenzahl still falsch würde. Der nicht zugeteilte Rest bleibt Cash.
//
// Bis 0.32 lagen diese Felder am CASH-Übergang. Das war der falsche Ort: Die Frage gehört
// zum Bezugs-Entscheid des jeweiligen Vorsorgeguthabens, nicht zum Cash-Konto -- und mit
// zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten.
capitalUseAmortizationPct?: number;
capitalUseInvestPct?: number;
// Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen».
capitalUseTargetElementId?: string;
}
// --- Cash-Übergang: einmalige Sonderein-/ausgaben ---
// Entscheid am UEBERGANG zwischen zwei Phasen, direkt auf dem Cash-Konto (Cash ist kein
// Element, der Entscheid hängt darum an der Von-Phase). Erfassungs-Konventionen analog zu
// den laufenden Flows: Zufluss NOMINAL (wie Einkommen), Kosten REAL (wie Ausgaben).
export type CashTransitionMode = "NONE" | "INFLOW" | "OUTFLOW" | "BOTH";
export interface CashTransitionData {
mode?: CashTransitionMode;
// Einmaliger Zufluss (z. B. Erbschaft): NOMINAL erfasst, Steuersatz optional (Default 0 %).
inflowLabel?: string;
inflowAmount?: number;
inflowTaxRate?: number;
// Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft).
outflowLabel?: string;
outflowAmount?: number;
}
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
const nonNeg = z.number().min(0);
export const cashTransitionSchema = z
.object({
mode: z.enum(["NONE", "INFLOW", "OUTFLOW", "BOTH"]).optional(),
inflowLabel: z.string().max(120).optional(),
inflowAmount: nonNeg.optional(),
inflowTaxRate: z.number().min(0).max(100).optional(),
outflowLabel: z.string().max(120).optional(),
outflowAmount: nonNeg.optional(),
})
.strip();
export const phaseDataSchema = z
.object({
amount: nonNeg.optional(),
teuerungsausgleich: z.number().min(-20).max(50).optional(),
gapYears: z.number().int().min(0).optional(),
ahvContribution: nonNeg.optional(),
avgIncomeBefore: nonNeg.optional(),
gapYearsBefore: z.number().int().min(0).max(50).optional(),
currentValue: nonNeg.optional(),
startValue: nonNeg.optional(),
expectedReturn: z.number().min(-50).max(100).optional(),
annualContribution: nonNeg.optional(),
selfEmployed3a: z.boolean().optional(),
annualWithdrawal: nonNeg.optional(),
additionalInvestment: nonNeg.optional(),
purchasePrice: nonNeg.optional(),
mortgage: nonNeg.optional(),
amortization: nonNeg.optional(),
interestRate: z.number().min(0).max(20).optional(),
interestHandling: z.enum(["INCLUDED", "ADD"]).optional(),
valueGrowth: z.number().min(-20).max(20).optional(),
annualRepayment: nonNeg.optional(),
})
.strip();
export const transitionDataSchema = z
.object({
reviewed: z.boolean().optional(),
avgIncomeBefore: nonNeg.optional(),
gapYearsBefore: z.number().int().min(0).max(50).optional(),
withdrawalMode: z.enum(["NONE", "AMOUNT"]).optional(),
withdrawal: nonNeg.optional(),
payoutMode: z.enum(["CAPITAL", "PENSION", "COMBI"]).optional(),
capitalAmount: nonNeg.optional(),
conversionRate: z.number().min(0).max(20).optional(),
capitalTaxRate: z.number().min(0).max(100).optional(),
saleTaxRate: z.number().min(0).max(100).optional(),
decision: z.enum(["HOLD", "SELL", "PARTIAL"]).optional(),
salePrice: nonNeg.optional(),
partialSaleAmount: nonNeg.optional(),
extraAmortization: nonNeg.optional(),
immediateRepayment: nonNeg.optional(),
capitalUseAmortizationPct: z.number().min(0).max(100).optional(),
capitalUseInvestPct: z.number().min(0).max(100).optional(),
capitalUseTargetElementId: z.string().max(60).optional(),
})
.strip();
export function num(value: number | undefined | null, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
// --- Vererbung der Wiederkehr-Parameter (Roadmap Nr. 44, Punkt A) -----------------------
//
// Raten, Beitraege, Amortisation, Wertsteigerung und Zinssatz gelten weiter, bis man sie
// bewusst aendert: Fehlt der Wert in einer Phase, gilt der aus der Vorphase. Genau diese
// Regel wendet auch `computePlan` an -- hier steht sie fuer das UI, damit die Anzeige
// "Aus Vorphase uebernehmen: <Wert>" nicht raten muss.
export const INHERITABLE_KEYS = [
"teuerungsausgleich",
"expectedReturn",
"annualContribution",
"annualWithdrawal",
"amortization",
"valueGrowth",
"interestRate",
"annualRepayment",
] as const;
export type InheritableKey = (typeof INHERITABLE_KEYS)[number];
// Wert, der in `phaseId` gelten wuerde, wenn das Feld dort leer bleibt. `orderedPhaseIds`
// muss in Phasenreihenfolge vorliegen.
export function inheritedPhaseValues(
phaseValues: Record<string, PhaseData>,
orderedPhaseIds: string[],
phaseId: string
): Record<string, number> {
const out: Record<string, number> = {};
for (const id of orderedPhaseIds) {
if (id === phaseId) break;
const pd = phaseValues[id] ?? {};
for (const key of INHERITABLE_KEYS) {
const v = pd[key];
if (typeof v === "number") out[key] = v;
}
}
return out;
}