/** * Pure mapper from an EDR invoice onto the Ethiopian MoR EIMS registration document * (`POST https://core.mor.gov.et/v1/register`). * * Field names, casing and section layout are taken verbatim from the supplied * `EimsCoreApiMockCollection2.postman_collection.json`. Note the payload spells the district * `Wereda` even though the collection *variable* is named `sellerWoreda`. * * Scope: mapping only — no HTTP, no signing, no persistence, no counter allocation. Everything * that does not live on the invoice (document number, counters, previous IRN, seller identity, * tax treatment) is supplied by the caller and is never guessed here. * * Values that the collection only *demonstrates* by example — the date format, the meaning of an * empty `PreviousIrn`, the `SystemType` enum, `PaymentTerm` values — are treated as observed, not * authoritative: they are passed through or overridable rather than validated against a fixed set. */ import { round2 } from "./invoice-settlement.util"; /** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ const EIMS_VERSION = "1"; /** * `DocumentDetails.Type`. `"INV"` is the only value observed in the collection; `"DEB"`/`"CRE"` * (debit/credit note) were confirmed directly by MoR support — same `/v1/register` endpoint, no * separate API. MoR's answer, verbatim: "the same endpoint used for registration should be used * ... within the Document Detail object, you should specify DEB for a debit note, CRE for a * credit note... add a Reason attribute under document detail object". */ export const EIMS_DOCUMENT_TYPES = ["INV", "DEB", "CRE"] as const; export type EimsDocumentType = (typeof EIMS_DOCUMENT_TYPES)[number]; export interface EimsBuyerDetails { City: string | null; Email: string | null; HouseNumber: string | null; IdNumber: string | null; IdType: string | null; Tin: string; LegalName: string; Phone: string | null; Region: string | null; Country: string | null; Zone: string | null; Kebele: string | null; VatNumber: string | null; Wereda: string | null; } export interface EimsSellerDetails { City: string | null; Email: string | null; HouseNumber: string | null; LegalName: string; Locality: string | null; Phone: string | null; /** MoR region *code* (e.g. "13"), not a region name. */ Region: string | null; SubCity: string | null; Tin: string; VatNumber: string | null; /** MoR wereda *code* (e.g. "574"). */ Wereda: string | null; } export interface EimsDocumentDetails { DocumentNumber: string; /** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */ Date: string; Type: EimsDocumentType; /** Only for DEB/CRE, per MoR support — why the debit/credit note was issued. Absent for INV. */ Reason?: string; } export interface EimsInvoiceItem { Discount: number; ExciseTaxValue: number; HarmonizationCode: string | null; NatureOfSupplies: string; ItemCode: string; ProductDescription: string; PreTaxValue: number; Quantity: number; LineNumber: number; TaxAmount: number; TaxCode: string; TotalLineAmount: number; Unit: string; UnitPrice: number; } export interface EimsPaymentDetails { Mode: string; PaymentTerm: string; } export interface EimsReferenceDetails { PreviousIrn: string | null; RelatedDocument: string | null; } export interface EimsSourceSystem { CashierName: string | null; InvoiceCounter: number; SalesPersonName: string | null; SystemNumber: string; SystemType: string; } export interface EimsValueDetails { Discount: number | null; ExciseValue: number; IncomeWithholdValue: number; TaxValue: number; TotalValue: number; TransactionWithholdValue: number; InvoiceCurrency: string; /** Absent from the register sample, present on the verify response. Emitted only when supplied. */ ExchangeRate?: number; } export interface EimsInvoiceRequest { BuyerDetails: EimsBuyerDetails; DocumentDetails: EimsDocumentDetails; ItemList: EimsInvoiceItem[]; PaymentDetails: EimsPaymentDetails; ReferenceDetails: EimsReferenceDetails; SellerDetails: EimsSellerDetails; SourceSystem: EimsSourceSystem; TransactionType: string; ValueDetails: EimsValueDetails; Version: string; } /** `body` of a successful `POST /v1/register`, as observed in the collection. */ export interface EimsRegisterResponseBody { irn: string; ackDate: string; signedQR: string; signedInvoice: string; status: string; documentNumber: string; errorMessage: string | null; } /** Numeric columns arrive from pg as strings; every money field is normalised through `num`. */ export interface EimsMapperLine { chargeType: string; description?: string | null; quantity: number | string; unitRate: number | string; amount: number | string; metadata?: Record | null; } export interface EimsMapperCompany { name: string; tin: string; vatNumber?: string | null; phone?: string | null; email?: string | null; region?: string | null; zone?: string | null; woreda?: string | null; kebele?: string | null; houseNo?: string | null; country?: string | null; } /** * Structurally what `BillingService.findById` returns — the only read path that loads the header, * the buyer company and the lines together. */ export interface EimsMapperInvoice { invoiceNumber: string; currency: string; issuedAt?: Date | string | null; totalAmount: number | string; company?: EimsMapperCompany | null; lines: EimsMapperLine[]; } /** * Tax treatment for a single line. EIMS models `TaxCode`/`TaxAmount`/`ExciseTaxValue` per item, and * different charge types may eventually be treated differently, so this is resolved per line. * * Nothing in this repo can supply it: `Invoice.taxAmount` is hardcoded to 0 with no caller ever * setting it, `invoice_lines` has no tax column, and the rate catalogue has no fiscal field. That * is the absence of a tax model, not evidence of zero-rating — hence no default here. */ export interface EimsLineTax { code: string; ratePercent: number; exciseTaxValue: number; /** * Line-level `Discount`. Its effect on `TotalLineAmount` has never been observed live (every * prior test ran it at 0), so the total below still sums PreTax + Tax + Excise only — do not * start subtracting this without a confirmed MoR example. */ discount: number; } export interface EimsMapperContext { systemNumber: string; /** Observed values: POS, MAN, CRM, EFD, SYS (the collection prose also mentions ERP). */ systemType: string; /** Caller decides the source — our own `invoiceNumber` or a dedicated EIMS sequence. */ documentNumber: string; invoiceCounter: number; /** Passed through verbatim; the collection shows `""` used for an unchained document. */ previousIrn: string | null; cashierName: string | null; salesPersonName: string | null; /** B2B / B2C — a tax classification, so the caller states it. */ transactionType: string; payment: { mode: string; term: string }; /** Must return a treatment for every line, or throw. */ taxForLine: (line: EimsMapperLine, lineNumber: number) => EimsLineTax; natureOfSupplies: string; /** Used when a line carries no `metadata.unit`. */ unitDefault: string; incomeWithholdValue: number; transactionWithholdValue: number; /** * `DocumentDetails.Type`. Defaults to `"INV"`. For `"DEB"`/`"CRE"` both `reason` and * `relatedDocument` become required — confirmed directly by MoR support, not the collection. */ documentType?: EimsDocumentType; /** Required when `documentType` is `"DEB"`/`"CRE"` — why the note was issued. Unused for INV. */ reason?: string | null; /** * `ReferenceDetails.RelatedDocument`. Null for an ordinary invoice; required for a DEB/CRE — * the original registered invoice's IRN, per MoR's own IRC-P06/P07 checklist ("credit memo * from a registered invoice"). */ relatedDocument?: string | null; /** * Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already * in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer. */ buyerCountryCode?: string | null; /** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */ buyerCountryCodes: Record; /** * Region name → MoR numeric code, for buyers whose stored region is free text. * * `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region` * against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else * must be in this map or the mapping **fails locally** — sending a guessed region code onto a * tax document is worse than refusing to file. */ buyerRegionCodes: Record; /** * Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names * ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an * error, so this is precautionary rather than confirmed — but the fix is identical either way: * fail locally on an unmapped name rather than file a guess. */ buyerWeredaCodes: Record; /** * Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the * closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already * accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail * the mapping. */ buyerCityCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; invoiceDiscount?: number | null; /** Override while the observed `dd-MM-yyyyTHH:mm:ss` format is unconfirmed by MoR. */ formatDate?: (issuedAt: Date) => string; } /** * MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused * as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller * "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex * the way it named Region's. */ const LOCATION_CODE = /^[0-9]{1,3}$/; /** * The only two values MoR accepts for `NatureOfSupplies`, lowercase. * * Its schema branches on this as a `oneOf` with a `const` per branch, so `"Service"` fails the * whole `ItemList` — the error reads "must be the constant value 'service'". */ const NATURE_OF_SUPPLIES = ["goods", "service"] as const; const num = (v: number | string): number => { const n = Number(v); if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`); return n; }; const pad = (n: number, width = 2): string => String(n).padStart(width, "0"); /** Observed EIMS document-date format: `dd-MM-yyyyTHH:mm:ss`, no timezone marker. */ export const formatEimsDate = (issuedAt: Date): string => `${pad(issuedAt.getDate())}-${pad(issuedAt.getMonth() + 1)}-${issuedAt.getFullYear()}` + `T${pad(issuedAt.getHours())}:${pad(issuedAt.getMinutes())}:${pad(issuedAt.getSeconds())}`; /** * Map one loaded invoice onto an EIMS registration document. * * Throws rather than emitting a payload EIMS would reject opaquely: missing buyer TIN, no lines, * an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no * exchange rate. */ /** * A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already * numeric, otherwise looked up by name (case- and space-insensitive). * * Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax * document is worse than refusing to file. City is optional (`required: false`, City's own * caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to * null instead of blocking the invoice. */ function resolveLocationCode( field: "Region" | "Wereda" | "City", value: string | null | undefined, codes: Record, envVar: string, invoiceNumber: string, opts: { required?: boolean } = {}, ): string | null { const raw = (value ?? "").trim(); if (LOCATION_CODE.test(raw)) return raw; const key = raw.toLowerCase().replace(/\s+/g, " "); const mapped = Object.entries(codes).find( ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, )?.[1]; if (mapped && LOCATION_CODE.test(mapped)) return mapped; if (opts.required === false) return null; throw new Error( `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, ); } /** * A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies * `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default). * A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same * "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's * Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`. */ function resolveCountryCode( country: string | null | undefined, codes: Record, domesticFallback: string | null, invoiceNumber: string, ): string | null { const raw = (country ?? "").trim(); const key = raw.toLowerCase().replace(/\s+/g, " "); const mapped = Object.entries(codes).find( ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, )?.[1]; if (mapped) return mapped; if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback; throw new Error( `EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` + "MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.", ); } /** * Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an * error to and that must never throw — currently only `EimsSellerCacheService`, resolving * e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code, * name lookup, `undefined` on no match — the caller falls back to static config either way. */ export function resolveOptionalCode( value: string | null | undefined, codes: Record, ): string | undefined { const raw = (value ?? "").trim(); if (LOCATION_CODE.test(raw)) return raw; const key = raw.toLowerCase().replace(/\s+/g, " "); const mapped = Object.entries(codes).find( ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, )?.[1]; return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined; } export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, context: EimsMapperContext, ): EimsInvoiceRequest { const company = invoice.company; if (!company || !company.tin?.trim()) { throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no buyer company TIN`); } if (!invoice.lines?.length) { throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no lines`); } if (!invoice.issuedAt) { throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} is not issued (issuedAt is null)`); } if (invoice.currency !== "ETB" && context.exchangeRate == null) { throw new Error( `EIMS mapping: invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`, ); } const documentType = context.documentType ?? "INV"; if (!EIMS_DOCUMENT_TYPES.includes(documentType)) { throw new Error( `EIMS mapping: invoice ${invoice.invoiceNumber} has documentType "${documentType}", must be one of ${EIMS_DOCUMENT_TYPES.join(", ")}`, ); } if (documentType !== "INV") { if (!context.reason?.trim()) { throw new Error( `EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs a reason`, ); } if (!context.relatedDocument?.trim()) { throw new Error( `EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs ` + "relatedDocument — the original registered invoice's IRN", ); } } const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt); if (Number.isNaN(issuedAt.getTime())) { throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); } const natureOfSupplies = context.natureOfSupplies.trim().toLowerCase(); if (!NATURE_OF_SUPPLIES.includes(natureOfSupplies as (typeof NATURE_OF_SUPPLIES)[number])) { throw new Error( `EIMS mapping: NatureOfSupplies must be one of ${NATURE_OF_SUPPLIES.join(", ")}, ` + `got "${context.natureOfSupplies}"`, ); } const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => { const lineNumber = index + 1; const tax = context.taxForLine(line, lineNumber); if ( !tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue) || !Number.isFinite(tax.discount) ) { throw new Error( `EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` + `on invoice ${invoice.invoiceNumber}`, ); } const PreTaxValue = round2(num(line.amount)); const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100); const ExciseTaxValue = round2(tax.exciseTaxValue); // `line.metadata.unit` is our own fee-basis tag (PER_CONTAINER/PER_TON/PER_ITEM — how a charge // is computed, see the fee-rule docs), never a MoR unit of measure — sending it as-is here // (confirmed live 2026-08-17: "PER_CONTAINER" fails Unit's enum, its 8-char max, and its regex // all at once) is what a prior version of this mapper did by mistake. MoR's own enum // (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG) has no freight-shipment concept at all, so every line // uses the single configured default rather than guessing a per-line value that doesn't exist. const unit = context.unitDefault; return { Discount: round2(tax.discount), ExciseTaxValue, HarmonizationCode: null, NatureOfSupplies: natureOfSupplies, ItemCode: line.chargeType, ProductDescription: line.description?.trim() || line.chargeType, PreTaxValue, Quantity: round2(num(line.quantity)), LineNumber: lineNumber, TaxAmount, TaxCode: tax.code, TotalLineAmount: round2(PreTaxValue + TaxAmount + ExciseTaxValue), Unit: unit, UnitPrice: round2(num(line.unitRate)), }; }); const preTaxTotal = round2(ItemList.reduce((sum, item) => sum + item.PreTaxValue, 0)); const invoiceTotal = round2(num(invoice.totalAmount)); if (Math.abs(preTaxTotal - invoiceTotal) > 0.01) { throw new Error( `EIMS mapping: invoice ${invoice.invoiceNumber} lines sum to ${preTaxTotal} ` + `but the invoice total is ${invoiceTotal}`, ); } const ValueDetails: EimsValueDetails = { Discount: context.invoiceDiscount ?? null, ExciseValue: round2(ItemList.reduce((sum, item) => sum + item.ExciseTaxValue, 0)), IncomeWithholdValue: context.incomeWithholdValue, TaxValue: round2(ItemList.reduce((sum, item) => sum + item.TaxAmount, 0)), TotalValue: round2(ItemList.reduce((sum, item) => sum + item.TotalLineAmount, 0)), TransactionWithholdValue: context.transactionWithholdValue, InvoiceCurrency: invoice.currency, }; if (context.exchangeRate != null) ValueDetails.ExchangeRate = context.exchangeRate; return { BuyerDetails: { // No dedicated city column on Company — Zone is the closest match; optional (see // resolveLocationCode's City comment). City: resolveLocationCode( "City", company.zone, context.buyerCityCodes, "EIMS_BUYER_CITY_CODES", invoice.invoiceNumber, { required: false }, ), Email: company.email ?? null, HouseNumber: company.houseNo ?? null, IdNumber: context.buyerIdNumber ?? null, IdType: context.buyerIdType ?? null, Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, Region: resolveLocationCode( "Region", company.region, context.buyerRegionCodes, "EIMS_BUYER_REGION_CODES", invoice.invoiceNumber, ), Country: resolveCountryCode( company.country, context.buyerCountryCodes, context.buyerCountryCode ?? null, invoice.invoiceNumber, ), Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, Wereda: resolveLocationCode( "Wereda", company.woreda, context.buyerWeredaCodes, "EIMS_BUYER_WEREDA_CODES", invoice.invoiceNumber, ), }, DocumentDetails: { DocumentNumber: context.documentNumber, Date: (context.formatDate ?? formatEimsDate)(issuedAt), Type: documentType, ...(documentType !== "INV" ? { Reason: context.reason! } : {}), }, ItemList, PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term }, ReferenceDetails: { PreviousIrn: context.previousIrn, RelatedDocument: context.relatedDocument ?? null, }, SellerDetails: seller, SourceSystem: { CashierName: context.cashierName, InvoiceCounter: context.invoiceCounter, SalesPersonName: context.salesPersonName, SystemNumber: context.systemNumber, SystemType: context.systemType, }, TransactionType: context.transactionType, ValueDetails, Version: EIMS_VERSION, }; }