Compare commits
2 Commits
f768e01d61
...
87e6a5f564
| Author | SHA1 | Date | |
|---|---|---|---|
| 87e6a5f564 | |||
| dfebbeb397 |
@@ -9,3 +9,5 @@ npm-debug.log*
|
|||||||
Info Dateien
|
Info Dateien
|
||||||
.claude
|
.claude
|
||||||
*.md
|
*.md
|
||||||
|
# Die Spezifikation wird in der App unter /spezifikation ausgeliefert und muss ins Image.
|
||||||
|
!SPEZIFIKATION.md
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ COPY --from=builder /app/package.json ./package.json
|
|||||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
# Wird zur Laufzeit von /api/spec gelesen und in der App gerendert.
|
||||||
|
COPY --from=builder /app/SPEZIFIKATION.md ./SPEZIFIKATION.md
|
||||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
RUN chmod +x ./docker-entrypoint.sh
|
RUN chmod +x ./docker-entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
+1688
File diff suppressed because it is too large
Load Diff
Generated
+1519
-3
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,10 @@
|
|||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.9.2",
|
"recharts": "^3.9.2",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Die Inflation liegt seit dem V5-Modell plan-weit am Plan (Plan.inflationRateDefault).
|
||||||
|
-- Die Phasen-Ueberschreibung wurde von der Berechnung nie gelesen und wird nun entfernt.
|
||||||
|
ALTER TABLE "Phase" DROP COLUMN "inflationRate";
|
||||||
@@ -93,6 +93,7 @@ model Plan {
|
|||||||
|
|
||||||
// Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung)
|
// Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung)
|
||||||
// wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations).
|
// wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations).
|
||||||
|
// Die Inflation liegt seit V5 plan-weit am Plan (inflationRateDefault), nicht mehr hier.
|
||||||
model Phase {
|
model Phase {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
planId String
|
planId String
|
||||||
@@ -100,7 +101,6 @@ model Phase {
|
|||||||
sequenceNumber Int
|
sequenceNumber Int
|
||||||
name String
|
name String
|
||||||
durationYears Int
|
durationYears Int
|
||||||
inflationRate Float?
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { maxPhaseDuration } from "@/lib/calculations";
|
|||||||
const updatePhaseSchema = z.object({
|
const updatePhaseSchema = z.object({
|
||||||
name: z.string().min(1).max(120).optional(),
|
name: z.string().min(1).max(120).optional(),
|
||||||
durationYears: z.number().int().min(1).max(80).optional(),
|
durationYears: z.number().int().min(1).max(80).optional(),
|
||||||
inflationRate: z.number().min(-20).max(50).nullable().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
@@ -46,7 +45,6 @@ export async function PUT(
|
|||||||
data: {
|
data: {
|
||||||
name: parsed.data.name ?? undefined,
|
name: parsed.data.name ?? undefined,
|
||||||
durationYears: duration ?? undefined,
|
durationYears: duration ?? undefined,
|
||||||
inflationRate: parsed.data.inflationRate === undefined ? undefined : parsed.data.inflationRate,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json({ phase: { id: phase.id } });
|
return NextResponse.json({ phase: { id: phase.id } });
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { num, type PhaseData } from "@/lib/elements";
|
|||||||
const createPhaseSchema = z.object({
|
const createPhaseSchema = z.object({
|
||||||
name: z.string().min(1).max(120).optional(),
|
name: z.string().min(1).max(120).optional(),
|
||||||
durationYears: z.number().int().min(1).max(80).optional(),
|
durationYears: z.number().int().min(1).max(80).optional(),
|
||||||
inflationRate: z.number().min(-20).max(50).nullable().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
|
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
|
||||||
@@ -59,7 +58,6 @@ export async function POST(
|
|||||||
sequenceNumber: nextSequence,
|
sequenceNumber: nextSequence,
|
||||||
name: defaultName,
|
name: defaultName,
|
||||||
durationYears: duration,
|
durationYears: duration,
|
||||||
inflationRate: parsed.data.inflationRate ?? undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export async function POST(
|
|||||||
sequenceNumber: phase.sequenceNumber,
|
sequenceNumber: phase.sequenceNumber,
|
||||||
name: phase.name,
|
name: phase.name,
|
||||||
durationYears: phase.durationYears,
|
durationYears: phase.durationYears,
|
||||||
inflationRate: phase.inflationRate,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
phaseIdMap.set(phase.id, created.id);
|
phaseIdMap.set(phase.id, created.id);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
|
// Liefert die Spezifikation (SPEZIFIKATION.md aus dem Projekt-Root) als Rohtext an die App.
|
||||||
|
// Die Datei liegt im Docker-Image neben package.json (siehe Dockerfile).
|
||||||
|
export async function GET() {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const markdown = await readFile(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8");
|
||||||
|
return NextResponse.json({ markdown });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Spezifikation nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,3 +128,102 @@ body {
|
|||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
Dokument-Darstellung fuer gerenderten Markdown (Spezifikation, /spezifikation).
|
||||||
|
Bewusst ueber die semantischen Tokens, damit alle drei Farbschemata greifen.
|
||||||
|
------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.md-doc {
|
||||||
|
color: var(--fg);
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
max-width: 62rem;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-doc > *:first-child { margin-top: 0; }
|
||||||
|
.md-doc > *:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.md-doc h1,
|
||||||
|
.md-doc h2,
|
||||||
|
.md-doc h3,
|
||||||
|
.md-doc h4 {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
scroll-margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.md-doc h1 { font-size: 1.6rem; margin: 2.4rem 0 1rem; }
|
||||||
|
.md-doc h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 2.2rem 0 0.85rem;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.md-doc h3 { font-size: 1.05rem; margin: 1.7rem 0 0.6rem; }
|
||||||
|
.md-doc h4 { font-size: 0.95rem; margin: 1.3rem 0 0.5rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.md-doc p { margin: 0.75rem 0; }
|
||||||
|
.md-doc strong { font-weight: 600; color: var(--fg); }
|
||||||
|
.md-doc em { font-style: italic; }
|
||||||
|
|
||||||
|
.md-doc a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.md-doc a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
.md-doc ul,
|
||||||
|
.md-doc ol { margin: 0.75rem 0; padding-left: 1.4rem; }
|
||||||
|
.md-doc ul { list-style: disc; }
|
||||||
|
.md-doc ol { list-style: decimal; }
|
||||||
|
.md-doc li { margin: 0.3rem 0; }
|
||||||
|
.md-doc li::marker { color: var(--faint); }
|
||||||
|
|
||||||
|
.md-doc code {
|
||||||
|
font-family: var(--font-mono), monospace;
|
||||||
|
font-size: 0.84em;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
}
|
||||||
|
.md-doc pre {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.md-doc pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breite Tabellen scrollen in ihrem eigenen Container, die Seite bleibt ruhig. */
|
||||||
|
.md-doc .md-table-wrap { overflow-x: auto; margin: 1rem 0; }
|
||||||
|
.md-doc table { border-collapse: collapse; width: 100%; font-size: 0.82rem; }
|
||||||
|
.md-doc th,
|
||||||
|
.md-doc td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.md-doc th { background: var(--surface-2); font-weight: 600; white-space: nowrap; }
|
||||||
|
.md-doc tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface-2) 45%, transparent); }
|
||||||
|
|
||||||
|
.md-doc blockquote {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-soft-fg);
|
||||||
|
border-radius: 0 0.4rem 0.4rem 0;
|
||||||
|
}
|
||||||
|
.md-doc blockquote p { margin: 0.3rem 0; }
|
||||||
|
|
||||||
|
.md-doc hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
FileText,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PlanView } from "@/components/PlanView";
|
import { PlanView } from "@/components/PlanView";
|
||||||
import { Dashboard } from "@/components/Dashboard";
|
import { Dashboard } from "@/components/Dashboard";
|
||||||
|
import { SpecView } from "@/components/SpecView";
|
||||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
@@ -34,6 +36,8 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showNewPlan, setShowNewPlan] = useState(false);
|
const [showNewPlan, setShowNewPlan] = useState(false);
|
||||||
const [showScenario, setShowScenario] = useState(false);
|
const [showScenario, setShowScenario] = useState(false);
|
||||||
|
// Die Spezifikation ist eine eigene Ansicht neben Uebersicht und Plan (schliessen sich aus).
|
||||||
|
const [showSpec, setShowSpec] = useState(false);
|
||||||
|
|
||||||
const loadPlans = useCallback(async (preferId?: string) => {
|
const loadPlans = useCallback(async (preferId?: string) => {
|
||||||
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
||||||
@@ -95,10 +99,13 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPlanId(null);
|
setSelectedPlanId(null);
|
||||||
|
setShowSpec(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
||||||
selectedPlanId === null ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
selectedPlanId === null && !showSpec
|
||||||
|
? "bg-accent-soft text-accent-soft-fg"
|
||||||
|
: "text-muted hover:bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
@@ -123,10 +130,13 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPlanId(p.id);
|
setSelectedPlanId(p.id);
|
||||||
|
setShowSpec(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
|
||||||
selectedPlanId === p.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
selectedPlanId === p.id && !showSpec
|
||||||
|
? "bg-accent-soft font-medium text-accent-soft-fg"
|
||||||
|
: "text-muted hover:bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderKanban className="h-4 w-4 shrink-0" />
|
<FolderKanban className="h-4 w-4 shrink-0" />
|
||||||
@@ -134,6 +144,23 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<span className="text-[11px] text-faint">{p.phases.length}</span>
|
<span className="text-[11px] text-faint">{p.phases.length}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSpec(true);
|
||||||
|
setSelectedPlanId(null);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
||||||
|
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 shrink-0" />
|
||||||
|
SPEZIFIKATION
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -173,25 +200,30 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
|
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
|
||||||
{activePlan ? activePlan.name : "Uebersicht"}
|
{showSpec ? "Spezifikation" : activePlan ? activePlan.name : "Uebersicht"}
|
||||||
</h1>
|
</h1>
|
||||||
<ProfileMenu username={username} />
|
<ProfileMenu username={username} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 px-4 py-6 lg:px-8">
|
<main className="flex-1 px-4 py-6 lg:px-8">
|
||||||
{loading && <p className="text-sm text-muted">Laedt…</p>}
|
{showSpec && <SpecView />}
|
||||||
|
|
||||||
{!loading && selectedPlanId === null && (
|
{!showSpec && loading && <p className="text-sm text-muted">Laedt…</p>}
|
||||||
|
|
||||||
|
{!showSpec && !loading && selectedPlanId === null && (
|
||||||
<DashboardHome
|
<DashboardHome
|
||||||
username={username}
|
username={username}
|
||||||
plans={plans}
|
plans={plans}
|
||||||
onSelect={setSelectedPlanId}
|
onSelect={(id) => {
|
||||||
|
setSelectedPlanId(id);
|
||||||
|
setShowSpec(false);
|
||||||
|
}}
|
||||||
onCreate={() => setShowNewPlan(true)}
|
onCreate={() => setShowNewPlan(true)}
|
||||||
onDelete={handleDeletePlan}
|
onDelete={handleDeletePlan}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && detail && selectedPlanId && (
|
{!showSpec && !loading && detail && selectedPlanId && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{detail.plan.phases.length > 0 && (
|
{detail.plan.phases.length > 0 && (
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ 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)
|
||||||
phaseInflation: number; // Plan-Inflationsrate (Info)
|
|
||||||
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +48,9 @@ function DerivedField({ label, value, help }: { label: string; value: number; he
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PK/3a-Bezugs-Entscheid im normalen Uebergang: Kein Bezug / Bezug (+ Betrag).
|
// PK/3a-Bezugs-Entscheid im normalen Uebergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
|
||||||
|
// Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst
|
||||||
|
// der Betrag nach Abzug der Steuer.
|
||||||
function WithdrawalDecision({
|
function WithdrawalDecision({
|
||||||
td,
|
td,
|
||||||
setT,
|
setT,
|
||||||
@@ -62,6 +63,8 @@ function WithdrawalDecision({
|
|||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
const mode = td.withdrawalMode ?? "NONE";
|
const mode = td.withdrawalMode ?? "NONE";
|
||||||
|
const gross = num(td.withdrawal);
|
||||||
|
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SelectField
|
<SelectField
|
||||||
@@ -74,13 +77,27 @@ function WithdrawalDecision({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{mode === "AMOUNT" && (
|
{mode === "AMOUNT" && (
|
||||||
<MoneyField
|
<>
|
||||||
label={label}
|
<MoneyField
|
||||||
help={`Maximal ${formatChf(max)} (Endwert der Vorphase).`}
|
label={label}
|
||||||
value={num(td.withdrawal)}
|
help={`Bruttobetrag, dem Kapital entnommen. Maximal ${formatChf(max)} (Endwert der Vorphase).`}
|
||||||
max={max}
|
value={gross}
|
||||||
onChange={(v) => setT({ withdrawal: v })}
|
max={max}
|
||||||
/>
|
onChange={(v) => setT({ withdrawal: v })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Kapitalbezugssteuer (%)"
|
||||||
|
help="Ein Vorbezug ist wie der Bezug bei Pensionierung kapitalbezugssteuerpflichtig."
|
||||||
|
step={0.5}
|
||||||
|
value={taxRate}
|
||||||
|
onChange={(v) => setT({ capitalTaxRate: v })}
|
||||||
|
/>
|
||||||
|
<DerivedField
|
||||||
|
label="Auszahlung netto (ins Cash)"
|
||||||
|
value={Math.round(gross * (1 - taxRate / 100))}
|
||||||
|
help="Bruttobezug abzueglich Kapitalbezugssteuer. Wird automatisch berechnet."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function PlanProfileFields({
|
|||||||
|
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Erwartete Inflationsrate (%)"
|
label="Erwartete Inflationsrate (%)"
|
||||||
help="Langfristige Annahme zur jaehrlichen Teuerung. Kann pro Lebensphase individuell ueberschrieben werden."
|
help="Langfristige Annahme zur jaehrlichen Teuerung. Gilt plan-weit fuer alle Lebensphasen."
|
||||||
value={draft.inflationRateDefault}
|
value={draft.inflationRateDefault}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
|
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
|
||||||
|
|||||||
@@ -154,10 +154,6 @@ export function PlanView({
|
|||||||
return !!before?.working && !!after && !after.working;
|
return !!before?.working && !!after && !after.working;
|
||||||
}
|
}
|
||||||
|
|
||||||
function phaseInflationFor(phaseId: string): number {
|
|
||||||
return plan.phases.find((p) => p.id === phaseId)?.inflationRate ?? plan.inflationRateDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Baut den Kontext fuer eine Phasenzelle.
|
// Baut den Kontext fuer eine Phasenzelle.
|
||||||
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
||||||
const ce = computedElement(phase.id, element.id);
|
const ce = computedElement(phase.id, element.id);
|
||||||
@@ -175,7 +171,6 @@ 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,
|
||||||
phaseInflation: phaseInflationFor(phase.id),
|
|
||||||
deflatorStart: phase.cumulativeInflationStart,
|
deflatorStart: phase.cumulativeInflationStart,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -192,7 +187,6 @@ export function PlanView({
|
|||||||
carriedEndValue: ce?.endValue ?? 0,
|
carriedEndValue: ce?.endValue ?? 0,
|
||||||
carried: ce?.carried ?? false,
|
carried: ce?.carried ?? false,
|
||||||
derivedStart: 0,
|
derivedStart: 0,
|
||||||
phaseInflation: phaseInflationFor(fromPhase.id),
|
|
||||||
deflatorStart: fromPhase.cumulativeInflationStart,
|
deflatorStart: fromPhase.cumulativeInflationStart,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -241,7 +235,7 @@ export function PlanView({
|
|||||||
.sort((a, b) => a.orderIndex - b.orderIndex);
|
.sort((a, b) => a.orderIndex - b.orderIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddPhase(payload: { name?: string; durationYears?: number; inflationRate?: number | null }) {
|
async function handleAddPhase(payload: { name?: string; durationYears?: number }) {
|
||||||
await api.post(`/api/plans/${plan.id}/phases`, payload);
|
await api.post(`/api/plans/${plan.id}/phases`, payload);
|
||||||
setShowAddPhase(false);
|
setShowAddPhase(false);
|
||||||
onChanged();
|
onChanged();
|
||||||
@@ -855,7 +849,6 @@ function AddElementDialog({
|
|||||||
carriedEndValue: 0,
|
carriedEndValue: 0,
|
||||||
carried: false,
|
carried: false,
|
||||||
derivedStart: 0,
|
derivedStart: 0,
|
||||||
phaseInflation: plan.inflationRateDefault,
|
|
||||||
deflatorStart: firstPhase.cumulativeInflationStart,
|
deflatorStart: firstPhase.cumulativeInflationStart,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -954,7 +947,7 @@ function AddPhaseDialog({
|
|||||||
}: {
|
}: {
|
||||||
maxDurationYears: number | null;
|
maxDurationYears: number | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreate: (payload: { name?: string; durationYears?: number; inflationRate?: number | null }) => void;
|
onCreate: (payload: { name?: string; durationYears?: number }) => void;
|
||||||
}) {
|
}) {
|
||||||
const cap = maxDurationYears;
|
const cap = maxDurationYears;
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeSlug from "rehype-slug";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
|
||||||
|
// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema.
|
||||||
|
export function SpecView() {
|
||||||
|
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.get<{ markdown: string }>("/api/spec")
|
||||||
|
.then((data) => setMarkdown(data.markdown))
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : "Laden fehlgeschlagen."));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||||
|
if (markdown === null) return <p className="text-sm text-muted">Laedt…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-accent" />
|
||||||
|
<h2 className="text-lg font-semibold text-fg">Spezifikation</h2>
|
||||||
|
</div>
|
||||||
|
<article className="md-doc rounded-xl border border-border bg-surface p-6 shadow-sm lg:p-8">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeSlug]}
|
||||||
|
components={{
|
||||||
|
// Breite Tabellen scrollen in ihrem eigenen Container statt die Seite zu dehnen.
|
||||||
|
table: ({ children, ...props }) => (
|
||||||
|
<div className="md-table-wrap">
|
||||||
|
<table {...props}>{children}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ function plan(opts: {
|
|||||||
inflationRateDefault: opts.inflation ?? 2,
|
inflationRateDefault: opts.inflation ?? 2,
|
||||||
initialCash: opts.initialCash ?? 0,
|
initialCash: opts.initialCash ?? 0,
|
||||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
||||||
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears, inflationRate: null })),
|
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears })),
|
||||||
elements: opts.elements,
|
elements: opts.elements,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -185,6 +185,72 @@ describe("V5 Golden Tests", () => {
|
|||||||
expect(ph.cashEnd).toBe(50000);
|
expect(ph.cashEnd).toBe(50000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Tilgung stoppt, sobald die Schuld getilgt ist (belastet Cash und Sparrate nicht weiter)", () => {
|
||||||
|
// Schuld 25'000, Tilgung 10'000/Jahr, 5 Jahre: getilgt im Jahr 3 (10'000 + 10'000 + 5'000).
|
||||||
|
// Gesamtabfluss = 25'000, NICHT 50'000. Einkommen = Ausgaben, damit nur die Tilgung wirkt.
|
||||||
|
const p = plan({
|
||||||
|
age: 40,
|
||||||
|
retirementAge: 70,
|
||||||
|
inflation: 0,
|
||||||
|
initialCash: 100000,
|
||||||
|
phases: [{ id: "p1", durationYears: 5 }],
|
||||||
|
elements: [
|
||||||
|
el("INCOME", "PERSON_A", { p1: { amount: 50000, teuerungsausgleich: 0 } }),
|
||||||
|
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000, teuerungsausgleich: 0 } }),
|
||||||
|
el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 25000, annualRepayment: 10000 } }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ph = computePlan(p).phases[0];
|
||||||
|
expect(ph.cashEnd).toBe(75000); // 100'000 - 25'000
|
||||||
|
expect(ph.plannedSaveRate).toBe(10000); // erstes Jahr volle Rate
|
||||||
|
const debt = ph.elements.find((e) => e.category === "OTHER_DEBT")!;
|
||||||
|
expect(debt.endValue).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Amortisation stoppt, sobald die Hypothek abbezahlt ist", () => {
|
||||||
|
// Hypothek 15'000, Amortisation 10'000/Jahr, 4 Jahre: abbezahlt im Jahr 2 (10'000 + 5'000).
|
||||||
|
const p = plan({
|
||||||
|
age: 40,
|
||||||
|
retirementAge: 70,
|
||||||
|
inflation: 0,
|
||||||
|
initialCash: 100000,
|
||||||
|
phases: [{ id: "p1", durationYears: 4 }],
|
||||||
|
elements: [
|
||||||
|
el("REAL_ESTATE", "HOUSEHOLD", { p1: { purchasePrice: 500000, mortgage: 15000, amortization: 10000 } }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ph = computePlan(p).phases[0];
|
||||||
|
expect(ph.cashEnd).toBe(85000); // 100'000 - 15'000 (nicht - 40'000)
|
||||||
|
const re = ph.elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||||
|
expect(re.endValue).toBe(500000); // schuldenfrei
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Vorbezug PK vor Pensionierung: Kapitalbezugssteuer wird abgezogen", () => {
|
||||||
|
// Bezug 100'000 brutto bei 8% Steuer -> 92'000 netto ins Cash der Folgephase.
|
||||||
|
const p = plan({
|
||||||
|
age: 40,
|
||||||
|
retirementAge: 70,
|
||||||
|
inflation: 0,
|
||||||
|
phases: [
|
||||||
|
{ id: "p1", durationYears: 1 },
|
||||||
|
{ id: "p2", durationYears: 1 },
|
||||||
|
],
|
||||||
|
elements: [
|
||||||
|
el(
|
||||||
|
"PENSION_FUND",
|
||||||
|
"PERSON_A",
|
||||||
|
{ p1: { currentValue: 300000, expectedReturn: 0, annualContribution: 0 }, p2: {} },
|
||||||
|
{ p1: { withdrawalMode: "AMOUNT", withdrawal: 100000, capitalTaxRate: 8 } }
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = computePlan(p);
|
||||||
|
expect(r.phases[1].capitalInflow).toBe(92000); // netto nach 8% Steuer
|
||||||
|
expect(r.phases[1].cashStart).toBe(92000);
|
||||||
|
const pk = r.phases[1].elements.find((e) => e.category === "PENSION_FUND")!;
|
||||||
|
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
|
||||||
|
});
|
||||||
|
|
||||||
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
|
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40,
|
age: 40,
|
||||||
|
|||||||
+41
-21
@@ -54,7 +54,10 @@ export interface PhaseComputed {
|
|||||||
quotaStart: number;
|
quotaStart: number;
|
||||||
quotaEnd: number;
|
quotaEnd: number;
|
||||||
isConsumption: boolean;
|
isConsumption: boolean;
|
||||||
plannedSaveRate: number; // geplante Sparrate: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. + Tilgung
|
// Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. +
|
||||||
|
// Tilgung. Amortisation/Tilgung entfallen, sobald Hypothek/Schuld abbezahlt sind -- die Rate
|
||||||
|
// kann in spaeteren Phasenjahren also tiefer liegen.
|
||||||
|
plannedSaveRate: number;
|
||||||
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen
|
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen
|
||||||
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase)
|
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase)
|
||||||
capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen
|
capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen
|
||||||
@@ -208,9 +211,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||||
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 }[] = [];
|
||||||
const realEstates: { purchase: number; mortgageStart: number; amort: number; ec: ElementPhaseComputed }[] = [];
|
// mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
|
||||||
const debts: { owedStart: number; repay: number; ec: ElementPhaseComputed }[] = [];
|
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt).
|
||||||
let plannedRatesTotal = 0; // Sparraten (verlassen das Cash): 3a + Sonstiges Vermoegen + Amort. + Tilgung
|
const realEstates: { purchase: number; mortgage: number; amort: number; ec: ElementPhaseComputed }[] = [];
|
||||||
|
const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
|
||||||
|
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen
|
||||||
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen
|
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen
|
||||||
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
|
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
|
||||||
let wealthStart = 0;
|
let wealthStart = 0;
|
||||||
@@ -312,7 +317,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
const start = base + topUp;
|
const start = base + topUp;
|
||||||
const rate = Math.round(num(pd.annualContribution));
|
const rate = Math.round(num(pd.annualContribution));
|
||||||
plannedRatesTotal += rate;
|
fixedRatesTotal += rate;
|
||||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||||
ec.baseValue = base;
|
ec.baseValue = base;
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
@@ -327,7 +332,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
const start = base + topUp;
|
const start = base + topUp;
|
||||||
const rate = Math.round(num(pd.annualContribution));
|
const rate = Math.round(num(pd.annualContribution));
|
||||||
const withdrawal = Math.round(num(pd.annualWithdrawal));
|
const withdrawal = Math.round(num(pd.annualWithdrawal));
|
||||||
plannedRatesTotal += rate;
|
fixedRatesTotal += rate;
|
||||||
plannedWithdrawTotal += withdrawal;
|
plannedWithdrawTotal += withdrawal;
|
||||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||||
ec.baseValue = base;
|
ec.baseValue = base;
|
||||||
@@ -340,23 +345,21 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
const purchase = Math.round(num(pd.purchasePrice));
|
const purchase = 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));
|
||||||
plannedRatesTotal += amort;
|
|
||||||
const equity = purchase - mortgageStart;
|
const equity = purchase - 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.startValue = equity;
|
ec.startValue = equity;
|
||||||
wealthStart += equity;
|
wealthStart += equity;
|
||||||
realEstates.push({ purchase, mortgageStart, amort, ec });
|
realEstates.push({ purchase, mortgage: mortgageStart, amort, ec });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "OTHER_DEBT": {
|
case "OTHER_DEBT": {
|
||||||
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
|
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
|
||||||
const repay = Math.round(num(pd.annualRepayment));
|
const repay = Math.round(num(pd.annualRepayment));
|
||||||
plannedRatesTotal += repay;
|
|
||||||
ec.baseValue = -owedStart;
|
ec.baseValue = -owedStart;
|
||||||
ec.startValue = -owedStart;
|
ec.startValue = -owedStart;
|
||||||
wealthStart += -owedStart;
|
wealthStart += -owedStart;
|
||||||
debts.push({ owedStart, repay, ec });
|
debts.push({ owed: owedStart, repay, ec });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,6 +377,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
let expenseEnd = 0;
|
let expenseEnd = 0;
|
||||||
let quotaStart = 0;
|
let quotaStart = 0;
|
||||||
let quotaEnd = 0;
|
let quotaEnd = 0;
|
||||||
|
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr
|
||||||
|
|
||||||
for (let t = 1; t <= duration; t++) {
|
for (let t = 1; t <= duration; t++) {
|
||||||
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
|
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
|
||||||
@@ -413,14 +417,30 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
a.value = grown - w;
|
a.value = grown - w;
|
||||||
cashFromWithdraw += w;
|
cashFromWithdraw += w;
|
||||||
}
|
}
|
||||||
cash += quote - plannedRatesTotal + cashFromWithdraw;
|
|
||||||
|
// Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
|
||||||
|
// Hypothek/Schuld abbezahlt, entfaellt die Rate -- sie belastet weder Cash noch Sparquote.
|
||||||
|
let debtRates = 0;
|
||||||
|
for (const re of realEstates) {
|
||||||
|
const pay = Math.min(re.amort, re.mortgage);
|
||||||
|
re.mortgage -= pay;
|
||||||
|
debtRates += pay;
|
||||||
|
}
|
||||||
|
for (const d of debts) {
|
||||||
|
const pay = Math.min(d.repay, d.owed);
|
||||||
|
d.owed -= pay;
|
||||||
|
debtRates += pay;
|
||||||
|
}
|
||||||
|
if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates;
|
||||||
|
|
||||||
|
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
|
||||||
if (cash < 0) cashNegative = true;
|
if (cash < 0) cashNegative = true;
|
||||||
|
|
||||||
// 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 - Math.max(0, re.mortgageStart - re.amort * t);
|
for (const re of realEstates) total += re.purchase - re.mortgage;
|
||||||
for (const d of debts) total += -Math.max(0, d.owedStart - d.repay * t);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,17 +465,15 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
wealthEnd += a.ec.endValue;
|
wealthEnd += a.ec.endValue;
|
||||||
}
|
}
|
||||||
for (const re of realEstates) {
|
for (const re of realEstates) {
|
||||||
const mortgageEnd = Math.max(0, re.mortgageStart - re.amort * duration);
|
re.ec.endValue = re.purchase - re.mortgage;
|
||||||
re.ec.endValue = re.purchase - mortgageEnd;
|
|
||||||
re.ec.summary = fmt(re.ec.endValue);
|
re.ec.summary = fmt(re.ec.endValue);
|
||||||
wealthEnd += re.ec.endValue;
|
wealthEnd += re.ec.endValue;
|
||||||
}
|
}
|
||||||
for (const d of debts) {
|
for (const d of debts) {
|
||||||
const owedEnd = Math.max(0, d.owedStart - d.repay * duration);
|
d.ec.endValue = d.owed > 0 ? -d.owed : 0;
|
||||||
d.ec.endValue = -owedEnd;
|
|
||||||
d.ec.summary = fmt(d.ec.endValue);
|
d.ec.summary = fmt(d.ec.endValue);
|
||||||
wealthEnd += d.ec.endValue;
|
wealthEnd += d.ec.endValue;
|
||||||
if (owedEnd === 0) d.ec.note = "Wird getilgt";
|
if (d.owed === 0) d.ec.note = "Wird getilgt";
|
||||||
}
|
}
|
||||||
|
|
||||||
const cashEnd = Math.round(cash);
|
const cashEnd = Math.round(cash);
|
||||||
@@ -478,7 +496,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
quotaStart: Math.round(quotaStart),
|
quotaStart: Math.round(quotaStart),
|
||||||
quotaEnd: Math.round(quotaEnd),
|
quotaEnd: Math.round(quotaEnd),
|
||||||
isConsumption: quotaStart < 0,
|
isConsumption: quotaStart < 0,
|
||||||
plannedSaveRate: plannedRatesTotal,
|
plannedSaveRate,
|
||||||
plannedWithdrawRate: plannedWithdrawTotal,
|
plannedWithdrawRate: plannedWithdrawTotal,
|
||||||
capitalInflow: Math.round(incomingInflow),
|
capitalInflow: Math.round(incomingInflow),
|
||||||
capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay),
|
capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay),
|
||||||
@@ -538,9 +556,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Vorbezug (z. B. Wohneigentum/Selbststaendigkeit): ebenfalls kapitalbezugssteuerpflichtig.
|
||||||
|
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
|
||||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||||
carry.value = ec.endValue - withdrawal;
|
carry.value = ec.endValue - withdrawal;
|
||||||
txInflow += withdrawal;
|
txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -551,7 +571,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
|||||||
} else {
|
} else {
|
||||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||||
carry.value = ec.endValue - withdrawal;
|
carry.value = ec.endValue - withdrawal;
|
||||||
txInflow += withdrawal;
|
txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
|||||||
sequenceNumber: phase.sequenceNumber,
|
sequenceNumber: phase.sequenceNumber,
|
||||||
name: phase.name,
|
name: phase.name,
|
||||||
durationYears: phase.durationYears,
|
durationYears: phase.durationYears,
|
||||||
inflationRate: phase.inflationRate,
|
|
||||||
})),
|
})),
|
||||||
elements: plan.elements.map((e) => {
|
elements: plan.elements.map((e) => {
|
||||||
const phaseValues: Record<string, PhaseData> = {};
|
const phaseValues: Record<string, PhaseData> = {};
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export interface PhaseInput {
|
|||||||
sequenceNumber: number;
|
sequenceNumber: number;
|
||||||
name: string;
|
name: string;
|
||||||
durationYears: number;
|
durationYears: number;
|
||||||
inflationRate: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ElementInput {
|
export interface ElementInput {
|
||||||
|
|||||||
Reference in New Issue
Block a user