import { describe, it, expect, beforeAll, afterAll } from "vitest"; import Fastify from "fastify"; import jwt from "jsonwebtoken"; import prisma from "../config/database"; import { config } from "../config/env"; import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf"; import offersPdfRoutes from "../routes/admin/offers-pdf"; import invoicesPdfRoutes from "../routes/admin/invoices-pdf"; import { createInvoice } from "../services/invoices.service"; // Offers, received orders (their confirmation PDF) and issued orders are NOT // tax documents — VAT must never appear on them. These tests pin that contract // for the confirmation and offer PDFs: no VAT columns/rows/notices, and the // grand total is "Celkem bez DPH" / "Total excl. VAT". (The issued-order PDF // is covered in issued-orders.test.ts.) // The explanatory prices-excl.-VAT notice was removed at user request — the // total label alone carries the information. Pin its absence too. const CS_NOTE = "Ceny jsou uvedeny bez DPH"; const EN_NOTE = "Prices are exclusive of VAT"; describe("renderOrderConfirmationHtml (order confirmation PDF)", () => { const order = { order_number: "26730042", customer_order_number: "PO-XYZ", created_at: new Date("2026-06-09T12:00:00"), currency: "CZK", notes: null, customers: null, }; const items = [ { description: "Materiál", quantity: 2, unit: "ks", unit_price: 100, is_included_in_total: true, }, ]; it("cs: NET total labeled 'Celkem bez DPH', no VAT columns or notice", () => { const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan"); expect(html).not.toContain("%DPH"); expect(html).not.toContain(">DPH<"); expect(html).not.toContain("Mezisoučet"); // Line total = netto (2 × 100), no VAT-on-top anywhere. expect(html).toContain('200,00'); expect(html).toContain("Celkem bez DPH"); expect(html).not.toContain(CS_NOTE); }); it("en: 'Total excl. VAT', no VAT columns or notice", () => { const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan"); expect(html).not.toContain("VAT%"); expect(html).toContain("Total excl. VAT"); expect(html).not.toContain(EN_NOTE); }); it("excludes not-included lines from the NET total", () => { const html = renderOrderConfirmationHtml( order, [ ...items, { description: "Mimo cenu", quantity: 1, unit: "ks", unit_price: 999, is_included_in_total: false, }, ], null, "cs", "Jan", ); // Grand total stays 200,00 — the excluded line doesn't count. expect(html).toContain("200,00 CZK"); }); it("uses the shared NBSP-thousands formatter (pdf-shared extraction marker)", () => { const html = renderOrderConfirmationHtml( order, [ { description: "Velká položka", quantity: 2, unit: "ks", unit_price: 1000, is_included_in_total: true, }, ], null, "cs", "Jan", ); // 2 × 1000 = 2 000,00 with the NBSP (U+00A0) thousands separator. expect(html).toContain("2 000,00 CZK"); expect(html).not.toContain("1 199,00 CZK"); }); }); /* -------------------------------------------------------------------------- */ /* Offer PDF route (returns the HTML for the print view) */ /* -------------------------------------------------------------------------- */ let app: ReturnType | null = null; let adminToken = ""; const createdQuotationIds: number[] = []; const createdInvoiceIds: number[] = []; beforeAll(async () => { app = Fastify({ logger: false }); await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" }); await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" }); const admin = await prisma.users.findFirst({ where: { roles: { name: "admin" } }, }); if (!admin) throw new Error("Test setup: admin user not found"); adminToken = jwt.sign( { sub: admin.id, username: admin.username, role: "admin" }, config.jwt.secret, { expiresIn: "15m" }, ); }); afterAll(async () => { for (const id of createdQuotationIds) await prisma.quotations.deleteMany({ where: { id } }); for (const id of createdInvoiceIds) await prisma.invoices.deleteMany({ where: { id } }); if (app) await app.close(); }); describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => { it("renders NET-only totals with 'Celkem bez DPH', no notice", async () => { // Direct prisma insert (explicit number → no sequence consumed). const quotation = await prisma.quotations.create({ data: { quotation_number: `Q-VATNOTE-${Date.now()}`, status: "active", currency: "CZK", language: "cs", quotation_items: { create: [ { description: "Položka", quantity: 2, unit: "ks", unit_price: 1000, is_included_in_total: true, position: 0, }, ], }, }, }); createdQuotationIds.push(quotation.id); const res = await app!.inject({ method: "GET", url: `/api/admin/offers-pdf/${quotation.id}`, headers: { Authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const html = res.body; expect(html).toContain("Celkem bez DPH"); expect(html).not.toContain(CS_NOTE); expect(html).not.toContain("%DPH"); expect(html).not.toContain("Mezisoučet"); expect(html).not.toContain("Celkem k úhradě"); }); it("renders a fractional quantity as '1,50', not rounded to '2'", async () => { const quotation = await prisma.quotations.create({ data: { quotation_number: `Q-FRACQTY-${Date.now()}`, status: "active", currency: "CZK", language: "cs", quotation_items: { create: [ { description: "Půldenní sazba", quantity: 1.5, unit: "ks", unit_price: 1000, is_included_in_total: true, position: 0, }, ], }, }, }); createdQuotationIds.push(quotation.id); const res = await app!.inject({ method: "GET", url: `/api/admin/offers-pdf/${quotation.id}`, headers: { Authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const html = res.body; // The old toFixed(0) ROUNDED 1.5 → "2"; integers still render without // decimals elsewhere (covered by the previous test's qty 2). expect(html).toContain("1,50 / ks"); expect(html).not.toContain(">2 / ks<"); }); }); /* -------------------------------------------------------------------------- */ /* Invoice PDF route — post-pdf-shared-extraction render marker */ /* -------------------------------------------------------------------------- */ describe("GET /api/admin/invoices-pdf/:id (invoice PDF html)", () => { it("still renders the invoice HTML after the shared-helper extraction", async () => { // Draft → no number consumed; CZK → no CNB exchange-rate lookup. const invoice = await createInvoice({ status: "draft", currency: "CZK", apply_vat: true, vat_rate: 21, items: [ { description: "PDF-SHARED-MARKER", quantity: 2, unit_price: 1000, vat_rate: 21, }, ], }); createdInvoiceIds.push(invoice.id); const res = await app!.inject({ method: "GET", url: `/api/admin/invoices-pdf/${invoice.id}`, headers: { Authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const html = res.body; expect(html).toContain("FAKTURA - DAŇOVÝ DOKLAD"); expect(html).toContain("PDF-SHARED-MARKER"); // Shared NBSP formatNum: 2 × 1000 = 2 000,00 base. expect(html).toContain("2 000,00"); expect(html).toContain("Celkem k úhradě"); }); });