From a9763a541a4d9c8fe3594e35c1cbcc1ee1950996 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 08:45:15 +0000 Subject: [PATCH 01/12] feat(companies): support foreign investors onboarding on an investment licence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A foreign company licensed by the Ethiopian Investment Commission is not on the trade registry, so eTrade holds no record for its TIN and the lookup the company step is built around returns nothing. Those customers could not get past onboarding at all. They now take the same route a co-operative does: an `investorLicence` flag in `attributes`, and `applyEtradeSourcedFields` skips the eTrade re-check for any company `usesManualRegistration` covers, so the registration they type is persisted as sent instead of 400'ing "no registration found for this TIN". Unlike a co-operative they still hold a business licence per operational role, so that requirement is untouched, and the foreign document set already asks for the investment licence itself — no new set. Only a foreign company may carry the flag, and never alongside the co-operative one: the two resolve to different document sets. `POST /companies/onboarding/revert-to-etrade` gives it back. It clears the typed registration rather than keeping it — the wizard treats a populated registration block as a passed lookup, so leaving it would walk the customer straight past the eTrade step the switch exists to reach — and returns the company to pending, since an approval granted against typed data must not carry over to a record that now claims to be eTrade's. --- .../modules/companies/companies.controller.ts | 15 ++ .../companies.investor-licence.spec.ts | 147 ++++++++++++++++++ .../modules/companies/companies.service.ts | 126 ++++++++++++++- .../onboarding-requirements-response.dto.ts | 9 ++ .../companies/dto/profile-response.dto.ts | 13 +- .../companies/dto/response-company.dto.ts | 9 ++ .../companies/dto/start-onboarding.dto.ts | 11 ++ .../companies/entities/company.entity.ts | 31 ++++ 8 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 175533c6d..4139424c1 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 foreign investment-licence route: clear the typed registration and reopen onboarding so the TIN is verified against eTrade", + }) + async revertToRegularCompany( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.revertToRegularCompany(user.id); + return new CompanyInfoResponseDto(profile, company); + } + @Post("company-profile") @PortalCustomer() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts new file mode 100644 index 000000000..4a17d9594 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts @@ -0,0 +1,147 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { + CompanyNationality, + CompanyStatus, + CompanyType, +} from "./entities/company.entity"; +import { 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. + */ +function makeService(company: Record | null) { + const companiesRepo = { + findById: jest.fn(async () => company), + update: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ + id: "company-1", + ...row, + })), + existsByTin: jest.fn(async () => false), + }; + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + 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) => ({ + 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, 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, + ]; + 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("refuses to switch a company that never took the investment-licence route", async () => { + const { service } = makeService({ id: "company-1", attributes: {} }); + await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index bf8184833..f9090f288 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 = {}; if (nationality) updates.nationality = nationality; @@ -402,10 +413,15 @@ 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 } + : {}), }; } if (Object.keys(updates).length > 0) { @@ -416,6 +432,11 @@ export class CompaniesService { 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 +449,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 +513,34 @@ export class CompaniesService { } } + /** + * 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 +2199,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 +2349,7 @@ export class CompaniesService { documentSettingCode, nationality: company.nationality ?? CompanyNationality.Ethiopian, cooperative, + investorLicence, companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo, @@ -2369,6 +2427,66 @@ export class CompaniesService { return this.getCompanyInfoByUserId(userId); } + /** + * Drop the investment-licence route and send the company back through the + * normal eTrade one. + * + * 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. + */ + 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 (!hasInvestorLicence(company)) { + throw new BadRequestException( + "This company is already registered through eTrade — there is nothing to switch.", + ); + } + + const attributes = { ...(company.attributes ?? {}) }; + delete attributes[INVESTOR_LICENCE_KEY]; + // The manager captured alongside the (typed) registration goes with it — + // it never came from a licence, so it must not survive as one. + delete attributes.etradeManagerName; + delete attributes.etradeManagerPhone; + + await this.companiesRepo.update(companyId, { + attributes, + status: CompanyStatus.Pending, + licenceNumber: null, + statusDescription: null, + dateRegistered: null, + renewedFrom: null, + renewalDate: null, + renewedTo: null, + region: null, + zone: null, + woreda: null, + kebele: null, + houseNo: null, + etradePhone: null, + }); + await this.profilesRepo.update(profile.id, { + onboardingCompleted: false, + onboardingStep: "company", + }); + 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 +3653,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, diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 20bd0ab06..49dad4b8d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 0d7293ab0..14753f8ef 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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)) ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index e75182889..7c4348e4f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts index 91fcb44a1..0eac35a5f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 54c36bf41..68bf73cdf 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -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 | 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 | null | undefined, +): boolean { + return isCooperative(company) || hasInvestorLicence(company); +} + @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"]) From d1584ee708faa32e2fb1b13b5ef9421ca8f44d95 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 08:45:29 +0000 Subject: [PATCH 02/12] feat(portal): offer the investment-licence path in the onboarding wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A foreign company can now say it operates on an investment licence, on the same step as its nationality and roles. The box only appears for a foreign company, and moving the nationality answer back to Ethiopian drops it — the API refuses both pairings. The company step's eTrade gate now reads `manualRegistration` (co-operative OR investment licence): the TIN lookup still runs, but finding nothing is an expected outcome rather than a blocker, and the registration section is typed instead. What stays keyed to `cooperative` alone is the per-role business licence — an investor holds one, a co-operative does not — so the licence cards and their validation are unchanged for investors. Also carries the client plumbing for the revert endpoint the settings card uses next. --- .../onboarding/OnboardingWizardDialog.tsx | 46 ++++++++++++++++++- .../portal/src/constants/URLS.ts | 1 + .../src/pages/accounts/CompanyProfileForm.tsx | 29 +++++++++--- .../steps/CompanyInfoStep.tsx | 46 +++++++++++-------- .../companyProfileForm/steps/OwnerStep.tsx | 17 ++++--- .../portal/src/services/api.ts | 8 ++++ .../portal/src/services/companies.service.ts | 15 ++++++ .../portal/src/types/profile.ts | 2 + 8 files changed, 132 insertions(+), 32 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index d5a87a4ee..0dd3e4462 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -162,6 +162,13 @@ export default function OnboardingWizardDialog({ const [cooperative, setCooperative] = useState( 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( + 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 >({}); @@ -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 @@ -479,6 +501,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 +572,7 @@ export default function OnboardingWizardDialog({ + {/* 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 && ( + + 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." + /> + )} What does your company do?(multiple) diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index c9576d192..cefef5ddc 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -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`, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index aa445974f..542fd17b5 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -65,6 +65,7 @@ export default function CompanyProfileForm({ identity: rawIdentity, onIdentityChange, cooperative = false, + investorLicence = false, declarationLocked = false, }: { documentSettingCode: string; @@ -118,6 +119,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 +141,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(initialStep ?? "company"); const [saving, setSaving] = useState(false); /** @@ -434,7 +448,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; @@ -730,7 +744,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 +784,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 +901,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 +1002,7 @@ export default function CompanyProfileForm({ tinStatus={tinStatus} tinVerified={tinVerified} hasRegistrationDetails={hasRegistrationDetails} + manualRegistration={manualRegistration} cooperative={cooperative} onETradeDataLoaded={handleETradeDataLoaded} onETradeStatusChange={setTinStatus} @@ -1002,6 +1018,7 @@ export default function CompanyProfileForm({ source={ownerSource} sourced={ownerSourced} cooperative={cooperative} + manualRegistration={manualRegistration} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx index b89e0e920..04a91bf07 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -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 && ( )} - {/* 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 && ( diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx index 4035b8aaf..805ef8784 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx @@ -32,6 +32,8 @@ export interface OwnerStepProps { sourced: Record; /** 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 ( - {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."} - {/* 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 && ( }> 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. diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 755361520..2f633a06b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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( + "companies", + "revertToRegularCompany", + companiesService.revertToRegularCompany, + ), + setOnboardingStep: endpoint<{ step: string }, void>( "companies", "setOnboardingStep", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index eda9d7e3d..4a6c3139e 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -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 => { const response = await client.post>( 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 => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_REVERT_TO_ETRADE, + ); + return unwrap(response.data); + }, + setOnboardingStep: async (payload: { step: string }): Promise => { await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload); }, diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index 3839e3141..fc297110a 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -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; From 23d752d1ccb28bde25a6285cf58a2ba4a70f9a62 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 08:45:29 +0000 Subject: [PATCH 03/12] feat(portal): let an investor company switch back to eTrade registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A company that ticked the investment-licence box by mistake, or that has since been registered with the trade registry, had no way back — the flag is chosen once, on a step onboarding never returns to. The Company tab now carries a Registration source card for those companies. It is a re-application rather than a settings edit, so the confirmation says so outright: the typed registration is cleared, the company returns to pending and the wizard reopens on the company step, while documents, owner and contact details stay. Hidden for everyone else, and disabled while a profile change request is under review — switching then would strand it. --- .../portal/src/pages/SettingsPage.tsx | 2 + .../pages/settings/RegistrationSourceCard.tsx | 149 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 13b0d4025..53e0cf0a3 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -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() { /> + diff --git a/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx new file mode 100644 index 000000000..09f4e7195 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx @@ -0,0 +1,149 @@ +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 the foreign investment-licence route 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. + */ +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(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), + }); + + if (!profile.investorLicence) return null; + + return ( + <> + + + + Registration source + + + + 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. + + + If your company now holds an eTrade trade licence, you can switch + over and have your registration verified automatically. + + + + + {disabled && ( + + Not available while your profile changes are under review. + + )} + + + + setConfirming(false)} + title="Switch to eTrade registration?" + centered + radius="lg" + > + + This re-opens your application: + + + The registration details you typed are cleared — eTrade supplies + them once your TIN is found. + + + Your company goes back to pending and is reviewed again. + + + Your documents, owner and contact details stay as they are. + + + }> + If eTrade holds no record for your TIN you won't be able to finish — + come back here and re-select the investment licence in the wizard. + + {error && ( + + {error} + + )} + + + + + + + + ); +} From 0f11d9518f9c299a2e1f1ca82be2910c1c2a34b2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 08:45:38 +0000 Subject: [PATCH 04/12] feat(backoffice): flag customers whose registration was typed, not fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two kinds of customer reach approval with a registration nobody checked: a co-operative union or farm, which holds no trade licence, and a foreign investor, whose licence comes from the Investment Commission rather than the trade registry. Both were reviewed on screens that read exactly like an eTrade-verified company's, with only a small Registration field naming the difference. They now carry an orange "Manual entry" badge in the customers list and beside the company name, and their overview opens with an alert saying the name, registration and address below are the customer's own statement — pointing the reviewer at the paper that stands in for the licence (the co-operative certificate, or the investment licence) before approving. Approval itself is not blocked. --- .../src/components/customers/badges.tsx | 30 +++++++++++++++++++ .../src/components/customers/index.ts | 1 + .../pages/customers/CustomerDetailPage.tsx | 28 ++++++++++++++++- .../src/pages/customers/CustomersPage.tsx | 5 ++++ .../backoffice/src/types/customer.ts | 7 +++++ 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 90dea5adb..22d3e66cf 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -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 ( + + {cooperative + ? "Manual entry · co-operative" + : "Manual entry · investment licence"} + + ); +} + /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) * carrying its reference code, colored by the profile's status (green active, diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 6f869173c..864a89ae6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -4,6 +4,7 @@ export { CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, + ManualRegistrationBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 40185f990..a81f07c8b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -50,6 +50,7 @@ import { CompanyTimeline, CompanyTypeBadge, InvoiceStatusBadge, + ManualRegistrationBadge, PaymentStatusBadge, PersonCard, ProfileApprovalActions, @@ -744,6 +745,10 @@ export default function CustomerDetailPage() { ) : ( )} + } @@ -792,6 +797,25 @@ export default function CustomerDetailPage() { )} + {/* 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) && ( + } + 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."} + + )} + diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 91bb72fdc..3825f0165 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -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} + TIN {c.tin} diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 7fa7d792a..8c95e7342 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -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; From 0b8b9c39ab8064f966b0a428efbcf80d5eb8546f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 09:00:21 +0000 Subject: [PATCH 05/12] fix(companies): clear the typed registration when the manual-entry box is un-ticked Going back in the wizard and un-ticking co-operative or investment licence used to write the flag and nothing else. The registration the customer had typed stayed on the company row, so `hasRegistrationDetails` still read as a passed eTrade lookup, resume dropped them at their furthest step rather than the company one, and the application could be finished on unverified data with no flag left on it for the backoffice to show. That transition now costs what the settings switch costs: the eTrade-sourced columns and the manager captured beside them are cleared, and onboarding drops back to the company step so the TIN actually goes through eTrade. Both the reset payload and the attribute strip are now shared with `revertToRegularCompany`, which did this correctly already. --- .../companies.investor-licence.spec.ts | 64 +++++++++++++++ .../modules/companies/companies.service.ts | 77 +++++++++++++++---- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts index 4a17d9594..9623945d1 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts @@ -138,6 +138,70 @@ describe("the foreign investment-licence route", () => { }); }); + 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, + ]; + // 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, + ]; + 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, + ]; + expect(updates).not.toHaveProperty("region"); + expect(profilesRepo.update).not.toHaveBeenCalled(); + }); + it("refuses to switch a company that never took the investment-licence route", async () => { const { service } = makeService({ id: "company-1", attributes: {} }); await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf( diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index f9090f288..6dae48f24 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -424,9 +424,28 @@ export class CompaniesService { : {}), }; } + // 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); } @@ -513,6 +532,45 @@ 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 = { + 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 | null | undefined, + ): Record { + const next = { ...(attributes ?? {}) }; + delete next.etradeManagerName; + delete next.etradeManagerPhone; + return next; + } + /** * An investment licence belongs to a foreign company and to nothing else. * @@ -2457,28 +2515,13 @@ export class CompaniesService { ); } - const attributes = { ...(company.attributes ?? {}) }; + const attributes = this.withoutTypedEtradeManager(company.attributes); delete attributes[INVESTOR_LICENCE_KEY]; - // The manager captured alongside the (typed) registration goes with it — - // it never came from a licence, so it must not survive as one. - delete attributes.etradeManagerName; - delete attributes.etradeManagerPhone; await this.companiesRepo.update(companyId, { + ...CompaniesService.CLEARED_REGISTRATION, attributes, status: CompanyStatus.Pending, - licenceNumber: null, - statusDescription: null, - dateRegistered: null, - renewedFrom: null, - renewalDate: null, - renewedTo: null, - region: null, - zone: null, - woreda: null, - kebele: null, - houseNo: null, - etradePhone: null, }); await this.profilesRepo.update(profile.id, { onboardingCompleted: false, From 333232c4d9da86c3555dfc077313236f5990c7a9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 11:37:43 +0000 Subject: [PATCH 06/12] fix(portal): persist the region a manual-registration company picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Region select called setValue without shouldDirty. `region` is an eTrade-bundle key, and stepPayload sends those only when the customer changed them this session — so for the two routes that type their address by hand (a co-operative, a foreign investor) the region was dropped on every save while zone, woreda and kebele went through, because those are registered inputs and are dirty by construction. Found by the new onboarding e2e suite: both manual-route companies finished onboarding with zone/woreda/kebele on file and region empty. --- .../steps/CompanyInfoStep.tsx | 10 +- .../cypress/e2e/flows/onboarding.cy.ts | 276 ------------------ 2 files changed, 9 insertions(+), 277 deletions(-) delete mode 100644 e2e/freight/cypress/e2e/flows/onboarding.cy.ts diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx index 04a91bf07..52893ec59 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -159,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} /> diff --git a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts deleted file mode 100644 index 437c0e92e..000000000 --- a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts +++ /dev/null @@ -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