/** * 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"; /** The only `DocumentDetails.Type` observed in the supplied material. */ const EIMS_DOCUMENT_TYPE = "INV"; 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: 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; /** Null for an ordinary invoice; set only for a real related-document case. */ relatedDocument?: string | null; /** MoR numeric country code for the buyer; our DB stores the country name. */ buyerCountryCode?: string | null; /** * 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; buyerIdType?: string | null; buyerIdNumber?: string | null; buyerCity?: 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 or Wereda) as a MoR code: passed through when already numeric, * otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending * a guessed code onto a tax document is worse than refusing to file. */ function resolveLocationCode( field: "Region" | "Wereda", value: string | null | undefined, codes: Record, envVar: string, invoiceNumber: string, ): string { 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; 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}.`, ); } 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 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); const unit = typeof line.metadata?.unit === "string" ? line.metadata.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: { City: context.buyerCity ?? null, 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: context.buyerCountryCode ?? null, 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: EIMS_DOCUMENT_TYPE, }, 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, }; }