0953880a80
Deploy App / deploy (push) Successful in 1m10s
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>
263 lines
10 KiB
TypeScript
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;
|
|
}
|