Files
edr-platform/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts

290 lines
10 KiB
TypeScript

import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } 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");
});
});
describe("sameCompanyName", () => {
it("treats eTrade's legal-suffix spellings as the same name", () => {
expect(sameCompanyName("ABIJOEL PLC", "ABIJOEL P L C")).toBe(true);
expect(
sameCompanyName(
"WISH TRADING PLC",
"WISH TRADING PRIVATE LIMITED COMPANY",
),
).toBe(true);
expect(
sameCompanyName("TUTA TRADING PLC", "TUTA TRADING ONE MEMBER PLC"),
).toBe(true);
});
it("keeps a genuinely different trade name distinct", () => {
// Real pairs from eTrade: the licence trades under a different name than
// the company registered under, which is exactly the row worth printing.
expect(
sameCompanyName("Cozy Coffee Grower and Exporter", "ABIJOEL P L C"),
).toBe(false);
expect(sameCompanyName("MENNA PRODUCTION", "ICOFFEE TRADING PLC")).toBe(
false,
);
expect(
sameCompanyName("YUNABEK TRADING PLC", "YUNABEK INVESTMENT PLC"),
).toBe(false);
});
it("is false when either side is missing, so no row is printed", () => {
expect(sameCompanyName("", "ABIJOEL P L C")).toBe(false);
expect(sameCompanyName(null, null)).toBe(false);
expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false);
});
});
describe("InvoiceDocumentService.buildHtml — MoR tax-document layout (ADD-P001)", () => {
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
const mor = (over: Partial<NonNullable<InvoiceDocumentModel["mor"]>> = {}) =>
({
titleAm: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ",
titleEn: "Credit sales invoice / VAT / Excise Tax",
saleType: "B2B",
irn: "IRN-123",
systemNumber: "2B6E48BB75",
seller: { name: "Ethio-Djibouti Railway SC", tin: "0053481357" },
buyer: { name: "Afri Software Solutions", tin: "0089238373" },
tax: {
total: 904008.15,
discount: 0,
taxableTotal: 0,
excise: 0,
vatTaxableAmount: 0,
vatLabel: "VATEX ታክስ / VATEX Tax rate (N/A%)",
vatAmount: 0,
incomeWithholding: 0,
vatWithholding: 0,
totalIncludingTax: 904008.15,
amountInWords: "Nine hundred and four thousand and eight Birr and fifteen Cents",
},
payment: { mode: "CREDIT", typeMethod: "IMMIDIATE", receiverName: "Afri Software Solutions" },
...over,
}) as NonNullable<InvoiceDocumentModel["mor"]>;
it("switches layout only when the mor block is present", () => {
expect(service.buildHtml(model())).not.toContain("Total including Tax");
expect(service.buildHtml(model({ mor: mor() }))).toContain("Total including Tax");
});
it("prints the bilingual title, sale type, IRN and system number", () => {
const html = service.buildHtml(model({ mor: mor() }));
expect(html).toContain("Credit sales invoice / VAT / Excise Tax");
expect(html).toContain("የዱቤ ሽያጭ ደረሰኝ");
expect(html).toContain("(B2B)");
expect(html).toContain("IRN-123");
expect(html).toContain("2B6E48BB75");
});
it("prints every totals row even when the figure is zero", () => {
const html = service.buildHtml(model({ mor: mor() }));
for (const label of [
"Discount Amount",
"Taxable Total",
"Excise Tax",
"Total VAT Taxable Amount",
"Total Withheld Amount",
"Total VAT Withheld Amount",
"Total including Tax (in words)",
]) {
expect(html).toContain(label);
}
});
it("renders amounts bare, with the currency named once in the total label", () => {
const html = service.buildHtml(model({ mor: mor() }));
expect(html).toContain("904,008.15");
expect(html).toContain("Total (ETB)");
// The generic "1 Birr (ETB)" per-cell format must not leak into the tax layout.
expect(html).not.toContain("904,008.15 Birr (ETB)");
});
it("carries the MoR item columns", () => {
const html = service.buildHtml(
model({
mor: mor(),
lines: [
{
description: "Container Import",
quantity: 3,
unitRate: 5223,
amount: 15670,
nature: "service",
uom: "PCS",
taxCode: "VATEX",
excise: 0,
discount: 0,
},
],
}),
);
expect(html).toContain("Tax Code");
expect(html).toContain("VATEX");
expect(html).toContain("service");
expect(html).toContain("PCS");
});
it("shows the related document and approval block on a credit/debit note", () => {
const html = service.buildHtml(
model({
mor: mor({
titleEn: "Tax Credit Note",
relatedDocumentIrn: "ORIGINAL-IRN",
approval: { requestedBy: "biruk", checkedBy: "ermias", approvedBy: "kassahun" },
}),
}),
);
expect(html).toContain("Related Document");
expect(html).toContain("ORIGINAL-IRN");
expect(html).toContain("INVOICE AMENDMENT AUTHORIZATION");
expect(html).toContain("kassahun");
});
it("renders the sales receipt's linked-invoice table", () => {
const html = service.buildHtml(
model({
mor: mor({
titleEn: "Cash Receipt Voucher",
receipt: {
rrn: "RRN-9",
reason: "Payment for goods purchased",
collectedAmount: 950,
invoices: [
{
irn: "INV-IRN-1",
paymentCoverage: "PARTIAL",
totalAmount: 1200,
remainingAmount: 250,
paidAmount: 950,
},
],
},
}),
}),
);
expect(html).toContain("RRN-9");
expect(html).toContain("Payment Coverage");
expect(html).toContain("PARTIAL");
expect(html).toContain("Remaining Amount");
});
it("renders the withholding receipt without an item table", () => {
const html = service.buildHtml(
model({
mor: mor({
titleEn: "Withholding tax on payment",
tax: null,
withholding: {
receiptNumber: "WH-26-574705075",
counter: "574705075",
reason: "Tax Withholding",
type: "TWTH",
invoiceCurrency: "ETB",
preTaxAmount: 8640000,
withheldAmount: 259200,
systemType: "MAN",
systemNumber: "2B6E48BB75",
},
}),
lines: [{ description: "ignored", amount: 1 }],
}),
);
expect(html).toContain("WH-26-574705075");
expect(html).toContain("TWTH");
expect(html).toContain("Pre Tax Amount");
expect(html).toContain("259,200.00");
// A withholding receipt has no billed items — the item table must be suppressed entirely.
expect(html).not.toContain("Unit Price");
});
});