// 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 = { 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: " 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, orderedPhaseIds: string[], phaseId: string ): Record { const out: Record = {}; 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; }