feat(vat)!: remove VAT entirely from offers, orders and issued orders

Accounting rule: nabidky, objednavky (incl. confirmation PDF) and objednavky
vydane are NOT tax documents - VAT belongs only on invoices. Per user
decision this is a FULL removal including DB columns.

- migration: DROP orders.vat_rate/apply_vat, issued_orders.vat_rate/apply_vat,
  issued_order_items.vat_rate, quotations.vat_rate/apply_vat (applied to
  dev + test DBs)
- services: net-only totals everywhere (computeIssuedOrderTotals(items) ->
  {total}; enrichOrder/enrichQuotation net; per-currency list totals net)
- PDFs (offers, order confirmation, issued order): no VAT columns/summary,
  single 'Celkem bez DPH' / 'Total excl. VAT' total, note under totals:
  'Ceny jsou uvedeny bez DPH. DPH bude uctovano dle platnych predpisu.';
  duplicate Cena/Celkem column merged (desc width 56%); orders-pdf render
  extracted as exported renderOrderConfirmationHtml for testability
- frontend: Uplatnit DPH checkboxes, VAT selects and per-item VAT columns
  removed from OfferDetail/OrderDetail/IssuedOrderDetail/
  OrderConfirmationModal/ReceivedOrders manual-create; list footers read
  'Celkem bez DPH'; invoice-from-order prefill now takes the company default
  VAT (invoice decides its own VAT)
- tests: suites reworked to net math; new pdf-vat-note.test.ts pins the
  exact cs+en note text and VAT-free layout on all three PDFs

Invoices and received invoices keep their VAT handling unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 13:13:16 +02:00
parent 396a1d37ec
commit 7398cad466
29 changed files with 558 additions and 1013 deletions

View File

@@ -200,15 +200,11 @@ const translations: Record<Lang, Record<string, string>> = {
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",
total_no_vat: "Celkem bez DPH",
amounts_in: "Částky jsou uvedeny v",
vat_notice:
"Ceny jsou uvedeny bez DPH. DPH bude účtováno dle platných předpisů.",
notes: "Poznámky",
delivery_terms: "Dodací podmínky:",
payment_terms: "Platební podmínky:",
@@ -228,15 +224,11 @@ const translations: Record<Lang, Record<string, string>> = {
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",
total_no_vat: "Total excl. VAT",
amounts_in: "Amounts are in",
vat_notice:
"Prices are exclusive of VAT. VAT will be charged according to applicable regulations.",
notes: "Notes",
delivery_terms: "Delivery terms:",
payment_terms: "Payment terms:",
@@ -251,8 +243,6 @@ interface IssuedOrderPdfData {
order_date: Date | null;
delivery_date: Date | null;
currency: string | null;
apply_vat: boolean | null;
vat_rate: unknown;
notes: string | null;
delivery_terms: string | null;
payment_terms: string | null;
@@ -267,7 +257,6 @@ interface IssuedOrderPdfItem {
quantity: unknown;
unit: string | null;
unit_price: unknown;
vat_rate: unknown;
}
export function renderIssuedOrderHtml(
@@ -279,9 +268,7 @@ export function renderIssuedOrderHtml(
issuer: { name: string },
): string {
const t = translations[lang];
const applyVat = order.apply_vat !== false;
const currency = order.currency || "CZK";
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
const poNumber = escapeHtml(order.po_number || "");
// Logo embedding (same logic as the confirmation template).
@@ -308,31 +295,15 @@ export function renderIssuedOrderHtml(
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Items — NET + per-line VAT-on-top, rounded per line (same math the
// service computeIssuedOrderTotals uses; mirrors the confirmation loop).
let subtotal = 0;
let totalVat = 0;
const vatSummary: Record<string, { base: number; vat: number }> = {};
// Items — NET only. A purchase order is not a tax document: no VAT columns,
// no VAT math (same as the service computeIssuedOrderTotals).
let total = 0;
const itemsHtml = items
.map((it, i) => {
const qty = Number(it.quantity) || 0;
const unitPrice = Number(it.unit_price) || 0;
const rate =
it.vat_rate != null && it.vat_rate !== ""
? Number(it.vat_rate)
: docRate;
const lineSubtotal = qty * unitPrice;
const lineVat = applyVat
? Math.round(lineSubtotal * (rate / 100) * 100) / 100
: 0;
const lineTotal = lineSubtotal + lineVat;
subtotal += lineSubtotal;
totalVat += lineVat;
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal;
vatSummary[key].vat += lineVat;
const lineTotal = qty * unitPrice;
total += lineTotal;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const descHtml = `${escapeHtml(it.description)}${
@@ -340,40 +311,17 @@ export function renderIssuedOrderHtml(
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
: ""
}`;
// Without "Uplatnit DPH" the VAT columns are dropped entirely (the
// header does the same) instead of printing meaningless 0% / 0.00.
const vatCells = applyVat
? `
<td class="center">${Math.floor(rate)}%</td>
<td class="right">${formatNum(lineVat)}</td>`
: "";
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${descHtml}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
.join("");
subtotal = Math.round(subtotal * 100) / 100;
totalVat = Math.round(totalVat * 100) / 100;
const totalToPay = Math.round((subtotal + totalVat) * 100) / 100;
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>`;
}
}
}
total = Math.round(total * 100) / 100;
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
@@ -645,6 +593,12 @@ export function renderIssuedOrderHtml(
color: #1a1a1a;
margin-top: 2mm;
}
.totals .vat-note {
text-align: right;
font-size: 8pt;
color: #646464;
margin-top: 1mm;
}
/* Dodaci / platebni podminky (PO-specificke) */
.terms {
@@ -778,17 +732,10 @@ ${indentCSS}
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
<th style="width:56%">${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>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
applyVat
? `
<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:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
@@ -796,24 +743,15 @@ ${indentCSS}
</tbody>
</table>
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
<div class="totals-wrapper">
<div class="totals">${
applyVat
? `
<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="totals">
<div class="grand">
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
<span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
<div class="vat-note">${escapeHtml(t.vat_notice)}</div>
</div>
</div>

View File

@@ -203,9 +203,11 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
included: { EN: "Included", CZ: "Zahrnuto" },
total: { EN: "Total", CZ: "Celkem" },
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
vat: { EN: "VAT", CZ: "DPH" },
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
vat_notice: {
EN: "Prices are exclusive of VAT. VAT will be charged according to applicable regulations.",
CZ: "Ceny jsou uvedeny bez DPH. DPH bude \u00FA\u010Dtov\u00E1no dle platn\u00FDch p\u0159edpis\u016F.",
},
ico: { EN: "ID", CZ: "I\u010CO" },
dic: { EN: "VAT ID", CZ: "DI\u010C" },
page: { EN: "Page", CZ: "Strana" },
@@ -256,18 +258,15 @@ export default async function offersPdfRoutes(
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
}
// Offers are NOT tax documents — the total is NET only (no VAT math).
const items = quotation.quotation_items;
let subtotal = 0;
let total = 0;
for (const item of items) {
if (item.is_included_in_total !== false) {
subtotal +=
total +=
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
}
const applyVat = !!quotation.apply_vat;
const vatRate = Number(quotation.vat_rate) || 21;
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
const totalToPay = subtotal + vatAmount;
let hasScopeContent = false;
for (const s of quotation.scope_sections) {
if ((s.content || "").trim() || (s.title || "").trim()) {
@@ -318,23 +317,12 @@ export default async function offersPdfRoutes(
</tr>`;
});
let totalsHtml = "";
if (applyVat) {
totalsHtml += `<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t("subtotal"))}:</span>
<span class="value">${formatCurrency(subtotal, currency)}</span>
</div>
<div class="row">
<span class="label">${escapeHtml(t("vat"))} (${Math.round(vatRate)}%):</span>
<span class="value">${formatCurrency(vatAmount, currency)}</span>
</div>
</div>`;
}
totalsHtml += `<div class="grand">
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
<span class="value">${formatCurrency(totalToPay, currency)}</span>
</div>`;
// No Mezisoučet/VAT rows — they would only duplicate the net total.
const totalsHtml = `<div class="grand">
<span class="label">${escapeHtml(t("total_no_vat"))}</span>
<span class="value">${formatCurrency(total, currency)}</span>
</div>
<div class="vat-note">${escapeHtml(t("vat_notice"))}</div>`;
const quotationNumber = escapeHtml(quotation.quotation_number);
let scopeHtml = "";
@@ -578,6 +566,12 @@ ${indentCSS}
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .vat-note {
text-align: right;
font-size: 8pt;
color: #646464;
margin-top: 2mm;
}
/* ---- Scope sections ---- */
.scope-page {

View File

@@ -20,16 +20,14 @@ const OrderPdfItemSchema = z.object({
unit: z.string().max(255),
unit_price: z.number().min(0).finite(),
is_included_in_total: z.boolean().optional(),
vat_rate: z.number().min(0).max(100).finite(),
});
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the
// handler no longer reads them as untyped passthrough keys.
// `items` is strictly validated; `lang` is typed explicitly so the handler no
// longer reads it as an untyped passthrough key.
const OrderPdfBodySchema = z.looseObject({
items: z.array(OrderPdfItemSchema).optional(),
lang: z.string().max(10).optional(),
applyVat: z.boolean().optional(),
});
/* ── Helpers ─────────────────────────────────────────────────────── */
@@ -193,15 +191,11 @@ const translations: Record<string, Record<string, string>> = {
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",
total_no_vat: "Celkem bez DPH",
amounts_in: "Částky jsou uvedeny v",
vat_notice:
"Ceny jsou uvedeny bez DPH. DPH bude účtováno dle platných předpisů.",
notes: "Poznámky",
issued_by: "Vystavil:",
received_by: "Převzal:",
@@ -222,15 +216,11 @@ const translations: Record<string, Record<string, string>> = {
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",
total_no_vat: "Total excl. VAT",
amounts_in: "Amounts are in",
vat_notice:
"Prices are exclusive of VAT. VAT will be charged according to applicable regulations.",
notes: "Notes",
issued_by: "Issued by:",
received_by: "Received by:",
@@ -240,212 +230,119 @@ const translations: Record<string, Record<string, string>> = {
},
};
/* ── Route ───────────────────────────────────────────────────────── */
/* ── Template ────────────────────────────────────────────────────── */
export default async function ordersPdfRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
"/:id/confirmation",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
export interface OrderConfirmationPdfItem {
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
}
try {
const lang = body.lang === "en" ? "en" : "cs";
const t = translations[lang];
interface OrderConfirmationPdfData {
order_number: string | null;
customer_order_number: string | null;
created_at: Date | string | null;
currency: string | null;
notes: string | null;
// Not a real orders column today — read defensively (PHP-era data carried it).
payment_method?: string | null;
customers?: Record<string, unknown> | null;
}
const order = await prisma.orders.findUnique({
where: { id },
// The confirmation PDF never renders the PO attachment — don't pull
// the blob just to read the order header/items.
omit: { attachment_data: true },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
},
});
/**
* Order-confirmation HTML. The confirmation is NOT a tax document — prices
* are NET only: no VAT columns or VAT summary, the grand total is labeled
* "Celkem bez DPH" and the totals box carries the fixed prices-excl.-VAT
* notice. Exported for tests.
*/
export function renderOrderConfirmationHtml(
order: OrderConfirmationPdfData,
items: OrderConfirmationPdfItem[],
settings: Record<string, unknown> | null,
lang: "cs" | "en",
userName: string,
): string {
const t = translations[lang];
if (!order) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
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 settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
const currency = order.currency || "CZK";
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" />`;
}
// NET-only total over the included lines — no VAT math anywhere.
let total = 0;
for (const item of items) {
if (item.is_included_in_total) total += item.quantity * item.unit_price;
}
total = Math.round(total * 100) / 100;
const currency = order.currency || "CZK";
const applyVat =
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
const orderVatRate = Number(order.vat_rate) || 21;
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(
(order.customers as Record<string, unknown>) || null,
false,
t,
);
// The confirmation PDF can be rendered from client-supplied items (e.g.
// a live preview of unsaved edits on the detail page) OR from the
// stored order. Fabricating descriptions/prices that don't reflect the
// stored order is an editing action, so the custom-items path requires
// `orders.edit` (admins bypass). A view-only caller is silently served
// the STORED order items instead — preventing a `orders.view` holder
// from producing an "official" confirmation with invented figures.
const authData = request.authData;
const canUseCustomItems =
authData?.roleName === "admin" ||
!!authData?.permissions.includes("orders.edit");
const customItemsRaw =
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
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("");
let items: Array<{
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}> = [];
const orderNumber = escapeHtml(order.order_number || "");
const poNumber = escapeHtml(order.customer_order_number || "");
const orderDateStr = formatDate(order.created_at);
if (customItemsRaw && customItemsRaw.length > 0) {
items = customItemsRaw.map((it) => ({
description: it.description,
quantity: it.quantity,
unit: it.unit,
unit_price: it.unit_price,
is_included_in_total: it.is_included_in_total !== false,
vat_rate: it.vat_rate,
}));
} else {
items = order.order_items.map((it) => ({
description: it.description || "",
quantity: Number(it.quantity) || 0,
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
is_included_in_total: !!it.is_included_in_total,
vat_rate: orderVatRate,
}));
}
let subtotal = 0;
let totalVat = 0;
const vatSummary: Record<string, { base: number; vat: number }> = {};
for (const item of items) {
if (item.is_included_in_total) {
const lineTotal = item.quantity * item.unit_price;
subtotal += lineTotal;
const rate = item.vat_rate;
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineTotal;
if (applyVat) {
const lineVat = (lineTotal * rate) / 100;
vatSummary[key].vat += lineVat;
totalVat += lineVat;
}
}
}
const totalToPay = subtotal + totalVat;
const userName = request.authData
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
: "";
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(
(order.customers as Record<string, unknown>) || null,
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("");
const orderNumber = escapeHtml(order.order_number || "");
const poNumber = escapeHtml(order.customer_order_number || "");
const orderDateStr = formatDate(order.created_at);
const itemsHtml = items
.map((item, i) => {
const lineSubtotal = item.quantity * item.unit_price;
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals =
Math.floor(item.quantity) === item.quantity ? 0 : 2;
// Without "Uplatnit DPH" the VAT columns are dropped entirely
// (the header does the same) instead of printing 0% / 0.00.
const vatCells = applyVat
? `
<td class="center">${Math.floor(item.vat_rate)}%</td>
<td class="right">${formatNum(lineVat)}</td>`
: "";
return `<tr>
const itemsHtml = items
.map((item, i) => {
const lineTotal = item.quantity * item.unit_price;
const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2;
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(item.unit_price)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
.join("");
})
.join("");
const paymentMethod =
String((order as Record<string, unknown>).payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer");
const paymentMethod =
String(order.payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer");
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>`;
}
}
}
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(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>
return `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8" />
@@ -676,6 +573,12 @@ export default async function ordersPdfRoutes(
color: #1a1a1a;
margin-top: 2mm;
}
.totals .vat-note {
text-align: right;
font-size: 8pt;
color: #646464;
margin-top: 1mm;
}
/* Vystavil */
.issued-by {
@@ -693,47 +596,6 @@ export default async function ordersPdfRoutes(
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;
@@ -855,17 +717,10 @@ ${indentCSS}
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
<th style="width:56%">${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>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
applyVat
? `
<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:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
@@ -873,24 +728,15 @@ ${indentCSS}
</tbody>
</table>
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
<div class="totals-wrapper">
<div class="totals">${
applyVat
? `
<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="totals">
<div class="grand">
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
<span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
<div class="vat-note">${escapeHtml(t.vat_notice)}</div>
</div>
</div>
@@ -915,9 +761,96 @@ ${indentCSS}
</body>
</html>`;
}
/* ── Route ───────────────────────────────────────────────────────── */
export default async function ordersPdfRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
"/:id/confirmation",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
try {
const lang = body.lang === "en" ? "en" : "cs";
const order = await prisma.orders.findUnique({
where: { id },
// The confirmation PDF never renders the PO attachment — don't pull
// the blob just to read the order header/items.
omit: { attachment_data: true },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
},
});
if (!order) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
// The confirmation PDF can be rendered from client-supplied items (e.g.
// a live preview of unsaved edits on the detail page) OR from the
// stored order. Fabricating descriptions/prices that don't reflect the
// stored order is an editing action, so the custom-items path requires
// `orders.edit` (admins bypass). A view-only caller is silently served
// the STORED order items instead — preventing a `orders.view` holder
// from producing an "official" confirmation with invented figures.
const authData = request.authData;
const canUseCustomItems =
authData?.roleName === "admin" ||
!!authData?.permissions.includes("orders.edit");
const customItemsRaw =
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
let items: OrderConfirmationPdfItem[];
if (customItemsRaw && customItemsRaw.length > 0) {
items = customItemsRaw.map((it) => ({
description: it.description,
quantity: it.quantity,
unit: it.unit,
unit_price: it.unit_price,
is_included_in_total: it.is_included_in_total !== false,
}));
} else {
items = order.order_items.map((it) => ({
description: it.description || "",
quantity: Number(it.quantity) || 0,
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
is_included_in_total: !!it.is_included_in_total,
}));
}
const userName = request.authData
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
: "";
const html = renderOrderConfirmationHtml(
order,
items,
settings,
lang,
userName,
);
const pdfBuffer = await htmlToPdf(html);
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`;
return reply
.type("application/pdf")