diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index cf77072e0..c5565cbc1 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -80,7 +80,19 @@ export interface EimsInvoiceConfig { paymentMode: string; paymentTerm: string; unitDefault: string; + /** + * Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the + * column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign + * buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never + * applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia. + */ buyerCountryCode: string | null; + /** + * Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format + * unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them — + * this is not validated against a fixed digit pattern, only looked up by name. + */ + buyerCountryCodes: Record; /** * 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 @@ -89,6 +101,14 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has + * no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike + * Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already + * succeeds with it null), so an unmapped zone falls back to null rather than failing the + * mapping. + */ + buyerCityCodes: Record; /** * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to @@ -208,8 +228,10 @@ 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, + buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES), taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index 21676c160..3767637a7 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -84,7 +84,13 @@ export class PdfRenderService { const page = await browser.newPage(); const thermal = opts.thermal ?? false; const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794; - await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 }); + // Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight + // is defined as the LARGER of the content's height and the viewport's own height, so a + // receipt shorter than the viewport would otherwise report the viewport height back, not + // its true content height — a real page-length trailing blank space bug, not theoretical + // (confirmed by actually rendering one). A short viewport forces content to overflow it, + // so scrollHeight always reflects the content, never the viewport. + await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 }); await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); await page.emulateMediaType("print"); await new Promise((resolve) => setTimeout(resolve, 250)); @@ -154,7 +160,7 @@ export class PdfRenderService { // browser context regardless, same as the closure form would be. const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number; const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM; - return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm); + return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100); } private injectPdfPrintStyles(html: string): string { 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 7406613fc..6d58a7bec 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,8 +60,11 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, + buyerCountryCode: "231", // test-only, not a confirmed real MoR code + buyerCountryCodes: {}, buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: {}, + buyerCityCodes: {}, ...over, }); @@ -92,6 +95,9 @@ describe("toEimsInvoice", () => { expect(doc.BuyerDetails).toEqual({ City: null, + // company.country is "Ethiopia" (the domestic default) — resolves to context's flat + // buyerCountryCode fallback, not null, per resolveCountryCode. + Country: "231", Email: "buyer@abc.et", HouseNumber: "NEW", IdNumber: null, @@ -100,7 +106,6 @@ describe("toEimsInvoice", () => { LegalName: "ABC Trading PLC", Phone: "0912345678", Region: "13", - Country: null, Zone: "SHA", Kebele: "03", VatNumber: "123475885858", @@ -335,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => { ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); }); + it("derives City from the buyer's zone via the city code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Kirkos" } }), + seller, + context({ buyerCityCodes: { Kirkos: "101" } }), + ); + expect(doc.BuyerDetails.City).toBe("101"); + }); + + it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }), + seller, + context({ buyerCityCodes: {} }), + ); + expect(doc.BuyerDetails.City).toBeNull(); + }); + + it("maps a buyer country name to its code via the country code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Djibouti" } }), + seller, + context({ buyerCountryCodes: { Djibouti: "071" } }), + ); + expect(doc.BuyerDetails.Country).toBe("071"); + }); + + it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Ethiopia" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ); + expect(doc.BuyerDetails.Country).toBe("231"); + }); + + it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Kenya" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ), + ).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/); + }); + 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"); 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 582e3d326..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 @@ -234,8 +234,13 @@ export interface EimsMapperContext { * from a registered invoice"). */ relatedDocument?: string | null; - /** MoR numeric country code for the buyer; our DB stores the country name. */ + /** + * Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already + * in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer. + */ buyerCountryCode?: string | null; + /** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */ + buyerCountryCodes: Record; /** * Region name → MoR numeric code, for buyers whose stored region is free text. * @@ -252,9 +257,15 @@ export interface EimsMapperContext { * fail locally on an unmapped name rather than file a guess. */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the + * closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already + * accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail + * the mapping. + */ + buyerCityCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; - buyerCity?: string | null; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; invoiceDiscount?: number | null; @@ -299,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string => * exchange rate. */ /** - * 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. + * A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already + * numeric, otherwise looked up by name (case- and space-insensitive). + * + * Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax + * document is worse than refusing to file. City is optional (`required: false`, City's own + * caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to + * null instead of blocking the invoice. */ function resolveLocationCode( - field: "Region" | "Wereda", + field: "Region" | "Wereda" | "City", value: string | null | undefined, codes: Record, envVar: string, invoiceNumber: string, -): string { + opts: { required?: boolean } = {}, +): string | null { const raw = (value ?? "").trim(); if (LOCATION_CODE.test(raw)) return raw; @@ -319,12 +335,61 @@ function resolveLocationCode( )?.[1]; if (mapped && LOCATION_CODE.test(mapped)) return mapped; + if (opts.required === false) return null; + throw new Error( `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}.`, ); } +/** + * A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies + * `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default). + * A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same + * "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's + * Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`. + */ +function resolveCountryCode( + country: string | null | undefined, + codes: Record, + domesticFallback: string | null, + invoiceNumber: string, +): string | null { + const raw = (country ?? "").trim(); + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + if (mapped) return mapped; + + if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback; + + throw new Error( + `EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` + + "MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.", + ); +} + +/** + * 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, @@ -440,7 +505,16 @@ export function toEimsInvoice( return { BuyerDetails: { - City: context.buyerCity ?? null, + // No dedicated city column on Company — Zone is the closest match; optional (see + // resolveLocationCode's City comment). + City: resolveLocationCode( + "City", + company.zone, + context.buyerCityCodes, + "EIMS_BUYER_CITY_CODES", + invoice.invoiceNumber, + { required: false }, + ), Email: company.email ?? null, HouseNumber: company.houseNo ?? null, IdNumber: context.buyerIdNumber ?? null, @@ -455,7 +529,12 @@ export function toEimsInvoice( "EIMS_BUYER_REGION_CODES", invoice.invoiceNumber, ), - Country: context.buyerCountryCode ?? null, + Country: resolveCountryCode( + company.country, + context.buyerCountryCodes, + context.buyerCountryCode ?? null, + invoice.invoiceNumber, + ), Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, 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-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index b77804eda..4c1c82c10 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 @@ -206,8 +206,10 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, + buyerCountryCodes: invoice.buyerCountryCodes, buyerRegionCodes: invoice.buyerRegionCodes, buyerWeredaCodes: invoice.buyerWeredaCodes, + buyerCityCodes: invoice.buyerCityCodes, // TEMPORARY — see EimsInvoiceConfig.buyerIdType. buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, 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-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index 93df29da2..650a834b8 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,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, + buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code + buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code taxCodeByChargeType: {}, taxRateByChargeType: {}, exciseByChargeType: {}, 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,