Files
edr-platform/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts
Hagernesh aec4f3d654 fix(eims): match MoR's payload rules found by live rejections
Three live attempts turned six guesses into facts. Each fix below is the
gateway's own words, not a reading of the collection.

DocumentNumber and InvoiceCounter move differently, because MoR constrains
them differently. The counter must not skip -- "Invoice counter is not
correct. expected : 1" -- so a definitively refused document hands it back.
The document number must not repeat, so the attempt burns it. Both stay spent
after an ambiguous result, where MoR may have stored the document.

NatureOfSupplies is normalised to MoR's exact lowercase constant and rejected
outright if it is neither 'goods' nor 'service'; its schema branches on this
as a oneOf, so "Service" invalidated the whole ItemList.

Buyer region resolves through a name->code map and now FAILS locally when
unmapped. MoR validates Region against ^[0-9]{1,3}$ on both the seller and
buyer sides, so a name can never be sent and a guessed code on a tax document
is worse than refusing to file.

Seller phone, email, region and wereda are checked against MoR's own regexes
before anything is sent, so a placeholder like "_" fails locally instead of
costing a request and a counter.

EIMS_TAX_CODE stays required and unset in .env.example: the choice between
VAT0 (zero-rated) and VATEX (exempt) is a tax position awaiting finance, and
MoR's enum is recorded there for whoever decides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 06:57:50 +00:00

263 lines
8.1 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 }),
natureOfSupplies: "Service",
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerRegionCodes: { "Addis Ababa": "13" },
...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,
Email: "buyer@abc.et",
HouseNumber: "NEW",
IdNumber: null,
IdType: null,
Tin: "0999930000",
LegalName: "ABC Trading PLC",
Phone: "0912345678",
Region: "13",
Country: null,
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 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
}),
);
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,
TotalLineAmount: 11500,
Unit: "PCS",
NatureOfSupplies: "service",
HarmonizationCode: null,
});
expect(doc.ItemList[1]).toMatchObject({
LineNumber: 2,
ProductDescription: "HAZARD_SURCHARGE",
TaxCode: "EXEMPT",
TaxAmount: 0,
ExciseTaxValue: 50,
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 }) }),
),
).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/);
});
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("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");
});
});