Merge pull request #1332 from Tria-plc/freight/feat/foreign-investors

Freight/feat/foreign investors
This commit is contained in:
Nathnael Wondisha
2026-08-18 14:49:28 +03:00
committed by GitHub
37 changed files with 2788 additions and 412 deletions

View File

@@ -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>

View File

@@ -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`,

View File

@@ -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" />

View File

@@ -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}
/>
)}

View File

@@ -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}
/>

View File

@@ -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.

View File

@@ -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>
</>
);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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")}

View File

@@ -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);
});
});

View File

@@ -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",

View File

@@ -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);
},

View File

@@ -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;