diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 54da939cd..2c636eee4 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -192,6 +192,9 @@ 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 +# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is +# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess. +EIMS_BUYER_WEREDA_CODES= 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 210667409..a8a929ca5 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -87,6 +87,8 @@ export interface EimsInvoiceConfig { * locally rather than being filed with a guessed one. */ buyerRegionCodes: Record; + /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ + buyerWeredaCodes: Record; cashierName: string | null; salesPersonName: string | null; } @@ -110,7 +112,7 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n }; /** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */ -const parseRegionCodes = (raw: string | undefined): Record => { +const parseCodeMap = (raw: string | undefined): Record => { const map: Record = {}; for (const pair of (raw ?? "").split(",")) { const [name, code] = pair.split("="); @@ -184,7 +186,8 @@ 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, - buyerRegionCodes: parseRegionCodes(process.env.EIMS_BUYER_REGION_CODES), + buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), + buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_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 45b4d33bd..fa0876310 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 @@ -61,6 +61,7 @@ const context = (over: Partial = {}): EimsMapperContext => ({ incomeWithholdValue: 0, transactionWithholdValue: 0, buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: {}, ...over, }); @@ -230,7 +231,7 @@ describe("toEimsInvoice — MoR field constraints", () => { seller, context(), ), - ).toThrow(/not a MoR region code and has no mapping/); + ).toThrow(/not a MoR Region code and has no mapping/); }); it("refuses a buyer with no region at all rather than guessing one", () => { @@ -240,7 +241,31 @@ describe("toEimsInvoice — MoR field constraints", () => { seller, context(), ), - ).toThrow(/buyer region \(unset\)/); + ).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("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { 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 1e6f8958d..0c4b40829 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 @@ -219,6 +219,13 @@ export interface EimsMapperContext { * tax document is worse than refusing to file. */ buyerRegionCodes: Record; + /** + * Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names + * ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an + * error, so this is precautionary rather than confirmed — but the fix is identical either way: + * fail locally on an unmapped name rather than file a guess. + */ + buyerWeredaCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; buyerCity?: string | null; @@ -229,8 +236,13 @@ export interface EimsMapperContext { formatDate?: (issuedAt: Date) => string; } -/** MoR's own constraint on `Region`, on both the seller and buyer sides: one to three digits. */ -const REGION_CODE = /^[0-9]{1,3}$/; +/** + * MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused + * as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller + * "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex + * the way it named Region's. + */ +const LOCATION_CODE = /^[0-9]{1,3}$/; /** * The only two values MoR accepts for `NatureOfSupplies`, lowercase. @@ -261,26 +273,29 @@ export const formatEimsDate = (issuedAt: Date): string => * 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. + * A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric, + * otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending + * a guessed code onto a tax document is worse than refusing to file. */ -function resolveRegionCode( - region: string | null | undefined, +function resolveLocationCode( + field: "Region" | "Wereda", + value: string | null | undefined, codes: Record, + envVar: string, invoiceNumber: string, ): string { - const raw = (region ?? "").trim(); - if (REGION_CODE.test(raw)) return raw; + const raw = (value ?? "").trim(); + if (LOCATION_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; + if (mapped && LOCATION_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.", + `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + + `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, ); } @@ -381,12 +396,24 @@ export function toEimsInvoice( Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, - Region: resolveRegionCode(company.region, context.buyerRegionCodes, invoice.invoiceNumber), + Region: resolveLocationCode( + "Region", + company.region, + context.buyerRegionCodes, + "EIMS_BUYER_REGION_CODES", + invoice.invoiceNumber, + ), Country: context.buyerCountryCode ?? null, Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, - Wereda: company.woreda ?? null, + Wereda: resolveLocationCode( + "Wereda", + company.woreda, + context.buyerWeredaCodes, + "EIMS_BUYER_WEREDA_CODES", + invoice.invoiceNumber, + ), }, DocumentDetails: { DocumentNumber: context.documentNumber, 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 adb6826ec..4e7a82069 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 @@ -144,6 +144,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, buyerRegionCodes: invoice.buyerRegionCodes, + buyerWeredaCodes: invoice.buyerWeredaCodes, exchangeRate: input.exchangeRate ?? 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 d30d28f5b..edd079b51 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 @@ -34,6 +34,7 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn unitDefault: "PCS", buyerCountryCode: null, buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code cashierName: null, salesPersonName: null, ...over,