import { Injectable } from "@nestjs/common"; import { StampSettingsService } from "../../stamp-settings/stamp-settings.service"; import { LogoSettingsService } from "../../logo-settings/logo-settings.service"; import { PdfRenderService } from "./pdf-render.service"; import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; import { logoImageCss, logoMarkup } from "./logo-markup.util"; import { PdfColor, assembleSinglePagePdf, lineOp, rectOp, sealOp, textOp, textOpRight, wrapText, } from "./styled-pdf.util"; export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; /** * MoR returns `signedQR`/`qr` as a base64 PNG already rendered server-side — confirmed against the * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), not a * payload we encode ourselves. Wrap, don't encode. Shared by `Invoice.eimsSignedQr` * (`BillingService`) and `EimsReceipt.qr` (`eims-receipt-document.mapper.ts`) — same convention, * same gateway. */ 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; /** Optional categorisation column (e.g. "Fee type" / "Charge type"). */ category?: string | null; quantity?: number | null; unitRate?: number | null; amount?: number | null; currency?: string | null; } /** A labelled total row in the totals box; mark `grand` for the headline total. */ export interface InvoiceDocumentTotal { label: string; amount: number; grand?: boolean; } /** * Source-agnostic description of a printable invoice/receipt. Each billing * source maps its own entity onto this shape; the renderer owns the layout so * every EDR invoice document looks identical regardless of source. */ export interface InvoiceDocumentModel { kind: InvoiceDocumentKind; /** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */ title: string; documentNumber: string; issuedAt?: Date | string | null; status: string; currency: string; /** Free-form summary grid (label/value pairs). */ summary: Array<{ label: string; value: string | null }>; /** Header for the line-item category column; column hidden when omitted. */ categoryHeader?: string; lines: InvoiceDocumentLine[]; totals: InvoiceDocumentTotal[]; /** Override the round seal text; defaults from kind/status. */ sealText?: string; /** * Company stamp image (data URL) to render instead of the plain text seal. * Callers normally leave this unset — `InvoiceDocumentService.render()` * fills it in from the single global stamp in StampSettingsService; set it * explicitly only to override that default for one document. */ stampImageUrl?: string | null; logoImageUrl?: string | null; /** * MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` — * see that column's comment). Set only once an invoice is actually registered; the IRN text * itself goes through the ordinary `summary` rows, not a dedicated field. */ qrImageUrl?: string | null; } /** * Central invoice/receipt PDF renderer shared by every billing source. Turns a * {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it * via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in * `WarehouseInvoiceService`; it now serves all invoices. */ @Injectable() export class InvoiceDocumentService { constructor( private readonly pdf: PdfRenderService, private readonly stampSettings: StampSettingsService, private readonly logoSettings: LogoSettingsService, ) {} async render( model: InvoiceDocumentModel, ): Promise<{ filename: string; buffer: Buffer }> { const stampImageUrl = model.stampImageUrl !== undefined ? model.stampImageUrl : await this.stampSettings.getStampImageUrl(); const logoImageUrl = model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl(); const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl, logoImageUrl }; const html = this.buildHtml(resolvedModel); const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; return { filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}`, // Chromium-less fallback: draw a genuine styled invoice (header, seal, // summary grid, line-item table, totals) from the model — not a flat // plain-text dump — so it still reads as a proper invoice document. // ponytail: still draws the plain vector seal, not the uploaded stamp // image, and omits the EIMS QR entirely — embedding a raster image // needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade // when the Chromium-less path needs to carry the real stamp/QR too; // today it's a rare degraded fallback. The IRN text itself still // comes through (buildFallbackPdf renders model.summary same as HTML). fallback: () => this.buildFallbackPdf(resolvedModel), }), }; } /** * 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) => `
| Description | ${showCategory ? `${esc(model.categoryHeader)} | ` : ""}Qty | Rate | Amount |
|---|