From aec4f3d654b80e9c2319f41ae66117f023fbbba0 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 05:27:07 +0000 Subject: [PATCH] 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 --- apps/edr-freight-api/.env.example | 9 ++- .../edr-freight-api/src/config/eims.config.ts | 20 ++++++- .../billing/eims-invoice.mapper.spec.ts | 50 +++++++++++++++- .../modules/billing/eims-invoice.mapper.ts | 59 +++++++++++++++---- .../src/modules/eims/eims-invoice-context.ts | 35 ++++++++++- .../eims-invoice-registration.service.spec.ts | 12 ++-- .../eims/eims-invoice-registration.service.ts | 17 ++++-- .../src/modules/eims/eims-test-fixtures.ts | 2 +- 8 files changed, 174 insertions(+), 30 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 53d599de5..54da939cd 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -172,8 +172,10 @@ EIMS_SELLER_LOCALITY= # Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails # locally, naming the missing variables, until these are set. -# MoR enum: TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH -EIMS_TAX_CODE=VAT0 +# Required, and deliberately unset: the choice is a tax position, not a default. +# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH +# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt). +EIMS_TAX_CODE= EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 @@ -187,6 +189,9 @@ EIMS_PAYMENT_TERM=IMMIDIATE EIMS_UNIT_DEFAULT=PCS # MoR numeric country code for the buyer; our companies store the country name. EIMS_BUYER_COUNTRY_CODE= +# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$. +# An unmapped region fails locally rather than being filed with a guess. +EIMS_BUYER_REGION_CODES=Addis Ababa=13 EIMS_CASHIER_NAME= EIMS_SALESPERSON_NAME= # Automatic filing of issued invoices (@Cron sweep, one invoice per tick). diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 659377c77..210667409 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -81,8 +81,12 @@ export interface EimsInvoiceConfig { paymentTerm: string; unitDefault: string; buyerCountryCode: string | null; - /** MoR region code used when a buyer's stored region is a name rather than a code. */ - buyerRegionFallback: string | null; + /** + * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` + * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails + * locally rather than being filed with a guessed one. + */ + buyerRegionCodes: Record; cashierName: string | null; salesPersonName: string | null; } @@ -105,6 +109,16 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n return value; }; +/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */ +const parseRegionCodes = (raw: string | undefined): Record => { + const map: Record = {}; + for (const pair of (raw ?? "").split(",")) { + const [name, code] = pair.split("="); + if (name?.trim() && code?.trim()) map[name.trim()] = code.trim(); + } + return map; +}; + /** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ const optionalNumber = (raw: string | undefined, name: string): number | null => { if (raw === undefined || raw === "") return null; @@ -170,7 +184,7 @@ export default registerAs("eims", (): EimsConfig => { paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, - buyerRegionFallback: process.env.EIMS_BUYER_REGION_FALLBACK || null, + buyerRegionCodes: parseRegionCodes(process.env.EIMS_BUYER_REGION_CODES), cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, }, diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index aef4ac163..45b4d33bd 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -60,6 +60,7 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, + buyerRegionCodes: { "Addis Ababa": "13" }, ...over, }); @@ -130,7 +131,7 @@ describe("toEimsInvoice", () => { ExciseTaxValue: 0, TotalLineAmount: 11500, Unit: "PCS", - NatureOfSupplies: "Service", + NatureOfSupplies: "service", HarmonizationCode: null, }); expect(doc.ItemList[1]).toMatchObject({ @@ -207,6 +208,53 @@ describe("toEimsInvoice", () => { }); }); +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"); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index 0bd5d5a92..1e6f8958d 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -211,13 +211,14 @@ export interface EimsMapperContext { /** MoR numeric country code for the buyer; our DB stores the country name. */ buyerCountryCode?: string | null; /** - * Region code to use when the buyer's stored region is not already one. + * Region name → MoR numeric code, for buyers whose stored region is free text. * - * MoR validates `BuyerDetails.Region` against `^[0-9]{1,3}$`, but `companies.region` is free - * text ("Addis Ababa"). Rather than ship a name→code table we cannot verify, a stored value that - * already looks like a code is passed through and anything else falls back to this. + * `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. */ - buyerRegionFallback?: string | null; + buyerRegionCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; buyerCity?: string | null; @@ -228,9 +229,17 @@ export interface EimsMapperContext { formatDate?: (issuedAt: Date) => string; } -/** MoR's own constraint on `Region`: one to three digits. */ +/** MoR's own constraint on `Region`, on both the seller and buyer sides: one to three digits. */ const REGION_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)}`); @@ -251,6 +260,30 @@ export const formatEimsDate = (issuedAt: Date): string => * an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no * exchange rate. */ +/** + * A buyer's region as a MoR code: passed through when already numeric, otherwise looked up by name + * (case- and space-insensitive). Throws when neither applies. + */ +function resolveRegionCode( + region: string | null | undefined, + codes: Record, + invoiceNumber: string, +): string { + const raw = (region ?? "").trim(); + if (REGION_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 && REGION_CODE.test(mapped)) return mapped; + + throw new Error( + `EIMS mapping: invoice ${invoiceNumber} has buyer region ${raw ? `"${raw}"` : "(unset)"}, ` + + "which is not a MoR region code and has no mapping. Add it to EIMS_BUYER_REGION_CODES.", + ); +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -277,6 +310,14 @@ export function toEimsInvoice( 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); @@ -296,7 +337,7 @@ export function toEimsInvoice( Discount: 0, ExciseTaxValue, HarmonizationCode: null, - NatureOfSupplies: context.natureOfSupplies, + NatureOfSupplies: natureOfSupplies, ItemCode: line.chargeType, ProductDescription: line.description?.trim() || line.chargeType, PreTaxValue, @@ -340,9 +381,7 @@ export function toEimsInvoice( Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, - Region: REGION_CODE.test(company.region ?? "") - ? (company.region as string) - : (context.buyerRegionFallback ?? null), + Region: resolveRegionCode(company.region, context.buyerRegionCodes, invoice.invoiceNumber), Country: context.buyerCountryCode ?? null, Zone: company.zone ?? null, Kebele: company.kebele ?? null, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 8f81b5f78..adb6826ec 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -57,6 +57,37 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void { `(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`, }); } + + assertSellerFormats(config.invoice); +} + +/** + * MoR's own patterns for the seller fields, checked here rather than at the gateway. + * + * A placeholder like `_` is "set" but unfilable, and finding that out costs a real request and a + * consumed counter — these are the exact regexes its 400 SCHEMA ERROR quoted back at us. + */ +const SELLER_FORMATS: { env: string; value: (i: EimsConfig["invoice"]) => string; pattern: RegExp }[] = [ + { env: "EIMS_SELLER_PHONE", value: (i) => i.sellerPhone, pattern: /^\+?[0-9]{6,}$/ }, + { + env: "EIMS_SELLER_EMAIL", + value: (i) => i.sellerEmail, + pattern: /^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/, + }, + { env: "EIMS_SELLER_REGION", value: (i) => i.sellerRegion, pattern: /^[0-9]{1,3}$/ }, + { env: "EIMS_SELLER_WEREDA", value: (i) => i.sellerWereda, pattern: /^[0-9A-Za-z]{1,10}$/ }, +]; + +function assertSellerFormats(invoice: EimsConfig["invoice"]): void { + const bad = SELLER_FORMATS.filter(({ value, pattern }) => !pattern.test(value(invoice))).map( + ({ env, pattern }) => `${env} (must match ${pattern.source})`, + ); + if (bad.length > 0) { + throw new BadRequestException({ + code: "EIMS_INVOICE_CONFIG_INVALID", + message: `EIMS seller details would be rejected by MoR: ${bad.join("; ")}`, + }); + } } export function buildEimsSeller(config: EimsConfig): EimsSellerDetails { @@ -112,9 +143,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, - // companies.region is free text ("Addis Ababa"); MoR wants ^[0-9]{1,3}$. A stored value that - // already looks like a code wins, otherwise the seller's own region stands in. - buyerRegionFallback: invoice.buyerRegionFallback || invoice.sellerRegion, + buyerRegionCodes: invoice.buyerRegionCodes, exchangeRate: input.exchangeRate ?? null, }; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index f32b78000..66e27da6b 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -421,10 +421,14 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { ); await service.registerInvoiceWithEims(OTHER_INVOICE_ID); - // A refused document returns its counter, so the next attempt reuses it — MoR expects a - // contiguous sequence of *accepted* documents, not of attempts. - expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); - expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); + // The two numbers move differently, because MoR constrains them differently: the counter must + // not skip (it returns), the document number must not repeat (it is burned). + const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest; + expect(first.SourceSystem.InvoiceCounter).toBe(7); + expect(second.SourceSystem.InvoiceCounter).toBe(7); + expect(first.DocumentDetails.DocumentNumber).toBe("5"); + expect(second.DocumentDetails.DocumentNumber).toBe("6"); }); }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 66131ed01..64854bf53 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -397,10 +397,15 @@ export class EimsInvoiceRegistrationService { * ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown * for every later document. * - * Returning the counter is not an optimisation — MoR tracks the sequence itself and rejects a - * gap: "Invoice counter is not correct. expected : 1". A document it definitively refused was - * never counted on its side, so ours must not advance either. An ambiguous result is the - * opposite case: MoR may have counted it, so the number stays spent until a human resolves it. + * The two numbers move differently, because MoR constrains them differently: + * + * - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A + * document MoR definitively refused was never counted there, so ours must not advance either. + * - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not + * unique". It is therefore spent by the attempt itself and never handed back, even for a + * refusal. + * + * An ambiguous result keeps both: MoR may have counted and stored the document. */ private async settleFailure( invoiceId: string, @@ -429,9 +434,9 @@ export class EimsInvoiceRegistrationService { reservation.stateId, deterministic ? { - // Hand both numbers back: MoR never counted a document it refused outright. + // Counter returns (MoR never counted a refused document); the document number does + // not (MoR requires it to be unique, so it is burned by the attempt). nextInvoiceCounter: reservation.invoiceCounter, - nextDocumentNumber: Number(reservation.documentNumber), inFlightInvoiceId: null, inFlightCounter: null, inFlightDocumentNumber: null, diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index d620cfa40..d30d28f5b 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -33,7 +33,7 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, - buyerRegionFallback: "13", + buyerRegionCodes: { "Addis Ababa": "13" }, cashierName: null, salesPersonName: null, ...over,