Finanzuebersicht Stichtage und Spenden ergaenzen
CI / Build and Deploy (push) Successful in 3m11s

This commit is contained in:
jan
2026-05-11 23:41:07 +02:00
parent c93616f09e
commit c738b35d06
14 changed files with 884 additions and 62 deletions
+123
View File
@@ -0,0 +1,123 @@
import { NextResponse } from "next/server";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { snapshotDonation } from "@/lib/audit-snapshots";
import { createAuditLog } from "@/lib/audit-log";
import { canManageBudgets } from "@/lib/domain";
import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session";
function parseDateInput(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
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()) ? null : parsed;
}
const donationSchema = z
.object({
title: z.string().trim().min(2).max(120),
description: z
.union([z.string().trim().max(1000), z.literal(""), z.null(), z.undefined()])
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)),
amount: z.coerce.number().positive(),
donatedAt: z.string().trim().transform((value) => parseDateInput(value) ?? "invalid"),
periodId: z.string().trim().min(1),
expenseId: z
.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()])
.transform((value) => (typeof value === "string" && value.length > 0 ? value : null))
})
.superRefine((value, ctx) => {
if (value.donatedAt === "invalid") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Bitte ein gültiges Spendendatum angeben.",
path: ["donatedAt"]
});
}
});
export async function POST(request: Request) {
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 Spenden erfassen." }, { status: 403 });
}
const body = await request.json().catch(() => null);
const parsed = donationSchema.safeParse(body);
if (!parsed.success || !(parsed.data.donatedAt instanceof Date)) {
return NextResponse.json(
{ error: parsed.success ? "Bitte Spendendaten korrekt ausfüllen." : parsed.error.issues[0]?.message ?? "Bitte Spendendaten korrekt ausfüllen." },
{ status: 400 }
);
}
const period = await prisma.accountingPeriod.findUnique({
where: { id: parsed.data.periodId }
});
if (!period) {
return NextResponse.json({ error: "Zeitraum nicht gefunden." }, { status: 404 });
}
const expense = parsed.data.expenseId
? await prisma.expense.findUnique({
where: { id: parsed.data.expenseId }
})
: null;
if (parsed.data.expenseId && (!expense || expense.periodId !== period.id)) {
return NextResponse.json({ error: "Die ausgewählte Ausgabe passt nicht zum Zeitraum." }, { status: 404 });
}
const donation = {
id: randomUUID(),
title: parsed.data.title,
description: parsed.data.description ?? null,
amount: parsed.data.amount,
donatedAt: parsed.data.donatedAt,
periodId: period.id,
expenseId: expense?.id ?? null,
creatorId: viewer.id,
createdAt: new Date()
};
await prisma.$executeRaw`
INSERT INTO donations (id, title, description, amount, donated_at, period_id, expense_id, creator_id, created_at, updated_at)
VALUES (${donation.id}, ${donation.title}, ${donation.description}, ${donation.amount}, ${donation.donatedAt},
${donation.periodId}, ${donation.expenseId}, ${donation.creatorId}, ${donation.createdAt}, ${donation.createdAt})
`;
await createAuditLog(prisma, {
actorId: viewer.id,
action: "donation.create",
entityType: "donation",
entityId: donation.id,
entityLabel: donation.title,
summary: `Spende ${donation.title} wurde erfasst.`,
metadata: {
amount: donation.amount,
periodId: donation.periodId,
expenseId: donation.expenseId,
rollback: {
kind: "donation.create",
created: snapshotDonation(donation)
}
}
});
return NextResponse.json({ donation });
}
+4 -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),
cutoffPhase: z.enum(["PRE", "POST"]).default("PRE"),
recurrence: z.enum(["NONE", "MONTHLY"]).default("NONE"),
recurrenceStartAt: z
.union([z.string().trim(), z.literal(""), z.null(), z.undefined()])
@@ -114,6 +115,7 @@ 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}`;
if (needsManualApproval) {
await notifyApprovalRequest(
@@ -140,12 +142,13 @@ export async function POST(request: Request) {
workingGroupId: parsed.data.agId,
recurrence: parsed.data.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
cutoffPhase: parsed.data.cutoffPhase,
approvalStatus: expense.approvalStatus,
approvalThreshold,
requiredApprovalTypes,
rollback: {
kind: "expense.create",
created: snapshotExpense(expense)
created: snapshotExpense({ ...expense, cutoffPhase: parsed.data.cutoffPhase })
}
}
});
+60
View File
@@ -18,6 +18,8 @@ const CSV_HEADERS = [
"periodStartsAt",
"periodEndsAt",
"periodIsCurrent",
"cutoffName",
"cutoffDate",
"budgetId",
"budgetName",
"userId",
@@ -41,6 +43,9 @@ const CSV_HEADERS = [
"approvalType",
"recurrence",
"recurrenceStartAt",
"cutoffPhase",
"donatedAt",
"expenseId",
"invoiceDate",
"proofUrl",
"storedFileName",
@@ -194,6 +199,36 @@ export async function GET() {
createdAt: appSettings.createdAt.toISOString()
} as CsvRow);
const periodCutoffs = await prisma.$queryRaw<
{ id: string; cutoff_name: string; cutoff_date: Date | null }[]
>`SELECT id, cutoff_name, cutoff_date FROM accounting_periods`;
const expenseCutoffs = await prisma.$queryRaw<
{ id: string; cutoff_phase: "PRE" | "POST" }[]
>`SELECT id, cutoff_phase FROM expenses`;
const donationRows = await prisma.$queryRaw<
{
id: string;
title: string;
description: string | null;
amount: unknown;
donated_at: Date;
period_id: string;
expense_id: string | null;
creator_id: string;
creator_name: string;
creator_username: string;
created_at: Date;
}[]
>`
SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.creator_id,
u.name AS creator_name, u.username AS creator_username, d.created_at
FROM donations d
JOIN users u ON u.id = d.creator_id
ORDER BY d.donated_at ASC
`;
const periodCutoffById = new Map(periodCutoffs.map((period) => [period.id, period]));
const expenseCutoffById = new Map(expenseCutoffs.map((expense) => [expense.id, expense.cutoff_phase]));
for (const user of users) {
rows.push({
recordType: "user",
@@ -249,6 +284,7 @@ export async function GET() {
}
for (const period of accountingPeriods) {
const cutoff = periodCutoffById.get(period.id);
rows.push({
recordType: "period",
id: period.id,
@@ -261,6 +297,8 @@ export async function GET() {
periodStartsAt: period.startsAt.toISOString(),
periodEndsAt: period.endsAt.toISOString(),
periodIsCurrent: period.isCurrent ? "true" : "false",
cutoffName: cutoff?.cutoff_name ?? "Open Air",
cutoffDate: cutoff?.cutoff_date?.toISOString() ?? "",
budgetId: "",
budgetName: "",
userId: "",
@@ -442,6 +480,7 @@ export async function GET() {
approvalType: "",
recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE",
invoiceDate: "",
proofUrl: "",
storedFileName: "",
@@ -584,6 +623,27 @@ export async function GET() {
}
}
for (const donation of donationRows) {
rows.push({
recordType: "donation",
id: donation.id,
parentId: donation.expense_id ?? donation.period_id,
parentType: donation.expense_id ? "expense" : "period",
periodId: donation.period_id,
userId: donation.creator_id,
userName: donation.creator_name,
username: donation.creator_username,
title: donation.title,
description: donation.description ?? "",
amount: Number(donation.amount).toFixed(2),
donatedAt: donation.donated_at.toISOString(),
expenseId: donation.expense_id ?? "",
createdAt: donation.created_at.toISOString(),
creatorName: donation.creator_name,
creatorUsername: donation.creator_username
});
}
for (const auditLog of auditLogs) {
rows.push({
recordType: "auditLog",
+22 -2
View File
@@ -16,7 +16,11 @@ type Context = {
const periodSchema = z.object({
name: z.string().trim().min(2).max(80),
startsAt: z.coerce.date(),
endsAt: z.coerce.date()
endsAt: z.coerce.date(),
cutoffName: z.string().trim().min(2).max(80).optional(),
cutoffDate: z
.union([z.coerce.date(), z.literal(""), z.null(), z.undefined()])
.transform((value) => (value instanceof Date ? value : null))
});
export async function PATCH(request: Request, { params }: Context) {
@@ -76,6 +80,16 @@ export async function PATCH(request: Request, { params }: Context) {
endsAt: parsed.data.endsAt
}
});
await prisma.$executeRaw`
UPDATE accounting_periods
SET cutoff_name = ${parsed.data.cutoffName ?? "Open Air"},
cutoff_date = ${parsed.data.cutoffDate}
WHERE id = ${id}
`;
const cutoffRows = await prisma.$queryRaw<
{ cutoff_name: string; cutoff_date: Date | null }[]
>`SELECT cutoff_name, cutoff_date FROM accounting_periods WHERE id = ${id}`;
const cutoff = cutoffRows[0] ?? { cutoff_name: "Open Air", cutoff_date: null };
await createAuditLog(prisma, {
actorId: viewer.id,
@@ -90,10 +104,16 @@ export async function PATCH(request: Request, { params }: Context) {
metadata: {
startsAt: updatedPeriod.startsAt.toISOString(),
endsAt: updatedPeriod.endsAt.toISOString(),
cutoffName: cutoff.cutoff_name,
cutoffDate: cutoff.cutoff_date?.toISOString() ?? null,
rollback: {
kind: "period.update",
previous: snapshotPeriod(period),
next: snapshotPeriod(updatedPeriod)
next: snapshotPeriod({
...updatedPeriod,
cutoffName: cutoff.cutoff_name,
cutoffDate: cutoff.cutoff_date
})
}
}
});
-4
View File
@@ -78,10 +78,6 @@ export async function PATCH(request: Request, { params }: Context) {
? parsed.data.workingGroupId
: null;
if (parsed.data.role === "MEMBER" && !workingGroupId) {
return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 });
}
if (workingGroupId) {
const workingGroup = await prisma.workingGroup.findUnique({
where: { id: workingGroupId }
-4
View File
@@ -72,10 +72,6 @@ export async function POST(request: Request) {
null
);
if (parsed.data.role === "MEMBER" && !workingGroupId) {
return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 });
}
if (workingGroupId) {
const workingGroup = await prisma.workingGroup.findUnique({
where: { id: workingGroupId }
+66 -1
View File
@@ -7,6 +7,7 @@ import { getRollbackMetadata } from "@/lib/audit-log";
import type {
DashboardAccountingPeriod,
DashboardAuditLog,
DashboardDonation,
DashboardManagedUser,
DashboardSettings,
DashboardViewer,
@@ -141,6 +142,45 @@ 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`;
const expenseCutoffs = await prisma.$queryRaw<
{ id: string; cutoff_phase: "PRE" | "POST" }[]
>`SELECT id, cutoff_phase FROM expenses WHERE period_id = ${currentPeriod.id}`;
const donationRows = await prisma.$queryRaw<
{
id: string;
title: string;
description: string | null;
amount: unknown;
donated_at: Date;
period_id: string;
expense_id: string | null;
created_at: Date;
creator_id: string;
creator_name: string;
}[]
>`
SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.created_at,
u.id AS creator_id, u.username AS creator_name
FROM donations d
JOIN users u ON u.id = d.creator_id
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 donationsByExpenseId = new Map<string, number>();
for (const donation of donationRows) {
if (donation.expense_id) {
donationsByExpenseId.set(
donation.expense_id,
(donationsByExpenseId.get(donation.expense_id) ?? 0) + Number(donation.amount)
);
}
}
const serializedViewer: DashboardViewer = {
id: viewer.id,
name: viewer.username,
@@ -197,6 +237,13 @@ export default async function DashboardPage() {
approvalStatus: expense.approvalStatus,
recurrence: expense.recurrence,
recurrenceStartAt,
cutoffPhase: expenseCutoffById.get(expense.id) ?? "PRE",
donationAmount: donationsByExpenseId.get(expense.id) ?? 0,
netPeriodAmount: Math.max(
0,
getExpensePeriodAmount(amount, expense.recurrence, occurrences.length) -
(donationsByExpenseId.get(expense.id) ?? 0)
),
paidAt: expense.paidAt?.toISOString() ?? null,
documentedAt: expense.documentedAt?.toISOString() ?? null,
documents: expense.documents.map((document) => ({
@@ -249,7 +296,24 @@ export default async function DashboardPage() {
name: period.name,
startsAt: period.startsAt.toISOString(),
endsAt: period.endsAt.toISOString(),
isCurrent: period.isCurrent
isCurrent: period.isCurrent,
cutoffName: periodCutoffById.get(period.id)?.cutoff_name ?? "Open Air",
cutoffDate: periodCutoffById.get(period.id)?.cutoff_date?.toISOString() ?? null
}));
const serializedDonations: DashboardDonation[] = donationRows.map((donation) => ({
id: donation.id,
title: donation.title,
description: donation.description,
amount: Number(donation.amount),
donatedAt: donation.donated_at.toISOString(),
periodId: donation.period_id,
expenseId: donation.expense_id,
createdAt: donation.created_at.toISOString(),
creator: {
id: donation.creator_id,
name: donation.creator_name
}
}));
const serializedSettings: DashboardSettings = serializeAppSettings(appSettings);
@@ -282,6 +346,7 @@ export default async function DashboardPage() {
accountingPeriods={serializedPeriods}
currentPeriodId={currentPeriod.id}
settings={serializedSettings}
donations={serializedDonations}
/>
);
}