mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
|
||||
/**
|
||||
* One portal login belonging to a customer company: the company-side profile
|
||||
* joined to the IAM account that actually signs in.
|
||||
*
|
||||
* The two halves drift apart routinely — `company.email` is business contact
|
||||
* detail, while `email` here is the credential a reset link goes to — which is
|
||||
* exactly why staff need to see the IAM side rather than the company row.
|
||||
*/
|
||||
export interface CustomerAccount {
|
||||
/** external_profiles.id */
|
||||
profileId: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jobTitle: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
onboardingStep: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
/** Null when the profile points at a user row that no longer exists. */
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
phoneVerified: boolean | null;
|
||||
/** IAM account status (`EUserStatus`), surfaced as-is. */
|
||||
status: string | null;
|
||||
isActive: boolean | null;
|
||||
/** False means the account was created but never activated by its owner. */
|
||||
hasSetPassword: boolean | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerAccountsService {
|
||||
constructor(
|
||||
@InjectRepository(ExternalProfile)
|
||||
private readonly profiles: Repository<ExternalProfile>,
|
||||
@InjectRepository(User)
|
||||
private readonly users: Repository<User>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Every portal account for a company, primary contact first.
|
||||
*
|
||||
* Deliberately NOT filtered to active accounts: a suspended or never-activated
|
||||
* login is the case staff are usually looking into, and hiding it would leave
|
||||
* "the customer says they can't log in" unanswerable from this screen.
|
||||
*/
|
||||
async listForCompany(companyId: string): Promise<CustomerAccount[]> {
|
||||
const profiles = await this.profiles.find({ where: { companyId } });
|
||||
if (profiles.length === 0) return [];
|
||||
|
||||
const userIds = profiles.map((p) => p.userId).filter(Boolean);
|
||||
// Explicit select: the User entity's relations include credentials and
|
||||
// sessions, and this response goes to a browser.
|
||||
const users = userIds.length
|
||||
? await this.users
|
||||
.createQueryBuilder("user")
|
||||
.select([
|
||||
"user.id",
|
||||
"user.username",
|
||||
"user.email",
|
||||
"user.phoneNumber",
|
||||
"user.isPhoneNumberVerified",
|
||||
"user.status",
|
||||
"user.isActive",
|
||||
"user.hasSetPassword",
|
||||
])
|
||||
.where({ id: In(userIds) })
|
||||
.getMany()
|
||||
: [];
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
return profiles
|
||||
.map((p) => {
|
||||
const user = byId.get(p.userId);
|
||||
return {
|
||||
profileId: p.id,
|
||||
userId: p.userId,
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
jobTitle: p.jobTitle ?? null,
|
||||
isPrimaryContact: p.isPrimaryContact,
|
||||
onboardingStep: p.onboardingStep ?? null,
|
||||
onboardingCompleted: p.onboardingCompleted ?? false,
|
||||
username: user?.username ?? null,
|
||||
email: user?.email ?? null,
|
||||
phoneNumber: user?.phoneNumber ?? null,
|
||||
phoneVerified: user?.isPhoneNumberVerified ?? null,
|
||||
status: user?.status ?? null,
|
||||
isActive: user?.isActive ?? null,
|
||||
hasSetPassword: user?.hasSetPassword ?? null,
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Primary contact first — it is the account every staff action
|
||||
// (password reset, notifications) actually targets.
|
||||
if (a.isPrimaryContact !== b.isPrimaryContact) {
|
||||
return a.isPrimaryContact ? -1 : 1;
|
||||
}
|
||||
return a.createdAt.getTime() - b.createdAt.getTime();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
||||
import {
|
||||
CustomerAccount,
|
||||
CustomerAccountsService,
|
||||
} from "./customer-accounts.service";
|
||||
import {
|
||||
CustomerResetService,
|
||||
CustomerResetTarget,
|
||||
@@ -25,7 +29,22 @@ import {
|
||||
@Controller("backoffice/customers")
|
||||
@ApiBearerAuth()
|
||||
export class CustomerResetController {
|
||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
||||
constructor(
|
||||
private readonly customerResetService: CustomerResetService,
|
||||
private readonly customerAccountsService: CustomerAccountsService,
|
||||
) {}
|
||||
|
||||
@Get(":companyId/accounts")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The portal login accounts belonging to a customer, primary contact first",
|
||||
})
|
||||
async accounts(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<CustomerAccount[]> {
|
||||
return this.customerAccountsService.listForCompany(companyId);
|
||||
}
|
||||
|
||||
@Get(":companyId/reset-target")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { CustomerAccountsService } from './customer-accounts.service';
|
||||
import { CustomerResetController } from './customer-reset.controller';
|
||||
import { CustomerResetService } from './customer-reset.service';
|
||||
import { ForgotPasswordController } from './forgot-password.controller';
|
||||
@@ -50,6 +51,7 @@ import { ListUsersService } from './list-users.service';
|
||||
CheckAvailabilityService,
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
CustomerAccountsService,
|
||||
],
|
||||
// Shipping-line registration mints activation links through the same
|
||||
// staff-triggered reset path customers use.
|
||||
|
||||
@@ -29,6 +29,7 @@ import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
sameCompanyName,
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
@@ -875,10 +876,21 @@ export class BillingService {
|
||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||
|
||||
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
|
||||
|
||||
const summary: InvoiceDocumentModel["summary"] = [
|
||||
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
|
||||
// filed against, not just the seller. VatNumber shown only when the company has one.
|
||||
{ label: "Buyer", value: invoice.company?.name ?? null },
|
||||
// The trade name of the eTrade licence THIS profile operates as. A TIN
|
||||
// holds many licences and the invoiced role (importer/exporter/forwarder)
|
||||
// is usually a different business from the one the company registered
|
||||
// under, so the buyer's name alone doesn't say which one was billed.
|
||||
// Suppressed when it just repeats the buyer name — most companies trade
|
||||
// under their registered name and a duplicate row helps nobody.
|
||||
...(tradeName && !sameCompanyName(tradeName, invoice.company?.name)
|
||||
? [{ label: "Buyer trade name", value: tradeName }]
|
||||
: []),
|
||||
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
||||
...(invoice.company?.vatNumber
|
||||
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
|
||||
import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service";
|
||||
|
||||
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
|
||||
kind: "INVOICE",
|
||||
@@ -87,3 +87,38 @@ describe("InvoiceDocumentService.buildThermalHtml", () => {
|
||||
expect(html).not.toContain("right: 160px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameCompanyName", () => {
|
||||
it("treats eTrade's legal-suffix spellings as the same name", () => {
|
||||
expect(sameCompanyName("ABIJOEL PLC", "ABIJOEL P L C")).toBe(true);
|
||||
expect(
|
||||
sameCompanyName(
|
||||
"WISH TRADING PLC",
|
||||
"WISH TRADING PRIVATE LIMITED COMPANY",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sameCompanyName("TUTA TRADING PLC", "TUTA TRADING ONE MEMBER PLC"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a genuinely different trade name distinct", () => {
|
||||
// Real pairs from eTrade: the licence trades under a different name than
|
||||
// the company registered under, which is exactly the row worth printing.
|
||||
expect(
|
||||
sameCompanyName("Cozy Coffee Grower and Exporter", "ABIJOEL P L C"),
|
||||
).toBe(false);
|
||||
expect(sameCompanyName("MENNA PRODUCTION", "ICOFFEE TRADING PLC")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
sameCompanyName("YUNABEK TRADING PLC", "YUNABEK INVESTMENT PLC"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when either side is missing, so no row is printed", () => {
|
||||
expect(sameCompanyName("", "ABIJOEL P L C")).toBe(false);
|
||||
expect(sameCompanyName(null, null)).toBe(false);
|
||||
expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,34 @@ function formatDate(value: unknown): string {
|
||||
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this trade name just the company name again?
|
||||
*
|
||||
* Compared loosely on purpose: eTrade spells the same legal suffix as "PLC",
|
||||
* "P L C" and "PRIVATE LIMITED COMPANY", and pads names with double spaces, so
|
||||
* an exact comparison would call two spellings of one name different and print
|
||||
* a redundant row. Used only to decide whether a trade-name row is worth
|
||||
* showing — never to decide that two businesses ARE the same.
|
||||
*/
|
||||
export function sameCompanyName(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
const norm = (v: string | null | undefined) =>
|
||||
(v ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[.,]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\bPRIVATE LIMITED COMPANY\b/g, "PLC")
|
||||
.replace(/\bP L C\b/g, "PLC")
|
||||
.replace(/\bONE (MEMBER|PERSON) PLC\b/g, "PLC")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const left = norm(a);
|
||||
return left !== "" && left === norm(b);
|
||||
}
|
||||
|
||||
/** One billed line on the document (charge type / fee type agnostic). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
createdTo,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
profileType,
|
||||
sortBy = 'review',
|
||||
sortOrder = 'DESC',
|
||||
} = query;
|
||||
@@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
|
||||
if (search) {
|
||||
const term = `%${search.trim()}%`;
|
||||
// Staff search by whatever is in front of them: the company name, the
|
||||
// TIN/email, a profile reference off a document — and, since a TIN holds
|
||||
// many licences, the trade name or licence number of the specific
|
||||
// business a role operates as. All the per-profile terms share one EXISTS
|
||||
// so a match on any of them qualifies the company once.
|
||||
qb.andWhere(
|
||||
`(company.name ILIKE :term
|
||||
OR company.tin ILIKE :term
|
||||
OR company.email ILIKE :term
|
||||
OR company.licence_number ILIKE :term
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = company.id
|
||||
AND cp.reference ILIKE :term
|
||||
AND cp.deleted_at IS NULL
|
||||
AND (
|
||||
cp.reference ILIKE :term
|
||||
OR cp.etrade_business->>'tradeName' ILIKE :term
|
||||
OR cp.etrade_business->>'licenceNumber' ILIKE :term
|
||||
)
|
||||
))`,
|
||||
{ term },
|
||||
);
|
||||
}
|
||||
|
||||
// Companies holding a given operational role. EXISTS rather than a filter
|
||||
// on the joined `companyProfiles` alias: constraining the join would drop
|
||||
// the company's OTHER profiles from the loaded entity, so the list would
|
||||
// render an exporter-and-importer as importer-only.
|
||||
if (profileType) {
|
||||
qb.andWhere(
|
||||
`EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp_type
|
||||
WHERE cp_type.company_id = company.id
|
||||
AND cp_type.deleted_at IS NULL
|
||||
AND cp_type.type = :profileType
|
||||
)`,
|
||||
{ profileType },
|
||||
);
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||
if (sortBy === 'review') {
|
||||
// Queue ordering: actionable tiers first, newest first within each. The
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class ListCompaniesQueryDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@@ -56,6 +57,16 @@ export class ListCompaniesQueryDto {
|
||||
@IsIn(Object.values(CompanyNationality))
|
||||
nationality?: CompanyNationality;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ProfileType,
|
||||
description:
|
||||
"Only companies holding this operational role. A company may hold " +
|
||||
"several; its other roles are still returned on the row.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(ProfileType))
|
||||
profileType?: ProfileType;
|
||||
|
||||
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -78,6 +78,27 @@ describe('ETradeService business selection', () => {
|
||||
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
|
||||
});
|
||||
|
||||
it("takes the selected licence's trade name as the company name", () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
{
|
||||
LicenceNumber: 'MT/AA/14/670/128936/2007',
|
||||
TradeName: 'Pave Freight Forwarding',
|
||||
} as ETradeBusinessInfo,
|
||||
companyInfo(),
|
||||
);
|
||||
expect(data.companyName).toBe('Pave Freight Forwarding');
|
||||
});
|
||||
|
||||
it('falls back to the registered name when the licence has no trade name', () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
{ LicenceNumber: 'x', TradeName: ' ' } as ETradeBusinessInfo,
|
||||
companyInfo(),
|
||||
);
|
||||
expect(data.companyName).toBe('PAVE LOGISTICS AND TRADING P L C');
|
||||
});
|
||||
|
||||
it('lists every licence for the picker, code prefixes stripped', () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
ETradeCompanyInfo,
|
||||
ETradeBusinessInfo,
|
||||
ETradeBusinessOption,
|
||||
CompanyRegistrationData,
|
||||
normalizeRegion,
|
||||
} from "@edr/types";
|
||||
@@ -102,10 +103,16 @@ export class ETradeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* `companyInfo` carries the registered organization name (`BusinessName`);
|
||||
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
|
||||
* company name resolves to the legal entity rather than the trade name — and
|
||||
* never to `ManagerNameEng`, which is the manager's personal name.
|
||||
* `businessInfo` carries the selected licence's `TradeName`; `companyInfo`
|
||||
* carries the registered organization name (`BusinessName`). The company name
|
||||
* resolves to the trade name of the licence the customer picked — a TIN
|
||||
* routinely trades under a name that is not its registered one, and the
|
||||
* business they selected is the one they operate as here. `BusinessName` is
|
||||
* the fallback, because eTrade leaves `TradeName` blank on plenty of licences.
|
||||
* Never `ManagerNameEng`, which is the manager's personal name.
|
||||
*
|
||||
* Callers that need the legal entity (tax filings, EIMS seller details) must
|
||||
* read `companyInfo.BusinessName` themselves — it is not this field.
|
||||
*/
|
||||
extractRegistrationData(
|
||||
businessInfo: ETradeBusinessInfo,
|
||||
@@ -115,7 +122,7 @@ export class ETradeService {
|
||||
|
||||
return {
|
||||
companyName:
|
||||
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
|
||||
businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "",
|
||||
licenceNumber: businessInfo.LicenceNumber,
|
||||
statusDescription: businessInfo.StatusDescription,
|
||||
dateRegistered: businessInfo.DateRegistered,
|
||||
@@ -137,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 || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,7 +118,11 @@ export class EimsSellerCacheService implements OnModuleInit {
|
||||
woreda: data.woreda,
|
||||
});
|
||||
this.cached = {
|
||||
LegalName: data.companyName || undefined,
|
||||
// The *legal* entity name, not the licence's trade name that
|
||||
// `data.companyName` now carries — an EIMS seller is filed under its
|
||||
// registered name.
|
||||
LegalName:
|
||||
companyInfo?.BusinessName?.trim() || data.companyName || undefined,
|
||||
Phone: data.mobilePhone || data.regularPhone || undefined,
|
||||
Region: geo?.Region,
|
||||
Wereda: geo?.Wereda,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { InvoiceLine } from "../billing/entities/invoice-line.entity";
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
sameCompanyName,
|
||||
InvoiceDocumentService,
|
||||
} from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
@@ -71,6 +72,11 @@ const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
|
||||
export interface InvoiceDocumentDetails {
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
/**
|
||||
* Trade name of the eTrade licence the billed company profile operates as.
|
||||
* Null when nothing is attached, or for a company with no eTrade record.
|
||||
*/
|
||||
customerTradeName: string | null;
|
||||
inventoryReference: string | null;
|
||||
inventoryInfo: string | null;
|
||||
inventoryStatus: string | null;
|
||||
@@ -745,6 +751,17 @@ export class WarehouseInvoiceService {
|
||||
},
|
||||
{ label: "Booking reference", value: invoice.bookingReference ?? null },
|
||||
{ label: "Customer", value: invoice.customerName ?? null },
|
||||
// Which of the TIN's eTrade businesses was billed. Omitted when it just
|
||||
// repeats the customer name — see sameCompanyName.
|
||||
...(invoice.customerTradeName &&
|
||||
!sameCompanyName(invoice.customerTradeName, invoice.customerName)
|
||||
? [
|
||||
{
|
||||
label: "Customer trade name",
|
||||
value: invoice.customerTradeName,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Inventory reference",
|
||||
value: invoice.inventoryReference ?? null,
|
||||
@@ -795,6 +812,7 @@ export class WarehouseInvoiceService {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
cp.etrade_business->>'tradeName' AS "customerTradeName",
|
||||
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
|
||||
inv.status AS "inventoryStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
@@ -812,6 +830,7 @@ export class WarehouseInvoiceService {
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.company_profiles cp ON cp.id = b.company_profile_id AND cp.deleted_at IS NULL
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
@@ -837,6 +856,7 @@ export class WarehouseInvoiceService {
|
||||
return {
|
||||
bookingReference: row?.bookingReference ?? null,
|
||||
customerName: row?.customerName ?? null,
|
||||
customerTradeName: row?.customerTradeName ?? null,
|
||||
inventoryReference: row?.inventoryReference ?? null,
|
||||
inventoryInfo: row?.inventoryInfo ?? null,
|
||||
inventoryStatus: row?.inventoryStatus ?? null,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { AtSign, Phone, ShieldAlert, UserRound } from "lucide-react";
|
||||
|
||||
import type { CustomerAccount } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
/** First letters of the person's name; falls back to the login initial. */
|
||||
function initials(account: CustomerAccount): string {
|
||||
const letters = [account.firstName, account.lastName]
|
||||
.map((n) => n?.trim()?.[0])
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
return (letters || account.username?.[0] || "?").toUpperCase();
|
||||
}
|
||||
|
||||
/** A labelled value; rendered only when there is something to show. */
|
||||
function Field({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
after,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
after?: React.ReactNode;
|
||||
}) {
|
||||
if (!value?.trim()) return null;
|
||||
return (
|
||||
<Group gap={10} wrap="nowrap" align="flex-start">
|
||||
<Box c="edr-muted" mt={2}>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="edr-muted">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="sm" c="edr-text" style={{ wordBreak: "break-word" }}>
|
||||
{value}
|
||||
</Text>
|
||||
{after}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One portal login belonging to a customer.
|
||||
*
|
||||
* Distinct from the contact details on the Overview tab: those are the business
|
||||
* contact info on the company row, this is the credential someone actually
|
||||
* signs in with — the two drift apart routinely, and staff answering "the
|
||||
* customer can't log in" need this one.
|
||||
*/
|
||||
export function AccountCard({ account }: { account: CustomerAccount }) {
|
||||
const name =
|
||||
`${account.firstName ?? ""} ${account.lastName ?? ""}`.trim() ||
|
||||
account.username ||
|
||||
"Unnamed account";
|
||||
|
||||
// No IAM row at all — the profile points at a user that is gone. Treated as a
|
||||
// fault rather than a status: nothing below it can be trusted, so the card
|
||||
// says so once, loudly, instead of drawing empty credential fields.
|
||||
const orphaned = account.username === null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Avatar radius="xl" color="edr-green" variant="light">
|
||||
{initials(account)}
|
||||
</Avatar>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text fw={600} c="edr-text" style={{ wordBreak: "break-word" }}>
|
||||
{name}
|
||||
</Text>
|
||||
{account.isPrimaryContact && (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
Primary contact
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{account.jobTitle && (
|
||||
<Text size="xs" c="edr-muted">
|
||||
{account.jobTitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="wrap">
|
||||
{orphaned ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<ShieldAlert size={11} />}
|
||||
>
|
||||
No IAM account
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={account.isActive ? "edr-green" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{account.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
{account.status && (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{humanize(account.status)}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Created but never activated by its owner — usually the actual
|
||||
answer to "they say they never got in". */}
|
||||
{account.hasSetPassword === false && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Password never set
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{account.onboardingCompleted ? (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
Onboarding submitted
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Onboarding
|
||||
{account.onboardingStep
|
||||
? ` · ${humanize(account.onboardingStep)}`
|
||||
: " in progress"}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{!orphaned && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
<Field
|
||||
icon={<UserRound size={14} />}
|
||||
label="Username"
|
||||
value={account.username}
|
||||
/>
|
||||
<Field
|
||||
icon={<AtSign size={14} />}
|
||||
label="Email"
|
||||
value={account.email}
|
||||
/>
|
||||
<Field
|
||||
icon={<Phone size={14} />}
|
||||
label="Phone"
|
||||
value={account.phoneNumber}
|
||||
after={
|
||||
account.phoneVerified === false ? (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
Unverified
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text size="xs" c="edr-muted">
|
||||
Created {formatDate(account.createdAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountCard;
|
||||
@@ -3,6 +3,12 @@ import type { ReactNode } from "react";
|
||||
|
||||
export interface TableCardProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Optional heading row (title, chips, actions). Rendered in its own padded
|
||||
* section above the table and OUTSIDE the scroll region — a header inside it
|
||||
* would slide away from its own table on a narrow viewport.
|
||||
*/
|
||||
header?: ReactNode;
|
||||
/**
|
||||
* Minimum width (px) the table is forced to occupy. The Mantine `Table` is
|
||||
* always `width: 100%`, so without a floor it can never overflow its
|
||||
@@ -14,14 +20,27 @@ export interface TableCardProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush card shell for a `DataTable`: a borderless, padding-less card whose
|
||||
* single child is a horizontally scrollable region. Pair with the table's
|
||||
* `containerClassName="border-0 shadow-none bg-transparent"` so every table on
|
||||
* the customer pages reads identically (same surface, same scroll behaviour).
|
||||
* Flush card shell for a `DataTable`: a padding-less card whose table region
|
||||
* runs edge to edge. Padding is applied per section rather than to the card, so
|
||||
* the optional {@link TableCardProps.header} is inset like any other card
|
||||
* content while the table's own rows and header cells reach both edges.
|
||||
*
|
||||
* Pair with the table's `containerClassName="border-0 shadow-none bg-transparent"`
|
||||
* so every table on the customer pages reads identically (same surface, same
|
||||
* scroll behaviour).
|
||||
*/
|
||||
export function TableCard({ children, minWidth = 860 }: TableCardProps) {
|
||||
export function TableCard({
|
||||
children,
|
||||
minWidth = 860,
|
||||
header,
|
||||
}: TableCardProps) {
|
||||
return (
|
||||
<Card p={0}>
|
||||
{header && (
|
||||
<Box p="md" style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}>
|
||||
{header}
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={minWidth}>{children}</Box>
|
||||
</Box>
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { AccountCard } from "./AccountCard";
|
||||
export { CompanyTimeline } from "./CompanyTimeline";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
|
||||
@@ -80,6 +80,7 @@ export const QUERY_KEYS = {
|
||||
documents: (id: string) =>
|
||||
["customers", "detail", id, "documents"] as const,
|
||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||
accounts: (id: string) => ["customers", "detail", id, "accounts"] as const,
|
||||
resetTarget: (id: string) =>
|
||||
["customers", "detail", id, "reset-target"] as const,
|
||||
changeRequests: (id: string) =>
|
||||
|
||||
@@ -138,6 +138,8 @@ export const URL_CONSTANTS = {
|
||||
`/backoffice/customers/${companyId}/reset-password`,
|
||||
RESET_TARGET: (companyId: string) =>
|
||||
`/backoffice/customers/${companyId}/reset-target`,
|
||||
ACCOUNTS: (companyId: string) =>
|
||||
`/backoffice/customers/${companyId}/accounts`,
|
||||
},
|
||||
|
||||
BILLING: {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
FileText,
|
||||
History,
|
||||
Hourglass,
|
||||
KeyRound,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
@@ -43,6 +44,7 @@ import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
AccountCard,
|
||||
BookingStatusBadge,
|
||||
ChangeRequestPendingBadge,
|
||||
ChangeRequestReview,
|
||||
@@ -157,6 +159,12 @@ export default function CustomerDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const accountsQuery = useQuery(
|
||||
api.customers.accounts.queryOptions({
|
||||
input: { companyId: id ?? "" },
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const documentsQuery = useQuery(
|
||||
api.customers.documents.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
@@ -240,12 +248,61 @@ export default function CustomerDetailPage() {
|
||||
<div className="space-y-2">
|
||||
<ProfileTypeBadge type={row.original.type} />
|
||||
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
{/* The profile reference (EX-A00001). Minted only when a reviewer
|
||||
approves the role, so an unapproved one has none — say so
|
||||
rather than rendering an empty line that reads as a bug. */}
|
||||
{row.original.reference ? (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
Ref. issued on approval
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "etradeBusiness",
|
||||
header: "eTrade business",
|
||||
cell: ({ row }) => {
|
||||
const business = row.original.etradeBusiness;
|
||||
// Not attached is a review finding, not a blank: the role names no
|
||||
// business, so there is nothing to check the uploaded licence
|
||||
// against. Companies with no eTrade record legitimately show this,
|
||||
// which is why it reads as a warning rather than an error.
|
||||
if (!business) {
|
||||
return (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Not attached
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={2} maw={230}>
|
||||
<Text size="sm" fw={600} c="edr-text" lineClamp={2}>
|
||||
{business.tradeName || "(no trade name on this licence)"}
|
||||
</Text>
|
||||
{business.activity && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{business.activity}
|
||||
</Text>
|
||||
)}
|
||||
{/* The licence number is what the reviewer matches against the
|
||||
uploaded document — trade names repeat across licences. */}
|
||||
<Text size="xs" c="dimmed">
|
||||
{business.licenceNumber}
|
||||
</Text>
|
||||
{business.renewedTo && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Renewed to {business.renewedTo}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "licenseFiles",
|
||||
header: "License documents",
|
||||
@@ -774,6 +831,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="accounts" leftSection={<KeyRound size={16} />}>
|
||||
Account
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
@@ -981,29 +1041,29 @@ export default function CustomerDetailPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
{/* Padding sits on the header section, not the card, so the
|
||||
table runs edge to edge. minWidth carries the eTrade
|
||||
business column; the region scrolls rather than squashing
|
||||
the other columns. */}
|
||||
<TableCard
|
||||
minWidth={980}
|
||||
header={
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} c="edr-text">
|
||||
Role profiles
|
||||
</Text>
|
||||
<ProfileChips profiles={profiles} />
|
||||
</Group>
|
||||
{/* Narrower than the old full-width layout — the table
|
||||
shares the row with the people column now. */}
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={760}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={profiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={profiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</TableCard>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -1439,6 +1499,51 @@ export default function CustomerDetailPage() {
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* HISTORY */}
|
||||
{/* ACCOUNT — the IAM logins behind this customer. Distinct from the
|
||||
contact details on Overview: those are business contact info on the
|
||||
company row, these are the credentials someone actually signs in
|
||||
with, and the two drift apart routinely. Cards rather than a table:
|
||||
it is a handful of rows of mostly-optional detail, which a table
|
||||
renders as a field of dashes. */}
|
||||
<Tabs.Panel value="accounts" pt="lg">
|
||||
{accountsQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Center>
|
||||
) : accountsQuery.isError ? (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Failed to load accounts"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">
|
||||
We couldn't load this customer's portal logins.
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => void accountsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
) : (accountsQuery.data?.length ?? 0) === 0 ? (
|
||||
<Card>
|
||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||
This customer has no portal login yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
{accountsQuery.data?.map((account) => (
|
||||
<AccountCard key={account.profileId} account={account} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="lg">
|
||||
<CompanyTimeline company={company} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -108,6 +108,24 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
|
||||
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
|
||||
).map((value) => ({ value, label: humanize(value) })),
|
||||
},
|
||||
{
|
||||
// The operational role, not `type` above: one `customer` company routinely
|
||||
// holds importer AND exporter, so this asks "who does X?" rather than
|
||||
// "what kind of company is this?".
|
||||
key: "profileType",
|
||||
label: "Role",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: (
|
||||
[
|
||||
"importer",
|
||||
"exporter",
|
||||
"freight_forwarder",
|
||||
"dj_freight_forwarder",
|
||||
"transporter",
|
||||
] as const
|
||||
).map((value) => ({ value, label: humanize(value) })),
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
label: "Sector",
|
||||
@@ -348,7 +366,7 @@ export default function CustomersPage() {
|
||||
<FilterBar
|
||||
defs={CUSTOMER_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search by company, TIN, email or profile reference…"
|
||||
searchPlaceholder="Search by company, trade name, TIN, email, licence no. or profile ref…"
|
||||
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
viewId="customers"
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
CustomerAccount,
|
||||
CustomerResetTarget,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
@@ -3222,6 +3223,13 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
||||
),
|
||||
|
||||
accounts: endpoint<{ companyId: string }, CustomerAccount[]>(
|
||||
"customers",
|
||||
"accounts",
|
||||
({ companyId }) => customersService.accounts(companyId),
|
||||
({ companyId }) => QUERY_KEYS.CUSTOMERS.accounts(companyId),
|
||||
),
|
||||
|
||||
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
|
||||
"customers",
|
||||
"resetTarget",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
CustomerAccount,
|
||||
CustomerResetTarget,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
@@ -90,6 +91,18 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Every portal login belonging to this customer, primary contact first.
|
||||
*
|
||||
* Not filtered to active accounts — a suspended or never-activated login is
|
||||
* exactly what staff are checking when a customer says they cannot sign in.
|
||||
*/
|
||||
accounts(companyId: string): Promise<CustomerAccount[]> {
|
||||
return apiClient
|
||||
.get<CustomerAccount[]>(URL_CONSTANTS.COMPANIES.ACCOUNTS(companyId))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* The IAM account a reset link would go to. Read before offering the action
|
||||
* so staff see the credentials the link actually reaches, not the company's
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* API so the data layer can be swapped to live endpoints with no UI changes.
|
||||
*/
|
||||
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
|
||||
/** Mirrors backend `CompanyType`. */
|
||||
export type CompanyType =
|
||||
| "customer"
|
||||
@@ -65,6 +67,15 @@ export interface CompanyProfile {
|
||||
/** Business-license documents uploaded for this profile. */
|
||||
licenseFiles?: LicenseFile[];
|
||||
attributes?: Record<string, unknown> | null;
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as.
|
||||
*
|
||||
* A TIN routinely holds a dozen licences split by activity, so "exporter" and
|
||||
* "freight forwarder" are usually two different businesses under one company.
|
||||
* Null when the customer has not attached one, or when the company registered
|
||||
* without eTrade at all (co-operative / investment licence).
|
||||
*/
|
||||
etradeBusiness?: ETradeBusinessOption | null;
|
||||
/** Reviewer note when the role is rejected. */
|
||||
reviewNote?: string | null;
|
||||
createdAt: string;
|
||||
@@ -166,6 +177,34 @@ export interface ResetPasswordResult {
|
||||
* Distinct from `Company.email` / `Company.phone`, which are business contact
|
||||
* details and routinely differ from the credentials the customer logs in with.
|
||||
*/
|
||||
/**
|
||||
* One portal login belonging to a customer: the company-side profile joined to
|
||||
* the IAM account that actually signs in. Mirrors the API's `CustomerAccount`.
|
||||
*
|
||||
* The IAM fields are null when the profile points at a user row that no longer
|
||||
* exists — surfaced rather than hidden, since that is itself a fault worth
|
||||
* seeing.
|
||||
*/
|
||||
export interface CustomerAccount {
|
||||
profileId: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jobTitle: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
onboardingStep: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
phoneVerified: boolean | null;
|
||||
status: string | null;
|
||||
isActive: boolean | null;
|
||||
/** False means the account exists but its owner never set a password. */
|
||||
hasSetPassword: boolean | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerResetTarget {
|
||||
userId: string;
|
||||
name: string;
|
||||
@@ -314,6 +353,13 @@ export interface CompanyListFilter {
|
||||
kind?: CompanyKind;
|
||||
status?: CompanyStatus;
|
||||
nationality?: CompanyNationality;
|
||||
/**
|
||||
* Only companies holding this operational role. Distinct from `type`, which
|
||||
* is the company's own kind — a `customer` company can hold importer,
|
||||
* exporter and forwarder roles at once, and its other roles still come back
|
||||
* on the row.
|
||||
*/
|
||||
profileType?: ProfileType;
|
||||
/** ISO instants — inclusive bounds on the registration date. */
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -76,9 +76,14 @@ export interface ETradeBusinessOption {
|
||||
|
||||
export interface CompanyRegistrationData {
|
||||
/**
|
||||
* The registered organization name — `ETradeCompanyInfo.BusinessName`, falling
|
||||
* back to the licence's `TradeName`. Never the manager/owner's personal name;
|
||||
* that is {@link managerName}.
|
||||
* The selected licence's trade name — `ETradeBusinessInfo.TradeName`, falling
|
||||
* back to the registered organization name (`ETradeCompanyInfo.BusinessName`)
|
||||
* when eTrade leaves the licence's trade name blank. Never the manager/owner's
|
||||
* personal name; that is {@link managerName}.
|
||||
*
|
||||
* NOT the legal entity name: a TIN often trades under a different name, and
|
||||
* some hold several licences with different trade names. Anything that needs
|
||||
* the registered name (tax/EIMS) must read `BusinessName` directly.
|
||||
*/
|
||||
companyName: string;
|
||||
licenceNumber: string;
|
||||
|
||||
Reference in New Issue
Block a user