import { EimsMapperContext, EimsMapperInvoice, EimsSellerDetails, formatEimsDate, toEimsInvoice, } from "./eims-invoice.mapper"; const seller: EimsSellerDetails = { City: null, Email: "finance@edr.et", HouseNumber: null, LegalName: "Ethio-Djibouti Railway S.C.", Locality: null, Phone: "0911223344", Region: "13", SubCity: null, Tin: "0016324478", VatNumber: "3215840010", Wereda: "574", }; const invoice = (over: Partial = {}): EimsMapperInvoice => ({ invoiceNumber: "INV-20260807-00042", currency: "ETB", issuedAt: new Date(2026, 7, 7, 9, 5, 3), totalAmount: "11000.00", company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858", phone: "0912345678", email: "buyer@abc.et", region: "13", zone: "SHA", woreda: "574", kebele: "03", houseNo: "NEW", country: "Ethiopia", }, lines: [ { chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" }, { chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } }, ], ...over, }); const context = (over: Partial = {}): EimsMapperContext => ({ systemNumber: "B0360154BA", systemType: "SYS", documentNumber: "24", invoiceCounter: 7, previousIrn: "", cashierName: null, salesPersonName: null, transactionType: "B2B", payment: { mode: "CASH", term: "IMMIDIATE" }, taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }), natureOfSupplies: "Service", unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, buyerCountryCode: "231", // test-only, not a confirmed real MoR code buyerCountryCodes: {}, buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: {}, buyerCityCodes: {}, ...over, }); describe("toEimsInvoice", () => { it("emits the ten EIMS sections with the collection's field names", () => { const doc = toEimsInvoice(invoice(), seller, context()); expect(Object.keys(doc)).toEqual([ "BuyerDetails", "DocumentDetails", "ItemList", "PaymentDetails", "ReferenceDetails", "SellerDetails", "SourceSystem", "TransactionType", "ValueDetails", "Version", ]); expect(doc.Version).toBe("1"); expect(doc.DocumentDetails).toEqual({ DocumentNumber: "24", Date: "07-08-2026T09:05:03", Type: "INV" }); expect(doc.SourceSystem.InvoiceCounter).toBe(7); expect(doc.SellerDetails).toBe(seller); }); it("maps the buyer from the company row and leaves unmodelled fields null", () => { const doc = toEimsInvoice(invoice(), seller, context()); expect(doc.BuyerDetails).toEqual({ City: null, // company.country is "Ethiopia" (the domestic default) — resolves to context's flat // buyerCountryCode fallback, not null, per resolveCountryCode. Country: "231", Email: "buyer@abc.et", HouseNumber: "NEW", IdNumber: null, IdType: null, Tin: "0999930000", LegalName: "ABC Trading PLC", Phone: "0912345678", Region: "13", Zone: "SHA", Kebele: "03", VatNumber: "123475885858", Wereda: "574", }); }); it("applies per-line tax and totals it into ValueDetails", () => { const doc = toEimsInvoice( invoice(), seller, context({ taxForLine: (line) => line.chargeType === "RAIL_FREIGHT" ? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 } : { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50, discount: 25 }, }), ); expect(doc.ItemList[0]).toMatchObject({ LineNumber: 1, ItemCode: "RAIL_FREIGHT", ProductDescription: "Addis → Djibouti", Quantity: 1, UnitPrice: 10000, PreTaxValue: 10000, TaxCode: "VAT15", TaxAmount: 1500, ExciseTaxValue: 0, Discount: 0, TotalLineAmount: 11500, Unit: "PCS", NatureOfSupplies: "service", HarmonizationCode: null, }); expect(doc.ItemList[1]).toMatchObject({ LineNumber: 2, ProductDescription: "HAZARD_SURCHARGE", TaxCode: "EXEMPT", TaxAmount: 0, ExciseTaxValue: 50, // Discount is carried on the line but does not (yet) reduce TotalLineAmount — see the // EimsLineTax.discount comment in eims-invoice.mapper.ts. Discount: 25, TotalLineAmount: 1050, Unit: "CTR", }); expect(doc.ValueDetails).toEqual({ Discount: null, ExciseValue: 50, IncomeWithholdValue: 0, TaxValue: 1500, TotalValue: 12550, TransactionWithholdValue: 0, InvoiceCurrency: "ETB", }); }); it("passes PreviousIrn through verbatim and defaults RelatedDocument to null", () => { expect(toEimsInvoice(invoice(), seller, context()).ReferenceDetails).toEqual({ PreviousIrn: "", RelatedDocument: null, }); expect( toEimsInvoice(invoice(), seller, context({ previousIrn: null, relatedDocument: "CN-9" })) .ReferenceDetails, ).toEqual({ PreviousIrn: null, RelatedDocument: "CN-9" }); }); it("emits ExchangeRate only when supplied", () => { expect(toEimsInvoice(invoice(), seller, context()).ValueDetails.ExchangeRate).toBeUndefined(); const usd = toEimsInvoice( invoice({ currency: "USD" }), seller, context({ exchangeRate: 132.5 }), ); expect(usd.ValueDetails).toMatchObject({ InvoiceCurrency: "USD", ExchangeRate: 132.5 }); }); it("honours a caller-supplied date formatter", () => { const doc = toEimsInvoice(invoice(), seller, context({ formatDate: () => "2026-08-07T09:05:03Z" })); expect(doc.DocumentDetails.Date).toBe("2026-08-07T09:05:03Z"); }); it("throws when tax treatment cannot be resolved for a line", () => { expect(() => toEimsInvoice( invoice(), seller, context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0, discount: 0 }) }), ), ).toThrow(/unresolved tax treatment for line 1/); expect(() => toEimsInvoice( invoice(), seller, context({ taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: NaN }), }), ), ).toThrow(/unresolved tax treatment for line 1/); }); it("throws on a missing buyer TIN, no lines, or an unissued invoice", () => { expect(() => toEimsInvoice(invoice({ company: null }), seller, context())).toThrow(/buyer company TIN/); expect(() => toEimsInvoice(invoice({ lines: [] }), seller, context())).toThrow(/has no lines/); expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/); }); describe("debit/credit notes — confirmed by MoR support, same /v1/register endpoint", () => { it("defaults DocumentDetails.Type to INV with no Reason field", () => { const doc = toEimsInvoice(invoice(), seller, context()); expect(doc.DocumentDetails.Type).toBe("INV"); expect(doc.DocumentDetails).not.toHaveProperty("Reason"); }); it("files a credit note with Type, Reason and RelatedDocument", () => { const doc = toEimsInvoice( invoice(), seller, context({ documentType: "CRE", reason: "Overbilled freight charge", relatedDocument: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", }), ); expect(doc.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" }); expect(doc.ReferenceDetails.RelatedDocument).toBe( "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", ); }); it("files a debit note the same way", () => { const doc = toEimsInvoice( invoice(), seller, context({ documentType: "DEB", reason: "Additional handling fee", relatedDocument: "IRN-1" }), ); expect(doc.DocumentDetails).toMatchObject({ Type: "DEB", Reason: "Additional handling fee" }); }); it("throws when a credit/debit note has no reason", () => { expect(() => toEimsInvoice( invoice(), seller, context({ documentType: "CRE", reason: null, relatedDocument: "IRN-1" }), ), ).toThrow(/needs a reason/); }); it("throws when a credit/debit note has no relatedDocument", () => { expect(() => toEimsInvoice( invoice(), seller, context({ documentType: "CRE", reason: "Overbilled", relatedDocument: null }), ), ).toThrow(/needs.*relatedDocument/); }); }); it("throws when the lines do not sum to the invoice total", () => { expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow( /lines sum to 11000 but the invoice total is 9000/, ); }); it("throws on a non-ETB invoice with no exchange rate", () => { expect(() => toEimsInvoice(invoice({ currency: "USD" }), seller, context())).toThrow(/needs an exchangeRate/); }); }); describe("toEimsInvoice — MoR field constraints", () => { it("passes a buyer region through when it is already a MoR code", () => { const doc = toEimsInvoice(invoice(), seller, context()); expect(doc.BuyerDetails.Region).toBe("13"); }); it("maps a region name to its code, ignoring case and spacing", () => { const doc = toEimsInvoice( invoice({ company: { ...invoice().company!, region: " addis ababa " } }), seller, context({ buyerRegionCodes: { "Addis Ababa": "13" } }), ); expect(doc.BuyerDetails.Region).toBe("13"); }); it("refuses to file a buyer whose region has no mapping", () => { expect(() => toEimsInvoice( invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }), seller, context(), ), ).toThrow(/not a MoR Region code and has no mapping/); }); it("refuses a buyer with no region at all rather than guessing one", () => { expect(() => toEimsInvoice( invoice({ company: { ...invoice().company!, region: null } }), seller, context(), ), ).toThrow(/buyer Region \(unset\)/); }); it("passes a buyer wereda through when it is already a MoR code", () => { const doc = toEimsInvoice(invoice(), seller, context()); expect(doc.BuyerDetails.Wereda).toBe("574"); }); it("maps a wereda name to its code", () => { const doc = toEimsInvoice( invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), seller, context({ buyerWeredaCodes: { Yeka: "99" } }), ); expect(doc.BuyerDetails.Wereda).toBe("99"); }); it("refuses to file a buyer whose wereda has no mapping", () => { expect(() => toEimsInvoice( invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), seller, context({ buyerWeredaCodes: {} }), ), ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); }); it("derives City from the buyer's zone via the city code map", () => { const doc = toEimsInvoice( invoice({ company: { ...invoice().company!, zone: "Kirkos" } }), seller, context({ buyerCityCodes: { Kirkos: "101" } }), ); expect(doc.BuyerDetails.City).toBe("101"); }); it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => { const doc = toEimsInvoice( invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }), seller, context({ buyerCityCodes: {} }), ); expect(doc.BuyerDetails.City).toBeNull(); }); it("maps a buyer country name to its code via the country code map", () => { const doc = toEimsInvoice( invoice({ company: { ...invoice().company!, country: "Djibouti" } }), seller, context({ buyerCountryCodes: { Djibouti: "071" } }), ); expect(doc.BuyerDetails.Country).toBe("071"); }); it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => { const doc = toEimsInvoice( invoice({ company: { ...invoice().company!, country: "Ethiopia" } }), seller, context({ buyerCountryCode: "231", buyerCountryCodes: {} }), ); expect(doc.BuyerDetails.Country).toBe("231"); }); it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => { expect(() => toEimsInvoice( invoice({ company: { ...invoice().company!, country: "Kenya" } }), seller, context({ buyerCountryCode: "231", buyerCountryCodes: {} }), ), ).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/); }); it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" })); expect(doc.ItemList[0].NatureOfSupplies).toBe("service"); }); it("rejects a NatureOfSupplies MoR does not accept", () => { expect(() => toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })), ).toThrow(/must be one of goods, service/); }); }); describe("formatEimsDate", () => { it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => { expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00"); }); });