Files
AnnasRechnungsManager/app/routes/api.invoices.$id.ts
T
hwinkel f10a79471e Refactor: centralize Zod schemas and fully integrate into API routes
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>
2026-04-15 21:34:38 +02:00

216 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}