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 406952867..b8755c709 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 @@ -371,6 +371,25 @@ function resolveCountryCode( ); } +/** + * Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an + * error to and that must never throw — currently only `EimsSellerCacheService`, resolving + * e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code, + * name lookup, `undefined` on no match — the caller falls back to static config either way. + */ +export function resolveOptionalCode( + value: string | null | undefined, + codes: Record, +): string | undefined { + 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]; + return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined; +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index b990aa8f0..666634cb8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // Consumed by NotificationInboxModule for portal recipient targeting. ExternalProfileRepository, CompanyProfileRepository, + // Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup + // already used for every customer company at onboarding, reused for EDR's own TIN. + ETradeService, ], }) export class CompaniesModule { } 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 36c355bed..79d3204dd 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 @@ -11,7 +11,9 @@ import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; +import { buildEimsSeller } from "./eims-invoice-context"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsInvoiceStatus } from "./eims-registration.types"; @@ -172,6 +174,9 @@ const build = ( } as unknown as EimsAuthService, { notify } as unknown as NotificationInboxService, { directSend } as unknown as NotificationsService, + // Same static-config seller the real EimsSellerCacheService falls back to when it has never + // successfully fetched e-Trade — matches prior behavior for every test in this file. + { getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService, ); /** 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 ab9f953c5..2ca5f5ffd 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 @@ -27,12 +27,9 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; -import { - assertEimsInvoiceConfig, - buildEimsContext, - buildEimsSeller, -} from "./eims-invoice-context"; +import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; import { EimsInvoiceError, EimsInvoiceStatus, @@ -83,6 +80,7 @@ export class EimsInvoiceRegistrationService { private readonly auth: EimsAuthService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly sellerCache: EimsSellerCacheService, ) {} private get cfg(): EimsConfig { @@ -128,7 +126,7 @@ export class EimsInvoiceRegistrationService { // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. const request = toEimsInvoice( invoice, - buildEimsSeller(cfg), + this.sellerCache.getSellerDetails(cfg), buildEimsContext(cfg, { // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts new file mode 100644 index 000000000..7b4ff1b3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts @@ -0,0 +1,155 @@ +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; + +const registrationData = (over: Record = {}) => ({ + companyName: "Ethio-Djibouti Railway PLC (eTrade)", + region: "Addis Ababa", + zone: "Bole", + woreda: "Yeka", + mobilePhone: "0911000000", + regularPhone: "", + ...over, +}); + +const build = (cfg: EimsConfig = eimsConfig()) => { + const resolveCompanyData = jest.fn(); + const extractRegistrationData = jest.fn().mockReturnValue(registrationData()); + const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService; + const config = { get: () => cfg } as unknown as ConfigService; + const service = new EimsSellerCacheService(etrade, config); + return { service, resolveCompanyData, extractRegistrationData, cfg }; +}; + +const CODES = { + buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: { Yeka: "99" }, + buyerCityCodes: { Bole: "101" }, +}; + +describe("EimsSellerCacheService.getSellerDetails", () => { + it("static config wins over a conflicting e-Trade value", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ + companyInfo: {}, + businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked + }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + // The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's + // differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)". + expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C."); + }); + + it("e-Trade fills a field only when the static value is blank", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ + sellerLegalName: "", + sellerRegion: "", + sellerWereda: "", + sellerCity: null, + ...CODES, + }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + expect(seller.Region).toBe("13"); + expect(seller.Wereda).toBe("99"); + expect(seller.City).toBe("101"); + }); + + it("VatNumber and Email are always the static value, never touched by e-Trade", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.VatNumber).toBe("0000000000"); + expect(seller.Email).toBe("finance@example.et"); + }); + + it("falls back to the static config entirely when e-Trade has never been reachable", () => { + const cfg = eimsConfig(); + const { service } = build(cfg); + + // No refresh() ever called/succeeded — cached stays null. + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName); + expect(seller.Region).toBe(cfg.invoice.sellerRegion); + }); + + it("does no I/O at all — filing never triggers an e-Trade request", () => { + const { service, resolveCompanyData, cfg } = build(); + + service.getSellerDetails(cfg); + service.getSellerDetails(cfg); + + expect(resolveCompanyData).not.toHaveBeenCalled(); + }); +}); + +describe("EimsSellerCacheService.refresh", () => { + it("keeps the previous snapshot when a refresh fails", async () => { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down")); + await service.refresh(); + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + }); + + it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => { + jest.useFakeTimers(); + try { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves + const refreshing = service.refresh(); + await jest.advanceTimersByTimeAsync(10_000); + await refreshing; + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + } finally { + jest.useRealTimers(); + } + }); + + it("does not start a second e-Trade request while one is already in flight", async () => { + const { service, resolveCompanyData } = build(); + let resolveCall: (value: unknown) => void = () => {}; + resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve))); + + const first = service.refresh(); + const second = service.refresh(); + resolveCall({ companyInfo: {}, businessInfo: {} }); + await Promise.all([first, second]); + + expect(resolveCompanyData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts new file mode 100644 index 000000000..a8d242929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper"; +import { buildEimsSeller } from "./eims-invoice-context"; + +const has = (value: string | null | undefined): value is string => Boolean(value && value.trim()); + +/** + * EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same + * e-Trade business-registry lookup already used for every customer company at onboarding — instead + * of the whole thing being hand-maintained `EIMS_SELLER_*` config. + * + * **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates + * `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested + * with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a + * value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the + * e-Trade-derived value when the static one is blank; a static value, once set, is never replaced. + * This also means the durable fallback is the static config, not this cache — the in-memory + * snapshot disappearing on a process restart is harmless, not a reliability gap: every field it + * could supply already has a working static value today, so filing is unaffected either way. + * + * `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response + * shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field + * exists anywhere in what e-Trade returns. They stay on static config permanently, same as + * `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch. + * + * Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/ + * position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain + * field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a + * refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate + * deviations from that precedent, both because `ETradeService` has no request timeout configured + * at all (confirmed by reading it) and is a third-party dependency, unlike the DB: + * - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot; + * - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight + * returns the same in-flight promise instead of starting a duplicate request. + * + * `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration + * never depends on e-Trade being reachable at that moment, whether or not it ever has been. + */ +@Injectable() +export class EimsSellerCacheService implements OnModuleInit { + private readonly logger = new Logger(EimsSellerCacheService.name); + + /** Only the e-Trade-derived fields, used solely to fill a blank static value. */ + private cached: Partial | null = null; + /** Concurrency guard — a second `refresh()` call while one is running joins it. */ + private refreshing: Promise | null = null; + + // ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a + // filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever + // matters; EDR's own business registration changes rarely enough that this is a generous + // ceiling, not a real one. + private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; + /** Bounded locally since `ETradeService` itself sets none — see the class comment. */ + private static readonly REFRESH_TIMEOUT_MS = 10_000; + + constructor( + private readonly etrade: ETradeService, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + void this.refresh(); + const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS); + timer.unref?.(); + } + + /** + * Static config wins whenever it's non-blank — that's the value already confirmed against MoR. + * e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on + * every registration. + */ + getSellerDetails(cfg: EimsConfig): EimsSellerDetails { + const fallback = buildEimsSeller(cfg); + const e = this.cached; + return { + ...fallback, + LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName), + Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone), + Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region), + Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda), + City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City), + }; + } + + /** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */ + async refresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = this.doRefresh().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + + private async doRefresh(): Promise { + try { + const cfg = this.config.get("eims")!; + const { companyInfo, businessInfo } = await this.withTimeout( + this.etrade.resolveCompanyData(cfg.tin), + EimsSellerCacheService.REFRESH_TIMEOUT_MS, + ); + if (!businessInfo) return; // no licence on file yet — keep the previous snapshot + const data = this.etrade.extractRegistrationData(businessInfo, companyInfo); + const codes = cfg.invoice; + this.cached = { + LegalName: data.companyName || undefined, + Phone: data.mobilePhone || data.regularPhone || undefined, + // e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the + // same buyer code maps, since the geography is objective, not buyer-specific, despite the + // env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to + // getSellerDetails' static-config fallback. + Region: resolveOptionalCode(data.region, codes.buyerRegionCodes), + Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes), + City: resolveOptionalCode(data.zone, codes.buyerCityCodes), + }; + } catch (err) { + this.logger.warn( + `EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`, + ); + } + } + + /** + * `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only + * stops *waiting* on the request — nothing cancels the underlying HTTP call (no + * `AbortController` wired into `ETradeService`), so a timed-out request may still complete in + * the background; its result is simply never read. + */ + private withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 0ee22ec88..5d55a7ee8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { DocumentsModule } from "../billing/documents/documents.module"; +import { CompaniesModule } from "../companies/companies.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { EimsAuthService } from "./eims-auth.service"; @@ -14,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider"; import { EimsInvoiceController } from "./eims-invoice.controller"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsReceiptService } from "./eims-receipt.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSignerService } from "./eims-signer.service"; import { EimsReceipt } from "./entities/eims-receipt.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -33,6 +35,10 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; // For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain // deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle. DocumentsModule, + // For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with + // ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule, + // so this stays a plain one-directional import, not a new cycle. + CompaniesModule, ], controllers: [EimsInvoiceController], providers: [ @@ -44,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAutoSubmitService, EimsCancellationService, EimsReceiptService, + EimsSellerCacheService, ], exports: [ EimsAuthService,