diff --git a/src/routes/admin/invoices-pdf.ts b/src/routes/admin/invoices-pdf.ts index c11a9a4..a5688fa 100644 --- a/src/routes/admin/invoices-pdf.ts +++ b/src/routes/admin/invoices-pdf.ts @@ -2,14 +2,22 @@ import { FastifyInstance } from 'fastify'; import prisma from '../../config/database'; import { requirePermission } from '../../middleware/auth'; +/* ── Helpers ─────────────────────────────────────────────────────── */ + function formatDate(date: Date | string | null | undefined): string { if (!date) return ''; const d = new Date(date); - return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()}`; + if (isNaN(d.getTime())) return String(date); + return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; } -function formatNumber(n: number): string { - return n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +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 { @@ -17,247 +25,921 @@ function escapeHtml(str: string | null | undefined): string { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } -const LABELS: Record> = { +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: { - invoice: 'Faktura', - invoice_number: 'Číslo faktury', - issue_date: 'Datum vystavení', - due_date: 'Datum splatnosti', - tax_date: 'Datum zdanitelného plnění', - payment_method: 'Způsob platby', - variable_symbol: 'Variabilní symbol', - constant_symbol: 'Konstantní symbol', - bank: 'Banka', - iban: 'IBAN', - swift: 'SWIFT', - account: 'Číslo účtu', + title: 'Faktura', + heading: 'FAKTURA - DAŇOVÝ DOKLAD č.', supplier: 'Dodavatel', customer: 'Odběratel', - ico: 'IČO', - dic: 'DIČ', - description: 'Popis', - qty: 'Množství', - unit: 'Jednotka', - unit_price: 'Cena/ks', - vat: 'DPH %', - total: 'Celkem', - subtotal: 'Základ', - vat_total: 'DPH', - grand_total: 'Celkem k úhradě', - paid_date: 'Datum úhrady', - issued_by: 'Vystavil', + 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', - order_number: 'Objednávka', - currency: 'Měna', + 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: { - invoice: 'Invoice', - invoice_number: 'Invoice number', - issue_date: 'Issue date', - due_date: 'Due date', - tax_date: 'Tax date', - payment_method: 'Payment method', - variable_symbol: 'Variable symbol', - constant_symbol: 'Constant symbol', - bank: 'Bank', - iban: 'IBAN', - swift: 'SWIFT', - account: 'Account number', + title: 'Invoice', + heading: 'INVOICE - TAX DOCUMENT No.', supplier: 'Supplier', customer: 'Customer', - ico: 'Company ID', - dic: 'VAT ID', - description: 'Description', - qty: 'Qty', - unit: 'Unit', - unit_price: 'Unit price', - vat: 'VAT %', - total: 'Total', - subtotal: 'Subtotal', - vat_total: 'VAT', - grand_total: 'Total due', - paid_date: 'Paid date', - issued_by: 'Issued by', + 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', - order_number: 'Order', - currency: 'Currency', + 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.view') }, async (request, reply) => { + 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 L = LABELS[lang]; + const t = translations[lang]; const invoice = await prisma.invoices.findUnique({ where: { id }, - include: { - customers: true, - invoice_items: { orderBy: { position: 'asc' } }, - orders: { select: { order_number: true } }, - }, }); if (!invoice) { return reply.status(404).type('text/html').send('

Faktura nenalezena

'); } - const settings = await prisma.company_settings.findFirst(); - - // Compute totals - const items = invoice.invoice_items.map(item => { - const qty = Number(item.quantity) || 0; - const price = Number(item.unit_price) || 0; - const vatRate = Number(item.vat_rate) || Number(invoice.vat_rate) || 21; - const lineTotal = qty * price; - const lineVat = invoice.apply_vat ? lineTotal * (vatRate / 100) : 0; - return { ...item, qty, price, vatRate, lineTotal, lineVat }; + const items = await prisma.invoice_items.findMany({ + where: { invoice_id: id }, + orderBy: { position: 'asc' }, }); - const subtotal = items.reduce((s, i) => s + i.lineTotal, 0); - const vatTotal = items.reduce((s, i) => s + i.lineVat, 0); - const grandTotal = subtotal + vatTotal; + let customer: Record | null = null; + if (invoice.customer_id) { + customer = await prisma.customers.findUnique({ + where: { id: invoice.customer_id }, + }) as Record | null; + } - // Logo as base64 - let logoHtml = ''; + const settings = await prisma.company_settings.findFirst() as Record | null; + + // Order number lookup + let orderNumber = ''; + 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 || '')); + } + } + + // Logo + let logoImg = ''; if (settings?.logo_data) { - const buf = Buffer.from(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'); - logoHtml = ``; + logoImg = ``; } - const cust = invoice.customers; + const currency = invoice.currency || 'CZK'; + const applyVat = !!invoice.apply_vat; + + // Calculations + 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; + + // VAT recapitulation (always in CZK) + const isForeign = currency.toUpperCase() !== 'CZK'; + const cnbRate = 1.0; // Skip CNB rate conversion + 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, + }); + } + + // Address lines + 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); + + // Items HTML + 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)} + ${formatNum(unitPrice)} + ${formatNum(lineSubtotal)} + ${applyVat ? Math.floor(vatRate) : 0}% + ${formatNum(lineVat)} + ${formatNum(lineTotal)} + `; + }).join(''); + + // VAT recap rows + const vatRecapHtml = vatRecap.map(vr => ` + ${formatNum(vr.base)} + ${Math.floor(vr.rate)}% + ${formatNum(vr.vat)} + ${formatNum(vr.total)} + `).join(''); + + // VAT detail rows for totals section + 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)} +
`; + } + } + } + + // Notes section + 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 = ` - + - -${L.invoice} ${escapeHtml(invoice.invoice_number)} + +${escapeHtml(t.title)} ${invoiceNumber} + -
-
- ${logoHtml} -
${escapeHtml(settings?.company_name)}
-
${escapeHtml(settings?.street)}
-
${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}
- ${settings?.company_id ? `
${L.ico}: ${escapeHtml(settings.company_id)}
` : ''} - ${settings?.vat_id ? `
${L.dic}: ${escapeHtml(settings.vat_id)}
` : ''} +
+
+ + +
+
+ ${logoImg ? `
${logoImg}
` : ''} +
+
${escapeHtml(t.heading)} ${invoiceNumber}
-
-
${L.invoice}
-
${escapeHtml(invoice.invoice_number)}
+ +
+ + +
+
+
${escapeHtml(t.supplier)}
+
${escapeHtml(supp.name)}
+ ${suppLinesHtml} +
+
+
${escapeHtml(t.customer)}
+
${escapeHtml(cust.name)}
+ ${custLinesHtml} +
-
-
-
-
${L.supplier}
-
${escapeHtml(settings?.company_name)}
-
${escapeHtml(settings?.street)}
-
${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}
- ${settings?.company_id ? `
${L.ico}: ${escapeHtml(settings.company_id)}
` : ''} - ${settings?.vat_id ? `
${L.dic}: ${escapeHtml(settings.vat_id)}
` : ''} + +
+
+
+ ${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)}
+ ${orderNumber ? `${escapeHtml(t.order_no)} ${orderNumber}` : ''} +
+
+
+
+
${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)}
+
+
-
-
${L.customer}
-
${escapeHtml(cust?.name)}
-
${escapeHtml(cust?.street)}
-
${escapeHtml(cust?.city)} ${escapeHtml(cust?.postal_code)}
- ${cust?.company_id ? `
${L.ico}: ${escapeHtml(cust.company_id)}
` : ''} - ${cust?.vat_id ? `
${L.dic}: ${escapeHtml(cust.vat_id)}
` : ''} + + +
${escapeHtml(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)}
+
-
-
-
${L.invoice_number}:${escapeHtml(invoice.invoice_number)}
-
${L.issue_date}:${formatDate(invoice.issue_date)}
-
${L.due_date}:${formatDate(invoice.due_date)}
-
${L.tax_date}:${formatDate(invoice.tax_date)}
- ${invoice.payment_method ? `
${L.payment_method}:${escapeHtml(invoice.payment_method)}
` : ''} -
${L.variable_symbol}:${escapeHtml(invoice.invoice_number)}
- ${invoice.constant_symbol ? `
${L.constant_symbol}:${escapeHtml(invoice.constant_symbol)}
` : ''} - ${invoice.bank_name ? `
${L.bank}:${escapeHtml(invoice.bank_name)}
` : ''} - ${invoice.bank_iban ? `
${L.iban}:${escapeHtml(invoice.bank_iban)}
` : ''} - ${invoice.bank_swift ? `
${L.swift}:${escapeHtml(invoice.bank_swift)}
` : ''} - ${invoice.bank_account ? `
${L.account}:${escapeHtml(invoice.bank_account)}
` : ''} -
${L.currency}:${escapeHtml(invoice.currency)}
- ${invoice.orders?.order_number ? `
${L.order_number}:${escapeHtml(invoice.orders.order_number)}
` : ''} - ${invoice.issued_by ? `
${L.issued_by}:${escapeHtml(invoice.issued_by)}
` : ''} - ${invoice.paid_date ? `
${L.paid_date}:${formatDate(invoice.paid_date)}
` : ''} -
+ ${notesHtml} - - - - - - - - - ${invoice.apply_vat ? `` : ''} - - - - - ${items.map((item, i) => ` - - - - - - - ${invoice.apply_vat ? `` : ''} - - - `).join('')} - -
#${L.description}${L.qty}${L.unit}${L.unit_price}${L.vat}${L.total}
${i + 1}${escapeHtml(item.description)}${formatNumber(item.qty)}${escapeHtml(item.unit)}${formatNumber(item.price)}${item.vatRate}%${formatNumber(item.lineTotal)}
+
+ +
-${invoice.notes ? `
${L.notes}:
${escapeHtml(invoice.notes)}
` : ''} `;