From a97de5b1ed625cfc11b0c5f539140ac74b1cff38 Mon Sep 17 00:00:00 2001
From: kelle
Date: Fri, 17 Jul 2026 13:12:19 +0200
Subject: [PATCH] Netto/Brutto-Klarstellung fuer die AHV + erweitertes
Immobilien-Modul
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
---
SPEZIFIKATION.md | 212 +++++++++++++++++----
src/app/api/plans/[planId]/phases/route.ts | 11 +-
src/components/ElementDetail.tsx | 107 ++++++++---
src/components/PlanView.tsx | 3 +
src/lib/calculations.test.ts | 112 ++++++++++-
src/lib/calculations.ts | 114 ++++++++---
src/lib/constants.ts | 19 ++
src/lib/elements.ts | 10 +
8 files changed, 485 insertions(+), 103 deletions(-)
diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md
index d050e54..de827f0 100644
--- a/SPEZIFIKATION.md
+++ b/SPEZIFIKATION.md
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
-| **Version** | 0.4 |
+| **Version** | 0.5 |
| **Datum** | 2026-07-17 |
| **Status** | Lebendes Dokument |
-| **Codestand** | Arbeitsstand nach `9f5bd75` inkl. einkommensabhängiger AHV und Fortschreibungs-Warnhinweis (Branch `main`) |
+| **Codestand** | Arbeitsstand nach `e901d23` inkl. Netto/Brutto-Klarstellung und erweitertem Immobilien-Modul (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
+| 0.5 | 2026-07-17 | Claude (Opus 4.8) | **Netto/Brutto geklärt** (Roadmap Nr. 9, reduziert) und **Immobilien-Modul erweitert** (Roadmap Nr. 8). Einkommen ist neu explizit als **Nettolohn** definiert (Label und Hilfetext); für die AHV rechnet das Tool intern mit `AHV_GROSS_FROM_NET_FACTOR = 1.12` auf den Bruttolohn hoch – die AHV bemisst sich am Brutto, die bisherige Netto-Basis unterschätzte die Rente um bis zu ~1'900/Jahr. Immobilie neu mit **Hypothekarzins** (% der Restschuld, sinkt mit der Amortisation, mit Doppelzählungs-Schalter) und **Wertsteigerung** (auf die **Liegenschaft**, nicht auf das Eigenkapital – Hebeleffekt). Grundstückgewinnsteuer bemisst sich neu explizit am ursprünglichen Kaufpreis. Keine Aufschlüsselung bei Einkommen oder Ausgaben, keine Steuerschätzung (Begründung: 9.14). Neues Kapitel 4.4.5; 4.6.5 und 4.9.4 überarbeitet; Abschnitt 9 um zwei Punkte ergänzt. Sechs Regressionstests (30 → 36). **Verhaltensänderung:** siehe 9.13. |
| 0.4 | 2026-07-17 | Claude (Opus 4.8) | **AHV-Rente einkommensabhängig** (Roadmap Nr. 3) und **Fortschreibungs-Warnhinweis** (Roadmap Nr. 4). Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) über das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente; verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen). Prüfung der Beitragskarriere am Pensions-Übergang, mit Zusatzfeldern für die Jahre vor Planbeginn (ab Alter 21). Alles **real** gerechnet. Neue Konstanten `AHV_MIN_MONTHLY_FULL`, `AHV_PENSION_MONTHS`, `AHV_CONTRIBUTION_START_AGE`; `AHV_MAX_ANNUAL_SINGLE` neu abgeleitet. Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren. Neue Kapitel 3.5.6, 4.4; Abschnitt 9 um zwei Punkte ergänzt. Zwölf Regressionstests (18 → 30). **Verhaltensänderung:** siehe 9.10. |
| 0.3 | 2026-07-16 | Claude (Opus 4.8) | **Einmalige Sonderein-/ausgaben** umgesetzt (Roadmap Nr. 1). Der Cash-Übergang zwischen zwei Phasen ist neu ein eigener Entscheid: 1:1 übernehmen · einmaliger Zufluss · einmalige Kosten · beides. Beträge gehen direkt aufs Cash-Konto. Zufluss wird nominal erfasst (real angezeigt) mit optionalem Steuersatz (Default 0 %), Kosten werden real erfasst (nominal angezeigt); beide bleiben aus der Sparquote heraus. Der Entscheid startet unbeantwortet und zählt im „offen"-Badge mit. Neue Spalte `Phase.cashTransition` (JSON) + Migration, neue Route `PUT /api/phases//cash-transition`, eigene Kennzahlen im Phasenkopf. Fünf Regressionstests ergänzt (13 → 18). Neue Kapitel 3.5.5, 4.9.6; Abschnitt 9 um zwei Grenzen ergänzt. |
| 0.2 | 2026-07-16 | Claude (Opus 4.8) | Drei Fixes umgesetzt und dokumentiert: **(1)** `Phase.inflationRate` ersatzlos entfernt (DB-Migration, API, Typen, UI) – die Inflation liegt seit V5 plan-weit; das Feld war wirkungslos. **(2)** Amortisation und Tilgung stoppen neu, sobald Hypothek bzw. Schuld abbezahlt sind – sie belasten danach weder Cash noch Sparquote (Kap. 4.6.5, 4.6.7, 4.7, 4.8). **(3)** Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezügen vor der Pensionierung, inkl. Steuerfeld und Netto-Vorschau im UI (Kap. 3.5.2, 4.9.1, 4.9.2). Kennzahl `plannedSaveRate` ist neu die Rate des **ersten** Phasenjahres. Drei Regressionstests ergänzt (10 → 13). Abschnitt 9 neu nummeriert (erledigte Punkte entfernt, „Sparraten werden nicht indexiert" ergänzt). |
@@ -385,14 +386,22 @@ Referenz: `src/components/ElementDetail.tsx` Zeilen 124–340.
#### INCOME (Einkommen)
-Hinweistext: „Einkommen wird NOMINAL erfasst (die Zahl auf dem Lohnausweis)."
+**Einkommen ist der NETTOLOHN** – der Betrag, der nach allen Lohnabzügen (AHV/ALV,
+Pensionskasse, NBU) tatsächlich aufs Konto kommt. Das ist die für den Cash-Fluss richtige
+Grösse und die, in der man denkt.
| Feld | JSON | Semantik |
|---|---|---|
-| Jahreseinkommen NOMINAL (erstes Jahr) | `amount` | Basiswert Jahr 1. Ab Phase 2 vorbelegt mit dem fortgeschriebenen Wert, bewusst änderbar (Teilzeit, Beförderung). |
+| Jahreseinkommen NETTO, nominal (erstes Jahr) | `amount` | Basiswert Jahr 1. Ab Phase 2 vorbelegt mit dem fortgeschriebenen Wert, bewusst änderbar (Teilzeit, Beförderung). |
| ≈ real (heutige Kaufkraft) | – | Read-only Info: `amount / deflatorStart` |
| Nominale Lohnerhöhung (%/Jahr) | `teuerungsausgleich` | Default 0 %. 0 % = nominal gleichbleibend, real sinkend. |
+Für die **AHV** rechnet das Tool intern auf den Bruttolohn zurück – siehe
+[4.4.5](#445-netto-brutto-umrechnung-für-die-ahv). Warum die Definition überhaupt nötig war:
+Für den Cash-Fluss sind beide Konventionen gleichwertig (`brutto − Ausgaben inkl. Abzüge`
+≡ `netto − Ausgaben ohne Abzüge`), weshalb die Ambiguität lange folgenlos blieb. Mit der
+einkommensabhängigen AHV hängt daran aber eine Rente.
+
#### EXPENSE (Ausgaben)
Hinweistext: „Ausgaben werden REAL erfasst (in heutiger Kaufkraft)."
@@ -433,10 +442,28 @@ Wie PK, aber:
#### REAL_ESTATE (Immobilie)
-| Zustand | Felder |
-|---|---|
-| Phase 1 / Neukauf | **Kaufpreis** (`purchasePrice`), **Hypothek** (`mortgage`), **Amortisation CHF/Jahr** (`amortization`) |
-| ab Phase 2, fortgeschrieben | **Startwert Netto (fortgeschrieben)** (read-only = Kaufpreis − Resthypothek), **Amortisation** |
+| Feld | JSON | Semantik |
+|---|---|---|
+| Kaufpreis | `purchasePrice` | nur Phase 1 / Neukauf; ab Phase 2 read-only fortgeschrieben |
+| Hypothek | `mortgage` | dito |
+| Startwert Netto (fortgeschrieben) | – | read-only ab Phase 2: **Verkehrswert** − Resthypothek |
+| Resthypothek (fortgeschrieben) | – | read-only ab Phase 2 |
+| Amortisation (CHF/Jahr) | `amortization` | endet, sobald die Hypothek abbezahlt ist |
+| **Hypothekarzins (%/Jahr)** | `interestRate` | Zinssatz auf der **Restschuld** |
+| **Hypothekarzins-Betrag (Beginn → Ende)** | – | read-only: Zinsbetrag im ersten und im letzten Jahr der Phase |
+| **Geschätzte Wertsteigerung (%/Jahr)** | `valueGrowth` | wirkt auf die **Liegenschaft** |
+| **Sind die Zinsen bereits in den Ausgaben enthalten?** | `interestHandling` | `INCLUDED` (Default) / `ADD` |
+
+Der Zinsbetrag sinkt automatisch mit der Amortisation – das kann kein manueller
+Ausgabenposten. Beispiel: Hypothek 1'000'000, Amortisation 10'000/Jahr, Zins 1 %, 10 Jahre →
+Anzeige `10'000 → 9'100` (Jahr 1 auf 1'000'000, Jahr 10 auf 910'000).
+
+Der Schalter `interestHandling` verhindert die Doppelzählung: Bestehende Pläne haben die
+Zinsen im Ausgabenbetrag, deshalb ist `INCLUDED` der Default und das Tool zieht **nichts** ab.
+Erst `ADD` rechnet die Zinsen dazu – dann gehören sie aus dem Ausgabenbetrag entfernt. Der
+Schalter sitzt bewusst auf der **Immobilie** und nicht auf dem Ausgaben-Element: Bei mehreren
+Ausgaben-Elementen wäre sonst unklar, welches die Zinsen trägt (und zwei auf „Ja" würden
+doppelt zählen).
#### OTHER_ASSET (Sonstiges Vermögen)
@@ -866,7 +893,7 @@ Pro Person wird über die Phasen hinweg akkumuliert (`AhvCareer`):
| Feld | Bedeutung |
|---|---|
-| `plannedAvgIncome` | reales Durchschnittseinkommen der Beitragsjahre **im Plan** |
+| `plannedAvgGrossIncome` | reales **Brutto**-Durchschnittseinkommen der Beitragsjahre **im Plan** (siehe 4.4.5) |
| `planYears` | Beitragsjahre im Plan = Σ (Phasendauer − Ausfalljahre der Phase) |
| `yearsBeforePlan` | `max(0, Alter bei Planbeginn − 21)` |
| `gapYearsInPlan` | Summe der Ausfalljahre im Plan |
@@ -919,7 +946,45 @@ Einkommen wird einer Person nur zugerechnet, wenn das `INCOME`-Element ihr zugeo
einem **Einzelplan** zählt „Gemeinsam" (`HOUSEHOLD`) zur Person A; bei einem **Paar-Plan** nicht
(siehe [9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv)).
-### 4.4.5 Jahresrente, Skala und Plafonierung
+### 4.4.5 Netto-Brutto-Umrechnung für die AHV
+
+Das Tool erfasst das Einkommen **netto**, die AHV bemisst sich am **Bruttolohn**. Beim Aufbau
+der Karriere wird deshalb hochgerechnet:
+
+```
+plannedAvgGrossIncome = avgRealFlow(...) × AHV_GROSS_FROM_NET_FACTOR // = 1.12
+```
+
+Sämtliche Werte in `AhvCareer`, im Prüf-Dialog und im mdJE sind damit **brutto** – auch das
+Feld „Durchschnittliches Bruttoeinkommen vor Planbeginn", das der Benutzer aus der
+Rentenvorausberechnung übernimmt (die ohnehin brutto-basiert ist). Eine Einheit im ganzen
+Ablauf, keine Umrechnung an der Feldgrenze.
+
+**Herleitung des Faktors** (Arbeitnehmer-Abzüge in % des Bruttolohns):
+
+| Abzug | Satz | Bemerkung |
+|---|---|---|
+| AHV/IV/EO | 5.30 % | 10.6 % total, hälftig geteilt |
+| ALV | 1.10 % | 2.2 % total, hälftig 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 Hälfte |
+
+Total ~9–13 % je nach Alter und Lohn → Faktor `1/(1−q)` = **1.10 bis 1.16**, Mittel **1.12**.
+
+**Warum ein fixer Faktor genügt:** Das mdJE ist selbst ein Durchschnitt über die ganze
+Beitragskarriere (44 Jahre). Der altersabhängige PK-Satz mittelt sich dabei heraus – ein
+Karriere-Faktor gegen einen Karriere-Durchschnitt ist konzeptionell stimmig, nicht bloss eine
+Näherung. Die Restunschärfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung
+behebt (~11 %, siehe 9.13).
+
+**Grenzen:** Überobligatorische PK-Pläne, vom Arbeitgeber getragene NBU, Selbstständige und
+Löhne über 148'200 (ALV sinkt auf 0.5 %) weichen ab. Der Faktor ist heute eine nicht
+sichtbare Konstante; mit Roadmap Nr. 41 wird er in der Formel-Erklärung ausgewiesen.
+
+Quellen: BSV „Beträge gültig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
+„keine Änderung gegenüber 2025"); AHV-Merkblätter 2.01 (AHV/IV/EO) und 2.08 (ALV).
+
+### 4.4.6 Jahresrente, Skala und Plafonierung
```
factor = max(0, (44 − Ausfalljahre total) / 44) // Rentenskala 44
@@ -1030,14 +1095,29 @@ Identisch zu PK, mit zwei Unterschieden:
### 4.6.5 REAL_ESTATE (Immobilie)
```
-purchase = round(phaseData.purchasePrice) // in JEDER Phase aus den Phasendaten
-mortgageStart = hasCarry ? carry.mortgage : round(phaseData.mortgage)
-amort = round(phaseData.amortization) // wird JÄHRLICH am Restsaldo gekappt
-equity = purchase − mortgageStart → startValue, wealthStart
+purchase = hasCarry ? carry.propertyPurchase : round(phaseData.purchasePrice) // Kaufpreis
+valueStart = hasCarry ? carry.propertyValue : round(phaseData.purchasePrice) // Verkehrswert
+mortgage = hasCarry ? carry.mortgage : round(phaseData.mortgage)
+amort = round(phaseData.amortization) // wird JÄHRLICH am Restsaldo gekappt
+equity = valueStart − mortgage → startValue, wealthStart
```
-Die Hypothek wird als **laufender Saldo** in der Jahresschleife geführt (siehe 4.7), nicht per
-Linearformel. Sobald sie 0 erreicht, entfällt die Amortisationsrate.
+**Kaufpreis und Verkehrswert laufen getrennt.** Der Verkehrswert wächst mit `valueGrowth`, der
+Kaufpreis bleibt der ursprüngliche – er ist die Basis der Grundstückgewinnsteuer beim Verkauf
+(4.9.4). Beide werden über die Phasen fortgeschrieben (`carry.propertyValue`,
+`carry.propertyPurchase`); die Hypothek läuft als Saldo in der Jahresschleife (4.7).
+
+**Die Wertsteigerung wirkt auf die Liegenschaft, nicht auf das Eigenkapital.** Das ist der
+Hebel, der Wohneigentum finanziell auszeichnet: 1 % von 1'000'000 sind 10'000 im Jahr – bei
+einem Eigenkapital von 100'000 also 10 % darauf. Würde man die Wertsteigerung auf das
+Eigenkapital rechnen, ergäbe dieselbe Annahme nur 1'000 im Jahr:
+
+> Kaufpreis 1'000'000 · Hypothek 900'000 · EK 100'000 · 1 %/J. · Amortisation 10'000/J. · 10 J.
+> → korrekt **304'622**; auf das Eigenkapital gerechnet nur **210'462**. Differenz **94'160**,
+> und sie wächst mit dem Belehnungsgrad.
+
+Der angezeigte Elementwert bleibt das **Eigenkapital** (`Verkehrswert − Restschuld`); nur die
+Basis der Verzinsung ist die Liegenschaft.
Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase):
`investmentsFromCash += max(0, equity)` – das Eigenkapital wird aus dem Cash finanziert.
@@ -1085,10 +1165,12 @@ Dann für `t = 1 .. duration`:
// 1. Einkommen (nominal)
incomeFlow = renteTotal + Σ (inc.basis × (1 + inc.idx/100)^(t−1))
-// 2. Ausgaben (real → nominal)
-inflFactor = cumInflStart × (1 + infl/100)^(t−1)
-expenseReal = Σ (exp.basis × (1 + exp.idx/100)^(t−1))
-expenseNominal = expenseReal × inflFactor
+// 2. Ausgaben (real → nominal) + Hypothekarzins
+inflFactor = cumInflStart × (1 + infl/100)^(t−1)
+expenseRealBase = Σ (exp.basis × (1 + exp.idx/100)^(t−1))
+interestNominal = Σ (re.mortgage × re.interestRate/100) // nur wo interestHandling = ADD
+expenseNominal = expenseRealBase × inflFactor + interestNominal
+expenseReal = expenseRealBase + interestNominal / inflFactor
// 3. Quote
quote = incomeFlow − expenseNominal
@@ -1108,6 +1190,7 @@ für jede Immobilie re:
pay = min(re.amort, re.mortgage) // nie mehr als die Restschuld
re.mortgage −= pay
debtRates += pay
+ re.value ×= (1 + re.growth/100) // Wertsteigerung auf die LIEGENSCHAFT
für jede Schuld d:
pay = min(d.repay, d.owed)
d.owed −= pay
@@ -1119,7 +1202,7 @@ cash += quote − fixedRatesTotal − debtRates + cashFromWithdraw
falls cash < 0 → cashNegative = true
// 8. Ruin prüfen (Gesamtvermögen zum Jahresende)
-total = cash + Σ asset.value + Σ (re.purchase − re.mortgage) + Σ (−d.owed)
+total = cash + Σ asset.value + Σ (re.value − re.mortgage) + Σ (−d.owed)
falls ruinAge === null und total < 0 → ruinAge = age(Person A) + yearsBefore + t
```
@@ -1146,7 +1229,7 @@ Einkommen: startValue = basis
Ausgaben: startValue = basis × cumInflStart
endValue = basis × (1 + idx/100)^(duration−1) × flowDeflatorEnd
Assets: endValue = a.value (nach der Jahresschleife)
-Immobilie: endValue = purchase − re.mortgage (laufender Saldo nach der Jahresschleife)
+Immobilie: endValue = re.value − re.mortgage (Verkehrswert inkl. Wertsteigerung, minus Restschuld)
Schulden: endValue = −d.owed (0, falls getilgt; + Notiz „Wird getilgt")
```
@@ -1209,19 +1292,25 @@ Sonst → `carry.value = ec.endValue`.
### 4.9.4 REAL_ESTATE
-Die Resthypothek wird zurückgerechnet: `restMortgage = purchase − ec.endValue`.
+Gelesen werden die laufenden Werte aus der Jahresschleife (`re.value`, `re.mortgage`,
+`re.purchase`).
`decision = "SELL"`:
```
-gain = max(0, salePrice − purchase)
+gain = max(0, salePrice − re.purchase) // URSPRÜNGLICHER Kaufpreis, nicht der Verkehrswert
tax = gain × (saleTaxRate / 100)
-txInflow += round(salePrice − restMortgage − tax)
+txInflow += round(salePrice − re.mortgage − tax)
carry.status = "SOLD"
```
-Der Nettoerlös ist also Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer.
-Ein Verlustverkauf erzeugt keine Steuer (`gain` bei 0 geklammert).
+Der Nettoerlös ist Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer. Ein
+Verlustverkauf erzeugt keine Steuer (`gain` bei 0 geklammert).
-Sonst → `carry.mortgage = restMortgage`.
+**Die Steuer bemisst sich am ursprünglichen Kaufpreis**, nicht am zwischenzeitlich gestiegenen
+Verkehrswert – deshalb führt das Modell beide getrennt (4.6.5). Nicht modelliert sind
+wertvermehrende Investitionen und die Haltedauer-Abstufung (Roadmap Nr. 23).
+
+Sonst → `carry.mortgage`, `carry.propertyValue` und `carry.propertyPurchase` werden
+fortgeschrieben.
### 4.9.5 OTHER_DEBT
@@ -1293,6 +1382,7 @@ Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bunde
| `AHV_PENSION_MONTHS` | 13 | Rentenzahlungen pro Jahr – 13. Altersrente ab 1.1.2026 |
| `AHV_CONTRIBUTION_START_AGE` | 21 | Beitragspflicht ab 1. Januar nach dem 20. Geburtstag |
| `AHV_MAX_ANNUAL_SINGLE` | 32'760 | **abgeleitet**: `2 × R0 × 13` |
+| `AHV_GROSS_FROM_NET_FACTOR` | 1.12 | Netto → Brutto für die AHV; Herleitung siehe [4.4.5](#445-netto-brutto-umrechnung-für-die-ahv) |
| `AHV_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente |
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
@@ -1503,6 +1593,9 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48
| `purchasePrice` | REAL_ESTATE | ≥ 0 |
| `mortgage` | REAL_ESTATE | ≥ 0 |
| `amortization` | REAL_ESTATE | ≥ 0 |
+| `interestRate` | REAL_ESTATE – Hypothekarzins %/Jahr | 0–20 |
+| `interestHandling` | REAL_ESTATE – Doppelzählungs-Schalter | `INCLUDED` (Default) \| `ADD` |
+| `valueGrowth` | REAL_ESTATE – Wertsteigerung %/Jahr auf die Liegenschaft | −20 bis 20 |
| `annualRepayment` | OTHER_DEBT | ≥ 0 |
### 5.4.4 JSON-Payload `TransitionData`
@@ -1800,8 +1893,8 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi
## 8.1 Teststrategie
Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das
-Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 30 Tests (AHV-Rentenformel,
-AHV einkommensabhängig, „V5 Golden Tests"),
+Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 36 Tests (AHV-Rentenformel,
+Immobilie, AHV einkommensabhängig, „V5 Golden Tests"),
ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`).
Es gibt **keine** Komponenten-, API- oder E2E-Tests.
@@ -1818,6 +1911,12 @@ Es gibt **keine** Komponenten-, API- oder E2E-Tests.
| **AHV ohne Prüfung** | ohne erfassten Wert gilt der geplante Durchschnitt (nicht 0) |
| **AHV bereits pensioniert** | Karriere aus der Phasenzelle der ersten Phase |
| **AHV Plafonierung** | zwei Maximalrenten im Paar-Plan → gekappt auf `32'760 × 1.5` |
+| **AHV Netto → Brutto** | mdJE = `70'000 × 1.12`, nicht 70'000 |
+| **Immobilie: Hebel** | 1 Mio / 900k Hypothek / 1 % / 10 J. → Endwert 304'622; deutlich mehr als eine Wertsteigerung auf das Eigenkapital ergäbe |
+| **Immobilie: ohne Wertsteigerung** | Verhalten unverändert (1 Mio − 800k = 200'000) |
+| **Immobilie: Zins-Schalter** | `INCLUDED` → kein Cash-Abzug; `ADD` → 10 × 1 % von 900'000 = 90'000 |
+| **Immobilie: Zins sinkt** | Jahr 1: 10'000, Jahr 10: 9'100; schlägt auf die Quote durch |
+| **Immobilie: Verkauf** | Grundstückgewinnsteuer auf `Verkaufspreis − Kaufpreis`, nicht auf den Verkehrswert |
| Test 1 – Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
| Test 2 – Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` |
| Test 3 – Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ |
@@ -1878,13 +1977,18 @@ liefert `personByRole` dann `null`:
Es gibt keine Bereinigung und keine Warnung im UI. Da die Haushaltsform normalerweise nicht
nachträglich geändert wird, ist der Fall selten – er ist aber erreichbar.
-## 9.3 Immobilien ohne Wertentwicklung
+## 9.3 Immobilien: was noch fehlt
-Der Immobilienwert ist in jeder Phase der aus den Phasendaten gelesene `purchasePrice`. Der
-Kaufpreis wird beim Anlegen einer Folgephase mitkopiert (`buildCarryData`). Eine Wertsteigerung
-lässt sich nur indirekt abbilden, indem man den `purchasePrice` in einer späteren Phase
-manuell erhöht – was dann allerdings auch die Berechnung der Grundstückgewinnsteuer beim Verkauf
-beeinflusst (`gain = salePrice − purchase` liest den `purchasePrice` der Verkaufsphase).
+Seit Version 0.5 kennt das Modul Hypothekarzins und Wertsteigerung. Nicht modelliert sind:
+
+- **Nebenkosten und Unterhalt** (Faustregel ~1 % des Werts). Lassen sich heute als normales
+ Ausgaben-Element erfassen.
+- **Eigenmietwert** – ohne echte Steuerlogik (Roadmap Nr. 23) nur halb wirksam.
+- **Mieteinnahmen** (Renditeliegenschaften) – anderer Anwendungsfall.
+- **Wertvermehrende Investitionen** und die **Haltedauer-Abstufung** der
+ Grundstückgewinnsteuer (kantonal, teils stark degressiv).
+- **Zinsänderungsrisiko**: Der Zinssatz gilt für die ganze Phase. Ein Zinsschock lässt sich
+ nur abbilden, indem man an dieser Stelle eine Phasengrenze zieht und den Satz neu setzt.
## 9.4 Kein CSRF-Token
@@ -1959,7 +2063,41 @@ grösste verbliebene Hebel im AHV-Modell.
Das Feld ist per `max`-Prop hart geklammert. Das Zod-Schema kennt für `annualContribution` nur
`≥ 0` – ein direkter API-Aufruf kann die Obergrenze überschreiten.
-## 9.13 Kleinere Beobachtungen
+## 9.13 Verhaltensänderung: Nettolohn-Klarstellung und AHV
+
+Bis Version 0.4 war nirgends definiert, ob `INCOME` netto oder brutto ist – der Hilfetext sagte
+nur „die Zahl auf dem Lohnausweis", und dort stehen beide. Für den Cash-Fluss war das folgenlos,
+weil sich beide Konventionen aufheben (`brutto − Ausgaben inkl. Abzüge` ≡ `netto − Ausgaben ohne
+Abzüge`). Mit der einkommensabhängigen AHV (0.4) hing daran aber plötzlich eine Rente.
+
+Seit 0.5 gilt: **`amount` ist der Nettolohn**, und die AHV rechnet mit `× 1.12` auf brutto hoch.
+Konsequenzen:
+
+- **Wer bisher netto erfasst hat** (die dokumentierte Absicht): Die AHV-Rente **steigt** – sie
+ war zuvor um bis zu ~1'900/Jahr zu tief, am stärksten bei mittleren Einkommen (bei 80'000
+ brutto: 28'974 statt 30'902). Über 90'720 brutto verschwindet der Effekt, weil beide Werte in
+ die Maximalrente laufen.
+- **Wer brutto erfasst hat**: Cash-Fluss und AHV sind nun beide zu hoch. Der Einkommensbetrag
+ gehört auf netto korrigiert und die Lohnabzüge aus dem Ausgabenbetrag entfernt.
+
+## 9.14 Keine Steuerschätzung
+
+Bewusst **nicht** umgesetzt: eine automatische Schätzung von Einkommens- und Vermögenssteuer.
+
+Die Bemessungsgrundlage ist das *steuerbare* Einkommen, nicht der Nettolohn – also brutto minus
+PK, 3a, Berufsauslagen, Versicherungs- und Kinderabzüge. Darauf kommen drei Ebenen (Bund,
+Kanton, Gemeinde); allein der Gemeindesteuerfuss variiert innerhalb eines Kantons um rund den
+Faktor zwei. Dazu Zivilstand, Kinder, Konfession und für die Vermögenssteuer 26 kantonale
+Tarife mit eigenen Freibeträgen. Das Tool kennt weder Wohnort noch Kinder.
+
+Eine Schätzung daraus läge im Einzelfall schnell 30–50 % daneben – bei vielen Haushalten dem
+grössten Ausgabenposten. Eine selbst berechnete Zahl wirkt zudem autoritativ und wird nicht
+hinterfragt. Der Benutzer kennt seine Steuerrechnung dagegen exakt aus der letzten Veranlagung.
+
+**Heutiger Weg:** ein normales Ausgaben-Element „Steuern" – dafür braucht es kein neues Feld.
+Echte Steuerlogik ist Roadmap Nr. 23, mit Kanton und Gemeinde als Eingabe.
+
+## 9.15 Kleinere Beobachtungen
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht.
- Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) – ein Rest der
diff --git a/src/app/api/plans/[planId]/phases/route.ts b/src/app/api/plans/[planId]/phases/route.ts
index fb7bd52..c858cc8 100644
--- a/src/app/api/plans/[planId]/phases/route.ts
+++ b/src/app/api/plans/[planId]/phases/route.ts
@@ -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:
diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx
index 876735a..6e312ed 100644
--- a/src/components/ElementDetail.tsx
+++ b/src/components/ElementDetail.tsx
@@ -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({
<>
Die AHV-Rente haengt vom massgebenden durchschnittlichen Jahreseinkommen{" "}
- 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 Bruttolohn;
+ 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.
setT({ avgIncomeBefore: v })}
/>
@@ -416,11 +420,11 @@ export function ElementPhaseFields({
<>
{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."}
setP({ amount: v })}
@@ -457,8 +461,8 @@ export function ElementPhaseFields({
bitte hier erfassen (REAL, heutige Kaufkraft).
setP({ avgIncomeBefore: v })}
/>
@@ -559,34 +563,75 @@ export function ElementPhaseFields({
setP({ expectedReturn: v })} />
>
);
- case "REAL_ESTATE":
- if (carried) {
- return (
- <>
-
- 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 (
<>
- setP({ purchasePrice: v })} />
- setP({ mortgage: v })} />
+ {carried ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ setP({ purchasePrice: v })} />
+ setP({ mortgage: v })} />
+ >
+ )}
setP({ amortization: v })}
/>
+ setP({ interestRate: v })}
+ />
+
+
+
+ {formatChf(zinsStart)} → {formatChf(zinsEnde)}
+
+
+ setP({ valueGrowth: v })}
+ />
+
+ setP({ interestHandling: v })}
+ options={[
+ { value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element beruecksichtigt" },
+ { value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzaehlen" },
+ ]}
+ />
+
>
);
+ }
case "OTHER_ASSET":
return (
<>
diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx
index 3245783..3860434 100644
--- a/src/components/PlanView.tsx
+++ b/src/components/PlanView.tsx
@@ -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,
diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts
index 383c066..553436e 100644
--- a/src/lib/calculations.test.ts
+++ b/src/lib/calculations.test.ts
@@ -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", () => {
diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts
index 2f69c4d..059644f 100644
--- a/src/lib/calculations.ts
+++ b/src/lib/calculations.ts
@@ -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
): 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,
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 1cccdcd..1e2af91 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -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;
diff --git a/src/lib/elements.ts b/src/lib/elements.ts
index aca1494..9d0fb6a 100644
--- a/src/lib/elements.ts
+++ b/src/lib/elements.ts
@@ -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();