mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
New logo-settings module (mirrors stamp-settings): single uploaded logo, stored via FilesService/MinIO, injected as a data URL into invoice/receipt, contract, warehouse, train-scheduling, and payment-receipt PDFs. Adds a matching backoffice settings page and settings:logo:view/manage permissions. >
376 lines
17 KiB
TypeScript
376 lines
17 KiB
TypeScript
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";
|
|
|
|
/** 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),
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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, """)
|
|
.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 sealInner = sealMarkup(model.stampImageUrl, sealText);
|
|
const sealCssClass = sealClass(model.stampImageUrl);
|
|
const logoInner = logoMarkup(model.logoImageUrl);
|
|
|
|
const qrMarkup = model.qrImageUrl
|
|
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
|
|
: "";
|
|
|
|
const summaryRows = model.summary
|
|
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
|
.join("");
|
|
|
|
const itemRows = model.lines
|
|
.map(
|
|
(item) => `<tr>
|
|
<td>${esc(item.description)}</td>
|
|
${showCategory ? `<td>${esc((item.category ?? "").replace(/_/g, " "))}</td>` : ""}
|
|
<td class="num">${esc(item.quantity ?? 0)}</td>
|
|
<td class="num">${esc(money(item.unitRate, item.currency ?? model.currency))}</td>
|
|
<td class="num">${esc(money(item.amount, item.currency ?? model.currency))}</td>
|
|
</tr>`,
|
|
)
|
|
.join("");
|
|
|
|
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>`,
|
|
)
|
|
.join("");
|
|
|
|
return `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
|
.doc { padding: 16px 8px; position: relative; }
|
|
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
|
|
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
|
h1 { margin: 8px 0 0; font-size: 30px; }
|
|
.meta { text-align: right; font-size: 12px; color: #475569; }
|
|
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
|
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
|
${sealImageCss()}
|
|
${logoImageCss()}
|
|
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
|
|
.qr img { width: 90px; height: 90px; }
|
|
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
|
|
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
|
/* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the
|
|
150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */
|
|
.summary.summary-with-qr { margin-right: 270px; }
|
|
/* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long
|
|
unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than
|
|
honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay
|
|
(confirmed by isolating the two: margin-right alone already positioned the box correctly;
|
|
the text itself was still escaping the box's own right edge until this was added). */
|
|
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; }
|
|
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
|
th { text-align: left; background: #f8fafc; color: #475569; }
|
|
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
|
|
td.num, th.num { text-align: right; }
|
|
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
|
|
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
|
|
.grand { font-size: 16px; font-weight: 800; }
|
|
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
|
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="doc">
|
|
<div class="top">
|
|
<div>
|
|
${logoInner}
|
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
|
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
|
|
</div>
|
|
<div class="meta">
|
|
Document no.
|
|
<strong>${esc(model.documentNumber)}</strong>
|
|
Issued: ${esc(date(model.issuedAt))}
|
|
</div>
|
|
</div>
|
|
<div class="${sealCssClass}">${sealInner}</div>
|
|
${qrMarkup}
|
|
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Description</th>
|
|
${showCategory ? `<th>${esc(model.categoryHeader)}</th>` : ""}
|
|
<th class="num">Qty</th>
|
|
<th class="num">Rate</th>
|
|
<th class="num">Amount</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${itemRows}
|
|
</tbody>
|
|
</table>
|
|
<div class="totals">${totalRows}</div>
|
|
<div class="footer">
|
|
<div class="line">Prepared by EDR finance</div>
|
|
<div class="line">Authorized seal / signature</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
safeFilename(value: string): string {
|
|
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
}
|
|
}
|