mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +00:00
Merge pull request #1470 from Tria-plc/Wrehousechanges
feat(billing): render EIMS documents in the MoR tax-document layout
This commit is contained in:
BIN
INV-20260812-00005-mor.pdf
Normal file
BIN
INV-20260812-00005-mor.pdf
Normal file
Binary file not shown.
BIN
INV-20260812-00005-thermal.pdf
Normal file
BIN
INV-20260812-00005-thermal.pdf
Normal file
Binary file not shown.
@@ -33,6 +33,8 @@ import {
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { amountInWords } from "./documents/mor-document.util";
|
||||
import { buildEimsSeller, resolveLineTax } from "../eims/eims-invoice-context";
|
||||
import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
@@ -1021,18 +1023,133 @@ export class BillingService {
|
||||
currency: invoice.currency,
|
||||
summary,
|
||||
categoryHeader: "Charge type",
|
||||
lines: invoice.lines.map((l) => ({
|
||||
description: l.description ?? l.chargeType,
|
||||
category: l.chargeType,
|
||||
quantity: l.quantity,
|
||||
unitRate: l.unitRate,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
})),
|
||||
lines: invoice.lines.map((l) => {
|
||||
// Same resolver the filing used, so the printed Tax Code / Excise / Discount columns
|
||||
// state what MoR actually holds for this line.
|
||||
const tax = eimsCfg?.invoice ? resolveLineTax(eimsCfg, l.chargeType) : null;
|
||||
return {
|
||||
description: l.description ?? l.chargeType,
|
||||
category: l.chargeType,
|
||||
quantity: l.quantity,
|
||||
unitRate: l.unitRate,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
nature: eimsCfg?.invoice?.natureOfSupplies ?? null,
|
||||
uom: eimsCfg?.invoice?.unitDefault ?? null,
|
||||
taxCode: tax?.code ?? null,
|
||||
excise: tax?.exciseTaxValue ?? null,
|
||||
discount: tax?.discount ?? null,
|
||||
};
|
||||
}),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr
|
||||
? pngDataUrl(invoice.eimsSignedQr)
|
||||
: null,
|
||||
mor: eimsCfg?.invoice ? this.buildMorDetails(invoice, eimsCfg) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The MoR tax-document view of an invoice (ADD-P001) — the bilingual layout a customer also sees
|
||||
* when they scan the QR on the Ministry's portal.
|
||||
*
|
||||
* Built from the invoice plus EIMS configuration alone, never from a live EIMS call: a document
|
||||
* has to print whether or not it is registered yet, and printing must not depend on the gateway
|
||||
* being up. Per-line tax comes from `resolveLineTax`, the same resolver that decided what was
|
||||
* actually filed, so the paper and the filing cannot disagree.
|
||||
*/
|
||||
private buildMorDetails(
|
||||
invoice: Invoice & { lines: InvoiceLine[] },
|
||||
cfg: EimsConfig,
|
||||
): InvoiceDocumentModel["mor"] {
|
||||
const seller = buildEimsSeller(cfg);
|
||||
const company = invoice.company;
|
||||
const documentType = (invoice.eimsDocumentType as "INV" | "DEB" | "CRE" | undefined) ?? "INV";
|
||||
// CREDIT until the money is in: the title states the sale's payment nature, not its status.
|
||||
const isCash = Number(invoice.paidAmount) >= Number(invoice.totalAmount);
|
||||
|
||||
const TITLES: Record<string, { am: string; en: string }> = {
|
||||
INV: isCash
|
||||
? { am: "የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Cash sales invoice / VAT / Excise Tax" }
|
||||
: { am: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Credit sales invoice / VAT / Excise Tax" },
|
||||
CRE: { am: "የታክስ ክሬዲት ሰነድ", en: "Tax Credit Note" },
|
||||
DEB: { am: "የታክስ ዴቢት ሰነድ", en: "Tax Debit Note" },
|
||||
};
|
||||
|
||||
let total = 0;
|
||||
let excise = 0;
|
||||
let discount = 0;
|
||||
let vatAmount = 0;
|
||||
let vatTaxable = 0;
|
||||
for (const line of invoice.lines) {
|
||||
const tax = resolveLineTax(cfg, line.chargeType);
|
||||
const lineTotal = Number(line.amount);
|
||||
total += lineTotal;
|
||||
excise += tax.exciseTaxValue;
|
||||
discount += tax.discount;
|
||||
if (tax.ratePercent > 0) {
|
||||
vatTaxable += lineTotal;
|
||||
vatAmount += (lineTotal * tax.ratePercent) / 100;
|
||||
}
|
||||
}
|
||||
const totalIncludingTax = Number(invoice.totalAmount);
|
||||
const rate = cfg.invoice.taxRatePercent ?? 0;
|
||||
|
||||
const title = TITLES[documentType] ?? TITLES.INV;
|
||||
|
||||
return {
|
||||
titleAm: title.am,
|
||||
titleEn: title.en,
|
||||
saleType: cfg.invoice.transactionType,
|
||||
irn: invoice.eimsIrn,
|
||||
systemNumber: cfg.systemNumber || null,
|
||||
referenceNumber: invoice.eimsDocumentNumber ?? null,
|
||||
relatedDocumentIrn: invoice.relatedInvoice?.eimsIrn ?? null,
|
||||
seller: {
|
||||
name: cfg.invoice.sellerLegalName || seller.LegalName,
|
||||
city: seller.City,
|
||||
subCity: seller.SubCity,
|
||||
woreda: seller.Wereda,
|
||||
kebele: seller.Locality,
|
||||
houseNo: seller.HouseNumber,
|
||||
tin: seller.Tin,
|
||||
vatNumber: seller.VatNumber,
|
||||
},
|
||||
buyer: {
|
||||
name: company?.name ?? "N/A",
|
||||
city: company?.zone ?? null,
|
||||
subCity: company?.zone ?? null,
|
||||
woreda: company?.woreda ?? null,
|
||||
kebele: company?.kebele ?? null,
|
||||
houseNo: company?.houseNo ?? null,
|
||||
tin: company?.tin ?? null,
|
||||
vatNumber: company?.vatNumber ?? null,
|
||||
},
|
||||
tax: {
|
||||
total: round2(total),
|
||||
discount: round2(discount),
|
||||
taxableTotal: round2(vatTaxable),
|
||||
excise: round2(excise),
|
||||
vatTaxableAmount: round2(vatTaxable),
|
||||
// An exempt seller still prints the row, labelled the way the Ministry's portal labels it.
|
||||
vatLabel: rate > 0 ? `ተ.እ.ታ / VAT ${rate}%` : `${cfg.invoice.taxCode} ታክስ / ${cfg.invoice.taxCode} Tax rate (N/A%)`,
|
||||
vatAmount: round2(vatAmount),
|
||||
incomeWithholding: cfg.invoice.incomeWithholdValue ?? 0,
|
||||
vatWithholding: cfg.invoice.transactionWithholdValue ?? 0,
|
||||
totalIncludingTax: round2(totalIncludingTax),
|
||||
amountInWords: amountInWords(totalIncludingTax),
|
||||
},
|
||||
payment: {
|
||||
mode: isCash ? "CASH" : "CREDIT",
|
||||
typeMethod: cfg.invoice.paymentTerm,
|
||||
receiverName: company?.name ?? null,
|
||||
},
|
||||
// A memo is an amendment to a filed document; MoR's layout carries the sign-off that
|
||||
// authorised it. Names come from the recorded reason until an approval chain exists.
|
||||
approval:
|
||||
documentType === "INV"
|
||||
? null
|
||||
: { requestedBy: invoice.eimsReason ?? null, checkedBy: null, approvedBy: null },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -122,3 +122,168 @@ describe("sameCompanyName", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,11 @@ 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 {
|
||||
formatDocumentTime,
|
||||
formatEthiopianDate,
|
||||
formatGregorianDate,
|
||||
} from "./mor-document.util";
|
||||
import {
|
||||
PdfColor,
|
||||
assembleSinglePagePdf,
|
||||
@@ -44,6 +49,21 @@ function money(amount: unknown, currency: string): string {
|
||||
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare fixed-2 amount for the MoR tax layout — `1,304,228.00`, no currency suffix.
|
||||
*
|
||||
* The Ministry's own documents name the currency once, in the `ድምር (ETB) / Total (ETB)` label, and
|
||||
* keep every figure a plain right-aligned number. Repeating "Birr (ETB)" in each cell (what the
|
||||
* generic `money` helper does) both breaks that column alignment and reads as a different
|
||||
* document from the one the customer sees when they scan the QR.
|
||||
*/
|
||||
function amount2(value: unknown): string {
|
||||
return Number(value ?? 0).toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(value: unknown): string {
|
||||
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
}
|
||||
@@ -85,6 +105,115 @@ export interface InvoiceDocumentLine {
|
||||
unitRate?: number | null;
|
||||
amount?: number | null;
|
||||
currency?: string | null;
|
||||
/**
|
||||
* MoR tax-document columns (ADD-P001). Populated only for documents that carry a
|
||||
* {@link MorDocumentDetails}; the generic EDR layout ignores them.
|
||||
*/
|
||||
nature?: string | null;
|
||||
uom?: string | null;
|
||||
taxCode?: string | null;
|
||||
excise?: number | null;
|
||||
discount?: number | null;
|
||||
}
|
||||
|
||||
/** One party block (`ከ / From`, `ለ / To`) of a MoR tax document. */
|
||||
export interface MorPartyDetails {
|
||||
name: string;
|
||||
city?: string | null;
|
||||
/** `ዞን / ክ/ከተማ` — Zone/Sub city. */
|
||||
subCity?: string | null;
|
||||
woreda?: string | null;
|
||||
kebele?: string | null;
|
||||
houseNo?: string | null;
|
||||
tin?: string | null;
|
||||
subTin?: string | null;
|
||||
vatNumber?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ministry's totals block, in its printed order. Every row prints even at zero — a tax
|
||||
* document states each figure explicitly rather than omitting the ones that happen to be nil.
|
||||
*/
|
||||
export interface MorTaxSummary {
|
||||
total: number;
|
||||
discount: number;
|
||||
taxableTotal: number;
|
||||
excise: number;
|
||||
vatTaxableAmount: number;
|
||||
/** e.g. `ተ.እ.ታ / VAT 15%`, or `VATEX ታክስ / VATEX Tax rate (N/A%)` for an exempt seller. */
|
||||
vatLabel: string;
|
||||
vatAmount: number;
|
||||
incomeWithholding: number;
|
||||
vatWithholding: number;
|
||||
totalIncludingTax: number;
|
||||
amountInWords: string;
|
||||
}
|
||||
|
||||
export interface MorPaymentDetails {
|
||||
/** `CASH` / `CREDIT` — also selects the document title. */
|
||||
mode: string;
|
||||
/** `IMMEDIATE` and friends. */
|
||||
typeMethod: string;
|
||||
receiverName?: string | null;
|
||||
}
|
||||
|
||||
/** Credit/debit memo authorisation block. */
|
||||
export interface MorApprovalDetails {
|
||||
requestedBy?: string | null;
|
||||
checkedBy?: string | null;
|
||||
approvedBy?: string | null;
|
||||
}
|
||||
|
||||
/** Sales receipt (CRV) specifics. */
|
||||
export interface MorReceiptDetails {
|
||||
rrn: string;
|
||||
reason: string;
|
||||
collectedAmount: number;
|
||||
invoices: Array<{
|
||||
irn: string;
|
||||
paymentCoverage: string;
|
||||
totalAmount: number;
|
||||
remainingAmount: number;
|
||||
paidAmount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Withholding receipt specifics — a different document shape, with no item table. */
|
||||
export interface MorWithholdingDetails {
|
||||
receiptNumber: string;
|
||||
counter: string;
|
||||
reason: string;
|
||||
/** MoR withholding type, e.g. `TWTH`. */
|
||||
type: string;
|
||||
invoiceCurrency: string;
|
||||
preTaxAmount: number;
|
||||
withheldAmount: number;
|
||||
systemType: string;
|
||||
systemNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the MoR (ADD-P001) print layout needs beyond the generic model. Present ⇒ the
|
||||
* document renders in the Ministry's bilingual tax-document format instead of the plain EDR one.
|
||||
*/
|
||||
export interface MorDocumentDetails {
|
||||
/** Bilingual heading, e.g. `የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ` + `Cash sales invoice / VAT / Excise Tax`. */
|
||||
titleAm: string;
|
||||
titleEn: string;
|
||||
/** `B2B` / `B2C` / `B2G`. */
|
||||
saleType?: string | null;
|
||||
irn?: string | null;
|
||||
systemNumber?: string | null;
|
||||
referenceNumber?: string | null;
|
||||
/** Original document's IRN — credit and debit notes only. */
|
||||
relatedDocumentIrn?: string | null;
|
||||
seller: MorPartyDetails;
|
||||
buyer: MorPartyDetails;
|
||||
tax?: MorTaxSummary | null;
|
||||
payment?: MorPaymentDetails | null;
|
||||
approval?: MorApprovalDetails | null;
|
||||
receipt?: MorReceiptDetails | null;
|
||||
withholding?: MorWithholdingDetails | null;
|
||||
}
|
||||
|
||||
/** A labelled total row in the totals box; mark `grand` for the headline total. */
|
||||
@@ -129,6 +258,12 @@ export interface InvoiceDocumentModel {
|
||||
* itself goes through the ordinary `summary` rows, not a dedicated field.
|
||||
*/
|
||||
qrImageUrl?: string | null;
|
||||
/**
|
||||
* Present ⇒ render the Ministry's bilingual tax-document layout (ADD-P001) rather than the
|
||||
* generic EDR one. Set for every document EIMS knows about: invoice, credit/debit note, sales
|
||||
* receipt and withholding receipt.
|
||||
*/
|
||||
mor?: MorDocumentDetails | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,12 +367,38 @@ export class InvoiceDocumentService {
|
||||
})
|
||||
.join("");
|
||||
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
// A thermal receipt is a compact derivative of the A4 tax document, not a different document:
|
||||
// the tax breakdown, the amount in words and the payment mode are the legally load-bearing
|
||||
// parts and must survive the narrower page. Only the item-table columns are dropped.
|
||||
const tax = model.mor?.tax;
|
||||
const totalRows = tax
|
||||
? [
|
||||
["Total", money(tax.total, model.currency)],
|
||||
["Discount", money(tax.discount, model.currency)],
|
||||
["Taxable Total", money(tax.taxableTotal, model.currency)],
|
||||
["Excise Tax", money(tax.excise, model.currency)],
|
||||
[tax.vatLabel, money(tax.vatAmount, model.currency)],
|
||||
["Withheld", money(tax.incomeWithholding, model.currency)],
|
||||
["VAT Withheld", money(tax.vatWithholding, model.currency)],
|
||||
]
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<div class="total-row"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`,
|
||||
)
|
||||
.join("") +
|
||||
`<div class="total-row grand"><span>Total incl. Tax</span><strong>${esc(money(tax.totalIncludingTax, model.currency))}</strong></div>` +
|
||||
`<div class="words">${esc(tax.amountInWords)}</div>`
|
||||
: model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const payMarkup = model.mor?.payment
|
||||
? `<div class="rule"></div><div class="row"><span class="label">Mode of Payment</span><span class="value">${esc(model.mor.payment.mode)}</span></div>
|
||||
<div class="row"><span class="label">Type/Method</span><span class="value">${esc(model.mor.payment.typeMethod)}</span></div>`
|
||||
: "";
|
||||
|
||||
const qrMarkup = model.qrImageUrl
|
||||
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
|
||||
@@ -264,6 +425,7 @@ export class InvoiceDocumentService {
|
||||
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
|
||||
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
|
||||
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
|
||||
.words { font-size: 8px; text-align: center; margin-top: 4px; font-style: italic; }
|
||||
.qr { text-align: center; margin: 8px 0; }
|
||||
.qr img { width: 150px; height: 150px; }
|
||||
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
|
||||
@@ -282,6 +444,7 @@ export class InvoiceDocumentService {
|
||||
${itemBlocks}
|
||||
<div class="rule"></div>
|
||||
${totalRows}
|
||||
${payMarkup}
|
||||
${qrMarkup}
|
||||
<div class="footer">Thank you</div>
|
||||
</div>
|
||||
@@ -414,6 +577,10 @@ export class InvoiceDocumentService {
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
// A MoR-registered document prints in the Ministry's own bilingual format (ADD-P001). Anything
|
||||
// else — internal fee notes, statements — keeps the plain EDR layout below.
|
||||
if (model.mor) return this.buildMorHtml(model, model.mor);
|
||||
|
||||
const date = formatDate;
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
@@ -531,7 +698,317 @@ export class InvoiceDocumentService {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR EIMS tax-document layout (ADD-P001) — invoice, credit/debit note, sales receipt and
|
||||
* withholding receipt share this one template, differing only in which optional blocks appear.
|
||||
*
|
||||
* Field labels and their order come from the Ministry's own portal rendering of a registered EDR
|
||||
* invoice, so a printout and the page a customer reaches by scanning the QR read the same way.
|
||||
* Every totals row prints even at zero: a tax document states each figure rather than hiding the
|
||||
* nil ones.
|
||||
*/
|
||||
buildMorHtml(model: InvoiceDocumentModel, mor: MorDocumentDetails): string {
|
||||
const currency = model.currency;
|
||||
const party = (p: MorPartyDetails, sideAm: string, sideEn: string, tinAm: string, tinEn: string): string => `
|
||||
<table class="party">
|
||||
<tr><th class="side"><span class="am">${esc(sideAm)}</span><span class="en">${esc(sideEn)}</span></th>
|
||||
<td class="pname">${esc(p.name)}</td></tr>
|
||||
${morRow("ከተማ", "City/Town", p.city)}
|
||||
${morRow("ዞን / ክ/ከተማ", "Zone/Sub city", p.subCity)}
|
||||
${morRow("ወረዳ", "Woreda", p.woreda)}
|
||||
${morRow("ቀበሌ", "Kebele", p.kebele)}
|
||||
${morRow("የቤ/ቁ", "H/No", p.houseNo)}
|
||||
${morRow("የግብር ከፋይ መለያ ቁጥር", `${tinEn}'s TIN`, p.tin, tinAm)}
|
||||
${morRow("ንዑስ/ቁ", "Sub-TIN", p.subTin)}
|
||||
${morRow("ተ.እ.ታ ቁጥር", `${tinEn}'s VAT`, p.vatNumber)}
|
||||
</table>`;
|
||||
|
||||
const itemRows = model.lines
|
||||
.map(
|
||||
(item, i) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td>${esc(item.description)}</td>
|
||||
<td>${esc(item.nature ?? "-")}</td>
|
||||
<td>${esc(item.uom ?? "-")}</td>
|
||||
<td class="num">${esc(item.quantity ?? 0)}</td>
|
||||
<td class="num">${esc(amount2(item.unitRate))}</td>
|
||||
<td>${esc(item.taxCode ?? "-")}</td>
|
||||
<td class="num">${esc(amount2(item.excise ?? 0))}</td>
|
||||
<td class="num">${esc(amount2(item.discount ?? 0))}</td>
|
||||
<td class="num strong">${esc(amount2(item.amount))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const tax = mor.tax;
|
||||
const taxRows = tax
|
||||
? [
|
||||
totalRow("ድምር", `Total (${currency})`, amount2(tax.total)),
|
||||
totalRow("የቅናሽ መጠን", "Discount Amount", amount2(tax.discount)),
|
||||
totalRow("ታክስ የሚከፈልበት ድምር", "Taxable Total", amount2(tax.taxableTotal)),
|
||||
totalRow("ኤክሳይዝ ታክስ", "Excise Tax", amount2(tax.excise)),
|
||||
totalRow("ተ.እ.ታ የሚከፈልበት ድምር", "Total VAT Taxable Amount", amount2(tax.vatTaxableAmount)),
|
||||
totalRow("", tax.vatLabel, amount2(tax.vatAmount)),
|
||||
totalRow("ጠቅላላ የተያዘ መጠን", "Total Withheld Amount", amount2(tax.incomeWithholding)),
|
||||
totalRow("ጠቅላላ የተያዘ መጠን ተ.እ", "Total VAT Withheld Amount", amount2(tax.vatWithholding)),
|
||||
totalRow("ጠቅላላ ዋጋ ከታክስ ጋር", "Total including Tax", amount2(tax.totalIncludingTax), true),
|
||||
].join("")
|
||||
: "";
|
||||
|
||||
const wordsRow = tax
|
||||
? `<tr class="words"><td class="wl"><span class="am">ጠቅላላ ዋጋ ከታክስ ጋር (በፊደል)</span><span class="en">Total including Tax (in words)</span></td>
|
||||
<td class="wv">${esc(tax.amountInWords)}</td></tr>`
|
||||
: "";
|
||||
|
||||
const receipt = mor.receipt;
|
||||
const receiptBlock = receipt
|
||||
? `<table class="kv">
|
||||
${morRow("የክፍያ ምክንያት", "Payment Reason", receipt.reason)}
|
||||
${morRow("የተሰበሰበ መጠን", "Collected Amount", amount2(receipt.collectedAmount))}
|
||||
</table>
|
||||
<div class="sec">የደረሰኞች ዝርዝር / Invoices</div>
|
||||
<table class="items">
|
||||
<thead><tr>
|
||||
<th>IRN</th>
|
||||
<th>${esc("የክፍያ ሽፋን / Payment Coverage")}</th>
|
||||
<th class="num">${esc("ጠቅላላ ዋጋ / Total Amount")}</th>
|
||||
<th class="num">${esc("ቀሪ / Remaining Amount")}</th>
|
||||
<th class="num">${esc("የተከፈለ / Paid Amount")}</th>
|
||||
</tr></thead>
|
||||
<tbody>${receipt.invoices
|
||||
.map(
|
||||
(inv) => `<tr>
|
||||
<td class="irn">${esc(inv.irn)}</td>
|
||||
<td>${esc(inv.paymentCoverage)}</td>
|
||||
<td class="num">${esc(amount2(inv.totalAmount))}</td>
|
||||
<td class="num">${esc(amount2(inv.remainingAmount))}</td>
|
||||
<td class="num strong">${esc(amount2(inv.paidAmount))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}</tbody>
|
||||
</table>
|
||||
<div class="paid-total">ጠቅላላ የተከፈለ መጠን / Total Paid: <strong>${esc(amount2(receipt.collectedAmount))}</strong></div>`
|
||||
: "";
|
||||
|
||||
const wh = mor.withholding;
|
||||
const withholdingBlock = wh
|
||||
? `<table class="kv">
|
||||
${morRow("የደረሰኝ ቁጥር", "Receipt #", wh.receiptNumber)}
|
||||
${morRow("ቆጣሪ", "Counter", wh.counter)}
|
||||
${morRow("ምክንያት", "Reason", wh.reason)}
|
||||
${morRow("አይነት", "Type", wh.type)}
|
||||
</table>
|
||||
<table class="items">
|
||||
<thead><tr>
|
||||
<th>${esc("የደረሰኝ ቁጥር / Invoice Doc. Number")}</th>
|
||||
<th>${esc("የገንዘብ ዓይነት / Invoice Currency")}</th>
|
||||
<th class="num">${esc("ከታክስ በፊት ያለው ዋጋ / Pre Tax Amount")}</th>
|
||||
<th class="num">${esc("ተይዞ የቀረ መጠን / Withheld Amount")}</th>
|
||||
</tr></thead>
|
||||
<tbody><tr>
|
||||
<td>${esc(wh.receiptNumber)}</td>
|
||||
<td>${esc(wh.invoiceCurrency)}</td>
|
||||
<td class="num">${esc(amount2(wh.preTaxAmount))}</td>
|
||||
<td class="num strong">${esc(amount2(wh.withheldAmount))}</td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
<div class="paid-total">በገዥ ተይዞ የቀረ መጠን / Withheld Amount: <strong>${esc(amount2(wh.withheldAmount))}</strong></div>
|
||||
<table class="kv sys">
|
||||
${morRow("የስርዓት አይነት", "System Type", wh.systemType)}
|
||||
${morRow("የስርዓት ቁጥር", "System Number", wh.systemNumber)}
|
||||
</table>`
|
||||
: "";
|
||||
|
||||
const payment = mor.payment;
|
||||
const paymentBlock = payment
|
||||
? `<table class="pay">
|
||||
<tr>
|
||||
<td><span class="am">የክፍያ ሁኔታ</span><span class="en">Mode of Payment</span><strong>${esc(payment.mode)}</strong></td>
|
||||
<td><span class="am">አይነት</span><span class="en">Type/Method</span><strong>${esc(payment.typeMethod)}</strong></td>
|
||||
<td><span class="am">የተቀባይ ስምና ፊርማ</span><span class="en">Receiver Name & Signature</span><strong>${esc(payment.receiverName ?? "")}</strong></td>
|
||||
</tr>
|
||||
</table>`
|
||||
: "";
|
||||
|
||||
const approval = mor.approval;
|
||||
const approvalBlock = approval
|
||||
? `<div class="amend">INVOICE AMENDMENT AUTHORIZATION</div>
|
||||
<div class="amend-note">This amendment has been reviewed and approved in accordance with the company's approval matrix.</div>
|
||||
<table class="pay">
|
||||
<tr>
|
||||
<td><span class="am">የጠየቀው</span><span class="en">Requested By</span><strong>${esc(approval.requestedBy ?? "")}</strong></td>
|
||||
<td><span class="am">ያረጋገጠው</span><span class="en">Checked By</span><strong>${esc(approval.checkedBy ?? "")}</strong></td>
|
||||
<td><span class="am">ያፀደቀው</span><span class="en">Approved By</span><strong>${esc(approval.approvedBy ?? "")}</strong></td>
|
||||
</tr>
|
||||
</table>`
|
||||
: "";
|
||||
|
||||
const qrBlock = model.qrImageUrl
|
||||
? `<img class="qr" src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" />`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(mor.titleEn)} ${esc(model.documentNumber)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 10mm 9mm 12mm; }
|
||||
body { font-family: "Noto Sans Ethiopic", "Abyssinica SIL", Arial, sans-serif; color: #111827; margin: 0; font-size: 9.5px; }
|
||||
.doc { position: relative; }
|
||||
.am { display: block; font-size: 8px; color: #374151; }
|
||||
.en { display: block; font-size: 8.5px; color: #6b7280; }
|
||||
/* Header ------------------------------------------------------------- */
|
||||
.hdr { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #0f766e; padding-bottom: 6px; }
|
||||
.hdr-logo { max-height: 42px; max-width: 150px; object-fit: contain; display: block; margin-bottom: 4px; }
|
||||
.org { font-size: 12px; font-weight: 700; color: #0f172a; }
|
||||
.org-sub { font-size: 8.5px; color: #4b5563; line-height: 1.45; }
|
||||
.hdr-meta { text-align: right; font-size: 8.5px; }
|
||||
.hdr-meta div { margin-bottom: 2px; }
|
||||
.hdr-meta b { display: inline-block; min-width: 92px; text-align: right; color: #111827; }
|
||||
/* Title -------------------------------------------------------------- */
|
||||
.title { text-align: center; margin: 8px 0 4px; }
|
||||
.title .t-am { font-size: 12px; font-weight: 700; }
|
||||
.title .t-en { font-size: 11.5px; font-weight: 700; text-decoration: underline; }
|
||||
.title .t-type { font-size: 9.5px; color: #4b5563; margin-top: 2px; }
|
||||
/* Identity strip ----------------------------------------------------- */
|
||||
.ident { display: flex; justify-content: space-between; gap: 10px; margin: 6px 0 8px; }
|
||||
.ident table { border-collapse: collapse; }
|
||||
.ident td { padding: 1.5px 0; vertical-align: top; font-size: 8.5px; }
|
||||
.ident td.k { color: #6b7280; padding-right: 8px; white-space: nowrap; }
|
||||
.ident td.v { font-weight: 600; word-break: break-all; max-width: 330px; }
|
||||
.qr { width: 96px; height: 96px; flex: none; }
|
||||
/* Parties ------------------------------------------------------------ */
|
||||
.parties { display: flex; gap: 8px; }
|
||||
.parties > div { flex: 1; min-width: 0; }
|
||||
table.party { width: 100%; border-collapse: collapse; border: 1px solid #9ca3af; }
|
||||
table.party th, table.party td { border: 1px solid #d1d5db; padding: 2.5px 5px; text-align: left; vertical-align: top; font-weight: normal; }
|
||||
table.party th.side { width: 42%; background: #f9fafb; }
|
||||
table.party td.pname { font-weight: 700; font-size: 10px; }
|
||||
table.party td.pv { font-weight: 600; word-break: break-all; }
|
||||
/* Items -------------------------------------------------------------- */
|
||||
.sec { margin: 8px 0 3px; font-size: 9px; font-weight: 700; color: #374151; }
|
||||
table.items { width: 100%; border-collapse: collapse; margin-top: 6px; table-layout: fixed; }
|
||||
table.items th { background: #f3f4f6; font-size: 7.5px; color: #374151; }
|
||||
table.items th, table.items td { border: 1px solid #9ca3af; padding: 3px 4px; text-align: left; word-wrap: break-word; }
|
||||
table.items td { font-size: 8.5px; }
|
||||
table.items .num { text-align: right; }
|
||||
table.items .strong { font-weight: 700; }
|
||||
table.items td.irn { font-size: 7px; word-break: break-all; }
|
||||
/* Totals ------------------------------------------------------------- */
|
||||
table.totals { width: 100%; border-collapse: collapse; margin-top: -1px; }
|
||||
table.totals td { border: 1px solid #9ca3af; padding: 3px 6px; font-size: 8.5px; }
|
||||
table.totals td.tl { text-align: right; }
|
||||
table.totals td.tv { text-align: right; width: 130px; font-weight: 600; }
|
||||
table.totals tr.grand td { font-weight: 800; font-size: 10px; background: #f9fafb; }
|
||||
table.totals tr.words td { padding: 4px 6px; }
|
||||
table.totals td.wl { width: 240px; }
|
||||
table.totals td.wv { font-weight: 700; text-align: center; }
|
||||
/* Key/value + payment ------------------------------------------------ */
|
||||
table.kv { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
||||
table.kv td { border: 1px solid #9ca3af; padding: 3px 6px; font-size: 8.5px; }
|
||||
table.kv td.k { width: 220px; background: #f9fafb; }
|
||||
table.kv td.v { font-weight: 600; }
|
||||
table.kv.sys { margin-top: 10px; }
|
||||
.paid-total { text-align: right; font-size: 9px; margin-top: 4px; }
|
||||
table.pay { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
table.pay td { border: 1px solid #9ca3af; padding: 4px 6px; width: 33.33%; }
|
||||
table.pay strong { display: block; font-size: 10px; margin-top: 2px; }
|
||||
.amend { margin-top: 12px; text-align: center; font-weight: 800; font-size: 10px; color: #b91c1c; letter-spacing: .04em; }
|
||||
.amend-note { text-align: center; font-size: 8px; color: #6b7280; }
|
||||
/* Footer ------------------------------------------------------------- */
|
||||
.foot { margin-top: 14px; border-top: 1px solid #d1d5db; padding-top: 4px; display: flex; justify-content: space-between; font-size: 7.5px; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="hdr">
|
||||
<div>
|
||||
${model.logoImageUrl ? `<img class="hdr-logo" src="${esc(model.logoImageUrl)}" alt="EDR" />` : ""}
|
||||
<div class="org">${esc(mor.seller.name)}</div>
|
||||
<div class="org-sub">Ethio-Djibouti Railway S.C.</div>
|
||||
</div>
|
||||
<div class="hdr-meta">
|
||||
<div><span class="am">የደረሰኝ ቁጥር</span><span class="en">Document No</span><b>${esc(model.documentNumber)}</b></div>
|
||||
<div><span class="am">ቀን</span><span class="en">Date</span><b>${esc(formatEthiopianDate(model.issuedAt))}</b></div>
|
||||
<div><b>${esc(formatGregorianDate(model.issuedAt))}</b></div>
|
||||
<div><span class="am">ሰአት</span><span class="en">Time</span><b>${esc(formatDocumentTime(model.issuedAt))}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="title">
|
||||
<div class="t-am">${esc(mor.titleAm)}</div>
|
||||
<div class="t-en">${esc(mor.titleEn)}</div>
|
||||
${mor.saleType ? `<div class="t-type">የሽያጭ አይነት (${esc(mor.saleType)})</div>` : ""}
|
||||
</div>
|
||||
|
||||
<div class="ident">
|
||||
<table>
|
||||
${mor.irn ? `<tr><td class="k">IRN</td><td class="v">${esc(mor.irn)}</td></tr>` : ""}
|
||||
${mor.receipt ? `<tr><td class="k">RRN</td><td class="v">${esc(mor.receipt.rrn)}</td></tr>` : ""}
|
||||
${mor.systemNumber ? `<tr><td class="k">System Number</td><td class="v">${esc(mor.systemNumber)}</td></tr>` : ""}
|
||||
${mor.referenceNumber ? `<tr><td class="k">Reference Number</td><td class="v">${esc(mor.referenceNumber)}</td></tr>` : ""}
|
||||
${mor.relatedDocumentIrn ? `<tr><td class="k">Related Document</td><td class="v">${esc(mor.relatedDocumentIrn)}</td></tr>` : ""}
|
||||
</table>
|
||||
${qrBlock}
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div>${party(mor.seller, "ከ", "From", "የሻጭ", "Seller")}</div>
|
||||
<div>${party(mor.buyer, "ለ", "To", "የገዢ", "Customer")}</div>
|
||||
</div>
|
||||
|
||||
${withholdingBlock}
|
||||
${receiptBlock}
|
||||
|
||||
${
|
||||
model.lines.length > 0 && !mor.withholding
|
||||
? `<table class="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:4%">${esc("ተ/ቁ")}<br/>No.</th>
|
||||
<th style="width:24%">${esc("የዕቃው / አገልግሎት አይነት")}<br/>Description</th>
|
||||
<th style="width:9%">${esc("ምድብ")}<br/>Nature</th>
|
||||
<th style="width:7%">${esc("መለኪያ")}<br/>UoM</th>
|
||||
<th style="width:7%" class="num">${esc("ብዛት")}<br/>Qty</th>
|
||||
<th style="width:12%" class="num">${esc("የአንዱ ዋጋ")}<br/>Unit Price</th>
|
||||
<th style="width:9%">${esc("ታክስ ኮድ")}<br/>Tax Code</th>
|
||||
<th style="width:9%" class="num">${esc("ኤክሳይዝ")}<br/>Excise</th>
|
||||
<th style="width:9%" class="num">${esc("ቅናሽ")}<br/>Discount</th>
|
||||
<th style="width:14%" class="num">${esc("ጠቅላላ ዋጋ")}<br/>Total Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${itemRows}</tbody>
|
||||
</table>`
|
||||
: ""
|
||||
}
|
||||
|
||||
${tax ? `<table class="totals">${taxRows}${wordsRow}</table>` : ""}
|
||||
${paymentBlock}
|
||||
${approvalBlock}
|
||||
|
||||
<div class="foot">
|
||||
<div>Ethio-Djibouti Railway S.C. — ${esc(mor.titleEn)}</div>
|
||||
<div>Page 1 of 1 · Printed ${esc(formatGregorianDate(new Date()))} ${esc(formatDocumentTime(new Date()))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
safeFilename(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
||||
}
|
||||
}
|
||||
|
||||
/** One bilingual label/value row inside a party or key-value table. */
|
||||
function morRow(am: string, en: string, value: unknown, amOverride?: string): string {
|
||||
return `<tr><td class="k"><span class="am">${esc(amOverride ? `${amOverride} ${am}` : am)}</span><span class="en">${esc(en)}</span></td><td class="v pv">${esc(
|
||||
value === null || value === undefined || value === "" ? "N/A" : value,
|
||||
)}</td></tr>`;
|
||||
}
|
||||
|
||||
/** One row of the Ministry's totals block. */
|
||||
function totalRow(am: string, en: string, value: string, grand = false): string {
|
||||
return `<tr class="${grand ? "grand" : ""}"><td class="tl">${esc(am ? `${am} / ${en}` : en)}</td><td class="tv">${esc(value)}</td></tr>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
amountInWords,
|
||||
formatEthiopianDate,
|
||||
formatGregorianDate,
|
||||
gregorianToEthiopian,
|
||||
numberToWords,
|
||||
} from "./mor-document.util";
|
||||
|
||||
describe("gregorianToEthiopian", () => {
|
||||
it("matches the MoR portal's own rendering of a registered EDR invoice", () => {
|
||||
// portal.mor.gov.et printed `25-12-2018 ዓ/ም` beside `31-08-2026 G.C` for INV document no. 3.
|
||||
expect(gregorianToEthiopian(new Date(2026, 7, 31))).toEqual({ year: 2018, month: 12, day: 25 });
|
||||
expect(formatEthiopianDate(new Date(2026, 7, 31))).toBe("25-12-2018 ዓ/ም");
|
||||
expect(formatGregorianDate(new Date(2026, 7, 31))).toBe("31-08-2026 G.C");
|
||||
});
|
||||
|
||||
it("rolls the year on Ethiopian new year, not on the Gregorian one", () => {
|
||||
// 11 Sep 2026 is 1 መስከረም 2019; the day before is still 2018.
|
||||
expect(gregorianToEthiopian(new Date(2026, 8, 10))).toMatchObject({ year: 2018, month: 13 });
|
||||
expect(gregorianToEthiopian(new Date(2026, 8, 11))).toEqual({ year: 2019, month: 1, day: 1 });
|
||||
});
|
||||
|
||||
it("returns a placeholder rather than throwing on a missing date", () => {
|
||||
expect(formatEthiopianDate(null)).toBe("-");
|
||||
expect(formatGregorianDate(undefined)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("amountInWords", () => {
|
||||
it("spells an amount with cents the way the reference tax invoice does", () => {
|
||||
// WISCOM's certified printout: 3,759.93 -> "three thousand seven hundred and fifty-nine Birr
|
||||
// and ninety-three Cents".
|
||||
expect(amountInWords(3759.93)).toBe(
|
||||
"Three thousand seven hundred and fifty-nine Birr and ninety-three Cents",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the 'and' inside a scale group, as the reference printouts do", () => {
|
||||
// 407,422.98 on the reference credit-sales invoice reads "Four Hundred And Seven Thousand Four
|
||||
// Hundred And Twenty-Two Birr and Ninety-Eight Cents". Note the MoR portal itself uses the
|
||||
// other convention ("nine hundred four thousand"); the printed document follows the reference.
|
||||
expect(amountInWords(407422.98)).toBe(
|
||||
"Four hundred and seven thousand four hundred and twenty-two Birr and ninety-eight Cents",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the cents clause on a whole amount", () => {
|
||||
expect(amountInWords(880)).toBe("Eight hundred and eighty Birr");
|
||||
});
|
||||
|
||||
it("carries rounded cents into the Birr instead of printing 100 Cents", () => {
|
||||
expect(amountInWords(9.999)).toBe("Ten Birr");
|
||||
});
|
||||
|
||||
it("handles zero and sub-Birr amounts", () => {
|
||||
expect(amountInWords(0)).toBe("Zero Birr");
|
||||
expect(amountInWords(0.5)).toBe("Zero Birr and fifty Cents");
|
||||
});
|
||||
|
||||
it("spells the scale words", () => {
|
||||
expect(numberToWords(1_000_000)).toBe("one million");
|
||||
expect(numberToWords(21)).toBe("twenty-one");
|
||||
expect(numberToWords(115)).toBe("one hundred and fifteen");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Presentation helpers for MoR EIMS tax documents (ADD-P001 print layout).
|
||||
*
|
||||
* The layout these serve is modelled on the Ministry's own portal rendering of a registered EDR
|
||||
* invoice (portal.mor.gov.et), which is the authoritative source for the bilingual field labels —
|
||||
* not on any one vendor's template.
|
||||
*/
|
||||
|
||||
/** Ethiopian month names, index 0 = መስከረም. */
|
||||
const ETHIOPIAN_MONTHS = [
|
||||
"መስከረም",
|
||||
"ጥቅምት",
|
||||
"ኅዳር",
|
||||
"ታኅሣሥ",
|
||||
"ጥር",
|
||||
"የካቲት",
|
||||
"መጋቢት",
|
||||
"ሚያዝያ",
|
||||
"ግንቦት",
|
||||
"ሰኔ",
|
||||
"ሐምሌ",
|
||||
"ነሐሴ",
|
||||
"ጳጉሜ",
|
||||
] as const;
|
||||
|
||||
export interface EthiopianDate {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gregorian → Ethiopian, via Julian Day Number.
|
||||
*
|
||||
* JDN rather than day-of-year arithmetic because the Ethiopian new year drifts against September
|
||||
* 11/12 on the Gregorian leap cycle; JDN is the same conversion the passenger portal already uses.
|
||||
*/
|
||||
export function gregorianToEthiopian(date: Date): EthiopianDate {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
const a = Math.floor((14 - month) / 12);
|
||||
const y = year + 4800 - a;
|
||||
const m = month + 12 * a - 3;
|
||||
const jdn =
|
||||
day +
|
||||
Math.floor((153 * m + 2) / 5) +
|
||||
365 * y +
|
||||
Math.floor(y / 4) -
|
||||
Math.floor(y / 100) +
|
||||
Math.floor(y / 400) -
|
||||
32045;
|
||||
|
||||
// 1723856 is the JDN of 1 መስከረም 1 E.C.
|
||||
const r = (jdn - 1723856) % 1461;
|
||||
const n = (r % 365) + 365 * Math.floor(r / 1460);
|
||||
const ethYear = 4 * Math.floor((jdn - 1723856) / 1461) + Math.floor(r / 365) - Math.floor(r / 1460);
|
||||
const ethMonth = Math.floor(n / 30) + 1;
|
||||
const ethDay = (n % 30) + 1;
|
||||
|
||||
return { year: ethYear, month: ethMonth, day: ethDay };
|
||||
}
|
||||
|
||||
/** `25-12-2018 ዓ/ም` — the numeric form the MoR portal prints beside the Gregorian date. */
|
||||
export function formatEthiopianDate(value: Date | string | null | undefined): string {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return "-";
|
||||
const { year, month, day } = gregorianToEthiopian(date);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(day)}-${pad(month)}-${year} ዓ/ም`;
|
||||
}
|
||||
|
||||
/** `ሐምሌ 25, 2018` — the long form, when a document has room for it. */
|
||||
export function formatEthiopianDateLong(value: Date | string | null | undefined): string {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return "-";
|
||||
const { year, month, day } = gregorianToEthiopian(date);
|
||||
return `${ETHIOPIAN_MONTHS[month - 1] ?? ""} ${day}, ${year}`;
|
||||
}
|
||||
|
||||
/** `31-08-2026 G.C` — Gregorian, labelled the way the MoR portal labels it. */
|
||||
export function formatGregorianDate(value: Date | string | null | undefined): string {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return "-";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} G.C`;
|
||||
}
|
||||
|
||||
/** `10:58:30`, 24-hour, to match the portal's `ሰአት/Time` row. */
|
||||
export function formatDocumentTime(value: Date | string | null | undefined): string {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return "-";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
const ONES = [
|
||||
"",
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
"four",
|
||||
"five",
|
||||
"six",
|
||||
"seven",
|
||||
"eight",
|
||||
"nine",
|
||||
"ten",
|
||||
"eleven",
|
||||
"twelve",
|
||||
"thirteen",
|
||||
"fourteen",
|
||||
"fifteen",
|
||||
"sixteen",
|
||||
"seventeen",
|
||||
"eighteen",
|
||||
"nineteen",
|
||||
];
|
||||
const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
|
||||
const SCALES: [number, string][] = [
|
||||
[1_000_000_000, "billion"],
|
||||
[1_000_000, "million"],
|
||||
[1_000, "thousand"],
|
||||
];
|
||||
|
||||
/** 0-999 in words. */
|
||||
function underThousand(value: number): string {
|
||||
if (value < 20) return ONES[value];
|
||||
if (value < 100) {
|
||||
const rest = value % 10;
|
||||
return TENS[Math.floor(value / 10)] + (rest ? `-${ONES[rest]}` : "");
|
||||
}
|
||||
const rest = value % 100;
|
||||
return `${ONES[Math.floor(value / 100)]} hundred${rest ? ` and ${underThousand(rest)}` : ""}`;
|
||||
}
|
||||
|
||||
/** Whole number in words. Returns "zero" for 0. */
|
||||
export function numberToWords(value: number): string {
|
||||
const n = Math.floor(Math.abs(value));
|
||||
if (n === 0) return "zero";
|
||||
|
||||
const parts: string[] = [];
|
||||
let remaining = n;
|
||||
for (const [scale, name] of SCALES) {
|
||||
const count = Math.floor(remaining / scale);
|
||||
if (count > 0) {
|
||||
parts.push(`${numberToWords(count)} ${name}`);
|
||||
remaining %= scale;
|
||||
}
|
||||
}
|
||||
if (remaining > 0) {
|
||||
// "and" only before a trailing sub-hundred group, matching how the amount reads aloud
|
||||
// ("three thousand seven hundred and fifty-nine", not "three thousand and seven hundred").
|
||||
parts.push(parts.length > 0 && remaining < 100 ? `and ${underThousand(remaining)}` : underThousand(remaining));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* `Total including Tax (in words)` — the legally required spelling-out of the payable amount.
|
||||
*
|
||||
* Computed here rather than read back from MoR: the Ministry renders its own copy on the portal,
|
||||
* but returns nothing carrying it on `/v1/register`, and the line has to print on a document that
|
||||
* may not be registered yet.
|
||||
*/
|
||||
export function amountInWords(value: number, currencyLabel = "Birr", fractionLabel = "Cents"): string {
|
||||
const amount = Number.isFinite(value) ? Math.abs(value) : 0;
|
||||
const birr = Math.floor(amount);
|
||||
// Round the remainder rather than truncate: 0.155 must read as sixteen cents, not fifteen.
|
||||
const cents = Math.round((amount - birr) * 100);
|
||||
// Rounding cents can carry into the next Birr (x.999 -> 100 cents).
|
||||
const [wholeBirr, wholeCents] = cents === 100 ? [birr + 1, 0] : [birr, cents];
|
||||
|
||||
const head = `${numberToWords(wholeBirr)} ${currencyLabel}`;
|
||||
const text = wholeCents > 0 ? `${head} and ${numberToWords(wholeCents)} ${fractionLabel}` : head;
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { EimsConfig } from "../../config/eims.config";
|
||||
import { MorGeoCodes } from "../../config/mor-location.resolver";
|
||||
import { EimsSessionContext } from "./eims-auth.service";
|
||||
import {
|
||||
EimsLineTax,
|
||||
EimsMapperContext,
|
||||
EimsMapperLine,
|
||||
EimsSellerDetails,
|
||||
@@ -172,12 +173,35 @@ export interface EimsContextInput {
|
||||
relatedDocument?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax treatment of one charge type: its per-`chargeType` override when one is configured
|
||||
* (validated symmetric in `assertChargeTypeOverrides`), else the single invoice-wide default.
|
||||
*
|
||||
* Exported because the printed tax document has to state the same Tax Code, Excise and Discount
|
||||
* per line that was filed with MoR, and it must be able to do so without a live EIMS session —
|
||||
* `buildEimsContext` needs a system number from an access token, printing does not.
|
||||
*/
|
||||
export function resolveLineTax(config: EimsConfig, chargeType: string): EimsLineTax {
|
||||
const { invoice } = config;
|
||||
return {
|
||||
code: invoice.taxCodeByChargeType[chargeType] ?? invoice.taxCode,
|
||||
ratePercent:
|
||||
chargeType in invoice.taxRateByChargeType
|
||||
? Number(invoice.taxRateByChargeType[chargeType])
|
||||
: invoice.taxRatePercent!,
|
||||
exciseTaxValue:
|
||||
chargeType in invoice.exciseByChargeType
|
||||
? Number(invoice.exciseByChargeType[chargeType])
|
||||
: (invoice.exciseTaxValue ?? 0),
|
||||
discount:
|
||||
chargeType in invoice.discountByChargeType
|
||||
? Number(invoice.discountByChargeType[chargeType])
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
|
||||
const { invoice } = config;
|
||||
// Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call.
|
||||
const taxCode = invoice.taxCode;
|
||||
const ratePercent = invoice.taxRatePercent!;
|
||||
const exciseTaxValue = invoice.exciseTaxValue ?? 0;
|
||||
|
||||
return {
|
||||
systemNumber: input.session.systemNumber,
|
||||
@@ -191,23 +215,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
||||
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
||||
// Per-`chargeType` override when one is configured (validated symmetric in
|
||||
// assertChargeTypeOverrides), else the single invoice-wide default.
|
||||
taxForLine: (line: EimsMapperLine) => {
|
||||
const { chargeType } = line;
|
||||
const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode;
|
||||
const rate =
|
||||
chargeType in invoice.taxRateByChargeType
|
||||
? Number(invoice.taxRateByChargeType[chargeType])
|
||||
: ratePercent;
|
||||
const excise =
|
||||
chargeType in invoice.exciseByChargeType
|
||||
? Number(invoice.exciseByChargeType[chargeType])
|
||||
: exciseTaxValue;
|
||||
const discount =
|
||||
chargeType in invoice.discountByChargeType
|
||||
? Number(invoice.discountByChargeType[chargeType])
|
||||
: 0;
|
||||
return { code, ratePercent: rate, exciseTaxValue: excise, discount };
|
||||
},
|
||||
taxForLine: (line: EimsMapperLine) => resolveLineTax(config, line.chargeType),
|
||||
natureOfSupplies: invoice.natureOfSupplies,
|
||||
unitDefault: invoice.unitDefault,
|
||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
MorPartyDetails,
|
||||
pngDataUrl,
|
||||
} from "../billing/documents/invoice-document.service";
|
||||
import { buildEimsSeller } from "./eims-invoice-context";
|
||||
import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||
import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types";
|
||||
|
||||
@@ -20,7 +23,11 @@ import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-rece
|
||||
* would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw
|
||||
* surface as a 400 — there is nothing sensible to render instead.
|
||||
*/
|
||||
export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel {
|
||||
export function toReceiptDocumentModel(
|
||||
receipt: EimsReceipt,
|
||||
invoice: Invoice,
|
||||
config?: EimsConfig,
|
||||
): InvoiceDocumentModel {
|
||||
if (receipt.status !== EimsReceiptStatus.Registered) {
|
||||
throw new Error(
|
||||
`Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`,
|
||||
@@ -45,6 +52,34 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice):
|
||||
// if that default changes for an unrelated reason.
|
||||
sealText: "EDR PAID",
|
||||
extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }],
|
||||
mor: config?.invoice
|
||||
? {
|
||||
titleAm: "የገንዘብ መቀበያ ደረሰኝ",
|
||||
titleEn: "Cash Receipt Voucher",
|
||||
saleType: config.invoice.transactionType,
|
||||
systemNumber: req.SourceSystemNumber || config.systemNumber || null,
|
||||
...parties(config, invoice),
|
||||
payment: {
|
||||
mode: req.TransactionDetails.ModeOfPayment,
|
||||
typeMethod: config.invoice.paymentTerm,
|
||||
receiverName: invoice.company?.name ?? null,
|
||||
},
|
||||
receipt: {
|
||||
rrn: receipt.rrn ?? "",
|
||||
reason: req.Reason,
|
||||
collectedAmount: req.CollectedAmount,
|
||||
// One row per invoice the payment covers — MoR's receipt is invoice-linked, so the
|
||||
// printed voucher has to show which document(s) the money was applied to.
|
||||
invoices: req.Invoices.map((line) => ({
|
||||
irn: line.InvoiceIRN,
|
||||
paymentCoverage: line.PaymentCoverage,
|
||||
totalAmount: line.TotalAmount,
|
||||
remainingAmount: line.RemainingAmount ?? 0,
|
||||
paidAmount: line.InvoicePaidAmount,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,9 +94,63 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice):
|
||||
// wrong here, so this is the one case that MUST override it.
|
||||
sealText: "EDR",
|
||||
extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }],
|
||||
mor: config?.invoice
|
||||
? {
|
||||
titleAm: "ከተከፋይ ሒሳብ ላይ ለተቀነሰ ግብር የተሰጠ ደረሰኝ",
|
||||
titleEn: "Withholding tax on payment",
|
||||
...parties(config, invoice),
|
||||
systemNumber: req.SourceSystemNumber || config.systemNumber || null,
|
||||
withholding: {
|
||||
receiptNumber: receipt.receiptNumber,
|
||||
counter: req.ReceiptCounter,
|
||||
reason: req.Reason,
|
||||
type: req.WithholdDetail.Type,
|
||||
invoiceCurrency: req.InvoiceDetail.Currency,
|
||||
preTaxAmount: req.WithholdDetail.PreTaxAmount,
|
||||
withheldAmount: req.WithholdDetail.WithholdingAmount,
|
||||
systemType: req.SourceSystemType,
|
||||
systemNumber: req.SourceSystemNumber || config.systemNumber || "",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `ከ / From` and `ለ / To` for a receipt. On a withholding receipt the seller is the withholding
|
||||
* agent and the buyer the taxpayer, which is the same pair of blocks in the same order — the
|
||||
* layout relabels them, so the mapping does not change.
|
||||
*/
|
||||
function parties(
|
||||
config: EimsConfig,
|
||||
invoice: Invoice,
|
||||
): { seller: MorPartyDetails; buyer: MorPartyDetails } {
|
||||
const seller = buildEimsSeller(config);
|
||||
const company = invoice.company;
|
||||
return {
|
||||
seller: {
|
||||
name: config.invoice.sellerLegalName || seller.LegalName,
|
||||
city: seller.City,
|
||||
subCity: seller.SubCity,
|
||||
woreda: seller.Wereda,
|
||||
kebele: seller.Locality,
|
||||
houseNo: seller.HouseNumber,
|
||||
tin: seller.Tin,
|
||||
vatNumber: seller.VatNumber,
|
||||
},
|
||||
buyer: {
|
||||
name: company?.name ?? "N/A",
|
||||
city: company?.zone ?? null,
|
||||
subCity: company?.zone ?? null,
|
||||
woreda: company?.woreda ?? null,
|
||||
kebele: company?.kebele ?? null,
|
||||
houseNo: company?.houseNo ?? null,
|
||||
tin: company?.tin ?? null,
|
||||
vatNumber: company?.vatNumber ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function build(
|
||||
receipt: EimsReceipt,
|
||||
invoice: Invoice,
|
||||
@@ -73,6 +162,7 @@ function build(
|
||||
amount: number;
|
||||
sealText: string;
|
||||
extraSummary: Array<{ label: string; value: string | null }>;
|
||||
mor?: InvoiceDocumentModel["mor"];
|
||||
},
|
||||
): InvoiceDocumentModel {
|
||||
return {
|
||||
|
||||
@@ -196,7 +196,7 @@ export class EimsReceiptService {
|
||||
|
||||
let model: ReturnType<typeof toReceiptDocumentModel>;
|
||||
try {
|
||||
model = toReceiptDocumentModel(receipt, invoice);
|
||||
model = toReceiptDocumentModel(receipt, invoice, this.cfg);
|
||||
} catch (err) {
|
||||
// Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 —
|
||||
// a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService
|
||||
|
||||
Reference in New Issue
Block a user