Files
edr-platform/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts
Nathnael 604710bd25 feat(companies): attach an eTrade business to each company profile
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.
2026-08-27 09:29:15 +00:00

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 || "",
};
}