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>
107 lines
3.4 KiB
TypeScript
107 lines
3.4 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 { invoiceSchema, invoiceItemSchema } from "@/lib/schemas";
|
|
|
|
const itemSchema = invoiceItemSchema;
|
|
|
|
const invoiceCreateSchema = invoiceSchema;
|
|
|
|
/**
|
|
* Creates a new invoice for a given company.
|
|
*
|
|
* Requires a JSON object in the request body with the following shape:
|
|
* {
|
|
* companyId: string,
|
|
* customerId: string,
|
|
* issueDate: string,
|
|
* deliveryDate?: string,
|
|
* dueDate: string,
|
|
* notes?: string,
|
|
* kleinunternehmer?: boolean,
|
|
* items: [
|
|
* {
|
|
* position: number,
|
|
* description: string,
|
|
* quantity: number,
|
|
* unit?: string,
|
|
* unitPrice: number,
|
|
* taxRate: number,
|
|
* netAmount: number,
|
|
* taxAmount: number,
|
|
* grossAmount: number,
|
|
* },
|
|
* ],
|
|
* }
|
|
*
|
|
* Returns the created invoice as a JSON object.
|
|
*
|
|
* If the request is unauthorized, returns a 401 response with an error message.
|
|
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
|
|
* If the company is not found, returns a 404 response with an error message.
|
|
*/
|
|
export async function action({ request }: { request: Request }) {
|
|
const user = await getApiUser(request);
|
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
|
|
const body = await request.json();
|
|
const parsed = invoiceCreateSchema.safeParse(body);
|
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
|
|
|
const { items, companyId, kleinunternehmer, ...invoiceData } = parsed.data;
|
|
|
|
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
|
|
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
|
|
|
// Use company's kleinunternehmer setting, fallback to request data
|
|
const isKleinunternehmer = kleinunternehmer ?? 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 number = await generateInvoiceNumber(companyId);
|
|
|
|
const invoice = await prisma.invoice.create({
|
|
data: {
|
|
...invoiceData,
|
|
number,
|
|
companyId,
|
|
kleinunternehmer: isKleinunternehmer,
|
|
issueDate: new Date(invoiceData.issueDate),
|
|
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
|
|
dueDate: new Date(invoiceData.dueDate),
|
|
netTotal: totals.netTotal,
|
|
taxTotal: totals.taxTotal,
|
|
grossTotal: totals.grossTotal,
|
|
items: { create: recalculatedItems },
|
|
},
|
|
include: { items: true, customer: true },
|
|
});
|
|
|
|
await log({
|
|
userId: user.id,
|
|
action: "CREATE_INVOICE",
|
|
entity: "Invoice",
|
|
entityId: invoice.id,
|
|
metadata: {
|
|
number: invoice.number,
|
|
customerId: invoice.customerId,
|
|
grossTotal: invoice.grossTotal.toString(),
|
|
itemCount: recalculatedItems.length,
|
|
kleinunternehmer: isKleinunternehmer,
|
|
},
|
|
request,
|
|
});
|
|
|
|
return Response.json(invoice, { status: 201 });
|
|
}
|