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 { tryResolveMorGeo } from "../../config/mor-location.resolver"; import { EimsSellerDetails } 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); // e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved through the // same MoR location master the buyer side uses, since the geography is objective, not // buyer-specific. `tryResolveMorGeo` never throws: an address MoR does not list simply // leaves these fields to getSellerDetails' static-config fallback, which is authoritative // anyway (see the class comment — MoR has already cleared the static seller values under // rule 7017, so nothing here may override one). e-Trade carries no country field; the // resolver reads a blank country as domestic, which is correct for EDR's own registration. const geo = tryResolveMorGeo({ region: data.region, zone: data.zone, woreda: data.woreda, }); this.cached = { // The *legal* entity name, not the licence's trade name that // `data.companyName` now carries — an EIMS seller is filed under its // registered name. LegalName: companyInfo?.BusinessName?.trim() || data.companyName || undefined, Phone: data.mobilePhone || data.regularPhone || undefined, Region: geo?.Region, Wereda: geo?.Wereda, City: geo?.City, }; } 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); }, ); }); } }