Teilverkauf (Sonstiges Vermoegen) + Sonderamortisation (Immobilie)
Deploy App / deploy (push) Successful in 1m1s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:02:44 +02:00
parent a97de5b1ed
commit 5bf35bd6da
6 changed files with 182 additions and 28 deletions
+41 -13
View File
@@ -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` (v1v5) 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/<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. |
@@ -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 46, `src/lib/elements.ts` Zeilen 48
| `capitalAmount` | PK (COMBI) | ≥ 0 |
| `conversionRate` | PK | 020 |
| `capitalTaxRate` | PK + 3a, **sowohl Vorbezug als auch Pensionierung** | 0100 |
| `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 | 0100 |
| `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 |
+32 -10
View File
@@ -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({
<>
<SelectField
label="Entscheidung"
value={decision}
value={decision === "SELL" ? "SELL" : "HOLD"}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
@@ -785,21 +786,42 @@ export function ElementTransitionFields({
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
</>
)}
{decision !== "SELL" && context.mortgageEnd > 0 && (
<MoneyField
label="Sonderamortisation (CHF)"
help={`Einmaltilgung der Hypothek aus dem Cash der naechsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und kuenftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`}
value={num(td.extraAmortization)}
max={context.mortgageEnd}
onChange={(v) => setT({ extraAmortization: v })}
/>
)}
</>
);
}
case "OTHER_ASSET": {
const decision = td.decision ?? "HOLD";
return (
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
<>
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL" | "PARTIAL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen (ganz)" },
{ value: "PARTIAL", label: "Teilverkauf" },
]}
/>
{decision === "PARTIAL" && (
<MoneyField
label="Teilverkauf (CHF)"
help={`Betrag, der am Uebergang ins Cash fliesst. Der Rest bleibt investiert. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
value={num(td.partialSaleAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ partialSaleAmount: v })}
/>
)}
</>
);
}
case "OTHER_DEBT":
+14 -2
View File
@@ -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,
+75
View File
@@ -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({
+12 -1
View File
@@ -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;
}
+8 -2
View File
@@ -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();