f10a79471e
Improvements #1-3 deepening: 1. Server-side invoice amount validation - All amounts (qty × unitPrice) recalculated server-side using tax.ts - Prevents client-side manipulation attacks - Supports kleinunternehmer auto-inheritance 2. Comprehensive audit logging - LogAction type extended with 11 new actions - All CRUD operations now logged with metadata - Metadata includes: amounts, counts, status transitions, oldStatus/newStatus 3. Advanced Zod validation (centralized) - New file: app/lib/schemas.ts (220 lines, 18+ validators) - Custom validators: currencySchema, taxRateSchema, ibanSchema, taxIdSchema, vatIdSchema - All API routes (invoices, companies, customers) now use centralized schemas - Consistent German error messages - Single source of truth for validation logic Additional improvements: - DB indices applied: invoices(status, dueDate, deletedAt, customerId), customers(companyId) - Migration 20260415192953_add_indices applied successfully - Build succeeds without critical errors - TypeScript compilation validates all schemas Files modified: - app/lib/schemas.ts (NEW) - app/routes/api.invoices.ts (uses centralized schemas) - app/routes/api.invoices.$id.ts (status transition validation) - app/routes/api.companies.ts, api.companies.$id.ts - app/routes/api.customers.ts, api.customers.$id.ts - app/lib/logger.server.ts (metadata support) - prisma/schema.prisma (indices) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
216 lines
7.2 KiB
TypeScript
216 lines
7.2 KiB
TypeScript
import { getApiUser } from "@/session.server";
|
||
import prisma from "@/lib/prisma.server";
|
||
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
|
||
import { calcItemAmounts, calcInvoiceTotals, calcItemAmountsKleinunternehmer } from "@/lib/tax";
|
||
import { log } from "@/lib/logger.server";
|
||
import { InvoiceStatus } from "@prisma/client";
|
||
import { invoiceUpdateSchema, invoiceStatusSchema, invoiceItemSchema } from "@/lib/schemas";
|
||
|
||
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 = invoiceStatusSchema;
|
||
|
||
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 = invoiceUpdateSchema.safeParse(body);
|
||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||
|
||
const { items, kleinunternehmer, ...invoiceData } = parsed.data;
|
||
|
||
// Use provided kleinunternehmer or fall back to company setting
|
||
const isKleinunternehmer = kleinunternehmer ?? invoice.company.kleinunternehmer;
|
||
|
||
// Server-side recalculation of all amounts
|
||
const recalculatedItems = items.map(item => ({
|
||
...item,
|
||
...(isKleinunternehmer
|
||
? calcItemAmountsKleinunternehmer(item.quantity, item.unitPrice)
|
||
: calcItemAmounts(item.quantity, item.unitPrice, item.taxRate)),
|
||
}));
|
||
|
||
const totals = calcInvoiceTotals(recalculatedItems);
|
||
|
||
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: isKleinunternehmer,
|
||
netTotal: totals.netTotal,
|
||
taxTotal: totals.taxTotal,
|
||
grossTotal: totals.grossTotal,
|
||
items: { create: recalculatedItems },
|
||
},
|
||
include: { items: true, customer: true, company: true },
|
||
});
|
||
});
|
||
|
||
await log({
|
||
userId: user.id,
|
||
action: "UPDATE_INVOICE",
|
||
entity: "Invoice",
|
||
entityId: params.id,
|
||
metadata: {
|
||
customerId: invoiceData.customerId,
|
||
oldGrossTotal: invoice.grossTotal.toString(),
|
||
newGrossTotal: totals.grossTotal.toString(),
|
||
itemCount: recalculatedItems.length,
|
||
kleinunternehmer: isKleinunternehmer,
|
||
},
|
||
request,
|
||
});
|
||
|
||
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,
|
||
metadata: {
|
||
status: invoice.status,
|
||
grossTotal: invoice.grossTotal.toString(),
|
||
number: invoice.number,
|
||
},
|
||
request,
|
||
});
|
||
return Response.json({ ok: true });
|
||
}
|
||
|
||
// PATCH – Status change with validation
|
||
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;
|
||
|
||
// Validate status transitions
|
||
const validTransitions: Record<InvoiceStatus, InvoiceStatus[]> = {
|
||
DRAFT: ["SENT", "CANCELLED", "DELETED"],
|
||
SENT: ["PAID", "CANCELLED", "DRAFT", "DELETED"],
|
||
PAID: ["CANCELLED", "DELETED"],
|
||
CANCELLED: ["DRAFT", "DELETED"],
|
||
DELETED: ["DRAFT"],
|
||
};
|
||
|
||
if (!validTransitions[oldStatus]?.includes(newStatus)) {
|
||
return Response.json(
|
||
{ error: `Ungültiger Statuswechsel von ${oldStatus} zu ${newStatus}` },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
let numberUpdate: string | null | undefined = undefined;
|
||
if (newStatus === "DELETED") {
|
||
numberUpdate = null;
|
||
} else if (oldStatus === "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 }),
|
||
},
|
||
include: { items: true, customer: true, company: true },
|
||
});
|
||
|
||
await log({
|
||
userId: user.id,
|
||
action: "UPDATE_INVOICE_STATUS",
|
||
entity: "Invoice",
|
||
entityId: params.id,
|
||
metadata: { oldStatus, newStatus, buchungId: buchung.id },
|
||
request,
|
||
});
|
||
|
||
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 }),
|
||
},
|
||
include: { items: true, customer: true, company: true },
|
||
});
|
||
|
||
await log({
|
||
userId: user.id,
|
||
action: "UPDATE_INVOICE_STATUS",
|
||
entity: "Invoice",
|
||
entityId: params.id,
|
||
metadata: { oldStatus, newStatus },
|
||
request,
|
||
});
|
||
|
||
return Response.json(updated);
|
||
}
|