Files
edr-platform/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts
Hagernesh 09bc7c74d7 feat(eims): derive buyer City/Country from company profile, not global config
City: EimsMapperContext.buyerCity was declared but never wired anywhere —
always null, silently, for every buyer. No dedicated city column on Company;
derives from Zone via a new EIMS_BUYER_CITY_CODES map, same lookup mechanism
as Region/Wereda but optional (an unmapped zone resolves to null rather than
throwing) — MoR has already accepted a live filing with City null.

Country: previously a single flat EIMS_BUYER_COUNTRY_CODE applied to every
buyer regardless of Company.country. Now reads company.country, resolved via
a new EIMS_BUYER_COUNTRY_CODES name-to-code map; the flat env var becomes a
domestic-only fallback (applies only when country is empty/Ethiopia), so an
unmapped foreign buyer fails locally instead of silently filing as Ethiopia.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 07:43:04 +00:00

406 lines
13 KiB
TypeScript

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> = {}): 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> = {}): 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");
});
});