feat(billing): render EIMS documents in the MoR tax-document layout

This commit is contained in:
Hagernesh
2026-09-01 04:28:48 +00:00
parent fe3d398757
commit ed839ebb92
10 changed files with 1137 additions and 37 deletions

BIN
INV-20260812-00005-mor.pdf Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -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 },
};
}

View File

@@ -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");
});
});

View File

@@ -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 &amp; 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 &nbsp;·&nbsp; 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>`;
}

View File

@@ -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");
});
});

View File

@@ -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);
}

View File

@@ -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!,

View File

@@ -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 {

View File

@@ -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