Mehrere Stichtage pro Zeitraum verwalten
CI / Build and Deploy (push) Successful in 2m43s

This commit is contained in:
jan
2026-05-12 01:37:28 +02:00
parent 08df13c044
commit 5591d10d96
11 changed files with 790 additions and 88 deletions
+24 -1
View File
@@ -21,6 +21,7 @@ const updateExpenseSchema = z.object({
amount: z.coerce.number().positive(),
agId: z.string().trim().min(1),
budgetId: z.string().trim().min(1),
cutoffId: z.string().trim().min(1).nullable().optional(),
cutoffPhase: z.enum(["PRE", "POST"])
});
@@ -59,6 +60,24 @@ export async function PATCH(request: Request, { params }: Context) {
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG oder zum Zeitraum." }, { status: 400 });
}
const cutoffRows = await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM period_cutoffs
WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${expense.periodId}
`;
const fallbackCutoffRows = parsed.data.cutoffId
? []
: await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM period_cutoffs
WHERE period_id = ${expense.periodId}
ORDER BY date ASC NULLS LAST, created_at ASC
LIMIT 1
`;
const cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null;
if (parsed.data.cutoffId && !cutoffId) {
return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 });
}
const previousCutoffRows = await prisma.$queryRaw<{ cutoff_phase: "PRE" | "POST" }[]>`
SELECT cutoff_phase FROM expenses WHERE id = ${id}
`;
@@ -77,7 +96,11 @@ export async function PATCH(request: Request, { params }: Context) {
budgetId: parsed.data.budgetId
}
});
await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${id}`;
await prisma.$executeRaw`
UPDATE expenses
SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase"
WHERE id = ${id}
`;
await createAuditLog(prisma, {
actorId: viewer.id,
+24 -1
View File
@@ -31,6 +31,7 @@ const expenseSchema = z
amount: z.coerce.number().positive(),
agId: z.string().trim().min(1),
budgetId: z.string().trim().min(1),
cutoffId: z.string().trim().min(1).optional(),
cutoffPhase: z.enum(["PRE", "POST"]).default("PRE"),
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
recurrenceStartAt: z
@@ -93,6 +94,24 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG." }, { status: 404 });
}
const cutoffRows = await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM period_cutoffs
WHERE id = ${parsed.data.cutoffId ?? ""} AND period_id = ${budget.periodId}
`;
const fallbackCutoffRows = parsed.data.cutoffId
? []
: await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM period_cutoffs
WHERE period_id = ${budget.periodId}
ORDER BY date ASC NULLS LAST, created_at ASC
LIMIT 1
`;
const cutoffId = cutoffRows[0]?.id ?? fallbackCutoffRows[0]?.id ?? null;
if (parsed.data.cutoffId && !cutoffId) {
return NextResponse.json({ error: "Der ausgewählte Stichtag passt nicht zum Zeitraum." }, { status: 400 });
}
const approvalThreshold = toApprovalThresholdNumber(appSettings.approvalThreshold);
const requiredApprovalTypes = normalizeRequiredApprovalTypes(appSettings.requiredApprovalTypes);
const recurrenceStartAt =
@@ -115,7 +134,11 @@ export async function POST(request: Request) {
approvalStatus: needsManualApproval ? "PENDING" : "APPROVED"
}
});
await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${expense.id}`;
await prisma.$executeRaw`
UPDATE expenses
SET cutoff_id = ${cutoffId}, cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase"
WHERE id = ${expense.id}
`;
if (needsManualApproval) {
await notifyApprovalRequest(
+183
View File
@@ -0,0 +1,183 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createAuditLog } from "@/lib/audit-log";
import { canManageBudgets } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session";
type Context = {
params: Promise<{
id: string;
}>;
};
type CutoffRow = {
id: string;
name: string;
date: Date | null;
period_id: string;
};
function parseDateInput(value: string | null | undefined) {
if (!value) {
return null;
}
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return "invalid";
}
const [, year, month, day] = match;
const parsed = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0, 0));
return Number.isNaN(parsed.getTime()) ? "invalid" : parsed;
}
async function getCutoff(id: string) {
const rows = await prisma.$queryRaw<CutoffRow[]>`
SELECT id, name, date, period_id
FROM period_cutoffs
WHERE id = ${id}
`;
return rows[0] ?? null;
}
const cutoffSchema = z
.object({
name: z.string().trim().min(2).max(80),
date: z
.union([z.string().trim(), z.literal(""), z.null(), z.undefined()])
.transform((value) => parseDateInput(typeof value === "string" ? value : null))
})
.superRefine((value, ctx) => {
if (value.date === "invalid") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Bitte ein gültiges Stichtag-Datum angeben.",
path: ["date"]
});
}
});
export async function PATCH(request: Request, { params }: Context) {
const { id } = await params;
const viewer = await getCurrentViewer();
if (!viewer) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
if (!canManageBudgets(viewer.role)) {
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Stichtage bearbeiten." }, { status: 403 });
}
const cutoff = await getCutoff(id);
if (!cutoff) {
return NextResponse.json({ error: "Stichtag nicht gefunden." }, { status: 404 });
}
const body = await request.json().catch(() => null);
const parsed = cutoffSchema.safeParse(body);
if (!parsed.success || parsed.data.date === "invalid") {
return NextResponse.json(
{ error: parsed.success ? "Bitte Stichtag korrekt ausfüllen." : parsed.error.issues[0]?.message ?? "Bitte Stichtag korrekt ausfüllen." },
{ status: 400 }
);
}
const nextDate = parsed.data.date instanceof Date ? parsed.data.date : null;
await prisma.$executeRaw`
UPDATE period_cutoffs
SET name = ${parsed.data.name}, date = ${nextDate}, updated_at = ${new Date()}
WHERE id = ${id}
`;
await createAuditLog(prisma, {
actorId: viewer.id,
action: "periodCutoff.update",
entityType: "periodCutoff",
entityId: id,
entityLabel: parsed.data.name,
summary: `Stichtag ${parsed.data.name} wurde bearbeitet.`,
metadata: {
periodId: cutoff.period_id,
previous: {
name: cutoff.name,
date: cutoff.date?.toISOString() ?? null
},
next: {
name: parsed.data.name,
date: nextDate?.toISOString() ?? null
}
}
});
return NextResponse.json({ ok: true });
}
export async function DELETE(_: Request, { params }: Context) {
const { id } = await params;
const viewer = await getCurrentViewer();
if (!viewer) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
if (!canManageBudgets(viewer.role)) {
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Stichtage löschen." }, { status: 403 });
}
const cutoff = await getCutoff(id);
if (!cutoff) {
return NextResponse.json({ error: "Stichtag nicht gefunden." }, { status: 404 });
}
const countRows = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*)::bigint AS count
FROM period_cutoffs
WHERE period_id = ${cutoff.period_id}
`;
if (Number(countRows[0]?.count ?? 0) <= 1) {
return NextResponse.json({ error: "Der letzte Stichtag eines Zeitraums kann nicht gelöscht werden." }, { status: 400 });
}
const replacementRows = await prisma.$queryRaw<{ id: string }[]>`
SELECT id
FROM period_cutoffs
WHERE period_id = ${cutoff.period_id} AND id <> ${id}
ORDER BY date ASC NULLS LAST, created_at ASC
LIMIT 1
`;
const replacementCutoffId = replacementRows[0]?.id ?? null;
await prisma.$executeRaw`UPDATE expenses SET cutoff_id = ${replacementCutoffId} WHERE cutoff_id = ${id}`;
await prisma.$executeRaw`DELETE FROM period_cutoffs WHERE id = ${id}`;
await createAuditLog(prisma, {
actorId: viewer.id,
action: "periodCutoff.delete",
entityType: "periodCutoff",
entityId: id,
entityLabel: cutoff.name,
summary: `Stichtag ${cutoff.name} wurde gelöscht.`,
metadata: {
periodId: cutoff.period_id,
deleted: {
name: cutoff.name,
date: cutoff.date?.toISOString() ?? null
},
replacementCutoffId
}
});
return NextResponse.json({ ok: true });
}
+107
View File
@@ -0,0 +1,107 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { createAuditLog } from "@/lib/audit-log";
import { canManageBudgets } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session";
type Context = {
params: Promise<{
id: string;
}>;
};
function parseDateInput(value: string | null | undefined) {
if (!value) {
return null;
}
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return "invalid";
}
const [, year, month, day] = match;
const parsed = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0, 0));
return Number.isNaN(parsed.getTime()) ? "invalid" : parsed;
}
const cutoffSchema = z
.object({
name: z.string().trim().min(2).max(80),
date: z
.union([z.string().trim(), z.literal(""), z.null(), z.undefined()])
.transform((value) => parseDateInput(typeof value === "string" ? value : null))
})
.superRefine((value, ctx) => {
if (value.date === "invalid") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Bitte ein gültiges Stichtag-Datum angeben.",
path: ["date"]
});
}
});
export async function POST(request: Request, { params }: Context) {
const { id } = await params;
const viewer = await getCurrentViewer();
if (!viewer) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
if (!canManageBudgets(viewer.role)) {
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Stichtage anlegen." }, { status: 403 });
}
const body = await request.json().catch(() => null);
const parsed = cutoffSchema.safeParse(body);
if (!parsed.success || parsed.data.date === "invalid") {
return NextResponse.json(
{ error: parsed.success ? "Bitte Stichtag korrekt ausfüllen." : parsed.error.issues[0]?.message ?? "Bitte Stichtag korrekt ausfüllen." },
{ status: 400 }
);
}
const period = await prisma.accountingPeriod.findUnique({
where: { id }
});
if (!period) {
return NextResponse.json({ error: "Zeitraum nicht gefunden." }, { status: 404 });
}
const cutoff = {
id: randomUUID(),
name: parsed.data.name,
date: parsed.data.date instanceof Date ? parsed.data.date : null,
periodId: period.id,
createdAt: new Date()
};
await prisma.$executeRaw`
INSERT INTO period_cutoffs (id, name, date, period_id, created_at, updated_at)
VALUES (${cutoff.id}, ${cutoff.name}, ${cutoff.date}, ${cutoff.periodId}, ${cutoff.createdAt}, ${cutoff.createdAt})
`;
await createAuditLog(prisma, {
actorId: viewer.id,
action: "periodCutoff.create",
entityType: "periodCutoff",
entityId: cutoff.id,
entityLabel: cutoff.name,
summary: `Stichtag ${cutoff.name} wurde angelegt.`,
metadata: {
periodId: cutoff.periodId,
date: cutoff.date?.toISOString() ?? null
}
});
return NextResponse.json({ cutoff });
}
+8
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
@@ -50,6 +51,13 @@ export async function POST(request: Request) {
isCurrent: false
}
});
const defaultCutoffId = randomUUID();
const now = new Date();
await tx.$executeRaw`
INSERT INTO period_cutoffs (id, name, date, period_id, created_at, updated_at)
VALUES (${defaultCutoffId}, ${"Open Air"}, ${null}, ${createdPeriod.id}, ${now}, ${now})
`;
if (copyBudgetsFromPeriodId) {
const sourceBudgets = await tx.budget.findMany({
+26 -9
View File
@@ -143,11 +143,15 @@ export default async function DashboardPage() {
: [];
const periodCutoffs = await prisma.$queryRaw<
{ id: string; cutoff_name: string; cutoff_date: Date | null }[]
>`SELECT id, cutoff_name, cutoff_date FROM accounting_periods`;
{ id: string; name: string; date: Date | null; period_id: string }[]
>`
SELECT id, name, date, period_id
FROM period_cutoffs
ORDER BY date ASC NULLS LAST, created_at ASC
`;
const expenseCutoffs = await prisma.$queryRaw<
{ id: string; cutoff_phase: "PRE" | "POST" }[]
>`SELECT id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`;
{ id: string; cutoff_id: string | null; cutoff_phase: "PRE" | "POST" }[]
>`SELECT id, cutoff_id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`;
const donationRows = await prisma.$queryRaw<
{
id: string;
@@ -175,8 +179,14 @@ export default async function DashboardPage() {
WHERE d.period_id = ${currentPeriod.id}
ORDER BY d.donated_at DESC
`;
const periodCutoffById = new Map(periodCutoffs.map((period) => [period.id, period]));
const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense.cutoff_phase]));
const periodCutoffsByPeriodId = new Map<string, typeof periodCutoffs>();
for (const cutoff of periodCutoffs) {
periodCutoffsByPeriodId.set(cutoff.period_id, [...(periodCutoffsByPeriodId.get(cutoff.period_id) ?? []), cutoff]);
}
const primaryCutoffByPeriodId = new Map(
[...periodCutoffsByPeriodId.entries()].map(([periodId, cutoffs]) => [periodId, cutoffs[0]])
);
const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense]));
const donationsByExpenseId = new Map<string, number>();
const donationRowsByExpenseId = new Map<string, typeof donationRows>();
for (const donation of donationRows) {
@@ -248,7 +258,8 @@ export default async function DashboardPage() {
approvalStatus: expense.approvalStatus,
recurrence: expense.recurrence,
recurrenceStartAt,
cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE",
cutoffId: expenseCutoffById.get(expense.id)?.cutoff_id ?? primaryCutoffByPeriodId.get(expense.periodId)?.id ?? null,
cutoffPhase: expenseCutoffById.get(expense.id)?.cutoff_phase ?? "PRE",
donationAmount: donationsByExpenseId.get(expense.id) ?? 0,
donations: (donationRowsByExpenseId.get(expense.id) ?? []).map((donation) => ({
id: donation.id,
@@ -310,13 +321,19 @@ export default async function DashboardPage() {
}));
const serializedPeriods: DashboardAccountingPeriod[] = accountingPeriods.map((period) => ({
cutoffs: (periodCutoffsByPeriodId.get(period.id) ?? []).map((cutoff) => ({
id: cutoff.id,
name: cutoff.name,
date: cutoff.date?.toISOString() ?? null,
periodId: cutoff.period_id
})),
id: period.id,
name: period.name,
startsAt: period.startsAt.toISOString(),
endsAt: period.endsAt.toISOString(),
isCurrent: period.isCurrent,
cutoffName: periodCutoffById.get(period.id)?.cutoff_name ?? "Open Air",
cutoffDate: periodCutoffById.get(period.id)?.cutoff_date?.toISOString() ?? null
cutoffName: primaryCutoffByPeriodId.get(period.id)?.name ?? "Open Air",
cutoffDate: primaryCutoffByPeriodId.get(period.id)?.date?.toISOString() ?? null
}));
const serializedDonations: DashboardDonation[] = donationRows.map((donation) => ({