mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -162,6 +162,13 @@ export default function OnboardingWizardDialog({
|
||||
const [cooperative, setCooperative] = useState<boolean>(
|
||||
company?.company?.attributes?.cooperative === true,
|
||||
);
|
||||
// A foreign company operating on an Ethiopian Investment Commission licence.
|
||||
// eTrade holds nothing for its TIN, so it types the registration exactly as a
|
||||
// co-operative does — but it still holds a licence per role, so nothing about
|
||||
// the licence step changes.
|
||||
const [investorLicence, setInvestorLicence] = useState<boolean>(
|
||||
company?.company?.attributes?.investorLicence === true,
|
||||
);
|
||||
// Ticking the box drops the selections the company can no longer hold, rather
|
||||
// than letting Continue fail on ones the API refuses: a co-op cannot forward
|
||||
// freight, and is registered in Ethiopia so it is never foreign.
|
||||
@@ -171,8 +178,20 @@ export default function OnboardingWizardDialog({
|
||||
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
||||
// Ethiopian is then the only answer left, so it is made rather than asked.
|
||||
setNationality("ethiopian");
|
||||
// Which also rules out the investment licence — that is a foreign
|
||||
// company's, and the API refuses the pair.
|
||||
setInvestorLicence(false);
|
||||
}
|
||||
}, []);
|
||||
// The investment licence is a foreign company's document. Moving the answer
|
||||
// back to Ethiopian drops it rather than sending a pair the API refuses.
|
||||
const handleNationalityChange = useCallback(
|
||||
(value: CompanyNationality | null) => {
|
||||
setNationality(value);
|
||||
if (value !== "foreign") setInvestorLicence(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const [documentFiles, setDocumentFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
@@ -224,6 +243,7 @@ export default function OnboardingWizardDialog({
|
||||
roles: ProfileTypeValue[];
|
||||
nationality?: CompanyNationality;
|
||||
cooperative?: boolean;
|
||||
investorLicence?: boolean;
|
||||
}) => api.companies.startOnboarding.call(vars),
|
||||
onSuccess: async () => {
|
||||
// Nationality drives the server-resolved identity requirements (Fayda vs
|
||||
@@ -307,6 +327,7 @@ export default function OnboardingWizardDialog({
|
||||
setRoles(existingProfiles.map((p) => p.type));
|
||||
setNationality(savedNationality);
|
||||
setCooperative(company?.company?.attributes?.cooperative === true);
|
||||
setInvestorLicence(company?.company?.attributes?.investorLicence === true);
|
||||
// Resume into the form only when profiles exist; otherwise send the user to
|
||||
// role selection so the missing operational profiles get created.
|
||||
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
|
||||
@@ -322,8 +343,9 @@ export default function OnboardingWizardDialog({
|
||||
roles: roles as ProfileTypeValue[],
|
||||
nationality: nationality ?? undefined,
|
||||
cooperative,
|
||||
investorLicence,
|
||||
});
|
||||
}, [roles, nationality, cooperative, startMutation]);
|
||||
}, [roles, nationality, cooperative, investorLicence, startMutation]);
|
||||
|
||||
// Back from the form's first step returns to nationality/role selection.
|
||||
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
|
||||
@@ -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({
|
||||
</Text>
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
onChange={handleNationalityChange}
|
||||
embedded
|
||||
// A co-op is registered in Ethiopia by the co-operative
|
||||
// promotion agency — foreign is not on offer rather than
|
||||
@@ -563,6 +589,22 @@ export default function OnboardingWizardDialog({
|
||||
label="We're a co-operative union or farm"
|
||||
description="For members with a TIN but no business licence. You'll type your registration details instead of us pulling them from eTrade, and upload your co-operative papers in place of a trade licence."
|
||||
/>
|
||||
{/* Only a foreign company is offered this: the licence is the
|
||||
Investment Commission's, and it is the reason eTrade has
|
||||
nothing to look up. Same consequence as the co-operative box —
|
||||
typed registration instead of a lookup — but the per-role
|
||||
business licence still applies, so the documents step is
|
||||
unchanged. */}
|
||||
{nationality === "foreign" && !cooperative && (
|
||||
<Checkbox
|
||||
checked={investorLicence}
|
||||
onChange={(e) =>
|
||||
setInvestorLicence(e.currentTarget.checked)
|
||||
}
|
||||
label="We operate on a foreign investment licence"
|
||||
description="For investors registered with the Ethiopian Investment Commission rather than the trade registry. eTrade holds no record of your TIN, so you'll type your registration details instead — and our team reviews them by hand."
|
||||
/>
|
||||
)}
|
||||
<Text fw={600} size="lg" c="edr-text">
|
||||
What does your company do?(multiple)
|
||||
</Text>
|
||||
|
||||
@@ -103,6 +103,7 @@ export const URL_CONSTANTS = {
|
||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||
ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements",
|
||||
ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade",
|
||||
DASHBOARD: "/api/companies/dashboard",
|
||||
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
|
||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||
|
||||
@@ -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<CompanyStep>(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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,10 +17,13 @@ export interface CompanyInfoStepProps {
|
||||
/** Registration fields are already populated (a lookup passed, now or earlier). */
|
||||
hasRegistrationDetails: boolean;
|
||||
/**
|
||||
* The company is a co-operative union or farm: it has a TIN but no business
|
||||
* licence, so eTrade holds no record to look up and the registration is typed
|
||||
* here instead.
|
||||
* eTrade holds no record for this company's TIN, so the registration is typed
|
||||
* here rather than fetched. True for a co-operative union or farm (no
|
||||
* business licence) and for a foreign investor (licensed by the Investment
|
||||
* Commission, not the trade registry).
|
||||
*/
|
||||
manualRegistration?: boolean;
|
||||
/** Which of the two it is — wording only; the behaviour is the same. */
|
||||
cooperative?: boolean;
|
||||
onETradeDataLoaded: (data: CompanyRegistrationData) => void;
|
||||
onETradeStatusChange: (status: ETradeStatus) => void;
|
||||
@@ -32,6 +35,7 @@ export default function CompanyInfoStep({
|
||||
tinStatus,
|
||||
tinVerified,
|
||||
hasRegistrationDetails,
|
||||
manualRegistration = false,
|
||||
cooperative = false,
|
||||
onETradeDataLoaded,
|
||||
onETradeStatusChange,
|
||||
@@ -71,14 +75,16 @@ export default function CompanyInfoStep({
|
||||
index={2}
|
||||
title="Company TIN"
|
||||
subtitle={
|
||||
cooperative
|
||||
? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below."
|
||||
: "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||
!manualRegistration
|
||||
? "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||
: cooperative
|
||||
? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below."
|
||||
: "We'll check eTrade for your TIN. An investment licence usually isn't on it — if yours isn't, you'll fill the details in below."
|
||||
}
|
||||
status={
|
||||
tinStatus === "taken"
|
||||
? "blocked"
|
||||
: cooperative
|
||||
: manualRegistration
|
||||
? watch("tinNumber")?.trim() && !errors.tinNumber
|
||||
? "done"
|
||||
: "todo"
|
||||
@@ -96,26 +102,26 @@ export default function CompanyInfoStep({
|
||||
onReset={onETradeReset}
|
||||
alreadyVerified={hasRegistrationDetails}
|
||||
selectedLicenceNumber={watch("licenceNumber")}
|
||||
registrationOptional={cooperative}
|
||||
registrationOptional={manualRegistration}
|
||||
/>
|
||||
{!cooperative && tinVerified && (
|
||||
{!manualRegistration && tinVerified && (
|
||||
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
{/* A co-operative keeps its typed registration section either way. When
|
||||
the lookup found something these arrive prefilled — still editable,
|
||||
because for a co-op they are the customer's own statement rather than
|
||||
the licence's, and the API takes them as given (`applyEtradeSourcedFields`
|
||||
skips co-operatives entirely). */}
|
||||
{cooperative && (
|
||||
{/* A company eTrade cannot answer for keeps its typed registration
|
||||
section either way. When the lookup did find something these arrive
|
||||
prefilled — still editable, because here they are the customer's own
|
||||
statement rather than the licence's, and the API takes them as given
|
||||
(`applyEtradeSourcedFields` skips both cases entirely). */}
|
||||
{manualRegistration && (
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Registration details"
|
||||
subtitle={
|
||||
hasRegistrationDetails
|
||||
? "From eTrade. Correct anything that doesn't look right — for a co-operative these are yours to state."
|
||||
: "Everything we'd normally read off an eTrade licence. We need it from you instead."
|
||||
? "From eTrade. Correct anything that doesn't look right — these are yours to state."
|
||||
: "Everything we'd normally read off an eTrade licence. We need it from you instead. Our team checks it against the papers you upload."
|
||||
}
|
||||
status={
|
||||
watch("companyName")?.trim() && watch("region")?.trim()
|
||||
@@ -126,7 +132,11 @@ export default function CompanyInfoStep({
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Registered name of the union or farm"
|
||||
placeholder={
|
||||
cooperative
|
||||
? "Registered name of the union or farm"
|
||||
: "Name on your investment licence"
|
||||
}
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface OwnerStepProps {
|
||||
sourced: Record<OwnerField, string>;
|
||||
/** A co-operative union or farm: no licence, so no eTrade record to match. */
|
||||
cooperative?: boolean;
|
||||
/** No eTrade record at all (co-operative or foreign investment licence). */
|
||||
manualRegistration?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,7 @@ export default function OwnerStep({
|
||||
source,
|
||||
sourced,
|
||||
cooperative = false,
|
||||
manualRegistration = false,
|
||||
}: OwnerStepProps) {
|
||||
const {
|
||||
register,
|
||||
@@ -74,15 +77,17 @@ export default function OwnerStep({
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="edr-muted">
|
||||
{cooperative && !etradeOwner
|
||||
? "The person who runs the co-operative union or farm. eTrade held no record for your TIN, so we need all of these from you."
|
||||
{manualRegistration && !etradeOwner
|
||||
? cooperative
|
||||
? "The person who runs the co-operative union or farm. eTrade held no record for your TIN, so we need all of these from you."
|
||||
: "The person your investment licence names. eTrade held no record for your TIN, so we need all of these from you."
|
||||
: "These are the details of the person registered on your eTrade licence. What eTrade and Fayda gave us is shown as they gave it; anything they left blank we need from you."}
|
||||
</Text>
|
||||
|
||||
{/* A co-operative is not told its licence listed no manager — it has no
|
||||
licence. Its own "nothing came back" case is covered by the line
|
||||
above. */}
|
||||
{!cooperative && !etradeOwner && !ownerVerified && (
|
||||
{/* A company with no eTrade record is not told its licence listed no
|
||||
manager — eTrade never held one. That "nothing came back" case is
|
||||
covered by the line above. */}
|
||||
{!manualRegistration && !etradeOwner && !ownerVerified && (
|
||||
<Alert color="blue" variant="light" icon={<Info size={18} />}>
|
||||
Your eTrade licence didn't list a manager, so there's nothing for us
|
||||
to prefill. Enter the details of the person registered on it.
|
||||
|
||||
@@ -241,10 +241,18 @@ export const api = {
|
||||
nationality?: CompanyNationality;
|
||||
/** No business licence: registration typed, no eTrade lookup, no forwarding. */
|
||||
cooperative?: boolean;
|
||||
/** Foreign investment licence: registration typed, no eTrade lookup. */
|
||||
investorLicence?: boolean;
|
||||
},
|
||||
CompanyInfoResponse
|
||||
>("companies", "startOnboarding", companiesService.startOnboarding),
|
||||
|
||||
revertToRegularCompany: endpoint<void, CompanyInfoResponse>(
|
||||
"companies",
|
||||
"revertToRegularCompany",
|
||||
companiesService.revertToRegularCompany,
|
||||
),
|
||||
|
||||
setOnboardingStep: endpoint<{ step: string }, void>(
|
||||
"companies",
|
||||
"setOnboardingStep",
|
||||
|
||||
@@ -223,6 +223,8 @@ export interface OnboardingRequirements {
|
||||
nationality: string;
|
||||
/** No business licence: registration typed by hand, no eTrade lookup. */
|
||||
cooperative: boolean;
|
||||
/** Foreign investment licence: registration typed by hand, no eTrade record. */
|
||||
investorLicence: boolean;
|
||||
companyInfo: {
|
||||
complete: boolean;
|
||||
missingFields: { key: string; label: string }[];
|
||||
@@ -363,6 +365,7 @@ export const companiesService = {
|
||||
roles: ProfileTypeValue[];
|
||||
nationality?: CompanyNationality;
|
||||
cooperative?: boolean;
|
||||
investorLicence?: boolean;
|
||||
}): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
|
||||
@@ -371,6 +374,18 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Give up the foreign investment-licence route and go back through eTrade.
|
||||
* The API clears the typed registration and reopens onboarding at the company
|
||||
* step, so the caller must refresh the company info afterwards.
|
||||
*/
|
||||
revertToRegularCompany: async (): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REVERT_TO_ETRADE,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
|
||||
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface ProfileResponse {
|
||||
nationality: string | null;
|
||||
/** No business licence: the registration is typed, not fetched from eTrade. */
|
||||
cooperative: boolean;
|
||||
/** Foreign investor on an investment licence: same typed registration, no eTrade record. */
|
||||
investorLicence: boolean;
|
||||
companyProfiles: CompanyProfileResponse[];
|
||||
companyLocation: string;
|
||||
companyAddress: string | null;
|
||||
|
||||
Reference in New Issue
Block a user