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

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Attaches an eTrade business licence to each operational profile.
*
* A TIN routinely holds a dozen or more licences, split by activity ("Export
* trade in coffee", "Freight Forwarders"), and until now the company picked one
* for the whole record — every role shared it. Each profile now names the
* business it actually operates as.
*
* Stored as a snapshot ({@link ETradeBusinessOption}: licenceNumber, tradeName,
* activity, renewedTo) rather than a bare licence number, so the portal and the
* backoffice can show which business is attached without an eTrade round-trip —
* eTrade is slow, serves a broken TLS chain, and is regularly down.
*
* Nullable: existing profiles have none until the customer attaches one, and a
* co-operative or investor-licence company has no eTrade record at all.
* Deliberately NOT unique — one business can back several profiles.
*/
export class CompanyProfileEtradeBusiness3760000000000 implements MigrationInterface {
name = 'CompanyProfileEtradeBusiness3760000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
ADD COLUMN IF NOT EXISTS etrade_business jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
DROP COLUMN IF EXISTS etrade_business
`);
}
}

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

View File

@@ -0,0 +1,110 @@
import { Alert, Loader, Select, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle } from "lucide-react";
import { useMemo } from "react";
import type { ETradeBusinessOption } from "@edr/types";
import { api } from "@/services/api";
/**
* The eTrade business licences held under the signed-in company's TIN, cached
* for the session. Fetching goes out to eTrade, which is slow and regularly
* down, so this must not refetch on every mount of every role card.
*/
export function useEtradeBusinesses() {
return useQuery({
...api.companies.listEtradeBusinesses.queryOptions(),
staleTime: 5 * 60 * 1000,
retry: 1,
});
}
/** One licence, as it reads in the dropdown: trade name, then what it licenses. */
export function businessLabel(b: ETradeBusinessOption): string {
const name = b.tradeName || "(no trade name on this licence)";
return b.activity ? `${name}${b.activity}` : name;
}
interface EtradeBusinessSelectProps {
/** Currently attached licence number, if any. */
value: string | null;
onChange: (licenceNumber: string) => void;
label?: string;
error?: string;
disabled?: boolean;
}
/**
* Which of the TIN's eTrade businesses a company profile operates as.
*
* A TIN routinely holds a dozen licences split by activity — export of coffee,
* freight forwarding, import of vehicles — so the role a customer signs up for
* corresponds to one specific business, not to the company as a whole. The same
* business may legitimately back several roles, so nothing is filtered out
* because it is already in use elsewhere.
*/
export default function EtradeBusinessSelect({
value,
onChange,
label = "Which business does this profile operate as?",
error,
disabled,
}: EtradeBusinessSelectProps) {
const { data, isLoading, isError } = useEtradeBusinesses();
const options = useMemo(
() =>
(data ?? []).map((b) => ({
value: b.licenceNumber,
label: businessLabel(b),
})),
[data],
);
if (isLoading) {
return (
<Stack gap={4}>
<Text size="sm" c="edr-muted">
{label}
</Text>
<Loader size="sm" color="edr-green" />
</Stack>
);
}
if (isError) {
return (
<Alert color="yellow" icon={<AlertCircle size={16} />}>
We couldn't reach eTrade to list your business licences. Try again in a
moment.
</Alert>
);
}
if (options.length === 0) {
return (
<Text size="xs" c="edr-muted">
eTrade lists no business licence under your TIN, so there is nothing to
attach here.
</Text>
);
}
return (
<Select
label={label}
placeholder="Select a business licence"
data={options}
value={value}
onChange={(v) => v && onChange(v)}
error={error}
disabled={disabled}
searchable={options.length > 8}
nothingFoundMessage="No matching licence"
// The licence number is what identifies the business; the trade name
// repeats across licences, so it alone is not enough to tell them apart.
description={value ?? undefined}
comboboxProps={{ withinPortal: true }}
/>
);
}

View File

@@ -428,6 +428,7 @@ export default function OnboardingWizardDialog({
type: p.type,
reference: p.reference,
existingFiles: p.licenseFiles ?? [],
etradeBusiness: p.etradeBusiness ?? null,
}));
// The active step across the whole journey, driving the header + progress pill.

View File

@@ -1,9 +1,13 @@
import { Anchor, Group, Stack, Text } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Fragment } from "react";
import { Paperclip } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
import type { ETradeBusinessOption, IFileUploadSetting } from "@edr/types";
import EtradeBusinessSelect from "@/components/onboarding/EtradeBusinessSelect";
import { api } from "@/services/api";
import { fetchViewableFile } from "@/services/files.service";
import type { LicenseFile } from "@/services/companies.service";
@@ -63,6 +67,8 @@ export interface RoleLicenseProfile {
reference: string;
/** License files already uploaded for this profile (rehydration). */
existingFiles: LicenseFile[];
/** The eTrade business already attached to this profile, if any. */
etradeBusiness?: ETradeBusinessOption | null;
}
interface RoleLicenseStepProps {
@@ -73,6 +79,8 @@ interface RoleLicenseStepProps {
onChange: (value: Record<string, File[]>) => void;
/** "Business license is required" style error, keyed by profile id. */
errors?: Record<string, string>;
/** "Choose a business" error, keyed by profile id. */
businessErrors?: Record<string, string>;
}
/**
@@ -86,16 +94,43 @@ export default function RoleLicenseStep({
value,
onChange,
errors,
businessErrors,
}: RoleLicenseStepProps) {
const queryClient = useQueryClient();
const setFiles = (profileId: string, files: File[]) => {
onChange({ ...value, [profileId]: files });
};
// Attaching saves immediately rather than riding along with the step's
// submit: the roles were created on the wizard's first step, so each already
// has a row to attach to, and persisting on pick means a refresh or a resumed
// draft keeps the choice.
const attach = useMutation({
mutationFn: (vars: { profileId: string; licenceNumber: string }) =>
api.companies.attachEtradeBusiness.call(vars),
onSuccess: () => {
// getInfo FIRST: the wizard reads its role list (and each role's attached
// business) from that query, so skipping it leaves the dropdown showing
// blank right after a successful pick.
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.onboardingRequirements.queryKey(),
});
},
});
return (
<Stack gap="md">
<Text size="sm" c="edr-muted">
Upload the business license for each of your operational profiles. You
can attach more than one document per profile.
For each operational profile, say which of your eTrade business licences
it operates as, and upload that licence. You can attach more than one
document per profile, and the same business can back more than one role.
</Text>
{profiles.map((profile) => {
@@ -104,7 +139,21 @@ export default function RoleLicenseStep({
const hasExisting = profile.existingFiles.length > 0;
return (
<>
<Fragment key={profile.id}>
<EtradeBusinessSelect
label={`Which business is your ${label} profile?`}
value={profile.etradeBusiness?.licenceNumber ?? null}
error={businessErrors?.[profile.id]}
// Only the row being saved locks; picking the importer's business
// must not freeze the exporter's dropdown next to it.
disabled={
attach.isPending && attach.variables?.profileId === profile.id
}
onChange={(licenceNumber) =>
attach.mutate({ profileId: profile.id, licenceNumber })
}
/>
{hasExisting && (
<Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => (
@@ -142,7 +191,7 @@ export default function RoleLicenseStep({
setFiles(profile.id, files);
}}
/>
</>
</Fragment>
);
})}
</Stack>

View File

@@ -106,6 +106,9 @@ export const URL_CONSTANTS = {
ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade",
DASHBOARD: "/api/companies/dashboard",
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
ETRADE_BUSINESSES: "/api/companies/etrade-businesses",
PROFILE_ETRADE_BUSINESS: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/etrade-business`,
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
PROFILE_LICENSE: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/license`,

View File

@@ -232,9 +232,17 @@ const useAuth = () => {
const createProfile = async (
type: ProfileTypeValue,
licenseFiles: File[],
/**
* Which of the TIN's eTrade businesses the new role operates as. Required
* by the API for any company that has an eTrade record.
*/
licenceNumber?: string,
): Promise<Result<void>> => {
try {
const created = await api.companies.createCompanyProfile.call({ type });
const created = await api.companies.createCompanyProfile.call({
type,
licenceNumber,
});
if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(created.id, licenseFiles);
}

View File

@@ -38,6 +38,9 @@ import {
useParams,
} from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import EtradeBusinessSelect, {
useEtradeBusinesses,
} from "@/components/onboarding/EtradeBusinessSelect";
import {
CONTAINER_SIZES,
CONTRACT_STEPS,
@@ -417,6 +420,11 @@ export default function NewContractPage({
[profileStatusByType, profileTypes],
);
// Empty for a co-operative or investor-licence company — eTrade holds no
// record for it, so there is no business to attach and none is asked for.
const { data: etradeBusinesses } = useEtradeBusinesses();
const createBusinessRequired = (etradeBusinesses?.length ?? 0) > 0;
// Create-profile modal state (license upload → createProfile).
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
null,
@@ -424,6 +432,9 @@ export default function NewContractPage({
const [pendingOperation, setPendingOperation] =
useState<OperationType | null>(null);
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
// Which eTrade business the new role operates as — a TIN holds several
// licences and the role corresponds to one of them.
const [createLicence, setCreateLicence] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
// After a license is uploaded the new profile comes back "pending", so the
// create-profile modal switches to an "awaiting approval" success state.
@@ -437,11 +448,13 @@ export default function NewContractPage({
mutationFn: async ({
type,
files,
licenceNumber,
}: {
type: ProfileTypeValue;
files: File[];
licenceNumber?: string;
}) => {
const res = await auth.createProfile(type, files);
const res = await auth.createProfile(type, files, licenceNumber);
if (!res.success) {
throw new Error(res.error?.message ?? "Failed to create profile");
}
@@ -507,7 +520,17 @@ export default function NewContractPage({
setCreateError("Please upload at least one business license file.");
return;
}
createProfileMutation.mutate({ type: createTarget, files: licenseFiles });
// Only companies eTrade actually knows have a licence list; a co-operative
// has none, and the API does not ask them for one.
if (createBusinessRequired && !createLicence) {
setCreateError("Please choose which eTrade business this profile is.");
return;
}
createProfileMutation.mutate({
type: createTarget,
files: licenseFiles,
licenceNumber: createLicence ?? undefined,
});
};
const handleCreateProfileCancel = () => {
@@ -1235,6 +1258,13 @@ export default function NewContractPage({
Add your business license to create one. It goes to staff for
approval before you can use it.
</Text>
{createBusinessRequired && (
<EtradeBusinessSelect
value={createLicence}
onChange={setCreateLicence}
disabled={createProfileMutation.isPending}
/>
)}
<FileInput
label="Business license"
multiple

View File

@@ -6,10 +6,15 @@ import {
Card,
Group,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import EtradeBusinessSelect, {
businessLabel,
useEtradeBusinesses,
} from "@/components/onboarding/EtradeBusinessSelect";
import type { CompanyProfileResponse } from "@/services/companies.service";
import type { ProfileResponse } from "@/types/profile";
import RoleCard from "./RoleCard";
@@ -63,6 +68,16 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());
// Which eTrade business each newly-selected role will operate as. A TIN holds
// many licences split by activity, so this is picked per role, not per
// company — and the same business may back several roles.
const [licenceByType, setLicenceByType] = useState<Record<string, string>>({});
// Empty for a co-operative or investor-licence company: eTrade holds no
// record for it, so there is nothing to attach and nothing to require.
const { data: businesses } = useEtradeBusinesses();
const businessRequired = (businesses?.length ?? 0) > 0;
const toggle = (type: string) => {
if (profileByType.has(type)) return; // add-only: existing roles are locked
setSelected((prev) => {
@@ -71,13 +86,40 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
else next.add(type);
return next;
});
// Deselecting drops the licence with it, so re-picking the role does not
// silently reuse a choice the user backed out of.
setLicenceByType((prev) => {
const next = { ...prev };
delete next[type];
return next;
});
};
// Attach (or change) the business on a role that already exists.
const attachMutation = useMutation({
mutationFn: (vars: { profileId: string; licenceNumber: string }) =>
api.companies.attachEtradeBusiness.call(vars),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
});
const mutation = useMutation({
mutationFn: (types: string[]) =>
api.companies.addCompanyProfiles.call({ types }),
api.companies.addCompanyProfiles.call({
profiles: types.map((type) => ({
type,
licenceNumber: licenceByType[type],
})),
}),
onSuccess: () => {
setSelected(new Set());
setLicenceByType({});
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
@@ -103,8 +145,14 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
},
});
// Every selected role needs its business named first — the API rejects a role
// added without one, so the button is what tells the user, not a 400.
const missingLicence =
businessRequired &&
Array.from(selected).some((type) => !licenceByType[type]);
const handleSave = () => {
if (selected.size === 0) return;
if (selected.size === 0 || missingLicence) return;
mutation.mutate(Array.from(selected));
};
@@ -142,25 +190,53 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
lockedNote={view?.note}
lockedNoteColor={view?.color}
detail={
(rejected || existing?.status === "suspended") &&
existing?.reviewNote
? `Reviewer note: ${existing.reviewNote}`
existing
? [
existing.etradeBusiness
? `Operating as: ${businessLabel(existing.etradeBusiness)}`
: businessRequired
? "No eTrade business attached yet"
: null,
(rejected || existing.status === "suspended") &&
existing.reviewNote
? `Reviewer note: ${existing.reviewNote}`
: null,
]
.filter(Boolean)
.join(" · ") || undefined
: undefined
}
action={
rejected ? (
<Button
size="xs"
variant="light"
leftSection={<RefreshCw size={14} />}
loading={
reapplyMutation.isPending &&
reapplyMutation.variables === existing.id
}
onClick={() => reapplyMutation.mutate(existing.id)}
>
Resubmit for approval
</Button>
existing ? (
<Stack gap="xs">
{businessRequired && (
<EtradeBusinessSelect
label="Operating as"
value={existing.etradeBusiness?.licenceNumber ?? null}
disabled={attachMutation.isPending}
onChange={(licenceNumber) =>
attachMutation.mutate({
profileId: existing.id,
licenceNumber,
})
}
/>
)}
{rejected && (
<Button
size="xs"
variant="light"
leftSection={<RefreshCw size={14} />}
loading={
reapplyMutation.isPending &&
reapplyMutation.variables === existing.id
}
onClick={() => reapplyMutation.mutate(existing.id)}
>
Resubmit for approval
</Button>
)}
</Stack>
) : undefined
}
onClick={() => toggle(opt.type)}
@@ -170,6 +246,29 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
</SimpleGrid>
)}
{businessRequired && selected.size > 0 && (
<Stack gap="sm" mt="lg">
<Text size="sm" c="edr-muted">
Say which of your eTrade business licences each new role operates as.
</Text>
{options
.filter((opt) => selected.has(opt.type))
.map((opt) => (
<EtradeBusinessSelect
key={opt.type}
label={`${opt.label} operates as`}
value={licenceByType[opt.type] ?? null}
onChange={(licenceNumber) =>
setLicenceByType((prev) => ({
...prev,
[opt.type]: licenceNumber,
}))
}
/>
))}
</Stack>
)}
{options.length > 0 && (
<Group
justify="space-between"
@@ -199,7 +298,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
type="button"
leftSection={<Save size={16} />}
loading={mutation.isPending}
disabled={selected.size === 0}
disabled={selected.size === 0 || missingLicence}
onClick={handleSave}
>
{selected.size > 1 ? "Add Roles" : "Add Role"}

View File

@@ -1,4 +1,4 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import type { ETradeBusinessOption, Freight, PaginatedResponse } from "@edr/types";
import { endpoint } from "@/utils/endpoint";
import type {
CreateFileUploadFieldDto,
@@ -219,14 +219,13 @@ export const api = {
companiesService.getDashboard,
),
addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>(
"companies",
"addCompanyProfiles",
companiesService.addCompanyProfiles,
),
addCompanyProfiles: endpoint<
{ profiles: { type: string; licenceNumber?: string }[] },
CompanyProfileResponse[]
>("companies", "addCompanyProfiles", companiesService.addCompanyProfiles),
createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string },
{ type: ProfileTypeValue; businessLicense?: string; licenceNumber?: string },
CompanyProfileResponse
>(
"companies",
@@ -234,6 +233,21 @@ export const api = {
companiesService.createCompanyProfile,
),
listEtradeBusinesses: endpoint<void, ETradeBusinessOption[]>(
"companies",
"listEtradeBusinesses",
companiesService.listEtradeBusinesses,
),
attachEtradeBusiness: endpoint<
{ profileId: string; licenceNumber: string },
CompanyProfileResponse
>(
"companies",
"attachEtradeBusiness",
companiesService.attachEtradeBusiness,
),
startOnboarding: endpoint<
{
companyType: string;

View File

@@ -2,6 +2,7 @@ import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import type { ETradeBusinessOption } from "@edr/types";
import type { CompanyIdentityState } from "./verifayda.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { isAxiosError } from "axios";
@@ -83,6 +84,11 @@ export interface CompanyProfileResponse {
/** Business-license documents uploaded for this profile. */
licenseFiles: LicenseFile[];
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).
*/
etradeBusiness: ETradeBusinessOption | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: string;
@@ -338,7 +344,7 @@ export const companiesService = {
},
addCompanyProfiles: async (payload: {
types: string[];
profiles: { type: string; licenceNumber?: string }[];
}): Promise<CompanyProfileResponse[]> => {
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
@@ -351,6 +357,7 @@ export const companiesService = {
createCompanyProfile: async (payload: {
type: ProfileTypeValue;
businessLicense?: string;
licenceNumber?: string;
}): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
@@ -359,6 +366,29 @@ export const companiesService = {
return unwrap(response.data);
},
/**
* The eTrade business licences held under this company's TIN. Empty for a
* co-operative or investor-licence company, which has no eTrade record.
*/
listEtradeBusinesses: async (): Promise<ETradeBusinessOption[]> => {
const response = await client.get<ApiResponse<ETradeBusinessOption[]>>(
URL_CONSTANTS.COMPANIES_API.ETRADE_BUSINESSES,
);
return unwrap(response.data);
},
/** Attach (or re-attach) one of those businesses to an operational profile. */
attachEtradeBusiness: async (payload: {
profileId: string;
licenceNumber: string;
}): Promise<CompanyProfileResponse> => {
const response = await client.patch<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_ETRADE_BUSINESS(payload.profileId),
{ licenceNumber: payload.licenceNumber },
);
return unwrap(response.data);
},
/** Begin onboarding — create the draft company + profile + role(s) up front. */
startOnboarding: async (payload: {
companyType: string;

View File

@@ -383,3 +383,19 @@ export function expectProfileActive(companyName: string, type = "importer") {
expect(rows[0].company_status).to.eq("active");
});
}
/**
* Attach one of the TIN's eTrade businesses to a role, on the documents step.
*
* Which one does not matter to these flows — only that a company with an eTrade
* record cannot submit onboarding until every role names one. A co-operative or
* investor-licence company has no list, so its flows never call this.
*/
export function chooseRoleBusiness(role = "Importer") {
cy.contains("label", `Which business is your ${role} profile?`)
.parents(".mantine-InputWrapper-root")
.first()
.find("input")
.click();
cy.get("[role='option']").first().click();
}

View File

@@ -33,6 +33,7 @@ import {
vatNumber,
wizardClick,
SIGNUP_PASSWORD,
chooseRoleBusiness,
} from "./onboarding-utils";
const stamp = Date.now();
@@ -128,6 +129,7 @@ describe("onboarding — Ethiopian company, eTrade verified", { retries: 0 }, ()
cy.contains("Upload Importer Business license file(s)", {
timeout: 20000,
}).should("be.visible");
chooseRoleBusiness();
attachNextFile();
attachNextFile();
wizardClick("Submit for review");

View File

@@ -26,6 +26,7 @@ import {
signupIdentity,
wizardClick,
SIGNUP_PASSWORD,
chooseRoleBusiness,
} from "./onboarding-utils";
const stamp = Date.now();
@@ -133,6 +134,7 @@ describe("onboarding — switching back to eTrade registration", { retries: 0 },
cy.contains("Upload Importer Business license file(s)", {
timeout: 20000,
}).should("be.visible");
chooseRoleBusiness();
wizardClick("Submit for review");
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");