mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
A TIN routinely trades under a name that is not its registered one, and holds several licences with different trade names — of 58 TINs checked against eTrade, 8 had at least one licence whose trade name differs from the registered `BusinessName`, one of them across three licences. `extractRegistrationData` now resolves `companyName` from the selected licence's `TradeName`, falling back to `BusinessName` (16 of 309 licences carry a blank trade name, so the fallback is load-bearing). EIMS is pinned back to `BusinessName` for the seller's `LegalName`: an invoice is a MoR tax filing and must carry the legal entity, not the trade name. It is the only other caller.
160 lines
7.9 KiB
TypeScript
160 lines
7.9 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 { 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<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);
|
|
// 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<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);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
}
|