Files
AnnasRechnungsManager/app/lib/schemas.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

226 lines
5.9 KiB
TypeScript

import { z } from "zod";
import { InvoiceStatus } from "@prisma/client";
// ===== Reusable validators =====
/**
* Validates that a decimal string has at most 2 decimal places
* (required for currency/money fields in MySQL DECIMAL(10,2))
*/
export const currencySchema = z
.number()
.nonnegative("Geldbeträge dürfen nicht negativ sein")
.refine(
(n) => {
const decimal = n.toString().split(".")[1];
return !decimal || decimal.length <= 2;
},
"Geldbeträge dürfen maximal 2 Dezimalstellen haben"
);
/**
* Tax rate must be one of the valid German VAT rates
*/
export const taxRateSchema = z
.number()
.int("Steuersatz muss eine ganze Zahl sein")
.refine(
(r) => [0, 7, 19].includes(r),
"Steuersatz muss 0 (steuerfrei), 7 (ermäßigt) oder 19 (Standard) sein"
);
/**
* IBAN validation: 15-34 characters, starts with 2 letters + 2 digits
*/
export const ibanSchema = z
.string()
.refine(
(iban) => /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(iban),
"Ungültige IBAN"
);
/**
* German tax ID (Steuernummer): 10 digits
*/
export const taxIdSchema = z
.string()
.regex(/^\d{10}$/, "Steuernummer muss 10 Ziffern haben");
/**
* VAT ID (Umsatzsteuer-IdNr): DE + 9 digits
*/
export const vatIdSchema = z
.string()
.regex(/^DE\d{9}$/, "USt-IdNr. muss im Format DE + 9 Ziffern sein");
// ===== Invoice Schemas =====
export const invoiceItemSchema = z.object({
position: z
.number()
.int("Position muss eine ganze Zahl sein")
.positive("Position muss größer als 0 sein"),
description: z
.string()
.min(1, "Beschreibung erforderlich")
.max(500, "Beschreibung darf maximal 500 Zeichen sein"),
quantity: z
.number()
.positive("Menge muss größer als 0 sein")
.refine(
(q) => {
const decimal = q.toString().split(".")[1];
return !decimal || decimal.length <= 3;
},
"Menge darf maximal 3 Dezimalstellen haben"
),
unit: z
.string()
.max(50, "Einheit darf maximal 50 Zeichen sein")
.optional(),
unitPrice: currencySchema,
taxRate: taxRateSchema,
netAmount: currencySchema,
taxAmount: currencySchema,
grossAmount: currencySchema,
});
export const invoiceSchema = z.object({
companyId: z.string().min(1, "Mandant erforderlich"),
customerId: z.string().min(1, "Kunde erforderlich"),
issueDate: z
.string()
.refine(
(d) => !isNaN(Date.parse(d)),
"Ungültiges Datum"
),
deliveryDate: z
.string()
.refine((d) => !isNaN(Date.parse(d)), "Ungültiges Datum")
.optional(),
dueDate: z
.string()
.refine((d) => !isNaN(Date.parse(d)), "Ungültiges Datum"),
notes: z
.string()
.max(5000, "Notizen darf maximal 5000 Zeichen sein")
.optional(),
kleinunternehmer: z.boolean().optional().default(false),
items: z
.array(invoiceItemSchema)
.min(1, "Mindestens ein Rechnungsposition erforderlich"),
netTotal: currencySchema,
taxTotal: currencySchema,
grossTotal: currencySchema,
});
export const invoiceUpdateSchema = invoiceSchema.omit({ companyId: true });
export const invoiceStatusSchema = z.object({
status: z.nativeEnum(InvoiceStatus),
});
// ===== Company Schemas =====
export const companySchema = z.object({
name: z
.string()
.min(1, "Firmenname erforderlich")
.max(255, "Firmenname darf maximal 255 Zeichen sein"),
legalForm: z
.string()
.max(100, "Rechtsform darf maximal 100 Zeichen sein")
.optional(),
taxId: taxIdSchema.optional(),
vatId: vatIdSchema.optional(),
address: z
.string()
.min(1, "Adresse erforderlich")
.max(500, "Adresse darf maximal 500 Zeichen sein"),
zip: z
.string()
.min(1, "PLZ erforderlich")
.max(20, "PLZ darf maximal 20 Zeichen sein")
.regex(/^[\d\s-]*$/, "PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten"),
city: z
.string()
.min(1, "Stadt erforderlich")
.max(100, "Stadt darf maximal 100 Zeichen sein"),
country: z
.string()
.max(2, "Ländercode darf maximal 2 Zeichen sein")
.optional()
.default("DE"),
email: z
.string()
.email("Ungültige E-Mail-Adresse")
.optional()
.or(z.literal("")),
phone: z
.string()
.max(20, "Telefonnummer darf maximal 20 Zeichen sein")
.optional(),
website: z
.string()
.url("Ungültige URL")
.max(255, "Website darf maximal 255 Zeichen sein")
.optional(),
bankIban: ibanSchema.optional(),
bankBic: z
.string()
.regex(/^[A-Z0-9]{8,11}$/, "Ungültiger BIC")
.optional(),
bankName: z
.string()
.max(255, "Bankname darf maximal 255 Zeichen sein")
.optional(),
invoicePrefix: z
.string()
.max(10, "Rechnungsprefix darf maximal 10 Zeichen sein")
.optional()
.default("RE"),
kleinunternehmer: z.boolean().optional().default(false),
});
export const companyUpdateSchema = companySchema;
// ===== Customer Schemas =====
export const customerSchema = z.object({
companyId: z.string().min(1, "Mandant erforderlich"),
name: z
.string()
.min(1, "Kundenname erforderlich")
.max(255, "Kundenname darf maximal 255 Zeichen sein"),
taxId: taxIdSchema.optional(),
address: z
.string()
.min(1, "Adresse erforderlich")
.max(500, "Adresse darf maximal 500 Zeichen sein"),
zip: z
.string()
.min(1, "PLZ erforderlich")
.max(20, "PLZ darf maximal 20 Zeichen sein")
.regex(/^[\d\s-]*$/, "PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten"),
city: z
.string()
.min(1, "Stadt erforderlich")
.max(100, "Stadt darf maximal 100 Zeichen sein"),
country: z
.string()
.max(2, "Ländercode darf maximal 2 Zeichen sein")
.optional()
.default("DE"),
email: z
.string()
.email("Ungültige E-Mail-Adresse")
.optional()
.or(z.literal("")),
phone: z
.string()
.max(20, "Telefonnummer darf maximal 20 Zeichen sein")
.optional(),
});
export const customerUpdateSchema = customerSchema.omit({ companyId: true });