feat(portal): offer the investment-licence path in the onboarding wizard

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.
This commit is contained in:
Nathnael
2026-08-18 08:45:29 +00:00
parent a9763a541a
commit d1584ee708
8 changed files with 132 additions and 32 deletions

View File

@@ -162,6 +162,13 @@ export default function OnboardingWizardDialog({
const [cooperative, setCooperative] = useState<boolean>( const [cooperative, setCooperative] = useState<boolean>(
company?.company?.attributes?.cooperative === true, 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 // 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 // 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. // 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")); setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
// Ethiopian is then the only answer left, so it is made rather than asked. // Ethiopian is then the only answer left, so it is made rather than asked.
setNationality("ethiopian"); 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< const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null> Record<string, File | File[] | null>
>({}); >({});
@@ -224,6 +243,7 @@ export default function OnboardingWizardDialog({
roles: ProfileTypeValue[]; roles: ProfileTypeValue[];
nationality?: CompanyNationality; nationality?: CompanyNationality;
cooperative?: boolean; cooperative?: boolean;
investorLicence?: boolean;
}) => api.companies.startOnboarding.call(vars), }) => api.companies.startOnboarding.call(vars),
onSuccess: async () => { onSuccess: async () => {
// Nationality drives the server-resolved identity requirements (Fayda vs // Nationality drives the server-resolved identity requirements (Fayda vs
@@ -307,6 +327,7 @@ export default function OnboardingWizardDialog({
setRoles(existingProfiles.map((p) => p.type)); setRoles(existingProfiles.map((p) => p.type));
setNationality(savedNationality); setNationality(savedNationality);
setCooperative(company?.company?.attributes?.cooperative === true); 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 // Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created. // role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "nationality-role"); setPhase(hasOperationalProfiles ? "form" : "nationality-role");
@@ -322,8 +343,9 @@ export default function OnboardingWizardDialog({
roles: roles as ProfileTypeValue[], roles: roles as ProfileTypeValue[],
nationality: nationality ?? undefined, nationality: nationality ?? undefined,
cooperative, cooperative,
investorLicence,
}); });
}, [roles, nationality, cooperative, startMutation]); }, [roles, nationality, cooperative, investorLicence, startMutation]);
// Back from the form's first step returns to nationality/role selection. // Back from the form's first step returns to nationality/role selection.
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing // 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 // startOnboarding has persisted it, and the form's whole company step
// branches on it. // branches on it.
cooperative: requirementsQuery.data?.cooperative ?? cooperative, 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 // A freight forwarder cannot answer the power-of-attorney question — the
// API forces "yes" — so the step offers no way to change it. // API forces "yes" — so the step offers no way to change it.
declarationLocked: requirementsQuery.data?.poa?.locked ?? false, declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
@@ -546,7 +572,7 @@ export default function OnboardingWizardDialog({
</Text> </Text>
<NationalitySelect <NationalitySelect
value={nationality} value={nationality}
onChange={setNationality} onChange={handleNationalityChange}
embedded embedded
// A co-op is registered in Ethiopia by the co-operative // A co-op is registered in Ethiopia by the co-operative
// promotion agency — foreign is not on offer rather than // promotion agency — foreign is not on offer rather than
@@ -563,6 +589,22 @@ export default function OnboardingWizardDialog({
label="We're a co-operative union or farm" 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." 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"> <Text fw={600} size="lg" c="edr-text">
What does your company do?(multiple) What does your company do?(multiple)
</Text> </Text>

View File

@@ -103,6 +103,7 @@ export const URL_CONSTANTS = {
ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_STEP: "/api/companies/onboarding-step",
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements", ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements",
ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade",
DASHBOARD: "/api/companies/dashboard", DASHBOARD: "/api/companies/dashboard",
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,

View File

@@ -65,6 +65,7 @@ export default function CompanyProfileForm({
identity: rawIdentity, identity: rawIdentity,
onIdentityChange, onIdentityChange,
cooperative = false, cooperative = false,
investorLicence = false,
declarationLocked = false, declarationLocked = false,
}: { }: {
documentSettingCode: string; documentSettingCode: string;
@@ -118,6 +119,13 @@ export default function CompanyProfileForm({
* nationality one (resolved by the caller into `documentSettingCode`). * nationality one (resolved by the caller into `documentSettingCode`).
*/ */
cooperative?: boolean; 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 * The company operates as a freight forwarder, so the power-of-attorney
* answer is forced to "yes" and cannot be changed here. * answer is forced to "yes" and cannot be changed here.
@@ -133,6 +141,12 @@ export default function CompanyProfileForm({
[rawIdentity], [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 [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false); 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 // 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 // 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. // hand, over a lookup that never filled anything in the first place.
if (cooperative && !etradeFilledRef.current) { if (manualRegistration && !etradeFilledRef.current) {
setLiveEtradeOwner(null); setLiveEtradeOwner(null);
setEtradeCleared(true); setEtradeCleared(true);
return; return;
@@ -730,7 +744,7 @@ export default function CompanyProfileForm({
// A co-operative never runs the lookup, so there is nothing to be verified // 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. // against; its TIN is validated by the schema like any other typed field.
const tinVerified = const tinVerified =
cooperative || tinStatus === "verified" || hasRegistrationDetails; manualRegistration || tinStatus === "verified" || hasRegistrationDetails;
// Single source of truth for step sequence — navigation, labels and the // 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. // 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())); Boolean(watch(passportField)?.trim()));
const requiredKeys: (keyof FormData)[] = []; const requiredKeys: (keyof FormData)[] = [];
if (step === "company" && cooperative) { if (step === "company" && manualRegistration) {
// A co-operative has no eTrade record, so the fields every other company // These companies have no eTrade record, so the fields every other company
// gets read-only from the licence are typed here — and are therefore // gets read-only from the licence are typed here — and are therefore
// required here. House number stays optional: plenty of addresses have none. // required here. House number stays optional: plenty of addresses have none.
requiredKeys.push("companyName", "region", "zone", "woreda", "kebele"); 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 // 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. // 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 // A co-operative and a foreign investor are exempt: eTrade holds no record
// `tinVerified` is true for it and only the duplicate-TIN check applies. // for either, so `tinVerified` is true and only the duplicate-TIN check
// applies.
if (step === "company" && tinStatus === "taken") { if (step === "company" && tinStatus === "taken") {
failCheck( failCheck(
"This TIN is already registered to another company account.", "This TIN is already registered to another company account.",
@@ -987,6 +1002,7 @@ export default function CompanyProfileForm({
tinStatus={tinStatus} tinStatus={tinStatus}
tinVerified={tinVerified} tinVerified={tinVerified}
hasRegistrationDetails={hasRegistrationDetails} hasRegistrationDetails={hasRegistrationDetails}
manualRegistration={manualRegistration}
cooperative={cooperative} cooperative={cooperative}
onETradeDataLoaded={handleETradeDataLoaded} onETradeDataLoaded={handleETradeDataLoaded}
onETradeStatusChange={setTinStatus} onETradeStatusChange={setTinStatus}
@@ -1002,6 +1018,7 @@ export default function CompanyProfileForm({
source={ownerSource} source={ownerSource}
sourced={ownerSourced} sourced={ownerSourced}
cooperative={cooperative} cooperative={cooperative}
manualRegistration={manualRegistration}
/> />
)} )}

View File

@@ -17,10 +17,13 @@ export interface CompanyInfoStepProps {
/** Registration fields are already populated (a lookup passed, now or earlier). */ /** Registration fields are already populated (a lookup passed, now or earlier). */
hasRegistrationDetails: boolean; hasRegistrationDetails: boolean;
/** /**
* The company is a co-operative union or farm: it has a TIN but no business * eTrade holds no record for this company's TIN, so the registration is typed
* licence, so eTrade holds no record to look up and the registration is typed * here rather than fetched. True for a co-operative union or farm (no
* here instead. * 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; cooperative?: boolean;
onETradeDataLoaded: (data: CompanyRegistrationData) => void; onETradeDataLoaded: (data: CompanyRegistrationData) => void;
onETradeStatusChange: (status: ETradeStatus) => void; onETradeStatusChange: (status: ETradeStatus) => void;
@@ -32,6 +35,7 @@ export default function CompanyInfoStep({
tinStatus, tinStatus,
tinVerified, tinVerified,
hasRegistrationDetails, hasRegistrationDetails,
manualRegistration = false,
cooperative = false, cooperative = false,
onETradeDataLoaded, onETradeDataLoaded,
onETradeStatusChange, onETradeStatusChange,
@@ -71,14 +75,16 @@ export default function CompanyInfoStep({
index={2} index={2}
title="Company TIN" title="Company TIN"
subtitle={ subtitle={
cooperative !manualRegistration
? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below." ? "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
: "We'll 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={ status={
tinStatus === "taken" tinStatus === "taken"
? "blocked" ? "blocked"
: cooperative : manualRegistration
? watch("tinNumber")?.trim() && !errors.tinNumber ? watch("tinNumber")?.trim() && !errors.tinNumber
? "done" ? "done"
: "todo" : "todo"
@@ -96,26 +102,26 @@ export default function CompanyInfoStep({
onReset={onETradeReset} onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails} alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")} selectedLicenceNumber={watch("licenceNumber")}
registrationOptional={cooperative} registrationOptional={manualRegistration}
/> />
{!cooperative && tinVerified && ( {!manualRegistration && tinVerified && (
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} /> <ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
)} )}
</StepSection> </StepSection>
{/* A co-operative keeps its typed registration section either way. When {/* A company eTrade cannot answer for keeps its typed registration
the lookup found something these arrive prefilled — still editable, section either way. When the lookup did find something these arrive
because for a co-op they are the customer's own statement rather than prefilled — still editable, because here they are the customer's own
the licence's, and the API takes them as given (`applyEtradeSourcedFields` statement rather than the licence's, and the API takes them as given
skips co-operatives entirely). */} (`applyEtradeSourcedFields` skips both cases entirely). */}
{cooperative && ( {manualRegistration && (
<StepSection <StepSection
index={3} index={3}
title="Registration details" title="Registration details"
subtitle={ subtitle={
hasRegistrationDetails hasRegistrationDetails
? "From eTrade. Correct anything that doesn't look right — for a co-operative these are yours to state." ? "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." : "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={ status={
watch("companyName")?.trim() && watch("region")?.trim() watch("companyName")?.trim() && watch("region")?.trim()
@@ -126,7 +132,11 @@ export default function CompanyInfoStep({
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label="Company Name" 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} error={errors.companyName?.message}
{...register("companyName")} {...register("companyName")}
/> />

View File

@@ -32,6 +32,8 @@ export interface OwnerStepProps {
sourced: Record<OwnerField, string>; sourced: Record<OwnerField, string>;
/** A co-operative union or farm: no licence, so no eTrade record to match. */ /** A co-operative union or farm: no licence, so no eTrade record to match. */
cooperative?: boolean; cooperative?: boolean;
/** No eTrade record at all (co-operative or foreign investment licence). */
manualRegistration?: boolean;
} }
/** /**
@@ -56,6 +58,7 @@ export default function OwnerStep({
source, source,
sourced, sourced,
cooperative = false, cooperative = false,
manualRegistration = false,
}: OwnerStepProps) { }: OwnerStepProps) {
const { const {
register, register,
@@ -74,15 +77,17 @@ export default function OwnerStep({
return ( return (
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="edr-muted"> <Text size="sm" c="edr-muted">
{cooperative && !etradeOwner {manualRegistration && !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." ? 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."} : "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> </Text>
{/* A co-operative is not told its licence listed no manager — it has no {/* A company with no eTrade record is not told its licence listed no
licence. Its own "nothing came back" case is covered by the line manager — eTrade never held one. That "nothing came back" case is
above. */} covered by the line above. */}
{!cooperative && !etradeOwner && !ownerVerified && ( {!manualRegistration && !etradeOwner && !ownerVerified && (
<Alert color="blue" variant="light" icon={<Info size={18} />}> <Alert color="blue" variant="light" icon={<Info size={18} />}>
Your eTrade licence didn't list a manager, so there's nothing for us 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. to prefill. Enter the details of the person registered on it.

View File

@@ -241,10 +241,18 @@ export const api = {
nationality?: CompanyNationality; nationality?: CompanyNationality;
/** No business licence: registration typed, no eTrade lookup, no forwarding. */ /** No business licence: registration typed, no eTrade lookup, no forwarding. */
cooperative?: boolean; cooperative?: boolean;
/** Foreign investment licence: registration typed, no eTrade lookup. */
investorLicence?: boolean;
}, },
CompanyInfoResponse CompanyInfoResponse
>("companies", "startOnboarding", companiesService.startOnboarding), >("companies", "startOnboarding", companiesService.startOnboarding),
revertToRegularCompany: endpoint<void, CompanyInfoResponse>(
"companies",
"revertToRegularCompany",
companiesService.revertToRegularCompany,
),
setOnboardingStep: endpoint<{ step: string }, void>( setOnboardingStep: endpoint<{ step: string }, void>(
"companies", "companies",
"setOnboardingStep", "setOnboardingStep",

View File

@@ -223,6 +223,8 @@ export interface OnboardingRequirements {
nationality: string; nationality: string;
/** No business licence: registration typed by hand, no eTrade lookup. */ /** No business licence: registration typed by hand, no eTrade lookup. */
cooperative: boolean; cooperative: boolean;
/** Foreign investment licence: registration typed by hand, no eTrade record. */
investorLicence: boolean;
companyInfo: { companyInfo: {
complete: boolean; complete: boolean;
missingFields: { key: string; label: string }[]; missingFields: { key: string; label: string }[];
@@ -363,6 +365,7 @@ export const companiesService = {
roles: ProfileTypeValue[]; roles: ProfileTypeValue[];
nationality?: CompanyNationality; nationality?: CompanyNationality;
cooperative?: boolean; cooperative?: boolean;
investorLicence?: boolean;
}): Promise<CompanyInfoResponse> => { }): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>( const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
@@ -371,6 +374,18 @@ export const companiesService = {
return unwrap(response.data); 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> => { setOnboardingStep: async (payload: { step: string }): Promise<void> => {
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload); await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
}, },

View File

@@ -8,6 +8,8 @@ export interface ProfileResponse {
nationality: string | null; nationality: string | null;
/** No business licence: the registration is typed, not fetched from eTrade. */ /** No business licence: the registration is typed, not fetched from eTrade. */
cooperative: boolean; cooperative: boolean;
/** Foreign investor on an investment licence: same typed registration, no eTrade record. */
investorLicence: boolean;
companyProfiles: CompanyProfileResponse[]; companyProfiles: CompanyProfileResponse[];
companyLocation: string; companyLocation: string;
companyAddress: string | null; companyAddress: string | null;