mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1332 from Tria-plc/freight/feat/foreign-investors
Freight/feat/foreign investors
This commit is contained in:
@@ -288,10 +288,25 @@ export class CompaniesController {
|
||||
dto.roles,
|
||||
dto.nationality,
|
||||
dto.cooperative,
|
||||
dto.investorLicence,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Post("onboarding/revert-to-etrade")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade",
|
||||
})
|
||||
async revertToRegularCompany(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.revertToRegularCompany(user.id);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Post("company-profile")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import {
|
||||
CompanyNationality,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "./entities/company.entity";
|
||||
import {
|
||||
ProfileStatus,
|
||||
ProfileType,
|
||||
} from "./entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* A foreign company on an Investment Commission licence has no eTrade record,
|
||||
* so it types its registration — and the flag saying so is what makes the
|
||||
* backoffice treat those fields as unverified. Two things must hold: only a
|
||||
* foreign company can carry it, and dropping it must not leave the typed
|
||||
* registration behind looking like eTrade's.
|
||||
*
|
||||
* The dropping half is shared with the co-operative route, which has the same
|
||||
* "eTrade holds nothing" shape, so it is exercised here for both.
|
||||
*/
|
||||
function makeService(company: Record<string, unknown> | null) {
|
||||
const companiesRepo = {
|
||||
findById: jest.fn(async () => company),
|
||||
update: jest.fn(async () => null),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "company-1",
|
||||
...row,
|
||||
})),
|
||||
existsByTin: jest.fn(async () => false),
|
||||
};
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async (): Promise<Record<string, unknown>[]> => []),
|
||||
updateStatus: jest.fn(async () => null),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cp-1",
|
||||
...row,
|
||||
})),
|
||||
softDelete: jest.fn(async () => undefined),
|
||||
};
|
||||
const profilesRepo = {
|
||||
findByUserId: jest.fn(async () =>
|
||||
company
|
||||
? { id: "external-1", companyId: "company-1", company: { id: "company-1" } }
|
||||
: null,
|
||||
),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "external-1",
|
||||
...row,
|
||||
})),
|
||||
update: jest.fn(async () => null),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
|
||||
);
|
||||
|
||||
return { service, companiesRepo, companyProfilesRepo, profilesRepo };
|
||||
}
|
||||
|
||||
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
|
||||
|
||||
const start = (
|
||||
service: CompaniesService,
|
||||
nationality: CompanyNationality | undefined,
|
||||
cooperative: boolean,
|
||||
investorLicence: boolean,
|
||||
) =>
|
||||
service.startOnboarding(
|
||||
identity as never,
|
||||
CompanyType.Customer,
|
||||
[ProfileType.importer],
|
||||
nationality,
|
||||
cooperative,
|
||||
investorLicence,
|
||||
);
|
||||
|
||||
describe("the foreign investment-licence route", () => {
|
||||
it("refuses the flag for an Ethiopian company", async () => {
|
||||
const { service } = makeService(null);
|
||||
await expect(
|
||||
start(service, CompanyNationality.Ethiopian, false, true),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses the flag alongside the co-operative one", async () => {
|
||||
const { service } = makeService(null);
|
||||
await expect(
|
||||
start(service, CompanyNationality.Foreign, true, true),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("stores the flag on a new foreign draft", async () => {
|
||||
const { service, companiesRepo } = makeService(null);
|
||||
await start(service, CompanyNationality.Foreign, false, true);
|
||||
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: { investorLicence: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the typed registration and reopens onboarding when switching back to eTrade", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
id: "company-1",
|
||||
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
|
||||
});
|
||||
|
||||
await service.revertToRegularCompany("user-1");
|
||||
|
||||
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(updates.attributes).toEqual({});
|
||||
expect(updates.status).toBe(CompanyStatus.Pending);
|
||||
// The wizard treats a populated registration as a passed lookup, so leaving
|
||||
// any of it behind would walk the customer straight past the eTrade step.
|
||||
expect(updates.licenceNumber).toBeNull();
|
||||
expect(updates.region).toBeNull();
|
||||
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
|
||||
onboardingCompleted: false,
|
||||
onboardingStep: "company",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the typed registration when the box is un-ticked on the way back", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
id: "company-1",
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
|
||||
region: "Addis Ababa",
|
||||
licenceNumber: "TYPED-1",
|
||||
});
|
||||
|
||||
await start(service, CompanyNationality.Foreign, false, false);
|
||||
|
||||
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
// The wizard sends both flags; the typed manager does not survive.
|
||||
expect(updates.attributes).toEqual({
|
||||
cooperative: false,
|
||||
investorLicence: false,
|
||||
});
|
||||
expect(updates.licenceNumber).toBeNull();
|
||||
expect(updates.region).toBeNull();
|
||||
// Resume must land back on the company step, or the customer never reaches
|
||||
// the eTrade lookup they just opted back into.
|
||||
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
|
||||
onboardingStep: "company",
|
||||
});
|
||||
});
|
||||
|
||||
it("does the same for a co-operative that stops being one", async () => {
|
||||
const { service, companiesRepo } = makeService({
|
||||
id: "company-1",
|
||||
nationality: CompanyNationality.Ethiopian,
|
||||
attributes: { cooperative: true },
|
||||
region: "Oromia",
|
||||
});
|
||||
|
||||
await start(service, CompanyNationality.Ethiopian, false, false);
|
||||
|
||||
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(updates.region).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the registration alone while the flag stays on", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
id: "company-1",
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: { investorLicence: true },
|
||||
region: "Addis Ababa",
|
||||
});
|
||||
|
||||
await start(service, CompanyNationality.Foreign, false, true);
|
||||
|
||||
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(updates).not.toHaveProperty("region");
|
||||
expect(profilesRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to switch a company that never took a manual-registration route", async () => {
|
||||
const { service } = makeService({ id: "company-1", attributes: {} });
|
||||
await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The switch belongs to both manual-registration routes, not just this one. A
|
||||
* co-operative that has since taken out a trade licence had no way back at
|
||||
* all: the wizard is where the flag is chosen, and an onboarded company can no
|
||||
* longer reach it.
|
||||
*/
|
||||
it("switches a co-operative back to eTrade on the same terms", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
id: "company-1",
|
||||
nationality: CompanyNationality.Ethiopian,
|
||||
attributes: { cooperative: true, etradeManagerName: "Typed Name" },
|
||||
region: "Oromia",
|
||||
licenceNumber: "TYPED-1",
|
||||
});
|
||||
|
||||
await service.revertToRegularCompany("user-1");
|
||||
|
||||
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(updates.attributes).toEqual({});
|
||||
expect(updates.status).toBe(CompanyStatus.Pending);
|
||||
expect(updates.licenceNumber).toBeNull();
|
||||
expect(updates.region).toBeNull();
|
||||
// A co-op owes no per-role business licence; once it stops being one it
|
||||
// does, so the application has to be re-opened and re-reviewed.
|
||||
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
|
||||
onboardingCompleted: false,
|
||||
onboardingStep: "company",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Approval of a co-op's role was granted without a business licence, because
|
||||
* a co-op owes none. Leaving makes one due, so the approval no longer stands
|
||||
* for what it said.
|
||||
*/
|
||||
it("sends a co-operative's approved roles back for approval", async () => {
|
||||
const { service, companyProfilesRepo } = makeService({
|
||||
id: "company-1",
|
||||
attributes: { cooperative: true },
|
||||
});
|
||||
companyProfilesRepo.findByCompanyId.mockResolvedValue([
|
||||
{ id: "role-active", status: ProfileStatus.Active },
|
||||
{ id: "role-blocked", status: ProfileStatus.Blacklisted },
|
||||
{ id: "role-pending", status: ProfileStatus.Pending },
|
||||
]);
|
||||
|
||||
await service.revertToRegularCompany("user-1");
|
||||
|
||||
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledWith(
|
||||
"role-active",
|
||||
ProfileStatus.Pending,
|
||||
);
|
||||
// A staff decision is not the customer's to undo by switching registration:
|
||||
// promoting a blocked role to "awaiting approval" would launder the block.
|
||||
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves an investor's roles alone — their licences were always due", async () => {
|
||||
const { service, companyProfilesRepo } = makeService({
|
||||
id: "company-1",
|
||||
attributes: { investorLicence: true },
|
||||
});
|
||||
companyProfilesRepo.findByCompanyId.mockResolvedValue([
|
||||
{ id: "role-active", status: ProfileStatus.Active },
|
||||
]);
|
||||
|
||||
await service.revertToRegularCompany("user-1");
|
||||
|
||||
expect(companyProfilesRepo.updateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,10 @@ import {
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
COOPERATIVE_KEY,
|
||||
INVESTOR_LICENCE_KEY,
|
||||
hasInvestorLicence,
|
||||
isCooperative,
|
||||
usesManualRegistration,
|
||||
} from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
@@ -378,6 +381,7 @@ export class CompaniesService {
|
||||
roles: ProfileType[],
|
||||
nationality?: CompanyNationality,
|
||||
cooperative?: boolean,
|
||||
investorLicence?: boolean,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
// Already started — reuse the existing draft, just ensure roles exist and
|
||||
// keep the nationality up to date if it was (re)selected.
|
||||
@@ -388,13 +392,20 @@ export class CompaniesService {
|
||||
// flag into `attributes`, or to read a stored one the caller didn't send.
|
||||
const needsCompany =
|
||||
cooperative !== undefined ||
|
||||
investorLicence !== undefined ||
|
||||
roles.includes(ProfileType.freightForwarder);
|
||||
const current = needsCompany
|
||||
? await this.companiesRepo.findById(companyId)
|
||||
: null;
|
||||
const isCoop = cooperative ?? isCooperative(current);
|
||||
const isInvestor = investorLicence ?? hasInvestorLicence(current);
|
||||
this.assertRolesAllowedForCooperative(isCoop, roles);
|
||||
this.assertNationalityAllowedForCooperative(isCoop, nationality);
|
||||
this.assertInvestorLicenceAllowed(
|
||||
isInvestor,
|
||||
isCoop,
|
||||
nationality ?? current?.nationality ?? undefined,
|
||||
);
|
||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||
const updates: Partial<Company> = {};
|
||||
if (nationality) updates.nationality = nationality;
|
||||
@@ -402,20 +413,49 @@ export class CompaniesService {
|
||||
// stored nationality too, or the company keeps resolving to the foreign
|
||||
// document set.
|
||||
if (isCoop) updates.nationality = CompanyNationality.Ethiopian;
|
||||
if (cooperative !== undefined) {
|
||||
if (cooperative !== undefined || investorLicence !== undefined) {
|
||||
updates.attributes = {
|
||||
...(current?.attributes ?? {}),
|
||||
[COOPERATIVE_KEY]: cooperative,
|
||||
...(cooperative !== undefined
|
||||
? { [COOPERATIVE_KEY]: cooperative }
|
||||
: {}),
|
||||
...(investorLicence !== undefined
|
||||
? { [INVESTOR_LICENCE_KEY]: investorLicence }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
// Going back and un-ticking the box is the same act as the settings
|
||||
// switch, so it has to cost the same: the registration the customer typed
|
||||
// goes, and onboarding drops back to the company step. Without this the
|
||||
// draft keeps the typed values, `hasRegistrationDetails` reads as a passed
|
||||
// lookup, resume lands past the company step entirely — and the company
|
||||
// finishes onboarding on unverified data with no flag left to say so.
|
||||
const backToEtrade =
|
||||
usesManualRegistration(current) && !isCoop && !isInvestor;
|
||||
if (backToEtrade) {
|
||||
Object.assign(updates, CompaniesService.CLEARED_REGISTRATION);
|
||||
updates.attributes = this.withoutTypedEtradeManager(
|
||||
updates.attributes ?? current?.attributes,
|
||||
);
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.companiesRepo.update(companyId, updates);
|
||||
}
|
||||
if (backToEtrade) {
|
||||
await this.profilesRepo.update(existing.id, {
|
||||
onboardingStep: "company",
|
||||
});
|
||||
}
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
this.assertRolesAllowedForCooperative(cooperative === true, roles);
|
||||
this.assertNationalityAllowedForCooperative(cooperative === true, nationality);
|
||||
this.assertInvestorLicenceAllowed(
|
||||
investorLicence === true,
|
||||
cooperative === true,
|
||||
nationality,
|
||||
);
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
|
||||
@@ -428,7 +468,14 @@ export class CompaniesService {
|
||||
country: "Ethiopia",
|
||||
nationality: nationality ?? CompanyNationality.Ethiopian,
|
||||
status: CompanyStatus.Pending,
|
||||
...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}),
|
||||
...(cooperative || investorLicence
|
||||
? {
|
||||
attributes: {
|
||||
...(cooperative ? { [COOPERATIVE_KEY]: true } : {}),
|
||||
...(investorLicence ? { [INVESTOR_LICENCE_KEY]: true } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
await this.profilesRepo.create({
|
||||
@@ -485,6 +532,73 @@ export class CompaniesService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration block as it must look when nobody has verified it.
|
||||
*
|
||||
* Used wherever a company stops being one eTrade cannot answer for: whatever
|
||||
* sits in these columns was the customer's own statement, and the wizard
|
||||
* treats a populated registration as a lookup that already passed
|
||||
* (`hasRegistrationDetails`). Leaving it behind would hand the company an
|
||||
* eTrade-verified record eTrade never supplied — and, once the flag is gone,
|
||||
* a backoffice screen that says so.
|
||||
*/
|
||||
private static readonly CLEARED_REGISTRATION: Partial<Company> = {
|
||||
licenceNumber: null,
|
||||
statusDescription: null,
|
||||
dateRegistered: null,
|
||||
renewedFrom: null,
|
||||
renewalDate: null,
|
||||
renewedTo: null,
|
||||
region: null,
|
||||
zone: null,
|
||||
woreda: null,
|
||||
kebele: null,
|
||||
houseNo: null,
|
||||
etradePhone: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* The company's own `attributes`, minus the manager captured alongside a
|
||||
* typed registration. It never came from a licence, so it must not outlive
|
||||
* the registration it belonged to.
|
||||
*/
|
||||
private withoutTypedEtradeManager(
|
||||
attributes: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...(attributes ?? {}) };
|
||||
delete next.etradeManagerName;
|
||||
delete next.etradeManagerPhone;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* An investment licence belongs to a foreign company and to nothing else.
|
||||
*
|
||||
* It is the Ethiopian Investment Commission's licence, issued to a foreign
|
||||
* investor — an Ethiopian company registers with the trade registry, which is
|
||||
* exactly the eTrade record this flag says does not exist. A co-operative
|
||||
* cannot hold one either: it is Ethiopian by construction, and the two flags
|
||||
* resolve to different document sets, so a company carrying both would owe an
|
||||
* incoherent list of papers.
|
||||
*/
|
||||
private assertInvestorLicenceAllowed(
|
||||
investorLicence: boolean,
|
||||
cooperative: boolean,
|
||||
nationality: CompanyNationality | undefined,
|
||||
): void {
|
||||
if (!investorLicence) return;
|
||||
if (cooperative) {
|
||||
throw new BadRequestException(
|
||||
"A co-operative union or farm is registered in Ethiopia — it cannot also onboard on a foreign investment licence.",
|
||||
);
|
||||
}
|
||||
if (nationality !== CompanyNationality.Foreign) {
|
||||
throw new BadRequestException(
|
||||
"Only a foreign company can onboard on an investment licence.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the company's operational profiles with the roles the user has
|
||||
* selected: create the missing ones, drop the ones they deselected.
|
||||
@@ -2143,6 +2257,7 @@ export class CompaniesService {
|
||||
// or farm holds no business licence, so it owes its own list rather than the
|
||||
// nationality list plus extras.
|
||||
const cooperative = isCooperative(company);
|
||||
const investorLicence = hasInvestorLicence(company);
|
||||
const documentSettingCode = this.documentSettingCodeFor(company);
|
||||
const [setting, uploadedFiles] = await Promise.all([
|
||||
this.fileUploadSettingsService
|
||||
@@ -2292,6 +2407,7 @@ export class CompaniesService {
|
||||
documentSettingCode,
|
||||
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||
cooperative,
|
||||
investorLicence,
|
||||
companyInfo: {
|
||||
complete: missingInfo.length === 0,
|
||||
missingFields: missingInfo,
|
||||
@@ -2369,6 +2485,97 @@ export class CompaniesService {
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop whichever manual-registration route the company is on and send it back
|
||||
* through the normal eTrade one.
|
||||
*
|
||||
* Both routes exist for the same reason — eTrade holds no record to fetch —
|
||||
* so leaving one is the same act whichever it is, and it is the only way back
|
||||
* to eTrade for either. A co-operative union or farm that has since taken out
|
||||
* a trade licence had no exit at all before this; its only route was the
|
||||
* wizard, which an onboarded company can no longer reach.
|
||||
*
|
||||
* Everything the flag let the customer type is cleared, not kept: the
|
||||
* registration block on file was their own statement, and leaving it there
|
||||
* would let the wizard treat the company as already looked-up
|
||||
* (`hasRegistrationDetails` is what stands in for a verified TIN on a
|
||||
* resume) and walk straight past the eTrade step this switch exists to
|
||||
* reach. Onboarding reopens at the company step and the company goes back to
|
||||
* pending — an approval granted against typed data cannot silently carry over
|
||||
* to a record that now claims to be eTrade's.
|
||||
*
|
||||
* Switching the other way — INTO a co-operative or an investment licence — is
|
||||
* deliberately not here. It is the wizard's nationality/role step, which this
|
||||
* reopens, and which is the one place the mutually-exclusive rules live
|
||||
* (`assertInvestorLicenceAllowed`, `assertRolesAllowedForCooperative`,
|
||||
* `assertNationalityAllowedForCooperative`). A second entry point would have
|
||||
* to restate all three.
|
||||
*/
|
||||
async revertToRegularCompany(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.companiesRepo.findById(companyId);
|
||||
if (!company)
|
||||
throw new NotFoundException(`Company ${companyId} not found`);
|
||||
if (!usesManualRegistration(company)) {
|
||||
throw new BadRequestException(
|
||||
"This company is already registered through eTrade — there is nothing to switch.",
|
||||
);
|
||||
}
|
||||
|
||||
const wasCooperative = isCooperative(company);
|
||||
|
||||
// Both flags go, not just the one that was set: they are mutually exclusive
|
||||
// and a company can only ever hold one, but the destination is "neither",
|
||||
// so stripping only the one we happened to check for would leave the other
|
||||
// behind if the pair ever did coexist.
|
||||
const attributes = this.withoutTypedEtradeManager(company.attributes);
|
||||
delete attributes[INVESTOR_LICENCE_KEY];
|
||||
delete attributes[COOPERATIVE_KEY];
|
||||
|
||||
await this.companiesRepo.update(companyId, {
|
||||
...CompaniesService.CLEARED_REGISTRATION,
|
||||
attributes,
|
||||
status: CompanyStatus.Pending,
|
||||
});
|
||||
await this.profilesRepo.update(profile.id, {
|
||||
onboardingCompleted: false,
|
||||
onboardingStep: "company",
|
||||
});
|
||||
|
||||
// A co-operative owes no per-role business licence — that is the whole
|
||||
// reason its own document set stands in for one. The moment it stops being
|
||||
// one, every role owes a licence that was never uploaded, so an approval
|
||||
// granted without one no longer means what it said: back to Pending, and
|
||||
// the reviewer sees the licence with the rest of the re-application.
|
||||
//
|
||||
// Only Active roles move. Rejected, Suspended and Blacklisted are the
|
||||
// backoffice's own decisions, and quietly promoting a blocked role to
|
||||
// "awaiting approval" would launder the block away. The reference survives
|
||||
// either way — it is minted once (`setCompanyProfileStatus`) and re-approval
|
||||
// reuses it, so bookings that cite it keep citing the same number.
|
||||
//
|
||||
// An investor is untouched: it always held a licence per role, so nothing
|
||||
// becomes due that was not already reviewed.
|
||||
if (wasCooperative) {
|
||||
const roles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
for (const role of roles) {
|
||||
if (role.status !== ProfileStatus.Active) continue;
|
||||
await this.companyProfilesRepo.updateStatus(
|
||||
role.id,
|
||||
ProfileStatus.Pending,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a self-service action when the company account isn't active, naming
|
||||
* the actual status — a suspended customer told "awaiting approval" has no
|
||||
@@ -3535,7 +3742,7 @@ export class CompaniesService {
|
||||
// the registered address themselves, and what they send IS the data. The
|
||||
// check is skipped rather than failed: running the lookup would 400 every
|
||||
// save with "no registration found for this TIN".
|
||||
if (isCooperative(company)) return;
|
||||
if (usesManualRegistration(company)) return;
|
||||
|
||||
const touched = ETRADE_SOURCED_FIELDS.some(
|
||||
(key) => key !== "tin" && dto[key] !== undefined,
|
||||
|
||||
@@ -78,6 +78,14 @@ export class OnboardingRequirementsResponseDto {
|
||||
*/
|
||||
cooperative: boolean;
|
||||
|
||||
/**
|
||||
* The company is a foreign investor on an investment licence: no eTrade
|
||||
* record, so the registration was typed. The nationality document set still
|
||||
* applies (it already asks for the investment licence itself), and so does
|
||||
* the per-role business licence.
|
||||
*/
|
||||
investorLicence: boolean;
|
||||
|
||||
/** Required company-information fields and whether each is filled. */
|
||||
companyInfo: {
|
||||
complete: boolean;
|
||||
@@ -116,6 +124,7 @@ export class OnboardingRequirementsResponseDto {
|
||||
this.documentSettingCode = init.documentSettingCode;
|
||||
this.nationality = init.nationality;
|
||||
this.cooperative = init.cooperative;
|
||||
this.investorLicence = init.investorLicence;
|
||||
this.companyInfo = init.companyInfo;
|
||||
this.documents = init.documents;
|
||||
this.licenseProfiles = init.licenseProfiles;
|
||||
|
||||
@@ -2,7 +2,11 @@ import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
} from "./complete-identity-verification.dto";
|
||||
import { Company, isCooperative } from "../entities/company.entity";
|
||||
import {
|
||||
Company,
|
||||
hasInvestorLicence,
|
||||
isCooperative,
|
||||
} from "../entities/company.entity";
|
||||
import { ExternalProfile } from "../entities/external-profile.entity";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
@@ -21,6 +25,12 @@ export class ProfileResponseDto {
|
||||
* it from eTrade.
|
||||
*/
|
||||
cooperative: boolean;
|
||||
/**
|
||||
* The company is a foreign investor on an investment licence: eTrade holds
|
||||
* no record, so the company step collects the registration by hand. Drives
|
||||
* the settings card that switches back to the eTrade route.
|
||||
*/
|
||||
investorLicence: boolean;
|
||||
companyLocation: string;
|
||||
companyAddress: string | null;
|
||||
tinNumber: string;
|
||||
@@ -93,6 +103,7 @@ export class ProfileResponseDto {
|
||||
this.companyType = company.type;
|
||||
this.nationality = company.nationality ?? null;
|
||||
this.cooperative = isCooperative(company);
|
||||
this.investorLicence = hasInvestorLicence(company);
|
||||
this.companyProfiles =
|
||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||
[];
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CompanyType,
|
||||
CompanyStatus,
|
||||
CompanyNationality,
|
||||
hasInvestorLicence,
|
||||
isCooperative,
|
||||
} from '../entities/company.entity';
|
||||
import {
|
||||
@@ -62,6 +63,13 @@ export class ResponseCompanyDto {
|
||||
* eTrade manager to check the owner against.
|
||||
*/
|
||||
cooperative: boolean;
|
||||
/**
|
||||
* The company onboarded as a foreign investor on an investment licence:
|
||||
* eTrade holds no record for its TIN, so its registration below was typed by
|
||||
* the customer rather than fetched — nothing here has been checked against a
|
||||
* licence, and the reviewer is the check.
|
||||
*/
|
||||
investorLicence: boolean;
|
||||
tin: string;
|
||||
vatNumber?: string | null;
|
||||
fanNumber?: string | null;
|
||||
@@ -118,6 +126,7 @@ export class ResponseCompanyDto {
|
||||
this.status = company.status;
|
||||
this.nationality = company.nationality ?? null;
|
||||
this.cooperative = isCooperative(company);
|
||||
this.investorLicence = hasInvestorLicence(company);
|
||||
this.tin = company.tin;
|
||||
this.vatNumber = company.vatNumber;
|
||||
this.fanNumber = company.fanNumber;
|
||||
|
||||
@@ -31,4 +31,15 @@ export class StartOnboardingDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
cooperative?: boolean;
|
||||
|
||||
/**
|
||||
* The company is a foreign investor: it operates on an investment licence
|
||||
* issued by the Ethiopian Investment Commission, so eTrade holds no record
|
||||
* for its TIN and the registration is typed here instead. Chosen on the same
|
||||
* step for the same reason as the co-operative flag — it decides what the
|
||||
* company step asks for. Only a foreign company can hold one.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
investorLicence?: boolean;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,37 @@ export function isCooperative(
|
||||
return company?.attributes?.[COOPERATIVE_KEY] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `attributes` key marking a foreign company onboarding on an investment
|
||||
* licence.
|
||||
*
|
||||
* The Ethiopian Investment Commission registers it, not the trade registry, so
|
||||
* eTrade holds no record for its TIN: the registration is typed and the eTrade
|
||||
* authenticity check is skipped rather than failed — exactly as for a
|
||||
* co-operative. What does NOT change is the licence: the company still holds
|
||||
* one per operational role, so that requirement stands.
|
||||
*/
|
||||
export const INVESTOR_LICENCE_KEY = "investorLicence";
|
||||
|
||||
/** Is this a foreign company registered on an investment licence? */
|
||||
export function hasInvestorLicence(
|
||||
company: Pick<Company, "attributes"> | null | undefined,
|
||||
): boolean {
|
||||
return company?.attributes?.[INVESTOR_LICENCE_KEY] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* eTrade holds nothing for this company, so its registration was typed by hand
|
||||
* rather than fetched — and the backoffice is told so. Two different companies
|
||||
* reach it (a co-operative has no licence at all; a foreign investor's is not
|
||||
* the trade registry's), and every consequence they share hangs off this.
|
||||
*/
|
||||
export function usesManualRegistration(
|
||||
company: Pick<Company, "attributes"> | null | undefined,
|
||||
): boolean {
|
||||
return isCooperative(company) || hasInvestorLicence(company);
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "companies" })
|
||||
@Index(["tin"])
|
||||
@Index(["type"])
|
||||
|
||||
@@ -114,6 +114,36 @@ export function CompanyNationalityBadge({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's registration was typed, not fetched from eTrade — nothing in it
|
||||
* has been checked against a licence. Loud on purpose: it is the one thing a
|
||||
* reviewer must not miss about this customer. Two kinds of company land here
|
||||
* for different reasons, and the badge names which.
|
||||
*/
|
||||
export function ManualRegistrationBadge({
|
||||
cooperative,
|
||||
investorLicence,
|
||||
}: {
|
||||
cooperative?: boolean | null;
|
||||
investorLicence?: boolean | null;
|
||||
}) {
|
||||
if (!cooperative && !investorLicence) return null;
|
||||
return (
|
||||
<Badge
|
||||
color="orange"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{cooperative
|
||||
? "Manual entry · co-operative"
|
||||
: "Manual entry · investment licence"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
|
||||
* carrying its reference code, colored by the profile's status (green active,
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
ManualRegistrationBadge,
|
||||
PaymentStatusBadge,
|
||||
ProfileApprovalActions,
|
||||
ProfileChips,
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
CompanyTimeline,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
ManualRegistrationBadge,
|
||||
PaymentStatusBadge,
|
||||
PersonCard,
|
||||
ProfileApprovalActions,
|
||||
@@ -744,6 +745,10 @@ export default function CustomerDetailPage() {
|
||||
) : (
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
)}
|
||||
<ManualRegistrationBadge
|
||||
cooperative={company.cooperative}
|
||||
investorLicence={company.investorLicence}
|
||||
/>
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
@@ -792,6 +797,25 @@ export default function CustomerDetailPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Nothing below came from eTrade for these customers. A
|
||||
co-operative holds no trade licence at all; a foreign investor's
|
||||
comes from the Investment Commission, not the trade registry.
|
||||
Either way every registration field was typed, and the reviewer
|
||||
is the only check there is. */}
|
||||
{(company.cooperative || company.investorLicence) && (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Registration entered by hand — not verified against eTrade"
|
||||
>
|
||||
{company.cooperative
|
||||
? "This company onboarded as a co-operative union or farm, which holds no trade licence, so eTrade had no record to look its TIN up in. The company name, registration and address below are the customer's own statement. Check them against the Co-operative Registration Certificate on the Documents tab before approving."
|
||||
: "This company onboarded on a foreign investment licence, so we could not look its TIN up on eTrade. The company name, registration and address below are the customer's own statement. Check them against the Investment Licence on the Documents tab before approving."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ChangeRequestReview company={company} />
|
||||
|
||||
<KpiStrip
|
||||
@@ -861,6 +885,8 @@ export default function CustomerDetailPage() {
|
||||
value={
|
||||
company.cooperative
|
||||
? "Co-operative union / farm (no trade licence)"
|
||||
: company.investorLicence
|
||||
? "Foreign investment licence — typed by the customer, not from eTrade"
|
||||
: "eTrade trade licence"
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CompanyNationalityBadge,
|
||||
CompanyStatusBadge,
|
||||
ManualRegistrationBadge,
|
||||
ProfileChips,
|
||||
formatDate,
|
||||
} from "@/components/customers";
|
||||
@@ -142,6 +143,10 @@ export default function CustomersPage() {
|
||||
{c.name}
|
||||
</Text>
|
||||
<CompanyNationalityBadge nationality={c.nationality} />
|
||||
<ManualRegistrationBadge
|
||||
cooperative={c.cooperative}
|
||||
investorLicence={c.investorLicence}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
TIN {c.tin}
|
||||
|
||||
@@ -234,6 +234,13 @@ export interface Company {
|
||||
* manager to check the owner against, and it holds no freight-forwarder role.
|
||||
*/
|
||||
cooperative?: boolean;
|
||||
/**
|
||||
* A foreign investor on an Ethiopian Investment Commission licence: eTrade
|
||||
* holds no record for its TIN, so every registration field below was typed by
|
||||
* the customer and verified by nobody. The reviewer is the check — compare
|
||||
* them against the investment licence on the Documents tab.
|
||||
*/
|
||||
investorLicence?: boolean;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
|
||||
@@ -162,6 +162,13 @@ export default function OnboardingWizardDialog({
|
||||
const [cooperative, setCooperative] = useState<boolean>(
|
||||
company?.company?.attributes?.cooperative === true,
|
||||
);
|
||||
// A foreign company operating on an Ethiopian Investment Commission licence.
|
||||
// eTrade holds nothing for its TIN, so it types the registration exactly as a
|
||||
// co-operative does — but it still holds a licence per role, so nothing about
|
||||
// the licence step changes.
|
||||
const [investorLicence, setInvestorLicence] = useState<boolean>(
|
||||
company?.company?.attributes?.investorLicence === true,
|
||||
);
|
||||
// Ticking the box drops the selections the company can no longer hold, rather
|
||||
// than letting Continue fail on ones the API refuses: a co-op cannot forward
|
||||
// freight, and is registered in Ethiopia so it is never foreign.
|
||||
@@ -171,8 +178,20 @@ export default function OnboardingWizardDialog({
|
||||
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
||||
// Ethiopian is then the only answer left, so it is made rather than asked.
|
||||
setNationality("ethiopian");
|
||||
// Which also rules out the investment licence — that is a foreign
|
||||
// company's, and the API refuses the pair.
|
||||
setInvestorLicence(false);
|
||||
}
|
||||
}, []);
|
||||
// The investment licence is a foreign company's document. Moving the answer
|
||||
// back to Ethiopian drops it rather than sending a pair the API refuses.
|
||||
const handleNationalityChange = useCallback(
|
||||
(value: CompanyNationality | null) => {
|
||||
setNationality(value);
|
||||
if (value !== "foreign") setInvestorLicence(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const [documentFiles, setDocumentFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
@@ -224,6 +243,7 @@ export default function OnboardingWizardDialog({
|
||||
roles: ProfileTypeValue[];
|
||||
nationality?: CompanyNationality;
|
||||
cooperative?: boolean;
|
||||
investorLicence?: boolean;
|
||||
}) => api.companies.startOnboarding.call(vars),
|
||||
onSuccess: async () => {
|
||||
// Nationality drives the server-resolved identity requirements (Fayda vs
|
||||
@@ -307,6 +327,7 @@ export default function OnboardingWizardDialog({
|
||||
setRoles(existingProfiles.map((p) => p.type));
|
||||
setNationality(savedNationality);
|
||||
setCooperative(company?.company?.attributes?.cooperative === true);
|
||||
setInvestorLicence(company?.company?.attributes?.investorLicence === true);
|
||||
// Resume into the form only when profiles exist; otherwise send the user to
|
||||
// role selection so the missing operational profiles get created.
|
||||
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
|
||||
@@ -322,8 +343,9 @@ export default function OnboardingWizardDialog({
|
||||
roles: roles as ProfileTypeValue[],
|
||||
nationality: nationality ?? undefined,
|
||||
cooperative,
|
||||
investorLicence,
|
||||
});
|
||||
}, [roles, nationality, cooperative, startMutation]);
|
||||
}, [roles, nationality, cooperative, investorLicence, startMutation]);
|
||||
|
||||
// Back from the form's first step returns to nationality/role selection.
|
||||
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
|
||||
@@ -470,6 +492,15 @@ export default function OnboardingWizardDialog({
|
||||
licenseFiles,
|
||||
onLicenseChange: setLicenseFiles,
|
||||
uploadedDocumentKeys,
|
||||
// What the server says is already on file, per operational profile. The
|
||||
// wizard's own `roleProfiles` cannot say: getInfo leaves `licenseFiles`
|
||||
// empty, so a resumed wizard asked for a licence it had already been given
|
||||
// and refused to submit until it was uploaded a second time.
|
||||
uploadedLicenceProfileIds: (
|
||||
requirementsQuery.data?.licenseProfiles ?? []
|
||||
)
|
||||
.filter((p) => p.uploaded)
|
||||
.map((p) => p.profileId),
|
||||
onUploadDocuments: handleUploadDocuments,
|
||||
// The company's single identity verification, and whose it is. Fayda is
|
||||
// mandatory for an Ethiopian company; a foreign one may instead type a
|
||||
@@ -479,6 +510,10 @@ export default function OnboardingWizardDialog({
|
||||
// startOnboarding has persisted it, and the form's whole company step
|
||||
// branches on it.
|
||||
cooperative: requirementsQuery.data?.cooperative ?? cooperative,
|
||||
// Same rule, same reason: only a persisted flag changes what the company
|
||||
// step asks for.
|
||||
investorLicence:
|
||||
requirementsQuery.data?.investorLicence ?? investorLicence,
|
||||
// A freight forwarder cannot answer the power-of-attorney question — the
|
||||
// API forces "yes" — so the step offers no way to change it.
|
||||
declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
|
||||
@@ -546,7 +581,7 @@ export default function OnboardingWizardDialog({
|
||||
</Text>
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
onChange={handleNationalityChange}
|
||||
embedded
|
||||
// A co-op is registered in Ethiopia by the co-operative
|
||||
// promotion agency — foreign is not on offer rather than
|
||||
@@ -563,6 +598,22 @@ export default function OnboardingWizardDialog({
|
||||
label="We're a co-operative union or farm"
|
||||
description="For members with a TIN but no business licence. You'll type your registration details instead of us pulling them from eTrade, and upload your co-operative papers in place of a trade licence."
|
||||
/>
|
||||
{/* Only a foreign company is offered this: the licence is the
|
||||
Investment Commission's, and it is the reason eTrade has
|
||||
nothing to look up. Same consequence as the co-operative box —
|
||||
typed registration instead of a lookup — but the per-role
|
||||
business licence still applies, so the documents step is
|
||||
unchanged. */}
|
||||
{nationality === "foreign" && !cooperative && (
|
||||
<Checkbox
|
||||
checked={investorLicence}
|
||||
onChange={(e) =>
|
||||
setInvestorLicence(e.currentTarget.checked)
|
||||
}
|
||||
label="We operate on a foreign investment licence"
|
||||
description="For investors registered with the Ethiopian Investment Commission rather than the trade registry. eTrade holds no record of your TIN, so you'll type your registration details instead — and our team reviews them by hand."
|
||||
/>
|
||||
)}
|
||||
<Text fw={600} size="lg" c="edr-text">
|
||||
What does your company do?(multiple)
|
||||
</Text>
|
||||
|
||||
@@ -103,6 +103,7 @@ export const URL_CONSTANTS = {
|
||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||
ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements",
|
||||
ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade",
|
||||
DASHBOARD: "/api/companies/dashboard",
|
||||
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
|
||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||
|
||||
@@ -47,6 +47,7 @@ import useAuth from "@/hooks/useAuth";
|
||||
import { rolesForCompanyType } from "./settings/companyRoles";
|
||||
import TabAccount from "./settings/TabAccount";
|
||||
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
||||
import RegistrationSourceCard from "./settings/RegistrationSourceCard";
|
||||
import TabContactPerson from "./settings/TabContactPerson";
|
||||
import TabDocuments from "./settings/TabDocuments";
|
||||
import TabOwner from "./settings/TabOwner";
|
||||
@@ -398,6 +399,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</Fieldset>
|
||||
<OperationalServicesCard profile={profile} />
|
||||
<RegistrationSourceCard profile={profile} disabled={locked} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="contact">
|
||||
<TabContactPerson profile={profile} mode="edit" />
|
||||
|
||||
@@ -61,10 +61,12 @@ export default function CompanyProfileForm({
|
||||
onLicenseChange,
|
||||
submitError,
|
||||
uploadedDocumentKeys,
|
||||
uploadedLicenceProfileIds,
|
||||
onUploadDocuments,
|
||||
identity: rawIdentity,
|
||||
onIdentityChange,
|
||||
cooperative = false,
|
||||
investorLicence = false,
|
||||
declarationLocked = false,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
@@ -95,6 +97,16 @@ export default function CompanyProfileForm({
|
||||
submitError?: string | null;
|
||||
/** fileKeys whose company document is already uploaded server-side (resume). */
|
||||
uploadedDocumentKeys?: string[];
|
||||
/**
|
||||
* Profile ids whose business licence the server already holds.
|
||||
*
|
||||
* `roleProfiles.existingFiles` cannot answer this on a resumed wizard:
|
||||
* `getInfo` does not populate `licenseFiles`, so every profile looks empty
|
||||
* however many licences are on file. Taken from the onboarding requirements,
|
||||
* which is the server's own verdict and what `markOnboardingComplete`
|
||||
* enforces.
|
||||
*/
|
||||
uploadedLicenceProfileIds?: string[];
|
||||
/**
|
||||
* Auto-upload the currently-selected company documents (the Documents step's
|
||||
* "Continue" action). Resolves to an error message string on failure so the
|
||||
@@ -118,6 +130,13 @@ export default function CompanyProfileForm({
|
||||
* nationality one (resolved by the caller into `documentSettingCode`).
|
||||
*/
|
||||
cooperative?: boolean;
|
||||
/**
|
||||
* The company is a foreign investor on an Investment Commission licence. Like
|
||||
* a co-operative, eTrade holds no record of it, so the registration is typed
|
||||
* and the lookup gate does not apply — but it does hold a business licence
|
||||
* per role, so the licence step is untouched.
|
||||
*/
|
||||
investorLicence?: boolean;
|
||||
/**
|
||||
* The company operates as a freight forwarder, so the power-of-attorney
|
||||
* answer is forced to "yes" and cannot be changed here.
|
||||
@@ -133,6 +152,12 @@ export default function CompanyProfileForm({
|
||||
[rawIdentity],
|
||||
);
|
||||
|
||||
// eTrade has nothing to say about this company, whichever of the two reasons
|
||||
// applies — so the registration is typed here and the lookup cannot gate the
|
||||
// step. Everything the two cases do NOT share (the per-role business licence)
|
||||
// keeps reading `cooperative` on its own.
|
||||
const manualRegistration = cooperative || investorLicence;
|
||||
|
||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||
const [saving, setSaving] = useState(false);
|
||||
/**
|
||||
@@ -434,7 +459,7 @@ export default function CompanyProfileForm({
|
||||
// holds nothing — and wiping them because the customer went back to fix a
|
||||
// digit of their TIN would throw away an address they had just typed by
|
||||
// hand, over a lookup that never filled anything in the first place.
|
||||
if (cooperative && !etradeFilledRef.current) {
|
||||
if (manualRegistration && !etradeFilledRef.current) {
|
||||
setLiveEtradeOwner(null);
|
||||
setEtradeCleared(true);
|
||||
return;
|
||||
@@ -674,7 +699,9 @@ export default function CompanyProfileForm({
|
||||
if (cooperative) return errs;
|
||||
for (const p of roleProfiles ?? []) {
|
||||
const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0;
|
||||
const hasExisting = p.existingFiles.length > 0;
|
||||
const hasExisting =
|
||||
p.existingFiles.length > 0 ||
|
||||
(uploadedLicenceProfileIds ?? []).includes(p.id);
|
||||
if (!hasNew && !hasExisting) {
|
||||
errs[p.id] = "Business license is required";
|
||||
}
|
||||
@@ -730,7 +757,7 @@ export default function CompanyProfileForm({
|
||||
// A co-operative never runs the lookup, so there is nothing to be verified
|
||||
// against; its TIN is validated by the schema like any other typed field.
|
||||
const tinVerified =
|
||||
cooperative || tinStatus === "verified" || hasRegistrationDetails;
|
||||
manualRegistration || tinStatus === "verified" || hasRegistrationDetails;
|
||||
|
||||
// Single source of truth for step sequence — navigation, labels and the
|
||||
// progress bar all derive from this so adding/removing a step is one edit.
|
||||
@@ -770,8 +797,8 @@ export default function CompanyProfileForm({
|
||||
Boolean(watch(passportField)?.trim()));
|
||||
|
||||
const requiredKeys: (keyof FormData)[] = [];
|
||||
if (step === "company" && cooperative) {
|
||||
// A co-operative has no eTrade record, so the fields every other company
|
||||
if (step === "company" && manualRegistration) {
|
||||
// These companies have no eTrade record, so the fields every other company
|
||||
// gets read-only from the licence are typed here — and are therefore
|
||||
// required here. House number stays optional: plenty of addresses have none.
|
||||
requiredKeys.push("companyName", "region", "zone", "woreda", "kebele");
|
||||
@@ -887,8 +914,9 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
// The TIN must resolve to a real eTrade record before anything else on
|
||||
// this step is even worth validating — gates here rather than through zod.
|
||||
// A co-operative is exempt: it has no licence for eTrade to hold, so
|
||||
// `tinVerified` is true for it and only the duplicate-TIN check applies.
|
||||
// A co-operative and a foreign investor are exempt: eTrade holds no record
|
||||
// for either, so `tinVerified` is true and only the duplicate-TIN check
|
||||
// applies.
|
||||
if (step === "company" && tinStatus === "taken") {
|
||||
failCheck(
|
||||
"This TIN is already registered to another company account.",
|
||||
@@ -987,6 +1015,7 @@ export default function CompanyProfileForm({
|
||||
tinStatus={tinStatus}
|
||||
tinVerified={tinVerified}
|
||||
hasRegistrationDetails={hasRegistrationDetails}
|
||||
manualRegistration={manualRegistration}
|
||||
cooperative={cooperative}
|
||||
onETradeDataLoaded={handleETradeDataLoaded}
|
||||
onETradeStatusChange={setTinStatus}
|
||||
@@ -1002,6 +1031,7 @@ export default function CompanyProfileForm({
|
||||
source={ownerSource}
|
||||
sourced={ownerSourced}
|
||||
cooperative={cooperative}
|
||||
manualRegistration={manualRegistration}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,10 +17,13 @@ export interface CompanyInfoStepProps {
|
||||
/** Registration fields are already populated (a lookup passed, now or earlier). */
|
||||
hasRegistrationDetails: boolean;
|
||||
/**
|
||||
* The company is a co-operative union or farm: it has a TIN but no business
|
||||
* licence, so eTrade holds no record to look up and the registration is typed
|
||||
* here instead.
|
||||
* eTrade holds no record for this company's TIN, so the registration is typed
|
||||
* here rather than fetched. True for a co-operative union or farm (no
|
||||
* business licence) and for a foreign investor (licensed by the Investment
|
||||
* Commission, not the trade registry).
|
||||
*/
|
||||
manualRegistration?: boolean;
|
||||
/** Which of the two it is — wording only; the behaviour is the same. */
|
||||
cooperative?: boolean;
|
||||
onETradeDataLoaded: (data: CompanyRegistrationData) => void;
|
||||
onETradeStatusChange: (status: ETradeStatus) => void;
|
||||
@@ -32,6 +35,7 @@ export default function CompanyInfoStep({
|
||||
tinStatus,
|
||||
tinVerified,
|
||||
hasRegistrationDetails,
|
||||
manualRegistration = false,
|
||||
cooperative = false,
|
||||
onETradeDataLoaded,
|
||||
onETradeStatusChange,
|
||||
@@ -71,14 +75,16 @@ export default function CompanyInfoStep({
|
||||
index={2}
|
||||
title="Company TIN"
|
||||
subtitle={
|
||||
cooperative
|
||||
!manualRegistration
|
||||
? "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||
: cooperative
|
||||
? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below."
|
||||
: "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||
: "We'll check eTrade for your TIN. An investment licence usually isn't on it — if yours isn't, you'll fill the details in below."
|
||||
}
|
||||
status={
|
||||
tinStatus === "taken"
|
||||
? "blocked"
|
||||
: cooperative
|
||||
: manualRegistration
|
||||
? watch("tinNumber")?.trim() && !errors.tinNumber
|
||||
? "done"
|
||||
: "todo"
|
||||
@@ -96,26 +102,26 @@ export default function CompanyInfoStep({
|
||||
onReset={onETradeReset}
|
||||
alreadyVerified={hasRegistrationDetails}
|
||||
selectedLicenceNumber={watch("licenceNumber")}
|
||||
registrationOptional={cooperative}
|
||||
registrationOptional={manualRegistration}
|
||||
/>
|
||||
{!cooperative && tinVerified && (
|
||||
{!manualRegistration && tinVerified && (
|
||||
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
{/* A co-operative keeps its typed registration section either way. When
|
||||
the lookup found something these arrive prefilled — still editable,
|
||||
because for a co-op they are the customer's own statement rather than
|
||||
the licence's, and the API takes them as given (`applyEtradeSourcedFields`
|
||||
skips co-operatives entirely). */}
|
||||
{cooperative && (
|
||||
{/* A company eTrade cannot answer for keeps its typed registration
|
||||
section either way. When the lookup did find something these arrive
|
||||
prefilled — still editable, because here they are the customer's own
|
||||
statement rather than the licence's, and the API takes them as given
|
||||
(`applyEtradeSourcedFields` skips both cases entirely). */}
|
||||
{manualRegistration && (
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Registration details"
|
||||
subtitle={
|
||||
hasRegistrationDetails
|
||||
? "From eTrade. Correct anything that doesn't look right — for a co-operative these are yours to state."
|
||||
: "Everything we'd normally read off an eTrade licence. We need it from you instead."
|
||||
? "From eTrade. Correct anything that doesn't look right — these are yours to state."
|
||||
: "Everything we'd normally read off an eTrade licence. We need it from you instead. Our team checks it against the papers you upload."
|
||||
}
|
||||
status={
|
||||
watch("companyName")?.trim() && watch("region")?.trim()
|
||||
@@ -126,7 +132,11 @@ export default function CompanyInfoStep({
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Registered name of the union or farm"
|
||||
placeholder={
|
||||
cooperative
|
||||
? "Registered name of the union or farm"
|
||||
: "Name on your investment licence"
|
||||
}
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
@@ -149,7 +159,15 @@ export default function CompanyInfoStep({
|
||||
searchable
|
||||
value={region || null}
|
||||
onChange={(v) =>
|
||||
setValue("region", v ?? "", { shouldValidate: true })
|
||||
// shouldDirty, or the pick never reaches the API: `region` is
|
||||
// an eTrade-bundle key, and `stepPayload` sends those only
|
||||
// when the customer changed them this session. Without it a
|
||||
// co-operative or foreign investor typed its address and the
|
||||
// region alone silently vanished on save.
|
||||
setValue("region", v ?? "", {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
error={errors.region?.message}
|
||||
/>
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface OwnerStepProps {
|
||||
sourced: Record<OwnerField, string>;
|
||||
/** A co-operative union or farm: no licence, so no eTrade record to match. */
|
||||
cooperative?: boolean;
|
||||
/** No eTrade record at all (co-operative or foreign investment licence). */
|
||||
manualRegistration?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,7 @@ export default function OwnerStep({
|
||||
source,
|
||||
sourced,
|
||||
cooperative = false,
|
||||
manualRegistration = false,
|
||||
}: OwnerStepProps) {
|
||||
const {
|
||||
register,
|
||||
@@ -74,15 +77,17 @@ export default function OwnerStep({
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="edr-muted">
|
||||
{cooperative && !etradeOwner
|
||||
{manualRegistration && !etradeOwner
|
||||
? cooperative
|
||||
? "The person who runs the co-operative union or farm. eTrade held no record for your TIN, so we need all of these from you."
|
||||
: "The person your investment licence names. eTrade held no record for your TIN, so we need all of these from you."
|
||||
: "These are the details of the person registered on your eTrade licence. What eTrade and Fayda gave us is shown as they gave it; anything they left blank we need from you."}
|
||||
</Text>
|
||||
|
||||
{/* A co-operative is not told its licence listed no manager — it has no
|
||||
licence. Its own "nothing came back" case is covered by the line
|
||||
above. */}
|
||||
{!cooperative && !etradeOwner && !ownerVerified && (
|
||||
{/* A company with no eTrade record is not told its licence listed no
|
||||
manager — eTrade never held one. That "nothing came back" case is
|
||||
covered by the line above. */}
|
||||
{!manualRegistration && !etradeOwner && !ownerVerified && (
|
||||
<Alert color="blue" variant="light" icon={<Info size={18} />}>
|
||||
Your eTrade licence didn't list a manager, so there's nothing for us
|
||||
to prefill. Enter the details of the person registered on it.
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
List,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, FileSearch } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/**
|
||||
* Leave whichever manual-registration route the company is on — a co-operative
|
||||
* union or farm, or a foreign investment licence — and go back to the ordinary
|
||||
* eTrade one. For a company that has since been registered with the trade
|
||||
* registry, or that ticked the box by mistake.
|
||||
*
|
||||
* It is a re-application, not a settings edit: the API clears the registration
|
||||
* the customer typed (nothing on file was ever checked against a licence) and
|
||||
* puts the company back to pending, so the wizard reopens on the company step
|
||||
* and the TIN goes through eTrade this time. Said plainly here rather than
|
||||
* discovered afterwards.
|
||||
*
|
||||
* Only the way OUT is here. Moving between the three registration sources in
|
||||
* the other direction is the wizard's own step, which this reopens — that is
|
||||
* where the rules about which combinations are legal already live, and a second
|
||||
* picker would have to restate every one of them.
|
||||
*/
|
||||
export default function RegistrationSourceCard({
|
||||
profile,
|
||||
disabled = false,
|
||||
}: {
|
||||
profile: ProfileResponse;
|
||||
/** A change request is under review — switching now would strand it. */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const revert = useMutation({
|
||||
mutationFn: () => api.companies.revertToRegularCompany.call(),
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
// getInfo is what the onboarding gate reads, so refreshing it is what
|
||||
// reopens the wizard.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.onboardingRequirements.queryKey(),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (err) => setError(extractApiError(err).message),
|
||||
});
|
||||
|
||||
// Which of the two routes this is — wording only; leaving costs the same.
|
||||
const cooperative = profile.cooperative;
|
||||
if (!profile.investorLicence && !cooperative) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding="lg" radius="lg" mt="lg">
|
||||
<Group gap="sm" mb="md">
|
||||
<FileSearch size={20} />
|
||||
<Title order={3}>Registration source</Title>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="edr-muted">
|
||||
{cooperative
|
||||
? "Your company is registered as a co-operative union or farm, so your registration details were entered by hand instead of being read from eTrade. Our team reviews them against the documents you uploaded."
|
||||
: "Your company is registered on a foreign investment licence, so your registration details were entered by hand instead of being read from eTrade. Our team reviews them against the documents you uploaded."}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted">
|
||||
If your company now holds an eTrade trade licence, you can switch
|
||||
over and have your registration verified automatically.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setConfirming(true);
|
||||
}}
|
||||
>
|
||||
Switch to eTrade registration
|
||||
</Button>
|
||||
</Group>
|
||||
{disabled && (
|
||||
<Text size="xs" c="edr-muted" ta="right">
|
||||
Not available while your profile changes are under review.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={confirming}
|
||||
onClose={() => setConfirming(false)}
|
||||
title="Switch to eTrade registration?"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">This re-opens your application:</Text>
|
||||
<List size="sm" spacing="xs">
|
||||
<List.Item>
|
||||
The registration details you typed are cleared — eTrade supplies
|
||||
them once your TIN is found.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Your company goes back to pending and is reviewed again.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Your documents, owner and contact details stay as they are — but
|
||||
the papers we ask for change with the registration source, so some
|
||||
may be listed as outstanding again.
|
||||
</List.Item>
|
||||
{cooperative && (
|
||||
<List.Item>
|
||||
A business licence becomes due for each of your operational
|
||||
services — a co-operative owes none, an eTrade-registered
|
||||
company does — so any that are already approved go back to
|
||||
awaiting approval until you upload one. Their reference numbers
|
||||
stay the same.
|
||||
</List.Item>
|
||||
)}
|
||||
</List>
|
||||
<Alert color="amber" variant="light" icon={<AlertCircle size={18} />}>
|
||||
If eTrade holds no record for your TIN you won't be able to finish —
|
||||
the wizard reopens on the company step, so go back one step and
|
||||
re-select {cooperative ? "co-operative" : "the investment licence"}{" "}
|
||||
there.
|
||||
</Alert>
|
||||
{error && (
|
||||
<Text size="sm" c="red">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setConfirming(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={revert.isPending}
|
||||
onClick={() => revert.mutate()}
|
||||
>
|
||||
Switch and re-apply
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -20,18 +21,22 @@ import {
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
|
||||
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
||||
import ETradeInfo, {
|
||||
type ETradeStatus,
|
||||
} from "@/components/onboarding/ETradeInfo";
|
||||
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
||||
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
||||
import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema";
|
||||
import {
|
||||
ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS,
|
||||
onboardingSchema,
|
||||
} from "@/pages/accounts/companyProfileForm/schema";
|
||||
|
||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
const BASE_COMPANY_PROFILE_SCHEMA = z.object({
|
||||
// eTrade-sourced and read-only, like the registration block below.
|
||||
companyName: z.string().optional(),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
@@ -39,12 +44,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
// no standalone input.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
// Same rule as onboarding — the two forms write the same column, so they must
|
||||
// not disagree about what is acceptable in it.
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
|
||||
// Onboarding's own rule, reused rather than restated: the two forms write the
|
||||
// same column, and the copy here had drifted into a 10-or-11-digit check that
|
||||
// onboarding and the API both refuse to make. A foreign company's VAT is
|
||||
// whatever its tax authority issues and a co-operative's follows neither, so
|
||||
// the stricter copy locked those customers out of their own Company tab
|
||||
// entirely — every save on it, not just the VAT.
|
||||
vatNumber: onboardingSchema.shape.vatNumber,
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
// Registration/address fields are eTrade-sourced and never typed by hand —
|
||||
// not even when eTrade leaves one blank, so none of them may be required
|
||||
@@ -62,7 +68,48 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
houseNo: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
||||
/**
|
||||
* The registration fields a manual-registration company types by hand.
|
||||
*
|
||||
* eTrade holds no record for a co-operative union or farm, nor for a foreign
|
||||
* investor on an Investment Commission licence, so the block every other
|
||||
* company gets read-only off the licence is typed by these two — and is
|
||||
* therefore required of them, exactly as the onboarding wizard requires it.
|
||||
* House number stays optional: plenty of addresses have none.
|
||||
*/
|
||||
const TYPED_REGISTRATION_LABELS = {
|
||||
companyName: "Company name",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `manualRegistration` is the only thing that changes here, and it changes the
|
||||
* same way it does in the wizard: **a field is required iff there is an input
|
||||
* on screen for it.** For an eTrade company these are read-only rows, so
|
||||
* requiring one would be a Save button failing on a field with nothing to fix.
|
||||
*/
|
||||
export const buildCompanyProfileSchema = (manualRegistration: boolean) =>
|
||||
manualRegistration
|
||||
? BASE_COMPANY_PROFILE_SCHEMA.superRefine((d, ctx) => {
|
||||
for (const key of Object.keys(
|
||||
TYPED_REGISTRATION_LABELS,
|
||||
) as (keyof typeof TYPED_REGISTRATION_LABELS)[]) {
|
||||
if (d[key]?.trim()) continue;
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [key],
|
||||
message: `${TYPED_REGISTRATION_LABELS[key]} is required`,
|
||||
});
|
||||
}
|
||||
})
|
||||
: BASE_COMPANY_PROFILE_SCHEMA;
|
||||
|
||||
export type CompanyProfileFormData = z.infer<
|
||||
typeof BASE_COMPANY_PROFILE_SCHEMA
|
||||
>;
|
||||
|
||||
/**
|
||||
* `etradePhone` is not on this form, so the shared list is filtered down to the
|
||||
@@ -86,6 +133,17 @@ export default function TabCompanyProfile({
|
||||
}: TabCompanyProfileProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isCreate = mode === "create";
|
||||
/**
|
||||
* eTrade has nothing to say about this company — a co-operative holds no
|
||||
* business licence, a foreign investor's is the Investment Commission's — so
|
||||
* its registration was typed during onboarding and has to stay editable here.
|
||||
* Rendering the eTrade card instead left that data invisible and frozen: the
|
||||
* one company that owns its registration details was the one that could not
|
||||
* see them.
|
||||
*/
|
||||
const manualRegistration = Boolean(
|
||||
profile?.cooperative || profile?.investorLicence,
|
||||
);
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
|
||||
|
||||
@@ -140,7 +198,7 @@ export default function TabCompanyProfile({
|
||||
setValue,
|
||||
formState: { errors, isDirty, dirtyFields },
|
||||
} = useForm<CompanyProfileFormData>({
|
||||
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
|
||||
resolver: zodResolver(buildCompanyProfileSchema(manualRegistration)),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
@@ -315,7 +373,6 @@ export default function TabCompanyProfile({
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
placeholder="e.g. 0012345678"
|
||||
maxLength={11}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
@@ -325,12 +382,20 @@ export default function TabCompanyProfile({
|
||||
<StepSection
|
||||
index={2}
|
||||
title="Company TIN"
|
||||
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||
subtitle={
|
||||
manualRegistration
|
||||
? "We'll check eTrade for your TIN. If it holds nothing — the usual case here — the details below stay yours to state."
|
||||
: "Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||
}
|
||||
status={
|
||||
tinVerified
|
||||
? "done"
|
||||
: tinStatus === "taken"
|
||||
tinStatus === "taken"
|
||||
? "blocked"
|
||||
: manualRegistration
|
||||
? watch("tinNumber")?.trim() && !errors.tinNumber
|
||||
? "done"
|
||||
: "todo"
|
||||
: tinVerified
|
||||
? "done"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
@@ -342,12 +407,33 @@ export default function TabCompanyProfile({
|
||||
onStatusChange={setTinStatus}
|
||||
alreadyVerified={hasRegistrationDetails}
|
||||
selectedLicenceNumber={watch("licenceNumber")}
|
||||
registrationOptional={manualRegistration}
|
||||
/>
|
||||
{tinVerified && (
|
||||
{!manualRegistration && tinVerified && (
|
||||
<EtradeLockedCard tin={watch("tinNumber")} watch={watch} />
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
{manualRegistration && (
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Registration details"
|
||||
subtitle="Everything we'd normally read off an eTrade licence. Our team checks what you state here against the documents you upload."
|
||||
status={
|
||||
watch("companyName")?.trim() && watch("region")?.trim()
|
||||
? "done"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<TypedRegistrationFields
|
||||
register={register}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
/>
|
||||
</StepSection>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
@@ -409,6 +495,86 @@ export default function TabCompanyProfile({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration a manual-registration company states for itself.
|
||||
*
|
||||
* Deliberately editable, unlike `EtradeLockedCard` below: nothing here came
|
||||
* from a licence, so there is no verified record to protect — it is the
|
||||
* customer's own claim, checked by a reviewer against the papers they upload.
|
||||
* Same fields, same rules and same region list as the onboarding wizard's
|
||||
* company step, so a co-operative or investor sees one story in both places.
|
||||
*/
|
||||
function TypedRegistrationFields({
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
errors,
|
||||
}: {
|
||||
register: UseFormRegister<CompanyProfileFormData>;
|
||||
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
||||
setValue: UseFormSetValue<CompanyProfileFormData>;
|
||||
errors: FieldErrors<CompanyProfileFormData>;
|
||||
}) {
|
||||
const region = watch("region") ?? "";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Registered name of the company"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<Text size="sm" c="edr-muted">
|
||||
Registered address
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Select
|
||||
label="Region"
|
||||
placeholder="Select region"
|
||||
// A value eTrade (or an earlier save) supplied may not be spelled the
|
||||
// way our list spells it. Carrying it in as an option keeps it visible
|
||||
// rather than silently blanking a field nobody touched.
|
||||
data={
|
||||
region && !(ETHIOPIAN_REGIONS as readonly string[]).includes(region)
|
||||
? [...ETHIOPIAN_REGIONS, region]
|
||||
: [...ETHIOPIAN_REGIONS]
|
||||
}
|
||||
searchable
|
||||
value={region || null}
|
||||
onChange={(v) =>
|
||||
setValue("region", v ?? "", {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
error={errors.region?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Zone"
|
||||
error={errors.zone?.message}
|
||||
{...register("zone")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Woreda"
|
||||
error={errors.woreda?.message}
|
||||
{...register("woreda")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Kebele"
|
||||
error={errors.kebele?.message}
|
||||
{...register("kebele")}
|
||||
/>
|
||||
<TextInput
|
||||
label="House No."
|
||||
error={errors.houseNo?.message}
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The verified eTrade record, rendered strictly read-only — same rule as
|
||||
* onboarding's ETradeCompanyCard: nothing here is typeable, not even a field
|
||||
|
||||
@@ -5,20 +5,30 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { CheckCircle2, Save, User, XCircle } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The same four fields onboarding collects, with the same requiredness. The
|
||||
// position and the email were captured by the wizard and then had no input
|
||||
// here at all — stored, invisible, and impossible to correct.
|
||||
const schema = z.object({
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPosition: z.string().optional(),
|
||||
contactPersonEmail: z
|
||||
.string()
|
||||
.email("Invalid email address")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
contactPersonPhone: z
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
@@ -39,6 +49,8 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
return {
|
||||
contactPersonName: profile.contactPersonName ?? "",
|
||||
contactPersonPosition: profile.contactPersonPosition ?? "",
|
||||
contactPersonEmail: profile.contactPersonEmail ?? "",
|
||||
contactPersonPhone: profile.contactPersonPhone ?? "",
|
||||
};
|
||||
}, [profile]);
|
||||
@@ -58,6 +70,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
contactPersonName: data.contactPersonName,
|
||||
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
|
||||
// and undefined, so an empty string is validated and 400s with
|
||||
// "contactPersonEmail must be an email".
|
||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||
contactPersonPhone: data.contactPersonPhone,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -80,19 +97,36 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Jane Smith"
|
||||
error={errors.contactPersonName?.message}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Position (Optional)"
|
||||
placeholder="Operations Lead"
|
||||
error={errors.contactPersonPosition?.message}
|
||||
{...register("contactPersonPosition")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Email (Optional)"
|
||||
type="email"
|
||||
placeholder="contact@company.com"
|
||||
error={errors.contactPersonEmail?.message}
|
||||
{...register("contactPersonEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="contactPersonPhone"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
|
||||
@@ -171,7 +171,11 @@ export default function TabDocuments({
|
||||
return errs;
|
||||
};
|
||||
|
||||
const licenseProfiles = profile.companyProfiles;
|
||||
// A co-operative union or farm holds no business licence — that is the whole
|
||||
// reason it uploads its own document set instead — so the API lifts the
|
||||
// per-role requirement and these cards are not shown. Offering an upload slot
|
||||
// nothing can ever fill reads as an outstanding task that cannot be finished.
|
||||
const licenseProfiles = profile.cooperative ? [] : profile.companyProfiles;
|
||||
|
||||
// Company documents render through SmartFileInput, which is keyed by field and
|
||||
// has no per-file review slot. Surfacing the outstanding corrections as one
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -19,6 +20,11 @@ import { api } from "@/services/api";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField";
|
||||
import {
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
resolveOwnerSources,
|
||||
} from "@/pages/accounts/companyProfileForm/helpers";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// Optional, not unrequired: whatever a Fayda verification supplied is owned by
|
||||
@@ -27,6 +33,10 @@ import type { ProfileResponse } from "@/types/profile";
|
||||
// are actually on screen; zod only polices format.
|
||||
const schema = z.object({
|
||||
ownerName: z.string().optional(),
|
||||
// The alternative credential for a foreign company's owner — asked for only
|
||||
// when they are the identity subject and have not verified with Fayda, so
|
||||
// like the three below it is optional here and gated on what is rendered.
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
ownerEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -64,20 +74,41 @@ export default function TabOwner({
|
||||
onContinue,
|
||||
}: TabOwnerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const identity = profile.identity;
|
||||
// Fayda reports a phone as the national registry holds it, routinely a local
|
||||
// number that neither this form nor the API's `@IsValidPhone()` accepts.
|
||||
// Normalize once on read, exactly as the wizard does.
|
||||
const identity = useMemo(
|
||||
() => normalizeIdentityPhones(profile.identity),
|
||||
[profile.identity],
|
||||
);
|
||||
const owner = identity?.owner;
|
||||
// Only the person the declaration points at carries the verification, so the
|
||||
// panel is offered here only when that person is the owner.
|
||||
const ownerIsSubject = identity?.subject === "owner";
|
||||
// A Fayda verification owns what its claims filled — the API refuses to
|
||||
// overwrite those, so they show read-only. Anything it left blank stays
|
||||
// editable here, whatever value is currently stored.
|
||||
const ownerVerified = owner?.verified ?? false;
|
||||
const ownerLocked = {
|
||||
name: ownerVerified && Boolean(owner?.name?.trim()),
|
||||
email: ownerVerified && Boolean(owner?.email?.trim()),
|
||||
phone: ownerVerified && Boolean(owner?.phone?.trim()),
|
||||
};
|
||||
|
||||
/**
|
||||
* The manager the eTrade licence names, read back from what the lookup
|
||||
* captured. The wizard shows these read-only for the same reason this tab
|
||||
* must: that record is what the backoffice checks the company against, so it
|
||||
* is reported, not retyped. Leaving it editable here let a customer overwrite
|
||||
* the very value the review compares against.
|
||||
*/
|
||||
const etradeOwner = useMemo(() => {
|
||||
const name = identity?.etradeManagerName?.trim() ?? "";
|
||||
// Dropped if it cannot normalize — eTrade's is free text ("09 " is a
|
||||
// real answer), and an unusable number must fall through to an input rather
|
||||
// than lock the field behind a value the API would reject.
|
||||
const phone = firstValidPhone(identity?.etradeManagerPhone);
|
||||
return name || phone ? { name, phone } : null;
|
||||
}, [identity?.etradeManagerName, identity?.etradeManagerPhone]);
|
||||
|
||||
// Who owns each field, and with what value — the wizard's own rule, reused so
|
||||
// the two cannot disagree about which details are the customer's to edit.
|
||||
const { source: ownerSource, sourced: ownerSourced } = resolveOwnerSources(
|
||||
identity,
|
||||
etradeOwner,
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -90,19 +121,23 @@ export default function TabOwner({
|
||||
ownerName: profile.ownerName ?? "",
|
||||
ownerEmail: profile.ownerEmail ?? "",
|
||||
ownerPhone: profile.ownerPhone ?? "",
|
||||
ownerPassportNumber: profile.identity?.owner.passportNumber ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
|
||||
// and undefined, so an empty string is validated and 400s with
|
||||
// "ownerEmail must be an email". A verified owner legitimately leaves
|
||||
// the fields Fayda did supply blank here.
|
||||
ownerName: data.ownerName || undefined,
|
||||
ownerEmail: data.ownerEmail || undefined,
|
||||
ownerPhone: data.ownerPhone || undefined,
|
||||
// A field a source owns is submitted as that source has it, not as the
|
||||
// form happens to hold it: the read-only row is what the customer was
|
||||
// shown, and only the fields are sent. `|| undefined`, never "": the
|
||||
// DTO's `@IsOptional()` skips null and undefined, so an empty string is
|
||||
// validated and 400s with "ownerEmail must be an email", and a verified
|
||||
// owner legitimately leaves the fields Fayda supplied blank here.
|
||||
ownerName: (ownerSourced.name || data.ownerName) || undefined,
|
||||
ownerEmail: (ownerSourced.email || data.ownerEmail) || undefined,
|
||||
ownerPhone: (ownerSourced.phone || data.ownerPhone) || undefined,
|
||||
ownerPassportNumber: data.ownerPassportNumber || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -112,15 +147,21 @@ export default function TabOwner({
|
||||
},
|
||||
});
|
||||
|
||||
// What the verification did NOT supply. Fayda's email and phone claims are
|
||||
// optional, so a verified owner can still be missing details the API demands
|
||||
// — the API leaves exactly those keys typeable, and so does this form.
|
||||
const gaps = {
|
||||
name: !owner?.name?.trim(),
|
||||
email: !owner?.email?.trim(),
|
||||
phone: !owner?.phone?.trim(),
|
||||
};
|
||||
const savable = gaps.name || gaps.email || gaps.phone;
|
||||
// A foreign owner who is the identity subject and has not verified proves
|
||||
// themselves with a passport instead — the same either/or the wizard offers.
|
||||
// Without an input here the only route to it was the onboarding wizard, which
|
||||
// an onboarded company can no longer reach.
|
||||
const passportAskable =
|
||||
(identity?.passportAccepted ?? false) && ownerIsSubject && !ownerVerified;
|
||||
|
||||
// Save is offered when there is at least one input on screen to save. A field
|
||||
// an outside source owns has none, so an owner fully supplied by Fayda and
|
||||
// eTrade has nothing to submit.
|
||||
const savable =
|
||||
!ownerSource.name ||
|
||||
!ownerSource.email ||
|
||||
!ownerSource.phone ||
|
||||
passportAskable;
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
@@ -158,8 +199,8 @@ export default function TabOwner({
|
||||
|
||||
<SourcedField
|
||||
label="Name"
|
||||
value={owner?.name}
|
||||
source={ownerLocked.name ? "Fayda" : null}
|
||||
value={ownerSourced.name}
|
||||
source={ownerSource.name}
|
||||
>
|
||||
<TextInput
|
||||
label="Name"
|
||||
@@ -173,8 +214,8 @@ export default function TabOwner({
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField
|
||||
label="Email"
|
||||
value={owner?.email}
|
||||
source={ownerLocked.email ? "Fayda" : null}
|
||||
value={ownerSourced.email}
|
||||
source={ownerSource.email}
|
||||
>
|
||||
<TextInput
|
||||
label="Email"
|
||||
@@ -188,8 +229,8 @@ export default function TabOwner({
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField
|
||||
label="Phone"
|
||||
value={owner?.phone}
|
||||
source={ownerLocked.phone ? "Fayda" : null}
|
||||
value={ownerSourced.phone}
|
||||
source={ownerSource.phone}
|
||||
>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
@@ -200,6 +241,16 @@ export default function TabOwner({
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{passportAskable && (
|
||||
<TextInput
|
||||
label="Owner's Passport Number"
|
||||
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
|
||||
placeholder="P1234567"
|
||||
error={errors.ownerPassportNumber?.message}
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{savable && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
|
||||
@@ -42,18 +42,68 @@ import {
|
||||
type LicenseFileStatus,
|
||||
} from "@/services/companies.service";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import {
|
||||
firstValidEmail,
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
} from "@/pages/accounts/companyProfileForm/helpers";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import RoleCard from "@/pages/settings/RoleCard";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The representative's name, email, phone and address all come from their
|
||||
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
|
||||
// is the only detail this form owns.
|
||||
const schema = z.object({
|
||||
poaLocation: z.string().optional(),
|
||||
});
|
||||
/** The representative's details that a Fayda verification may or may not own. */
|
||||
const POA_LABELS = {
|
||||
poaName: "Representative's name",
|
||||
poaEmail: "Representative's email",
|
||||
poaPhone: "Representative's phone",
|
||||
} as const;
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
type PoaField = keyof typeof POA_LABELS;
|
||||
|
||||
/**
|
||||
* The representative's details.
|
||||
*
|
||||
* Optional here, not unrequired. Whatever their Fayda verification supplied is
|
||||
* owned by the API and shown read-only, so a blanket `min(1)` would fail a form
|
||||
* that is correct — but Fayda's email and phone claims are optional and
|
||||
* routinely come back empty, and the API's own `REQUIRED_POA_FIELDS` demands
|
||||
* all three once a representative is declared. So requiredness is decided per
|
||||
* render, exactly as the wizard decides it: **a field is required iff there is
|
||||
* an input on screen for it.** This form used to assume the verification always
|
||||
* supplied everything and rendered no inputs at all, which left a company
|
||||
* reported incomplete with nowhere to fix it.
|
||||
*/
|
||||
const buildSchema = (required: readonly PoaField[]) =>
|
||||
z
|
||||
.object({
|
||||
poaName: z.string().optional(),
|
||||
poaEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || z.string().email().safeParse(v).success,
|
||||
"Invalid email address",
|
||||
),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaPassportNumber: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
})
|
||||
.superRefine((d, ctx) => {
|
||||
for (const key of required) {
|
||||
if (d[key]?.trim()) continue;
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [key],
|
||||
message: `${POA_LABELS[key]} is required`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof buildSchema>>;
|
||||
|
||||
const LETTER_ACCEPT = ".pdf,.png,.jpg,.jpeg";
|
||||
|
||||
@@ -96,18 +146,63 @@ export default function TabPowerOfAttorney({
|
||||
const { view, viewer } = useFileViewer();
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fayda holds a phone as the national registry does, often a local number the
|
||||
// form's E.164 validation would reject. Normalize on read, as the wizard does.
|
||||
const identity = useMemo(
|
||||
() => normalizeIdentityPhones(profile.identity),
|
||||
[profile.identity],
|
||||
);
|
||||
const poa = identity?.poa;
|
||||
const poaVerified = poa?.verified ?? false;
|
||||
|
||||
/**
|
||||
* Which details the verification owns. Same test as the wizard's: presence is
|
||||
* not enough for an email or a phone, because Fayda's claims are free text and
|
||||
* one the schema would reject is not a claim an input can be hidden behind.
|
||||
*/
|
||||
const locked = {
|
||||
name: poaVerified && Boolean(poa?.name?.trim()),
|
||||
email: poaVerified && Boolean(firstValidEmail(poa?.email)),
|
||||
phone: poaVerified && Boolean(firstValidPhone(poa?.phone)),
|
||||
};
|
||||
|
||||
const declaredYes = (identity?.poaDeclared ?? null) === "yes";
|
||||
const passportAccepted = identity?.passportAccepted ?? false;
|
||||
/**
|
||||
* The person exists to describe. Before a verification lands there is nothing
|
||||
* to attach details to — and asking for a name the verification is about to
|
||||
* overwrite is the trap the wizard avoids by the same rule. A foreign company
|
||||
* whose representative may hold no Fayda ID is the exception: the passport is
|
||||
* the proof, so its details are typed from the start.
|
||||
*/
|
||||
const established = declaredYes && (poaVerified || passportAccepted);
|
||||
|
||||
const requiredPoaFields: PoaField[] = [];
|
||||
if (established) {
|
||||
if (!locked.name) requiredPoaFields.push("poaName");
|
||||
if (!locked.email) requiredPoaFields.push("poaEmail");
|
||||
if (!locked.phone) requiredPoaFields.push("poaPhone");
|
||||
}
|
||||
|
||||
const defaultValues = useMemo(
|
||||
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
|
||||
(): FormData => ({
|
||||
poaName: profile.poaName ?? "",
|
||||
poaEmail: profile.poaEmail ?? "",
|
||||
poaPhone: profile.poaPhone ?? "",
|
||||
poaPassportNumber: profile.identity?.poa.passportNumber ?? "",
|
||||
poaLocation: profile.poaLocation ?? "",
|
||||
}),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
resolver: zodResolver(buildSchema(requiredPoaFields)),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
@@ -133,12 +228,6 @@ export default function TabPowerOfAttorney({
|
||||
const requirePoa = profile.companyProfiles.some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
// No company types its representative's details — they come from the Fayda
|
||||
// verification whatever the nationality, since a representative acts for the
|
||||
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||
// has been verified.
|
||||
const identity = profile.identity;
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
// Whether there is a representative at all is the company's own declaration,
|
||||
// held server-side — it decides whose identity the API gates on, so it is
|
||||
// never local state here.
|
||||
@@ -155,9 +244,17 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: FormData) => {
|
||||
// Every identity field except the city is written by the verification, so
|
||||
// only the paper and the location are ever saved here.
|
||||
const fields = { poaLocation: data.poaLocation || undefined };
|
||||
// Whatever the verification did NOT supply is this form's to save, plus
|
||||
// the paper. A field it owns is legitimately blank here (there is no
|
||||
// input), and `|| undefined` keeps that blank out of the payload — the
|
||||
// DTO's `@IsOptional()` skips null and undefined, never "".
|
||||
const fields = {
|
||||
poaName: data.poaName || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaPassportNumber: data.poaPassportNumber || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
};
|
||||
// A fresh upload already stages the removal of every paper on file, so
|
||||
// the explicit removals only need applying when no replacement was
|
||||
// picked. Saving the details after it means the API sees the new paper.
|
||||
@@ -355,15 +452,62 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{/* Name, email, phone and address all come from the Fayda
|
||||
verification and are shown on the panel above. Only a company
|
||||
whose representative may hold no Fayda ID still types a
|
||||
location. */}
|
||||
{declared === "yes" && !poaProvided && (
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
{/* Whatever the verification supplied is on the panel above; only
|
||||
what it left blank is asked for here. A foreign representative
|
||||
who holds no Fayda ID proves themselves by passport instead —
|
||||
the same either/or the onboarding wizard offers, which an
|
||||
onboarded company can no longer reach. */}
|
||||
{established && passportAccepted && !poaVerified && (
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
label="Representative's Passport Number"
|
||||
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
|
||||
placeholder="P1234567"
|
||||
error={errors.poaPassportNumber?.message}
|
||||
{...register("poaPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{established && !locked.name && (
|
||||
<TextInput
|
||||
label="Representative's Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{established && (!locked.email || !locked.phone) && (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: !locked.email && !locked.phone ? 2 : 1 }}
|
||||
spacing="md"
|
||||
>
|
||||
{!locked.email && (
|
||||
<TextInput
|
||||
label="Representative's Email"
|
||||
type="email"
|
||||
placeholder="representative@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
)}
|
||||
{!locked.phone && (
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="Representative's Phone"
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* The company's own statement of where the representative is
|
||||
based — never Fayda's `poaAddress`, which the portal does not
|
||||
send — so it stays typeable however well Fayda knows them. */}
|
||||
{established && (
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<TextInput
|
||||
label="Representative's Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCompanyProfileSchema } from "./TabCompanyProfile";
|
||||
|
||||
/**
|
||||
* The two rules this schema exists to keep in step with onboarding: what counts
|
||||
* as a VAT number, and who has to type their registration.
|
||||
*/
|
||||
const base = {
|
||||
companyName: "Acme plc",
|
||||
companyLocation: "Addis Ababa",
|
||||
companyAddress: "",
|
||||
tinNumber: "0012345678",
|
||||
vatNumber: "0012345678",
|
||||
region: "Addis Ababa",
|
||||
zone: "Bole",
|
||||
woreda: "03",
|
||||
kebele: "07",
|
||||
houseNo: "",
|
||||
};
|
||||
|
||||
describe("buildCompanyProfileSchema", () => {
|
||||
it("accepts a VAT number no Ethiopian format rule would allow", () => {
|
||||
// The whole point of reusing onboarding's rule: a foreign company's VAT is
|
||||
// its own tax authority's, and the stricter copy locked it out of the tab.
|
||||
const parsed = buildCompanyProfileSchema(false).safeParse({
|
||||
...base,
|
||||
vatNumber: "GB123456789",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("still requires a VAT number", () => {
|
||||
const parsed = buildCompanyProfileSchema(false).safeParse({
|
||||
...base,
|
||||
vatNumber: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("does not require the registration block off an eTrade company", () => {
|
||||
// It is read-only for them, so requiring it would fail a save on a field
|
||||
// with no input to fix it.
|
||||
const parsed = buildCompanyProfileSchema(false).safeParse({
|
||||
...base,
|
||||
companyName: "",
|
||||
region: "",
|
||||
zone: "",
|
||||
woreda: "",
|
||||
kebele: "",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires it of a company that types it by hand", () => {
|
||||
const parsed = buildCompanyProfileSchema(true).safeParse({
|
||||
...base,
|
||||
region: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error?.issues.map((i) => i.path[0])).toContain("region");
|
||||
});
|
||||
|
||||
it("leaves the house number optional either way", () => {
|
||||
expect(
|
||||
buildCompanyProfileSchema(true).safeParse({ ...base, houseNo: "" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -241,10 +241,18 @@ export const api = {
|
||||
nationality?: CompanyNationality;
|
||||
/** No business licence: registration typed, no eTrade lookup, no forwarding. */
|
||||
cooperative?: boolean;
|
||||
/** Foreign investment licence: registration typed, no eTrade lookup. */
|
||||
investorLicence?: boolean;
|
||||
},
|
||||
CompanyInfoResponse
|
||||
>("companies", "startOnboarding", companiesService.startOnboarding),
|
||||
|
||||
revertToRegularCompany: endpoint<void, CompanyInfoResponse>(
|
||||
"companies",
|
||||
"revertToRegularCompany",
|
||||
companiesService.revertToRegularCompany,
|
||||
),
|
||||
|
||||
setOnboardingStep: endpoint<{ step: string }, void>(
|
||||
"companies",
|
||||
"setOnboardingStep",
|
||||
|
||||
@@ -223,6 +223,8 @@ export interface OnboardingRequirements {
|
||||
nationality: string;
|
||||
/** No business licence: registration typed by hand, no eTrade lookup. */
|
||||
cooperative: boolean;
|
||||
/** Foreign investment licence: registration typed by hand, no eTrade record. */
|
||||
investorLicence: boolean;
|
||||
companyInfo: {
|
||||
complete: boolean;
|
||||
missingFields: { key: string; label: string }[];
|
||||
@@ -363,6 +365,7 @@ export const companiesService = {
|
||||
roles: ProfileTypeValue[];
|
||||
nationality?: CompanyNationality;
|
||||
cooperative?: boolean;
|
||||
investorLicence?: boolean;
|
||||
}): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
|
||||
@@ -371,6 +374,18 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Give up the foreign investment-licence route and go back through eTrade.
|
||||
* The API clears the typed registration and reopens onboarding at the company
|
||||
* step, so the caller must refresh the company info afterwards.
|
||||
*/
|
||||
revertToRegularCompany: async (): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REVERT_TO_ETRADE,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
|
||||
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface ProfileResponse {
|
||||
nationality: string | null;
|
||||
/** No business licence: the registration is typed, not fetched from eTrade. */
|
||||
cooperative: boolean;
|
||||
/** Foreign investor on an investment licence: same typed registration, no eTrade record. */
|
||||
investorLicence: boolean;
|
||||
companyProfiles: CompanyProfileResponse[];
|
||||
companyLocation: string;
|
||||
companyAddress: string | null;
|
||||
|
||||
385
e2e/freight/cypress/e2e/flows/onboarding-utils.ts
Normal file
385
e2e/freight/cypress/e2e/flows/onboarding-utils.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Shared machinery for the onboarding journeys (portal → backoffice).
|
||||
*
|
||||
* The wizard's five form steps are `company → owner → representation →
|
||||
* contact → documents`, preceded by the nationality/role phase. Three routes
|
||||
* run through them:
|
||||
*
|
||||
* ordinary eTrade answers for the TIN; the registration is read-only.
|
||||
* investor a foreign company on an Investment Commission licence — eTrade
|
||||
* holds nothing, the registration is typed, the per-role business
|
||||
* licence is still owed.
|
||||
* co-op a union or farm — eTrade holds nothing, the registration is
|
||||
* typed, and no business licence exists to ask for.
|
||||
*
|
||||
* The eTrade mock picks its answer from the TIN's leading digit (see
|
||||
* etrade-mock/server.js), so a spec chooses "verified" or "nothing on file" by
|
||||
* choosing its number — `etradeTin()` / `noLicenceTin()` / `unknownTin()`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every manual-route journey deliberately looks up a TIN eTrade holds nothing
|
||||
* for, and the API answers 400. The portal handles that outcome on screen (the
|
||||
* "nothing on file" alert is the whole point) but leaves the rejected request
|
||||
* unhandled at the promise level, and Cypress fails a test on any unhandled
|
||||
* rejection from the app. Ignored here rather than per spec: it is the expected
|
||||
* response to a request these journeys make on purpose, in every one of them.
|
||||
*
|
||||
* Narrow on purpose — only the 400. A 500, or anything else the app throws,
|
||||
* still fails the test.
|
||||
*/
|
||||
Cypress.on("uncaught:exception", (err) => {
|
||||
if (/Request failed with status code 400/.test(err.message)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const apiUrl = () => Cypress.env("apiUrl") as string;
|
||||
|
||||
export const portalUrl = () => Cypress.env("portalUrl") as string;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Field helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Fill a labelled Mantine input (label[for] → input id).
|
||||
*
|
||||
* The input is resolved fresh for every action rather than captured once.
|
||||
* Each wizard step persists and re-seeds asynchronously, and when a field
|
||||
* remounts Mantine mints a NEW generated id — so a subject captured a command
|
||||
* earlier can already be stale. Going label → for → element each time always
|
||||
* addresses what is on the page now.
|
||||
*/
|
||||
export function fill(label: string | RegExp, value: string) {
|
||||
const input = () =>
|
||||
cy
|
||||
.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => cy.get(`[id="${id}"]`));
|
||||
|
||||
input().clear({ force: true });
|
||||
input().type(value, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an input that has no <label> — the company step renders TIN and VAT
|
||||
* inside StepSection cards (the heading is the card's title, not a label), and
|
||||
* both share the "0012345678" placeholder, so they are addressable only by
|
||||
* aria-label.
|
||||
*/
|
||||
export function fillAria(ariaSelector: string, value: string) {
|
||||
const selector = `.mantine-Modal-content ${ariaSelector}`;
|
||||
cy.get(selector).clear({ force: true });
|
||||
cy.get(selector).type(value, { force: true });
|
||||
}
|
||||
|
||||
/** The wizard's phone inputs (react-phone-number-input, type=tel). */
|
||||
export function fillPhone(index: number, national: string) {
|
||||
const selector = '.mantine-Modal-content input[type="tel"]';
|
||||
cy.get(selector).eq(index).clear({ force: true });
|
||||
cy.get(selector).eq(index).type(national, { force: true });
|
||||
}
|
||||
|
||||
/** The wizard's own Continue / Submit button (never the page's). */
|
||||
export function wizardClick(label: string | RegExp) {
|
||||
cy.get(".mantine-Modal-content").contains("button", label).click();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Identities */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** A TIN eTrade answers for: registration + one business licence. */
|
||||
export const etradeTin = (stamp: number) => `1${String(stamp).slice(-9)}`;
|
||||
/** A TIN eTrade knows, but which holds no trade licence (co-op / investor). */
|
||||
export const noLicenceTin = (stamp: number) => `9${String(stamp).slice(-9)}`;
|
||||
/** A TIN eTrade has never heard of at all. */
|
||||
export const unknownTin = (stamp: number) => `8${String(stamp).slice(-9)}`;
|
||||
/** VAT is 10–11 digits and must not collide with the TIN. */
|
||||
export const vatNumber = (stamp: number) => `2${String(stamp + 7).slice(-9)}`;
|
||||
|
||||
export const SIGNUP_PASSWORD = "Password@e2e1";
|
||||
|
||||
export interface Signup {
|
||||
email: string;
|
||||
/** Ethiopian mobile, national format (9 + 8 digits). */
|
||||
phoneNational: string;
|
||||
}
|
||||
|
||||
/** A unique signup identity for this run, namespaced per journey. */
|
||||
export function signupIdentity(prefix: string, stamp: number): Signup {
|
||||
return {
|
||||
email: `e2e.${prefix}.${stamp}@example.com`,
|
||||
phoneNational: `9${String(stamp).slice(-8)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Journey steps */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* /signup → OTP (read from the DB; delivery is off in e2e) → signed in on
|
||||
* /portal with the onboarding wizard already open on the nationality step.
|
||||
*/
|
||||
export function signupCustomer(who: Signup, firstName = "Onboard") {
|
||||
cy.visit(`${portalUrl()}/signup`);
|
||||
|
||||
fill(/^First name/, firstName);
|
||||
fill(/^Last name/, "Tester");
|
||||
fill(/^Email/, who.email);
|
||||
cy.get('input[type="tel"]').first().type(who.phoneNational, { force: true });
|
||||
fill(/^Password/, SIGNUP_PASSWORD);
|
||||
fill(/^Confirm password/, SIGNUP_PASSWORD);
|
||||
cy.contains("button", "Continue").click();
|
||||
|
||||
cy.contains("Verify", { timeout: 15000 }).should("be.visible");
|
||||
cy.getOtp(who.email).then((otp) => cy.typeOtp(otp));
|
||||
cy.contains("button", "Verify & create account").click();
|
||||
|
||||
cy.location("pathname", { timeout: 20000 }).should("eq", "/portal");
|
||||
cy.contains("Where is your company registered?", { timeout: 15000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the "Registration details" block the manual routes type by hand. Region
|
||||
* is a Mantine Select; the rest are plain inputs.
|
||||
*/
|
||||
export function typeRegistration(companyName: string) {
|
||||
fill(/^Company Name/, companyName);
|
||||
cy.mantineSelect("Region", "Addis Ababa");
|
||||
fill(/^Zone/, "Zone 1");
|
||||
fill(/^Woreda/, "Woreda 1");
|
||||
fill(/^Kebele/, "Kebele 1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a PDF to the next empty dropzone on the documents step.
|
||||
*
|
||||
* Not positional: SmartFileInput swaps its dropzone for the file row once
|
||||
* something is attached, so the input disappears from the DOM and every later
|
||||
* index shifts under you. "The first one still asking for a file" is the only
|
||||
* stable address, and it walks the step in render order — company documents
|
||||
* first, then one card per operational profile.
|
||||
*/
|
||||
export function attachNextFile() {
|
||||
cy.get('.mantine-Modal-content input[type="file"]')
|
||||
.first()
|
||||
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the whole investor wizard, signup included, and submit it.
|
||||
*
|
||||
* Shared because two specs need the same customer sitting on the far side of
|
||||
* onboarding: the one that is about the journey, and the one that is about
|
||||
* what happens afterwards. Driving the real UI rather than posting the
|
||||
* equivalent API calls keeps the second spec honest — it starts from a company
|
||||
* the product itself produced.
|
||||
*
|
||||
* `beforeSubmit` runs on the documents step, with everything attached and the
|
||||
* submit button still unpressed: the one place a caller can assert about a
|
||||
* state that does not survive submission.
|
||||
*/
|
||||
export function completeInvestorOnboarding(
|
||||
who: Signup,
|
||||
stamp: number,
|
||||
opts: { companyName?: string; beforeSubmit?: () => void } = {},
|
||||
) {
|
||||
const companyName = opts.companyName ?? `E2E Investor Holdings ${stamp}`;
|
||||
|
||||
signupCustomer(who, "Investor");
|
||||
|
||||
cy.contains("button", "Foreign Company").click();
|
||||
cy.contains("We operate on a foreign investment licence").click();
|
||||
cy.contains("button", "Importer").click();
|
||||
wizardClick("Continue");
|
||||
|
||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
|
||||
cy.contains("Nothing on file at eTrade for this TIN", {
|
||||
timeout: 20000,
|
||||
}).should("be.visible");
|
||||
typeRegistration(companyName);
|
||||
fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
|
||||
wizardClick("Continue");
|
||||
|
||||
// No licence, so no eTrade manager: every owner field is typed, and the
|
||||
// "your licence listed no manager" hint has no business appearing.
|
||||
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
|
||||
cy.contains("Your eTrade licence didn't list a manager").should("not.exist");
|
||||
fill(/^Owner's Name/, "Investor Owner");
|
||||
fill(/^Owner's Email/, `owner.${stamp}@example.com`);
|
||||
fillPhone(0, "911234570");
|
||||
wizardClick("Continue");
|
||||
|
||||
cy.contains("Does anyone hold power of attorney for this company?", {
|
||||
timeout: 20000,
|
||||
}).should("be.visible");
|
||||
cy.contains("button", "No, the owner acts for us").click();
|
||||
// Identity is not proven yet, so the step must hold. Asserted as "we did not
|
||||
// advance" rather than on the alert text: answering the declaration refetches
|
||||
// the profile, which re-seeds the form, and the form's watch subscription
|
||||
// clears the alert on any value change — so the message is real but lives for
|
||||
// a few milliseconds.
|
||||
wizardClick("Continue");
|
||||
cy.contains("Who Acts For You").should("be.visible");
|
||||
cy.contains("Contact Person").should("not.exist");
|
||||
// The alternative a foreign company gets and an Ethiopian one does not.
|
||||
cy.contains("Use a passport instead").click();
|
||||
fill(/Passport Number/, `P${String(stamp).slice(-7)}`);
|
||||
wizardClick("Continue");
|
||||
|
||||
cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Name$/, "Investor Contact");
|
||||
fillPhone(0, "911234571");
|
||||
wizardClick("Continue");
|
||||
|
||||
cy.contains("Upload Importer Business license file(s)", {
|
||||
timeout: 20000,
|
||||
}).should("be.visible");
|
||||
attachNextFile();
|
||||
attachNextFile();
|
||||
opts.beforeSubmit?.();
|
||||
wizardClick("Submit for review");
|
||||
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Database + API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface CompanyRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
nationality: string | null;
|
||||
attributes: Record<string, unknown> | null;
|
||||
licence_number: string | null;
|
||||
region: string | null;
|
||||
etrade_phone: string | null;
|
||||
onboarding_step: string | null;
|
||||
onboarding_completed: boolean;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const COMPANY_SQL = `
|
||||
SELECT c.id, c.name, c.status, c.nationality, c.attributes,
|
||||
c.licence_number, c.region, c.etrade_phone,
|
||||
ep.onboarding_step, ep.onboarding_completed, u.email
|
||||
FROM freight.companies c
|
||||
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
||||
JOIN iam.users u ON u.id = ep.user_id`;
|
||||
|
||||
/** The company behind one signup email. */
|
||||
export function companyByEmail(email: string) {
|
||||
return cy
|
||||
.task<{ rows: CompanyRow[] }>("db:query", {
|
||||
sql: `${COMPANY_SQL} WHERE u.email = $1`,
|
||||
params: [email],
|
||||
})
|
||||
.then(({ rows }) => {
|
||||
expect(rows, `company for ${email}`).to.have.length(1);
|
||||
return cy.wrap(rows[0], { log: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent journey of one kind, resolved from the DB rather than from
|
||||
* module state: Cypress re-evaluates the spec bundle on every cross-origin
|
||||
* visit, so anything held in a module variable is gone by the backoffice test.
|
||||
*/
|
||||
export function latestJourney(emailPrefix: string) {
|
||||
// Scoped to THIS cypress run. `run:stamp` is minted by the node plugin, which
|
||||
// outlives the bundle re-evaluation a cross-origin visit causes — unlike
|
||||
// anything held in module state. Without the cutoff a journey that failed
|
||||
// mid-way would silently hand the backoffice tests a previous run's company
|
||||
// and pass on it.
|
||||
return cy.task<string>("run:stamp", null, { log: false }).then((runStamp) =>
|
||||
cy
|
||||
.task<{ rows: CompanyRow[] }>("db:query", {
|
||||
sql: `${COMPANY_SQL}
|
||||
WHERE u.email LIKE $1
|
||||
AND c.created_at >= to_timestamp($2::bigint / 1000.0)
|
||||
ORDER BY c.created_at DESC LIMIT 1`,
|
||||
params: [`e2e.${emailPrefix}.%`, runStamp],
|
||||
})
|
||||
.then(({ rows }) => {
|
||||
expect(rows, `journey for e2e.${emailPrefix}.* in this run`).to.have.length(
|
||||
1,
|
||||
);
|
||||
return cy.wrap(rows[0], { log: false });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** A portal access token for a customer account. */
|
||||
export function portalToken(email: string, password = SIGNUP_PASSWORD) {
|
||||
return cy.apiLogin(email, password, "portal").then((body) => body.token);
|
||||
}
|
||||
|
||||
/** cy.request against the freight API with a bearer token. */
|
||||
export function apiRequest(
|
||||
token: string,
|
||||
method: "GET" | "POST" | "PATCH",
|
||||
path: string,
|
||||
body?: Record<string, unknown>,
|
||||
failOnStatusCode = true,
|
||||
) {
|
||||
return cy.request({
|
||||
method,
|
||||
url: `${apiUrl()}${path}`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body,
|
||||
failOnStatusCode,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Backoffice */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Open a customer's detail page from the backoffice list, by company name. */
|
||||
export function openCustomer(companyName: string) {
|
||||
cy.visit("/dashboard/customers");
|
||||
cy.get('input[placeholder*="Search by company"]').type(companyName);
|
||||
cy.contains(companyName, { timeout: 15000 }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the pending role profile on the open customer page. Once active the
|
||||
* row's action flips to "Suspend", which is what proves the write landed.
|
||||
*/
|
||||
export function approveFirstProfile() {
|
||||
cy.contains("button", "Approve", { timeout: 15000 }).click();
|
||||
cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible");
|
||||
}
|
||||
|
||||
/** Assert the role profile went active and the company with it. */
|
||||
export function expectProfileActive(companyName: string, type = "importer") {
|
||||
return cy
|
||||
.task<{
|
||||
rows: Array<{
|
||||
status: string;
|
||||
reference: string | null;
|
||||
company_status: string;
|
||||
}>;
|
||||
}>("db:query", {
|
||||
sql: `SELECT p.status, p.reference, c.status AS company_status
|
||||
FROM freight.company_profiles p
|
||||
JOIN freight.companies c ON c.id = p.company_id
|
||||
WHERE c.name = $1 AND p.type = $2`,
|
||||
params: [companyName, type],
|
||||
})
|
||||
.then(({ rows }) => {
|
||||
expect(rows, `${type} profile`).to.have.length(1);
|
||||
expect(rows[0].status).to.eq("active");
|
||||
expect(rows[0].reference, "minted reference").to.be.a("string").and.not.be
|
||||
.empty;
|
||||
expect(rows[0].company_status).to.eq("active");
|
||||
});
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* Full customer onboarding journey, both apps:
|
||||
*
|
||||
* 1. portal — /signup form → OTP (read from DB, delivery is off in e2e)
|
||||
* → account created → onboarding wizard (nationality/role →
|
||||
* company → personnel → contact → PoA → documents incl. the
|
||||
* per-role business license) → "Submit for review"
|
||||
* 2. backoffice — staff (chief, holds edr_freight_app:admin) approves the
|
||||
* importer profile on /dashboard/customers/:id
|
||||
* 3. portal — the new customer is active: contract wizard reachable
|
||||
*
|
||||
* Tests are sequential steps of ONE journey (fresh unique user per run), so
|
||||
* retries are disabled — a mid-journey retry would replay a non-idempotent
|
||||
* step against already-advanced state.
|
||||
*
|
||||
* NOTE: switching origin between tests (portal 5373 ↔ backoffice 5383)
|
||||
* reloads the spec bundle and resets module state — later tests resolve the
|
||||
* journey's user/company from the DB instead of module variables.
|
||||
*/
|
||||
|
||||
import { completeFaydaVerification } from "./import-utils";
|
||||
|
||||
const stamp = Date.now();
|
||||
const email = `e2e.onboard.${stamp}@example.com`;
|
||||
// Ethiopian mobile: 9 + 8 digits, unique per run.
|
||||
const phoneNational = `9${String(stamp).slice(-8)}`;
|
||||
const signupPassword = "Password@e2e1";
|
||||
const tin = String(stamp).slice(-10).padStart(10, "1");
|
||||
const vat = String(stamp + 1).slice(-10).padStart(10, "2");
|
||||
|
||||
const portal = () => Cypress.env("portalUrl") as string;
|
||||
|
||||
/** The journey's company/user = the latest e2e.onboard.* signup in the DB. */
|
||||
function latestOnboardJourney() {
|
||||
return cy.task<{ rows: Array<{ name: string; email: string }> }>("db:query", {
|
||||
sql: `SELECT c.name, u.email
|
||||
FROM freight.companies c
|
||||
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
||||
JOIN iam.users u ON u.id = ep.user_id
|
||||
WHERE u.email LIKE 'e2e.onboard.%'
|
||||
ORDER BY c.created_at DESC LIMIT 1`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a labelled Mantine input (label[for] → input id).
|
||||
*
|
||||
* The input is resolved fresh for every action rather than captured once.
|
||||
* Each wizard step persists and re-seeds asynchronously, and when a field
|
||||
* remounts Mantine mints a NEW generated id — so both a subject and an id
|
||||
* captured a command earlier can be stale by the time the next command runs.
|
||||
* Going label → for → element each time always addresses what's on the page
|
||||
* now.
|
||||
*/
|
||||
function fill(label: string | RegExp, value: string) {
|
||||
const input = () =>
|
||||
cy
|
||||
.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => cy.get(`[id="${id}"]`));
|
||||
|
||||
input().clear({ force: true });
|
||||
input().type(value, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an input that has no <label> — the wizard's company step renders its
|
||||
* fields inside StepSection cards (the heading is the card's title, not a
|
||||
* label), so they're reachable only by aria-label. Both TIN and VAT share the
|
||||
* "0012345678" placeholder, which is why this matches on aria-label instead.
|
||||
*/
|
||||
function fillAria(ariaSelector: string, value: string) {
|
||||
const selector = `.mantine-Modal-content ${ariaSelector}`;
|
||||
cy.get(selector).clear({ force: true });
|
||||
cy.get(selector).type(value, { force: true });
|
||||
}
|
||||
|
||||
/** The wizard's phone inputs (react-phone-number-input, type=tel). */
|
||||
function fillPhone(index: number, national: string) {
|
||||
const selector = '.mantine-Modal-content input[type="tel"]';
|
||||
cy.get(selector).eq(index).clear({ force: true });
|
||||
cy.get(selector).eq(index).type(national, { force: true });
|
||||
}
|
||||
|
||||
describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
it("signs up with OTP and completes the onboarding wizard", () => {
|
||||
cy.visit(`${portal()}/signup`);
|
||||
|
||||
fill(/^First name/, "Onboard");
|
||||
fill(/^Last name/, "Tester");
|
||||
fill(/^Email/, email);
|
||||
cy.get('input[type="tel"]').first().type(phoneNational, { force: true });
|
||||
fill(/^Password/, signupPassword);
|
||||
fill(/^Confirm password/, signupPassword);
|
||||
cy.contains("button", "Continue").click();
|
||||
|
||||
// OTP stage — the code is generated + stored even though delivery is off.
|
||||
cy.contains("Verify", { timeout: 15000 }).should("be.visible");
|
||||
cy.getOtp(email).then((otp) => cy.typeOtp(otp));
|
||||
cy.contains("button", "Verify & create account").click();
|
||||
|
||||
// Signed in → /portal → wizard auto-opens on the nationality/role step.
|
||||
cy.location("pathname", { timeout: 20000 }).should("eq", "/portal");
|
||||
cy.contains("Where is your company registered?", { timeout: 15000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.contains("button", "Ethiopian Company").click();
|
||||
cy.contains("button", "Importer").click();
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Ethiopian companies gate the "Owner identity" step on Fayda
|
||||
// verification — a real eSignet redirect + SMS OTP flow that can't run in
|
||||
// e2e. Complete it via the API against fayda-mock-e2e (the profile this
|
||||
// attaches to was just created by the nationality/role step above).
|
||||
// The wizard already fetched `identity` once when this step mounted, and
|
||||
// completing verification out-of-band skips the redirect that would
|
||||
// normally remount everything — so reload to force a fresh fetch. Wizard
|
||||
// progress resumes server-side, so this doesn't lose the nationality/role
|
||||
// step just completed.
|
||||
completeFaydaVerification("owner");
|
||||
cy.reload();
|
||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
// The owner's identity is the verification's output, never typed: the
|
||||
// panel must show it verified and render the name/phone/email that came
|
||||
// back from fayda-mock-e2e. Asserting the mock's own values is the only
|
||||
// way to prove the payload travelled Fayda → API → UI rather than the
|
||||
// panel simply flipping a "verified" flag.
|
||||
cy.get(".mantine-Modal-content").within(() => {
|
||||
cy.contains("Fayda verified").should("be.visible");
|
||||
cy.contains("Abebe Bekele").should("be.visible");
|
||||
cy.contains("+251911223344").should("be.visible");
|
||||
cy.contains("abebe.bekele@example.com").should("be.visible");
|
||||
});
|
||||
|
||||
// The verified sub is what locks the owner's fields server-side, so check
|
||||
// it actually landed on the company rather than trusting the panel alone.
|
||||
cy.task<{ rows: Array<{ owner_fayda_sub: string | null }> }>("db:query", {
|
||||
sql: `SELECT c.attributes->>'ownerFaydaSub' AS owner_fayda_sub
|
||||
FROM freight.companies c
|
||||
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
||||
JOIN iam.users u ON u.id = ep.user_id
|
||||
WHERE u.email = $1`,
|
||||
params: [email],
|
||||
}).then(({ rows }) => {
|
||||
expect(rows, "company row").to.have.length(1);
|
||||
expect(rows[0].owner_fayda_sub, "owner Fayda sub").to.eq(
|
||||
"e2e-fayda-sub-0001",
|
||||
);
|
||||
});
|
||||
|
||||
// Company step. TIN auto-triggers the eTrade lookup once it's a full 10
|
||||
// digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
|
||||
// A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
|
||||
// No as read-only (ETradeCompanyCard) — nothing left to type there, and
|
||||
// Company Email/Phone/Location were dropped from this step entirely (the
|
||||
// Fayda-verified owner supplies contact details now). By label, not
|
||||
// placeholder: the VAT Number field on this same step shares the TIN
|
||||
// field's "0012345678" placeholder, so a placeholder selector matches 2.
|
||||
fillAria('[aria-label^="TIN Number"]', tin);
|
||||
cy.contains("Verified with eTrade", { timeout: 15000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
// handleETradeDataLoaded sets several fields in sequence (name, region,
|
||||
// zone, woreda, kebele, houseNo) — each a render, still settling right
|
||||
// after the badge appears. Typing into VAT immediately raced one of
|
||||
// those and detached mid-type; let it finish before touching the form.
|
||||
cy.wait(500);
|
||||
fillAria('[aria-label="VAT Number"]', vat);
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Personnel (general manager).
|
||||
fill(/^Name/, "General Manager");
|
||||
fill(/^Email/, `gm.${stamp}@example.com`);
|
||||
fillPhone(0, "911234568");
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Contact person.
|
||||
fill(/^Name/, "Contact Person");
|
||||
fillPhone(0, "911234569");
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// PoA — optional for an importer, and left unverified here. The DARS
|
||||
// delegation paper authorises the representative the verification names,
|
||||
// so with no verified PoA there is nothing for it to authorise: the
|
||||
// upload must not be offered, and the step must not block on it. Asserted
|
||||
// as "no file input on this step" rather than by label, so a reworded
|
||||
// document setting doesn't turn a real regression into a passing test.
|
||||
cy.get(".mantine-Modal-content")
|
||||
.contains("Power of Attorney")
|
||||
.should("be.visible");
|
||||
cy.get('.mantine-Modal-content input[type="file"]').should("not.exist");
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Documents: no company docs are configured in e2e, but every role needs
|
||||
// a business license.
|
||||
cy.contains("Business license", { timeout: 15000 }).should("be.visible");
|
||||
cy.get('.mantine-Modal-content input[type="file"]')
|
||||
.first()
|
||||
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
|
||||
cy.get(".mantine-Modal-content")
|
||||
.contains("button", "Submit for review")
|
||||
.click();
|
||||
|
||||
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
|
||||
|
||||
// DB cross-check: submitted, awaiting approval.
|
||||
cy.task<{ rows: Array<{ status: string; onboarding_completed: boolean }> }>(
|
||||
"db:query",
|
||||
{
|
||||
sql: `SELECT c.status, ep.onboarding_completed
|
||||
FROM freight.companies c
|
||||
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
||||
JOIN iam.users u ON u.id = ep.user_id
|
||||
WHERE u.email = $1`,
|
||||
params: [email],
|
||||
},
|
||||
).then(({ rows }) => {
|
||||
expect(rows, "company row").to.have.length(1);
|
||||
expect(rows[0].status).to.eq("pending");
|
||||
expect(rows[0].onboarding_completed).to.eq(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("backoffice staff approves the submitted importer profile", () => {
|
||||
cy.loginBackoffice("chief@edr.local");
|
||||
cy.visit("/dashboard/customers");
|
||||
|
||||
latestOnboardJourney().then(({ rows }) => {
|
||||
expect(rows, "onboarded company").to.have.length(1);
|
||||
const company = rows[0].name;
|
||||
|
||||
cy.get('input[placeholder*="Search by company"]').type(company);
|
||||
cy.contains(company, { timeout: 15000 }).click();
|
||||
|
||||
// Role profiles table → approve the pending importer profile. Once
|
||||
// active, the row's action flips to "Suspend".
|
||||
cy.contains("button", "Approve", { timeout: 15000 }).click();
|
||||
cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible");
|
||||
|
||||
cy.task<{ rows: Array<{ status: string; reference: string | null; company_status: string }> }>(
|
||||
"db:query",
|
||||
{
|
||||
sql: `SELECT p.status, p.reference, c.status AS company_status
|
||||
FROM freight.company_profiles p
|
||||
JOIN freight.companies c ON c.id = p.company_id
|
||||
WHERE c.name = $1 AND p.type = 'importer'`,
|
||||
params: [company],
|
||||
},
|
||||
).then(({ rows: profiles }) => {
|
||||
expect(profiles, "importer profile").to.have.length(1);
|
||||
expect(profiles[0].status).to.eq("active");
|
||||
expect(profiles[0].reference, "minted reference").to.be.a("string").and
|
||||
.not.be.empty;
|
||||
expect(profiles[0].company_status).to.eq("active");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("the approved customer can reach the contract wizard", () => {
|
||||
latestOnboardJourney().then(({ rows }) => {
|
||||
cy.loginPortal(rows[0].email, signupPassword);
|
||||
});
|
||||
cy.visitPortal("/contracts/new");
|
||||
|
||||
// No "Awaiting Approval" gate — the wizard's first step renders.
|
||||
cy.contains("label", "Operation Type", { timeout: 15000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.contains("Awaiting Approval").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
161
e2e/freight/cypress/e2e/flows/onboarding_cooperative.cy.ts
Normal file
161
e2e/freight/cypress/e2e/flows/onboarding_cooperative.cy.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* A co-operative union or farm: a TIN, but no business licence at all.
|
||||
*
|
||||
* It reaches the same typed-registration route as a foreign investor and the
|
||||
* same backoffice flag, and differs from it in three ways the wizard has to
|
||||
* enforce rather than merely explain — it is always Ethiopian, it cannot hold
|
||||
* the freight-forwarder role, and it owes no per-role business licence. Its own
|
||||
* document set stands in for the trade licence.
|
||||
*
|
||||
* One journey across both apps; retries off (see onboarding_ethiopian.cy.ts).
|
||||
*/
|
||||
|
||||
import { completeFaydaVerification } from "./import-utils";
|
||||
import {
|
||||
apiRequest,
|
||||
attachNextFile,
|
||||
companyByEmail,
|
||||
fill,
|
||||
fillAria,
|
||||
fillPhone,
|
||||
latestJourney,
|
||||
noLicenceTin,
|
||||
openCustomer,
|
||||
portalToken,
|
||||
signupCustomer,
|
||||
signupIdentity,
|
||||
typeRegistration,
|
||||
vatNumber,
|
||||
wizardClick,
|
||||
} from "./onboarding-utils";
|
||||
|
||||
const stamp = Date.now();
|
||||
const who = signupIdentity("coop", stamp);
|
||||
|
||||
describe("onboarding — co-operative union or farm", { retries: 0 }, () => {
|
||||
it("types its registration, owes no business licence, and submits", () => {
|
||||
signupCustomer(who, "Cooperative");
|
||||
|
||||
// Before the box: both nationalities and all three roles are on offer.
|
||||
cy.contains("button", "Foreign Company").should("be.visible");
|
||||
cy.contains("button", "Freight Forwarder").should("be.visible");
|
||||
|
||||
cy.contains("We're a co-operative union or farm").click();
|
||||
|
||||
// A co-op is registered in Ethiopia by the co-operative promotion agency,
|
||||
// holds no licence, and cannot forward freight — so none of those choices
|
||||
// are offered rather than refused later by the API.
|
||||
cy.contains("button", "Foreign Company").should("not.exist");
|
||||
cy.contains("button", "Freight Forwarder").should("not.exist");
|
||||
cy.contains("We operate on a foreign investment licence").should("not.exist");
|
||||
|
||||
cy.contains("button", "Importer").click();
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Company step ────────────────────────────────────────────────────
|
||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
|
||||
cy.contains("Nothing on file at eTrade for this TIN", {
|
||||
timeout: 20000,
|
||||
}).should("be.visible");
|
||||
|
||||
typeRegistration(`E2E Farmers Union ${stamp}`);
|
||||
fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Owner step ──────────────────────────────────────────────────────
|
||||
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Owner's Name/, "Union Chairperson");
|
||||
fill(/^Owner's Email/, `chair.${stamp}@example.com`);
|
||||
fillPhone(0, "911234572");
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Representation step ─────────────────────────────────────────────
|
||||
cy.contains("Does anyone hold power of attorney for this company?", {
|
||||
timeout: 20000,
|
||||
}).should("be.visible");
|
||||
cy.contains("button", "No, the owner acts for us").click();
|
||||
|
||||
// Ethiopian, so Fayda is the only route — the passport alternative belongs
|
||||
// to foreign companies.
|
||||
cy.contains("Use a passport instead").should("not.exist");
|
||||
completeFaydaVerification("owner");
|
||||
cy.reload();
|
||||
cy.get(".mantine-Modal-content", { timeout: 30000 }).within(() => {
|
||||
cy.contains("Fayda verified").should("be.visible");
|
||||
});
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Contact step ────────────────────────────────────────────────────
|
||||
cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Name$/, "Union Contact");
|
||||
fillPhone(0, "911234573");
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Documents step ──────────────────────────────────────────────────
|
||||
// The co-operative set replaces the nationality one, and the per-role
|
||||
// licence cards are not rendered at all — offering a slot nothing can fill
|
||||
// reads as an unfinishable step.
|
||||
cy.contains("Upload Documents", { timeout: 20000 }).should("be.visible");
|
||||
cy.contains("Upload Importer Business license file(s)").should("not.exist");
|
||||
cy.get('.mantine-Modal-content input[type="file"]').should(
|
||||
"have.length",
|
||||
1,
|
||||
);
|
||||
|
||||
portalToken(who.email).then((token) =>
|
||||
apiRequest(token, "GET", "/api/companies/onboarding/requirements").then(
|
||||
(res) => {
|
||||
expect(res.body.data.documentSettingCode).to.eq(
|
||||
"company_onboarding_documents_cooperative",
|
||||
);
|
||||
expect(res.body.data.cooperative).to.eq(true);
|
||||
expect(res.body.data.investorLicence).to.eq(false);
|
||||
// The requirement is lifted, not merely hidden on screen.
|
||||
const outstanding: string[] = res.body.data.outstanding;
|
||||
expect(
|
||||
outstanding.filter((o) => /business license/i.test(o)),
|
||||
"no licence is owed",
|
||||
).to.have.length(0);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
attachNextFile();
|
||||
wizardClick("Submit for review");
|
||||
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
|
||||
|
||||
companyByEmail(who.email).then((company) => {
|
||||
expect(company.status).to.eq("pending");
|
||||
expect(company.onboarding_completed).to.eq(true);
|
||||
expect(company.nationality).to.eq("ethiopian");
|
||||
expect((company.attributes ?? {})["cooperative"]).to.eq(true);
|
||||
expect((company.attributes ?? {})["investorLicence"]).to.be.undefined;
|
||||
expect(company.licence_number, "no eTrade licence").to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
it("the backoffice flags it as a co-operative", () => {
|
||||
cy.loginBackoffice("chief@edr.local");
|
||||
|
||||
latestJourney("coop").then((company) => {
|
||||
openCustomer(company.name);
|
||||
|
||||
cy.contains("Manual entry · co-operative").should("be.visible");
|
||||
cy.contains("Registration entered by hand — not verified against eTrade")
|
||||
.should("be.visible");
|
||||
cy.contains(
|
||||
"Check them against the Co-operative Registration Certificate",
|
||||
).should("be.visible");
|
||||
cy.contains("Co-operative union / farm (no trade licence)").should(
|
||||
"be.visible",
|
||||
);
|
||||
// The two manual routes must not be confused with one another.
|
||||
cy.contains("Manual entry · investment licence").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
180
e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts
Normal file
180
e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* The ordinary onboarding journey, both apps — the baseline every other
|
||||
* onboarding spec is a deviation from:
|
||||
*
|
||||
* 1. portal signup + OTP → nationality/role → company (eTrade lookup) →
|
||||
* owner → representation (Fayda) → contact → documents →
|
||||
* "Submit for review"
|
||||
* 2. backoffice staff approve the importer profile; the customer carries NO
|
||||
* manual-entry flag, because eTrade answered for its TIN
|
||||
* 3. portal the approved customer reaches the contract wizard
|
||||
*
|
||||
* Sequential steps of ONE journey, so retries are off — a mid-journey retry
|
||||
* would replay a non-idempotent step against already-advanced state. Switching
|
||||
* origin between tests (portal ↔ backoffice) re-evaluates the spec bundle and
|
||||
* wipes module state, so the later tests resolve the journey from the DB.
|
||||
*/
|
||||
|
||||
import { completeFaydaVerification } from "./import-utils";
|
||||
import {
|
||||
attachNextFile,
|
||||
companyByEmail,
|
||||
etradeTin,
|
||||
expectProfileActive,
|
||||
fill,
|
||||
fillAria,
|
||||
fillPhone,
|
||||
latestJourney,
|
||||
noLicenceTin,
|
||||
openCustomer,
|
||||
approveFirstProfile,
|
||||
signupCustomer,
|
||||
signupIdentity,
|
||||
vatNumber,
|
||||
wizardClick,
|
||||
SIGNUP_PASSWORD,
|
||||
} from "./onboarding-utils";
|
||||
|
||||
const stamp = Date.now();
|
||||
const who = signupIdentity("ethiopian", stamp);
|
||||
|
||||
describe("onboarding — Ethiopian company, eTrade verified", { retries: 0 }, () => {
|
||||
it("completes the wizard and submits for review", () => {
|
||||
signupCustomer(who, "Ethiopian");
|
||||
|
||||
cy.contains("button", "Ethiopian Company").click();
|
||||
// An Ethiopian company is never offered the investment licence — that is a
|
||||
// foreign company's document, and the API refuses the pair.
|
||||
cy.contains("We operate on a foreign investment licence").should("not.exist");
|
||||
cy.contains("button", "Importer").click();
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Company step ────────────────────────────────────────────────────
|
||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
// A TIN eTrade knows but which holds no trade licence is a dead end for an
|
||||
// ordinary company: the alert is red, and Continue must refuse rather than
|
||||
// carry unverified registration data forward.
|
||||
fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
|
||||
cy.contains("No matching business record", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
wizardClick("Continue");
|
||||
cy.contains("We need to confirm your TIN with eTrade before continuing.").should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
// The real one. A successful lookup fills and locks company name, region,
|
||||
// zone, woreda, kebele and house number (ETradeCompanyCard).
|
||||
fillAria('[aria-label^="TIN Number"]', etradeTin(stamp));
|
||||
cy.contains("Verified with eTrade", { timeout: 20000 }).should("be.visible");
|
||||
// handleETradeDataLoaded sets a dozen fields in sequence — each a render.
|
||||
// Typing into VAT immediately races one of those and detaches mid-type.
|
||||
cy.wait(500);
|
||||
fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Owner step ──────────────────────────────────────────────────────
|
||||
// Name and phone came from the licence and are read-only; eTrade carries
|
||||
// no email, so that one is asked for.
|
||||
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Owner's Email/, `owner.${stamp}@example.com`);
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Representation step ─────────────────────────────────────────────
|
||||
cy.contains("Does anyone hold power of attorney for this company?", {
|
||||
timeout: 20000,
|
||||
}).should("be.visible");
|
||||
cy.contains("button", "No, the owner acts for us").click();
|
||||
|
||||
// An Ethiopian company has no passport alternative — Fayda or nothing.
|
||||
cy.contains("Use a passport instead").should("not.exist");
|
||||
// A real eSignet redirect + SMS OTP can't run in e2e, so complete it
|
||||
// against fayda-mock-e2e via the API. The step already fetched `identity`
|
||||
// when it mounted, and completing out-of-band skips the redirect that
|
||||
// would remount everything — reload to force a fresh fetch. Wizard
|
||||
// progress is server-side, so nothing already answered is lost.
|
||||
completeFaydaVerification("owner");
|
||||
cy.reload();
|
||||
|
||||
cy.get(".mantine-Modal-content", { timeout: 30000 }).within(() => {
|
||||
cy.contains("Fayda verified").should("be.visible");
|
||||
// The mock's own payload — proof it travelled Fayda → API → UI rather
|
||||
// than a flag simply flipping.
|
||||
cy.contains("Abebe Bekele").should("be.visible");
|
||||
});
|
||||
|
||||
// The verified sub is what locks the owner's fields server-side.
|
||||
companyByEmail(who.email).then((company) => {
|
||||
expect(
|
||||
(company.attributes ?? {})["ownerFaydaSub"],
|
||||
"owner Fayda sub",
|
||||
).to.eq("e2e-fayda-sub-0001");
|
||||
});
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Contact step ────────────────────────────────────────────────────
|
||||
cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Name$/, "Contact Person");
|
||||
fillPhone(0, "911234569");
|
||||
wizardClick("Continue");
|
||||
|
||||
// ── Documents step ──────────────────────────────────────────────────
|
||||
// One company document is seeded per set on a fresh e2e database
|
||||
// (FileUploadSettingsSeeder gives a new set exactly its first field), and
|
||||
// every operational profile owes a business licence.
|
||||
cy.contains("Upload Importer Business license file(s)", {
|
||||
timeout: 20000,
|
||||
}).should("be.visible");
|
||||
attachNextFile();
|
||||
attachNextFile();
|
||||
wizardClick("Submit for review");
|
||||
|
||||
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
|
||||
|
||||
companyByEmail(who.email).then((company) => {
|
||||
expect(company.status).to.eq("pending");
|
||||
expect(company.onboarding_completed).to.eq(true);
|
||||
expect(company.nationality).to.eq("ethiopian");
|
||||
// eTrade answered, so neither manual-entry flag is set and the licence
|
||||
// it returned is on file.
|
||||
const attributes = company.attributes ?? {};
|
||||
expect(attributes["investorLicence"]).to.be.undefined;
|
||||
expect(attributes["cooperative"]).to.be.undefined;
|
||||
expect(company.licence_number, "eTrade licence").to.eq("LIC-E2E-0001");
|
||||
});
|
||||
});
|
||||
|
||||
it("backoffice approves it, with no manual-entry flag anywhere", () => {
|
||||
cy.loginBackoffice("chief@edr.local");
|
||||
|
||||
latestJourney("ethiopian").then((company) => {
|
||||
openCustomer(company.name);
|
||||
|
||||
// The badge and banner belong to companies whose registration was typed.
|
||||
// This one's came from eTrade, so neither may appear.
|
||||
cy.contains("Manual entry").should("not.exist");
|
||||
cy.contains("Registration entered by hand").should("not.exist");
|
||||
cy.contains("eTrade trade licence").should("be.visible");
|
||||
|
||||
approveFirstProfile();
|
||||
expectProfileActive(company.name);
|
||||
});
|
||||
});
|
||||
|
||||
it("the approved customer reaches the contract wizard", () => {
|
||||
latestJourney("ethiopian").then((company) => {
|
||||
cy.loginPortal(company.email, SIGNUP_PASSWORD);
|
||||
});
|
||||
cy.visitPortal("/contracts/new");
|
||||
|
||||
cy.contains("label", "Operation Type", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.contains("Awaiting Approval").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
171
e2e/freight/cypress/e2e/flows/onboarding_guards.cy.ts
Normal file
171
e2e/freight/cypress/e2e/flows/onboarding_guards.cy.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The states onboarding must refuse, and the one it must undo.
|
||||
*
|
||||
* Two halves. The API guards are cheap `cy.request` checks against the seeded
|
||||
* demo customer — combinations the portal never offers, which is exactly why
|
||||
* they have to be refused server-side rather than merely hidden. The second
|
||||
* half is the expensive one and the reason this spec exists at all: going back
|
||||
* in the wizard and un-ticking the investment licence has to cost what the
|
||||
* settings switch costs, or a company finishes onboarding on registration data
|
||||
* nobody verified, with no flag left to say so.
|
||||
*/
|
||||
|
||||
import {
|
||||
apiRequest,
|
||||
companyByEmail,
|
||||
fillAria,
|
||||
portalToken,
|
||||
signupCustomer,
|
||||
signupIdentity,
|
||||
typeRegistration,
|
||||
noLicenceTin,
|
||||
vatNumber,
|
||||
wizardClick,
|
||||
} from "./onboarding-utils";
|
||||
|
||||
const stamp = Date.now();
|
||||
const who = signupIdentity("toggle", stamp);
|
||||
|
||||
/** The seeded demo customer: an ordinary company that came through eTrade. */
|
||||
const DEMO_CUSTOMER = "user@gmail.com";
|
||||
const demoPassword = () => Cypress.env("demoPassword") as string;
|
||||
|
||||
describe("onboarding — refused combinations", { retries: 0 }, () => {
|
||||
it("refuses an investment licence for an Ethiopian company", () => {
|
||||
portalToken(DEMO_CUSTOMER, demoPassword()).then((token) =>
|
||||
apiRequest(
|
||||
token,
|
||||
"POST",
|
||||
"/api/companies/onboarding/start",
|
||||
{
|
||||
companyType: "customer",
|
||||
roles: ["importer"],
|
||||
nationality: "ethiopian",
|
||||
investorLicence: true,
|
||||
},
|
||||
false,
|
||||
).then((res) => {
|
||||
expect(res.status).to.eq(400);
|
||||
expect(JSON.stringify(res.body)).to.contain(
|
||||
"Only a foreign company can onboard on an investment licence",
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a co-operative that also claims an investment licence", () => {
|
||||
// Ethiopian on purpose: a co-op sent as foreign is refused by the older
|
||||
// co-operative guard, which would pass this test without the new one ever
|
||||
// running. Ethiopian gets past that guard and lands on this one.
|
||||
portalToken(DEMO_CUSTOMER, demoPassword()).then((token) =>
|
||||
apiRequest(
|
||||
token,
|
||||
"POST",
|
||||
"/api/companies/onboarding/start",
|
||||
{
|
||||
companyType: "customer",
|
||||
roles: ["importer"],
|
||||
nationality: "ethiopian",
|
||||
cooperative: true,
|
||||
investorLicence: true,
|
||||
},
|
||||
false,
|
||||
).then((res) => {
|
||||
expect(res.status).to.eq(400);
|
||||
expect(JSON.stringify(res.body)).to.contain(
|
||||
"it cannot also onboard on a foreign investment licence",
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to switch a company that never took the route", () => {
|
||||
portalToken(DEMO_CUSTOMER, demoPassword()).then((token) =>
|
||||
apiRequest(
|
||||
token,
|
||||
"POST",
|
||||
"/api/companies/onboarding/revert-to-etrade",
|
||||
undefined,
|
||||
false,
|
||||
).then((res) => {
|
||||
expect(res.status).to.eq(400);
|
||||
expect(JSON.stringify(res.body)).to.contain(
|
||||
"already registered through eTrade",
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboarding — un-ticking the box mid-wizard", { retries: 0 }, () => {
|
||||
it("clears the typed registration and reopens on the company step", () => {
|
||||
signupCustomer(who, "Toggle");
|
||||
|
||||
cy.contains("button", "Foreign Company").click();
|
||||
cy.contains("We operate on a foreign investment licence").click();
|
||||
cy.contains("button", "Importer").click();
|
||||
wizardClick("Continue");
|
||||
|
||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
// A TIN eTrade knows but which holds no trade licence — the manual route's
|
||||
// own shape. (A TIN eTrade has never heard of surfaces as an outage rather
|
||||
// than as "nothing on file": the API wraps its 404 as "Failed to fetch",
|
||||
// which ETradeInfo reads as unreachable. Different message, different
|
||||
// test.)
|
||||
fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
|
||||
cy.contains("Nothing on file at eTrade for this TIN", {
|
||||
timeout: 20000,
|
||||
}).should("be.visible");
|
||||
typeRegistration(`E2E Toggle Trading ${stamp}`);
|
||||
fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
|
||||
wizardClick("Continue");
|
||||
|
||||
// The typed registration is now on file.
|
||||
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
|
||||
companyByEmail(who.email).then((company) => {
|
||||
expect(company.region, "typed address saved").to.eq("Addis Ababa");
|
||||
expect((company.attributes ?? {})["investorLicence"]).to.eq(true);
|
||||
});
|
||||
|
||||
// Back to the company step, then back again to the nationality phase.
|
||||
wizardClick("Back");
|
||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
wizardClick("Back");
|
||||
// `exist`, not `visible`: the heading scrolls under the modal's sticky
|
||||
// header, and where the dialog happens to be scrolled says nothing about
|
||||
// whether we are back on the nationality phase. The checkbox below is the
|
||||
// thing this test actually needs to reach.
|
||||
cy.contains("Where is your company registered?", { timeout: 20000 }).should(
|
||||
"exist",
|
||||
);
|
||||
|
||||
// Change of mind: this company is an ordinary foreign company after all.
|
||||
cy.contains("We operate on a foreign investment licence").click();
|
||||
wizardClick("Continue");
|
||||
|
||||
// Whatever was typed under the flag is gone, and the resume target is the
|
||||
// company step — not the furthest step reached, which would skip the
|
||||
// eTrade lookup the customer has just opted back into.
|
||||
companyByEmail(who.email).then((company) => {
|
||||
expect((company.attributes ?? {})["investorLicence"]).to.eq(false);
|
||||
expect(company.region, "typed address cleared").to.be.null;
|
||||
expect(company.licence_number).to.be.null;
|
||||
expect(company.etrade_phone).to.be.null;
|
||||
expect(company.onboarding_step).to.eq("company");
|
||||
});
|
||||
|
||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
// No typed-registration block any more: eTrade owns these fields again.
|
||||
cy.contains("Nothing on file at eTrade for this TIN").should("not.exist");
|
||||
cy.contains("Registration details").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
123
e2e/freight/cypress/e2e/flows/onboarding_investor.cy.ts
Normal file
123
e2e/freight/cypress/e2e/flows/onboarding_investor.cy.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* A foreign company onboarding on an Ethiopian Investment Commission licence.
|
||||
*
|
||||
* The Commission licenses it, not the trade registry, so eTrade holds no
|
||||
* record for its TIN: the registration is typed, the company is flagged
|
||||
* `investorLicence`, and the backoffice is told in as many words that nothing
|
||||
* on the screen was verified against a licence. What does NOT change is the
|
||||
* business licence per operational profile — an investor holds one, unlike a
|
||||
* co-operative — so the documents step still asks for it.
|
||||
*
|
||||
* The wizard itself is driven by `completeInvestorOnboarding`, which carries
|
||||
* the step-by-step assertions (the blue "nothing on file" alert, the absent
|
||||
* eTrade manager, the refusal to continue on an unproven identity) because
|
||||
* they hold for every investor run. What lives here is what is specific to
|
||||
* this journey: the document requirements, the persisted flag, and the
|
||||
* backoffice's treatment of it.
|
||||
*
|
||||
* One journey across both apps; retries off (see onboarding_ethiopian.cy.ts).
|
||||
*/
|
||||
|
||||
import {
|
||||
apiRequest,
|
||||
approveFirstProfile,
|
||||
companyByEmail,
|
||||
completeInvestorOnboarding,
|
||||
expectProfileActive,
|
||||
latestJourney,
|
||||
portalToken,
|
||||
signupIdentity,
|
||||
SIGNUP_PASSWORD,
|
||||
} from "./onboarding-utils";
|
||||
|
||||
const stamp = Date.now();
|
||||
const who = signupIdentity("investor", stamp);
|
||||
|
||||
describe("onboarding — foreign investor, no eTrade record", { retries: 0 }, () => {
|
||||
it("types its registration and submits for review", () => {
|
||||
completeInvestorOnboarding(who, stamp, {
|
||||
// On the documents step, everything attached, nothing submitted yet.
|
||||
beforeSubmit: () => {
|
||||
portalToken(who.email).then((token) =>
|
||||
apiRequest(
|
||||
token,
|
||||
"GET",
|
||||
"/api/companies/onboarding/requirements",
|
||||
).then((res) => {
|
||||
// The foreign set applies unchanged — it already asks for the
|
||||
// investment licence itself, so no third set exists for this case.
|
||||
expect(res.body.data.documentSettingCode).to.eq(
|
||||
"company_onboarding_documents_foreign",
|
||||
);
|
||||
expect(res.body.data.investorLicence, "investorLicence flag").to.eq(
|
||||
true,
|
||||
);
|
||||
expect(res.body.data.cooperative).to.eq(false);
|
||||
// The one thing this route does NOT share with a co-operative.
|
||||
expect(
|
||||
res.body.data.licenseProfiles,
|
||||
"per-role licence still tracked",
|
||||
).to.have.length(1);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
companyByEmail(who.email).then((company) => {
|
||||
expect(company.status).to.eq("pending");
|
||||
expect(company.onboarding_completed).to.eq(true);
|
||||
expect(company.nationality).to.eq("foreign");
|
||||
expect((company.attributes ?? {})["investorLicence"]).to.eq(true);
|
||||
expect(company.name).to.contain("E2E Investor Holdings");
|
||||
// Typed, not fetched: the address is what the customer entered, and no
|
||||
// licence number exists at all.
|
||||
expect(company.region).to.eq("Addis Ababa");
|
||||
expect(company.licence_number, "no eTrade licence").to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
it("the backoffice flags the typed registration, then approves", () => {
|
||||
cy.loginBackoffice("chief@edr.local");
|
||||
|
||||
latestJourney("investor").then((company) => {
|
||||
cy.visit("/dashboard/customers");
|
||||
cy.get('input[placeholder*="Search by company"]').type(company.name);
|
||||
cy.contains(company.name, { timeout: 20000 }).should("be.visible");
|
||||
|
||||
// The list is where a reviewer first meets this customer, so the flag
|
||||
// has to be there and not only on the detail page.
|
||||
cy.contains("Manual entry · investment licence").should("be.visible");
|
||||
cy.contains(company.name).click();
|
||||
|
||||
cy.contains("Manual entry · investment licence").should("be.visible");
|
||||
cy.contains(
|
||||
"Registration entered by hand — not verified against eTrade",
|
||||
).should("be.visible");
|
||||
cy.contains("Check them against the Investment Licence").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.contains(
|
||||
"Foreign investment licence — typed by the customer, not from eTrade",
|
||||
).should("be.visible");
|
||||
cy.contains("Manual entry · co-operative").should("not.exist");
|
||||
|
||||
// Flagging is advisory: approval itself is not blocked.
|
||||
approveFirstProfile();
|
||||
expectProfileActive(company.name);
|
||||
});
|
||||
});
|
||||
|
||||
it("the approved investor reaches the contract wizard", () => {
|
||||
latestJourney("investor").then((company) => {
|
||||
cy.loginPortal(company.email, SIGNUP_PASSWORD);
|
||||
});
|
||||
cy.visitPortal("/contracts/new");
|
||||
|
||||
cy.contains("label", "Operation Type", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.contains("Awaiting Approval").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
169
e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts
Normal file
169
e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Giving the investment-licence route back.
|
||||
*
|
||||
* A company that ticked the box by mistake, or that has since been registered
|
||||
* with the trade registry, switches from Settings → Company. It is a
|
||||
* re-application rather than a settings edit, and this spec is about proving
|
||||
* that literally: the typed registration is cleared (nothing on file was ever
|
||||
* checked against a licence), the company returns to pending, onboarding
|
||||
* reopens on the company step — and only after a real eTrade lookup does the
|
||||
* backoffice stop flagging it.
|
||||
*
|
||||
* Each test is one leg of a single journey, in order, retries off. The
|
||||
* portal ↔ backoffice hops re-evaluate the spec bundle, so every leg resolves
|
||||
* the company from the database rather than from module state.
|
||||
*/
|
||||
|
||||
import {
|
||||
approveFirstProfile,
|
||||
companyByEmail,
|
||||
completeInvestorOnboarding,
|
||||
etradeTin,
|
||||
expectProfileActive,
|
||||
fillAria,
|
||||
latestJourney,
|
||||
openCustomer,
|
||||
signupIdentity,
|
||||
wizardClick,
|
||||
SIGNUP_PASSWORD,
|
||||
} from "./onboarding-utils";
|
||||
|
||||
const stamp = Date.now();
|
||||
const who = signupIdentity("switchback", stamp);
|
||||
|
||||
describe("onboarding — switching back to eTrade registration", { retries: 0 }, () => {
|
||||
it("onboards on an investment licence", () => {
|
||||
completeInvestorOnboarding(who, stamp, {
|
||||
companyName: `E2E Switchback Trading ${stamp}`,
|
||||
});
|
||||
|
||||
companyByEmail(who.email).then((company) => {
|
||||
expect((company.attributes ?? {})["investorLicence"]).to.eq(true);
|
||||
expect(company.region, "typed address").to.eq("Addis Ababa");
|
||||
});
|
||||
});
|
||||
|
||||
it("is approved by the backoffice", () => {
|
||||
cy.loginBackoffice("chief@edr.local");
|
||||
latestJourney("switchback").then((company) => {
|
||||
openCustomer(company.name);
|
||||
cy.contains("Manual entry · investment licence").should("be.visible");
|
||||
approveFirstProfile();
|
||||
expectProfileActive(company.name);
|
||||
});
|
||||
});
|
||||
|
||||
it("switches back from settings, which clears the typed registration", () => {
|
||||
latestJourney("switchback").then((company) => {
|
||||
cy.loginPortal(company.email, SIGNUP_PASSWORD);
|
||||
});
|
||||
cy.visitPortal("/settings");
|
||||
|
||||
cy.contains("Registration source", { timeout: 20000 }).should("be.visible");
|
||||
cy.contains("button", "Switch to eTrade registration").click();
|
||||
|
||||
// The confirmation has to state the cost outright — this is the screen
|
||||
// that decides whether the customer knows they are re-applying.
|
||||
cy.contains("Switch to eTrade registration?").should("be.visible");
|
||||
cy.contains("The registration details you typed are cleared").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.contains("Your company goes back to pending").should("be.visible");
|
||||
cy.contains("button", "Switch and re-apply").click();
|
||||
|
||||
cy.contains("Registration source", { timeout: 20000 }).should("not.exist");
|
||||
|
||||
latestJourney("switchback").then((company) => {
|
||||
expect((company.attributes ?? {})["investorLicence"]).to.be.undefined;
|
||||
expect(company.status).to.eq("pending");
|
||||
expect(company.onboarding_completed).to.eq(false);
|
||||
// The wizard treats a populated registration as a lookup that already
|
||||
// passed, so leaving any of it behind would walk the customer straight
|
||||
// past the eTrade step this switch exists to reach.
|
||||
expect(company.region, "typed address cleared").to.be.null;
|
||||
expect(company.licence_number).to.be.null;
|
||||
expect(company.etrade_phone).to.be.null;
|
||||
expect(company.onboarding_step, "resume target").to.eq("company");
|
||||
});
|
||||
});
|
||||
|
||||
it("re-runs onboarding through eTrade and resubmits", () => {
|
||||
latestJourney("switchback").then((company) => {
|
||||
cy.loginPortal(company.email, SIGNUP_PASSWORD);
|
||||
});
|
||||
cy.visitPortal("/portal");
|
||||
|
||||
// Reopened on the company step — not on the furthest step reached before.
|
||||
cy.contains("Confirm your VAT number", { timeout: 30000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
// The typed registration section is gone: this is an ordinary company now.
|
||||
cy.contains("Nothing on file at eTrade for this TIN").should("not.exist");
|
||||
|
||||
// Wait for the profile to rehydrate before typing. The company still holds
|
||||
// the TIN it typed under the flag (the switch clears the registration, not
|
||||
// the tax number), and RHF re-seeds the field when the refetch lands — so a
|
||||
// TIN typed into an empty-looking field is silently replaced by the stored
|
||||
// one, and the lookup then runs against the wrong number.
|
||||
cy.get('.mantine-Modal-content [aria-label="VAT Number"]')
|
||||
.should("not.have.value", "");
|
||||
fillAria('[aria-label^="TIN Number"]', etradeTin(stamp));
|
||||
cy.get('.mantine-Modal-content [aria-label^="TIN Number"]').should(
|
||||
"have.value",
|
||||
etradeTin(stamp),
|
||||
);
|
||||
cy.contains("Verified with eTrade", { timeout: 20000 }).should("be.visible");
|
||||
cy.wait(500);
|
||||
wizardClick("Continue");
|
||||
|
||||
// Owner, representation and contact are already satisfied server-side —
|
||||
// the switch keeps everything except the registration — so each step only
|
||||
// needs advancing.
|
||||
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
|
||||
wizardClick("Continue");
|
||||
cy.contains("The owner acts for the company", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
wizardClick("Continue");
|
||||
cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
|
||||
wizardClick("Continue");
|
||||
|
||||
// Documents and the licence were uploaded before the switch and survive
|
||||
// it, so the step is already satisfied.
|
||||
cy.contains("Upload Importer Business license file(s)", {
|
||||
timeout: 20000,
|
||||
}).should("be.visible");
|
||||
wizardClick("Submit for review");
|
||||
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
|
||||
|
||||
latestJourney("switchback").then((company) => {
|
||||
expect(company.onboarding_completed).to.eq(true);
|
||||
expect((company.attributes ?? {})["investorLicence"]).to.be.undefined;
|
||||
// eTrade answered this time, and its licence is on file.
|
||||
expect(company.licence_number).to.eq("LIC-E2E-0001");
|
||||
});
|
||||
});
|
||||
|
||||
it("the backoffice no longer flags it", () => {
|
||||
cy.loginBackoffice("chief@edr.local");
|
||||
latestJourney("switchback").then((company) => {
|
||||
openCustomer(company.name);
|
||||
|
||||
cy.contains("eTrade trade licence").should("be.visible");
|
||||
cy.contains("Manual entry").should("not.exist");
|
||||
cy.contains("Registration entered by hand").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("offers nothing to switch to a customer who came through eTrade", () => {
|
||||
// The seeded demo customer onboarded the ordinary way, so the card must
|
||||
// not be on its settings page at all.
|
||||
cy.loginPortal();
|
||||
cy.visitPortal("/settings");
|
||||
|
||||
cy.contains("Operational Services", { timeout: 20000 }).should("be.visible");
|
||||
cy.contains("Registration source").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
@@ -9,10 +9,30 @@
|
||||
// TLS_CERT_PATH/TLS_KEY_PATH before starting this — nothing shaped like a
|
||||
// key/cert is committed here.
|
||||
//
|
||||
// Every TIN resolves to the same canned company — these specs don't care
|
||||
// about per-TIN business logic, only that the lookup succeeds so the rest
|
||||
// of the company-info form (name, address, manager) auto-fills instead of
|
||||
// staying gated behind a "No matching business record" alert.
|
||||
// A TIN resolves to the same canned company whatever its digits, EXCEPT for
|
||||
// the two failure shapes the onboarding specs need — chosen by the TIN's
|
||||
// leading digit so a spec picks its outcome by picking its number, with no
|
||||
// per-spec stubbing and no state in this process:
|
||||
//
|
||||
// 1xxxxxxxxx (default) registration + one business licence → "Verified with eTrade"
|
||||
// 9xxxxxxxxx registration, but `Businesses: []` → the API's resolveCompanyData
|
||||
// returns businessInfo: null,
|
||||
// so /fetch-etrade-info 400s
|
||||
// with "couldn't find a
|
||||
// business license for this
|
||||
// TIN". This is the real
|
||||
// co-operative / foreign-
|
||||
// investor case: the TIN is
|
||||
// registered, the trade
|
||||
// licence is not.
|
||||
// 8xxxxxxxxx 404 on the registration lookup → getCompanyInfoByTin throws,
|
||||
// same 400 to the portal by a
|
||||
// different route (eTrade knows
|
||||
// nothing about this TIN at all).
|
||||
//
|
||||
// Both failures reach the portal as a 400, which ETradeInfo renders as its
|
||||
// `notFound` branch: a red dead end for an ordinary company, and the blue
|
||||
// "that's expected" alert for one that types its registration.
|
||||
const https = require("node:https");
|
||||
const fs = require("node:fs");
|
||||
|
||||
@@ -25,6 +45,11 @@ const options = {
|
||||
),
|
||||
};
|
||||
|
||||
/** No trade licence on file for this TIN (a co-operative, a foreign investor). */
|
||||
const TIN_WITHOUT_LICENCE = "9";
|
||||
/** eTrade holds no registration whatsoever for this TIN. */
|
||||
const TIN_UNKNOWN = "8";
|
||||
|
||||
function companyInfo(tin) {
|
||||
return {
|
||||
Tin: tin,
|
||||
@@ -101,8 +126,21 @@ const server = https.createServer(options, (req, res) => {
|
||||
/^\/api\/Registration\/GetRegistrationInfoByTin\/([^/]+)\/en$/,
|
||||
);
|
||||
if (req.method === "GET" && regMatch) {
|
||||
const tin = regMatch[1];
|
||||
|
||||
if (tin.startsWith(TIN_UNKNOWN)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const info = companyInfo(tin);
|
||||
// Registered, but holding no trade licence. The API reads `Businesses`
|
||||
// rather than the HTTP status to decide this, so an empty array is the
|
||||
// honest shape — not an error.
|
||||
if (tin.startsWith(TIN_WITHOUT_LICENCE)) info.Businesses = [];
|
||||
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(companyInfo(regMatch[1])));
|
||||
res.end(JSON.stringify(info));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user