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

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