import { FastifyInstance } from "fastify"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { parseId, error } from "../../utils/response"; import { formatDate, formatNum, escapeHtml, cleanQuillHtml, buildQuillIndentCss, } from "../../utils/pdf-shared"; import { parseBody } from "../../schemas/common"; import { z } from "zod"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; const window = new JSDOM("").window; const DOMPurify = createDOMPurify(window); // Plain object (Zod strips unknown keys rather than rejecting) — the frontend // may send extra item fields (id, position, item_description) we don't use. const OrderPdfItemSchema = z.object({ description: z.string().max(8000), quantity: z.number().min(0).finite(), unit: z.string().max(255), unit_price: z.number().min(0).finite(), is_included_in_total: z.boolean().optional(), }); // `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`. // `items` is strictly validated; `lang` is typed explicitly so the handler no // longer reads it as an untyped passthrough key. const OrderPdfBodySchema = z.looseObject({ items: z.array(OrderPdfItemSchema).optional(), lang: z.string().max(10).optional(), }); /* ── Helpers ─────────────────────────────────────────────────────── */ interface AddressResult { name: string; lines: string[]; } function buildAddressLines( entity: Record | null, isSupplier: boolean, tObj: Record, ): AddressResult { if (!entity) return { name: "", lines: [] }; const nameKey = isSupplier ? "company_name" : "name"; const name = String(entity[nameKey] || ""); let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = []; let fieldOrder: string[] | null = null; const raw = entity.custom_fields; if (raw) { let parsed: unknown; try { parsed = typeof raw === "string" ? JSON.parse(raw) : raw; } catch { parsed = null; } if (parsed && typeof parsed === "object") { if ((parsed as Record).fields) { cfData = ((parsed as Record).fields as typeof cfData) || []; fieldOrder = ((parsed as Record).field_order || (parsed as Record).fieldOrder) as string[] | null; } else if (Array.isArray(parsed)) { cfData = parsed; } } } if (Array.isArray(fieldOrder)) { const legacyMap: Record = { Name: "name", CompanyName: "company_name", Street: "street", CityPostal: "city_postal", Country: "country", CompanyId: "company_id", VatId: "vat_id", }; fieldOrder = fieldOrder.map((k) => legacyMap[k] || k); } const fieldMap: Record = {}; if (name) fieldMap[nameKey] = name; if (entity.street) fieldMap.street = String(entity.street); const cityParts = [entity.city || "", entity.postal_code || ""] .filter(Boolean) .map(String); const cityPostal = cityParts.join(" ").trim(); if (cityPostal) fieldMap.city_postal = cityPostal; if (entity.country) fieldMap.country = String(entity.country); if (entity.company_id) fieldMap.company_id = `${tObj.ico}${entity.company_id}`; if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`; cfData.forEach((cf, i) => { const cfName = (cf.name || "").trim(); const cfValue = (cf.value || "").trim(); const showLabel = cf.showLabel !== false; if (cfValue) { fieldMap[`custom_${i}`] = showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue; } }); const lines: string[] = []; if (Array.isArray(fieldOrder) && fieldOrder.length) { for (const key of fieldOrder) { if (key === nameKey) continue; if (fieldMap[key]) lines.push(fieldMap[key]); } for (const [key, line] of Object.entries(fieldMap)) { if (key === nameKey) continue; if (!fieldOrder!.includes(key)) lines.push(line); } } else { for (const [key, line] of Object.entries(fieldMap)) { if (key === nameKey) continue; lines.push(line); } } return { name, lines }; } /* ── Translations ────────────────────────────────────────────────── */ const translations: Record> = { cs: { title: "POTVRZENÍ PŘIJETÍ OBJEDNÁVKY", supplier: "Dodavatel", customer: "Odběratel", order_no: "Číslo objednávky:", po_no: "Číslo zakáz. objednávky:", date: "Datum:", payment_method: "Forma úhrady:", billing: "Potvrzujeme Vám následující položky:", col_no: "Č.", col_desc: "Popis", col_qty: "Množství", col_unit_price: "Jedn. cena", col_total: "Celkem", total_no_vat: "Celkem bez DPH", amounts_in: "Částky jsou uvedeny v", notes: "Poznámky", issued_by: "Vystavil:", received_by: "Převzal:", stamp: "Razítko:", ico: "IČ: ", dic: "DIČ: ", }, en: { title: "ORDER CONFIRMATION", supplier: "Supplier", customer: "Customer", order_no: "Order No.:", po_no: "PO No.:", date: "Date:", payment_method: "Payment method:", billing: "We confirm the following items:", col_no: "No.", col_desc: "Description", col_qty: "Quantity", col_unit_price: "Unit price", col_total: "Total", total_no_vat: "Total excl. VAT", amounts_in: "Amounts are in", notes: "Notes", issued_by: "Issued by:", received_by: "Received by:", stamp: "Stamp:", ico: "Reg. No.: ", dic: "Tax ID: ", }, }; /* ── Template ────────────────────────────────────────────────────── */ export interface OrderConfirmationPdfItem { description: string; quantity: number; unit: string; unit_price: number; is_included_in_total: boolean; } interface OrderConfirmationPdfData { order_number: string | null; customer_order_number: string | null; created_at: Date | string | null; currency: string | null; notes: string | null; // Not a real orders column today — read defensively (PHP-era data carried it). payment_method?: string | null; customers?: Record | null; } /** * Order-confirmation HTML. The confirmation is NOT a tax document — prices * are NET only: no VAT columns or VAT summary; the grand total is labeled * "Celkem bez DPH". Exported for tests. */ export function renderOrderConfirmationHtml( order: OrderConfirmationPdfData, items: OrderConfirmationPdfItem[], settings: Record | null, lang: "cs" | "en", userName: string, ): string { const t = translations[lang]; let logoImg = ""; if (settings?.logo_data) { const buf = Buffer.from(settings.logo_data as Buffer); let mime = "image/png"; if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp"; const b64 = buf.toString("base64"); logoImg = ``; } const currency = order.currency || "CZK"; // NET-only total over the included lines — no VAT math anywhere. let total = 0; for (const item of items) { if (item.is_included_in_total) total += item.quantity * item.unit_price; } total = Math.round(total * 100) / 100; const supp = buildAddressLines(settings, true, t); const cust = buildAddressLines( (order.customers as Record) || null, false, t, ); const suppLinesHtml = supp.lines .map((l) => `
${escapeHtml(l)}
`) .join(""); const custLinesHtml = cust.lines .map((l) => `
${escapeHtml(l)}
`) .join(""); const orderNumber = escapeHtml(order.order_number || ""); const poNumber = escapeHtml(order.customer_order_number || ""); const orderDateStr = formatDate(order.created_at); const itemsHtml = items .map((item, i) => { const lineTotal = item.quantity * item.unit_price; const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2; return ` ${i + 1} ${escapeHtml(item.description)} ${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""} ${formatNum(item.unit_price)} ${formatNum(lineTotal)} `; }) .join(""); const paymentMethod = String(order.payment_method || "") || (lang === "cs" ? "převodem" : "Bank transfer"); const notesRaw = order.notes ?? ""; const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); const notesHtml = notesStripped ? `
${escapeHtml(t.notes)}
${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}
` : ""; // Quill indent CSS const indentCSS = buildQuillIndentCss(); return ` ${escapeHtml(t.title)} ${orderNumber}
${logoImg ? `
${logoImg}
` : ""}
${escapeHtml(t.title)}
${escapeHtml(t.supplier)}
${escapeHtml(supp.name)}
${suppLinesHtml}
${escapeHtml(t.customer)}
${escapeHtml(cust.name)}
${custLinesHtml}
${escapeHtml(t.order_no)} ${orderNumber}
${poNumber ? `
${escapeHtml(t.po_no)} ${poNumber}
` : ""}
${escapeHtml(t.payment_method)} ${escapeHtml(paymentMethod)}
${escapeHtml(t.date)} ${escapeHtml(orderDateStr)}
${escapeHtml(t.billing)}
${itemsHtml}
${escapeHtml(t.col_no)} ${escapeHtml(t.col_desc)} ${escapeHtml(t.col_qty)} ${escapeHtml(t.col_unit_price)} ${escapeHtml(t.col_total)}
${escapeHtml(t.total_no_vat)} ${formatNum(total)} ${escapeHtml(currency)}
${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}
${notesHtml}
`; } /* ── Route ───────────────────────────────────────────────────────── */ export default async function ordersPdfRoutes( fastify: FastifyInstance, ): Promise { fastify.post<{ Params: { id: string }; Body: Record }>( "/:id/confirmation", { preHandler: requirePermission("orders.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(OrderPdfBodySchema, request.body || {}); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; try { const lang = body.lang === "en" ? "en" : "cs"; const order = await prisma.orders.findUnique({ where: { id }, // The confirmation PDF never renders the PO attachment — don't pull // the blob just to read the order header/items. omit: { attachment_data: true }, include: { customers: true, order_items: { orderBy: { position: "asc" } }, }, }); if (!order) { return reply .status(404) .type("text/html") .send("

Objednávka nenalezena

"); } const settings = (await prisma.company_settings.findFirst()) as Record< string, unknown > | null; // The confirmation PDF can be rendered from client-supplied items (e.g. // a live preview of unsaved edits on the detail page) OR from the // stored order. Fabricating descriptions/prices that don't reflect the // stored order is an editing action, so the custom-items path requires // `orders.edit` (admins bypass). A view-only caller is silently served // the STORED order items instead — preventing a `orders.view` holder // from producing an "official" confirmation with invented figures. const authData = request.authData; const canUseCustomItems = authData?.roleName === "admin" || !!authData?.permissions.includes("orders.edit"); const customItemsRaw = canUseCustomItems && Array.isArray(body.items) ? body.items : null; let items: OrderConfirmationPdfItem[]; if (customItemsRaw && customItemsRaw.length > 0) { items = customItemsRaw.map((it) => ({ description: it.description, quantity: it.quantity, unit: it.unit, unit_price: it.unit_price, is_included_in_total: it.is_included_in_total !== false, })); } else { items = order.order_items.map((it) => ({ description: it.description || "", quantity: Number(it.quantity) || 0, unit: it.unit || "", unit_price: Number(it.unit_price) || 0, is_included_in_total: !!it.is_included_in_total, })); } const userName = request.authData ? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim() : ""; const html = renderOrderConfirmationHtml( order, items, settings, lang, userName, ); const pdfBuffer = await htmlToPdf(html); const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`; return reply .type("application/pdf") .header("Content-Disposition", `attachment; filename="${filename}"`) .send(pdfBuffer); } catch (err) { request.log.error(err, "PDF generation failed"); return reply .status(500) .type("text/html") .send("

Chyba při generování PDF

"); } }, ); }