diff --git a/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts b/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts new file mode 100644 index 000000000..7e2db4eb3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + ADD COLUMN IF NOT EXISTS etrade_business jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + DROP COLUMN IF EXISTS etrade_business + `); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 5d29b32f6..000b0733f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { 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 { + 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 { + 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); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 30c323ac3..da2497d90 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -162,7 +162,16 @@ function makeService(overrides: Partial = {}) { {} 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" } }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 96dc3ff53..b801a793f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -133,7 +133,16 @@ function makeService(overrides: Partial = {}) { {} 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(); }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts new file mode 100644 index 000000000..c04ce40f9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts @@ -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 = {}) { + const company = { + id: "company-1", + tin: "0045014036", + type: "customer", + attributes, + companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }], + }; + + const created: Record[] = []; + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => created), + findByType: jest.fn(async () => null), + create: jest.fn(async (row: Record) => { + created.push({ id: `profile-${created.length + 2}`, ...row }); + return created[created.length - 1]; + }), + update: jest.fn(async (id: string, data: Record) => ({ + 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).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([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index d7151f200..1080ceb12 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { + 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 { 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 { + 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 { + 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 { + 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, diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts index 838c42111..8809310cc 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -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[]; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts new file mode 100644 index 000000000..3ee50b51a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts index 9ac6c13b7..9bb5453ee 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index d82093336..db58d0bd7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 49dad4b8d..a31e517d5 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 7c4348e4f..321d739e3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -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 | 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; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 9bba9396e..0a16343f4 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -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 | null; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 5bbcefb7b..9a258099c 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -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 { + 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 { + 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 || "", + }; } diff --git a/apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx b/apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx new file mode 100644 index 000000000..f1be4ab86 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx @@ -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 ( + + + {label} + + + + ); + } + + if (isError) { + return ( + }> + We couldn't reach eTrade to list your business licences. Try again in a + moment. + + ); + } + + if (options.length === 0) { + return ( + + eTrade lists no business licence under your TIN, so there is nothing to + attach here. + + ); + } + + return ( +