1192 lines
38 KiB
TypeScript
1192 lines
38 KiB
TypeScript
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<string, unknown> | null,
|
||
isSupplier: boolean,
|
||
tObj: Record<string, string>,
|
||
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<string, unknown>).fields) {
|
||
cfData =
|
||
((parsed as Record<string, unknown>).fields as typeof cfData) || [];
|
||
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
|
||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
|
||
} else if (Array.isArray(parsed)) {
|
||
cfData = parsed;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(fieldOrder)) {
|
||
const legacyMap: Record<string, string> = {
|
||
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<string, string> = {};
|
||
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<string, Record<string, string>> = {
|
||
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<string, Record<string, string>> = {
|
||
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 — "<p><br></p>" (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: <name>"
|
||
* 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 `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||
<div style="flex:1;"></div>
|
||
</div>`;
|
||
}
|
||
|
||
/**
|
||
* 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<void> {
|
||
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<string, string>;
|
||
|
||
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("<html><body><h1>Faktura nenalezena</h1></body></html>");
|
||
}
|
||
|
||
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<string, unknown> | null = null;
|
||
if (invoice.customer_id) {
|
||
customer = (await prisma.customers.findUnique({
|
||
where: { id: invoice.customer_id },
|
||
})) as Record<string, unknown> | 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
|
||
? `<img src="${logoDataUri}" class="logo" />`
|
||
: "";
|
||
|
||
const currency = invoice.currency || "CZK";
|
||
const applyVat = !!invoice.apply_vat;
|
||
|
||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||
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) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||
.join("");
|
||
const custLinesHtml = cust.lines
|
||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||
.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<string, unknown>).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
|
||
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}</td>`
|
||
: "";
|
||
const subDesc = item.item_description || "";
|
||
|
||
return `<tr>
|
||
<td class="row-num">${i + 1}</td>
|
||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||
<td class="right">${formatNum(unitPrice)}</td>
|
||
${discCell}
|
||
<td class="right">${formatNum(lineSubtotal)}</td>
|
||
<td class="center">${applyVat ? Math.floor(vatRate) : 0}%</td>
|
||
<td class="right">${formatNum(lineVat)}</td>
|
||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
|
||
const vatRecapHtml = vatRecap
|
||
.map(
|
||
(vr) => `<tr>
|
||
<td class="right">${formatNum(vr.base)}</td>
|
||
<td class="center">${Math.floor(vr.rate)}%</td>
|
||
<td class="right">${formatNum(vr.vat)}</td>
|
||
<td class="right">${formatNum(vr.total)}</td>
|
||
</tr>`,
|
||
)
|
||
.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
|
||
? ""
|
||
: `<table>
|
||
<thead>
|
||
<tr>
|
||
<th colspan="4">${escapeHtml(t.vat_recap)}</th>
|
||
</tr>
|
||
<tr>
|
||
<th>${escapeHtml(t.vat_base)}</th>
|
||
<th>${escapeHtml(t.vat_rate)}</th>
|
||
<th>${escapeHtml(t.vat_amount)}</th>
|
||
<th>${escapeHtml(t.vat_with_total)}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${vatRecapHtml}
|
||
</tbody>
|
||
${
|
||
isForeign
|
||
? `<tfoot>
|
||
<tr>
|
||
<td colspan="4" style="font-size:0.7em; color:#666; padding-top:6px; text-align:left;">
|
||
${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
|
||
</td>
|
||
</tr>
|
||
</tfoot>`
|
||
: ""
|
||
}
|
||
</table>`;
|
||
|
||
let vatDetailHtml = "";
|
||
if (applyVat) {
|
||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||
if (data.vat > 0) {
|
||
vatDetailHtml += `
|
||
<div class="row">
|
||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||
</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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 "<p><br></p>" 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 += '<div class="scope-sections">';
|
||
for (const section of visibleSections) {
|
||
const title = resolveSectionTitle(section);
|
||
const content = (section.content || "").trim();
|
||
sectionsHtml += '<div class="scope-section">';
|
||
if (title.trim())
|
||
sectionsHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||
if (content)
|
||
sectionsHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
|
||
sectionsHtml += "</div>";
|
||
}
|
||
sectionsHtml += "</div>";
|
||
}
|
||
|
||
// Quill indent CSS
|
||
const indentCSS = buildQuillIndentCss();
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="${escapeHtml(lang)}">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>${escapeHtml(t.title)} ${invoiceNumber}</title>
|
||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
|
||
<style>
|
||
@page {
|
||
size: A4;
|
||
/* Chromium uses THESE margins for layout (they override Puppeteer's
|
||
margin option) — they must match the header/footer template space:
|
||
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
|
||
Strana X z Y). Same geometry as the issued-order PDF. */
|
||
margin: 32mm 12mm 18mm 12mm;
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
html, body {
|
||
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
|
||
font-size: 10pt;
|
||
color: #1a1a1a;
|
||
width: 186mm;
|
||
}
|
||
|
||
.invoice-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
/* Printable area with the Puppeteer header (32mm top) + footer (18mm
|
||
bottom) margins, minus 1mm slack so a one-page invoice can't spill. */
|
||
min-height: calc(297mm - 51mm);
|
||
}
|
||
.invoice-content { flex: 1 1 auto; }
|
||
/* The bottom block (notice + QR/VAT recap + Převzal/Razítko) must never be
|
||
SPLIT by a page break — on a one-page invoice the flex pinning keeps it
|
||
at the page bottom; when the content overflows, break-inside moves the
|
||
whole block to the next page as one unit instead of stranding the notice
|
||
on the previous page. */
|
||
.invoice-footer {
|
||
flex-shrink: 0;
|
||
break-inside: avoid;
|
||
}
|
||
|
||
.accent { color: #de3a3a; }
|
||
|
||
/* ── Hlavicka ── */
|
||
.invoice-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 1mm;
|
||
padding-bottom: 1mm;
|
||
border-bottom: 2pt solid #de3a3a;
|
||
}
|
||
.invoice-header .left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 3mm;
|
||
}
|
||
.logo-header { text-align: left; }
|
||
.company-title {
|
||
font-size: 12pt;
|
||
font-weight: 700;
|
||
}
|
||
.invoice-title {
|
||
font-size: 13pt;
|
||
font-weight: 700;
|
||
color: #de3a3a;
|
||
text-align: right;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
.logo {
|
||
max-width: 42mm;
|
||
max-height: 22mm;
|
||
object-fit: contain;
|
||
}
|
||
|
||
/* ── Adresy ── */
|
||
.header-grid {
|
||
border: 0.5pt solid #d0d0d0;
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin-bottom: 1mm;
|
||
}
|
||
.header-grid td {
|
||
padding: 2mm 3mm;
|
||
border: 0.5pt solid #d0d0d0;
|
||
vertical-align: top;
|
||
width: 50%;
|
||
}
|
||
.header-grid td.addr-customer {
|
||
background: #f5f5f5;
|
||
}
|
||
.header-grid td.details-bank {
|
||
background: #f5f5f5;
|
||
}
|
||
.address-label {
|
||
font-size: 8pt;
|
||
font-weight: 700;
|
||
color: #de3a3a;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
margin-bottom: 1mm;
|
||
}
|
||
.address-name {
|
||
font-size: 10pt;
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
line-height: 1.3;
|
||
margin-bottom: 1mm;
|
||
}
|
||
.address-line {
|
||
font-size: 9pt;
|
||
color: #444;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── Detaily (banka + datumy) — inside header-grid ── */
|
||
|
||
.info-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
font-size: 9pt;
|
||
padding: 1mm 0;
|
||
border-bottom: 0.5pt solid #f0f0f0;
|
||
}
|
||
.info-row:last-child { border-bottom: none; }
|
||
.info-row .lbl {
|
||
color: #666;
|
||
font-weight: 400;
|
||
flex-shrink: 0;
|
||
white-space: nowrap;
|
||
margin-right: 3mm;
|
||
}
|
||
.info-row .val {
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
text-align: right;
|
||
margin-left: auto;
|
||
}
|
||
|
||
/* VS/KS blok */
|
||
.vs-block {
|
||
font-size: 9pt;
|
||
line-height: 1.4;
|
||
padding-top: 2mm;
|
||
}
|
||
|
||
/* ── Polozky ── */
|
||
.billing-label {
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
font-size: 10pt;
|
||
padding: 2mm 0 1mm 0;
|
||
border-bottom: 1.5pt solid #de3a3a;
|
||
margin-bottom: 0;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
table.items {
|
||
width: 100%;
|
||
table-layout: fixed;
|
||
border-collapse: collapse;
|
||
font-size: 9pt;
|
||
margin-bottom: 2mm;
|
||
}
|
||
table.items thead th {
|
||
font-size: 8.5pt;
|
||
font-weight: 600;
|
||
color: #646464;
|
||
padding: 4px 4px;
|
||
text-align: left;
|
||
border-bottom: 0.5pt solid #d0d0d0;
|
||
white-space: nowrap;
|
||
}
|
||
table.items thead th.center { text-align: center; }
|
||
table.items thead th.right { text-align: right; }
|
||
table.items tbody td {
|
||
padding: 4px 4px;
|
||
border-bottom: 0.5pt solid #e0e0e0;
|
||
vertical-align: middle;
|
||
color: #1a1a1a;
|
||
}
|
||
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
|
||
table.items tbody td.center { text-align: center; white-space: nowrap; }
|
||
table.items tbody td.right { text-align: right; }
|
||
table.items tbody td.row-num {
|
||
text-align: center;
|
||
color: #969696;
|
||
font-size: 9pt;
|
||
}
|
||
table.items tbody td.desc {
|
||
font-size: 9pt;
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
}
|
||
.item-subdesc {
|
||
font-size: 9pt;
|
||
color: #646464;
|
||
margin-top: 2px;
|
||
}
|
||
table.items tbody td.total-cell { font-weight: 700; }
|
||
|
||
/* Soucet + total - styl z nabidek */
|
||
.totals-wrapper {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 2mm;
|
||
}
|
||
.totals {
|
||
width: 80mm;
|
||
}
|
||
.totals .detail-rows {
|
||
margin-bottom: 3mm;
|
||
}
|
||
.totals .row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
font-size: 9.5pt;
|
||
color: #1a1a1a;
|
||
margin-bottom: 2mm;
|
||
}
|
||
.totals .grand {
|
||
border-top: 0.5pt solid #e0e0e0;
|
||
padding-top: 4mm;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
}
|
||
.totals .grand .label {
|
||
font-size: 10.5pt;
|
||
font-weight: 400;
|
||
color: #1a1a1a;
|
||
align-self: center;
|
||
}
|
||
.totals .grand .value {
|
||
font-size: 14pt;
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
border-bottom: 2.5pt solid #de3a3a;
|
||
padding-bottom: 1mm;
|
||
}
|
||
.totals .currency-note {
|
||
text-align: right;
|
||
font-size: 8pt;
|
||
color: #1a1a1a;
|
||
margin-top: 2mm;
|
||
}
|
||
|
||
/* Vystavil lives in Puppeteer's footerTemplate (bottom page margin) on
|
||
every page — same as the issued-order PDF; no body block. */
|
||
|
||
/* Upozorneni */
|
||
.notice {
|
||
font-size: 8pt;
|
||
color: #1a1a1a;
|
||
margin: 2mm 0;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
/* DPH rekapitulace + QR */
|
||
.recap-section {
|
||
display: flex;
|
||
gap: 5mm;
|
||
align-items: flex-start;
|
||
margin-top: 1mm;
|
||
}
|
||
.recap-section .qr {
|
||
flex-shrink: 0;
|
||
width: 28mm;
|
||
}
|
||
.recap-section .qr img,
|
||
.recap-section .qr svg { width: 28mm; height: 28mm; }
|
||
|
||
.recap-section table {
|
||
border-collapse: collapse;
|
||
font-size: 9pt;
|
||
flex: 1;
|
||
}
|
||
.recap-section table th {
|
||
font-size: 8pt;
|
||
font-weight: 600;
|
||
color: #555;
|
||
padding: 3px 6px;
|
||
text-align: right;
|
||
border-bottom: 0.5pt solid #ccc;
|
||
}
|
||
.recap-section table td {
|
||
padding: 3px 6px;
|
||
text-align: right;
|
||
border-bottom: 0.5pt solid #eee;
|
||
}
|
||
.recap-section table td.center { text-align: center; }
|
||
.recap-section table td.cnb-rate {
|
||
font-size: 8pt;
|
||
color: #888;
|
||
text-align: right;
|
||
border-bottom: none;
|
||
padding-top: 4px;
|
||
}
|
||
|
||
/* Prevzal / razitko */
|
||
.footer-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-top: 4mm;
|
||
font-size: 9pt;
|
||
border-top: 0.5pt solid #aaa;
|
||
padding-top: 2mm;
|
||
min-height: 15mm;
|
||
}
|
||
.footer-row .col {
|
||
font-weight: 600;
|
||
color: #555;
|
||
}
|
||
|
||
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
|
||
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
|
||
stranu (mirrors issued-orders-pdf). */
|
||
.scope-sections {
|
||
margin-top: 6mm;
|
||
}
|
||
.scope-section {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
margin-bottom: 3mm;
|
||
break-inside: avoid;
|
||
}
|
||
.scope-section-title {
|
||
font-size: 11pt;
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
margin-bottom: 1mm;
|
||
}
|
||
.section-content {
|
||
color: #1a1a1a;
|
||
line-height: 1.5;
|
||
word-break: normal;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.section-content,
|
||
.section-content * {
|
||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||
}
|
||
.section-content { font-size: 14px; }
|
||
.section-content h1 { font-size: 20px; }
|
||
.section-content h2 { font-size: 18px; }
|
||
.section-content h3 { font-size: 16px; }
|
||
.section-content h4 { font-size: 15px; }
|
||
.section-content p { margin: 0 0 0.4em 0; }
|
||
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
|
||
.section-content li { margin-bottom: 0.2em; }
|
||
|
||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||
.ql-align-center { text-align: center; }
|
||
.ql-align-right { text-align: right; }
|
||
.ql-align-justify { text-align: justify; }
|
||
${indentCSS}
|
||
|
||
@media print {
|
||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
|
||
page — hide the body copy so page 1 doesn't show it twice. The screen
|
||
(HTML preview) keeps the body header. */
|
||
.invoice-header { display: none; }
|
||
}
|
||
@media screen {
|
||
html { background: #525659; }
|
||
body {
|
||
width: 100vw !important;
|
||
margin: 0;
|
||
padding: 30px 0;
|
||
background: transparent;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
min-height: 100vh;
|
||
overflow-x: hidden;
|
||
}
|
||
.invoice-page {
|
||
width: 210mm;
|
||
min-height: 297mm;
|
||
padding: 15mm;
|
||
background: white;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||
box-sizing: border-box;
|
||
border-radius: 2px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="invoice-page">
|
||
<div class="invoice-content">
|
||
|
||
<!-- Hlavicka -->
|
||
<div class="invoice-header">
|
||
<div class="left">
|
||
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
|
||
</div>
|
||
<div class="invoice-title">${escapeHtml(t.heading)} ${invoiceNumber}</div>
|
||
</div>
|
||
|
||
<!-- Dodavatel / Odberatel + Banka / Datumy -->
|
||
<table class="header-grid" cellspacing="0">
|
||
<tr>
|
||
<td>
|
||
<div class="address-label">${escapeHtml(t.supplier)}</div>
|
||
<div class="address-name">${escapeHtml(supp.name)}</div>
|
||
${suppLinesHtml}
|
||
</td>
|
||
<td class="addr-customer">
|
||
<div class="address-label">${escapeHtml(t.customer)}</div>
|
||
<div class="address-name">${escapeHtml(cust.name)}</div>
|
||
${custLinesHtml}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="details-bank">
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.bank)}</span> <span class="val">${escapeHtml(invoice.bank_name)}</span></div>
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.swift)}</span> <span class="val">${escapeHtml(invoice.bank_swift)}</span></div>
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.iban)}</span> <span class="val">${escapeHtml(invoice.bank_iban)}</span></div>
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.account_no)}</span> <span class="val">${escapeHtml(invoice.bank_account)}</span></div>
|
||
<div class="vs-block">
|
||
${escapeHtml(t.var_symbol)} <strong>${invoiceNumber}</strong>
|
||
${escapeHtml(t.const_symbol)} <strong>${escapeHtml(invoice.constant_symbol)}</strong>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.issue_date))}</span></div>
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.due_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.due_date))}</span></div>
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.tax_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.tax_date))}</span></div>
|
||
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(translatePaymentMethod(invoice.payment_method, lang))}</span></div>
|
||
${orderNumber ? `<div class="info-row"><span class="lbl">${escapeHtml(t.order_no)}</span> <span class="val">${orderNumber}</span></div>` : ""}
|
||
${orderDate ? `<div class="info-row"><span class="lbl">${escapeHtml(t.order_date)}</span> <span class="val">${escapeHtml(orderDate)}</span></div>` : ""}
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<!-- Polozky -->
|
||
<div class="billing-label">${escapeHtml(invoice.billing_text || t.billing)}</div>
|
||
<table class="items">
|
||
<thead>
|
||
<tr>
|
||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||
<th style="width:${hasDiscount ? 29 : 36}%">${escapeHtml(t.col_desc)}</th>
|
||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||
${hasDiscount ? `<th class="center" style="width:7%">${escapeHtml(t.col_discount)}</th>` : ""}
|
||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
||
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${itemsHtml}
|
||
</tbody>
|
||
</table>
|
||
|
||
<!-- Soucty -->
|
||
<div class="totals-wrapper">
|
||
<div class="totals">
|
||
<div class="detail-rows">
|
||
<div class="row">
|
||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||
</div>${vatDetailHtml}
|
||
</div>
|
||
<div class="grand">
|
||
<span class="label">${escapeHtml(t.total)}</span>
|
||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||
</div>
|
||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
${sectionsHtml}
|
||
|
||
</div><!-- /.invoice-content -->
|
||
<div class="invoice-footer">
|
||
|
||
<!-- Vystavil tiskne Puppeteer footerTemplate na kazde strance -->
|
||
|
||
<!-- Upozorneni -->
|
||
<div class="notice">
|
||
${escapeHtml(t.notice)}
|
||
</div>
|
||
|
||
<!-- DPH rekapitulace + QR -->
|
||
<div class="recap-section">
|
||
<div class="qr">
|
||
${qrSvg}
|
||
</div>
|
||
${recapTableHtml}
|
||
</div>
|
||
|
||
<!-- Prevzal / razitko -->
|
||
<div class="footer-row">
|
||
<div class="col">${escapeHtml(t.received_by)}</div>
|
||
<div class="col" style="text-align:right">${escapeHtml(t.stamp)}</div>
|
||
</div>
|
||
|
||
</div><!-- /.invoice-footer -->
|
||
</div><!-- /.invoice-page -->
|
||
|
||
</body>
|
||
</html>`;
|
||
|
||
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("<html><body><h1>Chyba při generování PDF</h1></body></html>");
|
||
}
|
||
},
|
||
);
|
||
}
|