mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 14:20:58 +00:00
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>
406 lines
13 KiB
TypeScript
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");
|
|
});
|
|
});
|