mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
A TIN holds many business licences split by activity — export of coffee, freight forwarding, import of vehicles — but the company picked one for its whole record, so every operational role shared it. Each profile now names the business it actually operates as. Stored on `company_profiles.etrade_business` as a snapshot (licence number, trade name, activity, renewal) rather than a bare licence number, so the portal and backoffice can show it without an eTrade round-trip — that API is slow, serves a broken TLS chain and is regularly down. Not unique: one business may legitimately back several roles. The licence number is a client input, so it is never stored as sent — `ETradeService.findBusinessOption` looks it up under the company's own TIN and persists eTrade's record, which makes another company's licence simply unfindable. Choosing one is required wherever the customer adds a role with a TIN already on file. The onboarding wizard is the exception by necessity: it picks roles on its first step, before a TIN exists, so there is nothing to choose from yet. There it is enforced through `getOnboardingRequirements` instead — an unattached role is reported outstanding and blocks submission — and the picker sits on the documents step beside that role's licence upload. Lifted entirely for a co-operative or investment-licence company: eTrade holds no record for its TIN, so the requirement would be unsatisfiable.
201 lines
7.4 KiB
TypeScript
201 lines
7.4 KiB
TypeScript
import { Injectable, BadRequestException } from "@nestjs/common";
|
|
import { HttpService } from "@nestjs/axios";
|
|
import { Agent } from "https";
|
|
import { firstValueFrom } from "rxjs";
|
|
import {
|
|
ETradeCompanyInfo,
|
|
ETradeBusinessInfo,
|
|
ETradeBusinessOption,
|
|
CompanyRegistrationData,
|
|
normalizeRegion,
|
|
} from "@edr/types";
|
|
|
|
@Injectable()
|
|
export class ETradeService {
|
|
private readonly baseUrl = "https://etrade.gov.et/api";
|
|
private readonly referer = "https://etrade.gov.et/business-license-checker";
|
|
|
|
/**
|
|
* The eTrade server serves an incomplete TLS chain (it omits the intermediate
|
|
* CA cert), so Node rejects the handshake with UNABLE_TO_GET_ISSUER_CERT.
|
|
* Scope a relaxed agent to these outbound calls only — the rest of the app
|
|
* keeps full certificate verification.
|
|
*/
|
|
private readonly httpsAgent = new Agent({ rejectUnauthorized: false });
|
|
|
|
constructor(private readonly httpService: HttpService) {}
|
|
|
|
async getCompanyInfoByTin(tin: string): Promise<ETradeCompanyInfo> {
|
|
const url = `${this.baseUrl}/Registration/GetRegistrationInfoByTin/${tin}/en`;
|
|
try {
|
|
const response = await firstValueFrom(
|
|
this.httpService.get<ETradeCompanyInfo>(url, {
|
|
headers: { Referer: this.referer },
|
|
httpsAgent: this.httpsAgent,
|
|
}),
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
throw new BadRequestException(
|
|
`Failed to fetch company info from eTrade: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async getBusinessByLicenseNo(
|
|
licenseNo: string,
|
|
tin: string,
|
|
): Promise<ETradeBusinessInfo> {
|
|
const url = `${this.baseUrl}/BusinessMain/GetBusinessByLicenseNo`;
|
|
try {
|
|
const response = await firstValueFrom(
|
|
this.httpService.get<ETradeBusinessInfo>(url, {
|
|
params: {
|
|
LicenseNo: licenseNo,
|
|
Tin: tin,
|
|
Lang: "en",
|
|
},
|
|
headers: { Referer: this.referer },
|
|
httpsAgent: this.httpsAgent,
|
|
}),
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
throw new BadRequestException(
|
|
`Failed to fetch business info from eTrade: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param licenceNumber which of the TIN's licences to resolve. Defaults to the
|
|
* first one — a TIN with several licences is only unambiguous once the
|
|
* customer has picked one (see {@link ETradeBusinessOption}).
|
|
*/
|
|
async resolveCompanyData(
|
|
tin: string,
|
|
licenceNumber?: string,
|
|
): Promise<{
|
|
companyInfo: ETradeCompanyInfo;
|
|
businessInfo: ETradeBusinessInfo | null;
|
|
}> {
|
|
const companyInfo = await this.getCompanyInfoByTin(tin);
|
|
|
|
if (!companyInfo.Businesses || companyInfo.Businesses.length === 0) {
|
|
return { companyInfo, businessInfo: null };
|
|
}
|
|
|
|
// An unknown licence falls back to the first rather than 400-ing: eTrade can
|
|
// drop or renumber a licence between the customer picking it and the save
|
|
// that re-verifies it, and that must not lock them out of their own profile.
|
|
const selected =
|
|
companyInfo.Businesses.find((b) => b.LicenceNumber === licenceNumber) ??
|
|
companyInfo.Businesses[0];
|
|
try {
|
|
const businessInfo = await this.getBusinessByLicenseNo(
|
|
selected.LicenceNumber,
|
|
tin,
|
|
);
|
|
return { companyInfo, businessInfo };
|
|
} catch {
|
|
return { companyInfo, businessInfo: null };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `businessInfo` carries the selected licence's `TradeName`; `companyInfo`
|
|
* carries the registered organization name (`BusinessName`). The company name
|
|
* resolves to the trade name of the licence the customer picked — a TIN
|
|
* routinely trades under a name that is not its registered one, and the
|
|
* business they selected is the one they operate as here. `BusinessName` is
|
|
* the fallback, because eTrade leaves `TradeName` blank on plenty of licences.
|
|
* Never `ManagerNameEng`, which is the manager's personal name.
|
|
*
|
|
* Callers that need the legal entity (tax filings, EIMS seller details) must
|
|
* read `companyInfo.BusinessName` themselves — it is not this field.
|
|
*/
|
|
extractRegistrationData(
|
|
businessInfo: ETradeBusinessInfo,
|
|
companyInfo?: ETradeCompanyInfo,
|
|
): CompanyRegistrationData {
|
|
const primaryManager = businessInfo.AssociateShortInfos?.[0];
|
|
|
|
return {
|
|
companyName:
|
|
businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "",
|
|
licenceNumber: businessInfo.LicenceNumber,
|
|
statusDescription: businessInfo.StatusDescription,
|
|
dateRegistered: businessInfo.DateRegistered,
|
|
renewedFrom: businessInfo.RenewedFrom,
|
|
renewalDate: businessInfo.RenewalDate,
|
|
// RenewedTo is ISO ("2018-07-07T00:00:00"); RenewedToDateString matches
|
|
// RenewedFrom/RenewalDate's "M/D/YYYY" format — use that for consistency.
|
|
renewedTo: businessInfo.RenewedToDateString,
|
|
// eTrade returns uncoded uppercase text and sometimes a zone name in the
|
|
// Region slot. Map it onto the canonical list; an unresolved value yields
|
|
// "" so the form asks the user to pick rather than failing validation on
|
|
// save with a value they never typed.
|
|
region: normalizeRegion(businessInfo.AddressInfo?.Region) ?? "",
|
|
zone: businessInfo.AddressInfo?.Zone || "",
|
|
woreda: businessInfo.AddressInfo?.Woreda || "",
|
|
kebele: businessInfo.AddressInfo?.Kebele || "",
|
|
houseNo: businessInfo.AddressInfo?.HouseNo || "",
|
|
mobilePhone: businessInfo.AddressInfo?.MobilePhone || "",
|
|
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
|
|
managerName: primaryManager?.ManagerNameEng || "",
|
|
managerPhone: primaryManager?.RegularPhone || "",
|
|
businesses: (companyInfo?.Businesses ?? []).map(toBusinessOption),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Every business licence held under a TIN, as the customer picks them.
|
|
*
|
|
* Split out from {@link extractRegistrationData} because attaching a business
|
|
* to a company profile needs the list alone — no licence detail fetch, so one
|
|
* eTrade call instead of two.
|
|
*/
|
|
async listBusinessOptions(tin: string): Promise<ETradeBusinessOption[]> {
|
|
const companyInfo = await this.getCompanyInfoByTin(tin);
|
|
return (companyInfo.Businesses ?? []).map(toBusinessOption);
|
|
}
|
|
|
|
/**
|
|
* Resolve one of the TIN's licences, or throw if eTrade does not list it.
|
|
*
|
|
* This is the trust boundary for a client-supplied licence number: a profile
|
|
* may only ever be attached to a business eTrade actually holds under that
|
|
* TIN, so the snapshot that gets stored is eTrade's own data, never the
|
|
* client's.
|
|
*/
|
|
async findBusinessOption(
|
|
tin: string,
|
|
licenceNumber: string,
|
|
): Promise<ETradeBusinessOption> {
|
|
const options = await this.listBusinessOptions(tin);
|
|
const match = options.find((b) => b.licenceNumber === licenceNumber);
|
|
if (!match) {
|
|
throw new BadRequestException(
|
|
`eTrade lists no business licence "${licenceNumber}" under TIN ${tin}.`,
|
|
);
|
|
}
|
|
return match;
|
|
}
|
|
}
|
|
|
|
function toBusinessOption(
|
|
b: ETradeCompanyInfo["Businesses"][number],
|
|
): ETradeBusinessOption {
|
|
return {
|
|
licenceNumber: b.LicenceNumber,
|
|
tradeName: b.TradesName?.trim() || "",
|
|
activity: (b.SubGroups ?? [])
|
|
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
|
// eTrade also puts null entries in this array, so every hop is optional.
|
|
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
|
.filter(Boolean)
|
|
.join(", "),
|
|
renewedTo: b.RenewedTo || "",
|
|
};
|
|
}
|