mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
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:
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user