From 8d3dfa4113d905d3999dcf93176d7de7f2dadbaf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 07:47:03 +0000 Subject: [PATCH 1/2] fix(eims): map buyer Wereda to a MoR code too, fail locally if unmapped BuyerDetails.Wereda had the same problem Region did: companies.woreda holds names ("Yeka") MoR has no confirmed regex for, but every Wereda value MoR has actually shown us (seller "12"/"13", the collection's "574") is 1-3 digits like Region. Precautionary, not confirmed -- but the fix is identical either way: resolve through EIMS_BUYER_WEREDA_CODES and refuse to file rather than send a guessed code. Generalises the Region resolver (resolveRegionCode -> resolveLocationCode) to cover both fields instead of duplicating it. No code was invented for "Yeka" -- EIMS_BUYER_WEREDA_CODES ships empty, so this buyer now fails locally (new stop) instead of silently sending a name that was never verified against MoR's schema. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 3 ++ .../edr-freight-api/src/config/eims.config.ts | 7 ++- .../billing/eims-invoice.mapper.spec.ts | 29 +++++++++- .../modules/billing/eims-invoice.mapper.ts | 53 ++++++++++++++----- .../src/modules/eims/eims-invoice-context.ts | 1 + .../src/modules/eims/eims-test-fixtures.ts | 1 + 6 files changed, 77 insertions(+), 17 deletions(-) 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, From 375cf55e5f06fb336ffa989dc2aa51cda08d8ad0 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 07:48:39 +0000 Subject: [PATCH 2/2] LM map missing map api fall back fix --- apps/edr-freight-web/backoffice/.env.example | 6 ++++++ .../backoffice/src/pages/fleet/TrackingPage.tsx | 2 ++ .../src/pages/bookings/new-booking-form/LocationPicker.tsx | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index 454817139..36bdb80de 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -12,3 +12,9 @@ VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 # observability stays off (the app works either way). Self-hosted instance. VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx VITE_POSTHOG_HOST=https://posthog.example.com + +# Maps JavaScript API key (fleet TrackingPage). Required — the hardcoded +# fallback in TrackingPage.tsx is expired (ExpiredKeyMapError), so without +# this set the tracking map renders blank. Get a key from the Google Cloud +# Console (Maps JavaScript API + Places API + Geocoding API enabled). +VITE_GOOGLE_MAPS_API_KEY= diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index 958d99775..b773e9c7c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -34,6 +34,8 @@ import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.serv import { freightBrand } from "@/theme/freight-brand"; // Same default key + env override the portal's LocationPicker uses. +// NOTE: fallback key is EXPIRED (ExpiredKeyMapError) — set +// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key. const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 8832279ad..45d133c5c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -47,6 +47,12 @@ interface PlacePrediction { // Maps JavaScript API keys are public client-side keys (lock them down by // HTTP-referrer in the Google Cloud console). The env var lets deployments // override the default key without a code change. +// +// NOTE: the fallback key below is EXPIRED (confirmed via live request — +// "Google Maps JavaScript API error: ExpiredKeyMapError"), which renders +// this picker's map blank while the search box spins forever. Set +// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key to fix it; don't +// rely on this default. const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";