Netto/Brutto-Klarstellung fuer die AHV + erweitertes Immobilien-Modul
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:
2026-07-17 13:12:19 +02:00
parent e901d23970
commit a97de5b1ed
8 changed files with 485 additions and 103 deletions
+9 -2
View File
@@ -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:
+76 -31
View File
@@ -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 (
<>
+3
View File
@@ -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,
+106 -6
View File
@@ -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
View File
@@ -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,
+19
View File
@@ -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;
+10
View File
@@ -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();