style: run prettier on entire codebase
This commit is contained in:
@@ -1,69 +1,85 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import QRCode from 'qrcode';
|
||||
import prisma from '../../config/database';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return '';
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
|
||||
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
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 [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, '>').replace(/"/g, '"');
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
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(
|
||||
/<(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 = '';
|
||||
s = s.replace(/( )/g, " ");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, '<span$1>$2');
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult { name: string; lines: string[] }
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function buildAddressLines(
|
||||
entity: Record<string, unknown> | null,
|
||||
isSupplier: boolean,
|
||||
tObj: Record<string, string>,
|
||||
): AddressResult {
|
||||
if (!entity) return { name: '', lines: [] };
|
||||
if (!entity) return { name: "", lines: [] };
|
||||
|
||||
const nameKey = isSupplier ? 'company_name' : 'name';
|
||||
const name = String(entity[nameKey] || '');
|
||||
const nameKey = isSupplier ? "company_name" : "name";
|
||||
const name = String(entity[nameKey] || "");
|
||||
|
||||
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = [];
|
||||
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') {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -72,29 +88,37 @@ function buildAddressLines(
|
||||
|
||||
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',
|
||||
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);
|
||||
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();
|
||||
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.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 cfName = (cf.name || "").trim();
|
||||
const cfValue = (cf.value || "").trim();
|
||||
const showLabel = cf.showLabel !== false;
|
||||
if (cfValue) {
|
||||
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue;
|
||||
fieldMap[`custom_${i}`] =
|
||||
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -122,251 +146,282 @@ function buildAddressLines(
|
||||
|
||||
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 č.:',
|
||||
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Č: ',
|
||||
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: ',
|
||||
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<void> {
|
||||
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<string, string>;
|
||||
const lang = query.lang === 'en' ? 'en' : 'cs';
|
||||
const t = translations[lang];
|
||||
export default async function invoicesPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
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<string, string>;
|
||||
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' },
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 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 },
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (orderRow) {
|
||||
orderNumber = escapeHtml(String(orderRow.customer_order_number || orderRow.order_number || ''));
|
||||
|
||||
if (!invoice) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
|
||||
}
|
||||
}
|
||||
|
||||
// Logo
|
||||
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 = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
const items = await prisma.invoice_items.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
const currency = invoice.currency || 'CZK';
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
// Calculations
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
let totalVat = 0;
|
||||
for (const data of Object.values(vatSummary)) {
|
||||
totalVat += data.vat;
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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 || "",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
// Logo
|
||||
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 = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
// Address lines
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
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('');
|
||||
// Calculations
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
// 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<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);
|
||||
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)
|
||||
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) => `<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;
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
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 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;
|
||||
// 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 `<tr>
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}</td>
|
||||
@@ -376,53 +431,58 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
})
|
||||
.join("");
|
||||
|
||||
// VAT recap rows
|
||||
const vatRecapHtml = vatRecap.map(vr => `<tr>
|
||||
// VAT recap rows
|
||||
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('');
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
// VAT detail rows for totals section
|
||||
let vatDetailHtml = '';
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
// VAT detail rows for totals section
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes section
|
||||
const notesRaw = invoice.notes ?? '';
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, '').trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
// Notes section
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<!-- Poznamky -->
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
: "";
|
||||
|
||||
// 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`;
|
||||
}
|
||||
// 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 = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -833,7 +893,7 @@ ${indentCSS}
|
||||
<!-- Hlavicka -->
|
||||
<div class="invoice-header">
|
||||
<div class="left">
|
||||
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ''}
|
||||
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
|
||||
</div>
|
||||
<div class="invoice-title">${escapeHtml(t.heading)} ${invoiceNumber}</div>
|
||||
</div>
|
||||
@@ -866,7 +926,7 @@ ${indentCSS}
|
||||
<div class="vs-block">
|
||||
${escapeHtml(t.var_symbol)} <strong>${invoiceNumber}</strong>
|
||||
${escapeHtml(t.const_symbol)} <strong>${escapeHtml(invoice.constant_symbol)}</strong><br>
|
||||
${orderNumber ? `${escapeHtml(t.order_no)} ${orderNumber}` : ''}
|
||||
${orderNumber ? `${escapeHtml(t.order_no)} ${orderNumber}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
@@ -923,8 +983,8 @@ ${indentCSS}
|
||||
|
||||
<!-- Vystavil -->
|
||||
<div class="issued-by">
|
||||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || '')}
|
||||
${suppEmail ? `<br> ${escapeHtml(suppEmail)}` : ''}
|
||||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
|
||||
${suppEmail ? `<br> ${escapeHtml(suppEmail)}` : ""}
|
||||
</div>
|
||||
|
||||
<!-- Upozorneni -->
|
||||
@@ -967,6 +1027,7 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return reply.type('text/html').send(html);
|
||||
});
|
||||
return reply.type("text/html").send(html);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user