import { Injectable } from "@nestjs/common"; import { PdfRenderService } from "./pdf-render.service"; import { PdfColor, assembleSinglePagePdf, lineOp, rectOp, sealOp, textOp, textOpRight, wrapText, } from "./styled-pdf.util"; export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; /** 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; } /** * 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) {} async render( model: InvoiceDocumentModel, ): Promise<{ filename: string; buffer: Buffer }> { const html = this.buildHtml(model); 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. fallback: () => this.buildFallbackPdf(model), }), }; } /** * Vector-drawn styled invoice/receipt used when headless Chromium is * unavailable. Mirrors the HTML layout closely enough to pass as the same * document. Single A4 page; long summaries / line lists are capped to fit. */ buildFallbackPdf(model: InvoiceDocumentModel): Buffer { const currency = (cur?: string | null) => (cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency); const money = (amount: unknown, cur?: string | null) => `${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`; const date = (value: unknown) => value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`; const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); const showCategory = Boolean(model.categoryHeader); const ops: string[] = []; // ── Header ──────────────────────────────────────────────────────────── ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4)); ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray)); const titleSize = heading.length > 34 ? 18 : 22; ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark)); ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray)); ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark)); ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray)); ops.push( textOpRight( `Status ${model.status}`, 559, 748, 8.5, "F1", model.status === "PAID" ? PdfColor.teal : PdfColor.gray, ), ); ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1)); // ── Seal ────────────────────────────────────────────────────────────── ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal)); // ── Summary grid (two columns) ──────────────────────────────────────── let y = 700; const colX = [36, 300]; const colW = 250; model.summary.slice(0, 16).forEach((row, i) => { const x = colX[i % 2]; if (i % 2 === 0 && i > 0) y -= 27; ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray)); ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark)); ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6)); }); y -= 34; // ── Line-item table ─────────────────────────────────────────────────── const qtyR = 402; const rateR = 486; const amtR = 555; ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7)); ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray)); if (showCategory) { ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray)); } ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray)); ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray)); ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray)); y -= 18; const descChars = showCategory ? 44 : 66; for (const item of model.lines) { if (y < 190) break; // leave room for totals + footer const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2); const rowH = Math.max(18, descLines.length * 10 + 8); ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6)); descLines.forEach((line, k) => { ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark)); }); if (showCategory) { ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark)); } ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark)); ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark)); ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark)); y -= rowH; } // ── Totals ──────────────────────────────────────────────────────────── let ty = y - 16; for (const total of model.totals) { if (ty < 88) break; if (total.grand) { ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9)); ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark)); ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark)); ty -= 24; } else { ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray)); ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark)); ty -= 17; } } // ── Footer ──────────────────────────────────────────────────────────── ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8)); ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray)); ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8)); ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray)); return assembleSinglePagePdf(ops); } /** Truncate to `max` chars with an ellipsis. */ private clip(value: string, max: number): string { const text = String(value ?? ""); return text.length > max ? `${text.slice(0, max - 3)}...` : text; } 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 showCategory = Boolean(model.categoryHeader); const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); const summaryRows = model.summary .map((row) => `
| Description | ${showCategory ? `${esc(model.categoryHeader)} | ` : ""}Qty | Rate | Amount |
|---|