import { FastifyInstance } from "fastify"; import QRCode from "qrcode"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { getRate } from "../../services/exchange-rates"; import { localDateStr } from "../../utils/date"; import { parseId, success } from "../../utils/response"; import { lineNet } from "../../utils/money"; import { parseSelectedCustomFields } from "../../utils/custom-fields"; import { formatDate, formatNum, escapeHtml, cleanQuillHtml, buildQuillIndentCss, logoDataUriFromSettings, buildPdfHeaderTemplate, } from "../../utils/pdf-shared"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; const window = new JSDOM("").window; const DOMPurify = createDOMPurify(window); /* ── Helpers ─────────────────────────────────────────────────────── */ interface AddressResult { name: string; lines: string[]; } function buildAddressLines( entity: Record | null, isSupplier: boolean, tObj: Record, selectedCustomFields?: number[], ): 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}`; const filterCustom = Array.isArray(selectedCustomFields); cfData.forEach((cf, i) => { if (filterCustom && !selectedCustomFields!.includes(i)) return; 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 paymentMethodMap: Record> = { cs: { "Bank transfer": "Příkazem", Cash: "Hotově", "Cash on delivery": "Dobírka", }, en: { Příkazem: "Bank transfer", Hotově: "Cash", Dobírka: "Cash on delivery", }, }; function translatePaymentMethod(value: string | null, lang: string): string { if (!value) return ""; return paymentMethodMap[lang]?.[value] || value; } const translations: Record> = { cs: { title: "Faktura", heading: "FAKTURA - DAŇOVÝ DOKLAD č.", supplier: "Dodavatel", customer: "Odběratel", bank: "Banka:", swift: "SWIFT:", iban: "IBAN:", account_no: "Číslo účtu:", var_symbol: "Variabilní s.:", const_symbol: "Konstantní s.:", order_no: "Objednávka č.:", order_date: "Objednávka ze dne:", issue_date: "Datum vystavení:", due_date: "Datum splatnosti:", tax_date: "Datum uskutečnění plnění:", payment_method: "Forma úhrady:", billing: "Fakturujeme Vám za:", col_no: "Č.", col_desc: "Popis", col_qty: "Množství", col_unit_price: "Jedn. cena", col_discount: "Sleva", col_price: "Cena", col_vat_pct: "%DPH", col_vat: "DPH", col_total: "Celkem", subtotal: "Mezisoučet:", vat_label: "DPH", total: "Celkem k úhradě", amounts_in: "Částky jsou uvedeny v", notes: "Poznámky", issued_by: "Vystavil:", notice: "Dovolujeme si Vás upozornit, že v případě nedodržení data splatnosti" + " uvedeného na faktuře Vám budeme účtovat úrok z prodlení v dohodnuté, resp." + " zákonné výši a smluvní pokutu (byla-li sjednána).", vat_recap: "Rekapitulace DPH v Kč:", vat_base: "Základ v Kč", vat_rate: "Sazba", vat_amount: "DPH v Kč", vat_with_total: "Celkem s DPH v Kč", received_by: "Převzal:", stamp: "Razítko:", ico: "IČ: ", dic: "DIČ: ", cnb_rate: "Přepočet kurzem ČNB ke dni", }, en: { title: "Invoice", heading: "INVOICE - TAX DOCUMENT No.", supplier: "Supplier", customer: "Customer", bank: "Bank:", swift: "SWIFT:", iban: "IBAN:", account_no: "Account No.:", var_symbol: "Variable symbol:", const_symbol: "Constant symbol:", order_no: "Order No.:", order_date: "Order date:", issue_date: "Issue date:", due_date: "Due date:", tax_date: "Tax point date:", payment_method: "Payment method:", billing: "We invoice you for:", col_no: "No.", col_desc: "Description", col_qty: "Quantity", col_unit_price: "Unit price", col_discount: "Discount", col_price: "Price", col_vat_pct: "VAT%", col_vat: "VAT", col_total: "Total", subtotal: "Subtotal:", vat_label: "VAT", total: "Total to pay", amounts_in: "Amounts are in", notes: "Notes", issued_by: "Issued by:", notice: "Please note that in case of late payment, we will charge default interest" + " at the agreed or statutory rate and a contractual penalty (if agreed).", vat_recap: "VAT recapitulation in CZK:", vat_base: "Tax base in CZK", vat_rate: "Rate", vat_amount: "VAT in CZK", vat_with_total: "Total incl. VAT in CZK", received_by: "Received by:", stamp: "Stamp:", ico: "Reg. No.: ", dic: "Tax ID: ", cnb_rate: "CNB exchange rate as of", }, }; /** Tag-stripped emptiness check — "


" (empty Quill) counts as empty. */ function stripTags(html: string | null | undefined): string { return (html || "").replace(/<[^>]*>/g, "").trim(); } /** * Repeating per-page footer for the invoice PDF, rendered by Puppeteer in the * bottom page margin (mirrors buildIssuedOrderPdfFooter): "Vystavil: " * left, "Strana X z Y" / "Page X of Y" (by document language) centered. * Styles must stay inline and the font-size explicit — Chromium defaults * footer text to 0. */ export function buildInvoicePdfFooter( lang: string, issuerLine: string, ): string { const t = translations[lang] || translations.cs; const pageWord = lang === "cs" ? "Strana" : "Page"; const ofWord = lang === "cs" ? "z" : "of"; return `
${escapeHtml(t.issued_by)} ${escapeHtml(issuerLine)}
${pageWord} ${ofWord}
`; } /** * Repeating per-page header for the invoice PDF — the unified red-accent * family header (shared with issued orders): logo left, red * "FAKTURA - DAŇOVÝ DOKLAD č. X" right, red rule underneath, on EVERY page. * The body's own header is print-hidden so page 1 doesn't show it twice. */ export function buildInvoicePdfHeader( lang: string, logoDataUri: string, invoiceNumber: string, ): string { const t = translations[lang] || translations.cs; return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${invoiceNumber}`); } /* ── Route ───────────────────────────────────────────────────────── */ export default async function invoicesPdfRoutes( fastify: FastifyInstance, ): Promise { fastify.get<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("invoices.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const query = request.query as Record; try { const lang = query.lang === "en" ? "en" : "cs"; const t = translations[lang]; const invoice = await prisma.invoices.findUnique({ where: { id }, }); if (!invoice) { return reply .status(404) .type("text/html") .send("

Faktura nenalezena

"); } const items = await prisma.invoice_items.findMany({ where: { invoice_id: id }, orderBy: { position: "asc" }, }); // id tiebreak: position can repeat, keep the order deterministic. const sections = await prisma.invoice_sections.findMany({ where: { invoice_id: id }, orderBy: [{ position: "asc" }, { id: "asc" }], }); // Sleva column is rendered only when at least one item has a discount. const hasDiscount = items.some((it) => Number(it.discount) > 0); let customer: Record | null = null; if (invoice.customer_id) { customer = (await prisma.customers.findUnique({ where: { id: invoice.customer_id }, })) as Record | null; } const settings = (await prisma.company_settings.findFirst()) as Record< string, unknown > | null; let orderNumber = ""; let orderDate = ""; if (invoice.order_id) { const orderRow = await prisma.orders.findUnique({ where: { id: invoice.order_id }, select: { order_number: true, customer_order_number: true, created_at: true, }, }); if (orderRow) { orderNumber = escapeHtml( String( orderRow.customer_order_number || orderRow.order_number || "", ), ); if (orderRow.created_at) { orderDate = formatDate(orderRow.created_at); } } } // Logo as a data URI — used by the body header (screen/HTML view) AND // the Puppeteer headerTemplate (templates can only load data: URLs). const logoDataUri = logoDataUriFromSettings(settings); const logoImg = logoDataUri ? `` : ""; const currency = invoice.currency || "CZK"; const applyVat = !!invoice.apply_vat; const vatSummary: Record = {}; let subtotal = 0; for (const item of items) { const lineSubtotal = lineNet( Number(item.quantity), Number(item.unit_price), Number(item.discount), ); subtotal += lineSubtotal; const rate = Number(item.vat_rate); const key = String(rate); if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; vatSummary[key].base += lineSubtotal; if (applyVat) { vatSummary[key].vat += Math.round(((lineSubtotal * rate) / 100) * 100) / 100; } } let totalVat = 0; for (const data of Object.values(vatSummary)) { totalVat += data.vat; } const totalToPay = subtotal + totalVat; // QR code - SPAYD payment format let qrSvg = ""; try { const spaydParts = [ "SPD*1.0", "ACC:" + (invoice.bank_iban || "").replace(/ /g, ""), "AM:" + totalToPay.toFixed(2), "CC:" + currency, "X-VS:" + (invoice.invoice_number || ""), "X-KS:" + (invoice.constant_symbol || "0308"), "MSG:" + t.title + " " + (invoice.invoice_number || ""), ]; const spaydString = spaydParts.join("*"); qrSvg = await QRCode.toString(spaydString, { type: "svg", errorCorrectionLevel: "M", margin: 1, width: 200, }); } catch { // QR generation failed — leave empty } // VAT recapitulation (always in CZK — Czech tax requirement) const isForeign = currency.toUpperCase() !== "CZK"; const issueDateStr = invoice.issue_date ? localDateStr(new Date(invoice.issue_date)) : undefined; // CNB may be unreachable (or the issue date never cached). The rate // only feeds the CZK recap + conversion footnote — degrade by // omitting them (cnbRate = null) rather than failing the whole // render AND the NAS archival (expected condition, logged). let cnbRate: number | null = 1.0; if (isForeign) { try { cnbRate = await getRate(currency, issueDateStr); } catch (err) { console.error( `[invoices-pdf] Kurz ČNB nedostupný pro ${currency} — rekapitulace v Kč vynechána`, err, ); cnbRate = null; } } const vatRates = [21, 12, 0]; const vatRecap: Array<{ rate: number; base: number; vat: number; total: number; }> = []; if (cnbRate !== null) { for (const rate of vatRates) { const key = String(rate); const base = vatSummary[key]?.base ?? 0; const vat = vatSummary[key]?.vat ?? 0; vatRecap.push({ rate, base: Math.round(base * cnbRate * 100) / 100, vat: Math.round(vat * cnbRate * 100) / 100, total: Math.round((base + vat) * cnbRate * 100) / 100, }); } } const supp = buildAddressLines( settings, true, t, parseSelectedCustomFields( (invoice as { selected_custom_fields?: unknown }) .selected_custom_fields, ), ); const cust = buildAddressLines(customer, false, t); const suppLinesHtml = supp.lines .map((l) => `
${escapeHtml(l)}
`) .join(""); const custLinesHtml = cust.lines .map((l) => `
${escapeHtml(l)}
`) .join(""); // Supplier email/web from custom_fields let suppEmail = ""; if (settings?.custom_fields) { const raw = settings.custom_fields; // Malformed JSON in custom_fields must degrade gracefully (no email) // instead of 500-ing the whole invoice PDF. let parsed: unknown; try { parsed = typeof raw === "string" ? JSON.parse(raw) : raw; } catch (e) { request.log.warn( e, "Neplatný JSON v company_settings.custom_fields — e-mail dodavatele vynechán", ); parsed = null; } if (parsed && typeof parsed === "object") { const fields = (parsed as Record).fields; if (Array.isArray(fields)) { for (const f of fields) { if (f.name && f.name.toLowerCase() === "email" && f.value) { suppEmail = String(f.value); } } } } } const invoiceNumber = escapeHtml(invoice.invoice_number); const itemsHtml = items .map((item, i) => { const qty = Number(item.quantity); const unitPrice = Number(item.unit_price); const disc = Number(item.discount) || 0; const lineSubtotal = lineNet(qty, unitPrice, disc); const vatRate = Number(item.vat_rate); const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0; const lineTotal = lineSubtotal + lineVat; const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; const discDecimals = Math.floor(disc) === disc ? 0 : 2; const discCell = hasDiscount ? `${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}` : ""; const subDesc = item.item_description || ""; return ` ${i + 1} ${escapeHtml(item.description)}${subDesc ? `
${escapeHtml(subDesc)}
` : ""} ${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""} ${formatNum(unitPrice)} ${discCell} ${formatNum(lineSubtotal)} ${applyVat ? Math.floor(vatRate) : 0}% ${formatNum(lineVat)} ${formatNum(lineTotal)} `; }) .join(""); const vatRecapHtml = vatRecap .map( (vr) => ` ${formatNum(vr.base)} ${Math.floor(vr.rate)}% ${formatNum(vr.vat)} ${formatNum(vr.total)} `, ) .join(""); // CNB rate unavailable → the CZK figures cannot be computed; omit // the whole recap table (the payment QR next to it stays). const recapTableHtml = cnbRate === null ? "" : ` ${vatRecapHtml} ${ isForeign ? `` : "" }
${escapeHtml(t.vat_recap)}
${escapeHtml(t.vat_base)} ${escapeHtml(t.vat_rate)} ${escapeHtml(t.vat_amount)} ${escapeHtml(t.vat_with_total)}
${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
`; 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)}
`; } } } // ── Sections ("Obsah") — flow INLINE right after the items/totals // block (mirrors issued orders): no separate page, long content // paginates naturally and the Puppeteer footer template stamps every // page. Suppressed entirely when every section is empty (tag-stripped // check, so an empty-Quill "


" doesn't count). cs prefers // the Czech title when present; en (or a missing Czech title) falls // back to the EN title. const resolveSectionTitle = (s: { title: string | null; title_cz: string | null; }): string => lang === "cs" && (s.title_cz || "").trim() ? s.title_cz || "" : s.title || ""; // Visibility must use the SAME per-language title rule as the render // loop — otherwise a section with only the other language's title // would count as content and produce an empty sections block. const visibleSections = sections.filter( (s) => resolveSectionTitle(s).trim() || stripTags(s.content), ); let sectionsHtml = ""; if (visibleSections.length > 0) { sectionsHtml += '
'; for (const section of visibleSections) { const title = resolveSectionTitle(section); const content = (section.content || "").trim(); sectionsHtml += '
'; if (title.trim()) sectionsHtml += `
${escapeHtml(title)}
`; if (content) sectionsHtml += `
${cleanQuillHtml(DOMPurify.sanitize(content))}
`; sectionsHtml += "
"; } sectionsHtml += "
"; } // Quill indent CSS const indentCSS = buildQuillIndentCss(); const html = ` ${escapeHtml(t.title)} ${invoiceNumber}
${logoImg ? `
${logoImg}
` : ""}
${escapeHtml(t.heading)} ${invoiceNumber}
${escapeHtml(t.supplier)}
${escapeHtml(supp.name)}
${suppLinesHtml}
${escapeHtml(t.customer)}
${escapeHtml(cust.name)}
${custLinesHtml}
${escapeHtml(t.bank)} ${escapeHtml(invoice.bank_name)}
${escapeHtml(t.swift)} ${escapeHtml(invoice.bank_swift)}
${escapeHtml(t.iban)} ${escapeHtml(invoice.bank_iban)}
${escapeHtml(t.account_no)} ${escapeHtml(invoice.bank_account)}
${escapeHtml(t.var_symbol)} ${invoiceNumber}     ${escapeHtml(t.const_symbol)} ${escapeHtml(invoice.constant_symbol)}
${escapeHtml(t.issue_date)} ${escapeHtml(formatDate(invoice.issue_date))}
${escapeHtml(t.due_date)} ${escapeHtml(formatDate(invoice.due_date))}
${escapeHtml(t.tax_date)} ${escapeHtml(formatDate(invoice.tax_date))}
${escapeHtml(t.payment_method)} ${escapeHtml(translatePaymentMethod(invoice.payment_method, lang))}
${orderNumber ? `
${escapeHtml(t.order_no)} ${orderNumber}
` : ""} ${orderDate ? `
${escapeHtml(t.order_date)} ${escapeHtml(orderDate)}
` : ""}
${escapeHtml(invoice.billing_text || t.billing)}
${hasDiscount ? `` : ""} ${itemsHtml}
${escapeHtml(t.col_no)} ${escapeHtml(t.col_desc)} ${escapeHtml(t.col_qty)} ${escapeHtml(t.col_unit_price)}${escapeHtml(t.col_discount)}${escapeHtml(t.col_price)} ${escapeHtml(t.col_vat_pct)} ${escapeHtml(t.col_vat)} ${escapeHtml(t.col_total)}
${escapeHtml(t.subtotal)} ${formatNum(subtotal)} ${escapeHtml(currency)}
${vatDetailHtml}
${escapeHtml(t.total)} ${formatNum(totalToPay)} ${escapeHtml(currency)}
${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}
${sectionsHtml}
`; const saveMode = query.save === "1"; // Save PDF to NAS if ( saveMode && nasInvoicesManager.isConfigured() && invoice.invoice_number ) { const issueDate = invoice.issue_date ? new Date(invoice.issue_date) : new Date(); nasInvoicesManager.cleanIssued(invoice.invoice_number!); // Per-page "Vystavil + Strana X z Y" footer (same as issued // orders); the supplier e-mail rides along on the left (it used to // print under the body Vystavil block). const issuerLine = `${invoice.issued_by || ""}${ suppEmail ? `${invoice.issued_by ? " · " : ""}${suppEmail}` : "" }`; const pdfBuffer = await htmlToPdf(html, { headerTemplate: buildInvoicePdfHeader( lang, logoDataUri, invoice.invoice_number || "", ), footerTemplate: buildInvoicePdfFooter(lang, issuerLine), }); nasInvoicesManager.saveIssuedPdf( invoice.invoice_number!, issueDate.getFullYear(), issueDate.getMonth() + 1, pdfBuffer, ); return success(reply, null, 200, "PDF uloženo"); } return reply.type("text/html").send(html); } catch (err) { request.log.error(err, "PDF generation failed"); return reply .status(500) .type("text/html") .send("

Chyba při generování PDF

"); } }, ); }