mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 10:52:53 +00:00
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>
90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
|
|
|
|
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
|
|
kind: "INVOICE",
|
|
title: "Freight",
|
|
documentNumber: "INV-20260812-00001",
|
|
issuedAt: new Date(2026, 7, 12),
|
|
status: "PENDING",
|
|
currency: "ETB",
|
|
summary: [{ label: "Status", value: "PENDING" }],
|
|
lines: [],
|
|
totals: [{ label: "Total", amount: 100, grand: true }],
|
|
...over,
|
|
});
|
|
|
|
describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
|
|
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
|
|
|
|
it("renders no QR block when qrImageUrl is unset", () => {
|
|
const html = service.buildHtml(model());
|
|
expect(html).not.toContain('class="qr"');
|
|
});
|
|
|
|
it("renders the QR image when qrImageUrl is set", () => {
|
|
const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
|
|
expect(html).toContain('class="qr"');
|
|
expect(html).toContain('src="data:image/png;base64,QR"');
|
|
});
|
|
|
|
it("still shows the IRN text row via the ordinary summary grid", () => {
|
|
const html = service.buildHtml(
|
|
model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }),
|
|
);
|
|
expect(html).toContain("EIMS IRN");
|
|
expect(html).toContain("IRN-123");
|
|
});
|
|
|
|
it("widens the summary's right margin only when a QR is present, to clear the QR block", () => {
|
|
// "summary-with-qr" also appears in the always-present <style> rule, so the check has to be
|
|
// the actual div's class attribute, not a bare substring match.
|
|
expect(service.buildHtml(model())).not.toContain('class="summary summary-with-qr"');
|
|
expect(service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }))).toContain(
|
|
'class="summary summary-with-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");
|
|
});
|
|
});
|