diff --git a/INV-20260812-00005-mor.pdf b/INV-20260812-00005-mor.pdf new file mode 100644 index 000000000..4ce89c534 Binary files /dev/null and b/INV-20260812-00005-mor.pdf differ diff --git a/INV-20260812-00005-thermal.pdf b/INV-20260812-00005-thermal.pdf new file mode 100644 index 000000000..21df74ca7 Binary files /dev/null and b/INV-20260812-00005-thermal.pdf differ diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 1dd4c5656..e9bdfb35f 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 = { + 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 }, }; } diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index c443fad7e..609518eef 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -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> = {}) => + ({ + 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; + + 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"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index d85facc0f..ac5da6203 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -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) => - `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, - ) - .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]) => + `
${esc(label)}${esc(value)}
`, + ) + .join("") + + `
Total incl. Tax${esc(money(tax.totalIncludingTax, model.currency))}
` + + `
${esc(tax.amountInWords)}
` + : model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, + ) + .join(""); + + const payMarkup = model.mor?.payment + ? `
Mode of Payment${esc(model.mor.payment.mode)}
+
Type/Method${esc(model.mor.payment.typeMethod)}
` + : ""; const qrMarkup = model.qrImageUrl ? `
EIMS verification QR
Scan to verify (MoR EIMS)
` @@ -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}
${totalRows} + ${payMarkup} ${qrMarkup} @@ -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 { `; } + /** + * 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 => ` + + + + ${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)} +
${esc(sideAm)}${esc(sideEn)}${esc(p.name)}
`; + + const itemRows = model.lines + .map( + (item, i) => ` + ${i + 1} + ${esc(item.description)} + ${esc(item.nature ?? "-")} + ${esc(item.uom ?? "-")} + ${esc(item.quantity ?? 0)} + ${esc(amount2(item.unitRate))} + ${esc(item.taxCode ?? "-")} + ${esc(amount2(item.excise ?? 0))} + ${esc(amount2(item.discount ?? 0))} + ${esc(amount2(item.amount))} + `, + ) + .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 + ? `ጠቅላላ ዋጋ ከታክስ ጋር (በፊደል)Total including Tax (in words) + ${esc(tax.amountInWords)}` + : ""; + + const receipt = mor.receipt; + const receiptBlock = receipt + ? ` + ${morRow("የክፍያ ምክንያት", "Payment Reason", receipt.reason)} + ${morRow("የተሰበሰበ መጠን", "Collected Amount", amount2(receipt.collectedAmount))} +
+
የደረሰኞች ዝርዝር / Invoices
+ + + + + + + + + ${receipt.invoices + .map( + (inv) => ` + + + + + + `, + ) + .join("")} +
IRN${esc("የክፍያ ሽፋን / Payment Coverage")}${esc("ጠቅላላ ዋጋ / Total Amount")}${esc("ቀሪ / Remaining Amount")}${esc("የተከፈለ / Paid Amount")}
${esc(inv.irn)}${esc(inv.paymentCoverage)}${esc(amount2(inv.totalAmount))}${esc(amount2(inv.remainingAmount))}${esc(amount2(inv.paidAmount))}
+ ` + : ""; + + const wh = mor.withholding; + const withholdingBlock = wh + ? ` + ${morRow("የደረሰኝ ቁጥር", "Receipt #", wh.receiptNumber)} + ${morRow("ቆጣሪ", "Counter", wh.counter)} + ${morRow("ምክንያት", "Reason", wh.reason)} + ${morRow("አይነት", "Type", wh.type)} +
+ + + + + + + + + + + + + +
${esc("የደረሰኝ ቁጥር / Invoice Doc. Number")}${esc("የገንዘብ ዓይነት / Invoice Currency")}${esc("ከታክስ በፊት ያለው ዋጋ / Pre Tax Amount")}${esc("ተይዞ የቀረ መጠን / Withheld Amount")}
${esc(wh.receiptNumber)}${esc(wh.invoiceCurrency)}${esc(amount2(wh.preTaxAmount))}${esc(amount2(wh.withheldAmount))}
+ + + ${morRow("የስርዓት አይነት", "System Type", wh.systemType)} + ${morRow("የስርዓት ቁጥር", "System Number", wh.systemNumber)} +
` + : ""; + + const payment = mor.payment; + const paymentBlock = payment + ? ` + + + + + +
የክፍያ ሁኔታMode of Payment${esc(payment.mode)}አይነትType/Method${esc(payment.typeMethod)}የተቀባይ ስምና ፊርማReceiver Name & Signature${esc(payment.receiverName ?? "")}
` + : ""; + + const approval = mor.approval; + const approvalBlock = approval + ? `
INVOICE AMENDMENT AUTHORIZATION
+
This amendment has been reviewed and approved in accordance with the company's approval matrix.
+ + + + + + +
የጠየቀውRequested By${esc(approval.requestedBy ?? "")}ያረጋገጠውChecked By${esc(approval.checkedBy ?? "")}ያፀደቀውApproved By${esc(approval.approvedBy ?? "")}
` + : ""; + + const qrBlock = model.qrImageUrl + ? `EIMS verification QR` + : ""; + + return ` + + + + ${esc(mor.titleEn)} ${esc(model.documentNumber)} + + + +
+
+
+ ${model.logoImageUrl ? `` : ""} +
${esc(mor.seller.name)}
+
Ethio-Djibouti Railway S.C.
+
+
+
የደረሰኝ ቁጥርDocument No${esc(model.documentNumber)}
+
ቀንDate${esc(formatEthiopianDate(model.issuedAt))}
+
${esc(formatGregorianDate(model.issuedAt))}
+
ሰአትTime${esc(formatDocumentTime(model.issuedAt))}
+
+
+ +
+
${esc(mor.titleAm)}
+
${esc(mor.titleEn)}
+ ${mor.saleType ? `
የሽያጭ አይነት (${esc(mor.saleType)})
` : ""} +
+ +
+ + ${mor.irn ? `` : ""} + ${mor.receipt ? `` : ""} + ${mor.systemNumber ? `` : ""} + ${mor.referenceNumber ? `` : ""} + ${mor.relatedDocumentIrn ? `` : ""} +
IRN${esc(mor.irn)}
RRN${esc(mor.receipt.rrn)}
System Number${esc(mor.systemNumber)}
Reference Number${esc(mor.referenceNumber)}
Related Document${esc(mor.relatedDocumentIrn)}
+ ${qrBlock} +
+ +
+
${party(mor.seller, "ከ", "From", "የሻጭ", "Seller")}
+
${party(mor.buyer, "ለ", "To", "የገዢ", "Customer")}
+
+ + ${withholdingBlock} + ${receiptBlock} + + ${ + model.lines.length > 0 && !mor.withholding + ? ` + + + + + + + + + + + + + + + ${itemRows} +
${esc("ተ/ቁ")}
No.
${esc("የዕቃው / አገልግሎት አይነት")}
Description
${esc("ምድብ")}
Nature
${esc("መለኪያ")}
UoM
${esc("ብዛት")}
Qty
${esc("የአንዱ ዋጋ")}
Unit Price
${esc("ታክስ ኮድ")}
Tax Code
${esc("ኤክሳይዝ")}
Excise
${esc("ቅናሽ")}
Discount
${esc("ጠቅላላ ዋጋ")}
Total Amount
` + : "" + } + + ${tax ? `${taxRows}${wordsRow}
` : ""} + ${paymentBlock} + ${approvalBlock} + +
+
Ethio-Djibouti Railway S.C. — ${esc(mor.titleEn)}
+
Page 1 of 1  ·  Printed ${esc(formatGregorianDate(new Date()))} ${esc(formatDocumentTime(new Date()))}
+
+
+ +`; + } + 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 `${esc(amOverride ? `${amOverride} ${am}` : am)}${esc(en)}${esc( + value === null || value === undefined || value === "" ? "N/A" : value, + )}`; +} + +/** One row of the Ministry's totals block. */ +function totalRow(am: string, en: string, value: string, grand = false): string { + return `${esc(am ? `${am} / ${en}` : en)}${esc(value)}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.spec.ts new file mode 100644 index 000000000..ab9ad6791 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.spec.ts @@ -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"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts new file mode 100644 index 000000000..ba74737b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts @@ -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); +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 2fb83dc72..030cf2fe5 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -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!, diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts index 841bfcc54..0d4a0fd78 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts index f2de2937c..8c98d0293 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts @@ -196,7 +196,7 @@ export class EimsReceiptService { let model: ReturnType; 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