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
+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();