Automate transition: carried-over securities auto-appear in next phase, sale proceeds tracked as incoming capital with allocation status indicators
Deploy App / deploy (push) Successful in 1m27s

This commit is contained in:
2026-07-08 20:20:20 +02:00
parent 837da980f2
commit a1e689167c
11 changed files with 172 additions and 31 deletions
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Phase" ADD COLUMN "incomingCapital" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Security" ADD COLUMN "carriedBaseValue" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN "carriedFromSecurityId" TEXT;
+10
View File
@@ -112,6 +112,9 @@ model Phase {
durationYears Int durationYears Int
inflationRate Float? inflationRate Float?
incomeMode IncomeMode @default(HOUSEHOLD) incomeMode IncomeMode @default(HOUSEHOLD)
// Aus Verkaeufen im Uebergang aus der Vorphase verfuegbares Startkapital (wird beim
// Speichern des Uebergangs automatisch gesetzt, siehe PhaseTransition).
incomingCapital Float @default(0)
incomeEntries IncomeEntry[] incomeEntries IncomeEntry[]
expenseEntries ExpenseEntry[] expenseEntries ExpenseEntry[]
@@ -162,6 +165,13 @@ model Security {
ownerTag OwnerTag @default(HOUSEHOLD) ownerTag OwnerTag @default(HOUSEHOLD)
// Steuersatz auf Verkaufsgewinn bei Uebernahme in PhaseTransitionItem (Default 0%, siehe Kap. 9) // Steuersatz auf Verkaufsgewinn bei Uebernahme in PhaseTransitionItem (Default 0%, siehe Kap. 9)
saleTaxRate Float @default(0) saleTaxRate Float @default(0)
// Baseline-Wert bei automatischer Uebernahme aus der Vorphase (0 bei manuell angelegten
// Wertschriften). Dient dazu, im UI zu erkennen, wie viel vom verfuegbaren Startkapital
// bereits (on top of der Uebernahme) zugewiesen wurde.
carriedBaseValue Float @default(0)
// Verweist auf die Wertschrift der Vorphase, aus der automatisch uebernommen wurde
// (nur intern zur Deduplizierung bei wiederholtem Speichern des Uebergangs, kein FK).
carriedFromSecurityId String?
transitionItems PhaseTransitionItem[] transitionItems PhaseTransitionItem[]
} }
+1
View File
@@ -19,6 +19,7 @@ const securitySchema = z.object({
annualContribution: z.number(), annualContribution: z.number(),
ownerTag: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]), ownerTag: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]),
saleTaxRate: z.number().min(0).max(100), saleTaxRate: z.number().min(0).max(100),
carriedBaseValue: z.number().default(0),
}); });
const realEstateSchema = z.object({ const realEstateSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { computeSecurityYearlyValues } from "@/lib/calculations";
const transitionItemSchema = z.object({ const transitionItemSchema = z.object({
positionType: z.enum(["SECURITY", "REAL_ESTATE"]), positionType: z.enum(["SECURITY", "REAL_ESTATE"]),
@@ -50,6 +51,9 @@ export async function GET(
} }
// Speichert die Entscheidungen (Uebernehmen/Verkaufen) fuer jede Position der Vorphase. // Speichert die Entscheidungen (Uebernehmen/Verkaufen) fuer jede Position der Vorphase.
// Uebernommene Wertschriften werden automatisch als neue Wertschrift in der Folgephase
// angelegt (Startwert = Endwert dieser Phase). Verkaufte Positionen fliessen als
// "verfuegbares Startkapital" (Phase.incomingCapital) in die Folgephase ein.
export async function PUT( export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> } { params }: { params: Promise<{ phaseId: string }> }
@@ -94,8 +98,74 @@ export async function PUT(
); );
} }
const securityById = new Map(phase.securities.map((s) => [s.id, s]));
const realEstateById = new Map(phase.realEstates.map((re) => [re.id, re]));
let incomingCapital = 0;
const securitiesToCarry: { source: (typeof phase.securities)[number]; endValue: number }[] = [];
for (const item of parsed.data.items) {
if (item.positionType === "SECURITY" && item.securityId) {
const security = securityById.get(item.securityId);
if (!security) continue;
const endValue = computeSecurityYearlyValues(
security.startValue,
security.expectedReturn,
security.annualContribution,
phase.durationYears
)[phase.durationYears];
if (item.decision === "CARRY_OVER") {
securitiesToCarry.push({ source: security, endValue });
} else {
const gain = Math.max(0, endValue - security.startValue);
const tax = gain * (security.saleTaxRate / 100);
incomingCapital += endValue - tax;
}
} else if (item.positionType === "REAL_ESTATE" && item.realEstateId) {
const realEstate = realEstateById.get(item.realEstateId);
if (!realEstate || item.decision !== "SELL") continue;
const salePrice = item.salePrice ?? 0;
const gain = Math.max(0, salePrice - realEstate.marketValue);
const tax = gain * (realEstate.saleTaxRate / 100);
incomingCapital += salePrice - tax;
}
}
const transition = await prisma.$transaction(async (tx) => { const transition = await prisma.$transaction(async (tx) => {
await tx.phaseTransition.deleteMany({ where: { fromPhaseId: phaseId } }); await tx.phaseTransition.deleteMany({ where: { fromPhaseId: phaseId } });
// Vorherige automatisch uebernommene Wertschriften aus einem frueheren Speichern
// dieses Uebergangs entfernen, damit sie nicht dupliziert werden. Manuell vom
// Benutzer angelegte Wertschriften (carriedFromSecurityId = null) bleiben unberuehrt.
await tx.security.deleteMany({
where: {
phaseId: nextPhase.id,
carriedFromSecurityId: { in: phase.securities.map((s) => s.id) },
},
});
for (const { source, endValue } of securitiesToCarry) {
await tx.security.create({
data: {
phaseId: nextPhase.id,
name: source.name,
startValue: endValue,
carriedBaseValue: endValue,
expectedReturn: source.expectedReturn,
annualContribution: 0,
ownerTag: source.ownerTag,
saleTaxRate: source.saleTaxRate,
carriedFromSecurityId: source.id,
},
});
}
await tx.phase.update({
where: { id: nextPhase.id },
data: { incomingCapital },
});
return tx.phaseTransition.create({ return tx.phaseTransition.create({
data: { data: {
fromPhaseId: phaseId, fromPhaseId: phaseId,
@@ -114,5 +184,5 @@ export async function PUT(
}); });
}); });
return NextResponse.json({ transition }); return NextResponse.json({ transition, incomingCapital });
} }
@@ -55,6 +55,7 @@ export async function POST(
durationYears: phase.durationYears, durationYears: phase.durationYears,
inflationRate: phase.inflationRate, inflationRate: phase.inflationRate,
incomeMode: phase.incomeMode, incomeMode: phase.incomeMode,
incomingCapital: phase.incomingCapital,
incomeEntries: { incomeEntries: {
create: phase.incomeEntries.map((e) => ({ create: phase.incomeEntries.map((e) => ({
personId: e.personId, personId: e.personId,
@@ -73,6 +74,7 @@ export async function POST(
annualContribution: s.annualContribution, annualContribution: s.annualContribution,
ownerTag: s.ownerTag, ownerTag: s.ownerTag,
saleTaxRate: s.saleTaxRate, saleTaxRate: s.saleTaxRate,
carriedBaseValue: s.carriedBaseValue,
})), })),
}, },
realEstates: { realEstates: {
+6 -1
View File
@@ -220,7 +220,12 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
onChanged={refreshCurrent} onChanged={refreshCurrent}
/> />
{nextPhase && ( {nextPhase && (
<TransitionPanel phase={phase} computed={computedPhase} nextPhaseName={nextPhase.name} /> <TransitionPanel
phase={phase}
computed={computedPhase}
nextPhaseName={nextPhase.name}
onChanged={refreshCurrent}
/>
)} )}
</div> </div>
); );
+1
View File
@@ -88,6 +88,7 @@ export function PhaseCard({
</button> </button>
{expanded && ( {expanded && (
<PhaseForm <PhaseForm
key={`${phase.id}:${phase.securities.length}:${phase.incomingCapital}`}
household={household} household={household}
phase={phase} phase={phase}
onSaved={() => { onSaved={() => {
+45 -10
View File
@@ -58,6 +58,13 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
const savingsQuota = totalIncome - totalExpense; const savingsQuota = totalIncome - totalExpense;
const allocated = securities.reduce((s, sec) => s + sec.annualContribution, 0); const allocated = securities.reduce((s, sec) => s + sec.annualContribution, 0);
const overAllocated = allocated > savingsQuota; const overAllocated = allocated > savingsQuota;
const savingsRemaining = savingsQuota - allocated > 0.5;
const allocatedStartCapital = securities.reduce(
(s, sec) => s + Math.max(0, sec.startValue - sec.carriedBaseValue),
0
);
const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5;
async function handleSave() { async function handleSave() {
setSaving(true); setSaving(true);
@@ -193,16 +200,9 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
label="+ Ausgabenposten" label="+ Ausgabenposten"
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])} onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
/> />
<div <div className="rounded-md bg-zinc-50 px-3 py-2 text-sm text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
className={`rounded-md px-3 py-2 text-sm ${
overAllocated
? "bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-300"
: "bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
}`}
>
Verfuegbare Sparquote (CHF/Jahr): <strong>{savingsQuota.toLocaleString("de-CH")}</strong> Verfuegbare Sparquote (CHF/Jahr): <strong>{savingsQuota.toLocaleString("de-CH")}</strong>
{" "} zugewiesen an Wertschriften: {allocated.toLocaleString("de-CH")} {" "}(Details und Verteilung siehe Wertschriften weiter unten)
{overAllocated && " ⚠ Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote."}
</div> </div>
</Section> </Section>
@@ -258,10 +258,36 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
onClick={() => onClick={() =>
setSecurities((prev) => [ setSecurities((prev) => [
...prev, ...prev,
{ id: tempId(), name: "", startValue: 0, expectedReturn: 0, annualContribution: 0, ownerTag: "HOUSEHOLD", saleTaxRate: 0 }, {
id: tempId(),
name: "",
startValue: 0,
expectedReturn: 0,
annualContribution: 0,
ownerTag: "HOUSEHOLD",
saleTaxRate: 0,
carriedBaseValue: 0,
},
]) ])
} }
/> />
<div className="flex flex-col gap-1.5 rounded-md bg-zinc-50 px-3 py-2 text-sm dark:bg-zinc-800">
<div className="flex items-center gap-2">
<StatusDot ok={!startCapitalRemaining} />
Verfuegbares Startkapital (aus Verkaeufen der Vorphase): <strong>{phase.incomingCapital.toLocaleString("de-CH")}</strong> CHF
{" "} zugewiesen: {allocatedStartCapital.toLocaleString("de-CH")}
</div>
<div className="flex items-center gap-2">
<StatusDot ok={!savingsRemaining} />
Verfuegbare Sparquote (CHF/Jahr): <strong>{savingsQuota.toLocaleString("de-CH")}</strong>
{" "} zugewiesen: {allocated.toLocaleString("de-CH")}
</div>
{overAllocated && (
<div className="text-amber-700 dark:text-amber-400">
Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote.
</div>
)}
</div>
</Section> </Section>
{/* Immobilien */} {/* Immobilien */}
@@ -461,6 +487,15 @@ function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
); );
} }
function StatusDot({ ok }: { ok: boolean }) {
return (
<span
title={ok ? "Vollstaendig verteilt" : "Noch nicht vollstaendig verteilt"}
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${ok ? "bg-emerald-500" : "bg-red-500"}`}
/>
);
}
function RemoveButton({ onClick }: { onClick: () => void }) { function RemoveButton({ onClick }: { onClick: () => void }) {
return ( return (
<button <button
+5 -2
View File
@@ -21,10 +21,12 @@ export function TransitionPanel({
phase, phase,
computed, computed,
nextPhaseName, nextPhaseName,
onChanged,
}: { }: {
phase: PhaseInput; phase: PhaseInput;
computed: PhaseComputed; computed: PhaseComputed;
nextPhaseName: string; nextPhaseName: string;
onChanged: () => void;
}) { }) {
const [items, setItems] = useState<ItemDraft[]>([]); const [items, setItems] = useState<ItemDraft[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
@@ -131,6 +133,7 @@ export function TransitionPanel({
})), })),
}); });
setSaved(true); setSaved(true);
onChanged();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally { } finally {
@@ -198,8 +201,8 @@ export function TransitionPanel({
<div className="rounded-md bg-white px-3 py-2 text-sm dark:bg-zinc-900"> <div className="rounded-md bg-white px-3 py-2 text-sm dark:bg-zinc-900">
Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{totalAvailableCapital.toLocaleString("de-CH")} CHF</strong> Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{totalAvailableCapital.toLocaleString("de-CH")} CHF</strong>
<p className="mt-1 text-xs text-zinc-500"> <p className="mt-1 text-xs text-zinc-500">
Dieser Betrag kann anschliessend frei auf neue oder bestehende Wertschriften der Folgephase verteilt werden Wird beim Speichern automatisch in &quot;{nextPhaseName}&quot; als verfuegbares Startkapital hinterlegt.
(Startwert der jeweiligen Wertschrift in &quot;{nextPhaseName}&quot; manuell anpassen). Uebernommene Wertschriften erscheinen dort automatisch mit ihrem Endwert als neuer Startwert.
</p> </p>
</div> </div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>} {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
+2
View File
@@ -49,6 +49,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
durationYears: phase.durationYears, durationYears: phase.durationYears,
inflationRate: phase.inflationRate, inflationRate: phase.inflationRate,
incomeMode: phase.incomeMode, incomeMode: phase.incomeMode,
incomingCapital: phase.incomingCapital,
incomeEntries: phase.incomeEntries.map((e) => ({ incomeEntries: phase.incomeEntries.map((e) => ({
id: e.id, id: e.id,
personId: e.personId, personId: e.personId,
@@ -68,6 +69,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
annualContribution: s.annualContribution, annualContribution: s.annualContribution,
ownerTag: s.ownerTag, ownerTag: s.ownerTag,
saleTaxRate: s.saleTaxRate, saleTaxRate: s.saleTaxRate,
carriedBaseValue: s.carriedBaseValue,
})), })),
realEstates: phase.realEstates.map((re) => ({ realEstates: phase.realEstates.map((re) => ({
id: re.id, id: re.id,
+6
View File
@@ -45,6 +45,9 @@ export interface SecurityInput {
annualContribution: number; annualContribution: number;
ownerTag: OwnerTag; ownerTag: OwnerTag;
saleTaxRate: number; saleTaxRate: number;
// Baseline-Wert bei automatischer Uebernahme aus der Vorphase (0 bei manuell angelegten
// Wertschriften). Siehe PhaseInput.incomingCapital fuer den Kontext.
carriedBaseValue: number;
} }
export interface RealEstateInput { export interface RealEstateInput {
@@ -81,6 +84,9 @@ export interface PhaseInput {
durationYears: number; durationYears: number;
inflationRate: number | null; inflationRate: number | null;
incomeMode: IncomeMode; incomeMode: IncomeMode;
// Aus Verkaeufen im Uebergang aus der Vorphase verfuegbares Startkapital (automatisch
// gesetzt beim Speichern des Uebergangs der Vorphase).
incomingCapital: number;
incomeEntries: IncomeEntryInput[]; incomeEntries: IncomeEntryInput[];
expenseEntries: ExpenseEntryInput[]; expenseEntries: ExpenseEntryInput[];
securities: SecurityInput[]; securities: SecurityInput[];