mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +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:
@@ -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 || "",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user