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;