feat: add choice to etrade fetcher

This commit is contained in:
Nathnael
2026-08-10 14:49:17 +00:00
parent c24c93bcf3
commit 615f7e6798
18 changed files with 443 additions and 330 deletions

View File

@@ -210,6 +210,7 @@ export class CompaniesController {
const data = await this.companiesService.fetchETradeData(
dto.tin,
companyId,
dto.licenceNumber,
);
return new ETradeResponseDto(data);
}

View File

@@ -3541,9 +3541,10 @@ export class CompaniesService {
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
private async resolveEtradeRegistration(
tin: string,
licenceNumber?: string,
): Promise<CompanyRegistrationData> {
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
await this.etradeService.resolveCompanyData(tin, licenceNumber);
if (!businessInfo) {
throw new BadRequestException(
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
@@ -3555,8 +3556,15 @@ export class CompaniesService {
);
}
async fetchETradeData(tin: string, excludeCompanyId?: string) {
const registrationData = await this.resolveEtradeRegistration(tin);
async fetchETradeData(
tin: string,
excludeCompanyId?: string,
licenceNumber?: string,
) {
const registrationData = await this.resolveEtradeRegistration(
tin,
licenceNumber,
);
const tinTaken = await this.companiesRepo.existsByTin(
tin,
excludeCompanyId,
@@ -3583,7 +3591,13 @@ export class CompaniesService {
if (!touched) return;
const tin = dto.tin ?? company.tin;
const registration = await this.resolveEtradeRegistration(tin);
// Re-verify the licence the customer actually chose. Without it a TIN
// holding several licences would silently snap back to eTrade's first one on
// every save, overwriting the selection with a different business's record.
const registration = await this.resolveEtradeRegistration(
tin,
dto.licenceNumber ?? company.licenceNumber ?? undefined,
);
const fresh: Partial<
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
> = {

View File

@@ -1,4 +1,4 @@
import { CompanyRegistrationData } from "@edr/types";
import { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types";
export class ETradeResponseDto implements CompanyRegistrationData {
companyName!: string;
@@ -19,6 +19,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
managerEmail?: string;
managerPhone!: string;
tinTaken?: boolean;
businesses?: ETradeBusinessOption[];
constructor(data: CompanyRegistrationData) {
this.companyName = data.companyName;
@@ -39,5 +40,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
this.managerEmail = data.managerEmail;
this.managerPhone = data.managerPhone;
this.tinTaken = data.tinTaken;
this.businesses = data.businesses;
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty } from "class-validator";
import { IsString, IsNotEmpty, IsOptional, MaxLength } from "class-validator";
import { IsTin } from "../../../common/validators/is-tin.validator";
export class FetchETradeDto {
@@ -6,4 +6,14 @@ export class FetchETradeDto {
@IsNotEmpty()
@IsTin({ message: "TIN must be exactly 10 digits" })
tin!: string;
/**
* Which of the TIN's business licences to resolve. Omitted on the first
* lookup — the response lists them all so the customer can pick, and the pick
* comes back here.
*/
@IsOptional()
@IsString()
@MaxLength(100)
licenceNumber?: string;
}

View File

@@ -0,0 +1,87 @@
import { ETradeService } from './etrade.service';
import type { ETradeBusinessInfo, ETradeCompanyInfo } from '@edr/types';
/**
* A TIN routinely holds several business licences (import of vehicles, export of
* coffee, freight forwarding…), all under the same trade name. The customer
* picks one, and every later lookup has to resolve that same licence — snapping
* back to eTrade's first would silently swap their company record.
*/
const companyInfo = (): ETradeCompanyInfo =>
({
Tin: '0045014036',
BusinessName: 'PAVE LOGISTICS AND TRADING P L C',
Businesses: [
{
LicenceNumber: 'MT/AA/14/670/11551235/2017',
TradesName: 'PAVE LOGISTICS AND TRADING P L C',
RenewedTo: '7/7/2026',
SubGroups: [
{ Code: 66331, Description: 'Export trade in minerals' },
],
},
{
LicenceNumber: 'MT/AA/14/670/128936/2007',
TradesName: 'PAVE LOGISTICS AND TRADING P L C',
RenewedTo: '7/7/2026',
SubGroups: [{ Code: 72131, Description: '(72131)Freight Forwarders' }],
},
],
}) as unknown as ETradeCompanyInfo;
describe('ETradeService business selection', () => {
const build = () => {
const service = new ETradeService({} as never);
const fetched: string[] = [];
jest
.spyOn(service, 'getCompanyInfoByTin')
.mockResolvedValue(companyInfo());
jest
.spyOn(service, 'getBusinessByLicenseNo')
.mockImplementation(async (licenceNo: string) => {
fetched.push(licenceNo);
return { LicenceNumber: licenceNo } as ETradeBusinessInfo;
});
return { service, fetched };
};
it('defaults to the first licence when none is chosen', async () => {
const { service, fetched } = build();
await service.resolveCompanyData('0045014036');
expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']);
});
it('resolves the chosen licence', async () => {
const { service, fetched } = build();
await service.resolveCompanyData('0045014036', 'MT/AA/14/670/128936/2007');
expect(fetched).toEqual(['MT/AA/14/670/128936/2007']);
});
it('falls back to the first licence when the chosen one is gone', async () => {
const { service, fetched } = build();
await service.resolveCompanyData('0045014036', 'NO/SUCH/LICENCE');
expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']);
});
it('lists every licence for the picker, code prefixes stripped', () => {
const { service } = build();
const data = service.extractRegistrationData(
{ LicenceNumber: 'x' } as ETradeBusinessInfo,
companyInfo(),
);
expect(data.businesses).toEqual([
{
licenceNumber: 'MT/AA/14/670/11551235/2017',
tradeName: 'PAVE LOGISTICS AND TRADING P L C',
activity: 'Export trade in minerals',
renewedTo: '7/7/2026',
},
{
licenceNumber: 'MT/AA/14/670/128936/2007',
tradeName: 'PAVE LOGISTICS AND TRADING P L C',
activity: 'Freight Forwarders',
renewedTo: '7/7/2026',
},
]);
});
});

View File

@@ -66,7 +66,15 @@ export class ETradeService {
}
}
async resolveCompanyData(tin: string): Promise<{
/**
* @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;
}> {
@@ -76,10 +84,15 @@ export class ETradeService {
return { companyInfo, businessInfo: null };
}
const latestBusiness = companyInfo.Businesses[0];
// 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(
latestBusiness.LicenceNumber,
selected.LicenceNumber,
tin,
);
return { companyInfo, businessInfo };
@@ -124,6 +137,16 @@ export class ETradeService {
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
managerName: primaryManager?.ManagerNameEng || "",
managerPhone: primaryManager?.RegularPhone || "",
businesses: (companyInfo?.Businesses ?? []).map((b) => ({
licenceNumber: b.LicenceNumber,
tradeName: b.TradesName?.trim() || "",
activity: (b.SubGroups ?? [])
// Some descriptions repeat the code inline ("(65611)Import trade …").
.map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim())
.filter(Boolean)
.join(", "),
renewedTo: b.RenewedTo || "",
})),
};
}
}

View File

@@ -69,6 +69,7 @@ export class ClientActionDto {
"LAUNCH_APP",
"INVOKE_BRIDGE",
"COLLECT_OTP",
"AWAIT_PUSH",
"SHOW_BILL_REFERENCE",
],
})
@@ -77,6 +78,7 @@ export class ClientActionDto {
| "LAUNCH_APP"
| "INVOKE_BRIDGE"
| "COLLECT_OTP"
| "AWAIT_PUSH"
| "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
@@ -104,9 +106,16 @@ export class ClientActionDto {
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
providerOrderId?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
@ApiPropertyOptional({
description: "Set when type=COLLECT_OTP or type=AWAIT_PUSH",
})
message?: string;
@ApiPropertyOptional({
description: "Set when type=AWAIT_PUSH (masked MSISDN the push prompt went to)",
})
payerAccountMasked?: string;
@ApiPropertyOptional({
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
})