import { FastifyInstance } from "fastify"; import QRCode from "qrcode"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { localDateCzStr } from "../../utils/date"; import { nasFinancialsManager } from "../../services/nas-financials-manager"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { getRate } from "../../services/exchange-rates"; import { localDateStr } from "../../utils/date"; /* ── 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, "\u00A0"); 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, " "); 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) { const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; 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: "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 č.:", 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_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Č: ", }, 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.:", 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_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: ", }, }; /* ── Route ───────────────────────────────────────────────────────── */ export default async function invoicesPdfRoutes( fastify: FastifyInstance, ): Promise { fastify.get<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("invoices.export") }, async (request, reply) => { const id = parseInt(request.params.id, 10); const query = request.query as Record; 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" }, }); 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); } } } 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 = invoice.currency || "CZK"; const applyVat = !!invoice.apply_vat; const vatSummary: Record = {}; let subtotal = 0; for (const item of items) { const lineSubtotal = Number(item.quantity) * Number(item.unit_price); 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 += (lineSubtotal * rate) / 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; const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0; const vatRates = [21, 12, 0]; const vatRecap: Array<{ rate: number; base: number; vat: number; total: number; }> = []; 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); 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; const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; 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 lineSubtotal = qty * unitPrice; 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; return ` ${i + 1} ${escapeHtml(item.description)} ${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""} ${formatNum(unitPrice)} ${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(""); 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 = invoice.notes ?? ""; const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); const notesHtml = notesStripped ? `
${escapeHtml(t.notes)}
${cleanQuillHtml(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)} ${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(invoice.payment_method)}
${orderNumber ? `
${lang === "cs" ? "Objednávka č.:" : "Order no.:"} ${orderNumber}
` : ""} ${orderDate ? `
${lang === "cs" ? "Objednávka ze dne:" : "Order date:"} ${escapeHtml(orderDate)}
` : ""}
${escapeHtml(invoice.billing_text || t.billing)}
${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)}
${escapeHtml(t.subtotal)} ${formatNum(subtotal)} ${escapeHtml(currency)}
${vatDetailHtml}
${escapeHtml(t.total)} ${formatNum(totalToPay)} ${escapeHtml(currency)}
${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}
${notesHtml}
`; // Save PDF to NAS if (nasFinancialsManager.isConfigured() && invoice.invoice_number) { const issueDate = invoice.issue_date ? new Date(invoice.issue_date) : new Date(); const saveMode = query.save === "1"; const pdfPromise = htmlToPdf(html) .then((pdfBuffer) => { nasFinancialsManager.saveIssuedInvoicePdf( invoice.invoice_number!, issueDate.getFullYear(), issueDate.getMonth() + 1, pdfBuffer, ); }) .catch((err) => { request.log.error(err, "Failed to save invoice PDF to NAS"); }); if (saveMode) { await pdfPromise; return reply.send({ success: true, message: "PDF uloženo" }); } } return reply.type("text/html").send(html); }, ); }