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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user