mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 19:28:17 +00:00
Merge pull request #1332 from Tria-plc/freight/feat/foreign-investors
Freight/feat/foreign investors
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
|
||||
@@ -470,6 +492,15 @@ export default function OnboardingWizardDialog({
|
||||
licenseFiles,
|
||||
onLicenseChange: setLicenseFiles,
|
||||
uploadedDocumentKeys,
|
||||
// What the server says is already on file, per operational profile. The
|
||||
// wizard's own `roleProfiles` cannot say: getInfo leaves `licenseFiles`
|
||||
// empty, so a resumed wizard asked for a licence it had already been given
|
||||
// and refused to submit until it was uploaded a second time.
|
||||
uploadedLicenceProfileIds: (
|
||||
requirementsQuery.data?.licenseProfiles ?? []
|
||||
)
|
||||
.filter((p) => p.uploaded)
|
||||
.map((p) => p.profileId),
|
||||
onUploadDocuments: handleUploadDocuments,
|
||||
// The company's single identity verification, and whose it is. Fayda is
|
||||
// mandatory for an Ethiopian company; a foreign one may instead type a
|
||||
@@ -479,6 +510,10 @@ export default function OnboardingWizardDialog({
|
||||
// startOnboarding has persisted it, and the form's whole company step
|
||||
// branches on it.
|
||||
cooperative: requirementsQuery.data?.cooperative ?? cooperative,
|
||||
// Same rule, same reason: only a persisted flag changes what the company
|
||||
// step asks for.
|
||||
investorLicence:
|
||||
requirementsQuery.data?.investorLicence ?? investorLicence,
|
||||
// A freight forwarder cannot answer the power-of-attorney question — the
|
||||
// API forces "yes" — so the step offers no way to change it.
|
||||
declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
|
||||
@@ -546,7 +581,7 @@ export default function OnboardingWizardDialog({
|
||||
</Text>
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
onChange={handleNationalityChange}
|
||||
embedded
|
||||
// A co-op is registered in Ethiopia by the co-operative
|
||||
// promotion agency — foreign is not on offer rather than
|
||||
@@ -563,6 +598,22 @@ export default function OnboardingWizardDialog({
|
||||
label="We're a co-operative union or farm"
|
||||
description="For members with a TIN but no business licence. You'll type your registration details instead of us pulling them from eTrade, and upload your co-operative papers in place of a trade licence."
|
||||
/>
|
||||
{/* Only a foreign company is offered this: the licence is the
|
||||
Investment Commission's, and it is the reason eTrade has
|
||||
nothing to look up. Same consequence as the co-operative box —
|
||||
typed registration instead of a lookup — but the per-role
|
||||
business licence still applies, so the documents step is
|
||||
unchanged. */}
|
||||
{nationality === "foreign" && !cooperative && (
|
||||
<Checkbox
|
||||
checked={investorLicence}
|
||||
onChange={(e) =>
|
||||
setInvestorLicence(e.currentTarget.checked)
|
||||
}
|
||||
label="We operate on a foreign investment licence"
|
||||
description="For investors registered with the Ethiopian Investment Commission rather than the trade registry. eTrade holds no record of your TIN, so you'll type your registration details instead — and our team reviews them by hand."
|
||||
/>
|
||||
)}
|
||||
<Text fw={600} size="lg" c="edr-text">
|
||||
What does your company do?(multiple)
|
||||
</Text>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -47,6 +47,7 @@ import useAuth from "@/hooks/useAuth";
|
||||
import { rolesForCompanyType } from "./settings/companyRoles";
|
||||
import TabAccount from "./settings/TabAccount";
|
||||
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
||||
import RegistrationSourceCard from "./settings/RegistrationSourceCard";
|
||||
import TabContactPerson from "./settings/TabContactPerson";
|
||||
import TabDocuments from "./settings/TabDocuments";
|
||||
import TabOwner from "./settings/TabOwner";
|
||||
@@ -398,6 +399,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</Fieldset>
|
||||
<OperationalServicesCard profile={profile} />
|
||||
<RegistrationSourceCard profile={profile} disabled={locked} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="contact">
|
||||
<TabContactPerson profile={profile} mode="edit" />
|
||||
|
||||
@@ -61,10 +61,12 @@ export default function CompanyProfileForm({
|
||||
onLicenseChange,
|
||||
submitError,
|
||||
uploadedDocumentKeys,
|
||||
uploadedLicenceProfileIds,
|
||||
onUploadDocuments,
|
||||
identity: rawIdentity,
|
||||
onIdentityChange,
|
||||
cooperative = false,
|
||||
investorLicence = false,
|
||||
declarationLocked = false,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
@@ -95,6 +97,16 @@ export default function CompanyProfileForm({
|
||||
submitError?: string | null;
|
||||
/** fileKeys whose company document is already uploaded server-side (resume). */
|
||||
uploadedDocumentKeys?: string[];
|
||||
/**
|
||||
* Profile ids whose business licence the server already holds.
|
||||
*
|
||||
* `roleProfiles.existingFiles` cannot answer this on a resumed wizard:
|
||||
* `getInfo` does not populate `licenseFiles`, so every profile looks empty
|
||||
* however many licences are on file. Taken from the onboarding requirements,
|
||||
* which is the server's own verdict and what `markOnboardingComplete`
|
||||
* enforces.
|
||||
*/
|
||||
uploadedLicenceProfileIds?: string[];
|
||||
/**
|
||||
* Auto-upload the currently-selected company documents (the Documents step's
|
||||
* "Continue" action). Resolves to an error message string on failure so the
|
||||
@@ -118,6 +130,13 @@ export default function CompanyProfileForm({
|
||||
* nationality one (resolved by the caller into `documentSettingCode`).
|
||||
*/
|
||||
cooperative?: boolean;
|
||||
/**
|
||||
* The company is a foreign investor on an Investment Commission licence. Like
|
||||
* a co-operative, eTrade holds no record of it, so the registration is typed
|
||||
* and the lookup gate does not apply — but it does hold a business licence
|
||||
* per role, so the licence step is untouched.
|
||||
*/
|
||||
investorLicence?: boolean;
|
||||
/**
|
||||
* The company operates as a freight forwarder, so the power-of-attorney
|
||||
* answer is forced to "yes" and cannot be changed here.
|
||||
@@ -133,6 +152,12 @@ export default function CompanyProfileForm({
|
||||
[rawIdentity],
|
||||
);
|
||||
|
||||
// eTrade has nothing to say about this company, whichever of the two reasons
|
||||
// applies — so the registration is typed here and the lookup cannot gate the
|
||||
// step. Everything the two cases do NOT share (the per-role business licence)
|
||||
// keeps reading `cooperative` on its own.
|
||||
const manualRegistration = cooperative || investorLicence;
|
||||
|
||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||
const [saving, setSaving] = useState(false);
|
||||
/**
|
||||
@@ -434,7 +459,7 @@ export default function CompanyProfileForm({
|
||||
// holds nothing — and wiping them because the customer went back to fix a
|
||||
// digit of their TIN would throw away an address they had just typed by
|
||||
// hand, over a lookup that never filled anything in the first place.
|
||||
if (cooperative && !etradeFilledRef.current) {
|
||||
if (manualRegistration && !etradeFilledRef.current) {
|
||||
setLiveEtradeOwner(null);
|
||||
setEtradeCleared(true);
|
||||
return;
|
||||
@@ -674,7 +699,9 @@ export default function CompanyProfileForm({
|
||||
if (cooperative) return errs;
|
||||
for (const p of roleProfiles ?? []) {
|
||||
const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0;
|
||||
const hasExisting = p.existingFiles.length > 0;
|
||||
const hasExisting =
|
||||
p.existingFiles.length > 0 ||
|
||||
(uploadedLicenceProfileIds ?? []).includes(p.id);
|
||||
if (!hasNew && !hasExisting) {
|
||||
errs[p.id] = "Business license is required";
|
||||
}
|
||||
@@ -730,7 +757,7 @@ export default function CompanyProfileForm({
|
||||
// A co-operative never runs the lookup, so there is nothing to be verified
|
||||
// against; its TIN is validated by the schema like any other typed field.
|
||||
const tinVerified =
|
||||
cooperative || tinStatus === "verified" || hasRegistrationDetails;
|
||||
manualRegistration || tinStatus === "verified" || hasRegistrationDetails;
|
||||
|
||||
// Single source of truth for step sequence — navigation, labels and the
|
||||
// progress bar all derive from this so adding/removing a step is one edit.
|
||||
@@ -770,8 +797,8 @@ export default function CompanyProfileForm({
|
||||
Boolean(watch(passportField)?.trim()));
|
||||
|
||||
const requiredKeys: (keyof FormData)[] = [];
|
||||
if (step === "company" && cooperative) {
|
||||
// A co-operative has no eTrade record, so the fields every other company
|
||||
if (step === "company" && manualRegistration) {
|
||||
// These companies have no eTrade record, so the fields every other company
|
||||
// gets read-only from the licence are typed here — and are therefore
|
||||
// required here. House number stays optional: plenty of addresses have none.
|
||||
requiredKeys.push("companyName", "region", "zone", "woreda", "kebele");
|
||||
@@ -887,8 +914,9 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
// The TIN must resolve to a real eTrade record before anything else on
|
||||
// this step is even worth validating — gates here rather than through zod.
|
||||
// A co-operative is exempt: it has no licence for eTrade to hold, so
|
||||
// `tinVerified` is true for it and only the duplicate-TIN check applies.
|
||||
// A co-operative and a foreign investor are exempt: eTrade holds no record
|
||||
// for either, so `tinVerified` is true and only the duplicate-TIN check
|
||||
// applies.
|
||||
if (step === "company" && tinStatus === "taken") {
|
||||
failCheck(
|
||||
"This TIN is already registered to another company account.",
|
||||
@@ -987,6 +1015,7 @@ export default function CompanyProfileForm({
|
||||
tinStatus={tinStatus}
|
||||
tinVerified={tinVerified}
|
||||
hasRegistrationDetails={hasRegistrationDetails}
|
||||
manualRegistration={manualRegistration}
|
||||
cooperative={cooperative}
|
||||
onETradeDataLoaded={handleETradeDataLoaded}
|
||||
onETradeStatusChange={setTinStatus}
|
||||
@@ -1002,6 +1031,7 @@ export default function CompanyProfileForm({
|
||||
source={ownerSource}
|
||||
sourced={ownerSourced}
|
||||
cooperative={cooperative}
|
||||
manualRegistration={manualRegistration}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,10 +17,13 @@ export interface CompanyInfoStepProps {
|
||||
/** Registration fields are already populated (a lookup passed, now or earlier). */
|
||||
hasRegistrationDetails: boolean;
|
||||
/**
|
||||
* The company is a co-operative union or farm: it has a TIN but no business
|
||||
* licence, so eTrade holds no record to look up and the registration is typed
|
||||
* here instead.
|
||||
* eTrade holds no record for this company's TIN, so the registration is typed
|
||||
* here rather than fetched. True for a co-operative union or farm (no
|
||||
* business licence) and for a foreign investor (licensed by the Investment
|
||||
* Commission, not the trade registry).
|
||||
*/
|
||||
manualRegistration?: boolean;
|
||||
/** Which of the two it is — wording only; the behaviour is the same. */
|
||||
cooperative?: boolean;
|
||||
onETradeDataLoaded: (data: CompanyRegistrationData) => void;
|
||||
onETradeStatusChange: (status: ETradeStatus) => void;
|
||||
@@ -32,6 +35,7 @@ export default function CompanyInfoStep({
|
||||
tinStatus,
|
||||
tinVerified,
|
||||
hasRegistrationDetails,
|
||||
manualRegistration = false,
|
||||
cooperative = false,
|
||||
onETradeDataLoaded,
|
||||
onETradeStatusChange,
|
||||
@@ -71,14 +75,16 @@ export default function CompanyInfoStep({
|
||||
index={2}
|
||||
title="Company TIN"
|
||||
subtitle={
|
||||
cooperative
|
||||
? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below."
|
||||
: "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||
!manualRegistration
|
||||
? "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||
: cooperative
|
||||
? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below."
|
||||
: "We'll check eTrade for your TIN. An investment licence usually isn't on it — if yours isn't, you'll fill the details in below."
|
||||
}
|
||||
status={
|
||||
tinStatus === "taken"
|
||||
? "blocked"
|
||||
: cooperative
|
||||
: manualRegistration
|
||||
? watch("tinNumber")?.trim() && !errors.tinNumber
|
||||
? "done"
|
||||
: "todo"
|
||||
@@ -96,26 +102,26 @@ export default function CompanyInfoStep({
|
||||
onReset={onETradeReset}
|
||||
alreadyVerified={hasRegistrationDetails}
|
||||
selectedLicenceNumber={watch("licenceNumber")}
|
||||
registrationOptional={cooperative}
|
||||
registrationOptional={manualRegistration}
|
||||
/>
|
||||
{!cooperative && tinVerified && (
|
||||
{!manualRegistration && tinVerified && (
|
||||
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
{/* A co-operative keeps its typed registration section either way. When
|
||||
the lookup found something these arrive prefilled — still editable,
|
||||
because for a co-op they are the customer's own statement rather than
|
||||
the licence's, and the API takes them as given (`applyEtradeSourcedFields`
|
||||
skips co-operatives entirely). */}
|
||||
{cooperative && (
|
||||
{/* A company eTrade cannot answer for keeps its typed registration
|
||||
section either way. When the lookup did find something these arrive
|
||||
prefilled — still editable, because here they are the customer's own
|
||||
statement rather than the licence's, and the API takes them as given
|
||||
(`applyEtradeSourcedFields` skips both cases entirely). */}
|
||||
{manualRegistration && (
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Registration details"
|
||||
subtitle={
|
||||
hasRegistrationDetails
|
||||
? "From eTrade. Correct anything that doesn't look right — for a co-operative these are yours to state."
|
||||
: "Everything we'd normally read off an eTrade licence. We need it from you instead."
|
||||
? "From eTrade. Correct anything that doesn't look right — these are yours to state."
|
||||
: "Everything we'd normally read off an eTrade licence. We need it from you instead. Our team checks it against the papers you upload."
|
||||
}
|
||||
status={
|
||||
watch("companyName")?.trim() && watch("region")?.trim()
|
||||
@@ -126,7 +132,11 @@ export default function CompanyInfoStep({
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Registered name of the union or farm"
|
||||
placeholder={
|
||||
cooperative
|
||||
? "Registered name of the union or farm"
|
||||
: "Name on your investment licence"
|
||||
}
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
@@ -149,7 +159,15 @@ export default function CompanyInfoStep({
|
||||
searchable
|
||||
value={region || null}
|
||||
onChange={(v) =>
|
||||
setValue("region", v ?? "", { shouldValidate: true })
|
||||
// shouldDirty, or the pick never reaches the API: `region` is
|
||||
// an eTrade-bundle key, and `stepPayload` sends those only
|
||||
// when the customer changed them this session. Without it a
|
||||
// co-operative or foreign investor typed its address and the
|
||||
// region alone silently vanished on save.
|
||||
setValue("region", v ?? "", {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
error={errors.region?.message}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
List,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, FileSearch } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/**
|
||||
* Leave whichever manual-registration route the company is on — a co-operative
|
||||
* union or farm, or a foreign investment licence — and go back to the ordinary
|
||||
* eTrade one. For a company that has since been registered with the trade
|
||||
* registry, or that ticked the box by mistake.
|
||||
*
|
||||
* It is a re-application, not a settings edit: the API clears the registration
|
||||
* the customer typed (nothing on file was ever checked against a licence) and
|
||||
* puts the company back to pending, so the wizard reopens on the company step
|
||||
* and the TIN goes through eTrade this time. Said plainly here rather than
|
||||
* discovered afterwards.
|
||||
*
|
||||
* Only the way OUT is here. Moving between the three registration sources in
|
||||
* the other direction is the wizard's own step, which this reopens — that is
|
||||
* where the rules about which combinations are legal already live, and a second
|
||||
* picker would have to restate every one of them.
|
||||
*/
|
||||
export default function RegistrationSourceCard({
|
||||
profile,
|
||||
disabled = false,
|
||||
}: {
|
||||
profile: ProfileResponse;
|
||||
/** A change request is under review — switching now would strand it. */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const revert = useMutation({
|
||||
mutationFn: () => api.companies.revertToRegularCompany.call(),
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
// getInfo is what the onboarding gate reads, so refreshing it is what
|
||||
// reopens the wizard.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.onboardingRequirements.queryKey(),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (err) => setError(extractApiError(err).message),
|
||||
});
|
||||
|
||||
// Which of the two routes this is — wording only; leaving costs the same.
|
||||
const cooperative = profile.cooperative;
|
||||
if (!profile.investorLicence && !cooperative) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding="lg" radius="lg" mt="lg">
|
||||
<Group gap="sm" mb="md">
|
||||
<FileSearch size={20} />
|
||||
<Title order={3}>Registration source</Title>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="edr-muted">
|
||||
{cooperative
|
||||
? "Your company is registered as a co-operative union or farm, so your registration details were entered by hand instead of being read from eTrade. Our team reviews them against the documents you uploaded."
|
||||
: "Your company is registered on a foreign investment licence, so your registration details were entered by hand instead of being read from eTrade. Our team reviews them against the documents you uploaded."}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted">
|
||||
If your company now holds an eTrade trade licence, you can switch
|
||||
over and have your registration verified automatically.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setConfirming(true);
|
||||
}}
|
||||
>
|
||||
Switch to eTrade registration
|
||||
</Button>
|
||||
</Group>
|
||||
{disabled && (
|
||||
<Text size="xs" c="edr-muted" ta="right">
|
||||
Not available while your profile changes are under review.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={confirming}
|
||||
onClose={() => setConfirming(false)}
|
||||
title="Switch to eTrade registration?"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">This re-opens your application:</Text>
|
||||
<List size="sm" spacing="xs">
|
||||
<List.Item>
|
||||
The registration details you typed are cleared — eTrade supplies
|
||||
them once your TIN is found.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Your company goes back to pending and is reviewed again.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Your documents, owner and contact details stay as they are — but
|
||||
the papers we ask for change with the registration source, so some
|
||||
may be listed as outstanding again.
|
||||
</List.Item>
|
||||
{cooperative && (
|
||||
<List.Item>
|
||||
A business licence becomes due for each of your operational
|
||||
services — a co-operative owes none, an eTrade-registered
|
||||
company does — so any that are already approved go back to
|
||||
awaiting approval until you upload one. Their reference numbers
|
||||
stay the same.
|
||||
</List.Item>
|
||||
)}
|
||||
</List>
|
||||
<Alert color="amber" variant="light" icon={<AlertCircle size={18} />}>
|
||||
If eTrade holds no record for your TIN you won't be able to finish —
|
||||
the wizard reopens on the company step, so go back one step and
|
||||
re-select {cooperative ? "co-operative" : "the investment licence"}{" "}
|
||||
there.
|
||||
</Alert>
|
||||
{error && (
|
||||
<Text size="sm" c="red">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setConfirming(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={revert.isPending}
|
||||
onClick={() => revert.mutate()}
|
||||
>
|
||||
Switch and re-apply
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -20,18 +21,22 @@ import {
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
|
||||
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
||||
import ETradeInfo, {
|
||||
type ETradeStatus,
|
||||
} from "@/components/onboarding/ETradeInfo";
|
||||
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
||||
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
||||
import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema";
|
||||
import {
|
||||
ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS,
|
||||
onboardingSchema,
|
||||
} from "@/pages/accounts/companyProfileForm/schema";
|
||||
|
||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
const BASE_COMPANY_PROFILE_SCHEMA = z.object({
|
||||
// eTrade-sourced and read-only, like the registration block below.
|
||||
companyName: z.string().optional(),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
@@ -39,12 +44,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
// no standalone input.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
// Same rule as onboarding — the two forms write the same column, so they must
|
||||
// not disagree about what is acceptable in it.
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
|
||||
// Onboarding's own rule, reused rather than restated: the two forms write the
|
||||
// same column, and the copy here had drifted into a 10-or-11-digit check that
|
||||
// onboarding and the API both refuse to make. A foreign company's VAT is
|
||||
// whatever its tax authority issues and a co-operative's follows neither, so
|
||||
// the stricter copy locked those customers out of their own Company tab
|
||||
// entirely — every save on it, not just the VAT.
|
||||
vatNumber: onboardingSchema.shape.vatNumber,
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
// Registration/address fields are eTrade-sourced and never typed by hand —
|
||||
// not even when eTrade leaves one blank, so none of them may be required
|
||||
@@ -62,7 +68,48 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
houseNo: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
||||
/**
|
||||
* The registration fields a manual-registration company types by hand.
|
||||
*
|
||||
* eTrade holds no record for a co-operative union or farm, nor for a foreign
|
||||
* investor on an Investment Commission licence, so the block every other
|
||||
* company gets read-only off the licence is typed by these two — and is
|
||||
* therefore required of them, exactly as the onboarding wizard requires it.
|
||||
* House number stays optional: plenty of addresses have none.
|
||||
*/
|
||||
const TYPED_REGISTRATION_LABELS = {
|
||||
companyName: "Company name",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `manualRegistration` is the only thing that changes here, and it changes the
|
||||
* same way it does in the wizard: **a field is required iff there is an input
|
||||
* on screen for it.** For an eTrade company these are read-only rows, so
|
||||
* requiring one would be a Save button failing on a field with nothing to fix.
|
||||
*/
|
||||
export const buildCompanyProfileSchema = (manualRegistration: boolean) =>
|
||||
manualRegistration
|
||||
? BASE_COMPANY_PROFILE_SCHEMA.superRefine((d, ctx) => {
|
||||
for (const key of Object.keys(
|
||||
TYPED_REGISTRATION_LABELS,
|
||||
) as (keyof typeof TYPED_REGISTRATION_LABELS)[]) {
|
||||
if (d[key]?.trim()) continue;
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [key],
|
||||
message: `${TYPED_REGISTRATION_LABELS[key]} is required`,
|
||||
});
|
||||
}
|
||||
})
|
||||
: BASE_COMPANY_PROFILE_SCHEMA;
|
||||
|
||||
export type CompanyProfileFormData = z.infer<
|
||||
typeof BASE_COMPANY_PROFILE_SCHEMA
|
||||
>;
|
||||
|
||||
/**
|
||||
* `etradePhone` is not on this form, so the shared list is filtered down to the
|
||||
@@ -86,6 +133,17 @@ export default function TabCompanyProfile({
|
||||
}: TabCompanyProfileProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isCreate = mode === "create";
|
||||
/**
|
||||
* eTrade has nothing to say about this company — a co-operative holds no
|
||||
* business licence, a foreign investor's is the Investment Commission's — so
|
||||
* its registration was typed during onboarding and has to stay editable here.
|
||||
* Rendering the eTrade card instead left that data invisible and frozen: the
|
||||
* one company that owns its registration details was the one that could not
|
||||
* see them.
|
||||
*/
|
||||
const manualRegistration = Boolean(
|
||||
profile?.cooperative || profile?.investorLicence,
|
||||
);
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
|
||||
|
||||
@@ -140,7 +198,7 @@ export default function TabCompanyProfile({
|
||||
setValue,
|
||||
formState: { errors, isDirty, dirtyFields },
|
||||
} = useForm<CompanyProfileFormData>({
|
||||
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
|
||||
resolver: zodResolver(buildCompanyProfileSchema(manualRegistration)),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
@@ -315,7 +373,6 @@ export default function TabCompanyProfile({
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
placeholder="e.g. 0012345678"
|
||||
maxLength={11}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
@@ -325,13 +382,21 @@ export default function TabCompanyProfile({
|
||||
<StepSection
|
||||
index={2}
|
||||
title="Company TIN"
|
||||
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||
subtitle={
|
||||
manualRegistration
|
||||
? "We'll check eTrade for your TIN. If it holds nothing — the usual case here — the details below stay yours to state."
|
||||
: "Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||
}
|
||||
status={
|
||||
tinVerified
|
||||
? "done"
|
||||
: tinStatus === "taken"
|
||||
? "blocked"
|
||||
: "todo"
|
||||
tinStatus === "taken"
|
||||
? "blocked"
|
||||
: manualRegistration
|
||||
? watch("tinNumber")?.trim() && !errors.tinNumber
|
||||
? "done"
|
||||
: "todo"
|
||||
: tinVerified
|
||||
? "done"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<ETradeInfo
|
||||
@@ -342,12 +407,33 @@ export default function TabCompanyProfile({
|
||||
onStatusChange={setTinStatus}
|
||||
alreadyVerified={hasRegistrationDetails}
|
||||
selectedLicenceNumber={watch("licenceNumber")}
|
||||
registrationOptional={manualRegistration}
|
||||
/>
|
||||
{tinVerified && (
|
||||
{!manualRegistration && tinVerified && (
|
||||
<EtradeLockedCard tin={watch("tinNumber")} watch={watch} />
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
{manualRegistration && (
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Registration details"
|
||||
subtitle="Everything we'd normally read off an eTrade licence. Our team checks what you state here against the documents you upload."
|
||||
status={
|
||||
watch("companyName")?.trim() && watch("region")?.trim()
|
||||
? "done"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<TypedRegistrationFields
|
||||
register={register}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
/>
|
||||
</StepSection>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
@@ -409,6 +495,86 @@ export default function TabCompanyProfile({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration a manual-registration company states for itself.
|
||||
*
|
||||
* Deliberately editable, unlike `EtradeLockedCard` below: nothing here came
|
||||
* from a licence, so there is no verified record to protect — it is the
|
||||
* customer's own claim, checked by a reviewer against the papers they upload.
|
||||
* Same fields, same rules and same region list as the onboarding wizard's
|
||||
* company step, so a co-operative or investor sees one story in both places.
|
||||
*/
|
||||
function TypedRegistrationFields({
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
errors,
|
||||
}: {
|
||||
register: UseFormRegister<CompanyProfileFormData>;
|
||||
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
||||
setValue: UseFormSetValue<CompanyProfileFormData>;
|
||||
errors: FieldErrors<CompanyProfileFormData>;
|
||||
}) {
|
||||
const region = watch("region") ?? "";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Registered name of the company"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<Text size="sm" c="edr-muted">
|
||||
Registered address
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Select
|
||||
label="Region"
|
||||
placeholder="Select region"
|
||||
// A value eTrade (or an earlier save) supplied may not be spelled the
|
||||
// way our list spells it. Carrying it in as an option keeps it visible
|
||||
// rather than silently blanking a field nobody touched.
|
||||
data={
|
||||
region && !(ETHIOPIAN_REGIONS as readonly string[]).includes(region)
|
||||
? [...ETHIOPIAN_REGIONS, region]
|
||||
: [...ETHIOPIAN_REGIONS]
|
||||
}
|
||||
searchable
|
||||
value={region || null}
|
||||
onChange={(v) =>
|
||||
setValue("region", v ?? "", {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
error={errors.region?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Zone"
|
||||
error={errors.zone?.message}
|
||||
{...register("zone")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Woreda"
|
||||
error={errors.woreda?.message}
|
||||
{...register("woreda")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Kebele"
|
||||
error={errors.kebele?.message}
|
||||
{...register("kebele")}
|
||||
/>
|
||||
<TextInput
|
||||
label="House No."
|
||||
error={errors.houseNo?.message}
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The verified eTrade record, rendered strictly read-only — same rule as
|
||||
* onboarding's ETradeCompanyCard: nothing here is typeable, not even a field
|
||||
|
||||
@@ -5,20 +5,30 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { CheckCircle2, Save, User, XCircle } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The same four fields onboarding collects, with the same requiredness. The
|
||||
// position and the email were captured by the wizard and then had no input
|
||||
// here at all — stored, invisible, and impossible to correct.
|
||||
const schema = z.object({
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPosition: z.string().optional(),
|
||||
contactPersonEmail: z
|
||||
.string()
|
||||
.email("Invalid email address")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
contactPersonPhone: z
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
@@ -39,6 +49,8 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
return {
|
||||
contactPersonName: profile.contactPersonName ?? "",
|
||||
contactPersonPosition: profile.contactPersonPosition ?? "",
|
||||
contactPersonEmail: profile.contactPersonEmail ?? "",
|
||||
contactPersonPhone: profile.contactPersonPhone ?? "",
|
||||
};
|
||||
}, [profile]);
|
||||
@@ -58,6 +70,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
contactPersonName: data.contactPersonName,
|
||||
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
|
||||
// and undefined, so an empty string is validated and 400s with
|
||||
// "contactPersonEmail must be an email".
|
||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||
contactPersonPhone: data.contactPersonPhone,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -80,19 +97,36 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Jane Smith"
|
||||
error={errors.contactPersonName?.message}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Jane Smith"
|
||||
error={errors.contactPersonName?.message}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Position (Optional)"
|
||||
placeholder="Operations Lead"
|
||||
error={errors.contactPersonPosition?.message}
|
||||
{...register("contactPersonPosition")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="contactPersonPhone"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Email (Optional)"
|
||||
type="email"
|
||||
placeholder="contact@company.com"
|
||||
error={errors.contactPersonEmail?.message}
|
||||
{...register("contactPersonEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="contactPersonPhone"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
|
||||
@@ -171,7 +171,11 @@ export default function TabDocuments({
|
||||
return errs;
|
||||
};
|
||||
|
||||
const licenseProfiles = profile.companyProfiles;
|
||||
// A co-operative union or farm holds no business licence — that is the whole
|
||||
// reason it uploads its own document set instead — so the API lifts the
|
||||
// per-role requirement and these cards are not shown. Offering an upload slot
|
||||
// nothing can ever fill reads as an outstanding task that cannot be finished.
|
||||
const licenseProfiles = profile.cooperative ? [] : profile.companyProfiles;
|
||||
|
||||
// Company documents render through SmartFileInput, which is keyed by field and
|
||||
// has no per-file review slot. Surfacing the outstanding corrections as one
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -19,6 +20,11 @@ import { api } from "@/services/api";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField";
|
||||
import {
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
resolveOwnerSources,
|
||||
} from "@/pages/accounts/companyProfileForm/helpers";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// Optional, not unrequired: whatever a Fayda verification supplied is owned by
|
||||
@@ -27,6 +33,10 @@ import type { ProfileResponse } from "@/types/profile";
|
||||
// are actually on screen; zod only polices format.
|
||||
const schema = z.object({
|
||||
ownerName: z.string().optional(),
|
||||
// The alternative credential for a foreign company's owner — asked for only
|
||||
// when they are the identity subject and have not verified with Fayda, so
|
||||
// like the three below it is optional here and gated on what is rendered.
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
ownerEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -64,20 +74,41 @@ export default function TabOwner({
|
||||
onContinue,
|
||||
}: TabOwnerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const identity = profile.identity;
|
||||
// Fayda reports a phone as the national registry holds it, routinely a local
|
||||
// number that neither this form nor the API's `@IsValidPhone()` accepts.
|
||||
// Normalize once on read, exactly as the wizard does.
|
||||
const identity = useMemo(
|
||||
() => normalizeIdentityPhones(profile.identity),
|
||||
[profile.identity],
|
||||
);
|
||||
const owner = identity?.owner;
|
||||
// Only the person the declaration points at carries the verification, so the
|
||||
// panel is offered here only when that person is the owner.
|
||||
const ownerIsSubject = identity?.subject === "owner";
|
||||
// A Fayda verification owns what its claims filled — the API refuses to
|
||||
// overwrite those, so they show read-only. Anything it left blank stays
|
||||
// editable here, whatever value is currently stored.
|
||||
const ownerVerified = owner?.verified ?? false;
|
||||
const ownerLocked = {
|
||||
name: ownerVerified && Boolean(owner?.name?.trim()),
|
||||
email: ownerVerified && Boolean(owner?.email?.trim()),
|
||||
phone: ownerVerified && Boolean(owner?.phone?.trim()),
|
||||
};
|
||||
|
||||
/**
|
||||
* The manager the eTrade licence names, read back from what the lookup
|
||||
* captured. The wizard shows these read-only for the same reason this tab
|
||||
* must: that record is what the backoffice checks the company against, so it
|
||||
* is reported, not retyped. Leaving it editable here let a customer overwrite
|
||||
* the very value the review compares against.
|
||||
*/
|
||||
const etradeOwner = useMemo(() => {
|
||||
const name = identity?.etradeManagerName?.trim() ?? "";
|
||||
// Dropped if it cannot normalize — eTrade's is free text ("09 " is a
|
||||
// real answer), and an unusable number must fall through to an input rather
|
||||
// than lock the field behind a value the API would reject.
|
||||
const phone = firstValidPhone(identity?.etradeManagerPhone);
|
||||
return name || phone ? { name, phone } : null;
|
||||
}, [identity?.etradeManagerName, identity?.etradeManagerPhone]);
|
||||
|
||||
// Who owns each field, and with what value — the wizard's own rule, reused so
|
||||
// the two cannot disagree about which details are the customer's to edit.
|
||||
const { source: ownerSource, sourced: ownerSourced } = resolveOwnerSources(
|
||||
identity,
|
||||
etradeOwner,
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -90,19 +121,23 @@ export default function TabOwner({
|
||||
ownerName: profile.ownerName ?? "",
|
||||
ownerEmail: profile.ownerEmail ?? "",
|
||||
ownerPhone: profile.ownerPhone ?? "",
|
||||
ownerPassportNumber: profile.identity?.owner.passportNumber ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
|
||||
// and undefined, so an empty string is validated and 400s with
|
||||
// "ownerEmail must be an email". A verified owner legitimately leaves
|
||||
// the fields Fayda did supply blank here.
|
||||
ownerName: data.ownerName || undefined,
|
||||
ownerEmail: data.ownerEmail || undefined,
|
||||
ownerPhone: data.ownerPhone || undefined,
|
||||
// A field a source owns is submitted as that source has it, not as the
|
||||
// form happens to hold it: the read-only row is what the customer was
|
||||
// shown, and only the fields are sent. `|| undefined`, never "": the
|
||||
// DTO's `@IsOptional()` skips null and undefined, so an empty string is
|
||||
// validated and 400s with "ownerEmail must be an email", and a verified
|
||||
// owner legitimately leaves the fields Fayda supplied blank here.
|
||||
ownerName: (ownerSourced.name || data.ownerName) || undefined,
|
||||
ownerEmail: (ownerSourced.email || data.ownerEmail) || undefined,
|
||||
ownerPhone: (ownerSourced.phone || data.ownerPhone) || undefined,
|
||||
ownerPassportNumber: data.ownerPassportNumber || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -112,15 +147,21 @@ export default function TabOwner({
|
||||
},
|
||||
});
|
||||
|
||||
// What the verification did NOT supply. Fayda's email and phone claims are
|
||||
// optional, so a verified owner can still be missing details the API demands
|
||||
// — the API leaves exactly those keys typeable, and so does this form.
|
||||
const gaps = {
|
||||
name: !owner?.name?.trim(),
|
||||
email: !owner?.email?.trim(),
|
||||
phone: !owner?.phone?.trim(),
|
||||
};
|
||||
const savable = gaps.name || gaps.email || gaps.phone;
|
||||
// A foreign owner who is the identity subject and has not verified proves
|
||||
// themselves with a passport instead — the same either/or the wizard offers.
|
||||
// Without an input here the only route to it was the onboarding wizard, which
|
||||
// an onboarded company can no longer reach.
|
||||
const passportAskable =
|
||||
(identity?.passportAccepted ?? false) && ownerIsSubject && !ownerVerified;
|
||||
|
||||
// Save is offered when there is at least one input on screen to save. A field
|
||||
// an outside source owns has none, so an owner fully supplied by Fayda and
|
||||
// eTrade has nothing to submit.
|
||||
const savable =
|
||||
!ownerSource.name ||
|
||||
!ownerSource.email ||
|
||||
!ownerSource.phone ||
|
||||
passportAskable;
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
@@ -158,8 +199,8 @@ export default function TabOwner({
|
||||
|
||||
<SourcedField
|
||||
label="Name"
|
||||
value={owner?.name}
|
||||
source={ownerLocked.name ? "Fayda" : null}
|
||||
value={ownerSourced.name}
|
||||
source={ownerSource.name}
|
||||
>
|
||||
<TextInput
|
||||
label="Name"
|
||||
@@ -173,8 +214,8 @@ export default function TabOwner({
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField
|
||||
label="Email"
|
||||
value={owner?.email}
|
||||
source={ownerLocked.email ? "Fayda" : null}
|
||||
value={ownerSourced.email}
|
||||
source={ownerSource.email}
|
||||
>
|
||||
<TextInput
|
||||
label="Email"
|
||||
@@ -188,8 +229,8 @@ export default function TabOwner({
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField
|
||||
label="Phone"
|
||||
value={owner?.phone}
|
||||
source={ownerLocked.phone ? "Fayda" : null}
|
||||
value={ownerSourced.phone}
|
||||
source={ownerSource.phone}
|
||||
>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
@@ -200,6 +241,16 @@ export default function TabOwner({
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{passportAskable && (
|
||||
<TextInput
|
||||
label="Owner's Passport Number"
|
||||
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
|
||||
placeholder="P1234567"
|
||||
error={errors.ownerPassportNumber?.message}
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{savable && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
|
||||
@@ -42,18 +42,68 @@ import {
|
||||
type LicenseFileStatus,
|
||||
} from "@/services/companies.service";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import {
|
||||
firstValidEmail,
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
} from "@/pages/accounts/companyProfileForm/helpers";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import RoleCard from "@/pages/settings/RoleCard";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The representative's name, email, phone and address all come from their
|
||||
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
|
||||
// is the only detail this form owns.
|
||||
const schema = z.object({
|
||||
poaLocation: z.string().optional(),
|
||||
});
|
||||
/** The representative's details that a Fayda verification may or may not own. */
|
||||
const POA_LABELS = {
|
||||
poaName: "Representative's name",
|
||||
poaEmail: "Representative's email",
|
||||
poaPhone: "Representative's phone",
|
||||
} as const;
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
type PoaField = keyof typeof POA_LABELS;
|
||||
|
||||
/**
|
||||
* The representative's details.
|
||||
*
|
||||
* Optional here, not unrequired. Whatever their Fayda verification supplied is
|
||||
* owned by the API and shown read-only, so a blanket `min(1)` would fail a form
|
||||
* that is correct — but Fayda's email and phone claims are optional and
|
||||
* routinely come back empty, and the API's own `REQUIRED_POA_FIELDS` demands
|
||||
* all three once a representative is declared. So requiredness is decided per
|
||||
* render, exactly as the wizard decides it: **a field is required iff there is
|
||||
* an input on screen for it.** This form used to assume the verification always
|
||||
* supplied everything and rendered no inputs at all, which left a company
|
||||
* reported incomplete with nowhere to fix it.
|
||||
*/
|
||||
const buildSchema = (required: readonly PoaField[]) =>
|
||||
z
|
||||
.object({
|
||||
poaName: z.string().optional(),
|
||||
poaEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || z.string().email().safeParse(v).success,
|
||||
"Invalid email address",
|
||||
),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaPassportNumber: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
})
|
||||
.superRefine((d, ctx) => {
|
||||
for (const key of required) {
|
||||
if (d[key]?.trim()) continue;
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [key],
|
||||
message: `${POA_LABELS[key]} is required`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof buildSchema>>;
|
||||
|
||||
const LETTER_ACCEPT = ".pdf,.png,.jpg,.jpeg";
|
||||
|
||||
@@ -96,18 +146,63 @@ export default function TabPowerOfAttorney({
|
||||
const { view, viewer } = useFileViewer();
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fayda holds a phone as the national registry does, often a local number the
|
||||
// form's E.164 validation would reject. Normalize on read, as the wizard does.
|
||||
const identity = useMemo(
|
||||
() => normalizeIdentityPhones(profile.identity),
|
||||
[profile.identity],
|
||||
);
|
||||
const poa = identity?.poa;
|
||||
const poaVerified = poa?.verified ?? false;
|
||||
|
||||
/**
|
||||
* Which details the verification owns. Same test as the wizard's: presence is
|
||||
* not enough for an email or a phone, because Fayda's claims are free text and
|
||||
* one the schema would reject is not a claim an input can be hidden behind.
|
||||
*/
|
||||
const locked = {
|
||||
name: poaVerified && Boolean(poa?.name?.trim()),
|
||||
email: poaVerified && Boolean(firstValidEmail(poa?.email)),
|
||||
phone: poaVerified && Boolean(firstValidPhone(poa?.phone)),
|
||||
};
|
||||
|
||||
const declaredYes = (identity?.poaDeclared ?? null) === "yes";
|
||||
const passportAccepted = identity?.passportAccepted ?? false;
|
||||
/**
|
||||
* The person exists to describe. Before a verification lands there is nothing
|
||||
* to attach details to — and asking for a name the verification is about to
|
||||
* overwrite is the trap the wizard avoids by the same rule. A foreign company
|
||||
* whose representative may hold no Fayda ID is the exception: the passport is
|
||||
* the proof, so its details are typed from the start.
|
||||
*/
|
||||
const established = declaredYes && (poaVerified || passportAccepted);
|
||||
|
||||
const requiredPoaFields: PoaField[] = [];
|
||||
if (established) {
|
||||
if (!locked.name) requiredPoaFields.push("poaName");
|
||||
if (!locked.email) requiredPoaFields.push("poaEmail");
|
||||
if (!locked.phone) requiredPoaFields.push("poaPhone");
|
||||
}
|
||||
|
||||
const defaultValues = useMemo(
|
||||
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
|
||||
(): FormData => ({
|
||||
poaName: profile.poaName ?? "",
|
||||
poaEmail: profile.poaEmail ?? "",
|
||||
poaPhone: profile.poaPhone ?? "",
|
||||
poaPassportNumber: profile.identity?.poa.passportNumber ?? "",
|
||||
poaLocation: profile.poaLocation ?? "",
|
||||
}),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
resolver: zodResolver(buildSchema(requiredPoaFields)),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
@@ -133,12 +228,6 @@ export default function TabPowerOfAttorney({
|
||||
const requirePoa = profile.companyProfiles.some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
// No company types its representative's details — they come from the Fayda
|
||||
// verification whatever the nationality, since a representative acts for the
|
||||
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||
// has been verified.
|
||||
const identity = profile.identity;
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
// Whether there is a representative at all is the company's own declaration,
|
||||
// held server-side — it decides whose identity the API gates on, so it is
|
||||
// never local state here.
|
||||
@@ -155,9 +244,17 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: FormData) => {
|
||||
// Every identity field except the city is written by the verification, so
|
||||
// only the paper and the location are ever saved here.
|
||||
const fields = { poaLocation: data.poaLocation || undefined };
|
||||
// Whatever the verification did NOT supply is this form's to save, plus
|
||||
// the paper. A field it owns is legitimately blank here (there is no
|
||||
// input), and `|| undefined` keeps that blank out of the payload — the
|
||||
// DTO's `@IsOptional()` skips null and undefined, never "".
|
||||
const fields = {
|
||||
poaName: data.poaName || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaPassportNumber: data.poaPassportNumber || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
};
|
||||
// A fresh upload already stages the removal of every paper on file, so
|
||||
// the explicit removals only need applying when no replacement was
|
||||
// picked. Saving the details after it means the API sees the new paper.
|
||||
@@ -355,15 +452,62 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{/* Name, email, phone and address all come from the Fayda
|
||||
verification and are shown on the panel above. Only a company
|
||||
whose representative may hold no Fayda ID still types a
|
||||
location. */}
|
||||
{declared === "yes" && !poaProvided && (
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
{/* Whatever the verification supplied is on the panel above; only
|
||||
what it left blank is asked for here. A foreign representative
|
||||
who holds no Fayda ID proves themselves by passport instead —
|
||||
the same either/or the onboarding wizard offers, which an
|
||||
onboarded company can no longer reach. */}
|
||||
{established && passportAccepted && !poaVerified && (
|
||||
<TextInput
|
||||
label="Representative's Passport Number"
|
||||
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
|
||||
placeholder="P1234567"
|
||||
error={errors.poaPassportNumber?.message}
|
||||
{...register("poaPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{established && !locked.name && (
|
||||
<TextInput
|
||||
label="Representative's Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{established && (!locked.email || !locked.phone) && (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: !locked.email && !locked.phone ? 2 : 1 }}
|
||||
spacing="md"
|
||||
>
|
||||
{!locked.email && (
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
label="Representative's Email"
|
||||
type="email"
|
||||
placeholder="representative@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
)}
|
||||
{!locked.phone && (
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="Representative's Phone"
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* The company's own statement of where the representative is
|
||||
based — never Fayda's `poaAddress`, which the portal does not
|
||||
send — so it stays typeable however well Fayda knows them. */}
|
||||
{established && (
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<TextInput
|
||||
label="Representative's Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCompanyProfileSchema } from "./TabCompanyProfile";
|
||||
|
||||
/**
|
||||
* The two rules this schema exists to keep in step with onboarding: what counts
|
||||
* as a VAT number, and who has to type their registration.
|
||||
*/
|
||||
const base = {
|
||||
companyName: "Acme plc",
|
||||
companyLocation: "Addis Ababa",
|
||||
companyAddress: "",
|
||||
tinNumber: "0012345678",
|
||||
vatNumber: "0012345678",
|
||||
region: "Addis Ababa",
|
||||
zone: "Bole",
|
||||
woreda: "03",
|
||||
kebele: "07",
|
||||
houseNo: "",
|
||||
};
|
||||
|
||||
describe("buildCompanyProfileSchema", () => {
|
||||
it("accepts a VAT number no Ethiopian format rule would allow", () => {
|
||||
// The whole point of reusing onboarding's rule: a foreign company's VAT is
|
||||
// its own tax authority's, and the stricter copy locked it out of the tab.
|
||||
const parsed = buildCompanyProfileSchema(false).safeParse({
|
||||
...base,
|
||||
vatNumber: "GB123456789",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("still requires a VAT number", () => {
|
||||
const parsed = buildCompanyProfileSchema(false).safeParse({
|
||||
...base,
|
||||
vatNumber: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("does not require the registration block off an eTrade company", () => {
|
||||
// It is read-only for them, so requiring it would fail a save on a field
|
||||
// with no input to fix it.
|
||||
const parsed = buildCompanyProfileSchema(false).safeParse({
|
||||
...base,
|
||||
companyName: "",
|
||||
region: "",
|
||||
zone: "",
|
||||
woreda: "",
|
||||
kebele: "",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires it of a company that types it by hand", () => {
|
||||
const parsed = buildCompanyProfileSchema(true).safeParse({
|
||||
...base,
|
||||
region: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error?.issues.map((i) => i.path[0])).toContain("region");
|
||||
});
|
||||
|
||||
it("leaves the house number optional either way", () => {
|
||||
expect(
|
||||
buildCompanyProfileSchema(true).safeParse({ ...base, houseNo: "" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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