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
Deploy App / deploy (push) Successful in 1m27s
This commit is contained in:
@@ -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;
|
||||
@@ -112,6 +112,9 @@ model Phase {
|
||||
durationYears Int
|
||||
inflationRate Float?
|
||||
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[]
|
||||
expenseEntries ExpenseEntry[]
|
||||
@@ -162,6 +165,13 @@ model Security {
|
||||
ownerTag OwnerTag @default(HOUSEHOLD)
|
||||
// Steuersatz auf Verkaufsgewinn bei Uebernahme in PhaseTransitionItem (Default 0%, siehe Kap. 9)
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const securitySchema = z.object({
|
||||
annualContribution: z.number(),
|
||||
ownerTag: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]),
|
||||
saleTaxRate: z.number().min(0).max(100),
|
||||
carriedBaseValue: z.number().default(0),
|
||||
});
|
||||
const realEstateSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeSecurityYearlyValues } from "@/lib/calculations";
|
||||
|
||||
const transitionItemSchema = z.object({
|
||||
positionType: z.enum(["SECURITY", "REAL_ESTATE"]),
|
||||
@@ -50,6 +51,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
// 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(
|
||||
request: NextRequest,
|
||||
{ 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) => {
|
||||
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({
|
||||
data: {
|
||||
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,
|
||||
inflationRate: phase.inflationRate,
|
||||
incomeMode: phase.incomeMode,
|
||||
incomingCapital: phase.incomingCapital,
|
||||
incomeEntries: {
|
||||
create: phase.incomeEntries.map((e) => ({
|
||||
personId: e.personId,
|
||||
@@ -73,6 +74,7 @@ export async function POST(
|
||||
annualContribution: s.annualContribution,
|
||||
ownerTag: s.ownerTag,
|
||||
saleTaxRate: s.saleTaxRate,
|
||||
carriedBaseValue: s.carriedBaseValue,
|
||||
})),
|
||||
},
|
||||
realEstates: {
|
||||
|
||||
@@ -220,7 +220,12 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
|
||||
onChanged={refreshCurrent}
|
||||
/>
|
||||
{nextPhase && (
|
||||
<TransitionPanel phase={phase} computed={computedPhase} nextPhaseName={nextPhase.name} />
|
||||
<TransitionPanel
|
||||
phase={phase}
|
||||
computed={computedPhase}
|
||||
nextPhaseName={nextPhase.name}
|
||||
onChanged={refreshCurrent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -88,6 +88,7 @@ export function PhaseCard({
|
||||
</button>
|
||||
{expanded && (
|
||||
<PhaseForm
|
||||
key={`${phase.id}:${phase.securities.length}:${phase.incomingCapital}`}
|
||||
household={household}
|
||||
phase={phase}
|
||||
onSaved={() => {
|
||||
|
||||
@@ -58,6 +58,13 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
||||
const savingsQuota = totalIncome - totalExpense;
|
||||
const allocated = securities.reduce((s, sec) => s + sec.annualContribution, 0);
|
||||
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() {
|
||||
setSaving(true);
|
||||
@@ -193,16 +200,9 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
||||
label="+ Ausgabenposten"
|
||||
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
|
||||
/>
|
||||
<div
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div className="rounded-md bg-zinc-50 px-3 py-2 text-sm text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
Verfuegbare Sparquote (CHF/Jahr): <strong>{savingsQuota.toLocaleString("de-CH")}</strong>
|
||||
{" "}— zugewiesen an Wertschriften: {allocated.toLocaleString("de-CH")}
|
||||
{overAllocated && " ⚠ Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote."}
|
||||
{" "}(Details und Verteilung siehe Wertschriften weiter unten)
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -258,10 +258,36 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
||||
onClick={() =>
|
||||
setSecurities((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>
|
||||
|
||||
{/* 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 }) {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -21,10 +21,12 @@ export function TransitionPanel({
|
||||
phase,
|
||||
computed,
|
||||
nextPhaseName,
|
||||
onChanged,
|
||||
}: {
|
||||
phase: PhaseInput;
|
||||
computed: PhaseComputed;
|
||||
nextPhaseName: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<ItemDraft[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
@@ -131,6 +133,7 @@ export function TransitionPanel({
|
||||
})),
|
||||
});
|
||||
setSaved(true);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
@@ -198,8 +201,8 @@ export function TransitionPanel({
|
||||
<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>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Dieser Betrag kann anschliessend frei auf neue oder bestehende Wertschriften der Folgephase verteilt werden
|
||||
(Startwert der jeweiligen Wertschrift in "{nextPhaseName}" manuell anpassen).
|
||||
Wird beim Speichern automatisch in "{nextPhaseName}" als verfuegbares Startkapital hinterlegt.
|
||||
Uebernommene Wertschriften erscheinen dort automatisch mit ihrem Endwert als neuer Startwert.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
|
||||
@@ -49,6 +49,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
durationYears: phase.durationYears,
|
||||
inflationRate: phase.inflationRate,
|
||||
incomeMode: phase.incomeMode,
|
||||
incomingCapital: phase.incomingCapital,
|
||||
incomeEntries: phase.incomeEntries.map((e) => ({
|
||||
id: e.id,
|
||||
personId: e.personId,
|
||||
@@ -68,6 +69,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
annualContribution: s.annualContribution,
|
||||
ownerTag: s.ownerTag,
|
||||
saleTaxRate: s.saleTaxRate,
|
||||
carriedBaseValue: s.carriedBaseValue,
|
||||
})),
|
||||
realEstates: phase.realEstates.map((re) => ({
|
||||
id: re.id,
|
||||
|
||||
@@ -45,6 +45,9 @@ export interface SecurityInput {
|
||||
annualContribution: number;
|
||||
ownerTag: OwnerTag;
|
||||
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 {
|
||||
@@ -81,6 +84,9 @@ export interface PhaseInput {
|
||||
durationYears: number;
|
||||
inflationRate: number | null;
|
||||
incomeMode: IncomeMode;
|
||||
// Aus Verkaeufen im Uebergang aus der Vorphase verfuegbares Startkapital (automatisch
|
||||
// gesetzt beim Speichern des Uebergangs der Vorphase).
|
||||
incomingCapital: number;
|
||||
incomeEntries: IncomeEntryInput[];
|
||||
expenseEntries: ExpenseEntryInput[];
|
||||
securities: SecurityInput[];
|
||||
|
||||
Reference in New Issue
Block a user