Netto/Brutto-Klarstellung fuer die AHV + erweitertes Immobilien-Modul
Deploy App / deploy (push) Successful in 59s
Deploy App / deploy (push) Successful in 59s
Roadmap Nr. 9 (reduziert): Einkommen ist neu explizit als NETTOLOHN definiert (Label + Hilfetext). Bisher stand nirgends, ob netto oder brutto gemeint ist -- fuer den Cash-Fluss egal (beide Konventionen heben sich auf), aber seit der einkommensabhaengigen AHV haengt eine Rente daran. Die AHV bemisst sich am Bruttolohn, deshalb rechnet das Tool intern mit AHV_GROSS_FROM_NET_FACTOR = 1.12 hoch. Ohne das war die Rente um bis zu ~1'900/Jahr zu tief (Details: SPEZ 9.13). Der Faktor ist hergeleitet und dokumentiert (AHV/IV/EO 5.3% + ALV 1.1% + NBU ~1% + PK ~2-5% auf den koordinierten Lohn) -- fix vertretbar, weil das mdJE selbst ein Karriere-Durchschnitt ist. Keine Aufschluesselung, kein sichtbares Feld (kommt mit Roadmap Nr. 41 als erklaerte Konstante). Roadmap Nr. 8: Immobilie neu mit Hypothekarzins (% der Restschuld, Zinsbetrag sinkt mit der Amortisation, read-only "Beginn -> Ende") und Wertsteigerung. WICHTIG: Die Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000/Jahr, also 10% eines Eigenkapitals von 100'000 -- das ist der Hebel. Auf dem EK gerechnet waeren es 1'000 (Beispiel: 304'622 statt 210'462). Kaufpreis und Verkehrswert laufen deshalb getrennt; die Grundstueckgewinnsteuer bemisst sich weiterhin am urspruenglichen Kaufpreis. Doppelzaehlung: Schalter interestHandling auf der Immobilie, Default INCLUDED -- bestehende Plaene haben die Zinsen in den Ausgaben und aendern sich nicht. Keine Steuerschaetzung (Begruendung: SPEZ 9.14). Sechs Regressionstests (30 -> 36). Spezifikation auf v0.5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -93,8 +93,15 @@ function buildCarryData(category: string, prev: PhaseData): PhaseData {
|
||||
case "OTHER_ASSET":
|
||||
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
|
||||
case "REAL_ESTATE":
|
||||
// purchasePrice + amortization bleiben; die Resthypothek wird live fortgeschrieben.
|
||||
return { purchasePrice: num(prev.purchasePrice), amortization: num(prev.amortization) };
|
||||
// purchasePrice + amortization bleiben; Resthypothek und Verkehrswert werden live
|
||||
// fortgeschrieben. Zinssatz, Zins-Behandlung und Wertsteigerung gelten weiter.
|
||||
return {
|
||||
purchasePrice: num(prev.purchasePrice),
|
||||
amortization: num(prev.amortization),
|
||||
interestRate: num(prev.interestRate),
|
||||
interestHandling: prev.interestHandling ?? "INCLUDED",
|
||||
valueGrowth: num(prev.valueGrowth),
|
||||
};
|
||||
case "OTHER_DEBT":
|
||||
return { annualRepayment: num(prev.annualRepayment) };
|
||||
default:
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface CellContext {
|
||||
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
|
||||
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
|
||||
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
|
||||
derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn
|
||||
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
||||
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
|
||||
laterPhaseCount: number;
|
||||
@@ -148,7 +149,8 @@ export function AhvReviewFields({
|
||||
}) {
|
||||
const brauchtVorgeschichte = career.yearsBeforePlan > 0;
|
||||
// Vorbelegung mit dem geplanten Durchschnitt: besser als 0, wenn der Nutzer nichts weiss.
|
||||
const avgBefore = typeof td.avgIncomeBefore === "number" ? td.avgIncomeBefore : Math.round(career.plannedAvgIncome);
|
||||
const avgBefore =
|
||||
typeof td.avgIncomeBefore === "number" ? td.avgIncomeBefore : Math.round(career.plannedAvgGrossIncome);
|
||||
const gapBefore = Math.max(0, Math.round(num(td.gapYearsBefore)));
|
||||
const mdJE = ahvMdje(career, brauchtVorgeschichte ? avgBefore : 0, gapBefore);
|
||||
const rente = ahvAnnualPension(mdJE, career.gapYearsInPlan + gapBefore);
|
||||
@@ -157,14 +159,16 @@ export function AhvReviewFields({
|
||||
<>
|
||||
<p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Die AHV-Rente haengt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
|
||||
ueber die ganze Beitragsdauer (ab Alter 21) ab. Alle Betraege sind REAL (heutige Kaufkraft) –
|
||||
die AHV wertet vergangene Einkommen auf und indexiert die Schwellen, was sich real weitgehend aufhebt.
|
||||
ueber die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der <strong className="text-fg">Bruttolohn</strong>;
|
||||
das Tool rechnet die im Plan erfassten Nettoeinkommen dafuer automatisch hoch. Alle Betraege sind
|
||||
REAL (heutige Kaufkraft) – die AHV wertet vergangene Einkommen auf und indexiert die Schwellen,
|
||||
was sich real weitgehend aufhebt.
|
||||
</p>
|
||||
|
||||
<DerivedField
|
||||
label="Geplantes Durchschnittseinkommen (aus dem Plan)"
|
||||
value={Math.round(career.plannedAvgIncome)}
|
||||
help={`Reales Mittel ueber ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans. Nur Einkommen, die dieser Person zugeordnet sind.`}
|
||||
label="Geplantes Bruttoeinkommen (aus dem Plan)"
|
||||
value={Math.round(career.plannedAvgGrossIncome)}
|
||||
help={`Reales Mittel ueber ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans, hochgerechnet vom Netto- auf den Bruttolohn. Nur Einkommen, die dieser Person zugeordnet sind.`}
|
||||
/>
|
||||
<DerivedField
|
||||
label="Beitragsjahre im Plan"
|
||||
@@ -179,8 +183,8 @@ export function AhvReviewFields({
|
||||
davor kennt das Tool nicht – bitte ergaenzen.
|
||||
</p>
|
||||
<MoneyField
|
||||
label="Durchschnittseinkommen vor Planbeginn (real)"
|
||||
help="In HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung uebernehmen – dort ist die Aufwertung bereits enthalten. Die Rohbetraege im Kontoauszug sind historisch-nominal und daher zu tief."
|
||||
label="Durchschnittliches Bruttoeinkommen vor Planbeginn (real)"
|
||||
help="BRUTTO und in HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung uebernehmen – dort ist die Aufwertung bereits enthalten. Die Rohbetraege im Kontoauszug sind historisch-nominal und daher zu tief."
|
||||
value={avgBefore}
|
||||
onChange={(v) => setT({ avgIncomeBefore: v })}
|
||||
/>
|
||||
@@ -416,11 +420,11 @@ export function ElementPhaseFields({
|
||||
<>
|
||||
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
{isIncome
|
||||
? "Einkommen wird NOMINAL erfasst (die Zahl auf dem Lohnausweis). Der reale Wert (heutige Kaufkraft) wird nur zur Info angezeigt."
|
||||
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzuegen (AHV/ALV, Pensionskasse, NBU) tatsaechlich aufs Konto kommt – nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Fuer die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurueck."
|
||||
: "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben – nur zur Info."}
|
||||
</p>
|
||||
<MoneyField
|
||||
label={isIncome ? "Jahreseinkommen NOMINAL (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
|
||||
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
|
||||
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst aenderbar." : undefined}
|
||||
value={baseValue}
|
||||
onChange={(v) => setP({ amount: v })}
|
||||
@@ -457,8 +461,8 @@ export function ElementPhaseFields({
|
||||
bitte hier erfassen (REAL, heutige Kaufkraft).
|
||||
</p>
|
||||
<MoneyField
|
||||
label="Durchschnittseinkommen der Beitragsjahre (real)"
|
||||
help="Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfuegung. Ab 90'720 gibt es die Maximalrente."
|
||||
label="Durchschnittliches Bruttoeinkommen der Beitragsjahre (real)"
|
||||
help="BRUTTO und in heutiger Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfuegung. Ab 90'720 gibt es die Maximalrente."
|
||||
value={num(pd.avgIncomeBefore)}
|
||||
onChange={(v) => setP({ avgIncomeBefore: v })}
|
||||
/>
|
||||
@@ -559,34 +563,75 @@ export function ElementPhaseFields({
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
</>
|
||||
);
|
||||
case "REAL_ESTATE":
|
||||
if (carried) {
|
||||
return (
|
||||
<>
|
||||
<DerivedField label="Startwert Netto (fortgeschrieben)" value={context.derivedStart} help="Kaufpreis minus fortgeschriebene Resthypothek." />
|
||||
<MoneyField
|
||||
label="Amortisation (CHF/Jahr)"
|
||||
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
|
||||
value={num(pd.amortization)}
|
||||
|
||||
onChange={(v) => setP({ amortization: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "REAL_ESTATE": {
|
||||
// Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation,
|
||||
// der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung).
|
||||
const hypStart = carried ? context.derivedMortgage : num(pd.mortgage);
|
||||
const hypEnde = Math.max(0, hypStart - num(pd.amortization) * context.durationYears);
|
||||
const zinsStart = Math.round((hypStart * num(pd.interestRate)) / 100);
|
||||
const zinsEnde = Math.round((hypEnde * num(pd.interestRate)) / 100);
|
||||
const handling = pd.interestHandling ?? "INCLUDED";
|
||||
return (
|
||||
<>
|
||||
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
|
||||
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
|
||||
{carried ? (
|
||||
<>
|
||||
<DerivedField
|
||||
label="Startwert Netto (fortgeschrieben)"
|
||||
value={context.derivedStart}
|
||||
help="Verkehrswert (inkl. bisheriger Wertsteigerung) minus fortgeschriebene Resthypothek."
|
||||
/>
|
||||
<DerivedField label="Resthypothek (fortgeschrieben)" value={hypStart} help="Aus der Vorphase fortgeschrieben." />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
|
||||
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
|
||||
</>
|
||||
)}
|
||||
<MoneyField
|
||||
label="Amortisation (CHF/Jahr)"
|
||||
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
|
||||
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||
value={num(pd.amortization)}
|
||||
|
||||
onChange={(v) => setP({ amortization: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Hypothekarzins (%/Jahr)"
|
||||
help="Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
||||
step={0.1}
|
||||
value={num(pd.interestRate)}
|
||||
onChange={(v) => setP({ interestRate: v })}
|
||||
/>
|
||||
<div>
|
||||
<FieldLabel
|
||||
label="Hypothekarzins-Betrag (Beginn → Ende)"
|
||||
help="Restschuld x Zinssatz, im ersten und im letzten Jahr dieser Phase. Wird automatisch berechnet."
|
||||
/>
|
||||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||
{formatChf(zinsStart)} <span className="text-faint">→</span> {formatChf(zinsEnde)}
|
||||
</div>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Geschaetzte Wertsteigerung (%/Jahr)"
|
||||
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)."
|
||||
step={0.1}
|
||||
value={num(pd.valueGrowth)}
|
||||
onChange={(v) => setP({ valueGrowth: v })}
|
||||
/>
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
|
||||
help="Damit die Zinsen nicht doppelt zaehlen. Standard: bereits enthalten – so verhalten sich bestehende Plaene unveraendert."
|
||||
value={handling}
|
||||
onChange={(v: "INCLUDED" | "ADD") => setP({ interestHandling: v })}
|
||||
options={[
|
||||
{ value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element beruecksichtigt" },
|
||||
{ value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzaehlen" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "OTHER_ASSET":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -193,6 +193,7 @@ export function PlanView({
|
||||
carriedEndValue: ce?.endValue ?? 0,
|
||||
carried: ce?.carried ?? false,
|
||||
derivedStart: ce?.baseValue ?? 0,
|
||||
derivedMortgage: ce?.mortgageStart ?? 0,
|
||||
deflatorStart: phase.cumulativeInflationStart,
|
||||
laterPhaseCount: laterPhaseCount(phase),
|
||||
ahvCareer: careerFor(element),
|
||||
@@ -211,6 +212,7 @@ export function PlanView({
|
||||
carriedEndValue: ce?.endValue ?? 0,
|
||||
carried: ce?.carried ?? false,
|
||||
derivedStart: 0,
|
||||
derivedMortgage: 0,
|
||||
deflatorStart: fromPhase.cumulativeInflationStart,
|
||||
laterPhaseCount: laterPhaseCount(fromPhase),
|
||||
ahvCareer: careerFor(element),
|
||||
@@ -935,6 +937,7 @@ function AddElementDialog({
|
||||
carriedEndValue: 0,
|
||||
carried: false,
|
||||
derivedStart: 0,
|
||||
derivedMortgage: 0,
|
||||
deflatorStart: firstPhase.cumulativeInflationStart,
|
||||
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
|
||||
ahvCareer: null,
|
||||
|
||||
@@ -84,6 +84,83 @@ describe("AHV-Rentenformel (Skala 44)", () => {
|
||||
|
||||
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
|
||||
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
|
||||
// Netto -> Brutto fuer die AHV (AHV_GROSS_FROM_NET_FACTOR).
|
||||
const BRUTTO = 1.12;
|
||||
|
||||
describe("Immobilie: Zins und Wertsteigerung", () => {
|
||||
function immoPlan(pd: PhaseData, jahre = 10) {
|
||||
return plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
inflation: 0,
|
||||
initialCash: 500000,
|
||||
phases: [{ id: "p1", durationYears: jahre }],
|
||||
elements: [el("REAL_ESTATE", "HOUSEHOLD", { p1: pd })],
|
||||
});
|
||||
}
|
||||
const immo = (p: PlanInput, i = 0) =>
|
||||
computePlan(p).phases[i].elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
|
||||
it("Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital (Hebel)", () => {
|
||||
// Kaufpreis 1 Mio, Hypothek 900k -> EK 100k. 1%/J. auf die Liegenschaft, Amort. 10k/J.
|
||||
const p = immoPlan({ purchasePrice: 1000000, mortgage: 900000, amortization: 10000, valueGrowth: 1 });
|
||||
const wertEnde = 1000000 * Math.pow(1.01, 10);
|
||||
const hypEnde = 900000 - 10000 * 10;
|
||||
expect(immo(p).startValue).toBe(100000);
|
||||
expect(immo(p).endValue).toBe(Math.round(wertEnde - hypEnde)); // ~304'622
|
||||
// Der Hebel: 1% auf 1 Mio sind rund 10% des Eigenkapitals -- nicht 1%.
|
||||
expect(immo(p).endValue).toBeGreaterThan(Math.round(100000 * Math.pow(1.01, 10) + 100000));
|
||||
});
|
||||
|
||||
it("ohne Wertsteigerung bleibt es beim bisherigen Verhalten", () => {
|
||||
const p = immoPlan({ purchasePrice: 1000000, mortgage: 900000, amortization: 10000 });
|
||||
expect(immo(p).endValue).toBe(200000); // 1 Mio - 800k Resthypothek
|
||||
});
|
||||
|
||||
it("Hypothekarzins belastet das Cash nur bei 'ADD'", () => {
|
||||
const ohne = immoPlan({ purchasePrice: 1000000, mortgage: 900000, amortization: 0, interestRate: 1 });
|
||||
expect(computePlan(ohne).phases[0].cashEnd).toBe(500000); // Default INCLUDED -> kein Abzug
|
||||
|
||||
const mit = immoPlan({
|
||||
purchasePrice: 1000000, mortgage: 900000, amortization: 0, interestRate: 1, interestHandling: "ADD",
|
||||
});
|
||||
// 10 Jahre x 1% von 900'000 = 90'000
|
||||
expect(computePlan(mit).phases[0].cashEnd).toBe(500000 - 90000);
|
||||
});
|
||||
|
||||
it("Zinsbetrag sinkt mit der Amortisation und zaehlt in die Quote", () => {
|
||||
const p = immoPlan({
|
||||
purchasePrice: 1000000, mortgage: 1000000, amortization: 10000, interestRate: 1, interestHandling: "ADD",
|
||||
});
|
||||
const ph = computePlan(p).phases[0];
|
||||
// Jahr 1: 1% von 1'000'000 = 10'000. Jahr 10: 1% von (1'000'000 - 9 x 10'000) = 9'100.
|
||||
expect(ph.expenseStart).toBe(10000);
|
||||
expect(ph.expenseEnd).toBe(9100);
|
||||
expect(ph.quotaStart).toBe(-10000); // Zins schlaegt auf die Quote durch
|
||||
});
|
||||
|
||||
it("Verkauf: Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis", () => {
|
||||
const p = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [
|
||||
el(
|
||||
"REAL_ESTATE", "HOUSEHOLD",
|
||||
{ p1: { purchasePrice: 1000000, mortgage: 900000, amortization: 10000, valueGrowth: 1 }, p2: {} },
|
||||
{ p1: { decision: "SELL", salePrice: 1200000, saleTaxRate: 20 } }
|
||||
),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
// Gewinn = 1'200'000 - 1'000'000 (Kaufpreis!) = 200'000 -> Steuer 40'000.
|
||||
// Erloes = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000.
|
||||
expect(r.phases[1].capitalInflow).toBe(360000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AHV einkommensabhaengig", () => {
|
||||
// 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
|
||||
function ahvPlan(opts: {
|
||||
@@ -130,22 +207,44 @@ describe("AHV einkommensabhaengig", () => {
|
||||
});
|
||||
|
||||
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => {
|
||||
// mdJE = 60'000 -> 1'260 x (1.04 + 0.16 x 60'000/15'120) = 2'118.1/Monat x 13 = 27'535
|
||||
// Plan-Einkommen ist NETTO -> fuer die AHV auf brutto hochgerechnet; das Feld
|
||||
// "vor Planbeginn" ist bereits brutto. 39 Jahre davor, 5 im Plan.
|
||||
const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
|
||||
const erwartet = Math.round(ahvMonthlyFullPension(60000) * 13);
|
||||
expect(renteIn(p, 1)).toBe(erwartet);
|
||||
const mdJE = (60000 * 39 + 60000 * BRUTTO * 5) / 44;
|
||||
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
|
||||
expect(renteIn(p, 1)).toBeLessThan(32760);
|
||||
});
|
||||
|
||||
it("Nettolohn wird fuer die AHV auf den Bruttolohn hochgerechnet", () => {
|
||||
// Ohne Vorgeschichte (Alter 21 bei Planbeginn) haengt das mdJE nur am Plan-Einkommen.
|
||||
const p = plan({
|
||||
age: 21,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 44 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 70000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true } }),
|
||||
],
|
||||
});
|
||||
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
// mdJE = 70'000 x 1.12 = 78'400 (brutto), NICHT 70'000.
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(70000 * BRUTTO) * 13));
|
||||
expect(rente).toBeGreaterThan(Math.round(ahvMonthlyFullPension(70000) * 13));
|
||||
});
|
||||
|
||||
it("tiefes Einkommen -> Mindestrente 1'260 x 13", () => {
|
||||
const p = ahvPlan({ income: 10000, avgIncomeBefore: 10000 });
|
||||
expect(renteIn(p, 1)).toBe(1260 * 13);
|
||||
});
|
||||
|
||||
it("Einkommen vor Planbeginn dominiert bei kurzer Restlaufzeit", () => {
|
||||
// 39 Jahre vor Planbeginn zu 40'000, nur 5 Jahre im Plan zu 200'000.
|
||||
// 39 Jahre vor Planbeginn zu 40'000 brutto, nur 5 Jahre im Plan zu 200'000 netto.
|
||||
const p = ahvPlan({ income: 200000, avgIncomeBefore: 40000 });
|
||||
const mdJE = (40000 * 39 + 200000 * 5) / 44; // = 58'181.8
|
||||
const mdJE = (40000 * 39 + 200000 * BRUTTO * 5) / 44;
|
||||
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
|
||||
});
|
||||
|
||||
@@ -178,7 +277,8 @@ describe("AHV einkommensabhaengig", () => {
|
||||
],
|
||||
});
|
||||
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000) * 13)); // mdJE = 80'000, nicht 9'091
|
||||
// mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das waere der Fallback auf 0).
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
|
||||
});
|
||||
|
||||
it("bereits bei Planbeginn pensioniert: Karriere kommt aus der Phasenzelle", () => {
|
||||
|
||||
+87
-27
@@ -2,6 +2,7 @@ import {
|
||||
AHV_CONTRIBUTION_START_AGE,
|
||||
AHV_COUPLE_CAP_FACTOR,
|
||||
AHV_FULL_CONTRIBUTION_YEARS,
|
||||
AHV_GROSS_FROM_NET_FACTOR,
|
||||
AHV_MAX_ANNUAL_SINGLE,
|
||||
AHV_MIN_MONTHLY_FULL,
|
||||
AHV_PENSION_MONTHS,
|
||||
@@ -34,6 +35,7 @@ export interface ElementPhaseComputed {
|
||||
locked: boolean;
|
||||
carried: boolean; // Phase >= 2: Start-/Basiswert wird aus der Vorphase fortgeschrieben
|
||||
baseValue: number; // fortgeschriebener Basiswert (read-only Anzeige ab Phase 2; ohne Zusatzeinlage)
|
||||
mortgageStart: number; // nur REAL_ESTATE: Resthypothek zu Phasenbeginn (0 sonst)
|
||||
startValue: number; // Wert/Flow zu Phasenbeginn (Aktiven +, Schulden -, Einkommen/Ausgaben = Flow Jahr 1)
|
||||
endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
|
||||
summary: string;
|
||||
@@ -119,10 +121,12 @@ export function ahvMonthlyFullPension(mdJE: number): number {
|
||||
}
|
||||
|
||||
// Beitragskarriere einer Person fuer die AHV -- akkumuliert ueber die Erwerbsphasen des Plans.
|
||||
// ACHTUNG: Alle Einkommen sind BRUTTO. Das Tool erfasst netto (so stimmt der Cash-Fluss), die
|
||||
// AHV bemisst sich aber am Bruttolohn -- die Umrechnung passiert beim Aufbau der Karriere.
|
||||
export interface AhvCareer {
|
||||
personId: string;
|
||||
role: PersonRole;
|
||||
plannedAvgIncome: number; // reales Durchschnittseinkommen der Beitragsjahre IM Plan
|
||||
plannedAvgGrossIncome: number; // reales BRUTTO-Durchschnittseinkommen der Beitragsjahre im Plan
|
||||
planYears: number; // Beitragsjahre im Plan (Dauer abzueglich Ausfalljahre)
|
||||
yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn
|
||||
gapYearsInPlan: number;
|
||||
@@ -132,11 +136,12 @@ export interface AhvCareer {
|
||||
// Einkommen ueber ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
|
||||
// AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in
|
||||
// realer Betrachtung weitgehend auf.
|
||||
export function ahvMdje(career: AhvCareer, avgIncomeBefore: number, gapYearsBefore: number): number {
|
||||
// `avgGrossIncomeBefore` ist -- wie die Karriere -- ein BRUTTO-Wert.
|
||||
export function ahvMdje(career: AhvCareer, avgGrossIncomeBefore: number, gapYearsBefore: number): number {
|
||||
const yearsBefore = Math.max(0, career.yearsBeforePlan - Math.max(0, gapYearsBefore));
|
||||
const totalYears = yearsBefore + career.planYears;
|
||||
if (totalYears <= 0) return 0;
|
||||
return (avgIncomeBefore * yearsBefore + career.plannedAvgIncome * career.planYears) / totalYears;
|
||||
return (avgGrossIncomeBefore * yearsBefore + career.plannedAvgGrossIncome * career.planYears) / totalYears;
|
||||
}
|
||||
|
||||
// Jaehrliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekuerzt um die Ausfalljahre
|
||||
@@ -171,6 +176,8 @@ interface Carry {
|
||||
status: ElementStatus;
|
||||
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase
|
||||
mortgage: number; // Immobilie: Resthypothek
|
||||
propertyValue: number; // Immobilie: Verkehrswert am Ende der Vorphase (inkl. Wertsteigerung)
|
||||
propertyPurchase: number; // Immobilie: urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer)
|
||||
owed: number; // Schulden: Restschuld (positiv)
|
||||
pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung
|
||||
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der naechsten Phase
|
||||
@@ -178,7 +185,17 @@ interface Carry {
|
||||
}
|
||||
|
||||
function emptyCarry(): Carry {
|
||||
return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, flowBasis: 0, hasCarry: false };
|
||||
return {
|
||||
status: "ACTIVE",
|
||||
value: 0,
|
||||
mortgage: 0,
|
||||
propertyValue: 0,
|
||||
propertyPurchase: 0,
|
||||
owed: 0,
|
||||
pkPensionAnnual: 0,
|
||||
flowBasis: 0,
|
||||
hasCarry: false,
|
||||
};
|
||||
}
|
||||
|
||||
function fmt(v: number): string {
|
||||
@@ -309,8 +326,18 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
||||
const assets: { value: number; rate: number; r: number; withdrawal: number; ec: ElementPhaseComputed }[] = [];
|
||||
// mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
|
||||
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt).
|
||||
const realEstates: { purchase: number; mortgage: number; amort: number; ec: ElementPhaseComputed }[] = [];
|
||||
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der
|
||||
// Liegenschaft (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis.
|
||||
const realEstates: {
|
||||
value: number;
|
||||
purchase: number;
|
||||
mortgage: number;
|
||||
amort: number;
|
||||
growth: number;
|
||||
interestRate: number;
|
||||
addInterest: boolean;
|
||||
ec: ElementPhaseComputed;
|
||||
}[] = [];
|
||||
const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
|
||||
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen
|
||||
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen
|
||||
@@ -333,6 +360,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
locked: carry.status !== "ACTIVE",
|
||||
carried: carry.hasCarry,
|
||||
baseValue: 0,
|
||||
mortgageStart: 0,
|
||||
startValue: 0,
|
||||
endValue: 0,
|
||||
summary: "",
|
||||
@@ -369,14 +397,15 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
|
||||
// AHV: reales Erwerbseinkommen der Person mitfuehren. Nur Einkommen, die einer
|
||||
// Person zugeordnet sind -- bei einem Einzelplan zaehlt "Gemeinsam" zur Person A.
|
||||
// Das Feld ist NETTO erfasst; die AHV bemisst sich am Bruttolohn -> hochrechnen.
|
||||
if (e.category === "INCOME" && ownerWorking) {
|
||||
const attributed =
|
||||
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
|
||||
if (attributed) {
|
||||
const avgReal = avgRealFlow(basis, idx, infl, duration, cumInflStart);
|
||||
const avgRealGross = avgRealFlow(basis, idx, infl, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR;
|
||||
phaseRealIncomeByPerson.set(
|
||||
attributed.id,
|
||||
(phaseRealIncomeByPerson.get(attributed.id) ?? 0) + avgReal
|
||||
(phaseRealIncomeByPerson.get(attributed.id) ?? 0) + avgRealGross
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -454,15 +483,30 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
break;
|
||||
}
|
||||
case "REAL_ESTATE": {
|
||||
const purchase = Math.round(num(pd.purchasePrice));
|
||||
// Urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer) und Verkehrswert
|
||||
// (waechst mit der Wertsteigerung) laufen getrennt.
|
||||
const purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice));
|
||||
const valueStart = carry.hasCarry ? carry.propertyValue : Math.round(num(pd.purchasePrice));
|
||||
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
|
||||
const amort = Math.round(num(pd.amortization));
|
||||
const equity = purchase - mortgageStart;
|
||||
const equity = valueStart - mortgageStart;
|
||||
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
|
||||
ec.baseValue = equity;
|
||||
ec.mortgageStart = mortgageStart;
|
||||
ec.startValue = equity;
|
||||
wealthStart += equity;
|
||||
realEstates.push({ purchase, mortgage: mortgageStart, amort, ec });
|
||||
realEstates.push({
|
||||
value: valueStart,
|
||||
purchase,
|
||||
mortgage: mortgageStart,
|
||||
amort,
|
||||
growth: num(pd.valueGrowth),
|
||||
interestRate: num(pd.interestRate),
|
||||
// Default INCLUDED: bestehende Plaene haben die Zinsen in den Ausgaben -> nicht
|
||||
// nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu.
|
||||
addInterest: pd.interestHandling === "ADD",
|
||||
ec,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "OTHER_DEBT": {
|
||||
@@ -508,9 +552,20 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
|
||||
// Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
|
||||
const inflFactor = cumInflStart * Math.pow(1 + infl / 100, t - 1);
|
||||
let expenseReal = 0;
|
||||
for (const exp of expenses) expenseReal += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
|
||||
const expenseNominal = expenseReal * inflFactor;
|
||||
let expenseRealBase = 0;
|
||||
for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
|
||||
|
||||
// Hypothekarzins: NOMINAL aus der Restschuld zu Jahresbeginn -- nicht mit der Inflation
|
||||
// hochrechnen. Zaehlt zu den Ausgaben (und damit in die Quote), sofern nicht bereits
|
||||
// im Ausgaben-Element enthalten.
|
||||
let interestNominal = 0;
|
||||
for (const re of realEstates) {
|
||||
if (!re.addInterest) continue;
|
||||
interestNominal += re.mortgage * (re.interestRate / 100);
|
||||
}
|
||||
|
||||
const expenseNominal = expenseRealBase * inflFactor + interestNominal;
|
||||
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
|
||||
const quote = incomeFlow - expenseNominal;
|
||||
|
||||
yearly.push({
|
||||
@@ -548,6 +603,9 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const pay = Math.min(re.amort, re.mortgage);
|
||||
re.mortgage -= pay;
|
||||
debtRates += pay;
|
||||
// Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital -- das ist der
|
||||
// Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000.
|
||||
re.value *= 1 + re.growth / 100;
|
||||
}
|
||||
for (const d of debts) {
|
||||
const pay = Math.min(d.repay, d.owed);
|
||||
@@ -562,7 +620,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
|
||||
let total = cash;
|
||||
for (const a of assets) total += a.value;
|
||||
for (const re of realEstates) total += re.purchase - re.mortgage;
|
||||
for (const re of realEstates) total += re.value - re.mortgage;
|
||||
for (const d of debts) total += -d.owed;
|
||||
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
|
||||
}
|
||||
@@ -588,7 +646,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
wealthEnd += a.ec.endValue;
|
||||
}
|
||||
for (const re of realEstates) {
|
||||
re.ec.endValue = re.purchase - re.mortgage;
|
||||
re.ec.endValue = Math.round(re.value - re.mortgage);
|
||||
re.ec.summary = fmt(re.ec.endValue);
|
||||
wealthEnd += re.ec.endValue;
|
||||
}
|
||||
@@ -670,7 +728,6 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
for (const e of orderedElements) {
|
||||
const carry = carries.get(e.id)!;
|
||||
const ec = ecById.get(e.id)!;
|
||||
const pd = e.phaseValues[phase.id] ?? {};
|
||||
const td = e.transitionValues[phase.id] ?? {};
|
||||
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
||||
const ownerRetiresNext =
|
||||
@@ -690,8 +747,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
ahvBeforeByPerson.set(owner.id, {
|
||||
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre
|
||||
// vor Planbeginn -- exakt der Wert, den der Pruef-Dialog vorbelegt. Ein Fallback auf
|
||||
// 0 wuerde die Rente still und massiv zu tief rechnen.
|
||||
avg: num(td.avgIncomeBefore, career.plannedAvgIncome),
|
||||
// 0 wuerde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
|
||||
avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome),
|
||||
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
|
||||
});
|
||||
}
|
||||
@@ -752,17 +809,20 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
break;
|
||||
}
|
||||
case "REAL_ESTATE": {
|
||||
// ec.endValue = Kaufpreis - Resthypothek am Phasenende -> Resthypothek zurueckrechnen.
|
||||
const purchase = Math.round(num(pd.purchasePrice));
|
||||
const restMortgage = purchase - ec.endValue;
|
||||
const re = realEstates.find((r) => r.ec.elementId === e.id);
|
||||
if (!re) break;
|
||||
if (td.decision === "SELL") {
|
||||
const salePrice = Math.round(num(td.salePrice));
|
||||
const gain = Math.max(0, salePrice - purchase);
|
||||
// Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis, nicht am
|
||||
// zwischenzeitlich gestiegenen Verkehrswert.
|
||||
const gain = Math.max(0, salePrice - re.purchase);
|
||||
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
|
||||
txInflow += Math.round(salePrice - restMortgage - tax);
|
||||
txInflow += Math.round(salePrice - re.mortgage - tax);
|
||||
carry.status = "SOLD";
|
||||
} else {
|
||||
carry.mortgage = restMortgage;
|
||||
carry.mortgage = re.mortgage;
|
||||
carry.propertyValue = re.value;
|
||||
carry.propertyPurchase = re.purchase;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -813,11 +873,11 @@ function buildCareer(
|
||||
gapAccum: Map<string, number>
|
||||
): AhvCareer {
|
||||
const planYears = yearsAccum.get(owner.id) ?? 0;
|
||||
const incomeSum = incomeAccum.get(owner.id) ?? 0;
|
||||
const incomeSum = incomeAccum.get(owner.id) ?? 0; // bereits brutto (siehe Element-Setup)
|
||||
return {
|
||||
personId: owner.id,
|
||||
role: owner.role,
|
||||
plannedAvgIncome: planYears > 0 ? incomeSum / planYears : 0,
|
||||
plannedAvgGrossIncome: planYears > 0 ? incomeSum / planYears : 0,
|
||||
planYears,
|
||||
yearsBeforePlan: ahvYearsBeforePlan(owner.age),
|
||||
gapYearsInPlan: gapAccum.get(owner.id) ?? 0,
|
||||
|
||||
@@ -18,6 +18,25 @@ export const AHV_CONTRIBUTION_START_AGE = 21;
|
||||
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
|
||||
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
|
||||
|
||||
// Umrechnung Netto- -> Bruttolohn fuer die AHV. Das Tool erfasst das Einkommen NETTO (so
|
||||
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
|
||||
//
|
||||
// Herleitung (Arbeitnehmer-Abzuege in % des Bruttolohns):
|
||||
// AHV/IV/EO 5.30 % (10.6 % total, haelftig geteilt)
|
||||
// ALV 1.10 % ( 2.2 % total, haelftig geteilt, bis 148'200)
|
||||
// NBU ~1.00 % (variiert 0.4-1.6 %, teils vom Arbeitgeber getragen)
|
||||
// PK ~2-5 % (BVG-Altersgutschrift 7/10/15/18 % auf den KOORDINIERTEN Lohn
|
||||
// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Haelfte)
|
||||
// -> total ~9-13 % je nach Alter und Lohn; Faktor 1/(1-q) = 1.10 bis 1.16, Mittel ~1.12.
|
||||
//
|
||||
// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt ueber die ganze
|
||||
// Beitragskarriere (44 Jahre) ist: der altersabhaengige PK-Satz mittelt sich dabei heraus.
|
||||
// Die Restunschaerfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %).
|
||||
//
|
||||
// Quellen: BSV "Betraege gueltig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
|
||||
// keine Aenderung gegenueber 2025); AHV-Merkblaetter 2.01 (AHV/IV/EO) und 2.08 (ALV).
|
||||
export const AHV_GROSS_FROM_NET_FACTOR = 1.12;
|
||||
|
||||
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
|
||||
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
|
||||
export const AHV_COUPLE_CAP_FACTOR = 1.5;
|
||||
|
||||
@@ -77,6 +77,13 @@ export interface PhaseData {
|
||||
purchasePrice?: number;
|
||||
mortgage?: number;
|
||||
amortization?: number;
|
||||
// Hypothekarzins in % der Restschuld. Der Zinsbetrag sinkt dadurch mit der Amortisation.
|
||||
interestRate?: number;
|
||||
// Steuert die Doppelzaehlung: Sind die Zinsen im Ausgaben-Element bereits enthalten
|
||||
// (INCLUDED, Default -- Verhalten bisheriger Plaene) oder soll das Tool sie dazurechnen (ADD)?
|
||||
interestHandling?: "INCLUDED" | "ADD";
|
||||
// Geschaetzte jaehrliche Wertveraenderung der LIEGENSCHAFT (nicht des Eigenkapitals).
|
||||
valueGrowth?: number;
|
||||
// OTHER_DEBT
|
||||
annualRepayment?: number;
|
||||
}
|
||||
@@ -157,6 +164,9 @@ export const phaseDataSchema = z
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user