feat(billing): 80mm thermal invoice layout (ADD-P001)

GET billing/invoices/:id/document?format=thermal renders a dedicated 80mm
receipt template (72mm printable, 4mm margins each side), not a CSS variant
of the A4 layout — the A4 CSS is absolutely-positioned/fixed-px, tuned for a
210mm page, and doesn't reflow at thermal width. No seal (not a thermal
convention, renders badly on 1-bit thermal heads); line items stack
(description, then qty x rate = amount) instead of a table, since a real
table leaves ~10-14 chars for description at this width.

PdfRenderService gains a thermal render path: full 80mm-width viewport,
content height measured via page.evaluate after settle (continuous-roll
receipts have no fixed page length), and a noFallback option — a Chromium
failure throws a clear error instead of silently degrading to the generic
A4/no-QR fallback, which would hand back a different document than what was
asked for. The frontend surfaces that as a toast pointing at the existing A4
download.

format is strictly validated (a4|thermal only, BadRequestException
otherwise), not silently coerced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-15 08:05:58 +00:00
parent 47c2e82839
commit d40c340e1c
8 changed files with 353 additions and 47 deletions

View File

@@ -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);
}

View File

@@ -927,6 +927,7 @@ describe("BillingService.document", () => {
const build = (invoice: Record<string, unknown>) => {
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();
});
});

View File

@@ -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. */

View File

@@ -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("<table");
expect(html).not.toContain("<td");
expect(html).toContain("40ft container rail freight");
expect(html).toContain("12 x");
expect(html).toContain("2,948,207.4 Birr (ETB)");
});
it("uses fluid, full-width layout — no fixed-px A4 geometry", () => {
const html = service.buildThermalHtml(model());
expect(html).not.toContain("width: 330px");
expect(html).not.toContain("right: 160px");
});
});

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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) =>
`<div class="row"><span class="label">${esc(row.label)}</span><span class="value">${esc(row.value)}</span></div>`,
)
.join("");
const itemBlocks = model.lines
.map((item) => {
const currency = item.currency ?? model.currency;
return `<div class="item">
<div class="item-desc">${esc(item.description)}</div>
<div class="item-calc">${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = <strong>${esc(money(item.amount, currency))}</strong></div>
</div>`;
})
.join("");
const totalRows = model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
)
.join("");
const qrMarkup = model.qrImageUrl
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
: "";
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(heading)}</title>
<style>
body { font-family: Arial, sans-serif; font-size: 9px; color: #0f172a; margin: 0; }
.doc { width: 100%; box-sizing: border-box; }
.thermal-logo { display: block; max-height: 28px; max-width: 100%; object-fit: contain; margin: 0 auto 4px; }
.brand { text-align: center; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; }
.title { text-align: center; font-size: 13px; font-weight: 800; margin: 2px 0; }
.meta { text-align: center; font-size: 8px; color: #475569; margin-bottom: 4px; }
.rule { border-top: 1px dashed #334155; margin: 6px 0; }
.row { display: flex; justify-content: space-between; gap: 6px; font-family: monospace; font-size: 8.5px; padding: 1px 0; }
.row .label { color: #64748b; white-space: nowrap; }
.row .value { text-align: right; overflow-wrap: anywhere; }
.item { margin: 4px 0; }
.item-desc { font-size: 9px; overflow-wrap: anywhere; }
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
.qr { text-align: center; margin: 8px 0; }
.qr img { width: 150px; height: 150px; }
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
.footer { text-align: center; font-size: 7px; color: #94a3b8; margin-top: 8px; }
</style>
</head>
<body>
<div class="doc">
${logoInner}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<div class="title">${esc(heading)}</div>
<div class="meta">${esc(model.documentNumber)} &middot; ${esc(formatDate(model.issuedAt))}</div>
<div class="rule"></div>
${summaryRows}
<div class="rule"></div>
${itemBlocks}
<div class="rule"></div>
${totalRows}
${qrMarkup}
<div class="footer">Thank you</div>
</div>
</body>
</html>`;
}
/**
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
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) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
)
.join("");

View File

@@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = `
}
</style>`;
/**
* 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<number> {
// 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("</head>")) {

View File

@@ -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={<InvoiceStatusBadge status={invoice.status} />}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Download invoice"
disabled={!canExport}
loading={downloading}
onClick={() => void downloadDocument()}
>
<Download size={16} />
</ActionIcon>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Download invoice"
disabled={!canExport}
loading={downloading}
>
<Download size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Download size={14} />}
onClick={() => void downloadDocument("a4")}
>
Download PDF (A4)
</Menu.Item>
<Menu.Item
leftSection={<Printer size={14} />}
onClick={() => void downloadDocument("thermal")}
>
Download thermal invoice (80mm)
</Menu.Item>
</Menu.Dropdown>
</Menu>
}
/>

View File

@@ -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<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
responseType: "blob",
params: format ? { format } : undefined,
});
},