From 5bf35bd6daf139db7fe621d374b690d38d811fe1 Mon Sep 17 00:00:00 2001 From: kelle Date: Fri, 17 Jul 2026 21:02:44 +0200 Subject: [PATCH] Teilverkauf (Sonstiges Vermoegen) + Sonderamortisation (Immobilie) Roadmap Nr. 42: OTHER_ASSET am Uebergang neu mit Teilverkauf. Neben Halten und Vollverkauf kann ein Betrag ins Cash fliessen, der Rest bleibt investiert und waechst weiter. Der Teilverkauf erscheint als "Kapitalzufluss" im Phasenkopf ("zu investierendes Kapital"). Abgegrenzt zur laufenden Bezugsrate: der Teilverkauf ist die einmalige Entnahme AM Uebergang, die Bezugsrate der laufende Verzehr IN der Phase -- beide koexistieren. Roadmap Nr. 15: REAL_ESTATE im Halten-Fall neu mit Sonderamortisation -- Einmaltilgung der Hypothek aus dem Cash, am Restsaldo gekappt, bringt die Immobilie auf Augenhoehe mit OTHER_DEBT (immediateRepayment). Erscheint als "Kapitalinvestition" im Phasenkopf. Damit laesst sich die indirekte Amortisation via 3a MECHANISCH nachbilden (3a wachsen lassen -> bei Pensionierung ins Cash -> Sonderamortisation). Die Steuerwirkung der indirekten Amortisation (3a-Abzug, Zinsabzug) bleibt bewusst zurueckgestellt mit dem Steuer-Buendel 13/14/23 (SPEZ 9.14). Fuenf Regressionstests (36 -> 41). Spezifikation auf v0.6. Keine Verhaltensaenderung fuer bestehende Plaene. Co-Authored-By: Claude Opus 4.8 --- SPEZIFIKATION.md | 54 +++++++++++++++++------ src/components/ElementDetail.tsx | 42 +++++++++++++----- src/components/PlanView.tsx | 16 ++++++- src/lib/calculations.test.ts | 75 ++++++++++++++++++++++++++++++++ src/lib/calculations.ts | 13 +++++- src/lib/elements.ts | 10 ++++- 6 files changed, 182 insertions(+), 28 deletions(-) diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index de827f0..27eaf99 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.5 | +| **Version** | 0.6 | | **Datum** | 2026-07-17 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `e901d23` inkl. Netto/Brutto-Klarstellung und erweitertem Immobilien-Modul (Branch `main`) | +| **Codestand** | Arbeitsstand nach `a97de5b` inkl. Teilverkauf (Sonstiges Vermögen) und Sonderamortisation (Immobilie) (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.6 | 2026-07-17 | Claude (Opus 4.8) | **Teilverkauf** von Sonstigem Vermögen (Roadmap Nr. 42) und **Sonderamortisation** der Hypothek (Roadmap Nr. 15). `OTHER_ASSET` am Übergang neu: Halten / Verkaufen / **Teilverkauf** – ein Betrag fliesst ins Cash (erscheint als „Kapitalzufluss" im Phasenkopf), der Rest bleibt investiert. `REAL_ESTATE` im Halten-Fall neu mit **Einmaltilgung** aus dem Cash (analog zur Sofort-Tilgung bei Schulden; erscheint als „Kapitalinvestition"). Damit lässt sich die indirekte Amortisation via 3a mechanisch nachbilden. Die eigentliche Roadmap Nr. 15 (Steuerwirkung der indirekten Amortisation) bleibt mit dem Steuer-Bündel 13/14/23 zurückgestellt – siehe 9.14. Kapitel 4.9.3/4.9.4 ergänzt. Fünf Regressionstests (36 → 41). Keine Verhaltensänderung für bestehende Pläne. | | 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. | @@ -475,7 +476,8 @@ doppelt zählen). | Jährlicher Sparbeitrag | `annualContribution` | fliesst ins Vermögen, verlässt das Cash → **geplante Sparrate** | | Jährliche Bezugsrate | `annualWithdrawal` | Entnahme aus dem Vermögen ins Cash → **geplante Verzehrrate** | -Die Bezugsrate ist das Instrument für den Kapitalverzehr im Alter. +Die Bezugsrate ist das Instrument für den **laufenden** Kapitalverzehr *innerhalb* einer Phase. +Für einen **einmaligen** Zugriff *am Übergang* gibt es zusätzlich den Teilverkauf (3.5.2, 4.9.3). #### OTHER_DEBT (Sonstige Schulden) @@ -509,8 +511,8 @@ personenzugeordnete Elemente (nicht `HOUSEHOLD`) können das auslösen. |---|---|---| | `PENSION_FUND` | Bezug? Kein Bezug / Bezug + Bruttobetrag (max. Endwert) + **Kapitalbezugssteuer (%)** | **Bezugsart**: Rente / Kapitalbezug / Kombination | | `PILLAR_3A` | Bezug? Kein Bezug / Bezug + Bruttobetrag + **Kapitalbezugssteuer (%)** | Nur **Kapitalbezugssteuer (%)** – Bezug ist zwingend vollständig | -| `REAL_ESTATE` | Halten / Verkaufen (+ Verkaufspreis, Grundstückgewinnsteuer) | identisch | -| `OTHER_ASSET` | Halten / Verkaufen | identisch | +| `REAL_ESTATE` | Halten (+ optionale **Sonderamortisation**) / Verkaufen (+ Verkaufspreis, Grundstückgewinnsteuer) | identisch | +| `OTHER_ASSET` | Halten / Verkaufen / **Teilverkauf** (+ Betrag) | identisch | | `OTHER_DEBT` | Sofortige Tilgung (CHF) | identisch | Ein **Vorbezug** (vor der Pensionierung, z. B. für Wohneigentum oder Selbstständigkeit) ist wie @@ -533,7 +535,7 @@ Entscheidungsfeld gesetzt ist: | Kategorie | Beantwortet, wenn | |---|---| -| `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt | +| `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt (inkl. `PARTIAL`) | | `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt | | `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt | | **Cash** | `mode` gesetzt (`isCashTransitionAnswered`) | @@ -1287,8 +1289,16 @@ Kapitalbezugssteuer. ### 4.9.3 OTHER_ASSET -`decision = "SELL"` → `txInflow += ec.endValue`; `carry.status = "SOLD"` (kein Steuerabzug). -Sonst → `carry.value = ec.endValue`. +| `decision` | Wirkung | +|---|---| +| `SELL` | `txInflow += ec.endValue`; `carry.status = "SOLD"` (kein Steuerabzug) | +| `PARTIAL` | `part = min(ec.endValue, partialSaleAmount)`; `txInflow += part`; `carry.value = ec.endValue − part` – Rest bleibt **aktiv** und wächst weiter | +| `HOLD` / sonst | `carry.value = ec.endValue` | + +Der Teilverkauf (Roadmap Nr. 42) fliesst über `txInflow` ins Cash der Folgephase und erscheint +dort im Phasenkopf als **„Kapitalzufluss"** (`capitalInflow`) – das „zu investierende Kapital". +Er ist die **einmalige** Entnahme am Übergang; die laufende Entnahme *während* einer Phase +bleibt die jährliche Bezugsrate (`annualWithdrawal`, 4.6.6). Beide dürfen koexistieren. ### 4.9.4 REAL_ESTATE @@ -1309,8 +1319,22 @@ Verlustverkauf erzeugt keine Steuer (`gain` bei 0 geklammert). 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. +**Halten (`decision ≠ SELL`)** – mit optionaler **Sonderamortisation** (Roadmap Nr. 15): +``` +extra = min(re.mortgage, extraAmortization) // am Restsaldo gekappt +txImmediateRepay += extra // Einmaltilgung aus dem Cash +carry.mortgage = re.mortgage − extra +carry.propertyValue / carry.propertyPurchase // fortgeschrieben +``` + +Die Einmaltilgung bringt `REAL_ESTATE` auf Augenhöhe mit `OTHER_DEBT` (dort schon +`immediateRepayment`, 4.9.5): Sie senkt die Restschuld und damit die künftige Zinslast und +erscheint im Phasenkopf als **„Kapitalinvestition"** (`capitalInvest`). Damit lässt sich die +**indirekte Amortisation** mechanisch nachbilden – 3a wachsen lassen, bei Pensionierung ins +Cash beziehen, dann per Sonderamortisation die Hypothek tilgen. + +Die **Steuerwirkung** der indirekten Amortisation (3a-Abzug, erhaltener Zinsabzug) ist bewusst +**nicht** modelliert; sie gehört zum zurückgestellten Steuer-Bündel (siehe 9.14). ### 4.9.5 OTHER_DEBT @@ -1611,8 +1635,10 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48 | `capitalAmount` | PK (COMBI) | ≥ 0 | | `conversionRate` | PK | 0–20 | | `capitalTaxRate` | PK + 3a, **sowohl Vorbezug als auch Pensionierung** | 0–100 | -| `decision` | REAL_ESTATE, OTHER_ASSET | `HOLD` \| `SELL` | +| `decision` | REAL_ESTATE, OTHER_ASSET | `HOLD` \| `SELL` \| `PARTIAL` | | `salePrice` | REAL_ESTATE | ≥ 0 | +| `partialSaleAmount` | OTHER_ASSET (Teilverkauf) | ≥ 0 | +| `extraAmortization` | REAL_ESTATE (Sonderamortisation) | ≥ 0 | | `saleTaxRate` | REAL_ESTATE | 0–100 | | `immediateRepayment` | OTHER_DEBT | ≥ 0 | @@ -1893,8 +1919,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 36 Tests (AHV-Rentenformel, -Immobilie, AHV einkommensabhängig, „V5 Golden Tests"), +Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 41 Tests (AHV-Rentenformel, +Immobilie, Teilverkauf, Sonderamortisation, 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. @@ -1917,6 +1943,8 @@ Es gibt **keine** Komponenten-, API- oder E2E-Tests. | **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 | +| **Teilverkauf (42)** | Betrag ins Cash (→ `capitalInflow`), Rest bleibt aktiv; am Endwert gekappt; Halten/Vollverkauf unverändert | +| **Sonderamortisation (15)** | Einmaltilgung senkt Restschuld, belastet Cash (→ `capitalInvest`), am Restsaldo gekappt | | 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 | diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 6e312ed..b80af8d 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -33,6 +33,7 @@ export interface CellContext { 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 + mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (fuer die Sonderamortisation) deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr) // Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort). laterPhaseCount: number; @@ -772,7 +773,7 @@ export function ElementTransitionFields({ <> setT({ decision: v })} options={[ { value: "HOLD", label: "Halten" }, @@ -785,21 +786,42 @@ export function ElementTransitionFields({ setT({ saleTaxRate: v })} /> )} + {decision !== "SELL" && context.mortgageEnd > 0 && ( + setT({ extraAmortization: v })} + /> + )} ); } case "OTHER_ASSET": { const decision = td.decision ?? "HOLD"; return ( - setT({ decision: v })} - options={[ - { value: "HOLD", label: "Halten" }, - { value: "SELL", label: "Verkaufen" }, - ]} - /> + <> + setT({ decision: v })} + options={[ + { value: "HOLD", label: "Halten" }, + { value: "SELL", label: "Verkaufen (ganz)" }, + { value: "PARTIAL", label: "Teilverkauf" }, + ]} + /> + {decision === "PARTIAL" && ( + setT({ partialSaleAmount: v })} + /> + )} + ); } case "OTHER_DEBT": diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index 3860434..a56c96f 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -194,6 +194,7 @@ export function PlanView({ carried: ce?.carried ?? false, derivedStart: ce?.baseValue ?? 0, derivedMortgage: ce?.mortgageStart ?? 0, + mortgageEnd: ce?.mortgageEnd ?? 0, deflatorStart: phase.cumulativeInflationStart, laterPhaseCount: laterPhaseCount(phase), ahvCareer: careerFor(element), @@ -213,6 +214,7 @@ export function PlanView({ carried: ce?.carried ?? false, derivedStart: 0, derivedMortgage: 0, + mortgageEnd: ce?.mortgageEnd ?? 0, deflatorStart: fromPhase.cumulativeInflationStart, laterPhaseCount: laterPhaseCount(fromPhase), ahvCareer: careerFor(element), @@ -704,9 +706,18 @@ export function PlanView({ switch (el.category) { case "AHV": return td.reviewed === true ? "Geprueft" : "?"; - case "REAL_ESTATE": case "OTHER_ASSET": - return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?"; + return td.decision === "SELL" + ? "Verkauf" + : td.decision === "PARTIAL" + ? `Teilverkauf −${formatChf(num(td.partialSaleAmount))}` + : td.decision === "HOLD" + ? "Halten" + : "?"; + case "REAL_ESTATE": + if (td.decision === "SELL") return "Verkauf"; + if (td.decision === undefined) return "?"; + return num(td.extraAmortization) > 0 ? `Sonderamort. −${formatChf(num(td.extraAmortization))}` : "Halten"; case "PENSION_FUND": if (isRetirementTransition(el, fromPhase, toPhase)) { return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : td.payoutMode === "PENSION" ? "Rente" : "?"; @@ -938,6 +949,7 @@ function AddElementDialog({ carried: false, derivedStart: 0, derivedMortgage: 0, + mortgageEnd: 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 553436e..309fd4f 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -87,6 +87,81 @@ describe("AHV-Rentenformel (Skala 44)", () => { // Netto -> Brutto fuer die AHV (AHV_GROSS_FROM_NET_FACTOR). const BRUTTO = 1.12; +describe("Teilverkauf Sonstiges Vermoegen (Roadmap 42)", () => { + function p42(decision: "HOLD" | "SELL" | "PARTIAL", amount?: number) { + return plan({ + age: 40, retirementAge: 70, inflation: 0, initialCash: 0, + phases: [ + { id: "p1", durationYears: 1 }, + { id: "p2", durationYears: 1 }, + ], + elements: [ + el( + "OTHER_ASSET", "HOUSEHOLD", + { p1: { startValue: 100000, expectedReturn: 0, annualContribution: 0 }, p2: {} }, + { p1: { decision, partialSaleAmount: amount } } + ), + ], + }); + } + const asset = (pl: PlanInput, i: number) => + computePlan(pl).phases[i].elements.find((e) => e.category === "OTHER_ASSET")!; + + it("Teilverkauf: Betrag fliesst ins Cash, Rest bleibt investiert", () => { + const r = computePlan(p42("PARTIAL", 30000)); + expect(r.phases[1].capitalInflow).toBe(30000); // "zu investierendes Kapital" im Phasenkopf + expect(r.phases[1].cashStart).toBe(30000); + expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest laeuft weiter + }); + + it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unveraendert", () => { + expect(computePlan(p42("PARTIAL", 999999)).phases[1].capitalInflow).toBe(100000); + expect(computePlan(p42("SELL")).phases[1].capitalInflow).toBe(100000); + expect(computePlan(p42("HOLD")).phases[1].capitalInflow).toBe(0); + expect(asset(p42("HOLD"), 1).startValue).toBe(100000); + }); +}); + +describe("Sonderamortisation Hypothek (Roadmap 15)", () => { + function p15(extra?: number) { + return plan({ + age: 40, retirementAge: 70, inflation: 0, initialCash: 500000, + phases: [ + { id: "p1", durationYears: 5 }, + { id: "p2", durationYears: 5 }, + ], + elements: [ + el( + "REAL_ESTATE", "HOUSEHOLD", + { p1: { purchasePrice: 1000000, mortgage: 800000, amortization: 0 }, p2: {} }, + { p1: { decision: "HOLD", extraAmortization: extra } } + ), + ], + }); + } + const immo = (pl: PlanInput, i: number) => + computePlan(pl).phases[i].elements.find((e) => e.category === "REAL_ESTATE")!; + + it("Einmaltilgung senkt Restschuld und belastet das Cash", () => { + const r = computePlan(p15(300000)); + expect(r.phases[1].capitalInvest).toBe(300000); // erscheint als Kapitalinvestition + expect(r.phases[1].cashStart).toBe(200000); // 500'000 - 300'000 + // Netto-Immobilienwert steigt um die Tilgung: 1 Mio - (800k - 300k) = 500'000. + expect(immo(p15(300000), 1).startValue).toBe(500000); + }); + + it("Einmaltilgung an der Restschuld gekappt", () => { + const r = computePlan(p15(999999)); + expect(r.phases[1].capitalInvest).toBe(800000); // nie mehr als die Resthypothek + expect(immo(p15(999999), 1).startValue).toBe(1000000); // schuldenfrei + }); + + it("ohne Sonderamortisation bleibt alles beim Alten", () => { + expect(computePlan(p15()).phases[1].cashStart).toBe(500000); + expect(immo(p15(), 1).startValue).toBe(200000); + }); +}); + describe("Immobilie: Zins und Wertsteigerung", () => { function immoPlan(pd: PhaseData, jahre = 10) { return plan({ diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 059644f..b6fa557 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -36,6 +36,7 @@ export interface ElementPhaseComputed { 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) + mortgageEnd: number; // nur REAL_ESTATE: Resthypothek zu Phasenende (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; @@ -361,6 +362,7 @@ export function computePlan(plan: PlanInput): PlanComputed { carried: carry.hasCarry, baseValue: 0, mortgageStart: 0, + mortgageEnd: 0, startValue: 0, endValue: 0, summary: "", @@ -647,6 +649,7 @@ export function computePlan(plan: PlanInput): PlanComputed { } for (const re of realEstates) { re.ec.endValue = Math.round(re.value - re.mortgage); + re.ec.mortgageEnd = Math.round(re.mortgage); re.ec.summary = fmt(re.ec.endValue); wealthEnd += re.ec.endValue; } @@ -803,6 +806,11 @@ export function computePlan(plan: PlanInput): PlanComputed { if (td.decision === "SELL") { txInflow += ec.endValue; carry.status = "SOLD"; + } else if (td.decision === "PARTIAL") { + // Teilverkauf: Betrag fliesst ins Cash, der Rest bleibt aktiv und waechst weiter. + const part = Math.min(ec.endValue, Math.round(num(td.partialSaleAmount))); + txInflow += part; + carry.value = ec.endValue - part; } else { carry.value = ec.endValue; } @@ -820,7 +828,10 @@ export function computePlan(plan: PlanInput): PlanComputed { txInflow += Math.round(salePrice - re.mortgage - tax); carry.status = "SOLD"; } else { - carry.mortgage = re.mortgage; + // Sonderamortisation: Einmaltilgung der Hypothek aus dem Cash, am Restsaldo gekappt. + const extra = Math.min(re.mortgage, Math.round(num(td.extraAmortization))); + if (extra > 0) txImmediateRepay += extra; + carry.mortgage = re.mortgage - extra; carry.propertyValue = re.value; carry.propertyPurchase = re.purchase; } diff --git a/src/lib/elements.ts b/src/lib/elements.ts index 9d0fb6a..8a27c5e 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -88,7 +88,7 @@ export interface PhaseData { annualRepayment?: number; } -export type TransitionDecision = "HOLD" | "SELL"; +export type TransitionDecision = "HOLD" | "SELL" | "PARTIAL"; export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI"; export interface TransitionData { @@ -111,6 +111,10 @@ export interface TransitionData { // REAL_ESTATE / OTHER_ASSET decision?: TransitionDecision; salePrice?: number; + // OTHER_ASSET (Teilverkauf): Betrag, der am Uebergang ins Cash fliesst; der Rest bleibt aktiv. + partialSaleAmount?: number; + // REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Uebergang, aus dem Cash. + extraAmortization?: number; // OTHER_DEBT immediateRepayment?: number; } @@ -183,8 +187,10 @@ export const transitionDataSchema = z conversionRate: z.number().min(0).max(20).optional(), capitalTaxRate: z.number().min(0).max(100).optional(), saleTaxRate: z.number().min(0).max(100).optional(), - decision: z.enum(["HOLD", "SELL"]).optional(), + decision: z.enum(["HOLD", "SELL", "PARTIAL"]).optional(), salePrice: nonNeg.optional(), + partialSaleAmount: nonNeg.optional(), + extraAmortization: nonNeg.optional(), immediateRepayment: nonNeg.optional(), }) .strip();