Merge pull request #1332 from Tria-plc/freight/feat/foreign-investors

Freight/feat/foreign investors
This commit is contained in:
Nathnael Wondisha
2026-08-18 14:49:28 +03:00
committed by GitHub
37 changed files with 2788 additions and 412 deletions

View File

@@ -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({

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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)) ??
[];

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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"])

View File

@@ -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,

View File

@@ -4,6 +4,7 @@ export {
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
ManualRegistrationBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,

View File

@@ -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,7 +885,9 @@ export default function CustomerDetailPage() {
value={
company.cooperative
? "Co-operative union / farm (no trade licence)"
: "eTrade trade licence"
: company.investorLicence
? "Foreign investment licence — typed by the customer, not from eTrade"
: "eTrade trade licence"
}
/>
<InfoField label="Address" value={company.address} />

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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`,

View File

@@ -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" />

View File

@@ -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}
/>
)}

View File

@@ -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
? "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."
!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 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}
/>

View File

@@ -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
? "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."
{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.

View File

@@ -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>
</>
);
}

View File

@@ -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,13 +382,21 @@ 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"
? "blocked"
: "todo"
tinStatus === "taken"
? "blocked"
: manualRegistration
? watch("tinNumber")?.trim() && !errors.tinNumber
? "done"
: "todo"
: tinVerified
? "done"
: "todo"
}
>
<ETradeInfo
@@ -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

View File

@@ -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">
<TextInput
label="Full Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<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>
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Phone Number"
required
/>
<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

View File

@@ -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

View File

@@ -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

View File

@@ -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="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="PoA Location"
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")}

View File

@@ -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);
});
});

View File

@@ -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",

View File

@@ -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);
},

View File

@@ -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;