diff --git a/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts b/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts new file mode 100644 index 000000000..7e2db4eb3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Attaches an eTrade business licence to each operational profile. + * + * A TIN routinely holds a dozen or more licences, split by activity ("Export + * trade in coffee", "Freight Forwarders"), and until now the company picked one + * for the whole record — every role shared it. Each profile now names the + * business it actually operates as. + * + * Stored as a snapshot ({@link ETradeBusinessOption}: licenceNumber, tradeName, + * activity, renewedTo) rather than a bare licence number, so the portal and the + * backoffice can show which business is attached without an eTrade round-trip — + * eTrade is slow, serves a broken TLS chain, and is regularly down. + * + * Nullable: existing profiles have none until the customer attaches one, and a + * co-operative or investor-licence company has no eTrade record at all. + * Deliberately NOT unique — one business can back several profiles. + */ +export class CompanyProfileEtradeBusiness3760000000000 implements MigrationInterface { + name = 'CompanyProfileEtradeBusiness3760000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + ADD COLUMN IF NOT EXISTS etrade_business jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + DROP COLUMN IF EXISTS etrade_business + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts b/apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts new file mode 100644 index 000000000..d051e268b --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts @@ -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, + @InjectRepository(User) + private readonly users: Repository, + ) {} + + /** + * 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 { + 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(); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts index 52a900fe8..8da218193 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts @@ -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 { + return this.customerAccountsService.listForCompany(companyId); + } @Get(":companyId/reset-target") @BookingStaff(FREIGHT_PERMS.customers.resetPassword) diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 557e50fb3..9afe9efc0 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 175e3e00c..9dc6e15e9 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 }] diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index ebdf51be1..c443fad7e 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -1,4 +1,4 @@ -import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service"; +import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service"; const model = (over: Partial = {}): 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 0f8e4ee71..78cea01b6 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 5d29b32f6..000b0733f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import type { ETradeBusinessOption } from "@edr/types"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; +import { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; import { CompanyIdentityStateDto, @@ -260,11 +262,42 @@ export class CompaniesController { ): Promise { const profiles = await this.companiesService.addCompanyProfilesForUser( user.id, - dto.types, + dto.profiles, ); return profiles.map((p) => new ResponseCompanyProfileDto(p)); } + @Get("etrade-businesses") + @PortalCustomer() + @ApiOperation({ + summary: + "The eTrade business licences under this company's TIN, for attaching to its operational profiles", + }) + async listEtradeBusinesses( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.listEtradeBusinessesForUser(user.id); + } + + @Patch("company-profiles/:profileId/etrade-business") + @PortalCustomer() + @ApiOperation({ + summary: + "Attach one of the TIN's eTrade businesses to an operational profile (re-attaching refreshes the stored snapshot)", + }) + async attachEtradeBusiness( + @CurrentUser() user: CurrentIamUser, + @Param("profileId") profileId: string, + @Body() dto: AttachEtradeBusinessDto, + ): Promise { + const profile = await this.companiesService.attachEtradeBusinessToProfile( + user.id, + profileId, + dto.licenceNumber, + ); + return new ResponseCompanyProfileDto(profile); + } + @Post("onboarding/start") @PortalCustomer() @ApiOperation({ @@ -321,6 +354,7 @@ export class CompaniesController { user.id, dto.type, dto.businessLicense, + dto.licenceNumber, ); return new ResponseCompanyProfileDto(profile); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 30c323ac3..da2497d90 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -162,7 +162,16 @@ function makeService(overrides: Partial = {}) { {} as never, deps.filesService as never, deps.fileUploadSettings as never, - {} as never, + // Only the business-licence lookup is exercised here: adding a role now + // resolves which eTrade business it operates as. + { + findBusinessOption: async (_tin: string, licenceNumber: string) => ({ + licenceNumber, + tradeName: "Test Trade Name", + activity: "Freight Forwarders", + renewedTo: "7/7/2026", + }), + } as never, deps.companyNotifier as never, {} as never, deps.verifayda as never, @@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => { describe("the freight-forwarder gate", () => { const addForwarder = (service: CompaniesService) => - service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]); + service.addCompanyProfilesForUser("user-1", [ + { type: ProfileType.freightForwarder, licenceNumber: "LIC-1" }, + ]); it("blocks the role while the representative is unverified", async () => { const { service } = makeService({ attributes: { poaDeclared: "yes" } }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 96dc3ff53..b801a793f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -133,7 +133,16 @@ function makeService(overrides: Partial = {}) { {} as never, deps.filesService as never, {} as never, - {} as never, + // Only the business-licence lookup is exercised here: adding a role now + // resolves which eTrade business it operates as. + { + findBusinessOption: async (_tin: string, licenceNumber: string) => ({ + licenceNumber, + tradeName: "Test Trade Name", + activity: "Freight Forwarders", + renewedTo: "7/7/2026", + }), + } as never, deps.companyNotifier as never, {} as never, {} as never, @@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { service.createCompanyProfileForUser( "user-1", ProfileType.freightForwarder, + undefined, + "LIC-1", ), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { service.createCompanyProfileForUser( "user-1", ProfileType.freightForwarder, + undefined, + "LIC-1", ), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { service.createCompanyProfileForUser( "user-1", ProfileType.freightForwarder, + undefined, + "LIC-1", ), ).resolves.toBeDefined(); }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts new file mode 100644 index 000000000..c04ce40f9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts @@ -0,0 +1,149 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { CompaniesService } from "./companies.service"; +import { ProfileType } from "./entities/company-profile.entity"; +import { COOPERATIVE_KEY } from "./entities/company.entity"; + +/** + * A TIN holds many business licences; each operational profile names the one it + * trades as. What matters here is that the stored business is always eTrade's + * own record, looked up under the company's own TIN — never the client's word + * for it — and that the requirement lifts for a company eTrade knows nothing + * about. + */ +const BUSINESSES = [ + { + licenceNumber: "MT/AA/14/670/128936/2007", + tradeName: "Pave Freight Forwarding", + activity: "Freight Forwarders", + renewedTo: "7/7/2026", + }, + { + licenceNumber: "MT/AA/14/670/11551235/2017", + tradeName: "Pave Minerals Export", + activity: "Export trade in minerals", + renewedTo: "7/7/2026", + }, +]; + +function makeService(attributes: Record = {}) { + const company = { + id: "company-1", + tin: "0045014036", + type: "customer", + attributes, + companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }], + }; + + const created: Record[] = []; + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => created), + findByType: jest.fn(async () => null), + create: jest.fn(async (row: Record) => { + created.push({ id: `profile-${created.length + 2}`, ...row }); + return created[created.length - 1]; + }), + update: jest.fn(async (id: string, data: Record) => ({ + id, + ...data, + })), + }; + + const etradeService = { + listBusinessOptions: jest.fn(async () => BUSINESSES), + findBusinessOption: jest.fn(async (_tin: string, licenceNumber: string) => { + const match = BUSINESSES.find((b) => b.licenceNumber === licenceNumber); + if (!match) throw new BadRequestException("no such licence"); + return match; + }), + }; + + const service = new CompaniesService( + {} as never, + companyProfilesRepo as never, + {} as never, + {} as never, + { findByUserId: jest.fn(async () => ({ id: "ext-1", companyId: "company-1" })) } as never, + {} as never, + {} as never, + {} as never, + etradeService as never, + {} as never, + {} as never, + {} as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation(async () => ({ profile: {}, company }) as never); + // Private, but every add path goes through it; stubbing it keeps this spec on + // the business-attachment logic instead of the whole company lookup graph. + (service as unknown as Record).findCompanyById = async () => + company; + + return { service, companyProfilesRepo, etradeService }; +} + +describe("attaching an eTrade business to a company profile", () => { + it("stores eTrade's own record for the chosen licence, not the client's", async () => { + const { service, companyProfilesRepo } = makeService(); + const updated = await service.attachEtradeBusinessToProfile( + "user-1", + "profile-1", + "MT/AA/14/670/128936/2007", + ); + expect(companyProfilesRepo.update).toHaveBeenCalledWith("profile-1", { + etradeBusiness: BUSINESSES[0], + }); + expect(updated.etradeBusiness).toEqual(BUSINESSES[0]); + }); + + it("refuses a licence eTrade does not list under this TIN", async () => { + const { service } = makeService(); + await expect( + service.attachEtradeBusinessToProfile("user-1", "profile-1", "SOMEONE/ELSES/LICENCE"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses a profile belonging to another company", async () => { + const { service } = makeService(); + await expect( + service.attachEtradeBusinessToProfile("user-1", "not-mine", BUSINESSES[0].licenceNumber), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("the same business may back more than one profile", async () => { + const { service, etradeService } = makeService(); + await service.addCompanyProfilesForUser("user-1", [ + { type: ProfileType.exporter, licenceNumber: BUSINESSES[0].licenceNumber }, + { type: ProfileType.importer, licenceNumber: BUSINESSES[0].licenceNumber }, + ]); + expect(etradeService.findBusinessOption).toHaveBeenCalledTimes(2); + }); +}); + +describe("choosing a business is required when the company has one to choose", () => { + it("rejects a role added without a licence", async () => { + const { service } = makeService(); + await expect( + service.addCompanyProfilesForUser("user-1", [{ type: ProfileType.exporter }]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("lifts the requirement for a co-operative, which has no eTrade record", async () => { + const { service, companyProfilesRepo, etradeService } = makeService({ + [COOPERATIVE_KEY]: true, + }); + await service.addCompanyProfilesForUser("user-1", [ + { type: ProfileType.exporter }, + ]); + expect(etradeService.findBusinessOption).not.toHaveBeenCalled(); + expect(companyProfilesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ etradeBusiness: null }), + ); + }); + + it("offers a co-operative no businesses to pick from", async () => { + const { service } = makeService({ [COOPERATIVE_KEY]: true }); + await expect(service.listEtradeBusinessesForUser("user-1")).resolves.toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 17e3631e0..673e0fe64 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository { createdTo, onboardingCompleted, hasPendingChangeRequest, + profileType, sortBy = 'review', sortOrder = 'DESC', } = query; @@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository { 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 diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index d7151f200..1080ceb12 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -47,7 +47,7 @@ import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; -import type { CompanyRegistrationData } from "@edr/types"; +import type { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -343,6 +343,11 @@ export class CompaniesService { companyId: company.id, type: input.type, businessLicense: input.businessLicense ?? null, + etradeBusiness: await this.resolveProfileBusiness( + company, + input.licenceNumber, + input.type, + ), status: ProfileStatus.Pending, }); } @@ -2149,8 +2154,9 @@ export class CompaniesService { */ async addCompanyProfilesForUser( userId: string, - types: ProfileType[], + inputs: Array<{ type: ProfileType; licenceNumber?: string }>, ): Promise { + const types = inputs.map((i) => i.type); const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); @@ -2185,11 +2191,21 @@ export class CompaniesService { ); } + // Which eTrade business this role operates as. Resolved (and rejected if + // absent) BEFORE the row is created, so a role never lands unattached on + // a company that has licences to pick from. + const etradeBusiness = await this.resolveProfileBusiness( + company, + inputs.find((i) => i.type === type)?.licenceNumber, + type, + ); + // Self-service role adds start Pending and carry no reference — a reference // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ companyId, type, + etradeBusiness, status: ProfileStatus.Pending, }); } @@ -2207,6 +2223,7 @@ export class CompaniesService { userId: string, type: ProfileType, businessLicense?: string, + licenceNumber?: string, ): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) @@ -2232,12 +2249,18 @@ export class CompaniesService { ); } if (!created) { + const etradeBusiness = await this.resolveProfileBusiness( + company, + licenceNumber, + type, + ); // New self-service roles start Pending (awaiting backoffice approval) and // carry no reference until approved. created = await this.companyProfilesRepo.create({ companyId, type, businessLicense: businessLicense ?? null, + etradeBusiness, status: ProfileStatus.Pending, }); } @@ -2320,6 +2343,7 @@ export class CompaniesService { type: p.type, reference: p.reference ?? "", uploaded: records.some((r) => r.code === LICENSE_CODE), + etradeBusiness: p.etradeBusiness ?? null, }; }), ); @@ -2331,6 +2355,19 @@ export class CompaniesService { ? [] : licenseProfiles.filter((p) => !p.uploaded); + // Which eTrade business each role operates as. Enforced here rather than at + // role creation because the wizard picks roles on its FIRST step, before a + // TIN has been entered — there is nothing to pick from yet. The customer + // attaches one on the documents step, alongside that role's licence file, + // and onboarding cannot be submitted until every role has one. + // + // Lifted for a company with no eTrade record at all: a co-operative or a + // foreign investor has no licence list, so the requirement would be + // unsatisfiable (see `usesManualRegistration`). + const missingBusinesses = usesManualRegistration(company) + ? [] + : licenseProfiles.filter((p) => !p.etradeBusiness); + // 4. Power of Attorney. Whether there is one at all is the company's own // declaration — the question the wizard asks outright — and that answer is // what decides whose identity gets verified, so an unanswered one is itself @@ -2378,6 +2415,10 @@ export class CompaniesService { (p) => `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, ), + ...missingBusinesses.map( + (p) => + `Choose which eTrade business your ${p.type.replace(/_/g, " ")} profile operates as`, + ), ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), ...(missingDelegation ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] @@ -2416,6 +2457,8 @@ export class CompaniesService { requiredInfo.length + requiredDocCount + (cooperative ? 0 : licenseProfiles.length) + + // One "which business?" item per role, on the same terms as the licences. + (usesManualRegistration(company) ? 0 : licenseProfiles.length) + poaItemCount + // The declaration and the verification it selects. 2; @@ -2424,6 +2467,7 @@ export class CompaniesService { (missingInfo.length + missingDocs.length + missingLicenses.length + + missingBusinesses.length + missingPoaFields.length + (missingDelegation || flaggedDelegation ? 1 : 0) + missingIdentityCount); @@ -3747,6 +3791,86 @@ export class CompaniesService { return match?.id ?? null; } + /** + * Resolve the eTrade business a new/updated profile is being attached to. + * + * The client sends a licence number; what gets stored is eTrade's own record + * of it, looked up under THIS company's TIN. That is the whole check — a + * licence belonging to someone else's TIN simply is not in the list, so a + * client cannot attach a profile to a business the company does not hold. + * + * Returns null (rather than throwing) for a company that registered without + * eTrade: a co-operative union or farm holds no business licence, and a + * foreign investor's licence is the Investment Commission's, not the trade + * registry's. There is no list for them to pick from, so the role is theirs + * to hold unattached — the reviewer checks their uploaded documents instead. + */ + private async resolveProfileBusiness( + company: Company, + licenceNumber: string | undefined, + type: ProfileType, + ): Promise { + if (usesManualRegistration(company)) return null; + if (!licenceNumber) { + throw new BadRequestException( + `Choose which of your eTrade business licences the ${type.replace(/_/g, " ")} profile operates as.`, + ); + } + return this.etradeService.findBusinessOption(company.tin, licenceNumber); + } + + /** + * The eTrade business licences the current user's company can attach to its + * operational profiles. Empty for a company that registered without eTrade. + */ + async listEtradeBusinessesForUser( + userId: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + if (usesManualRegistration(company)) return []; + return this.etradeService.listBusinessOptions(company.tin); + } + + /** + * Attach (or re-attach) one of the TIN's eTrade businesses to a profile. + * + * Separate from role creation because the onboarding wizard picks roles + * before the TIN is known — the business is chosen later, on the step that + * already collects each role's licence document. Re-attaching also refreshes + * the stored snapshot, which is how a renewed licence's new expiry lands. + */ + async attachEtradeBusinessToProfile( + userId: string, + profileId: string, + licenceNumber: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const profile = (company.companyProfiles ?? []).find( + (p) => p.id === profileId, + ); + if (!profile) { + throw new NotFoundException( + `Company profile ${profileId} not found for this company`, + ); + } + if (usesManualRegistration(company)) { + throw new BadRequestException( + "This company is not registered with eTrade, so it has no business licences to attach.", + ); + } + const business = await this.etradeService.findBusinessOption( + company.tin, + licenceNumber, + ); + const updated = await this.companyProfilesRepo.update(profile.id, { + etradeBusiness: business, + }); + if (!updated) { + throw new NotFoundException(`Company profile ${profileId} not found`); + } + return updated; + } + /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ private async resolveEtradeRegistration( tin: string, diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts index 838c42111..8809310cc 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -1,9 +1,37 @@ -import { IsArray, IsEnum, ArrayMinSize } from "class-validator"; +import { Type } from "class-transformer"; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; import { ProfileType } from "../entities/company-profile.entity"; +export class AddCompanyProfileInputDto { + @IsEnum(ProfileType) + type!: ProfileType; + + /** + * Which of the TIN's eTrade business licences this role operates as. + * + * Optional at the DTO layer, required by the service for any company that + * HAS an eTrade record — a co-operative or investor-licence company has none + * to pick from, and rejecting them here would be wrong. See + * `CompaniesService.resolveProfileBusiness`. + */ + @IsOptional() + @IsString() + @MaxLength(120) + licenceNumber?: string; +} + export class AddCompanyProfilesDto { @IsArray() @ArrayMinSize(1) - @IsEnum(ProfileType, { each: true }) - types!: ProfileType[]; + @ValidateNested({ each: true }) + @Type(() => AddCompanyProfileInputDto) + profiles!: AddCompanyProfileInputDto[]; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts new file mode 100644 index 000000000..3ee50b51a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, MaxLength } from "class-validator"; + +export class AttachEtradeBusinessDto { + /** + * The eTrade licence number of the business this profile operates as. Checked + * against the licences eTrade lists under the company's own TIN, so an + * unknown or someone else's licence is rejected rather than stored. + */ + @IsString() + @IsNotEmpty() + @MaxLength(120) + licenceNumber!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts index 9ac6c13b7..9bb5453ee 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -9,4 +9,14 @@ export class CreateCompanyProfileDto { @IsString() @MaxLength(100) businessLicense?: string; + + /** + * Which of the TIN's eTrade business licences this role operates as. Required + * by the service for any company that has an eTrade record; see + * `AddCompanyProfileInputDto.licenceNumber`. + */ + @IsOptional() + @IsString() + @MaxLength(120) + licenceNumber?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index d82093336..db58d0bd7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -22,6 +22,16 @@ export class CompanyProfileInputDto { @IsString() @MaxLength(100) businessLicense?: string; + + /** + * Which of the TIN's eTrade business licences this role operates as. Required + * by the service for any company that has an eTrade record; see + * `CompaniesService.resolveProfileBusiness`. + */ + @IsOptional() + @IsString() + @MaxLength(120) + licenceNumber?: string; } export class CreateCompanyWithProfileDto { diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 18b816a2a..33a5f4131 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 49dad4b8d..a31e517d5 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -8,6 +8,7 @@ * truth the wizard uses to auto-finish. */ +import type { ETradeBusinessOption } from "@edr/types"; import { CompanyIdentityStateDto, PoaDeclaration, @@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile { reference: string; /** True when at least one business-license file is stored on the profile. */ uploaded: boolean; + /** + * The eTrade business this role operates as, once the customer has attached + * one. Null while outstanding — the wizard renders the picker off this. + */ + etradeBusiness: ETradeBusinessOption | null; } export interface OnboardingPoaState { diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 7c4348e4f..321d739e3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -6,6 +6,7 @@ import { hasInvestorLicence, isCooperative, } from '../entities/company.entity'; +import type { ETradeBusinessOption } from '@edr/types'; import { CompanyProfile, ProfileLicenseFileView, @@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto { */ licenseFiles: ProfileLicenseFileView[]; attributes?: Record | null; + /** + * The eTrade business licence this role operates as, or null when nothing is + * attached yet (or the company registered without eTrade). Snapshot — see + * `CompanyProfile.etradeBusiness`. + */ + etradeBusiness?: ETradeBusinessOption | null; /** Reviewer note when the role is rejected (drives the reapply prompt). */ reviewNote?: string | null; createdAt: Date; @@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto { this.businessLicense = profile.businessLicense; this.licenseFiles = []; this.attributes = profile.attributes; + this.etradeBusiness = profile.etradeBusiness ?? null; this.reviewNote = profile.reviewNote ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 9bba9396e..0a16343f4 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from "@edr/api-common"; +import type { ETradeBusinessOption } from "@edr/types"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; import { Company } from "./company.entity"; @@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity { @Column({ name: "business_license_files", type: "jsonb", nullable: true }) businessLicenseFiles?: BusinessLicenseFile[] | null; + /** + * Which of the TIN's eTrade business licences this profile operates as. + * + * A TIN holds many licences split by activity, so "exporter" and "freight + * forwarder" are usually two different businesses under one company. Stored + * as a snapshot rather than a bare licence number so the trade name and + * activity render without an eTrade call — that API is slow and regularly + * down, and this is display data, not a source of truth. Re-attaching + * refreshes it. + * + * NULL when nothing is attached yet, or when the company registered without + * eTrade at all (co-operative / investor licence — see + * {@link usesManualRegistration}). One business may back several profiles. + */ + @Column({ name: "etrade_business", type: "jsonb", nullable: true }) + etradeBusiness?: ETradeBusinessOption | null; + @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts index 86117d306..1400c069d 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index fac57238a..9a258099c 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs"; import { ETradeCompanyInfo, ETradeBusinessInfo, + ETradeBusinessOption, CompanyRegistrationData, normalizeRegion, } from "@edr/types"; @@ -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 { + const companyInfo = await this.getCompanyInfoByTin(tin); + return (companyInfo.Businesses ?? []).map(toBusinessOption); + } + + /** + * Resolve one of the TIN's licences, or throw if eTrade does not list it. + * + * This is the trust boundary for a client-supplied licence number: a profile + * may only ever be attached to a business eTrade actually holds under that + * TIN, so the snapshot that gets stored is eTrade's own data, never the + * client's. + */ + async findBusinessOption( + tin: string, + licenceNumber: string, + ): Promise { + const options = await this.listBusinessOptions(tin); + const match = options.find((b) => b.licenceNumber === licenceNumber); + if (!match) { + throw new BadRequestException( + `eTrade lists no business licence "${licenceNumber}" under TIN ${tin}.`, + ); + } + return match; + } +} + +function toBusinessOption( + b: ETradeCompanyInfo["Businesses"][number], +): ETradeBusinessOption { + return { + licenceNumber: b.LicenceNumber, + tradeName: b.TradesName?.trim() || "", + activity: (b.SubGroups ?? []) + // Some descriptions repeat the code inline ("(65611)Import trade …"). + // eTrade also puts null entries in this array, so every hop is optional. + .map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim()) + .filter(Boolean) + .join(", "), + renewedTo: b.RenewedTo || "", + }; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts index be3e3aa8e..7afe644a1 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 558ffc1a9..e29eba2c2 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx new file mode 100644 index 000000000..438f2293a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx @@ -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 ( + + + {icon} + + + + {label} + + + + {value} + + {after} + + + + ); +} + +/** + * 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 ( + + + + + {initials(account)} + + + + + {name} + + {account.isPrimaryContact && ( + + Primary contact + + )} + + {account.jobTitle && ( + + {account.jobTitle} + + )} + + + + + {orphaned ? ( + } + > + No IAM account + + ) : ( + <> + + {account.isActive ? "Active" : "Inactive"} + + {account.status && ( + + {humanize(account.status)} + + )} + {/* Created but never activated by its owner — usually the actual + answer to "they say they never got in". */} + {account.hasSetPassword === false && ( + + Password never set + + )} + + )} + {account.onboardingCompleted ? ( + + Onboarding submitted + + ) : ( + + Onboarding + {account.onboardingStep + ? ` · ${humanize(account.onboardingStep)}` + : " in progress"} + + )} + + + {!orphaned && ( + <> + + + } + label="Username" + value={account.username} + /> + } + label="Email" + value={account.email} + /> + } + label="Phone" + value={account.phoneNumber} + after={ + account.phoneVerified === false ? ( + + Unverified + + ) : undefined + } + /> + + + )} + + + Created {formatDate(account.createdAt)} + + + + ); +} + +export default AccountCard; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx index b67181da5..14aa8e80e 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx @@ -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 ( + {header && ( + + {header} + + )} {children} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 864a89ae6..463a85eca 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -15,6 +15,7 @@ export { ChangeRequestReview, ChangeRequestPendingBadge, } from "./ChangeRequestReview"; +export { AccountCard } from "./AccountCard"; export { CompanyTimeline } from "./CompanyTimeline"; export { RequestDocumentChangeModal, diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 3f1cce1b5..66a55b555 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -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) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 8249537a5..ad0fbafc9 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index a81f07c8b..8f315dff0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -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() {
- - {row.original.reference} - + {/* 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 ? ( + + {row.original.reference} + + ) : ( + + Ref. issued on approval + + )}
), }, + { + 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 ( + + Not attached + + ); + } + return ( + + + {business.tradeName || "(no trade name on this licence)"} + + {business.activity && ( + + {business.activity} + + )} + {/* The licence number is what the reviewer matches against the + uploaded document — trade names repeat across licences. */} + + {business.licenceNumber} + + {business.renewedTo && ( + + Renewed to {business.renewedTo} + + )} + + ); + }, + }, { id: "licenseFiles", header: "License documents", @@ -774,6 +831,9 @@ export default function CustomerDetailPage() { }> Invoices + }> + Account + }> History @@ -981,29 +1041,29 @@ export default function CustomerDetailPage() {
- - + {/* 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. */} + Role profiles - {/* Narrower than the old full-width layout — the table - shares the row with the people column now. */} - - - - - - - + } + > + + @@ -1439,6 +1499,51 @@ export default function CustomerDetailPage() { {/* 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. */} + + {accountsQuery.isLoading ? ( +
+ +
+ ) : accountsQuery.isError ? ( + } + title="Failed to load accounts" + > + + + We couldn't load this customer's portal logins. + + + + + ) : (accountsQuery.data?.length ?? 0) === 0 ? ( + + + This customer has no portal login yet. + + + ) : ( + + {accountsQuery.data?.map((account) => ( + + ))} + + )} +
+ diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 72eb38fe6..ef57313f6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -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() { ({ ...o }))} viewId="customers" > diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index c2026dce6..94da4837e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -135,14 +135,8 @@ export default function TrainScheduleV2DetailPage() { // Actual departure — staff often dispatch on paper first and record it later, // so the time is picked (defaults to now when the dialog opens). const [dispatchAt, setDispatchAt] = useState(null); - // Loading is manual: dispatch decides the fate of every unloaded origin - // boarder — checked = loaded and departs, unchecked = left behind (wagon - // freed, booking back to the pool). Default unchecked; government bookings - // cannot be removed from a train so they are forced on. - const [dispatchLoadedIds, setDispatchLoadedIds] = useState>(new Set()); const openDispatchConfirm = () => { setDispatchAt(new Date()); - setDispatchLoadedIds(new Set()); setDispatchConfirmOpen(true); }; const [switchTarget, setSwitchTarget] = useState(null); @@ -473,9 +467,8 @@ export default function TrainScheduleV2DetailPage() { // per yard from the track page's log-pass flow. Everything below is advisory. const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0; - // Unloaded boarders at the TRAIN's origin — the dispatch dialog's manual - // load/leave list. Mirrors the API's unloadedOriginBoarderIds predicate - // (plus government, which is shown but forced-loaded). + // Unloaded boarders at the TRAIN's origin — all sent as loaded on dispatch. + // Mirrors the API's unloadedOriginBoarderIds predicate (plus government). const originYardId = schedule.originStation?.id; const pendingOriginBoarders = dispatchBookings.filter( (b) => @@ -489,12 +482,9 @@ export default function TrainScheduleV2DetailPage() { // Shipping-line bookings ride from accept on the credit ledger. (Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")), ); - const dispatchLeftCount = pendingOriginBoarders.filter( - (b) => !b.isGovernment && !dispatchLoadedIds.has(b.id), - ).length; - // Origin loading time window: dispatch (which marks the ticked boarders - // loaded) is server-rejected until "Start loading" was clicked for the - // origin yard, so the button mirrors that gate. + // Origin loading time window: dispatch (which marks the boarders loaded) + // is server-rejected until "Start loading" was clicked for the origin + // yard, so the button mirrors that gate. const originLoadingLog = originYardId ? schedule.stationWorkLogs?.[originYardId]?.loading : undefined; @@ -557,9 +547,9 @@ export default function TrainScheduleV2DetailPage() { id: scheduleId, payload: { ...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}), - loadedBookingIds: pendingOriginBoarders - .filter((b) => b.isGovernment || dispatchLoadedIds.has(b.id)) - .map((b) => b.id), + // No per-booking ticking in the dispatch dialog: every pending origin + // boarder rides — none are left behind at dispatch time. + loadedBookingIds: pendingOriginBoarders.map((b) => b.id), }, }); await openMarshallingDocument({ @@ -1590,56 +1580,6 @@ export default function TrainScheduleV2DetailPage() { radius="md" /> - {pendingOriginBoarders.length > 0 ? ( - - - Cargo boarding at {schedule.originStation?.label ?? "the origin yard"} — - tick what was loaded - - {originYardId ? ( - - ) : null} - - Unticked bookings are left behind: removed from this train, their - wagons freed, and the booking returned to the pool for a later - schedule. The customer is notified. - - - {pendingOriginBoarders.map((b) => ( - { - const next = new Set(dispatchLoadedIds); - if (e.currentTarget.checked) next.add(b.id); - else next.delete(b.id); - setDispatchLoadedIds(next); - }} - label={ - - {b.reference ?? b.id.slice(0, 8)} — {b.customer ?? "Unknown customer"} - {b.isGovernment ? " (government — always rides)" : ""} - - } - /> - ))} - - {dispatchLeftCount > 0 ? ( - - {dispatchLeftCount} booking{dispatchLeftCount === 1 ? "" : "s"} will - be left behind and returned to the booking pool. - - ) : null} - - ) : null} - {hasDispatchWarnings ? ( 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", diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index 5b3cc4527..6adf413e3 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -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 { + return apiClient + .get(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 diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 3c2427a1b..1d3eb7588 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -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 | 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; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx b/apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx new file mode 100644 index 000000000..f1be4ab86 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/EtradeBusinessSelect.tsx @@ -0,0 +1,110 @@ +import { Alert, Loader, Select, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { AlertCircle } from "lucide-react"; +import { useMemo } from "react"; + +import type { ETradeBusinessOption } from "@edr/types"; +import { api } from "@/services/api"; + +/** + * The eTrade business licences held under the signed-in company's TIN, cached + * for the session. Fetching goes out to eTrade, which is slow and regularly + * down, so this must not refetch on every mount of every role card. + */ +export function useEtradeBusinesses() { + return useQuery({ + ...api.companies.listEtradeBusinesses.queryOptions(), + staleTime: 5 * 60 * 1000, + retry: 1, + }); +} + +/** One licence, as it reads in the dropdown: trade name, then what it licenses. */ +export function businessLabel(b: ETradeBusinessOption): string { + const name = b.tradeName || "(no trade name on this licence)"; + return b.activity ? `${name} — ${b.activity}` : name; +} + +interface EtradeBusinessSelectProps { + /** Currently attached licence number, if any. */ + value: string | null; + onChange: (licenceNumber: string) => void; + label?: string; + error?: string; + disabled?: boolean; +} + +/** + * Which of the TIN's eTrade businesses a company profile operates as. + * + * A TIN routinely holds a dozen licences split by activity — export of coffee, + * freight forwarding, import of vehicles — so the role a customer signs up for + * corresponds to one specific business, not to the company as a whole. The same + * business may legitimately back several roles, so nothing is filtered out + * because it is already in use elsewhere. + */ +export default function EtradeBusinessSelect({ + value, + onChange, + label = "Which business does this profile operate as?", + error, + disabled, +}: EtradeBusinessSelectProps) { + const { data, isLoading, isError } = useEtradeBusinesses(); + + const options = useMemo( + () => + (data ?? []).map((b) => ({ + value: b.licenceNumber, + label: businessLabel(b), + })), + [data], + ); + + if (isLoading) { + return ( + + + {label} + + + + ); + } + + if (isError) { + return ( + }> + We couldn't reach eTrade to list your business licences. Try again in a + moment. + + ); + } + + if (options.length === 0) { + return ( + + eTrade lists no business licence under your TIN, so there is nothing to + attach here. + + ); + } + + return ( + e.currentTarget.blur()} + className="flex-1 min-w-0 truncate bg-transparent border-0 p-0 text-sm text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-0 cursor-default" + /> + + + ); + + const heading = { + identifier: { title: 'Sign in', subtitle: 'Enter your phone number or email to continue' }, + password: { title: 'Welcome back', subtitle: 'Enter your password to sign in' }, + setup: { title: 'Set your password', subtitle: 'Enter the code we sent, then choose a password' }, + signup: { title: 'Create your account', subtitle: 'We just need a couple of details' }, + }[step]; + + const setupBlurb = + setupMethod === 'fayda' + ? 'Your Fayda-verified account does not have a password yet.' + : setupMethod === 'new' + ? 'Your account is almost ready.' + : 'You started signing up but never chose a password.'; + return (
@@ -54,86 +299,227 @@ function LoginContent() {
-

Sign in

-

Welcome back

+

{heading.title}

+

{heading.subtitle}

-
- {error && ( -
- {error} -
- )} - -
- - - {errors.email && ( -

{errors.email.message}

- )} + {error && ( +
+ {error}
+ )} -
- -
+ {step === 'identifier' && ( + +
+ + { setIdentifier(e.target.value); setError(''); }} + className="input-field" + placeholder="+251912345678 or your@email.com" + autoComplete="username" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + autoFocus + /> +
+ + + )} + + {step === 'password' && ( +
+ {identifierChip} +
+ +
+ { setPassword(e.target.value); setError(''); }} + className="input-field pr-10" + placeholder="••••••••" + autoComplete="current-password" + autoFocus + /> + +
+
+ + Forgot password? + +
+
+ +
+ )} + + {step === 'setup' && ( +
+ {identifierChip} +

+ {setupBlurb}{' '} + {maskedPhone + ? <>We sent a code to {maskedPhone}. + : 'We sent a code to your registered phone.'} +

+ +
+ + { setOtp(e.target.value); setError(''); }} + className="input-field tracking-widest" + placeholder="A1b2C3" + // The IAM issues codes with generateRandomString(6): letters and digits, + // and case-sensitive — so no numeric keypad and no autocapitalise. + inputMode="text" + autoComplete="one-time-code" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + maxLength={6} + autoFocus + /> +
+ +
+ +
+ { setNewPassword(e.target.value); setError(''); }} + className="input-field pr-10" + placeholder="••••••••" + autoComplete="new-password" + /> + +
+

{PASSWORD_RULE}

+
+ +
+ { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" placeholder="••••••••" + autoComplete="new-password" /> -
- {errors.password && ( -

{errors.password.message}

- )} -
- - Forgot password? - + + + +
+ {resendNote ? ( + {resendNote} + ) : ( + + )}
-
+
+ )} - - + {step === 'signup' && ( +
+ {identifierChip} +

+ We couldn't find an account for that {identifierIsEmail ? 'email' : 'number'}, so + let's create one. +

-
-
- Don't have an account? - - Create account - -
- - - Already verified with Fayda? Set up your password - -
+
+ + { setFullName(e.target.value); setError(''); }} + className="input-field" + placeholder="e.g. Abebe Kebede" + autoComplete="name" + autoFocus + /> +
-
+
+ + { setSecondaryContact(e.target.value); setError(''); }} + className="input-field" + placeholder={identifierIsEmail ? '+251912345678' : 'your@email.com'} + autoComplete={identifierIsEmail ? 'tel' : 'email'} + /> +

+ {identifierIsEmail + ? "We'll text your verification code to this number." + : "For receipts and booking confirmations. Your verification code is sent by SMS either way."} +

+
+ + + + )} + +
) : ( -
+
- Sign in - - - Register + Sign in or register
)} diff --git a/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx b/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx index a20301e85..4edf30b64 100644 --- a/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx +++ b/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { createPortal } from 'react-dom'; import { X, CheckCircle } from 'lucide-react'; import { iamAuthApi } from '@/lib/api/auth'; +import { isStrongPassword, PASSWORD_RULE } from '@/lib/password'; interface ChangePasswordModalProps { isOpen: boolean; @@ -32,8 +33,8 @@ export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordM const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); - if (newPassword.length < 6) { - setError('New password must be at least 6 characters.'); + if (!isStrongPassword(newPassword)) { + setError(PASSWORD_RULE); return; } if (newPassword !== confirmPassword) { diff --git a/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx index 92f3843fc..535739c1d 100644 --- a/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx +++ b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react'; import { iamAuthApi } from '@/lib/api/auth'; +import { isStrongPassword, PASSWORD_RULE } from '@/lib/password'; interface FaydaSetupWizardProps { // Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...) @@ -41,8 +42,8 @@ export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps) const handleSetPassword = async (e: React.FormEvent) => { e.preventDefault(); setError(''); - if (newPassword.length < 6) { - setError('Password must be at least 6 characters.'); + if (!isStrongPassword(newPassword)) { + setError(PASSWORD_RULE); return; } if (newPassword !== confirmPassword) { diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts index b37361238..b7703d1b6 100644 --- a/apps/edr-passenger-web/portal/src/lib/api/auth.ts +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -36,6 +36,42 @@ export const iamAuthApi = { headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }, }), + // --- Staged sign-in (/login) ------------------------------------------------- + // Step 1: hand the server one field and let it say which branch follows. `identifier` + // is a phone number or an email; the server works out which. + lookupIdentifier: (identifier: string) => + axios.post<{ + success: boolean; + data: { + status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND'; + method?: 'fayda' | 'pending'; + maskedPhone?: string; + }; + }>(`${API_URL}/auth/identifier/lookup`, { identifier }), + + // Step 2a: SMS the code for an account that exists but has no password yet. + // Always resolves — the server reports { sent: true } even for an unknown identifier. + requestPasswordSetup: (identifier: string) => + axios.post(`${API_URL}/auth/password-setup/request`, { identifier }), + + // Step 2b: redeem the code and set the password. Unlike the older Fayda dance this + // returns a usable session directly, so the user lands signed in rather than back on + // the login form. Same response shape as POST /auth/login. + completePasswordSetup: (data: { + identifier: string; + otp: string; + newPassword: string; + confirmPassword: string; + }) => + axios.post<{ + success: boolean; + data: { + token: string; + refreshToken: string; + user: { id: string; iamUserId: string; email: string | null; passengerId: string }; + }; + }>(`${API_URL}/auth/password-setup/complete`, data), + faydaRequestPasswordSetup: (phoneNumber: string) => axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }), diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts index 0d6e46fd9..443fa46a8 100644 --- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts @@ -113,13 +113,10 @@ export const useAuthStore = create((set, get) => ({ login: async (email: string, password: string) => { const response: any = await apiClient.post('/auth/login', { email, password }); const { token, user } = response.data || response; - - if (typeof window !== 'undefined') { - localStorage.setItem('auth_token', token); - localStorage.setItem('auth_user', JSON.stringify(user)); - } - - set({ user, token, isAuthenticated: true }); + // `setUser` is the one place a session is persisted. The staged sign-in's + // password-setup branch establishes a session without going through /auth/login, + // so it calls the same action rather than duplicating the storage writes. + get().setUser(user, token); }, register: async (data: RegisterData): Promise => { diff --git a/apps/edr-passenger-web/portal/src/lib/password.ts b/apps/edr-passenger-web/portal/src/lib/password.ts new file mode 100644 index 000000000..42b967d00 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/password.ts @@ -0,0 +1,21 @@ +/** + * The one password rule the portal enforces. + * + * It mirrors class-validator's `@IsStrongPassword` defaults, which is what the IAM applies on + * `PATCH /v1/auth/set-password` and what `POST /auth/password-setup/complete` applies on the + * passenger API. Screens that used a looser check (`length < 6`) accepted passwords the server + * then rejected with an opaque 400, so every screen shares this instead. + */ +export function isStrongPassword(pw: string): boolean { + return ( + pw.length >= 8 && + /[a-z]/.test(pw) && + /[A-Z]/.test(pw) && + /[0-9]/.test(pw) && + /[^A-Za-z0-9]/.test(pw) + ); +} + +/** The rule stated for humans. Shown as helper text and reused as the validation message. */ +export const PASSWORD_RULE = + 'Password must be at least 8 characters and include an upper-case letter, a lower-case letter, a number and a symbol.'; diff --git a/e2e/freight/cypress/e2e/flows/onboarding-utils.ts b/e2e/freight/cypress/e2e/flows/onboarding-utils.ts index 93d193ac7..e85bc5a12 100644 --- a/e2e/freight/cypress/e2e/flows/onboarding-utils.ts +++ b/e2e/freight/cypress/e2e/flows/onboarding-utils.ts @@ -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(); +} diff --git a/e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts index e6f08cc95..99d0e8a83 100644 --- a/e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts +++ b/e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts @@ -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"); diff --git a/e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts index e6c903e89..56db7fa9c 100644 --- a/e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts +++ b/e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts @@ -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"); diff --git a/packages/types/src/freight/etrade.ts b/packages/types/src/freight/etrade.ts index c23492413..2af992b04 100644 --- a/packages/types/src/freight/etrade.ts +++ b/packages/types/src/freight/etrade.ts @@ -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;