import { FastifyInstance } from "fastify"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { localDateCzStr } from "../../utils/date"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { parseId, error } from "../../utils/response"; 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(), vat_rate: z.number().min(0).max(100).finite(), }); // `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`. // `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the // handler no longer reads them as untyped passthrough keys. const OrderPdfBodySchema = z.looseObject({ items: z.array(OrderPdfItemSchema).optional(), lang: z.string().max(10).optional(), applyVat: z.boolean().optional(), }); /* ── Helpers ─────────────────────────────────────────────────────── */ function formatDate(date: Date | string | null | undefined): string { if (!date) return ""; const d = new Date(date); if (isNaN(d.getTime())) return String(date); return localDateCzStr(d); } function formatNum(n: number, decimals = 2): string { const abs = Math.abs(n); const fixed = abs.toFixed(decimals); const [intPart, decPart] = fixed.split("."); const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " "); const result = decPart ? `${withSep},${decPart}` : withSep; return n < 0 ? `-${result}` : result; } function escapeHtml(str: string | null | undefined): string { if (!str) return ""; return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function cleanQuillHtml(html: string | null | undefined): string { if (!html) return ""; let s = html; s = s.replace( /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, "", ); s = s.replace( /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, "", ); s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, ""); s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); s = s.replace(/( )/g, " "); s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); let prev = ""; while (prev !== s) { prev = s; s = s.replace(/]*)>(.*?)<\/span>\s*/gs, "$2"); } return s; } 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_price: "Cena", col_vat_pct: "%DPH", col_vat: "DPH", col_total: "Celkem", subtotal: "Mezisoučet:", vat_label: "DPH", 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_price: "Price", col_vat_pct: "VAT%", col_vat: "VAT", col_total: "Total", subtotal: "Subtotal:", vat_label: "VAT", 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: ", }, }; /* ── 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 t = translations[lang]; 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; 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"; const applyVat = body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat; const orderVatRate = Number(order.vat_rate) || 21; // 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: Array<{ description: string; quantity: number; unit: string; unit_price: number; is_included_in_total: boolean; vat_rate: number; }> = []; 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, vat_rate: it.vat_rate, })); } 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, vat_rate: orderVatRate, })); } let subtotal = 0; let totalVat = 0; const vatSummary: Record = {}; for (const item of items) { if (item.is_included_in_total) { const lineTotal = item.quantity * item.unit_price; subtotal += lineTotal; const rate = item.vat_rate; const key = String(rate); if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; vatSummary[key].base += lineTotal; if (applyVat) { const lineVat = (lineTotal * rate) / 100; vatSummary[key].vat += lineVat; totalVat += lineVat; } } } const totalToPay = subtotal + totalVat; const userName = request.authData ? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim() : ""; 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 lineSubtotal = item.quantity * item.unit_price; const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0; const lineTotal = lineSubtotal + lineVat; const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2; // Without "Uplatnit DPH" the VAT columns are dropped entirely // (the header does the same) instead of printing 0% / 0.00. const vatCells = applyVat ? ` ${Math.floor(item.vat_rate)}% ${formatNum(lineVat)}` : ""; return ` ${i + 1} ${escapeHtml(item.description)} ${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""} ${formatNum(item.unit_price)} ${formatNum(lineSubtotal)}${vatCells} ${formatNum(lineTotal)} `; }) .join(""); const paymentMethod = String((order as Record).payment_method || "") || (lang === "cs" ? "převodem" : "Bank transfer"); let vatDetailHtml = ""; if (applyVat) { for (const [rate, data] of Object.entries(vatSummary)) { if (data.vat > 0) { vatDetailHtml += `
${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%: ${formatNum(data.vat)} ${escapeHtml(currency)}
`; } } } const notesRaw = order.notes ?? ""; const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); const notesHtml = notesStripped ? `
${escapeHtml(t.notes)}
${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}
` : ""; // Quill indent CSS let indentCSS = ""; for (let n = 1; n <= 9; n++) { const pad = n * 3; const liPad = n * 3 + 1.5; indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; } const html = ` ${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)}
${ applyVat ? ` ` : "" } ${itemsHtml}
${escapeHtml(t.col_no)} ${escapeHtml(t.col_desc)} ${escapeHtml(t.col_qty)} ${escapeHtml(t.col_unit_price)} ${escapeHtml(t.col_price)}${escapeHtml(t.col_vat_pct)} ${escapeHtml(t.col_vat)}${escapeHtml(t.col_total)}
${ applyVat ? `
${escapeHtml(t.subtotal)} ${formatNum(subtotal)} ${escapeHtml(currency)}
${vatDetailHtml}
` : "" }
${escapeHtml(applyVat ? t.total : t.total_no_vat)} ${formatNum(totalToPay)} ${escapeHtml(currency)}
${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}
${notesHtml}
`; const pdfBuffer = await htmlToPdf(html); const filename = `Potvrzeni-${orderNumber || 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

"); } }, ); }