mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 17:50:54 +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"
|
||||
>
|
||||
|
||||
@@ -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<Date | null>(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<Set<string>>(new Set());
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchLoadedIds(new Set());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(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 ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={700}>
|
||||
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"} —
|
||||
tick what was loaded
|
||||
</Text>
|
||||
{originYardId ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={originYardId}
|
||||
phase="loading"
|
||||
log={originLoadingLog}
|
||||
/>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed">
|
||||
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.
|
||||
</Text>
|
||||
<Stack gap={6} mah={220} style={{ overflowY: "auto" }}>
|
||||
{pendingOriginBoarders.map((b) => (
|
||||
<Checkbox
|
||||
key={b.id}
|
||||
size="sm"
|
||||
checked={b.isGovernment || dispatchLoadedIds.has(b.id)}
|
||||
disabled={b.isGovernment}
|
||||
onChange={(e) => {
|
||||
const next = new Set(dispatchLoadedIds);
|
||||
if (e.currentTarget.checked) next.add(b.id);
|
||||
else next.delete(b.id);
|
||||
setDispatchLoadedIds(next);
|
||||
}}
|
||||
label={
|
||||
<Text size="sm" span>
|
||||
{b.reference ?? b.id.slice(0, 8)} — {b.customer ?? "Unknown customer"}
|
||||
{b.isGovernment ? " (government — always rides)" : ""}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{dispatchLeftCount > 0 ? (
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
{dispatchLeftCount} booking{dispatchLeftCount === 1 ? "" : "s"} will
|
||||
be left behind and returned to the booking pool.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { APP_FILTER } from "@nestjs/core";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
|
||||
@@ -82,6 +83,17 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
// Named tiers only — no APP_GUARD is registered, so nothing is throttled until a
|
||||
// controller opts in with @UseGuards(ThrottlerGuard). AuthController is currently the
|
||||
// only one that does, because the staged sign-in exposes an account-existence lookup.
|
||||
ThrottlerModule.forRoot([
|
||||
// 20/min, not the 5/min the commented-out decorators suggested: the staged sign-in
|
||||
// legitimately costs 3-5 calls (lookup → request code → resend → complete → a retry
|
||||
// after a typo), and the throttler keys on IP, so users sharing a NAT or mobile CGNAT
|
||||
// address share the budget. 5 would lock real passengers out.
|
||||
{ name: "auth", limit: 20, ttl: 60_000 },
|
||||
{ name: "strict", limit: 20, ttl: 60_000 },
|
||||
]),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -97,8 +109,11 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
`Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
|
||||
[EOtpType.RESET_PASSWORD]: ({ route }) =>
|
||||
`Reset your EDR Passenger password using this link: ${route}`,
|
||||
[EOtpType.SET_PASSWORD]: ({ route }) =>
|
||||
`Set your EDR Passenger password using this link: ${route}`,
|
||||
// Carries the bare code as well as the link: the staged sign-in asks for the code
|
||||
// inline, while the link is still what a `/set-password` deep link from an older SMS
|
||||
// relies on. `OtpMessageContext` supplies both.
|
||||
[EOtpType.SET_PASSWORD]: ({ otp, route }) =>
|
||||
`Your EDR Passenger code is ${otp}. Or set your password here: ${route}`,
|
||||
},
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-freight-api, which
|
||||
|
||||
77
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal file
77
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Phone normalisation shared by any lookup that has to match a number a customer typed
|
||||
* against one already stored. Ethiopian numbers reach us in three interchangeable shapes
|
||||
* (+2519…, 2519…, 09…) depending on whether they came from IAM, a guest booking form or a
|
||||
* saved profile, so an exact-string match silently misses.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns all plausible normalised variants of a raw phone string so that the
|
||||
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
||||
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
||||
*/
|
||||
export function normalizePhoneVariants(raw: string): string[] {
|
||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||
const stripped = raw.replace(/[^\d+]/g, '');
|
||||
const digits = stripped.replace(/^\+/, '');
|
||||
if (digits.length < 7) return [];
|
||||
|
||||
const variants = new Set<string>([stripped]);
|
||||
|
||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||
variants.add(digits); // 251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||
// bare international digits without +
|
||||
variants.add('+' + digits);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
/**
|
||||
* A sign-in identifier is a single free-text field: the passenger types either an email
|
||||
* address or a phone number and the server works out which. Phone is the default reading —
|
||||
* an email must contain an `@` with something either side of it, everything else is treated
|
||||
* as a number so that malformed emails don't silently fall through to a phone lookup that
|
||||
* can never match.
|
||||
*/
|
||||
export type ResolvedIdentifier = {
|
||||
kind: 'email' | 'phone';
|
||||
/** Lower-cased email, or null when the input is a phone number. */
|
||||
email: string | null;
|
||||
/** Every stored shape the number could have, or [] when the input is an email. */
|
||||
phoneVariants: string[];
|
||||
};
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function resolveIdentifier(raw: string): ResolvedIdentifier {
|
||||
const trimmed = raw.trim();
|
||||
if (EMAIL_RE.test(trimmed)) {
|
||||
return { kind: 'email', email: trimmed.toLowerCase(), phoneVariants: [] };
|
||||
}
|
||||
return { kind: 'phone', email: null, phoneVariants: normalizePhoneVariants(trimmed) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `+251912345678` → `+2519****678`. Shown on the OTP screen so the passenger can tell which
|
||||
* number the code went to without the server handing back the full number to an unauthenticated
|
||||
* caller.
|
||||
*/
|
||||
export function maskPhone(phone: string): string {
|
||||
const stripped = phone.replace(/[^\d+]/g, '');
|
||||
if (stripped.length <= 7) return stripped;
|
||||
const head = stripped.slice(0, stripped.startsWith('+') ? 5 : 4);
|
||||
const tail = stripped.slice(-3);
|
||||
return `${head}${'*'.repeat(4)}${tail}`;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ApiBearerAuth,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||
import { PassengerAuthService } from "./passenger-auth.service";
|
||||
import {
|
||||
RegisterDto,
|
||||
@@ -28,12 +29,19 @@ import {
|
||||
ResendRegistrationCodeDto,
|
||||
FaydaRequestPasswordSetupDto,
|
||||
FaydaVerifyAndLoginDto,
|
||||
IdentifierLookupDto,
|
||||
PasswordSetupRequestDto,
|
||||
PasswordSetupCompleteDto,
|
||||
} from "./auth.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
|
||||
@ApiTags("Passenger Auth")
|
||||
@Controller("auth")
|
||||
// @Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
||||
// Scoped to this controller rather than registered as a global APP_GUARD: the staged sign-in
|
||||
// exposes an account-existence lookup, and rate limiting is the mitigation for it. Applying the
|
||||
// guard app-wide would change the behaviour of every other module at the same time.
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ auth: { limit: 20, ttl: 60_000 } })
|
||||
export class AuthController {
|
||||
constructor(private passengerAuthService: PassengerAuthService) {}
|
||||
|
||||
@@ -189,6 +197,63 @@ export class AuthController {
|
||||
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
||||
}
|
||||
|
||||
@Post("identifier/lookup")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// Tighter than the rest of the controller: this is the endpoint that answers "does this
|
||||
// account exist", so it is the one worth making expensive to sweep. Still roomy enough
|
||||
// that a passenger correcting a typo two or three times is unaffected.
|
||||
@Throttle({ auth: { limit: 10, ttl: 60_000 } })
|
||||
@ApiOperation({
|
||||
summary: "Step 1 of sign-in — decide what to ask the user for next",
|
||||
description:
|
||||
"Takes a phone number or an email and reports whether the account exists and whether it " +
|
||||
"already has a password. PASSWORD → ask for the password. NEEDS_PASSWORD_SETUP → send a " +
|
||||
"code and let them set one. NOT_FOUND → sign them up.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "{ status, method?, maskedPhone? } — never returns email or user id",
|
||||
})
|
||||
@ApiBody({ type: IdentifierLookupDto })
|
||||
lookupIdentifier(@Body() dto: IdentifierLookupDto) {
|
||||
return this.passengerAuthService.lookupIdentifier(dto.identifier);
|
||||
}
|
||||
|
||||
@Post("password-setup/request")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Send the SMS code that lets an account with no password set one",
|
||||
description:
|
||||
"Covers Fayda-created accounts and abandoned registrations alike. Always returns " +
|
||||
"{ sent: true } regardless of whether the account exists.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "{ sent: true }" })
|
||||
@ApiBody({ type: PasswordSetupRequestDto })
|
||||
requestPasswordSetup(@Body() dto: PasswordSetupRequestDto, @Request() req: any) {
|
||||
return this.passengerAuthService.requestPasswordSetup(dto.identifier, req);
|
||||
}
|
||||
|
||||
@Post("password-setup/complete")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Redeem the code, set the password, and sign in",
|
||||
description:
|
||||
"Accepts both set-password codes (from password-setup/request) and verify-phone-number " +
|
||||
"codes (from POST /auth/register), so one screen finishes both branches.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Same shape as POST /auth/login — token, refreshToken and user.",
|
||||
})
|
||||
@ApiResponse({ status: 401, description: "Invalid or expired code" })
|
||||
@ApiBody({ type: PasswordSetupCompleteDto })
|
||||
completePasswordSetup(@Body() dto: PasswordSetupCompleteDto) {
|
||||
return this.passengerAuthService.completePasswordSetup(dto);
|
||||
}
|
||||
|
||||
@Post("fayda/request-password-setup")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
IsStrongPassword,
|
||||
Length,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@@ -75,3 +82,55 @@ export class FaydaVerifyAndLoginDto {
|
||||
@IsString()
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the staged sign-in. One field: the passenger types either their phone number
|
||||
* or their email and the server decides which of the three branches follows.
|
||||
*/
|
||||
export class IdentifierLookupDto {
|
||||
@ApiProperty({
|
||||
example: '+251912345678',
|
||||
description: 'Phone number or email address — the server detects which',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Step 2a: ask for the SMS code that lets an account with no password set one. */
|
||||
export class PasswordSetupRequestDto {
|
||||
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Step 2b: redeem the code, set the password, and receive a session in one call. */
|
||||
export class PasswordSetupCompleteDto {
|
||||
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
|
||||
@ApiProperty({ example: '123456', description: '6-digit code received via SMS' })
|
||||
@IsString()
|
||||
@Length(4, 10)
|
||||
otp: string;
|
||||
|
||||
// The credential is written directly against iam.user_credentials rather than through
|
||||
// the IAM's own set-password route, so the IAM's @IsStrongPassword rule has to be
|
||||
// restated here or weak passwords would slip in unvalidated.
|
||||
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||
@IsStrongPassword({
|
||||
minLength: 8,
|
||||
minLowercase: 1,
|
||||
minUppercase: 1,
|
||||
minNumbers: 1,
|
||||
minSymbols: 1,
|
||||
})
|
||||
newPassword: string;
|
||||
|
||||
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||
@IsString()
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
@@ -13,7 +14,22 @@ import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/au
|
||||
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
import { RegisterDto, LoginDto, PasswordSetupCompleteDto } from './auth.dto';
|
||||
import { maskPhone, resolveIdentifier } from '../../common/utils/phone.utils';
|
||||
|
||||
/**
|
||||
* The `iam.users` columns every sign-in branch needs. Kept separate from `IamUserRow`
|
||||
* (which is profile-shaped) because the auth branches key off credential state, not metadata.
|
||||
*/
|
||||
type IamAuthRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: { en: string; am: string } | null;
|
||||
username: string;
|
||||
phone_number: string | null;
|
||||
has_set_password: boolean;
|
||||
verified_by: string | null;
|
||||
};
|
||||
|
||||
type IamUserRow = {
|
||||
id: string;
|
||||
@@ -170,9 +186,19 @@ export class PassengerAuthService {
|
||||
async login(dto: LoginDto, req: any) {
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
|
||||
// `dto.email` may hold an email OR a phone number, in any of the shapes a passenger might
|
||||
// type. Resolve it to the exact string the IAM stores before handing it over: the IAM
|
||||
// matches the identifier literally, so someone entering `0912…` for a number stored as
|
||||
// `+2519…` would be told their credentials are invalid despite a correct password.
|
||||
const known = await this.findUserByIdentifier(dto.email);
|
||||
const loginIdentifier = known?.email ?? known?.phone_number ?? dto.email;
|
||||
|
||||
let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean };
|
||||
try {
|
||||
iamResult = await iamAuthService.login({ email: dto.email, password: dto.password });
|
||||
iamResult = await iamAuthService.login({
|
||||
email: loginIdentifier,
|
||||
password: dto.password,
|
||||
});
|
||||
} catch {
|
||||
this.eventEmitter.emit('auth.login.failed', { email: dto.email });
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
@@ -184,14 +210,16 @@ export class PassengerAuthService {
|
||||
|
||||
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
||||
|
||||
// `dto.email` may hold an email OR a phone number (passengers without an email log in
|
||||
// with their phone). Match on either so the post-auth lookup works regardless of which
|
||||
// identifier was used.
|
||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[dto.email],
|
||||
);
|
||||
const iamUser = iamRows[0];
|
||||
// `known` is the same row the identifier resolved to; only fall back to a fresh lookup if
|
||||
// the resolve missed but the IAM authenticated anyway.
|
||||
let iamUser: { id: string; email: string | null } | null = known;
|
||||
if (!iamUser) {
|
||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[loginIdentifier],
|
||||
);
|
||||
iamUser = iamRows[0] ?? null;
|
||||
}
|
||||
if (!iamUser) {
|
||||
throw new InternalServerErrorException('IAM user not found after successful authentication');
|
||||
}
|
||||
@@ -496,19 +524,26 @@ export class PassengerAuthService {
|
||||
}
|
||||
|
||||
async resetUserPassword(id: string, tempPassword: string) {
|
||||
await this.writeActiveCredential(id, tempPassword);
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the user's active credential. The IAM keeps credential history and relies on
|
||||
* exactly one row per user having `is_active = true`, so the old row is deactivated in the
|
||||
* same call rather than deleted.
|
||||
*/
|
||||
private async writeActiveCredential(userId: string, password: string): Promise<void> {
|
||||
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const passwordHash = await hashPassword(tempPassword);
|
||||
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
|
||||
const passwordHash = await hashPassword(password);
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
|
||||
[id],
|
||||
[userId],
|
||||
);
|
||||
// Insert new active credential
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
|
||||
[id, passwordHash],
|
||||
[userId, passwordHash],
|
||||
);
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
|
||||
@@ -554,15 +589,45 @@ export class PassengerAuthService {
|
||||
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
const u = users[0];
|
||||
|
||||
await this.consumeSetupOtp(u.id, otp, 'Invalid phone number or OTP');
|
||||
|
||||
const { token, refreshToken } = await this.mintSession(
|
||||
{ ...u, verified_by: 'fayda' },
|
||||
'fayda-otp-setup',
|
||||
);
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies and burns a one-time code from `iam.user_verifications`.
|
||||
*
|
||||
* Both password-setup entry points land here: `set-password` codes come from
|
||||
* `password-setup/request`, `verify-phone-number` codes from `POST /auth/register`. Accepting
|
||||
* both is what lets a single screen finish the "existing account with no password" branch and
|
||||
* the "brand new signup" branch.
|
||||
*
|
||||
* Codes are argon2-hashed at rest, so this is a verify rather than an equality check. The
|
||||
* attempt counter is incremented *before* the comparison so a crash mid-verify still costs an
|
||||
* attempt, and the code is burned on the 6th try.
|
||||
*/
|
||||
private async consumeSetupOtp(
|
||||
userId: string,
|
||||
otp: string,
|
||||
failureMessage = 'Invalid or expired code',
|
||||
): Promise<void> {
|
||||
const verifications = await this.dataSource.query<{
|
||||
id: string; verification_code: string; attempt_count: number;
|
||||
}[]>(
|
||||
`SELECT id, verification_code, attempt_count FROM iam.user_verifications
|
||||
WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW()
|
||||
WHERE user_id = $1
|
||||
AND otp_type IN ('set-password', 'verify-phone-number')
|
||||
AND "isUsed" = false
|
||||
AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[u.id],
|
||||
[userId],
|
||||
);
|
||||
if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
if (!verifications.length) throw new UnauthorizedException(failureMessage);
|
||||
const v = verifications[0];
|
||||
|
||||
if (v.attempt_count >= 5) {
|
||||
@@ -578,12 +643,24 @@ export class PassengerAuthService {
|
||||
|
||||
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const valid = await verifyPassword(otp, v.verification_code);
|
||||
if (!valid) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
if (!valid) throw new UnauthorizedException(failureMessage);
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts (or refreshes) an `iam.sessions` row and mints the token pair for it. The JWT payload
|
||||
* is only the session id — `JwtGuard` resolves everything else from the table.
|
||||
*
|
||||
* `device` participates in a unique constraint on `(user_id, device)`, so each flow passes its
|
||||
* own value and none of them clobbers a session another flow established.
|
||||
*/
|
||||
private async mintSession(
|
||||
u: IamAuthRow,
|
||||
device: string,
|
||||
): Promise<{ token: string; refreshToken: string }> {
|
||||
const userInfo = {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
@@ -604,19 +681,183 @@ export class PassengerAuthService {
|
||||
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||
`INSERT INTO iam.sessions
|
||||
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $4)
|
||||
ON CONFLICT (user_id, device) DO UPDATE
|
||||
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[u.email ?? '', JSON.stringify(userInfo), u.id],
|
||||
[u.email ?? '', device, JSON.stringify(userInfo), u.id],
|
||||
);
|
||||
|
||||
const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token');
|
||||
const token = generateToken({ id: sessions[0].id });
|
||||
const refreshToken = generateRefreshToken({ id: sessions[0].id });
|
||||
return {
|
||||
token: generateToken({ id: sessions[0].id }),
|
||||
refreshToken: generateRefreshToken({ id: sessions[0].id }),
|
||||
};
|
||||
}
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
/**
|
||||
* Resolves the single sign-in identifier field to an `iam.users` row.
|
||||
*
|
||||
* A phone number reaches us in three interchangeable shapes (`+2519…`, `2519…`, `09…`)
|
||||
* depending on whether the account was created by IAM signup, a guest booking or Fayda, so
|
||||
* matching on one canonical form silently misses. `normalizePhoneVariants` produces every
|
||||
* shape and the query matches any of them.
|
||||
*
|
||||
* `ORDER BY has_set_password DESC` makes a fully-registered account win over a leftover
|
||||
* pending row that shares the same phone — otherwise a passenger with an abandoned signup
|
||||
* would be pushed into password setup for an account they already finished.
|
||||
*/
|
||||
private async findUserByIdentifier(identifier: string): Promise<IamAuthRow | null> {
|
||||
const resolved = resolveIdentifier(identifier);
|
||||
if (!resolved.email && resolved.phoneVariants.length === 0) return null;
|
||||
|
||||
const rows = await this.dataSource.query<IamAuthRow[]>(
|
||||
`SELECT id, email, name, username, phone_number, has_set_password, verified_by
|
||||
FROM iam.users
|
||||
WHERE ($1::text IS NOT NULL AND lower(email) = $1)
|
||||
OR phone_number = ANY($2::text[])
|
||||
ORDER BY has_set_password DESC
|
||||
LIMIT 1`,
|
||||
[resolved.email, resolved.phoneVariants],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the staged sign-in: decide which of the three branches the portal should render.
|
||||
*
|
||||
* This deliberately reports whether an account exists — the whole point of the flow is that the
|
||||
* passenger stops guessing — so it is a user-enumeration oracle by design. `POST
|
||||
* /v1/auth/forgot-password` already leaks the same fact by throwing `user_not_found`; the
|
||||
* mitigation here is the throttle on this controller, not secrecy. Nothing identifying is
|
||||
* returned: no email, no user id, and the phone only ever masked.
|
||||
*/
|
||||
async lookupIdentifier(identifier: string): Promise<{
|
||||
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
|
||||
method?: 'fayda' | 'pending';
|
||||
maskedPhone?: string;
|
||||
}> {
|
||||
const user = await this.findUserByIdentifier(identifier);
|
||||
if (!user) return { status: 'NOT_FOUND' };
|
||||
if (user.has_set_password) return { status: 'PASSWORD' };
|
||||
|
||||
return {
|
||||
status: 'NEEDS_PASSWORD_SETUP',
|
||||
method: user.verified_by === 'fayda' ? 'fayda' : 'pending',
|
||||
maskedPhone: user.phone_number ? maskPhone(user.phone_number) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the SMS code that lets an account with no password set one. Covers both Fayda-created
|
||||
* accounts and abandoned registrations — the distinction only changes the copy the portal
|
||||
* shows, not what happens here.
|
||||
*
|
||||
* Always resolves `{ sent: true }`. Returning a real result would make this a cheaper
|
||||
* enumeration oracle than `lookupIdentifier`, which at least sits behind the same throttle.
|
||||
*/
|
||||
async requestPasswordSetup(identifier: string, req: any): Promise<{ sent: boolean }> {
|
||||
const user = await this.findUserByIdentifier(identifier);
|
||||
if (!user || user.has_set_password) return { sent: true };
|
||||
|
||||
if (!user.phone_number) {
|
||||
// OTP delivery is SMS + in-app only; there is no email channel. Every account-creation
|
||||
// path requires a phone, so this should be unreachable — log it rather than fail silently.
|
||||
this.logger.warn(
|
||||
`requestPasswordSetup: user ${user.id} has no phone number — no channel to send a code on`,
|
||||
);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
try {
|
||||
await iamAuthService.generateVerificationCode({
|
||||
// Both fields must match the stored row exactly: the IAM looks the user up with
|
||||
// `where: { phoneNumber, email }`, which is AND, not OR. Passing the values we just
|
||||
// read back guarantees the match — including a null email, which TypeORM renders as
|
||||
// `IS NULL` and which coercing to '' would break.
|
||||
email: user.email as string,
|
||||
phoneNumber: user.phone_number,
|
||||
type: EOtpType.SET_PASSWORD,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[PassengerAuthService] password setup code failed for user ${user.id}`,
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems the code, writes the password, and returns a session — the passenger lands signed in
|
||||
* rather than being bounced back to the login form.
|
||||
*
|
||||
* Returns the same shape as `login()` so the portal can store the result through one code path.
|
||||
*/
|
||||
async completePasswordSetup(dto: PasswordSetupCompleteDto): Promise<{
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
|
||||
}> {
|
||||
if (dto.newPassword !== dto.confirmPassword) {
|
||||
throw new BadRequestException('Passwords do not match');
|
||||
}
|
||||
|
||||
const user = await this.findUserByIdentifier(dto.identifier);
|
||||
// Same message whether the account is missing or the code is wrong: the branch was already
|
||||
// disclosed by `lookupIdentifier`, but there is no reason to re-confirm it on every attempt.
|
||||
if (!user) throw new UnauthorizedException('Invalid or expired code');
|
||||
if (user.has_set_password) {
|
||||
throw new BadRequestException(
|
||||
'This account already has a password. Sign in with it instead.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.consumeSetupOtp(user.id, dto.otp);
|
||||
await this.writeActiveCredential(user.id, dto.newPassword);
|
||||
|
||||
// Redeeming the code proves ownership of the phone, which is what promotes a Fayda-created
|
||||
// `submitted` row or a pending signup to a usable account.
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.users
|
||||
SET has_set_password = true,
|
||||
status = 'accepted',
|
||||
is_active = true,
|
||||
is_phone_number_verified = true,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[user.id],
|
||||
);
|
||||
|
||||
const { token, refreshToken } = await this.mintSession(
|
||||
{ ...user, has_set_password: true },
|
||||
'password-setup',
|
||||
);
|
||||
|
||||
let passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!passenger) {
|
||||
const result = await this.provisionPassengerSatellite({
|
||||
iamUserId: user.id,
|
||||
auditAction: 'USER_AUTO_PROVISIONED',
|
||||
});
|
||||
passenger = { id: result.passengerId };
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
iamUserId: user.id,
|
||||
email: user.email,
|
||||
passengerId: passenger.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private standardizePhone(phone: string): string {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { JourneyDirection } from '../seats/seats.dto';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
@@ -46,39 +47,6 @@ function resolvePackageRoundTripTotal(
|
||||
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all plausible normalised variants of a raw phone string so that the
|
||||
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
||||
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
||||
*/
|
||||
function normalizePhoneVariants(raw: string): string[] {
|
||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||
const stripped = raw.replace(/[^\d+]/g, '');
|
||||
const digits = stripped.replace(/^\+/, '');
|
||||
if (digits.length < 7) return [];
|
||||
|
||||
const variants = new Set<string>([stripped]);
|
||||
|
||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||
variants.add(digits); // 251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||
// bare international digits without +
|
||||
variants.add('+' + digits);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
|
||||
@@ -1,50 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
import { useState, useRef, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Train, ShieldCheck, Eye, EyeOff } from 'lucide-react';
|
||||
import { Train, Eye, EyeOff, Pencil } from 'lucide-react';
|
||||
|
||||
const loginSchema = z.object({
|
||||
// Accepts either an email or a phone number. Passengers who registered without an
|
||||
// email sign in with their phone number, which is sent in the same `email` field —
|
||||
// the IAM matches on either identifier.
|
||||
email: z.string().min(1, 'Phone or email is required'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
});
|
||||
/**
|
||||
* Staged sign-in.
|
||||
*
|
||||
* The passenger gives one identifier — phone or email — and the server decides which of three
|
||||
* things happens next. Previously this page asked for identifier *and* password up front and
|
||||
* offered three competing links underneath ("Create account", "Already verified with Fayda?",
|
||||
* "Forgot password?"), which made the user guess something only the server knows: whether their
|
||||
* number has an account, and whether that account has a password yet. Guessing wrong dead-ended.
|
||||
*
|
||||
* Now exactly one branch is ever on screen.
|
||||
*/
|
||||
type Step = 'identifier' | 'password' | 'setup' | 'signup';
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Loose enough to accept every shape a passenger might type (`+2519…`, `2519…`, `09…`) and the
|
||||
* occasional foreign number, strict enough that free text never reaches the signup branch — an
|
||||
* identifier that is neither an email nor a number would otherwise be stored as a phone the SMS
|
||||
* code can never reach. Mirrors the 7-digit floor in the API's `normalizePhoneVariants`.
|
||||
*/
|
||||
const looksLikePhone = (v: string) => v.replace(/[^\d]/g, '').length >= 7;
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const registerUser = useAuthStore((s) => s.register);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
|
||||
const [step, setStep] = useState<Step>('identifier');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema as any),
|
||||
});
|
||||
// Step 1
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const identifierRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
// Step 2 — sign in
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Step 3 — set a password (existing account with none, or a fresh signup)
|
||||
const [maskedPhone, setMaskedPhone] = useState('');
|
||||
const [setupMethod, setSetupMethod] = useState<'fayda' | 'pending' | 'new'>('pending');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [resendNote, setResendNote] = useState('');
|
||||
|
||||
// Step 4 — signup
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [secondaryContact, setSecondaryContact] = useState('');
|
||||
|
||||
const identifierIsEmail = EMAIL_RE.test(identifier.trim());
|
||||
|
||||
const finish = () => {
|
||||
const redirect = searchParams.get('redirect') || '/booking/search';
|
||||
router.push(redirect);
|
||||
};
|
||||
|
||||
const goBackToIdentifier = () => {
|
||||
setStep('identifier');
|
||||
setError('');
|
||||
setPassword('');
|
||||
setOtp('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setResendNote('');
|
||||
// Keep what they typed — they are usually fixing a typo, not starting over — but select
|
||||
// it, so typing replaces the value instead of appending to it. Without this, clicking
|
||||
// into a controlled input that still holds the old identifier silently concatenates.
|
||||
setTimeout(() => identifierRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const apiMessage = (err: any, fallback: string) =>
|
||||
err?.response?.data?.message || fallback;
|
||||
|
||||
// --- Step 1: who are you? ---------------------------------------------------
|
||||
const submitIdentifier = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = identifier.trim();
|
||||
if (!value) {
|
||||
setError('Enter your phone number or email');
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_RE.test(value) && !looksLikePhone(value)) {
|
||||
setError('Enter a valid phone number or email address');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login(data.email, data.password);
|
||||
const redirect = searchParams.get('redirect') || '/booking/search';
|
||||
router.push(redirect);
|
||||
const res = await iamAuthApi.lookupIdentifier(identifier.trim());
|
||||
const result = res.data.data;
|
||||
|
||||
if (result.status === 'PASSWORD') {
|
||||
setStep('password');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'NEEDS_PASSWORD_SETUP') {
|
||||
setMaskedPhone(result.maskedPhone || '');
|
||||
setSetupMethod(result.method || 'pending');
|
||||
// Fire the code now so the next screen is already actionable. It resolves even for
|
||||
// an unknown identifier, so a failure here is a transport problem, not a verdict.
|
||||
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||
setStep('setup');
|
||||
return;
|
||||
}
|
||||
setStep('signup');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Login failed. Please check your credentials.');
|
||||
setError(apiMessage(err, 'Something went wrong. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 2: existing account, has a password -------------------------------
|
||||
const submitPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) {
|
||||
setError('Enter your password');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login(identifier.trim(), password);
|
||||
finish();
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'Incorrect password. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 3: set a password with the SMS code -------------------------------
|
||||
const submitSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otp.trim()) {
|
||||
setError('Enter the code we sent you');
|
||||
return;
|
||||
}
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await iamAuthApi.completePasswordSetup({
|
||||
identifier: identifier.trim(),
|
||||
otp: otp.trim(),
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
const { token, user } = res.data.data;
|
||||
// The response carries a real session, so the user lands signed in instead of being
|
||||
// sent back to the form. `setUser` is the same action `login()` persists through.
|
||||
setUser(user as any, token);
|
||||
finish();
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'That code is not valid. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resend = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResendNote('');
|
||||
try {
|
||||
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||
setResendNote('We sent a new code.');
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'Could not send a new code. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 4: no account yet --------------------------------------------------
|
||||
const submitSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = fullName.trim();
|
||||
const other = secondaryContact.trim();
|
||||
if (name.length < 2) {
|
||||
setError('Enter your full name');
|
||||
return;
|
||||
}
|
||||
// A phone is always required — the verification code is sent by SMS and there is no
|
||||
// email channel for it. An email is optional.
|
||||
if (identifierIsEmail) {
|
||||
if (!other) {
|
||||
setError('Enter your phone number');
|
||||
return;
|
||||
}
|
||||
if (!looksLikePhone(other)) {
|
||||
setError('Enter a valid phone number — your verification code is sent by SMS');
|
||||
return;
|
||||
}
|
||||
} else if (other && !EMAIL_RE.test(other)) {
|
||||
// Only validate the shape when they actually typed something.
|
||||
setError('Enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
const phone = identifierIsEmail ? other : identifier.trim();
|
||||
// The IAM requires a non-empty account identifier in its `email` field but never checks
|
||||
// that it is email-shaped, so a passenger with no email address signs up under their phone
|
||||
// number — the same fallback `/register` uses. Both then match on either identifier.
|
||||
const email = identifierIsEmail ? identifier.trim() : other || phone;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await registerUser({ fullName: name, email, phone });
|
||||
setMaskedPhone(phone);
|
||||
setSetupMethod('new');
|
||||
setStep('setup');
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 409) {
|
||||
setError('An account with this email or phone number already exists. Go back and sign in.');
|
||||
} else {
|
||||
setError(apiMessage(err, 'Could not create your account. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The identifier, shown on every step after the first, with one way back to change it.
|
||||
*
|
||||
* It is a real `autocomplete="username"` input rather than a `<span>`, and it is rendered
|
||||
* *inside* each form. That is what makes password managers behave: a password field sitting
|
||||
* alone in a form gives Chrome nothing to match a saved credential against, so it fills
|
||||
* whichever password it holds for the origin — a password belonging to some other account.
|
||||
* Pairing it with the username lets the manager fill the right credential, or none at all.
|
||||
*/
|
||||
const identifierChip = (
|
||||
<div className="flex items-center justify-between gap-3 mb-4 px-3 py-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
autoComplete="username"
|
||||
aria-label="Signing in as"
|
||||
onFocus={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBackToIdentifier}
|
||||
className="flex items-center gap-1 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline shrink-0"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
@@ -54,86 +299,227 @@ function LoginContent() {
|
||||
<Train className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{heading.title}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">{heading.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone or email</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('email')}
|
||||
className="input-field"
|
||||
placeholder="+251912345678 or your@email.com"
|
||||
autoComplete="username"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
||||
<div className="relative">
|
||||
{step === 'identifier' && (
|
||||
<form onSubmit={submitIdentifier} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Phone number or email
|
||||
</label>
|
||||
<input
|
||||
ref={identifierRef}
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="+251912345678 or your@email.com"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Checking...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'password' && (
|
||||
<form onSubmit={submitPassword} className="space-y-4">
|
||||
{identifierChip}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
className="input-field pr-10"
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'setup' && (
|
||||
<form onSubmit={submitSetup} className="space-y-4">
|
||||
{identifierChip}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{setupBlurb}{' '}
|
||||
{maskedPhone
|
||||
? <>We sent a code to <span className="font-medium">{maskedPhone}</span>.</>
|
||||
: 'We sent a code to your registered phone.'}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Verification code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => { 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
New password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||
className="input-field pr-10"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{PASSWORD_RULE}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{...register('password')}
|
||||
className="input-field pr-10"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
||||
)}
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Setting password...' : 'Set password and sign in'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
{resendNote ? (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{resendNote}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={loading}
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Didn't get a code? Send it again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
{step === 'signup' && (
|
||||
<form onSubmit={submitSignup} className="space-y-4">
|
||||
{identifierChip}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
We couldn't find an account for that {identifierIsEmail ? 'email' : 'number'}, so
|
||||
let's create one.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="text-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Don't have an account? </span>
|
||||
<Link href="/register" className="text-sm font-medium text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline">
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/fayda-setup"
|
||||
className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Already verified with Fayda? Set up your password
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={(e) => { setFullName(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="e.g. Abebe Kebede"
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
{identifierIsEmail ? 'Phone number' : 'Email address (optional)'}
|
||||
</label>
|
||||
<input
|
||||
type={identifierIsEmail ? 'tel' : 'email'}
|
||||
value={secondaryContact}
|
||||
onChange={(e) => { setSecondaryContact(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder={identifierIsEmail ? '+251912345678' : 'your@email.com'}
|
||||
autoComplete={identifierIsEmail ? 'tel' : 'email'}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{identifierIsEmail
|
||||
? "We'll text your verification code to this number."
|
||||
: "For receipts and booking confirmations. Your verification code is sent by SMS either way."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center">
|
||||
<button
|
||||
onClick={() => router.push('/booking/search')}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
@@ -24,8 +25,8 @@ function ResetPasswordContent() {
|
||||
const handleSubmit = 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) {
|
||||
|
||||
@@ -6,18 +6,8 @@ import Link from 'next/link';
|
||||
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { isStrongPassword } from '@/lib/password';
|
||||
|
||||
// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults):
|
||||
// min length 8, with lower- and upper-case letters, a number, and a symbol.
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
function VerifyAccountContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -192,18 +192,12 @@ export default function AppSidebar() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-1 pt-1">
|
||||
<div className="px-1 pt-1">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="block text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Register
|
||||
Sign in or register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -113,13 +113,10 @@ export const useAuthStore = create<AuthState>((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<RegisterResult> => {
|
||||
|
||||
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
@@ -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.';
|
||||
@@ -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