Netto/Brutto-Klarstellung fuer die AHV + erweitertes Immobilien-Modul
Deploy App / deploy (push) Successful in 59s

Roadmap Nr. 9 (reduziert): Einkommen ist neu explizit als NETTOLOHN definiert
(Label + Hilfetext). Bisher stand nirgends, ob netto oder brutto gemeint ist -- fuer
den Cash-Fluss egal (beide Konventionen heben sich auf), aber seit der
einkommensabhaengigen AHV haengt eine Rente daran. Die AHV bemisst sich am
Bruttolohn, deshalb rechnet das Tool intern mit AHV_GROSS_FROM_NET_FACTOR = 1.12
hoch. Ohne das war die Rente um bis zu ~1'900/Jahr zu tief (Details: SPEZ 9.13).
Der Faktor ist hergeleitet und dokumentiert (AHV/IV/EO 5.3% + ALV 1.1% + NBU ~1% +
PK ~2-5% auf den koordinierten Lohn) -- fix vertretbar, weil das mdJE selbst ein
Karriere-Durchschnitt ist. Keine Aufschluesselung, kein sichtbares Feld (kommt mit
Roadmap Nr. 41 als erklaerte Konstante).

Roadmap Nr. 8: Immobilie neu mit Hypothekarzins (% der Restschuld, Zinsbetrag sinkt
mit der Amortisation, read-only "Beginn -> Ende") und Wertsteigerung.

WICHTIG: Die Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital.
1% von 1 Mio sind 10'000/Jahr, also 10% eines Eigenkapitals von 100'000 -- das ist
der Hebel. Auf dem EK gerechnet waeren es 1'000 (Beispiel: 304'622 statt 210'462).
Kaufpreis und Verkehrswert laufen deshalb getrennt; die Grundstueckgewinnsteuer
bemisst sich weiterhin am urspruenglichen Kaufpreis.

Doppelzaehlung: Schalter interestHandling auf der Immobilie, Default INCLUDED --
bestehende Plaene haben die Zinsen in den Ausgaben und aendern sich nicht.

Keine Steuerschaetzung (Begruendung: SPEZ 9.14). Sechs Regressionstests (30 -> 36).
Spezifikation auf v0.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:12:19 +02:00
parent e901d23970
commit a97de5b1ed
8 changed files with 485 additions and 103 deletions
+173 -35
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.4 | | **Version** | 0.5 |
| **Datum** | 2026-07-17 | | **Datum** | 2026-07-17 |
| **Status** | Lebendes Dokument | | **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` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | 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.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/<id>/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.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/<id>/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). | | 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 124340.
#### INCOME (Einkommen) #### 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 | | 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` | | ≈ real (heutige Kaufkraft) | | Read-only Info: `amount / deflatorStart` |
| Nominale Lohnerhöhung (%/Jahr) | `teuerungsausgleich` | Default 0 %. 0 % = nominal gleichbleibend, real sinkend. | | 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) #### EXPENSE (Ausgaben)
Hinweistext: „Ausgaben werden REAL erfasst (in heutiger Kaufkraft)." Hinweistext: „Ausgaben werden REAL erfasst (in heutiger Kaufkraft)."
@@ -433,10 +442,28 @@ Wie PK, aber:
#### REAL_ESTATE (Immobilie) #### REAL_ESTATE (Immobilie)
| Zustand | Felder | | Feld | JSON | Semantik |
|---|---| |---|---|---|
| Phase 1 / Neukauf | **Kaufpreis** (`purchasePrice`), **Hypothek** (`mortgage`), **Amortisation CHF/Jahr** (`amortization`) | | Kaufpreis | `purchasePrice` | nur Phase 1 / Neukauf; ab Phase 2 read-only fortgeschrieben |
| ab Phase 2, fortgeschrieben | **Startwert Netto (fortgeschrieben)** (read-only = Kaufpreis Resthypothek), **Amortisation** | | 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) #### OTHER_ASSET (Sonstiges Vermögen)
@@ -866,7 +893,7 @@ Pro Person wird über die Phasen hinweg akkumuliert (`AhvCareer`):
| Feld | Bedeutung | | 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) | | `planYears` | Beitragsjahre im Plan = Σ (Phasendauer Ausfalljahre der Phase) |
| `yearsBeforePlan` | `max(0, Alter bei Planbeginn 21)` | | `yearsBeforePlan` | `max(0, Alter bei Planbeginn 21)` |
| `gapYearsInPlan` | Summe der Ausfalljahre im Plan | | `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 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)). (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.41.6 %, teils vom Arbeitgeber getragen |
| PK | ~25 % | BVG-Altersgutschrift 7/10/15/18 % auf den **koordinierten** Lohn (Brutto 26'460, max. 90'720), Arbeitnehmer ≥ die Hälfte |
Total ~913 % je nach Alter und Lohn → Faktor `1/(1q)` = **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 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) ### 4.6.5 REAL_ESTATE (Immobilie)
``` ```
purchase = round(phaseData.purchasePrice) // in JEDER Phase aus den Phasendaten purchase = hasCarry ? carry.propertyPurchase : round(phaseData.purchasePrice) // Kaufpreis
mortgageStart = hasCarry ? carry.mortgage : round(phaseData.mortgage) 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 amort = round(phaseData.amortization) // wird JÄHRLICH am Restsaldo gekappt
equity = purchase mortgageStart → startValue, wealthStart equity = valueStart mortgage → startValue, wealthStart
``` ```
Die Hypothek wird als **laufender Saldo** in der Jahresschleife geführt (siehe 4.7), nicht per **Kaufpreis und Verkehrswert laufen getrennt.** Der Verkehrswert wächst mit `valueGrowth`, der
Linearformel. Sobald sie 0 erreicht, entfällt die Amortisationsrate. 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): Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase):
`investmentsFromCash += max(0, equity)` das Eigenkapital wird aus dem Cash finanziert. `investmentsFromCash += max(0, equity)` das Eigenkapital wird aus dem Cash finanziert.
@@ -1085,10 +1165,12 @@ Dann für `t = 1 .. duration`:
// 1. Einkommen (nominal) // 1. Einkommen (nominal)
incomeFlow = renteTotal + Σ (inc.basis × (1 + inc.idx/100)^(t1)) incomeFlow = renteTotal + Σ (inc.basis × (1 + inc.idx/100)^(t1))
// 2. Ausgaben (real → nominal) // 2. Ausgaben (real → nominal) + Hypothekarzins
inflFactor = cumInflStart × (1 + infl/100)^(t1) inflFactor = cumInflStart × (1 + infl/100)^(t1)
expenseReal = Σ (exp.basis × (1 + exp.idx/100)^(t1)) expenseRealBase = Σ (exp.basis × (1 + exp.idx/100)^(t1))
expenseNominal = expenseReal × inflFactor interestNominal = Σ (re.mortgage × re.interestRate/100) // nur wo interestHandling = ADD
expenseNominal = expenseRealBase × inflFactor + interestNominal
expenseReal = expenseRealBase + interestNominal / inflFactor
// 3. Quote // 3. Quote
quote = incomeFlow expenseNominal quote = incomeFlow expenseNominal
@@ -1108,6 +1190,7 @@ für jede Immobilie re:
pay = min(re.amort, re.mortgage) // nie mehr als die Restschuld pay = min(re.amort, re.mortgage) // nie mehr als die Restschuld
re.mortgage = pay re.mortgage = pay
debtRates += pay debtRates += pay
re.value ×= (1 + re.growth/100) // Wertsteigerung auf die LIEGENSCHAFT
für jede Schuld d: für jede Schuld d:
pay = min(d.repay, d.owed) pay = min(d.repay, d.owed)
d.owed = pay d.owed = pay
@@ -1119,7 +1202,7 @@ cash += quote fixedRatesTotal debtRates + cashFromWithdraw
falls cash < 0 → cashNegative = true falls cash < 0 → cashNegative = true
// 8. Ruin prüfen (Gesamtvermögen zum Jahresende) // 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 falls ruinAge === null und total < 0 → ruinAge = age(Person A) + yearsBefore + t
``` ```
@@ -1146,7 +1229,7 @@ Einkommen: startValue = basis
Ausgaben: startValue = basis × cumInflStart Ausgaben: startValue = basis × cumInflStart
endValue = basis × (1 + idx/100)^(duration1) × flowDeflatorEnd endValue = basis × (1 + idx/100)^(duration1) × flowDeflatorEnd
Assets: endValue = a.value (nach der Jahresschleife) 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") Schulden: endValue = d.owed (0, falls getilgt; + Notiz „Wird getilgt")
``` ```
@@ -1209,19 +1292,25 @@ Sonst → `carry.value = ec.endValue`.
### 4.9.4 REAL_ESTATE ### 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"`: `decision = "SELL"`:
``` ```
gain = max(0, salePrice purchase) gain = max(0, salePrice re.purchase) // URSPRÜNGLICHER Kaufpreis, nicht der Verkehrswert
tax = gain × (saleTaxRate / 100) tax = gain × (saleTaxRate / 100)
txInflow += round(salePrice restMortgage tax) txInflow += round(salePrice re.mortgage tax)
carry.status = "SOLD" carry.status = "SOLD"
``` ```
Der Nettoerlös ist also Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer. Der Nettoerlös ist Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer. Ein
Ein Verlustverkauf erzeugt keine Steuer (`gain` bei 0 geklammert). 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 ### 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_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_CONTRIBUTION_START_AGE` | 21 | Beitragspflicht ab 1. Januar nach dem 20. Geburtstag |
| `AHV_MAX_ANNUAL_SINGLE` | 32'760 | **abgeleitet**: `2 × R0 × 13` | | `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_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente |
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) | | `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) | | `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
@@ -1503,6 +1593,9 @@ Referenz: `prisma/schema.prisma` Zeilen 46, `src/lib/elements.ts` Zeilen 48
| `purchasePrice` | REAL_ESTATE | ≥ 0 | | `purchasePrice` | REAL_ESTATE | ≥ 0 |
| `mortgage` | REAL_ESTATE | ≥ 0 | | `mortgage` | REAL_ESTATE | ≥ 0 |
| `amortization` | REAL_ESTATE | ≥ 0 | | `amortization` | REAL_ESTATE | ≥ 0 |
| `interestRate` | REAL_ESTATE Hypothekarzins %/Jahr | 020 |
| `interestHandling` | REAL_ESTATE Doppelzählungs-Schalter | `INCLUDED` (Default) \| `ADD` |
| `valueGrowth` | REAL_ESTATE Wertsteigerung %/Jahr auf die Liegenschaft | 20 bis 20 |
| `annualRepayment` | OTHER_DEBT | ≥ 0 | | `annualRepayment` | OTHER_DEBT | ≥ 0 |
### 5.4.4 JSON-Payload `TransitionData` ### 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 ## 8.1 Teststrategie
Getestet wird ausschliesslich der Berechnungskern bewusst, da dort die Fachlogik und das 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, Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 36 Tests (AHV-Rentenformel,
AHV einkommensabhängig, „V5 Golden Tests"), Immobilie, AHV einkommensabhängig, „V5 Golden Tests"),
ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`).
Es gibt **keine** Komponenten-, API- oder E2E-Tests. 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 ohne Prüfung** | ohne erfassten Wert gilt der geplante Durchschnitt (nicht 0) |
| **AHV bereits pensioniert** | Karriere aus der Phasenzelle der ersten Phase | | **AHV bereits pensioniert** | Karriere aus der Phasenzelle der ersten Phase |
| **AHV Plafonierung** | zwei Maximalrenten im Paar-Plan → gekappt auf `32'760 × 1.5` | | **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 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 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 | | 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 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. 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 Seit Version 0.5 kennt das Modul Hypothekarzins und Wertsteigerung. Nicht modelliert sind:
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 - **Nebenkosten und Unterhalt** (Faustregel ~1 % des Werts). Lassen sich heute als normales
manuell erhöht was dann allerdings auch die Berechnung der Grundstückgewinnsteuer beim Verkauf Ausgaben-Element erfassen.
beeinflusst (`gain = salePrice purchase` liest den `purchasePrice` der Verkaufsphase). - **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 ## 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 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. `≥ 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 3050 % 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. - `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 - Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) ein Rest der
+9 -2
View File
@@ -93,8 +93,15 @@ function buildCarryData(category: string, prev: PhaseData): PhaseData {
case "OTHER_ASSET": case "OTHER_ASSET":
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) }; return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
case "REAL_ESTATE": case "REAL_ESTATE":
// purchasePrice + amortization bleiben; die Resthypothek wird live fortgeschrieben. // purchasePrice + amortization bleiben; Resthypothek und Verkehrswert werden live
return { purchasePrice: num(prev.purchasePrice), amortization: num(prev.amortization) }; // 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": case "OTHER_DEBT":
return { annualRepayment: num(prev.annualRepayment) }; return { annualRepayment: num(prev.annualRepayment) };
default: default:
+71 -26
View File
@@ -32,6 +32,7 @@ export interface CellContext {
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige) 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) deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort). // Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
laterPhaseCount: number; laterPhaseCount: number;
@@ -148,7 +149,8 @@ export function AhvReviewFields({
}) { }) {
const brauchtVorgeschichte = career.yearsBeforePlan > 0; const brauchtVorgeschichte = career.yearsBeforePlan > 0;
// Vorbelegung mit dem geplanten Durchschnitt: besser als 0, wenn der Nutzer nichts weiss. // 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 gapBefore = Math.max(0, Math.round(num(td.gapYearsBefore)));
const mdJE = ahvMdje(career, brauchtVorgeschichte ? avgBefore : 0, gapBefore); const mdJE = ahvMdje(career, brauchtVorgeschichte ? avgBefore : 0, gapBefore);
const rente = ahvAnnualPension(mdJE, career.gapYearsInPlan + gapBefore); const rente = ahvAnnualPension(mdJE, career.gapYearsInPlan + gapBefore);
@@ -157,14 +159,16 @@ export function AhvReviewFields({
<> <>
<p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted"> <p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Die AHV-Rente haengt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "} Die AHV-Rente haengt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
ueber die ganze Beitragsdauer (ab Alter 21) ab. Alle Betraege sind REAL (heutige Kaufkraft) ueber die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der <strong className="text-fg">Bruttolohn</strong>;
die AHV wertet vergangene Einkommen auf und indexiert die Schwellen, was sich real weitgehend aufhebt. das Tool rechnet die im Plan erfassten Nettoeinkommen dafuer automatisch hoch. Alle Betraege sind
REAL (heutige Kaufkraft) die AHV wertet vergangene Einkommen auf und indexiert die Schwellen,
was sich real weitgehend aufhebt.
</p> </p>
<DerivedField <DerivedField
label="Geplantes Durchschnittseinkommen (aus dem Plan)" label="Geplantes Bruttoeinkommen (aus dem Plan)"
value={Math.round(career.plannedAvgIncome)} value={Math.round(career.plannedAvgGrossIncome)}
help={`Reales Mittel ueber ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans. Nur Einkommen, die dieser Person zugeordnet sind.`} help={`Reales Mittel ueber ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans, hochgerechnet vom Netto- auf den Bruttolohn. Nur Einkommen, die dieser Person zugeordnet sind.`}
/> />
<DerivedField <DerivedField
label="Beitragsjahre im Plan" label="Beitragsjahre im Plan"
@@ -179,8 +183,8 @@ export function AhvReviewFields({
davor kennt das Tool nicht bitte ergaenzen. davor kennt das Tool nicht bitte ergaenzen.
</p> </p>
<MoneyField <MoneyField
label="Durchschnittseinkommen vor Planbeginn (real)" label="Durchschnittliches Bruttoeinkommen vor Planbeginn (real)"
help="In HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung uebernehmen dort ist die Aufwertung bereits enthalten. Die Rohbetraege im Kontoauszug sind historisch-nominal und daher zu tief." help="BRUTTO und in HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung uebernehmen dort ist die Aufwertung bereits enthalten. Die Rohbetraege im Kontoauszug sind historisch-nominal und daher zu tief."
value={avgBefore} value={avgBefore}
onChange={(v) => setT({ avgIncomeBefore: v })} onChange={(v) => setT({ avgIncomeBefore: v })}
/> />
@@ -416,11 +420,11 @@ export function ElementPhaseFields({
<> <>
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted"> <p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
{isIncome {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."} : "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben nur zur Info."}
</p> </p>
<MoneyField <MoneyField
label={isIncome ? "Jahreseinkommen NOMINAL (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"} label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst aenderbar." : undefined} help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst aenderbar." : undefined}
value={baseValue} value={baseValue}
onChange={(v) => setP({ amount: v })} onChange={(v) => setP({ amount: v })}
@@ -457,8 +461,8 @@ export function ElementPhaseFields({
bitte hier erfassen (REAL, heutige Kaufkraft). bitte hier erfassen (REAL, heutige Kaufkraft).
</p> </p>
<MoneyField <MoneyField
label="Durchschnittseinkommen der Beitragsjahre (real)" label="Durchschnittliches Bruttoeinkommen der Beitragsjahre (real)"
help="Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfuegung. Ab 90'720 gibt es die Maximalrente." help="BRUTTO und in heutiger Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfuegung. Ab 90'720 gibt es die Maximalrente."
value={num(pd.avgIncomeBefore)} value={num(pd.avgIncomeBefore)}
onChange={(v) => setP({ avgIncomeBefore: v })} onChange={(v) => setP({ avgIncomeBefore: v })}
/> />
@@ -559,34 +563,75 @@ export function ElementPhaseFields({
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} /> <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</> </>
); );
case "REAL_ESTATE": case "REAL_ESTATE": {
if (carried) { // 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 ( return (
<> <>
<DerivedField label="Startwert Netto (fortgeschrieben)" value={context.derivedStart} help="Kaufpreis minus fortgeschriebene Resthypothek." /> {carried ? (
<MoneyField <>
label="Amortisation (CHF/Jahr)" <DerivedField
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote." label="Startwert Netto (fortgeschrieben)"
value={num(pd.amortization)} value={context.derivedStart}
help="Verkehrswert (inkl. bisheriger Wertsteigerung) minus fortgeschriebene Resthypothek."
onChange={(v) => setP({ amortization: v })}
/> />
<DerivedField label="Resthypothek (fortgeschrieben)" value={hypStart} help="Aus der Vorphase fortgeschrieben." />
</> </>
); ) : (
}
return (
<> <>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} /> <MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} /> <MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
</>
)}
<MoneyField <MoneyField
label="Amortisation (CHF/Jahr)" label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote." help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
value={num(pd.amortization)} value={num(pd.amortization)}
onChange={(v) => setP({ amortization: v })} onChange={(v) => setP({ amortization: v })}
/> />
<NumberField
label="Hypothekarzins (%/Jahr)"
help="Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
step={0.1}
value={num(pd.interestRate)}
onChange={(v) => setP({ interestRate: v })}
/>
<div>
<FieldLabel
label="Hypothekarzins-Betrag (Beginn → Ende)"
help="Restschuld x Zinssatz, im ersten und im letzten Jahr dieser Phase. Wird automatisch berechnet."
/>
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
{formatChf(zinsStart)} <span className="text-faint"></span> {formatChf(zinsEnde)}
</div>
</div>
<NumberField
label="Geschaetzte Wertsteigerung (%/Jahr)"
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr bei 100'000 Eigenkapital also 10% darauf (Hebel)."
step={0.1}
value={num(pd.valueGrowth)}
onChange={(v) => setP({ valueGrowth: v })}
/>
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
help="Damit die Zinsen nicht doppelt zaehlen. Standard: bereits enthalten so verhalten sich bestehende Plaene unveraendert."
value={handling}
onChange={(v: "INCLUDED" | "ADD") => setP({ interestHandling: v })}
options={[
{ value: "INCLUDED", label: "Ja bereits im Ausgaben-Element beruecksichtigt" },
{ value: "ADD", label: "Nein bitte zu den Ausgaben dazuzaehlen" },
]}
/>
</div>
</> </>
); );
}
case "OTHER_ASSET": case "OTHER_ASSET":
return ( return (
<> <>
+3
View File
@@ -193,6 +193,7 @@ export function PlanView({
carriedEndValue: ce?.endValue ?? 0, carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false, carried: ce?.carried ?? false,
derivedStart: ce?.baseValue ?? 0, derivedStart: ce?.baseValue ?? 0,
derivedMortgage: ce?.mortgageStart ?? 0,
deflatorStart: phase.cumulativeInflationStart, deflatorStart: phase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(phase), laterPhaseCount: laterPhaseCount(phase),
ahvCareer: careerFor(element), ahvCareer: careerFor(element),
@@ -211,6 +212,7 @@ export function PlanView({
carriedEndValue: ce?.endValue ?? 0, carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false, carried: ce?.carried ?? false,
derivedStart: 0, derivedStart: 0,
derivedMortgage: 0,
deflatorStart: fromPhase.cumulativeInflationStart, deflatorStart: fromPhase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(fromPhase), laterPhaseCount: laterPhaseCount(fromPhase),
ahvCareer: careerFor(element), ahvCareer: careerFor(element),
@@ -935,6 +937,7 @@ function AddElementDialog({
carriedEndValue: 0, carriedEndValue: 0,
carried: false, carried: false,
derivedStart: 0, derivedStart: 0,
derivedMortgage: 0,
deflatorStart: firstPhase.cumulativeInflationStart, deflatorStart: firstPhase.cumulativeInflationStart,
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
ahvCareer: null, ahvCareer: null,
+106 -6
View File
@@ -84,6 +84,83 @@ describe("AHV-Rentenformel (Skala 44)", () => {
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis + // V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
// reale Mehrausgaben, nominal = real x (plan-weite Inflation). // 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", () => { describe("AHV einkommensabhaengig", () => {
// 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan. // 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
function ahvPlan(opts: { function ahvPlan(opts: {
@@ -130,22 +207,44 @@ describe("AHV einkommensabhaengig", () => {
}); });
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => { 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 p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
const erwartet = Math.round(ahvMonthlyFullPension(60000) * 13); const mdJE = (60000 * 39 + 60000 * BRUTTO * 5) / 44;
expect(renteIn(p, 1)).toBe(erwartet); expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
expect(renteIn(p, 1)).toBeLessThan(32760); 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", () => { it("tiefes Einkommen -> Mindestrente 1'260 x 13", () => {
const p = ahvPlan({ income: 10000, avgIncomeBefore: 10000 }); const p = ahvPlan({ income: 10000, avgIncomeBefore: 10000 });
expect(renteIn(p, 1)).toBe(1260 * 13); expect(renteIn(p, 1)).toBe(1260 * 13);
}); });
it("Einkommen vor Planbeginn dominiert bei kurzer Restlaufzeit", () => { 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 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)); 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; 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", () => { it("bereits bei Planbeginn pensioniert: Karriere kommt aus der Phasenzelle", () => {
+87 -27
View File
@@ -2,6 +2,7 @@ import {
AHV_CONTRIBUTION_START_AGE, AHV_CONTRIBUTION_START_AGE,
AHV_COUPLE_CAP_FACTOR, AHV_COUPLE_CAP_FACTOR,
AHV_FULL_CONTRIBUTION_YEARS, AHV_FULL_CONTRIBUTION_YEARS,
AHV_GROSS_FROM_NET_FACTOR,
AHV_MAX_ANNUAL_SINGLE, AHV_MAX_ANNUAL_SINGLE,
AHV_MIN_MONTHLY_FULL, AHV_MIN_MONTHLY_FULL,
AHV_PENSION_MONTHS, AHV_PENSION_MONTHS,
@@ -34,6 +35,7 @@ export interface ElementPhaseComputed {
locked: boolean; locked: boolean;
carried: boolean; // Phase >= 2: Start-/Basiswert wird aus der Vorphase fortgeschrieben carried: boolean; // Phase >= 2: Start-/Basiswert wird aus der Vorphase fortgeschrieben
baseValue: number; // fortgeschriebener Basiswert (read-only Anzeige ab Phase 2; ohne Zusatzeinlage) 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) startValue: number; // Wert/Flow zu Phasenbeginn (Aktiven +, Schulden -, Einkommen/Ausgaben = Flow Jahr 1)
endValue: number; // Wert/Flow am Phasenende (letztes Jahr) endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
summary: string; summary: string;
@@ -119,10 +121,12 @@ export function ahvMonthlyFullPension(mdJE: number): number {
} }
// Beitragskarriere einer Person fuer die AHV -- akkumuliert ueber die Erwerbsphasen des Plans. // 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 { export interface AhvCareer {
personId: string; personId: string;
role: PersonRole; 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) planYears: number; // Beitragsjahre im Plan (Dauer abzueglich Ausfalljahre)
yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn
gapYearsInPlan: number; gapYearsInPlan: number;
@@ -132,11 +136,12 @@ export interface AhvCareer {
// Einkommen ueber ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die // Einkommen ueber ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
// AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in // AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in
// realer Betrachtung weitgehend auf. // 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 yearsBefore = Math.max(0, career.yearsBeforePlan - Math.max(0, gapYearsBefore));
const totalYears = yearsBefore + career.planYears; const totalYears = yearsBefore + career.planYears;
if (totalYears <= 0) return 0; 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 // Jaehrliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekuerzt um die Ausfalljahre
@@ -171,6 +176,8 @@ interface Carry {
status: ElementStatus; status: ElementStatus;
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase
mortgage: number; // Immobilie: Resthypothek 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) owed: number; // Schulden: Restschuld (positiv)
pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der naechsten Phase flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der naechsten Phase
@@ -178,7 +185,17 @@ interface Carry {
} }
function emptyCarry(): 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 { function fmt(v: number): string {
@@ -309,8 +326,18 @@ export function computePlan(plan: PlanInput): PlanComputed {
let renteTotal = 0; // AHV + PK-Renten (nominal fix) let renteTotal = 0; // AHV + PK-Renten (nominal fix)
const assets: { value: number; rate: number; r: number; withdrawal: number; ec: ElementPhaseComputed }[] = []; 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 // mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). // Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der
const realEstates: { purchase: number; mortgage: number; amort: number; ec: ElementPhaseComputed }[] = []; // 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 }[] = []; const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): 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", locked: carry.status !== "ACTIVE",
carried: carry.hasCarry, carried: carry.hasCarry,
baseValue: 0, baseValue: 0,
mortgageStart: 0,
startValue: 0, startValue: 0,
endValue: 0, endValue: 0,
summary: "", summary: "",
@@ -369,14 +397,15 @@ export function computePlan(plan: PlanInput): PlanComputed {
// AHV: reales Erwerbseinkommen der Person mitfuehren. Nur Einkommen, die einer // AHV: reales Erwerbseinkommen der Person mitfuehren. Nur Einkommen, die einer
// Person zugeordnet sind -- bei einem Einzelplan zaehlt "Gemeinsam" zur Person A. // 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) { if (e.category === "INCOME" && ownerWorking) {
const attributed = const attributed =
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null); owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
if (attributed) { 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( phaseRealIncomeByPerson.set(
attributed.id, 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; break;
} }
case "REAL_ESTATE": { 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 mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
const amort = Math.round(num(pd.amortization)); const amort = Math.round(num(pd.amortization));
const equity = purchase - mortgageStart; const equity = valueStart - mortgageStart;
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity); if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
ec.baseValue = equity; ec.baseValue = equity;
ec.mortgageStart = mortgageStart;
ec.startValue = equity; ec.startValue = equity;
wealthStart += 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; break;
} }
case "OTHER_DEBT": { 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); 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. // Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
const inflFactor = cumInflStart * Math.pow(1 + infl / 100, t - 1); const inflFactor = cumInflStart * Math.pow(1 + infl / 100, t - 1);
let expenseReal = 0; let expenseRealBase = 0;
for (const exp of expenses) expenseReal += exp.basis * Math.pow(1 + exp.idx / 100, t - 1); for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
const expenseNominal = expenseReal * inflFactor;
// 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; const quote = incomeFlow - expenseNominal;
yearly.push({ yearly.push({
@@ -548,6 +603,9 @@ export function computePlan(plan: PlanInput): PlanComputed {
const pay = Math.min(re.amort, re.mortgage); const pay = Math.min(re.amort, re.mortgage);
re.mortgage -= pay; re.mortgage -= pay;
debtRates += 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) { for (const d of debts) {
const pay = Math.min(d.repay, d.owed); 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). // Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
let total = cash; let total = cash;
for (const a of assets) total += a.value; 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; for (const d of debts) total += -d.owed;
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t; if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
} }
@@ -588,7 +646,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
wealthEnd += a.ec.endValue; wealthEnd += a.ec.endValue;
} }
for (const re of realEstates) { 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); re.ec.summary = fmt(re.ec.endValue);
wealthEnd += re.ec.endValue; wealthEnd += re.ec.endValue;
} }
@@ -670,7 +728,6 @@ export function computePlan(plan: PlanInput): PlanComputed {
for (const e of orderedElements) { for (const e of orderedElements) {
const carry = carries.get(e.id)!; const carry = carries.get(e.id)!;
const ec = ecById.get(e.id)!; const ec = ecById.get(e.id)!;
const pd = e.phaseValues[phase.id] ?? {};
const td = e.transitionValues[phase.id] ?? {}; const td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerRetiresNext = const ownerRetiresNext =
@@ -690,8 +747,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
ahvBeforeByPerson.set(owner.id, { ahvBeforeByPerson.set(owner.id, {
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre // 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 // vor Planbeginn -- exakt der Wert, den der Pruef-Dialog vorbelegt. Ein Fallback auf
// 0 wuerde die Rente still und massiv zu tief rechnen. // 0 wuerde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
avg: num(td.avgIncomeBefore, career.plannedAvgIncome), avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome),
gap: Math.max(0, Math.round(num(td.gapYearsBefore))), gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
}); });
} }
@@ -752,17 +809,20 @@ export function computePlan(plan: PlanInput): PlanComputed {
break; break;
} }
case "REAL_ESTATE": { case "REAL_ESTATE": {
// ec.endValue = Kaufpreis - Resthypothek am Phasenende -> Resthypothek zurueckrechnen. const re = realEstates.find((r) => r.ec.elementId === e.id);
const purchase = Math.round(num(pd.purchasePrice)); if (!re) break;
const restMortgage = purchase - ec.endValue;
if (td.decision === "SELL") { if (td.decision === "SELL") {
const salePrice = Math.round(num(td.salePrice)); 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); 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"; carry.status = "SOLD";
} else { } else {
carry.mortgage = restMortgage; carry.mortgage = re.mortgage;
carry.propertyValue = re.value;
carry.propertyPurchase = re.purchase;
} }
break; break;
} }
@@ -813,11 +873,11 @@ function buildCareer(
gapAccum: Map<string, number> gapAccum: Map<string, number>
): AhvCareer { ): AhvCareer {
const planYears = yearsAccum.get(owner.id) ?? 0; 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 { return {
personId: owner.id, personId: owner.id,
role: owner.role, role: owner.role,
plannedAvgIncome: planYears > 0 ? incomeSum / planYears : 0, plannedAvgGrossIncome: planYears > 0 ? incomeSum / planYears : 0,
planYears, planYears,
yearsBeforePlan: ahvYearsBeforePlan(owner.age), yearsBeforePlan: ahvYearsBeforePlan(owner.age),
gapYearsInPlan: gapAccum.get(owner.id) ?? 0, gapYearsInPlan: gapAccum.get(owner.id) ?? 0,
+19
View File
@@ -18,6 +18,25 @@ export const AHV_CONTRIBUTION_START_AGE = 21;
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss. // 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; 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- // Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt. // Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
export const AHV_COUPLE_CAP_FACTOR = 1.5; export const AHV_COUPLE_CAP_FACTOR = 1.5;
+10
View File
@@ -77,6 +77,13 @@ export interface PhaseData {
purchasePrice?: number; purchasePrice?: number;
mortgage?: number; mortgage?: number;
amortization?: 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 // OTHER_DEBT
annualRepayment?: number; annualRepayment?: number;
} }
@@ -157,6 +164,9 @@ export const phaseDataSchema = z
purchasePrice: nonNeg.optional(), purchasePrice: nonNeg.optional(),
mortgage: nonNeg.optional(), mortgage: nonNeg.optional(),
amortization: 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(), annualRepayment: nonNeg.optional(),
}) })
.strip(); .strip();