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