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