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.
This commit is contained in:
Nathnael
2026-08-27 09:29:15 +00:00
parent 1f5e6296b1
commit 604710bd25
26 changed files with 922 additions and 55 deletions

View File

@@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import type { ETradeBusinessOption } from "@edr/types";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import {
CompanyIdentityStateDto,
@@ -260,11 +262,42 @@ export class CompaniesController {
): Promise<ResponseCompanyProfileDto[]> {
const profiles = await this.companiesService.addCompanyProfilesForUser(
user.id,
dto.types,
dto.profiles,
);
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
@Get("etrade-businesses")
@PortalCustomer()
@ApiOperation({
summary:
"The eTrade business licences under this company's TIN, for attaching to its operational profiles",
})
async listEtradeBusinesses(
@CurrentUser() user: CurrentIamUser,
): Promise<ETradeBusinessOption[]> {
return this.companiesService.listEtradeBusinessesForUser(user.id);
}
@Patch("company-profiles/:profileId/etrade-business")
@PortalCustomer()
@ApiOperation({
summary:
"Attach one of the TIN's eTrade businesses to an operational profile (re-attaching refreshes the stored snapshot)",
})
async attachEtradeBusiness(
@CurrentUser() user: CurrentIamUser,
@Param("profileId") profileId: string,
@Body() dto: AttachEtradeBusinessDto,
): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.attachEtradeBusinessToProfile(
user.id,
profileId,
dto.licenceNumber,
);
return new ResponseCompanyProfileDto(profile);
}
@Post("onboarding/start")
@PortalCustomer()
@ApiOperation({
@@ -321,6 +354,7 @@ export class CompaniesController {
user.id,
dto.type,
dto.businessLicense,
dto.licenceNumber,
);
return new ResponseCompanyProfileDto(profile);
}

View File

@@ -162,7 +162,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
{} as never,
deps.filesService as never,
deps.fileUploadSettings as never,
{} as never,
// Only the business-licence lookup is exercised here: adding a role now
// resolves which eTrade business it operates as.
{
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
licenceNumber,
tradeName: "Test Trade Name",
activity: "Freight Forwarders",
renewedTo: "7/7/2026",
}),
} as never,
deps.companyNotifier as never,
{} as never,
deps.verifayda as never,
@@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => {
describe("the freight-forwarder gate", () => {
const addForwarder = (service: CompaniesService) =>
service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]);
service.addCompanyProfilesForUser("user-1", [
{ type: ProfileType.freightForwarder, licenceNumber: "LIC-1" },
]);
it("blocks the role while the representative is unverified", async () => {
const { service } = makeService({ attributes: { poaDeclared: "yes" } });

View File

@@ -133,7 +133,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
{} as never,
deps.filesService as never,
{} as never,
{} as never,
// Only the business-licence lookup is exercised here: adding a role now
// resolves which eTrade business it operates as.
{
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
licenceNumber,
tradeName: "Test Trade Name",
activity: "Freight Forwarders",
renewedTo: "7/7/2026",
}),
} as never,
deps.companyNotifier as never,
{} as never,
{} as never,
@@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).resolves.toBeDefined();
});

View File

@@ -0,0 +1,149 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { ProfileType } from "./entities/company-profile.entity";
import { COOPERATIVE_KEY } from "./entities/company.entity";
/**
* A TIN holds many business licences; each operational profile names the one it
* trades as. What matters here is that the stored business is always eTrade's
* own record, looked up under the company's own TIN — never the client's word
* for it — and that the requirement lifts for a company eTrade knows nothing
* about.
*/
const BUSINESSES = [
{
licenceNumber: "MT/AA/14/670/128936/2007",
tradeName: "Pave Freight Forwarding",
activity: "Freight Forwarders",
renewedTo: "7/7/2026",
},
{
licenceNumber: "MT/AA/14/670/11551235/2017",
tradeName: "Pave Minerals Export",
activity: "Export trade in minerals",
renewedTo: "7/7/2026",
},
];
function makeService(attributes: Record<string, unknown> = {}) {
const company = {
id: "company-1",
tin: "0045014036",
type: "customer",
attributes,
companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }],
};
const created: Record<string, unknown>[] = [];
const companyProfilesRepo = {
findByCompanyId: jest.fn(async () => created),
findByType: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => {
created.push({ id: `profile-${created.length + 2}`, ...row });
return created[created.length - 1];
}),
update: jest.fn(async (id: string, data: Record<string, unknown>) => ({
id,
...data,
})),
};
const etradeService = {
listBusinessOptions: jest.fn(async () => BUSINESSES),
findBusinessOption: jest.fn(async (_tin: string, licenceNumber: string) => {
const match = BUSINESSES.find((b) => b.licenceNumber === licenceNumber);
if (!match) throw new BadRequestException("no such licence");
return match;
}),
};
const service = new CompaniesService(
{} as never,
companyProfilesRepo as never,
{} as never,
{} as never,
{ findByUserId: jest.fn(async () => ({ id: "ext-1", companyId: "company-1" })) } as never,
{} as never,
{} as never,
{} as never,
etradeService as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(async () => ({ profile: {}, company }) as never);
// Private, but every add path goes through it; stubbing it keeps this spec on
// the business-attachment logic instead of the whole company lookup graph.
(service as unknown as Record<string, unknown>).findCompanyById = async () =>
company;
return { service, companyProfilesRepo, etradeService };
}
describe("attaching an eTrade business to a company profile", () => {
it("stores eTrade's own record for the chosen licence, not the client's", async () => {
const { service, companyProfilesRepo } = makeService();
const updated = await service.attachEtradeBusinessToProfile(
"user-1",
"profile-1",
"MT/AA/14/670/128936/2007",
);
expect(companyProfilesRepo.update).toHaveBeenCalledWith("profile-1", {
etradeBusiness: BUSINESSES[0],
});
expect(updated.etradeBusiness).toEqual(BUSINESSES[0]);
});
it("refuses a licence eTrade does not list under this TIN", async () => {
const { service } = makeService();
await expect(
service.attachEtradeBusinessToProfile("user-1", "profile-1", "SOMEONE/ELSES/LICENCE"),
).rejects.toBeInstanceOf(BadRequestException);
});
it("refuses a profile belonging to another company", async () => {
const { service } = makeService();
await expect(
service.attachEtradeBusinessToProfile("user-1", "not-mine", BUSINESSES[0].licenceNumber),
).rejects.toBeInstanceOf(NotFoundException);
});
it("the same business may back more than one profile", async () => {
const { service, etradeService } = makeService();
await service.addCompanyProfilesForUser("user-1", [
{ type: ProfileType.exporter, licenceNumber: BUSINESSES[0].licenceNumber },
{ type: ProfileType.importer, licenceNumber: BUSINESSES[0].licenceNumber },
]);
expect(etradeService.findBusinessOption).toHaveBeenCalledTimes(2);
});
});
describe("choosing a business is required when the company has one to choose", () => {
it("rejects a role added without a licence", async () => {
const { service } = makeService();
await expect(
service.addCompanyProfilesForUser("user-1", [{ type: ProfileType.exporter }]),
).rejects.toBeInstanceOf(BadRequestException);
});
it("lifts the requirement for a co-operative, which has no eTrade record", async () => {
const { service, companyProfilesRepo, etradeService } = makeService({
[COOPERATIVE_KEY]: true,
});
await service.addCompanyProfilesForUser("user-1", [
{ type: ProfileType.exporter },
]);
expect(etradeService.findBusinessOption).not.toHaveBeenCalled();
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ etradeBusiness: null }),
);
});
it("offers a co-operative no businesses to pick from", async () => {
const { service } = makeService({ [COOPERATIVE_KEY]: true });
await expect(service.listEtradeBusinessesForUser("user-1")).resolves.toEqual([]);
});
});

View File

@@ -47,7 +47,7 @@ import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import type { CompanyRegistrationData } from "@edr/types";
import type { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
@@ -343,6 +343,11 @@ export class CompaniesService {
companyId: company.id,
type: input.type,
businessLicense: input.businessLicense ?? null,
etradeBusiness: await this.resolveProfileBusiness(
company,
input.licenceNumber,
input.type,
),
status: ProfileStatus.Pending,
});
}
@@ -2149,8 +2154,9 @@ export class CompaniesService {
*/
async addCompanyProfilesForUser(
userId: string,
types: ProfileType[],
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
): Promise<CompanyProfile[]> {
const types = inputs.map((i) => i.type);
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
@@ -2185,11 +2191,21 @@ export class CompaniesService {
);
}
// Which eTrade business this role operates as. Resolved (and rejected if
// absent) BEFORE the row is created, so a role never lands unattached on
// a company that has licences to pick from.
const etradeBusiness = await this.resolveProfileBusiness(
company,
inputs.find((i) => i.type === type)?.licenceNumber,
type,
);
// Self-service role adds start Pending and carry no reference — a reference
// is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
companyId,
type,
etradeBusiness,
status: ProfileStatus.Pending,
});
}
@@ -2207,6 +2223,7 @@ export class CompaniesService {
userId: string,
type: ProfileType,
businessLicense?: string,
licenceNumber?: string,
): Promise<CompanyProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
@@ -2232,12 +2249,18 @@ export class CompaniesService {
);
}
if (!created) {
const etradeBusiness = await this.resolveProfileBusiness(
company,
licenceNumber,
type,
);
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved.
created = await this.companyProfilesRepo.create({
companyId,
type,
businessLicense: businessLicense ?? null,
etradeBusiness,
status: ProfileStatus.Pending,
});
}
@@ -2320,6 +2343,7 @@ export class CompaniesService {
type: p.type,
reference: p.reference ?? "",
uploaded: records.some((r) => r.code === LICENSE_CODE),
etradeBusiness: p.etradeBusiness ?? null,
};
}),
);
@@ -2331,6 +2355,19 @@ export class CompaniesService {
? []
: licenseProfiles.filter((p) => !p.uploaded);
// Which eTrade business each role operates as. Enforced here rather than at
// role creation because the wizard picks roles on its FIRST step, before a
// TIN has been entered — there is nothing to pick from yet. The customer
// attaches one on the documents step, alongside that role's licence file,
// and onboarding cannot be submitted until every role has one.
//
// Lifted for a company with no eTrade record at all: a co-operative or a
// foreign investor has no licence list, so the requirement would be
// unsatisfiable (see `usesManualRegistration`).
const missingBusinesses = usesManualRegistration(company)
? []
: licenseProfiles.filter((p) => !p.etradeBusiness);
// 4. Power of Attorney. Whether there is one at all is the company's own
// declaration — the question the wizard asks outright — and that answer is
// what decides whose identity gets verified, so an unanswered one is itself
@@ -2378,6 +2415,10 @@ export class CompaniesService {
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
...missingBusinesses.map(
(p) =>
`Choose which eTrade business your ${p.type.replace(/_/g, " ")} profile operates as`,
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
@@ -2416,6 +2457,8 @@ export class CompaniesService {
requiredInfo.length +
requiredDocCount +
(cooperative ? 0 : licenseProfiles.length) +
// One "which business?" item per role, on the same terms as the licences.
(usesManualRegistration(company) ? 0 : licenseProfiles.length) +
poaItemCount +
// The declaration and the verification it selects.
2;
@@ -2424,6 +2467,7 @@ export class CompaniesService {
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingBusinesses.length +
missingPoaFields.length +
(missingDelegation || flaggedDelegation ? 1 : 0) +
missingIdentityCount);
@@ -3747,6 +3791,86 @@ export class CompaniesService {
return match?.id ?? null;
}
/**
* Resolve the eTrade business a new/updated profile is being attached to.
*
* The client sends a licence number; what gets stored is eTrade's own record
* of it, looked up under THIS company's TIN. That is the whole check — a
* licence belonging to someone else's TIN simply is not in the list, so a
* client cannot attach a profile to a business the company does not hold.
*
* Returns null (rather than throwing) for a company that registered without
* eTrade: a co-operative union or farm holds no business licence, and a
* foreign investor's licence is the Investment Commission's, not the trade
* registry's. There is no list for them to pick from, so the role is theirs
* to hold unattached — the reviewer checks their uploaded documents instead.
*/
private async resolveProfileBusiness(
company: Company,
licenceNumber: string | undefined,
type: ProfileType,
): Promise<ETradeBusinessOption | null> {
if (usesManualRegistration(company)) return null;
if (!licenceNumber) {
throw new BadRequestException(
`Choose which of your eTrade business licences the ${type.replace(/_/g, " ")} profile operates as.`,
);
}
return this.etradeService.findBusinessOption(company.tin, licenceNumber);
}
/**
* The eTrade business licences the current user's company can attach to its
* operational profiles. Empty for a company that registered without eTrade.
*/
async listEtradeBusinessesForUser(
userId: string,
): Promise<ETradeBusinessOption[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
if (usesManualRegistration(company)) return [];
return this.etradeService.listBusinessOptions(company.tin);
}
/**
* Attach (or re-attach) one of the TIN's eTrade businesses to a profile.
*
* Separate from role creation because the onboarding wizard picks roles
* before the TIN is known — the business is chosen later, on the step that
* already collects each role's licence document. Re-attaching also refreshes
* the stored snapshot, which is how a renewed licence's new expiry lands.
*/
async attachEtradeBusinessToProfile(
userId: string,
profileId: string,
licenceNumber: string,
): Promise<CompanyProfile> {
const { company } = await this.getCompanyInfoByUserId(userId);
const profile = (company.companyProfiles ?? []).find(
(p) => p.id === profileId,
);
if (!profile) {
throw new NotFoundException(
`Company profile ${profileId} not found for this company`,
);
}
if (usesManualRegistration(company)) {
throw new BadRequestException(
"This company is not registered with eTrade, so it has no business licences to attach.",
);
}
const business = await this.etradeService.findBusinessOption(
company.tin,
licenceNumber,
);
const updated = await this.companyProfilesRepo.update(profile.id, {
etradeBusiness: business,
});
if (!updated) {
throw new NotFoundException(`Company profile ${profileId} not found`);
}
return updated;
}
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
private async resolveEtradeRegistration(
tin: string,

View File

@@ -1,9 +1,37 @@
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsEnum,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
import { ProfileType } from "../entities/company-profile.entity";
export class AddCompanyProfileInputDto {
@IsEnum(ProfileType)
type!: ProfileType;
/**
* Which of the TIN's eTrade business licences this role operates as.
*
* Optional at the DTO layer, required by the service for any company that
* HAS an eTrade record — a co-operative or investor-licence company has none
* to pick from, and rejecting them here would be wrong. See
* `CompaniesService.resolveProfileBusiness`.
*/
@IsOptional()
@IsString()
@MaxLength(120)
licenceNumber?: string;
}
export class AddCompanyProfilesDto {
@IsArray()
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
types!: ProfileType[];
@ValidateNested({ each: true })
@Type(() => AddCompanyProfileInputDto)
profiles!: AddCompanyProfileInputDto[];
}

View File

@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
export class AttachEtradeBusinessDto {
/**
* The eTrade licence number of the business this profile operates as. Checked
* against the licences eTrade lists under the company's own TIN, so an
* unknown or someone else's licence is rejected rather than stored.
*/
@IsString()
@IsNotEmpty()
@MaxLength(120)
licenceNumber!: string;
}

View File

@@ -9,4 +9,14 @@ export class CreateCompanyProfileDto {
@IsString()
@MaxLength(100)
businessLicense?: string;
/**
* Which of the TIN's eTrade business licences this role operates as. Required
* by the service for any company that has an eTrade record; see
* `AddCompanyProfileInputDto.licenceNumber`.
*/
@IsOptional()
@IsString()
@MaxLength(120)
licenceNumber?: string;
}

View File

@@ -22,6 +22,16 @@ export class CompanyProfileInputDto {
@IsString()
@MaxLength(100)
businessLicense?: string;
/**
* Which of the TIN's eTrade business licences this role operates as. Required
* by the service for any company that has an eTrade record; see
* `CompaniesService.resolveProfileBusiness`.
*/
@IsOptional()
@IsString()
@MaxLength(120)
licenceNumber?: string;
}
export class CreateCompanyWithProfileDto {

View File

@@ -8,6 +8,7 @@
* truth the wizard uses to auto-finish.
*/
import type { ETradeBusinessOption } from "@edr/types";
import {
CompanyIdentityStateDto,
PoaDeclaration,
@@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile {
reference: string;
/** True when at least one business-license file is stored on the profile. */
uploaded: boolean;
/**
* The eTrade business this role operates as, once the customer has attached
* one. Null while outstanding — the wizard renders the picker off this.
*/
etradeBusiness: ETradeBusinessOption | null;
}
export interface OnboardingPoaState {

View File

@@ -6,6 +6,7 @@ import {
hasInvestorLicence,
isCooperative,
} from '../entities/company.entity';
import type { ETradeBusinessOption } from '@edr/types';
import {
CompanyProfile,
ProfileLicenseFileView,
@@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto {
*/
licenseFiles: ProfileLicenseFileView[];
attributes?: Record<string, any> | null;
/**
* The eTrade business licence this role operates as, or null when nothing is
* attached yet (or the company registered without eTrade). Snapshot — see
* `CompanyProfile.etradeBusiness`.
*/
etradeBusiness?: ETradeBusinessOption | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: Date;
@@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto {
this.businessLicense = profile.businessLicense;
this.licenseFiles = [];
this.attributes = profile.attributes;
this.etradeBusiness = profile.etradeBusiness ?? null;
this.reviewNote = profile.reviewNote ?? null;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from "@edr/api-common";
import type { ETradeBusinessOption } from "@edr/types";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Company } from "./company.entity";
@@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
businessLicenseFiles?: BusinessLicenseFile[] | null;
/**
* Which of the TIN's eTrade business licences this profile operates as.
*
* A TIN holds many licences split by activity, so "exporter" and "freight
* forwarder" are usually two different businesses under one company. Stored
* as a snapshot rather than a bare licence number so the trade name and
* activity render without an eTrade call — that API is slow and regularly
* down, and this is display data, not a source of truth. Re-attaching
* refreshes it.
*
* NULL when nothing is attached yet, or when the company registered without
* eTrade at all (co-operative / investor licence — see
* {@link usesManualRegistration}). One business may back several profiles.
*/
@Column({ name: "etrade_business", type: "jsonb", nullable: true })
etradeBusiness?: ETradeBusinessOption | null;
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;

View File

@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
import {
ETradeCompanyInfo,
ETradeBusinessInfo,
ETradeBusinessOption,
CompanyRegistrationData,
normalizeRegion,
} from "@edr/types";
@@ -143,17 +144,57 @@ 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 …").
// 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 || "",
})),
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 || "",
};
}