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,