160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
import { getApiUser } from "@/session.server";
|
|
import prisma from "@/lib/prisma.server";
|
|
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
|
|
import { log } from "@/lib/logger.server";
|
|
import { InvoiceStatus } from "@prisma/client";
|
|
import { z } from "zod";
|
|
|
|
async function getInvoice(id: string, userId: string) {
|
|
return prisma.invoice.findFirst({
|
|
where: { id, company: { userId } },
|
|
include: { items: { orderBy: { position: "asc" } }, customer: true, company: true },
|
|
});
|
|
}
|
|
|
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
|
const user = await getApiUser(request);
|
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
|
|
const invoice = await getInvoice(params.id, user.id);
|
|
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
|
|
|
return Response.json(invoice);
|
|
}
|
|
|
|
const statusSchema = z.object({ status: z.nativeEnum(InvoiceStatus) });
|
|
|
|
const itemSchema = z.object({
|
|
position: z.number().int(),
|
|
description: z.string().min(1),
|
|
quantity: z.number().positive(),
|
|
unit: z.string().optional(),
|
|
unitPrice: z.number(),
|
|
taxRate: z.number(),
|
|
netAmount: z.number(),
|
|
taxAmount: z.number(),
|
|
grossAmount: z.number(),
|
|
});
|
|
|
|
const updateSchema = z.object({
|
|
customerId: z.string().min(1),
|
|
issueDate: z.string(),
|
|
deliveryDate: z.string().optional(),
|
|
dueDate: z.string(),
|
|
notes: z.string().optional(),
|
|
kleinunternehmer: z.boolean().optional().default(false),
|
|
items: z.array(itemSchema).min(1),
|
|
netTotal: z.number().nonnegative(),
|
|
taxTotal: z.number().nonnegative(),
|
|
grossTotal: z.number().nonnegative(),
|
|
});
|
|
|
|
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
|
const user = await getApiUser(request);
|
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
|
|
const invoice = await getInvoice(params.id, user.id);
|
|
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
|
|
|
if (request.method === "PUT") {
|
|
const body = await request.json();
|
|
const parsed = updateSchema.safeParse(body);
|
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
|
|
|
const { items, ...invoiceData } = parsed.data;
|
|
const updated = await prisma.$transaction(async (tx) => {
|
|
await tx.invoiceItem.deleteMany({ where: { invoiceId: params.id } });
|
|
return tx.invoice.update({
|
|
where: { id: params.id },
|
|
data: {
|
|
customerId: invoiceData.customerId,
|
|
issueDate: new Date(invoiceData.issueDate),
|
|
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
|
|
dueDate: new Date(invoiceData.dueDate),
|
|
notes: invoiceData.notes ?? null,
|
|
kleinunternehmer: invoiceData.kleinunternehmer,
|
|
netTotal: invoiceData.netTotal,
|
|
taxTotal: invoiceData.taxTotal,
|
|
grossTotal: invoiceData.grossTotal,
|
|
items: { create: items },
|
|
},
|
|
});
|
|
});
|
|
return Response.json(updated);
|
|
}
|
|
|
|
if (request.method === "DELETE") {
|
|
const isAdmin = user.role === "ADMIN";
|
|
const deletableStatuses: InvoiceStatus[] = [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED, InvoiceStatus.DELETED];
|
|
if (!isAdmin && !deletableStatuses.includes(invoice.status)) {
|
|
return Response.json(
|
|
{ error: "Nur Entwürfe und stornierte Rechnungen können gelöscht werden." },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
await prisma.invoice.delete({ where: { id: params.id } });
|
|
await log({ userId: user.id, action: "DELETE_INVOICE", entity: "Invoice", entityId: params.id, request });
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
// PATCH
|
|
const body = await request.json();
|
|
const parsed = statusSchema.safeParse(body);
|
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
|
|
|
const newStatus = parsed.data.status;
|
|
const oldStatus = invoice.status;
|
|
|
|
let numberUpdate: string | null | undefined = undefined;
|
|
if (newStatus === "DELETED") {
|
|
numberUpdate = null;
|
|
} else if (invoice.status === "DELETED") {
|
|
numberUpdate = await generateInvoiceNumber(invoice.companyId);
|
|
}
|
|
|
|
// Handle Buchung sync: Create when PAID, delete when unpaying
|
|
if (newStatus === "PAID" && oldStatus !== "PAID") {
|
|
// Create a Buchung for the invoice payment
|
|
const buchung = await prisma.buchung.create({
|
|
data: {
|
|
companyId: invoice.companyId,
|
|
date: invoice.issueDate,
|
|
account: "BANK",
|
|
type: "EINLAGE",
|
|
amount: invoice.grossTotal,
|
|
description: `Rechnung ${invoice.number}`,
|
|
kategorie: "Rechnungseinnahme",
|
|
isBusinessRecord: true,
|
|
},
|
|
});
|
|
|
|
// Update invoice with buchungId
|
|
const updated = await prisma.invoice.update({
|
|
where: { id: params.id },
|
|
data: {
|
|
status: newStatus,
|
|
buchungId: buchung.id,
|
|
deletedAt: null,
|
|
...(numberUpdate !== undefined && { number: numberUpdate }),
|
|
},
|
|
});
|
|
return Response.json(updated);
|
|
}
|
|
|
|
if (newStatus !== "PAID" && oldStatus === "PAID" && invoice.buchungId) {
|
|
// Delete the linked Buchung when unpaying
|
|
await prisma.buchung.delete({ where: { id: invoice.buchungId } });
|
|
}
|
|
|
|
const updated = await prisma.invoice.update({
|
|
where: { id: params.id },
|
|
data: {
|
|
status: newStatus,
|
|
buchungId: newStatus === "PAID" ? invoice.buchungId : null,
|
|
deletedAt: newStatus === "DELETED" ? new Date() : null,
|
|
...(numberUpdate !== undefined && { number: numberUpdate }),
|
|
},
|
|
});
|
|
return Response.json(updated);
|
|
}
|