mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 19:03:40 +00:00
290 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|