Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts
Hagernesh 28c9dd93e0 feat(eims): seller identity enriched from e-Trade, cached (bootstrap only)
Ten EIMS_SELLER_* env vars were the only source of EDR's own seller identity,
duplicating data the platform already has via the same e-Trade lookup used
for every customer company at onboarding. EimsSellerCacheService now enriches
it — but static config remains the source of truth: MoR validates
SellerDetails against its own taxpayer registry (rule 7017, already cleared
against the current static values), so e-Trade fills a field only when the
static value is blank, never overrides one already confirmed. The static
config is therefore the durable fallback, not the cache; an in-memory
snapshot lost on restart is harmless.

ETradeService has no request timeout of its own and no AbortController, so
the cache enforces one locally (stops waiting, doesn't cancel the request)
and de-duplicates concurrent refresh() calls into the same in-flight promise.
getSellerDetails() is fully synchronous — zero I/O — so live registration
never depends on e-Trade being reachable, at boot or per invoice.

VatNumber and Email stay on static config permanently — confirmed by reading
e-Trade's actual response shapes, neither field exists anywhere in what it
returns. Region/Wereda/City reuse the existing EIMS_BUYER_*_CODES maps rather
than adding seller-specific ones — the geography is objective, not
buyer-specific.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 07:43:04 +00:00

148 lines
7.3 KiB
TypeScript

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<EimsSellerDetails> | null = null;
/** Concurrency guard — a second `refresh()` call while one is running joins it. */
private refreshing: Promise<void> | 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<void> {
if (this.refreshing) return this.refreshing;
this.refreshing = this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(): Promise<void> {
try {
const cfg = this.config.get<EimsConfig>("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<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((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);
},
);
});
}
}