diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index e65f301b2..02da40a4a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Get, @@ -113,9 +114,19 @@ export class BillingController { @Get("invoices/:id/document") @BookingStaff(FREIGHT_PERMS.invoices.export) - @ApiOperation({ summary: "Download the sealed invoice PDF" }) - async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { - const { filename, buffer } = await this.billingService.document(id); + @ApiOperation({ + summary: + 'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).', + }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @Query("format") format: string | undefined, + @Res() res: Response, + ) { + if (format !== undefined && format !== "a4" && format !== "thermal") { + throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`); + } + const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4"); sendPdf(res, filename, buffer); } diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 7148fa987..243b1ee4d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -927,6 +927,7 @@ describe("BillingService.document", () => { const build = (invoice: Record) => { const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); + const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") }); const service = new BillingService( {} as never, { findById: jest.fn().mockResolvedValue(invoice) } as never, @@ -934,7 +935,7 @@ describe("BillingService.document", () => { {} as never, {} as never, {} as never, - { render } as never, + { render, renderThermal } as never, {} as never, { get: (key: string) => @@ -943,7 +944,7 @@ describe("BillingService.document", () => { : undefined, } as never, // config ); - return { service, render }; + return { service, render, renderThermal }; }; it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { @@ -1000,4 +1001,24 @@ describe("BillingService.document", () => { expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); }); + + it("calls render (not renderThermal) for the default format", async () => { + const { service, render, renderThermal } = build(invoiceRow()); + jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); + + await service.document("inv-1"); + + expect(render).toHaveBeenCalledTimes(1); + expect(renderThermal).not.toHaveBeenCalled(); + }); + + it("calls renderThermal (not render) for format 'thermal'", async () => { + const { service, render, renderThermal } = build(invoiceRow()); + jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); + + await service.document("inv-1", "thermal"); + + expect(renderThermal).toHaveBeenCalledTimes(1); + expect(render).not.toHaveBeenCalled(); + }); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index a0f25f88b..49227efc9 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -411,12 +411,20 @@ export class BillingService { // ── Documents (central PDF) ────────────────────────────────────────────────── - /** Sealed PDF invoice for any source, rendered by the shared document service. */ - async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Sealed PDF invoice for any source, rendered by the shared document service. `format` + * validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input + * boundary check, not a business rule. + */ + async document( + id: string, + format: "a4" | "thermal" = "a4", + ): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render( - await this.toDocumentModel(invoice, "INVOICE"), - ); + const model = await this.toDocumentModel(invoice, "INVOICE"); + return format === "thermal" + ? this.invoiceDocuments.renderThermal(model) + : this.invoiceDocuments.render(model); } /** Sealed PDF receipt; available once any payment has been recorded. */ diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index bc5250888..ebdf51be1 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -44,3 +44,46 @@ describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { ); }); }); + +describe("InvoiceDocumentService.buildThermalHtml", () => { + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); + + it("renders no seal markup at all — dropped for thermal, not shrunk", () => { + const html = service.buildThermalHtml(model()); + expect(html).not.toContain('class="seal"'); + expect(html).not.toContain("seal-image"); + }); + + it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => { + const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" })); + expect(html).toContain('class="qr"'); + expect(html).toContain('src="data:image/png;base64,QR"'); + expect(html).not.toContain("position: absolute"); + }); + + it("wraps a long IRN summary value rather than truncating it", () => { + const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; + const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] })); + expect(html).toContain(irn); + expect(html).toContain("overflow-wrap: anywhere"); + }); + + it("renders a line item as stacked description + qty x rate = amount, not a table row", () => { + const html = service.buildThermalHtml( + model({ + lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }], + }), + ); + expect(html).not.toContain(" { + const html = service.buildThermalHtml(model()); + expect(html).not.toContain("width: 330px"); + expect(html).not.toContain("right: 160px"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 2d5e97e92..0f8e4ee71 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -27,6 +27,27 @@ export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; */ export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`; +// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ────────────────────────────────── +// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already- +// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here. + +function esc(value: unknown): string { + return String(value ?? "-") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function money(amount: unknown, currency: string): string { + return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; +} + +function formatDate(value: unknown): string { + return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; +} + /** One billed line on the document (charge type / fee type agnostic). */ export interface InvoiceDocumentLine { description: string | null; @@ -129,6 +150,117 @@ export class InvoiceDocumentService { }; } + /** + * 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within + * `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since + * thermal print mechanisms have a dead zone at the roll edge they can't reach either way. + * + * A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is + * absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`, + * `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width. + * No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS + * thermal receipt carries one, and thermal heads render rotated circles badly) and line items + * are stacked (description, then `qty x rate = amount` below it) rather than a table — a real + * multi-column table leaves ~10-14 chars for description at this width, truncating almost every + * line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`. + */ + async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> { + const logoImageUrl = + model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl(); + // Seal deliberately dropped — never fetched, so no stampSettings call either. + const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null }; + + const html = this.buildThermalHtml(resolvedModel); + return { + filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`, + buffer: await this.pdf.htmlToPdfBuffer(html, { + label: `${model.title} thermal invoice`, + thermal: true, + // A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal + // printer output" — fail loudly instead; the caller has the A4 download to fall back to. + noFallback: true, + }), + }; + } + + buildThermalHtml(model: InvoiceDocumentModel): string { + const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`; + const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo"); + + const summaryRows = model.summary + .map( + (row) => + `
${esc(row.label)}${esc(row.value)}
`, + ) + .join(""); + + const itemBlocks = model.lines + .map((item) => { + const currency = item.currency ?? model.currency; + return `
+
${esc(item.description)}
+
${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = ${esc(money(item.amount, currency))}
+
`; + }) + .join(""); + + const totalRows = model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, + ) + .join(""); + + const qrMarkup = model.qrImageUrl + ? `
EIMS verification QR
Scan to verify (MoR EIMS)
` + : ""; + + return ` + + + + ${esc(heading)} + + + +
+ ${logoInner} +
Ethio-Djibouti Railway S.C.
+
${esc(heading)}
+
${esc(model.documentNumber)} · ${esc(formatDate(model.issuedAt))}
+
+ ${summaryRows} +
+ ${itemBlocks} +
+ ${totalRows} + ${qrMarkup} + +
+ +`; + } + /** * Vector-drawn styled invoice/receipt used when headless Chromium is * unavailable. Mirrors the HTML layout closely enough to pass as the same @@ -250,18 +382,7 @@ export class InvoiceDocumentService { } buildHtml(model: InvoiceDocumentModel): string { - const esc = (value: unknown) => - String(value ?? "-") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - const money = (amount: unknown, currency = model.currency) => - `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; - const date = (value: unknown) => - value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; - + const date = formatDate; const showCategory = Boolean(model.categoryHeader); const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); @@ -292,7 +413,7 @@ export class InvoiceDocumentService { const totalRows = model.totals .map( (total) => - `
${esc(total.label)}${esc(money(total.amount))}
`, + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, ) .join(""); diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index e8c3de792..21676c160 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = ` } `; +/** + * Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream + * ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll + * it physically can't reach, so the page itself must stay 80mm (matching the roll the printer + * driver expects) with the safe area carved out by margin, not by shrinking the page. + */ +const THERMAL_PAGE_WIDTH_MM = 80; +const THERMAL_MARGIN_MM = 4; +/** Extra length past the measured content, so the cut isn't flush against the last line. */ +const THERMAL_FEED_MM = 6; +/** Guard against a runaway line-item list producing an absurd page. */ +const THERMAL_MAX_HEIGHT_MM = 1500; + export interface PdfRenderOptions { /** Label used in logs to identify the document kind. */ label?: string; /** Landscape A4 instead of the default portrait — wide tables need it. */ landscape?: boolean; + /** + * Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is + * measured and the page height grows to fit it, rather than a fixed page with the format's + * `format: "A4"`. + */ + thermal?: boolean; + /** + * Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a + * generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer + * output" (it silently hands back a different document shape than what was asked for); the + * caller has an existing A4 download to point the user at instead. Ignored when `fallback` is + * also supplied — an explicit fallback always wins. + */ + noFallback?: boolean; /** * Degraded renderer used when Chromium is unavailable. Receives the * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` - * header). When omitted, a generic single-page fallback is produced. + * header). When omitted (and `noFallback` is not set), a generic single-page fallback is + * produced. */ fallback?: (preparedHtml: string) => Buffer; } @@ -54,17 +82,31 @@ export class PdfRenderService { const browser = await puppeteer.default.launch(launchOptions); try { const page = await browser.newPage(); - await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + const thermal = opts.thermal ?? false; + const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794; + await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 }); await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); await page.emulateMediaType("print"); await new Promise((resolve) => setTimeout(resolve, 250)); - const pdf = await page.pdf({ - format: "A4", - landscape: opts.landscape ?? false, - printBackground: true, - margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, - }); + const pdf = thermal + ? await page.pdf({ + width: `${THERMAL_PAGE_WIDTH_MM}mm`, + height: `${await this.thermalContentHeightMm(page)}mm`, + printBackground: true, + margin: { + top: `${THERMAL_MARGIN_MM}mm`, + bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`, + left: `${THERMAL_MARGIN_MM}mm`, + right: `${THERMAL_MARGIN_MM}mm`, + }, + }) + : await page.pdf({ + format: "A4", + landscape: opts.landscape ?? false, + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); const buffer = Buffer.from(pdf); if (!this.isValidPdf(buffer)) { @@ -79,6 +121,15 @@ export class PdfRenderService { } } catch (error) { this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + if (!opts.fallback && opts.noFallback) { + // A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal + // printer output" — it silently hands back a different document than what was asked for. + // Fail loudly instead; the caller already has a working A4 download to fall back to. + throw new InternalServerErrorException( + `${label} could not be generated — thermal rendering requires Chromium. ` + + "Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.", + ); + } const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); if (this.isValidPdf(fallback)) { this.logger.warn( @@ -92,6 +143,20 @@ export class PdfRenderService { } } + /** + * Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered + * content's actual height and adds feed clearance, so the PDF page is exactly as long as the + * receipt, not a fixed A4-length page with blank space at the bottom. + */ + private async thermalContentHeightMm(page: import("puppeteer").Page): Promise { + // String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document` + // isn't a known global to type-check against — the string is evaluated in the page's own + // browser context regardless, same as the closure form would be. + const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number; + const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM; + return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm); + } + private injectPdfPrintStyles(html: string): string { if (html.includes("edr-pdf-print-fix")) return html; if (html.includes("")) { diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index ef7f9d086..18161d960 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -8,16 +8,18 @@ import { Grid, Group, Loader, + Menu, SimpleGrid, Stack, Table, Text, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft, Building2, Download, FileText } from "lucide-react"; +import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { EimsFilingCard } from "@/components/invoices/EimsFilingCard"; +import { useToast } from "@/hooks/use-toast"; import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; @@ -145,6 +147,7 @@ export default function InvoiceDetailPage() { const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { toast } = useToast(); const [downloading, setDownloading] = useState(false); const { data: invoice, isLoading } = useQuery( @@ -154,12 +157,27 @@ export default function InvoiceDetailPage() { }), ); - const downloadDocument = async () => { + const downloadDocument = async (format?: "a4" | "thermal") => { if (!id) return; setDownloading(true); try { - const { data } = await invoicesService.downloadDocument(id); - openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`); + const { data } = await invoicesService.downloadDocument(id, format); + const suffix = format === "thermal" ? "-thermal" : ""; + openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}${suffix}.pdf`); + } catch (error) { + // Thermal rendering deliberately fails loudly rather than silently returning an A4-shaped, + // QR-less document (see PdfRenderService's `noFallback`) — surface that here rather than + // let it become a silent unhandled rejection with just a spinner stopping. + toast({ + title: format === "thermal" ? "Could not generate the thermal invoice" : "Could not download the invoice", + description: + format === "thermal" + ? "Thermal rendering requires Chromium on the server. The A4 PDF is still available." + : error instanceof Error + ? error.message + : undefined, + variant: "destructive", + }); } finally { setDownloading(false); } @@ -202,17 +220,34 @@ export default function InvoiceDetailPage() { subtitle={humanize(invoice.source)} meta={} action={ - void downloadDocument()} - > - - + + + + + + + + } + onClick={() => void downloadDocument("a4")} + > + Download PDF (A4) + + } + onClick={() => void downloadDocument("thermal")} + > + Download thermal invoice (80mm) + + + } /> diff --git a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts index 145c0be4a..8c45bf9a3 100644 --- a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts @@ -29,9 +29,11 @@ export const invoicesService = { .then((r) => r.data); }, - downloadDocument(id: string) { + /** `format` omitted or "a4" → standard A4 PDF; "thermal" → 80mm thermal layout (ADD-P001). */ + downloadDocument(id: string, format?: "a4" | "thermal") { return apiClient.get(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), { responseType: "blob", + params: format ? { format } : undefined, }); },