refactor(freight-portal): rebuild the onboarding wizard around owner and representation

Steps are now company -> owner -> representation -> contact -> documents;
PersonnelStep and PoaStep are gone.

- OwnerStep shows whoever the eTrade licence names, read-only with a
  provenance badge (SourcedField), and renders an input for every gap eTrade
  and Fayda left. It warns when the stored owner does not match eTrade.
- RepresentationStep asks outright whether anyone holds power of attorney,
  then branches: "no" verifies the owner, "yes" collects the representative
  and the DARS delegation letter. Freight forwarders cannot answer "no".
- Passport input appears only for a foreign company whose subject has not
  verified with Fayda.
- Drop every same-as-owner copy-across and the auth-user fallbacks for the
  owner's email and phone. Re-picking a business licence now clears the
  eTrade owner prefill, since licences under one TIN can name different
  managers.
- Settings: TabGeneralManager replaced by TabOwner; TabPowerOfAttorney
  reworked around poaDeclared.
This commit is contained in:
Nathnael
2026-08-11 11:53:56 +00:00
parent 8cf041f494
commit 293f255daa
20 changed files with 1187 additions and 1682 deletions

View File

@@ -41,12 +41,17 @@ import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result";
/** Form steps rendered by CompanyProfileForm. */
type FormStep = "company" | "personnel" | "contact" | "poa" | "documents";
type FormStep =
| "company"
| "owner"
| "representation"
| "contact"
| "documents";
const FORM_STEPS: FormStep[] = [
"company",
"personnel",
"owner",
"representation",
"contact",
"poa",
"documents",
];
@@ -68,23 +73,25 @@ const STEP_META: Record<
icon: <Building2 size={20} />,
title: "Company Information",
description:
"Confirm your VAT number, verify the owner's identity, and we'll pull your registration from eTrade.",
"Confirm your VAT number and we'll pull your registration straight from eTrade.",
},
personnel: {
owner: {
icon: <User size={20} />,
title: "General Manager",
description: "Who is the general manager of the company?",
title: "Company Owner",
description:
"The person registered on your eTrade licence. We fill in what eTrade and Fayda gave us.",
},
representation: {
icon: <FileText size={20} />,
title: "Who Acts For You",
description:
"Tell us whether anyone holds power of attorney — your answer decides whose identity we verify.",
},
contact: {
icon: <UserCheck size={20} />,
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
poa: {
icon: <FileText size={20} />,
title: "Power of Attorney",
description: "Optionally add a representative with power of attorney.",
},
documents: {
icon: <UploadCloud size={20} />,
title: "Upload Documents",
@@ -210,7 +217,7 @@ export default function OnboardingWizardDialog({
}) => api.companies.startOnboarding.call(vars),
onSuccess: async () => {
// Nationality drives the server-resolved identity requirements (Fayda vs
// passport), the document set and the GM/PoA copy — all read from
// passport), the document set and the PoA copy — all read from
// onboardingRequirements/profile. Re-entering role selection can change
// it, so both must be refetched alongside getInfo or the form step would
// keep rendering the previous nationality's requirements.
@@ -414,15 +421,21 @@ export default function OnboardingWizardDialog({
const requiredDocsMissing = requirementDocuments.some(
(d) => d.isRequired && !d.uploaded,
);
// The PoA gets the same treatment: a resumed draft that predates the
// delegation-letter requirement (or a forwarder whose PoA is blank) must land
// back on the PoA step, where both the details and the letter are entered.
const poaIncomplete = requirementsQuery.data?.poa?.complete === false;
// The representation step gets the same treatment. An unanswered
// power-of-attorney question, or a declared representative still missing
// details or the DARS paper, must land the customer back on the step where
// all of that is entered — including a draft that predates the question
// existing at all, whose `declared` comes back null.
const representationIncomplete =
requirementsQuery.data?.poa?.declared == null ||
requirementsQuery.data?.poa?.complete === false ||
requirementsQuery.data?.identity?.identityProven === false;
// Each unmet requirement lowers the ceiling; resume never moves forward.
let ceiling = FORM_STEPS.length - 1;
if (requiredDocsMissing)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
if (representationIncomplete)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("representation"));
const effectiveResumeStep: FormStep =
FORM_STEPS[
Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)
@@ -446,9 +459,9 @@ export default function OnboardingWizardDialog({
onLicenseChange: setLicenseFiles,
uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments,
// Fayda verification state for the owner and the PoA — the general manager
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
// a foreign one requires a typed passport number for the owner instead.
// The company's single identity verification, and whose it is. Fayda is
// mandatory for an Ethiopian company; a foreign one may instead type a
// passport number for the same person.
identity: requirementsQuery.data?.identity,
onIdentityChange: () => {
void profileQuery.refetch();

View File

@@ -49,49 +49,46 @@ import TabAccount from "./settings/TabAccount";
import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager";
import TabOwner from "./settings/TabOwner";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
type SettingsTab =
| "account"
| "company"
| "contact"
| "gm"
| "owner"
| "poa"
| "documents";
/** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
switch (tabId) {
case "company": {
// Identity proof lives here: the owner's Fayda verification for an
// Ethiopian company, or the owner's typed passport number for a foreign
// one.
const identity = profile.identity;
const identityIncomplete = identity
? (identity.faydaRequired && !identity.owner.verified) ||
(identity.passportRequired && !identity.owner.passportNumber)
: false;
return !profile.companyAddress || identityIncomplete;
}
case "company":
return !profile.companyAddress;
case "contact":
return !profile.contactPersonName || !profile.contactPersonPhone;
case "gm":
// The GM is established through Fayda — verified in their own right or
// declared the same person as the owner — so the identity answers this,
// not the typed columns. A company that may still type them (foreign,
// whose manager may hold no Fayda ID) is judged on those instead.
if (profile.identity?.gm.verified) return false;
if (profile.identity?.faydaRequired) return true;
case "owner":
// The owner is whoever the eTrade licence names. All three details are
// required whatever supplied them, and the identity verification lives
// on whichever person the PoA declaration points at — flagged here when
// it is the owner and still unproven.
return (
!profile.generalManagerName ||
!profile.generalManagerEmail ||
!profile.generalManagerPhone
!profile.ownerName ||
!profile.ownerEmail ||
!profile.ownerPhone ||
(profile.identity?.subject === "owner" &&
!profile.identity.identityProven)
);
case "poa":
// Unanswered is itself incomplete — the answer decides whose identity is
// verified — as is a declared representative who has not proved theirs.
if (profile.identity?.poaDeclared == null) return true;
return (
profile.identity.subject === "poa" && !profile.identity.identityProven
);
case "account":
// Account fields live on the IAM user, not the company profile, and are
// always populated (signup requires them) — nothing to nag about here.
case "poa":
case "documents":
return false;
}
@@ -101,7 +98,7 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "account", label: "Account", icon: <UserCog size={16} /> },
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
{ id: "owner", label: "Owner", icon: <Briefcase size={16} /> },
{ id: "poa", label: "Power of Attorney", icon: <UserCheck size={16} /> },
{ id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
];
@@ -405,8 +402,8 @@ export default function SettingsPage() {
<Tabs.Panel value="contact">
<TabContactPerson profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="gm">
<TabGeneralManager profile={profile} mode="edit" />
<Tabs.Panel value="owner">
<TabOwner profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="poa">
<TabPowerOfAttorney profile={profile} mode="edit" />

View File

@@ -24,18 +24,19 @@ import {
import {
buildPayload,
firstPresent,
firstValidEmail,
firstValidPhone,
normalizeIdentityPhones,
stepPayload,
toFormValues,
} from "./companyProfileForm/helpers";
import { verifaydaService } from "@/services/verifayda.service";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type {
CompanyIdentityState,
PoaDeclaration,
} from "@/services/verifayda.service";
import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep";
import PersonnelStep from "./companyProfileForm/steps/PersonnelStep";
import OwnerStep from "./companyProfileForm/steps/OwnerStep";
import ContactStep from "./companyProfileForm/steps/ContactStep";
import PoaStep from "./companyProfileForm/steps/PoaStep";
import RepresentationStep from "./companyProfileForm/steps/RepresentationStep";
import DocumentsStep from "./companyProfileForm/steps/DocumentsStep";
export default function CompanyProfileForm({
@@ -96,7 +97,7 @@ export default function CompanyProfileForm({
onUploadDocuments?: () => Promise<
{ ok: true } | { ok: false; error: string }
>;
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
/** The company's single identity verification (undefined until loaded). */
identity?: CompanyIdentityState;
/**
* Refetch the profile + requirements. Only the in-page identity actions need
@@ -148,7 +149,7 @@ export default function CompanyProfileForm({
// Follow a parent-driven resume correction: if initialStep changes (the wizard
// re-clamps it back once onboarding requirements load — e.g. a required
// document is still missing, so it must not skip ahead to Business License),
// document is still missing, so it must not skip ahead to the documents step),
// adopt it, but only while the user hasn't started navigating themselves.
const lastInitialStep = useRef(initialStep);
useEffect(() => {
@@ -174,16 +175,6 @@ export default function CompanyProfileForm({
}),
);
// A freight forwarder signs on other companies' behalf, so its Power of
// Attorney (details + DARS delegation paper) is mandatory rather than optional.
const requirePoa = (roleProfiles ?? []).some(
(p) => p.type === "freight_forwarder",
);
// Fayda is an Ethiopian national ID: an Ethiopian company verifies its owner
// and PoA instead of typing their details, a foreign one keeps the typed
// forms (plus a mandatory owner passport number).
const verifiedIdentity = identity?.faydaRequired === true;
// Which fields the current step renders an input for and therefore requires.
// Filled in further down (it depends on values this form owns), and read at
// validation time rather than at render time — the resolver below runs on
@@ -192,16 +183,15 @@ export default function CompanyProfileForm({
const form = useForm<FormData>({
resolver: (values, context, options) =>
zodResolver(
buildOnboardingSchema(
identity?.passportRequired === true,
requiredKeysRef.current,
zodResolver(buildOnboardingSchema(requiredKeysRef.current))(
values,
context,
options,
),
)(values, context, options),
// `values` below re-seeds the form whenever the profile is refetched — and
// an in-page identity action (ticking "same as owner") refetches it. Without
// this, that reset silently throws away whatever the customer was part-way
// through typing on the current step.
// an in-page identity action (answering the PoA question) refetches it.
// Without this, that reset silently throws away whatever the customer was
// part-way through typing on the current step.
resetOptions: { keepDirtyValues: true, keepErrors: true },
defaultValues: {
companyName: "",
@@ -210,6 +200,7 @@ export default function CompanyProfileForm({
tinNumber: "",
vatNumber: "",
ownerPassportNumber: "",
poaPassportNumber: "",
licenceNumber: "",
statusDescription: "",
dateRegistered: "",
@@ -225,9 +216,9 @@ export default function CompanyProfileForm({
contactPersonPosition: "",
contactPersonEmail: "",
contactPersonPhone: "",
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
ownerName: "",
ownerEmail: "",
ownerPhone: "",
poaName: "",
poaPhone: "",
poaAddress: "",
@@ -273,8 +264,9 @@ export default function CompanyProfileForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [region, zone, woreda, kebele, houseNo]);
// The business owner/manager pulled from eTrade — powers "Use owner as
// manager" on the General Manager step. Null until a TIN lookup succeeds.
// The manager eTrade lists for this licence. This is the person the owner
// step is about — "owner" here means whoever the licence names, and the
// backoffice checks the stored owner against exactly this.
const [etradeOwner, setEtradeOwner] = useState<{
name: string;
phone: string;
@@ -302,31 +294,45 @@ export default function CompanyProfileForm({
setValue("woreda", data.woreda, dirty);
setValue("kebele", data.kebele, dirty);
setValue("houseNo", data.houseNo, dirty);
// companyAddress is composed reactively from the address fields below, so
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
// compose it here. companyPhone is derived below (identity → eTrade →
// account), not set directly here.
// companyAddress is composed reactively from the address fields above.
// etradePhone is the raw number eTrade returned for this TIN — kept as its
// own field (distinct from companyPhone, which prefers the Fayda-verified
// owner's phone) so the backend's "matches eTrade's current record" check
// always compares against what eTrade actually said, not the owner's phone.
// own field (distinct from the owner's phone) so the backend's "matches
// eTrade's current record" check always compares against what eTrade
// actually said.
setValue(
"etradePhone",
data.managerPhone || data.regularPhone || data.mobilePhone,
dirty,
);
setEtradeOwner({
const owner = {
name: data.managerName,
phone: toEthiopianE164(
data.managerPhone || data.regularPhone || data.mobilePhone,
),
});
};
setEtradeOwner(owner);
// Prefill the owner inputs rather than replacing them. The customer can
// still correct a name eTrade transliterated oddly — and if they change it
// to a different person, `identity.ownerMatchesEtrade` says so to both them
// and the reviewer. Only fills what is empty: a value the customer already
// typed (or a Fayda claim already stored) is not overwritten by a lookup.
if (owner.name && !getValues("ownerName")?.trim()) {
setValue("ownerName", owner.name, { shouldValidate: true, ...dirty });
}
if (owner.phone && !getValues("ownerPhone")?.trim()) {
setValue("ownerPhone", owner.phone, { shouldValidate: true, ...dirty });
}
};
// TIN changed since the last successful lookup — the registration/address
// fields it filled in describe the OLD TIN, not this one, so clear them
// rather than leaving them stale on screen.
//
// The owner goes too: different licences under one TIN can list different
// managers, so a prefill from the previous pick is someone else's name.
// Only the prefill is cleared — a Fayda-verified owner is the API's to own.
const handleETradeReset = () => {
setValue("licenceNumber", "");
setValue("statusDescription", "");
@@ -340,247 +346,33 @@ export default function CompanyProfileForm({
setValue("kebele", "");
setValue("houseNo", "");
setValue("etradePhone", "");
if (!identity?.owner.verified) {
if (getValues("ownerName") === etradeOwner?.name) setValue("ownerName", "");
if (getValues("ownerPhone") === etradeOwner?.phone)
setValue("ownerPhone", "");
}
setEtradeOwner(null);
};
// "Same as …" links. A checked card prefills the target step's fields from the
// source step and disables them (kept mirrored while linked); unchecking clears
// them and re-enables editing.
// Seeded from the server so a resumed draft reopens with the declaration the
// company already made, rather than an unticked box over a linked GM.
const [gmSameAsOwner, setGmSameAsOwner] = useState(
identity?.gmSameAsOwner ?? false,
);
// `identity` is undefined on the first render (the requirements query is still
// in flight), so the initial state above freezes at `false` — adopt the
// server's declaration the moment it lands, or a resumed draft shows an
// unticked box over a GM that is linked server-side.
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
identity?.poaSameAsOwner ?? false,
);
const identityLoaded = useRef(false);
useEffect(() => {
if (!identity || identityLoaded.current) return;
identityLoaded.current = true;
setGmSameAsOwner(identity.gmSameAsOwner);
setPoaSameAsOwner(identity.poaSameAsOwner);
}, [identity]);
const [contactSameAsGm, setContactSameAsGm] = useState(false);
// Where the owner's details come from when they are copied onto someone else
// — the GM, or the representative. A Fayda-verified owner outranks eTrade's
// registered owner: it's the higher-trust source, and the whole point of
// proving identity is to stop trusting typed/looked-up data for this.
const ownerSourceName = firstPresent(
identity?.owner.name,
etradeOwner?.name,
user.name?.en,
);
const ownerSourceEmail = firstValidEmail(identity?.owner.email, user.email);
// Same reason as `derivedPhone`: this value is written into
// `generalManagerPhone` / `poaPhone`, which the API validates with
// `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead.
const ownerSourcePhone = firstValidPhone(
identity?.owner.phone,
etradeOwner?.phone,
user.phoneNumber,
);
useEffect(() => {
if (!gmSameAsOwner) return;
// A verified owner's identity is copied server-side and read back from
// `identity.gm`; mirroring it into form fields here would send typed
// values for something the API already owns.
if (identity?.owner.verified) return;
setValue("generalManagerName", ownerSourceName, { shouldValidate: true });
setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true });
setValue("generalManagerPhone", ownerSourcePhone, {
shouldValidate: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gmSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
// The representative's half of the same copy. A verified owner's identity is
// copied server-side and read back from `identity.poa`, so only an owner
// backed by a typed passport is mirrored into form fields here — the same
// split the GM makes above, for the same reason.
//
// Only non-empty sources are written. A source the owner does not have is a
// gap the step renders an input for (see `poaGaps`), and this effect re-runs
// whenever any *other* source changes — so blanking here would wipe what the
// customer is typing into that input the moment an eTrade lookup lands.
useEffect(() => {
if (!poaSameAsOwner || identity?.owner.verified) return;
if (ownerSourceName) setValue("poaName", ownerSourceName, { shouldValidate: true });
if (ownerSourceEmail) setValue("poaEmail", ownerSourceEmail, { shouldValidate: true });
if (ownerSourcePhone) setValue("poaPhone", ownerSourcePhone, { shouldValidate: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poaSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
/**
* "Same as owner" has two meanings depending on what backs the owner.
* Answer the power-of-attorney question.
*
* A Fayda-verified owner is a proven identity, so the declaration is made
* server-side: the API copies that identity onto the GM and records what it
* did. Anything typed here would arrive wearing a verified badge it hadn't
* earned, which is exactly what the verification exists to prevent.
*
* A foreign company's owner is backed by a typed passport instead, so there
* is nothing proven to copy — that stays the local field-mirroring it has
* always been.
* Persisted server-side rather than held in form state: the answer decides
* whose identity the API gates on, and answering "no" tears down any
* representative already recorded (details, verification and DARS paper
* together) — none of which the form could do on its own.
*/
const [gmLinkPending, setGmLinkPending] = useState(false);
const toggleGmSameAsOwner = async (checked: boolean) => {
setGmSameAsOwner(checked);
if (!identity?.owner.verified) {
if (!checked) {
setValue("generalManagerName", "");
setValue("generalManagerEmail", "");
setValue("generalManagerPhone", "");
}
return;
}
setGmLinkPending(true);
try {
if (checked) await verifaydaService.setGmSameAsOwner();
else await verifaydaService.clearGmIdentity();
onIdentityChange?.();
} catch (err) {
setGmSameAsOwner(!checked);
setSaveError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error
? err.message
: "Could not update the general manager"),
);
} finally {
setGmLinkPending(false);
}
};
/**
* The representative is the owner. Unlike the GM's card this always goes to
* the API, whichever backs the owner: the declaration itself is what waives
* the DARS delegation paper, so it has to be recorded server-side even when
* there is no proven identity to copy and the details are mirrored locally.
*/
const [poaLinkPending, setPoaLinkPending] = useState(false);
const togglePoaSameAsOwner = async (checked: boolean) => {
const [declarePending, setDeclarePending] = useState(false);
const handleDeclare = async (declared: PoaDeclaration) => {
setSaveError(null);
setPoaSameAsOwner(checked);
setPoaLinkPending(true);
setDeclarePending(true);
try {
if (checked) await verifaydaService.setPoaSameAsOwner();
else {
await verifaydaService.clearPoaSameAsOwner();
// Only the locally mirrored values are ours to clear; a copied identity
// is cleared by the call above.
if (!identity?.owner.verified) {
setValue("poaName", "");
setValue("poaEmail", "");
setValue("poaPhone", "");
}
}
onIdentityChange?.();
} catch (err) {
setPoaSameAsOwner(!checked);
setSaveError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error
? err.message
: "Could not update the Power of Attorney"),
);
} finally {
setPoaLinkPending(false);
}
};
// Where the GM's details come from depends on how they were established: a
// Fayda verification (or a "same as owner" declaration) owns them outright,
// and only a company that may still type them falls back to form state.
// A verified GM's identity wins, but Fayda's email and phone claims are
// optional: what the verification did not supply is typed on this step, and
// the API deliberately does not read those back onto the identity (they are
// not proven), so the form value is the only place they exist.
const gmVerified = identity?.gm.verified ?? false;
const gmName = gmVerified
? firstPresent(identity?.gm.name, watch("generalManagerName"))
: watch("generalManagerName");
const gmEmail = gmVerified
? firstPresent(identity?.gm.email, watch("generalManagerEmail"))
: watch("generalManagerEmail");
const gmPhone = gmVerified
? firstPresent(identity?.gm.phone, watch("generalManagerPhone"))
: watch("generalManagerPhone");
/**
* Whether the GM has been established at all — by verification, by the
* "same as owner" declaration, or (only where Fayda is optional) by typing.
* Fayda is an Ethiopian national ID, so a foreign company's GM may hold none.
*/
// Matches what the API actually demands (`REQUIRED_COMPANY_INFO`): a name and
// a phone. The email is collected but optional — a manager proved through
// Fayda may have no email claim, and the notify resolver no longer needs one.
const gmTyped = Boolean(
watch("generalManagerName")?.trim() &&
watch("generalManagerPhone")?.trim(),
);
const gmEstablished =
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
/**
* Same rule for the representative: verified, or entered where Fayda is
* optional.
*
* Matches the API's own rule (`REQUIRED_POA_FIELDS`): a typed representative
* counts once they have a name, an email and a phone. The step now renders
* inputs for all three, so this is something the customer can actually
* satisfy — previously it gated on `poaName`, for which no input existed
* anywhere, leaving a foreign freight forwarder permanently stuck.
*/
const poaTyped = Boolean(
watch("poaName")?.trim() &&
watch("poaEmail")?.trim() &&
watch("poaPhone")?.trim(),
);
const poaEstablished =
(identity?.poa.verified ?? false) ||
(identity ? !identity.faydaRequired && poaTyped : false);
/**
* Drop an optional representative the company no longer wants.
*
* Verifying a PoA is one click on a step that calls itself optional, and it
* is not reversible from the form: the verification owns the fields (so
* blanking them is refused), and its mere existence makes the DARS paper due
* — which then blocks the submit AND clamps the resume back to this step. The
* settings page has the same escape hatch, but `/settings` is off-limits
* until onboarding finishes, so without this the customer is stuck.
*
* Not offered to a freight forwarder: the API refuses (they must have one).
*/
const [poaRemovePending, setPoaRemovePending] = useState(false);
const removePoa = async () => {
setSaveError(null);
setPoaRemovePending(true);
try {
await verifaydaService.removePoa();
for (const key of [
"poaName",
"poaEmail",
"poaPhone",
"poaAddress",
"poaLocation",
] as const) {
setValue(key, "", { shouldDirty: false });
}
// The paper is deleted server-side with the identity; a copy still
// sitting in the picker would be re-uploaded on the documents step.
await verifaydaService.setPoaDeclared(declared);
if (declared === "no") {
// The paper is deleted server-side with the representative; a copy
// still sitting in the picker would be re-uploaded on the next step.
setDocumentFiles({ ...documentFiles, [POA_DELEGATION_FILE_KEY]: null });
}
onIdentityChange?.();
} catch (err) {
setSaveError(
@@ -588,25 +380,57 @@ export default function CompanyProfileForm({
?.message ??
(err instanceof Error
? err.message
: "Could not remove the Power of Attorney"),
: "Could not save your answer"),
);
} finally {
setPoaRemovePending(false);
setDeclarePending(false);
}
};
/**
* What no source supplied, per person.
*
* A Fayda verification owns the fields its claims filled — the API refuses to
* let those be overwritten — but its email, phone and address claims are
* optional and routinely come back empty. eTrade fills the owner's name and
* phone, and nothing at all fills an email.
*
* So "what still has to be asked" varies per company. Computed here, once,
* and handed to both the step (which renders an input per gap) and the schema
* (which requires exactly those): **a field is required if and only if there
* is an input on screen to fix it in.**
*/
const ownerGaps = {
name: !identity?.owner.name?.trim(),
email: !identity?.owner.email?.trim(),
phone: !identity?.owner.phone?.trim(),
};
const poaGaps = {
name: !identity?.poa.name?.trim(),
email: !identity?.poa.email?.trim(),
phone: !identity?.poa.phone?.trim(),
address: !identity?.poa.address?.trim(),
};
// The owner's name from whichever source established them — powers the
// contact step's "same as owner" card.
const ownerName = firstPresent(identity?.owner.name, watch("ownerName"));
const ownerEmail = firstPresent(identity?.owner.email, watch("ownerEmail"));
const ownerPhone = firstPresent(identity?.owner.phone, watch("ownerPhone"));
const [contactSameAsOwner, setContactSameAsOwner] = useState(false);
// While linked, mirror the source values into the (disabled) target fields so
// the copy stays current even if the user goes back and edits the source.
useEffect(() => {
if (!contactSameAsGm) return;
setValue("contactPersonName", gmName ?? "", { shouldValidate: true });
setValue("contactPersonEmail", gmEmail ?? "");
setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true });
if (!contactSameAsOwner) return;
setValue("contactPersonName", ownerName, { shouldValidate: true });
setValue("contactPersonEmail", ownerEmail);
setValue("contactPersonPhone", ownerPhone, { shouldValidate: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
}, [contactSameAsOwner, ownerName, ownerEmail, ownerPhone]);
const toggleContactSameAsGm = (checked: boolean) => {
setContactSameAsGm(checked);
const toggleContactSameAsOwner = (checked: boolean) => {
setContactSameAsOwner(checked);
// Checked → the mirror effect fills the fields; unchecked → reset them.
if (!checked) {
setValue("contactPersonName", "");
@@ -616,10 +440,10 @@ export default function CompanyProfileForm({
};
// The DARS delegation paper ships in the same nationality document set as the
// rest (the API guarantees it is there), but belongs on the PoA step next to
// the details it evidences — so it's split out here and the Documents step
// renders the remainder. Both halves share `documentFiles`, so the existing
// bulk upload still carries it.
// rest (the API guarantees it is there), but belongs on the representation
// step next to the details it evidences — so it's split out here and the
// Documents step renders the remainder. Both halves share `documentFiles`, so
// the existing bulk upload still carries it.
const poaDocumentField = uploadSetting?.fields?.find(
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
);
@@ -713,8 +537,7 @@ export default function CompanyProfileForm({
// The registration/license details come straight from the eTrade lookup and
// are not user-editable — shown as a read-only confirmation once a TIN lookup
// (or rehydration) has filled them in. The address fields below are separate:
// user-entered and required. We watch the values so the display stays current.
// (or rehydration) has filled them in.
const registration = watch([
"licenceNumber",
"statusDescription",
@@ -732,51 +555,17 @@ export default function CompanyProfileForm({
// progress bar all derive from this so adding/removing a step is one edit.
const stepOrder: CompanyStep[] = [
"company",
"personnel",
"owner",
"representation",
"contact",
"poa",
"documents",
];
const currentIdx = stepOrder.indexOf(step);
// The DARS delegation paper is what proves the representative was actually
// delegated, so it's required the moment a PoA exists. The API enforces the
// same rule on save, so skipping it here only costs the customer a
// round-trip.
//
// "Exists" is the API's own test (`POA_ATTRIBUTES.some(...)`): ANY detail,
// verified or typed. Requiring a complete typed representative here instead
// hid the upload from a customer who had entered only a name — for whom the
// API still demands the paper, and whose resume would then be clamped back to
// this step with nothing on it to fill.
const poaAnyDetail = [
identity?.poa.name,
identity?.poa.email,
identity?.poa.phone,
identity?.poa.address,
watch("poaName"),
watch("poaEmail"),
watch("poaPhone"),
watch("poaLocation"),
].some((v) => v?.trim());
const poaProvided = (identity?.poa.verified ?? false) || poaAnyDetail;
// A freight forwarder owes the paper whether or not its representative could
// verify with Fayda — the API demands it at completion either way. Keying
// this on the verification alone hid the upload from a foreign forwarder and
// then failed them on submit for a file they were never shown.
//
// Unless the owner represents the company themselves: nobody delegates to
// themselves, so there is no delegation to evidence. Mirrors the API's own
// waiver in `assertPoaDelegationSatisfied` — the two must agree, or this
// demands a file the server would accept the submission without.
//
// Split from `poaDue` — "there is a representative, so their details are
// owed" — because a self-PoA keeps the second while dropping the first. The
// API draws the same line (`poaDue` / `delegationDue` in
// getOnboardingRequirements); anything that is about the *details* must key
// on `poaDue`, only the paper keys on this.
const poaDue = poaProvided || requirePoa;
const delegationRequired = poaDue && !poaSameAsOwner;
// The DARS delegation paper is owed exactly when the company says it has a
// representative. The API enforces the same rule on save, so skipping it here
// only costs the customer a round-trip.
const delegationRequired = identity?.poaDeclared === "yes";
const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
(() => {
@@ -785,90 +574,30 @@ export default function CompanyProfileForm({
})();
/**
* What a Fayda verification did NOT supply, per person.
* Is the company's one identity proven?
*
* Fayda's email, phone and address claims are optional and routinely come
* back empty, so a *verified* person can still be missing details the API
* demands (`REQUIRED_POA_FIELDS`, `REQUIRED_COMPANY_INFO`). Those gaps are
* typed instead — the API keeps exactly the keys a claim left empty typeable,
* since a claim that returned nothing owns no value to protect.
*
* Computed here, once, and handed to both the step (which renders an input
* per gap) and the schema (which requires exactly those) — a field is
* required if and only if there is an input on screen to fix it in.
* `identity.identityProven` is the server's verdict, but it is a step behind
* a passport number the customer has just typed and not yet saved — so the
* live form value counts too. Blocking on the stale server value would refuse
* to advance past a field the customer has visibly filled in.
*/
// Where Fayda is mandatory an unverified representative must verify rather
// than be typed, so nothing is offered until the verification lands.
const poaTypedAllowed =
!identity || identity.poa.verified || !identity.faydaRequired;
// A representative the verification never proved holds only typed details —
// the API leaves those unlocked, so the inputs stay on screen and stay
// editable. Only a *verified* PoA hides the fields their claim did fill,
// which is also the only case the API refuses to let anyone overwrite.
const poaGap = (v?: string | null) =>
poaTypedAllowed && (!identity?.poa.verified || !v?.trim());
/**
* "Same as owner" answers each field only as far as the owner actually has
* one. Fayda's name, email and phone claims are all optional, the account and
* eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still
* demands a name, an email and a phone — so anything the copy could not
* supply stays askable. Assuming the copy filled everything is what dead-ends
* the submit on "Add your poa phone" with no input anywhere to satisfy it.
*
* Keyed on the *source*, never on the field's current value: an input that
* disappears the moment the first character is typed into it is unusable.
* A verified owner's identity is copied server-side, so `identity.poa` is the
* source there; otherwise it is the same owner-derived values the mirror
* effect writes.
*/
const poaCopyGap = (copied?: string | null, mirrored?: string | null) =>
identity?.owner.verified ? !copied?.trim() : !mirrored?.trim();
const poaGaps = poaSameAsOwner
? {
name: poaCopyGap(identity?.poa.name, ownerSourceName),
email: poaCopyGap(identity?.poa.email, ownerSourceEmail),
phone: poaCopyGap(identity?.poa.phone, ownerSourcePhone),
// The location is the one detail the API never demands, so a blank one
// dead-ends nothing — and asking for the owner's city under a card that
// says "same as owner" reads as a contradiction.
address: false,
}
: {
name: poaGap(identity?.poa.name),
email: poaGap(identity?.poa.email),
phone: poaGap(identity?.poa.phone),
address: poaGap(identity?.poa.address),
};
// The GM's own verification never falls back to the signed-in account — that
// account is the person onboarding, not necessarily the manager — so a GM
// verified with no email claim has nowhere else for one to come from. The
// name claim is optional too, and `REQUIRED_COMPANY_INFO` demands it, so it
// gets the same treatment rather than dead-ending the submit.
// "Same as owner" is exempt: the API copies the owner's (account-backed)
// contact details across, so there is no gap and no input.
const gmGaps = {
name: !gmSameAsOwner && gmVerified && !identity?.gm.name?.trim(),
email: !gmSameAsOwner && gmVerified && !identity?.gm.email?.trim(),
phone: !gmSameAsOwner && gmVerified && !identity?.gm.phone?.trim(),
};
const passportField =
identity?.subject === "poa" ? "poaPassportNumber" : "ownerPassportNumber";
const identityProven =
(identity?.identityProven ?? false) ||
((identity?.passportAccepted ?? false) &&
Boolean(watch(passportField)?.trim()));
const requiredKeys: (keyof FormData)[] = [];
if (step === "personnel") {
// The manager's email is offered but not demanded: the API dropped it from
// `REQUIRED_COMPANY_INFO` once the notify resolver stopped depending on it.
// The name and phone are still required there, so they are still required
// here.
if (gmGaps.name) requiredKeys.push("generalManagerName");
if (gmGaps.phone) requiredKeys.push("generalManagerPhone");
} else if (step === "poa" && poaDue) {
// Only once a PoA is required or provided: an untouched optional PoA is
// still a step the customer may walk straight past.
//
// `poaDue`, NOT `delegationRequired`: the paper is waived for a self-PoA
// but `REQUIRED_POA_FIELDS` is not, and the API reports every one of them
// missing (`missingPoaFields` keys on its own `poaDue`) — which fails the
// submit and clamps the resume back here. Keying this on the paper let the
// customer walk past an input this step had already put on screen.
if (step === "owner") {
// All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input
// is rendered for each one a Fayda claim did not already own.
if (ownerGaps.name) requiredKeys.push("ownerName");
if (ownerGaps.email) requiredKeys.push("ownerEmail");
if (ownerGaps.phone) requiredKeys.push("ownerPhone");
} else if (step === "representation" && identity?.poaDeclared === "yes") {
// Only once a representative is actually declared: a company that answered
// "no" has no representative to describe.
if (poaGaps.name) requiredKeys.push("poaName");
if (poaGaps.email) requiredKeys.push("poaEmail");
if (poaGaps.phone) requiredKeys.push("poaPhone");
@@ -879,19 +608,15 @@ export default function CompanyProfileForm({
* Collect the messages for a set of fields into one sentence.
*
* A failed `trigger()` used to return silently, so Continue simply did
* nothing — and every field whose input is conditionally rendered (or derived
* and never rendered at all) turned into an invisible dead end. Naming the
* failures is the whole point: the ones worth reporting are exactly the ones
* with no error text on screen to read.
* nothing — and every field whose input is conditionally rendered turned into
* an invisible dead end. Naming the failures is the whole point: the ones
* worth reporting are exactly the ones with no error text on screen to read.
*/
const describeErrors = (fields: (keyof FormData)[]): string => {
// Re-parse rather than read `errors`: that's the render-time snapshot, and
// this runs immediately after an `await trigger()` that has not re-rendered
// yet, so the closure would still be holding the previous attempt's state.
const parsed = buildOnboardingSchema(
identity?.passportRequired === true,
requiredKeys,
).safeParse(getValues());
const parsed = buildOnboardingSchema(requiredKeys).safeParse(getValues());
const wanted = new Set<string>(fields as string[]);
const messages = parsed.success
? []
@@ -903,21 +628,10 @@ export default function CompanyProfileForm({
: "Some details on this step are incomplete. Please review the fields above.";
};
/**
* The fields this step actually validates. `stepFields` covers what the step
* always renders; the company step additionally exposes company email/phone
* as inputs when nothing could be derived for them, and a field is validated
* exactly when the customer can see and fix it.
*/
const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => {
if (s !== "company" || !identity) return stepFields[s];
return [...stepFields.company];
};
/** Validate + persist the current step, returning whether we may advance. */
const saveCurrentStep = async (): Promise<boolean> => {
setSaveError(null);
const fields = fieldsForStep(step);
const fields = stepFields[step];
const isValid = await trigger(fields);
if (!isValid) {
setSaveError(describeErrors(fields));
@@ -969,13 +683,13 @@ export default function CompanyProfileForm({
}
setSaveError(null);
// Deliberately NOT `handleSubmit`: that re-validated all 34 schema fields
// including every field belonging to a step that isn't on screen — and
// on failure did nothing at all, no alert and no navigation, which is the
// Deliberately NOT `handleSubmit`: that re-validated every schema field
// including every field belonging to a step that isn't on screen — and on
// failure did nothing at all, no alert and no navigation, which is the
// "Submit for review" button that appears dead. Each step has already
// validated and saved its own fields, and the API's `markOnboardingComplete`
// is the authority on what is still outstanding; its message reaches the
// customer through `submitError`.
// validated and saved its own fields, and the API's
// `markOnboardingComplete` is the authority on what is still outstanding;
// its message reaches the customer through `submitError`.
onSubmit(buildPayload(getValues(), user));
return;
}
@@ -995,66 +709,50 @@ export default function CompanyProfileForm({
);
return;
}
// Fayda verification is proved outside the form state, so it gates here
// rather than through zod. The passport number is a plain typed field —
// buildOnboardingSchema already requires it when passportRequired, so
// saveCurrentStep()'s trigger() below catches that; checking the stale
// server-side identity.owner.passportNumber here would block a value the
// user just typed but hasn't saved yet.
if (
step === "company" &&
identity?.faydaRequired &&
!identity.owner.verified
) {
// The declaration decides whose identity is verified, so it has to be
// answered before the verification below can mean anything.
if (step === "representation" && !identity?.poaDeclared) {
setSaveError(
"Verify the company owner's identity with Fayda before continuing.",
"Tell us whether anyone holds power of attorney for this company.",
);
return;
}
// The GM is established through Fayda now, so the step gates on the
// identity rather than on typed text — same strength as the old required
// fields, different evidence. A foreign company's GM may hold no Fayda ID,
// so typed details still satisfy it there.
if (step === "personnel" && !gmEstablished) {
// The verification itself is proved outside the form state, so it gates
// here rather than through zod.
if (step === "representation" && !identityProven) {
const who =
identity?.subject === "poa"
? "your Power of Attorney"
: "the company owner";
setSaveError(
identity?.faydaRequired
? "Verify the general manager with Fayda, or tick “same as owner” if they are the company's owner."
: "Add the general manager's details, or verify them with Fayda.",
identity?.passportAccepted
? `Verify ${who} with Fayda, or enter their passport number.`
: `Verify ${who} with Fayda before continuing.`,
);
return;
}
if (step === "poa" && requirePoa && !poaEstablished) {
setSaveError(
identity?.faydaRequired
? "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda."
: "Freight forwarders act on other companies' behalf, so a Power of Attorney is required.",
);
return;
}
// The PoA step also gates on a file, which lives outside the form state.
if (step === "poa" && delegationRequired && !delegationPresent) {
// The step also gates on a file, which lives outside the form state.
if (step === "representation" && delegationRequired && !delegationPresent) {
setDocumentFieldErrors({
[POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required",
});
// Validate the text fields too, so every problem shows at once.
const fieldsOk = await trigger(stepFields.poa);
const fieldsOk = await trigger(stepFields.representation);
setSaveError(
[
requirePoa
? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
: "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
fieldsOk ? null : describeErrors(stepFields.poa),
"Upload the DARS delegation paper for the representative you named.",
fieldsOk ? null : describeErrors(stepFields.representation),
]
.filter(Boolean)
.join(" "),
);
return;
}
// The API will not accept the PoA's details until the paper evidencing the
// delegation is actually on file, so the selection made on this step has to
// be uploaded before the save — not held back until the documents step,
// which is unreachable while this save keeps failing.
if (step === "poa" && delegationRequired && onUploadDocuments) {
// The API will not accept the representative's details until the paper
// evidencing the delegation is actually on file, so the selection made on
// this step has to be uploaded before the save — not held back until the
// documents step, which is unreachable while this save keeps failing.
if (step === "representation" && delegationRequired && onUploadDocuments) {
const pending = documentFiles[POA_DELEGATION_FILE_KEY];
const hasPending = Array.isArray(pending)
? pending.length > 0
@@ -1092,8 +790,6 @@ export default function CompanyProfileForm({
{step === "company" && (
<CompanyInfoStep
form={form}
identity={identity}
verifiedIdentity={verifiedIdentity}
tinStatus={tinStatus}
tinVerified={tinVerified}
hasRegistrationDetails={hasRegistrationDetails}
@@ -1103,46 +799,36 @@ export default function CompanyProfileForm({
/>
)}
{step === "personnel" && (
<PersonnelStep
{step === "owner" && (
<OwnerStep
form={form}
identity={identity}
etradeOwner={etradeOwner}
gmSameAsOwner={gmSameAsOwner}
onToggleGmSameAsOwner={toggleGmSameAsOwner}
gmLinkPending={gmLinkPending}
gmVerified={gmVerified}
gaps={gmGaps}
gaps={ownerGaps}
/>
)}
{step === "representation" && (
<RepresentationStep
form={form}
identity={identity}
onDeclare={handleDeclare}
declarePending={declarePending}
gaps={poaGaps}
poaDocumentSetting={poaDocumentSetting}
documentFiles={documentFiles}
uploadedDocumentKeys={uploadedDocumentKeys}
documentFieldErrors={documentFieldErrors}
onDocumentFilesChange={handleDocumentFilesChange}
/>
)}
{step === "contact" && (
<ContactStep
form={form}
gmName={gmName}
contactSameAsGm={contactSameAsGm}
onToggleContactSameAsGm={toggleContactSameAsGm}
/>
)}
{step === "poa" && (
<PoaStep
form={form}
identity={identity}
requirePoa={requirePoa}
poaSameAsOwner={poaSameAsOwner}
onTogglePoaSameAsOwner={togglePoaSameAsOwner}
poaLinkPending={poaLinkPending}
etradeOwner={etradeOwner}
gaps={poaGaps}
onRemovePoa={removePoa}
removePending={poaRemovePending}
delegationRequired={delegationRequired}
poaDocumentSetting={poaDocumentSetting}
documentFiles={documentFiles}
uploadedDocumentKeys={uploadedDocumentKeys}
documentFieldErrors={documentFieldErrors}
onDocumentFilesChange={handleDocumentFilesChange}
ownerName={ownerName}
contactSameAsOwner={contactSameAsOwner}
onToggleContactSameAsOwner={toggleContactSameAsOwner}
/>
)}
@@ -1197,6 +883,7 @@ export default function CompanyProfileForm({
disabled={
isPending ||
saving ||
declarePending ||
(step === "documents" && !hasDocuments && loadingDocuments)
}
loading={isPending || saving}

View File

@@ -0,0 +1,61 @@
import { Badge, Group, Stack, Text } from "@mantine/core";
import type { ReactNode } from "react";
/** Where a prefilled value came from, shown as a badge next to it. */
export type FieldSource = "eTrade" | "Fayda";
const SOURCE_NOTE: Record<FieldSource, string> = {
eTrade: "From your eTrade licence",
Fayda: "From the Fayda verification",
};
/**
* One person-detail field that may already be answered for us.
*
* The onboarding wizard fills what it can from the eTrade lookup and the Fayda
* verification, and asks the customer only for what neither supplied. Both
* sources are patchy in practice — eTrade returns no email at all and often no
* manager name; Fayda's email and phone claims are optional and routinely come
* back empty — so "what is missing" varies per company and cannot be decided
* once at build time.
*
* This is the single place that decision is rendered: a supplied value shows
* read-only with its provenance, a gap shows the input. It pairs with
* `requiredKeys` in CompanyProfileForm, which requires exactly the fields that
* fall through to `children` — the invariant being that **a field is required
* if and only if there is an input on screen to satisfy it**.
*/
export default function SourcedField({
label,
value,
source,
children,
}: {
label: string;
/** The value a source supplied. Blank/absent means "ask the customer". */
value?: string | null;
source: FieldSource;
/** The input rendered when no source supplied a value. */
children: ReactNode;
}) {
if (!value?.trim()) return <>{children}</>;
return (
<Stack gap={2}>
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{label}
</Text>
<Badge size="xs" variant="light" color="edr-green">
{source}
</Badge>
</Group>
<Text className="wrap-break-word" size="sm" c="edr-text" fw={500}>
{value}
</Text>
<Text size="xs" c="edr-muted">
{SOURCE_NOTE[source]}
</Text>
</Stack>
);
}

View File

@@ -69,7 +69,6 @@ export function normalizeIdentityPhones(
...identity,
owner: fix(identity.owner),
poa: fix(identity.poa),
gm: fix(identity.gm),
};
}
@@ -97,15 +96,14 @@ export function buildPayload(
vatNumber: data.vatNumber,
attributes: {
ownerPassportNumber: data.ownerPassportNumber || undefined,
poaPassportNumber: data.poaPassportNumber || undefined,
contactPersonName: data.contactPersonName,
contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
// The representative's own details are written by their Fayda
// verification, so the city is all the form has to send.
ownerName: data.ownerName,
ownerEmail: data.ownerEmail,
ownerPhone: data.ownerPhone,
poaLocation: data.poaLocation || undefined,
},
};
@@ -137,21 +135,20 @@ export function stepPayload(
return {
companyAddress: d.companyAddress,
vatNumber: d.vatNumber,
ownerPassportNumber: d.ownerPassportNumber || undefined,
...etrade,
};
}
case "personnel":
case "owner":
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and
// undefined, so an empty string is validated and 400s with
// "generalManagerEmail must be an email". An Ethiopian company never types
// these — the GM comes from the Fayda verification (or the "same as owner"
// declaration), so the form fields are legitimately blank and would fail a
// step that has no input to fix.
// "ownerEmail must be an email". A field the eTrade lookup or the Fayda
// claim already filled is legitimately blank in the form — it has no
// input — so sending "" would fail a step with nothing on screen to fix.
return {
generalManagerName: d.generalManagerName || undefined,
generalManagerEmail: d.generalManagerEmail || undefined,
generalManagerPhone: d.generalManagerPhone || undefined,
ownerName: d.ownerName || undefined,
ownerEmail: d.ownerEmail || undefined,
ownerPhone: d.ownerPhone || undefined,
ownerPassportNumber: d.ownerPassportNumber || undefined,
};
case "contact":
return {
@@ -160,12 +157,13 @@ export function stepPayload(
contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
case "representation":
return {
poaName: d.poaName || undefined,
poaEmail: d.poaEmail || undefined,
poaPhone: d.poaPhone || undefined,
poaLocation: d.poaLocation || undefined,
poaPassportNumber: d.poaPassportNumber || undefined,
};
default:
return {};
@@ -183,6 +181,7 @@ export function toFormValues(p: ProfileResponse): FormData {
tinNumber: tin,
vatNumber: p.vatNumber ?? "",
ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
poaPassportNumber: p.identity?.poa.passportNumber ?? "",
licenceNumber: p.licenceNumber ?? "",
statusDescription: p.statusDescription ?? "",
dateRegistered: p.dateRegistered ?? "",
@@ -198,9 +197,9 @@ export function toFormValues(p: ProfileResponse): FormData {
contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "",
ownerName: p.ownerName ?? "",
ownerEmail: p.ownerEmail ?? "",
ownerPhone: p.ownerPhone ?? "",
poaName: p.poaName ?? "",
poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",

View File

@@ -35,9 +35,9 @@ const values = (over: Partial<FormData> = {}): FormData =>
contactPersonPosition: "",
contactPersonEmail: "",
contactPersonPhone: "+251911223344",
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
ownerName: "",
ownerEmail: "",
ownerPhone: "",
poaName: "",
poaPhone: "",
poaAddress: "",
@@ -131,31 +131,29 @@ describe("buildOnboardingSchema (conditionally required fields)", () => {
data: FormData,
required: (keyof FormData)[],
): (keyof FormData)[] => {
const parsed = buildOnboardingSchema(false, required).safeParse(data);
const parsed = buildOnboardingSchema(required).safeParse(data);
return parsed.success
? []
: (parsed.error.issues.map((i) => i.path[0]) as (keyof FormData)[]);
};
// Fayda's email/phone claims are optional: the step renders an input for what
// the verification did not supply, and requires exactly those. Nothing else —
// a field with no input on screen must never fail Continue.
// eTrade returns no email and Fayda's email/phone claims are optional: the
// step renders an input for what no source supplied, and requires exactly
// those. Nothing else — a field with no input on screen must never fail
// Continue.
it("requires only the keys it is handed", () => {
const issues = issuesFor(values(), [
"generalManagerEmail",
"generalManagerPhone",
]);
expect(issues).toEqual(["generalManagerEmail", "generalManagerPhone"]);
const issues = issuesFor(values(), ["ownerEmail", "ownerPhone"]);
expect(issues).toEqual(["ownerEmail", "ownerPhone"]);
});
it("passes once those keys are filled", () => {
expect(
issuesFor(
values({
generalManagerEmail: "gm@example.com",
generalManagerPhone: "+251911223344",
ownerEmail: "owner@example.com",
ownerPhone: "+251911223344",
}),
["generalManagerEmail", "generalManagerPhone"],
["ownerEmail", "ownerPhone"],
),
).toEqual([]);
});
@@ -165,9 +163,7 @@ describe("buildOnboardingSchema (conditionally required fields)", () => {
});
it("names the field in the message, so it reads under its own input", () => {
const parsed = buildOnboardingSchema(false, ["poaEmail"]).safeParse(
values(),
);
const parsed = buildOnboardingSchema(["poaEmail"]).safeParse(values());
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues[0]?.message).toBe(
@@ -198,37 +194,33 @@ describe("stepPayload (company)", () => {
});
});
describe("stepPayload (personnel)", () => {
// An Ethiopian company never types the GM — Fayda (or "same as owner") owns
// those fields — so the form holds "". `@IsOptional()` on the DTO skips only
// null/undefined, so an empty string is validated and comes back as
// "generalManagerEmail must be an email", on a step that renders no input.
it("omits blank GM fields instead of sending empty strings", () => {
describe("stepPayload (owner)", () => {
// A Fayda claim owns whatever it supplied, so the form holds "" for those.
// `@IsOptional()` on the DTO skips only null/undefined, so an empty string is
// validated and comes back as "ownerEmail must be an email" — on a step that
// renders no input for it.
it("omits blank owner fields instead of sending empty strings", () => {
const payload = stepPayload(
"personnel",
values({
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
}),
"owner",
values({ ownerName: "", ownerEmail: "", ownerPhone: "" }),
);
expect(payload.generalManagerName).toBeUndefined();
expect(payload.generalManagerEmail).toBeUndefined();
expect(payload.generalManagerPhone).toBeUndefined();
expect(payload.ownerName).toBeUndefined();
expect(payload.ownerEmail).toBeUndefined();
expect(payload.ownerPhone).toBeUndefined();
});
it("still sends typed GM details (foreign company)", () => {
it("sends the owner details the customer typed or eTrade prefilled", () => {
const payload = stepPayload(
"personnel",
"owner",
values({
generalManagerName: "Abebe Bikila",
generalManagerEmail: "gm@example.com",
generalManagerPhone: "+251911223344",
ownerName: "Abebe Bikila",
ownerEmail: "owner@example.com",
ownerPhone: "+251911223344",
}),
);
expect(payload.generalManagerName).toBe("Abebe Bikila");
expect(payload.generalManagerEmail).toBe("gm@example.com");
expect(payload.generalManagerPhone).toBe("+251911223344");
expect(payload.ownerName).toBe("Abebe Bikila");
expect(payload.ownerEmail).toBe("owner@example.com");
expect(payload.ownerPhone).toBe("+251911223344");
});
});
@@ -271,10 +263,11 @@ describe("firstValidEmail", () => {
describe("normalizeIdentityPhones", () => {
it("converts a local Fayda phone claim to E.164", () => {
const identity = {
faydaRequired: true,
passportRequired: false,
passportAccepted: false,
poaDeclared: "yes",
subject: "poa",
owner: {
verified: true,
verified: false,
name: "A",
phone: "0911223344",
email: null,
@@ -283,28 +276,22 @@ describe("normalizeIdentityPhones", () => {
passportNumber: null,
},
poa: {
verified: false,
name: null,
phone: null,
email: null,
address: null,
verifiedAt: null,
},
gm: {
verified: false,
name: null,
verified: true,
name: "B",
phone: "251911223344",
email: null,
address: null,
verifiedAt: null,
passportNumber: null,
},
gmSameAsOwner: false,
complete: false,
identityProven: true,
etradeManagerName: null,
ownerMatchesEtrade: null,
complete: true,
} as CompanyIdentityState;
const fixed = normalizeIdentityPhones(identity)!;
expect(fixed.owner.phone).toBe("+251911223344");
expect(fixed.gm.phone).toBe("+251911223344");
expect(fixed.poa.phone).toBeNull();
expect(fixed.poa.phone).toBe("+251911223344");
});
});

View File

@@ -5,9 +5,9 @@ import { isValidPhone } from "@/components/PhoneField";
export type CompanyStep =
| "company"
| "personnel"
| "owner"
| "representation"
| "contact"
| "poa"
| "documents"
| "additional";
@@ -27,10 +27,13 @@ export const onboardingSchema = z.object({
.string()
.min(1, "VAT number is required")
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
// The owner's passport number — the foreign-company identity credential
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
// enforced in buildOnboardingSchema since that depends on `nationality`.
// Passport numbers — the alternative identity credential for a foreign
// company (Fayda is an Ethiopian national ID). Only the one belonging to the
// declared identity subject is ever asked for, and only when that person has
// not verified with Fayda — so requiredness is decided per render and lives
// in `requiredKeys`, not here.
ownerPassportNumber: z.string().optional(),
poaPassportNumber: z.string().optional(),
licenceNumber: z.string().optional(),
statusDescription: z.string().optional(),
dateRegistered: z.string().optional(),
@@ -57,21 +60,23 @@ export const onboardingSchema = z.object({
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
// Optional here, not unrequired: the GM is now established by Fayda — either
// verified in their own right or declared the same person as the owner — so
// for an Ethiopian company these fields are never typed and would fail a
// blanket `min(1)`. Presence is gated per nationality in the step's own
// check, where the identity state is available; zod only polices format for
// the foreign companies that still type them.
generalManagerName: z.string().optional(),
generalManagerEmail: z
// The owner — whoever the eTrade licence names as the business's manager.
//
// Optional here, not unrequired: the eTrade lookup fills the name and phone,
// and a Fayda verification can fill all three, so on a well-supplied company
// none of them is typed and a blanket `min(1)` would fail a step with no
// input on screen. What IS required is decided per render — a field is
// required exactly when the step renders an input for it (`requiredKeys`).
// zod only polices format here.
ownerName: z.string().optional(),
ownerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid Manager email",
"Invalid owner email",
),
generalManagerPhone: z
ownerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
@@ -105,42 +110,36 @@ const CONDITIONAL_LABELS: Partial<Record<keyof FormData, string>> = {
poaName: "Representative's name",
poaEmail: "Representative's email",
poaPhone: "Representative's phone",
generalManagerName: "General manager's name",
generalManagerEmail: "General manager's email",
generalManagerPhone: "General manager's phone",
poaPassportNumber: "Representative's passport number",
ownerName: "Owner's name",
ownerEmail: "Owner's email",
ownerPhone: "Owner's phone",
ownerPassportNumber: "Owner's passport number",
};
/**
* The PoA's and GM's identifying fields normally come from their Fayda
* verification, so nothing in the base schema requires them. But Fayda's email
* and phone claims are optional and routinely come back empty, and the steps
* render an input for whatever the verification did not supply — so those
* fields become mandatory exactly then.
* The owner's and the representative's identifying fields arrive from three
* places — the eTrade lookup, a Fayda verification, or the customer typing them
* and which one supplies what varies per company. eTrade returns no email at
* all; Fayda's email and phone claims are optional and routinely come back
* empty. So nothing in the base schema requires them, and the steps render an
* input for whatever no source supplied.
*
* `requiredKeys` is that decision, made by CompanyProfileForm from the same
* state that drives the rendering: a field is required iff an input exists for
* it. Passing it in (rather than deriving it here) is what keeps the two from
* drifting into a Continue button that fails on a field nobody can see.
* state that drives the rendering: **a field is required iff an input exists
* for it**. Passing it in (rather than deriving it here) is what keeps the two
* from drifting into a Continue button that fails on a field nobody can see.
*/
export function buildOnboardingSchema(
/** True for a foreign company: the owner's passport number is mandatory. */
passportRequired = false,
/** Fields the current step renders an input for and must not leave blank. */
requiredKeys: readonly (keyof FormData)[] = [],
) {
if (!passportRequired && requiredKeys.length === 0) return onboardingSchema;
if (requiredKeys.length === 0) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => {
if (passportRequired && !d.ownerPassportNumber?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ownerPassportNumber"],
message: "The owner's passport number is required",
});
}
for (const key of requiredKeys) {
if (d[key]?.trim()) continue;
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
path: [key],
message: `${CONDITIONAL_LABELS[key] ?? key} is required`,
});
@@ -184,25 +183,29 @@ export const ETRADE_BUNDLE_FIELDS = [
* (`REQUIRED_COMPANY_INFO`), and reports it with a message.
*/
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
// Only the three fields this step actually renders an input for. The company
// name and the registered address are eTrade's, shown read-only.
company: ["tinNumber", "vatNumber", "ownerPassportNumber"],
personnel: [
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
],
// Only what this step actually renders an input for. The company name and the
// registered address are eTrade's, shown read-only.
company: ["tinNumber", "vatNumber"],
// `REQUIRED_COMPANY_INFO` demands all three server-side, so the step offers
// an input wherever eTrade and Fayda between them left a gap.
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerPassportNumber"],
contact: [
"contactPersonName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
],
// The API requires poaName/poaEmail/poaPhone from a freight forwarder
// (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the
// representative isn't proven by Fayda — otherwise the save is rejected
// naming fields the form never rendered.
poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"],
// The API requires poaName/poaEmail/poaPhone once a representative is
// declared (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever
// Fayda didn't supply them — otherwise the save is rejected naming fields the
// form never rendered.
representation: [
"poaName",
"poaEmail",
"poaPhone",
"poaLocation",
"poaPassportNumber",
],
documents: [],
additional: [],
};

View File

@@ -2,11 +2,9 @@ import { Stack, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import type { CompanyRegistrationData } from "@edr/types";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import ETradeInfo, {
type ETradeStatus,
} from "@/components/onboarding/ETradeInfo";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import ETradeCompanyCard from "../ETradeCompanyCard";
@@ -14,10 +12,6 @@ import StepSection from "../StepSection";
export interface CompanyInfoStepProps {
form: UseFormReturn<FormData>;
/** Fayda verification state, phone-normalized by the parent. */
identity?: CompanyIdentityState;
/** True when Fayda (not a passport) is what this company must prove with. */
verifiedIdentity: boolean;
tinStatus: ETradeStatus;
tinVerified: boolean;
/** Registration fields are already populated (a lookup passed, now or earlier). */
@@ -29,8 +23,6 @@ export interface CompanyInfoStepProps {
export default function CompanyInfoStep({
form,
identity,
verifiedIdentity,
tinStatus,
tinVerified,
hasRegistrationDetails,
@@ -66,48 +58,6 @@ export default function CompanyInfoStep({
<StepSection
index={2}
title="Owner identity"
subtitle={
!identity?.owner.verified && !verifiedIdentity
? "Provide the company owner's passport number."
: undefined
}
status={
verifiedIdentity
? identity?.owner.verified
? "done"
: identity?.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity?.passportRequired
? "blocked"
: "todo"
}
>
{identity && (
<>
<FaydaVerifyPanel
subject="owner"
title="Owner"
state={identity.owner}
required={identity.faydaRequired}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</>
)}
</StepSection>
<StepSection
index={3}
title="Company TIN"
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
status={

View File

@@ -9,19 +9,19 @@ import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface ContactStepProps {
form: UseFormReturn<FormData>;
/**
* The GM's name from whichever source established them (verification or form)
* — the "same as GM" card only makes sense once there is a GM.
* The owner's name from whichever source established them (eTrade, Fayda or
* typed) — the "same as owner" card only makes sense once there is one.
*/
gmName?: string;
contactSameAsGm: boolean;
onToggleContactSameAsGm: (checked: boolean) => void;
ownerName?: string;
contactSameAsOwner: boolean;
onToggleContactSameAsOwner: (checked: boolean) => void;
}
export default function ContactStep({
form,
gmName,
contactSameAsGm,
onToggleContactSameAsGm,
ownerName,
contactSameAsOwner,
onToggleContactSameAsOwner,
}: ContactStepProps) {
const {
register,
@@ -34,15 +34,15 @@ export default function ContactStep({
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
{/* `gmName`, not the raw form field: a Fayda-verified GM never
fills `generalManagerName`, so gating on it hid this card from
every Ethiopian company — the majority case. */}
{gmName && (
{/* `ownerName`, not the raw form field: the owner's name usually comes
from the eTrade lookup or a Fayda claim rather than being typed, so
gating on the form value would hide this card from most companies. */}
{ownerName && (
<LinkCheckboxCard
checked={contactSameAsGm}
onToggle={onToggleContactSameAsGm}
title="Same as General Manager"
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
checked={contactSameAsOwner}
onToggle={onToggleContactSameAsOwner}
title="Same as company owner"
description="Reuse the owner's name, email and phone. Uncheck to enter different details."
/>
)}
<SimpleGrid cols={2} spacing="md">

View File

@@ -0,0 +1,132 @@
import { Alert, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
import { AlertTriangle, Info } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { ControlledPhoneField } from "@/components/PhoneField";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import SourcedField from "../SourcedField";
export interface OwnerStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** eTrade's registered manager, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
/**
* Which of the owner's details neither eTrade nor Fayda supplied, and are
* therefore typed here. Computed by CompanyProfileForm, which requires
* exactly these in the schema — so every input below is one the customer is
* actually asked to fill, and nothing is required that has no input.
*/
gaps: { name: boolean; email: boolean; phone: boolean };
}
/**
* Who the company's owner is — meaning whoever the eTrade licence names as the
* business's manager. Not necessarily the legal owner, but the person the
* record has to match: the backoffice's check is precisely "is this the person
* on the licence".
*
* Nothing here falls back to the signed-in account. The person doing the
* onboarding is often not the person on the licence, and stamping their name,
* email and phone onto the owner turned three required fields into a guess
* wearing the licence's authority.
*/
export default function OwnerStep({
form,
identity,
etradeOwner,
gaps,
}: OwnerStepProps) {
const {
register,
control,
formState: { errors },
} = form;
// A verified owner's own claims outrank eTrade's record for display: the
// government IdP is the higher-trust source, and the API locks those fields
// to it. eTrade still supplies the name and phone when there is no
// verification — which is the case for every company represented by a PoA.
const ownerVerified = identity?.owner.verified ?? false;
const nameValue = identity?.owner.name || etradeOwner?.name || "";
const phoneValue = identity?.owner.phone || etradeOwner?.phone || "";
const emailValue = identity?.owner.email || "";
const nameSource = identity?.owner.name ? "Fayda" : "eTrade";
const phoneSource = identity?.owner.phone ? "Fayda" : "eTrade";
// A Fayda verification that names someone other than the person on the
// licence is the one thing this step exists to catch. Advisory here — the two
// sources transliterate Amharic names differently, so the reviewer decides —
// but the customer should see it now rather than be rejected later.
const mismatch = identity?.ownerMatchesEtrade === false;
return (
<Stack gap="md">
<Text size="sm" c="edr-muted">
These are the details of the person registered on your eTrade licence.
We fill in whatever eTrade and Fayda gave us; anything they left blank
we need from you.
</Text>
{!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.
</Alert>
)}
{mismatch && (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={18} />}
title="This doesn't match your eTrade licence"
>
Your licence lists{" "}
<strong>{identity?.etradeManagerName}</strong>, but the name here is{" "}
<strong>{nameValue}</strong>. You can continue, but our team will
check this before approving your account so make sure it's the
person the licence actually names.
</Alert>
)}
<SourcedField label="Owner's Name" value={nameValue} source={nameSource}>
<TextInput
label="Owner's Name"
placeholder="Abebe Bikila"
error={errors.ownerName?.message}
{...register("ownerName")}
/>
</SourcedField>
<SimpleGrid cols={{ base: 1, sm: gaps.email && gaps.phone ? 2 : 1 }} spacing="md">
{/* eTrade never returns an email for the manager and Fayda's email
claim is optional, so this is the field most companies actually
type it is required either way (`REQUIRED_COMPANY_INFO`). */}
<SourcedField label="Owner's Email" value={emailValue} source="Fayda">
<TextInput
label="Owner's Email"
type="email"
placeholder="owner@company.com"
error={errors.ownerEmail?.message}
{...register("ownerEmail")}
/>
</SourcedField>
<SourcedField
label="Owner's Phone"
value={phoneValue}
source={phoneSource}
>
<ControlledPhoneField
control={control}
name="ownerPhone"
label="Owner's Phone"
/>
</SourcedField>
</SimpleGrid>
</Stack>
);
}

View File

@@ -1,150 +0,0 @@
import { SimpleGrid, Text, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PersonnelStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** eTrade-registered owner, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
gmSameAsOwner: boolean;
onToggleGmSameAsOwner: (checked: boolean) => void;
/** A server-side "same as owner" declaration is in flight. */
gmLinkPending: boolean;
gmVerified: boolean;
/**
* Which of the manager's contact details their Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
* requires exactly these in the schema.
*/
gaps: { name: boolean; email: boolean; phone: boolean };
}
export default function PersonnelStep({
form,
identity,
etradeOwner,
gmSameAsOwner,
onToggleGmSameAsOwner,
gmLinkPending,
gmVerified,
gaps,
}: PersonnelStepProps) {
const {
register,
control,
formState: { errors },
} = form;
return (
<>
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
{/* The GM is very often the owner. Where the owner is
Fayda-verified this reuses that proven identity outright
rather than making the same human verify twice; where the
owner is backed by a typed passport there is nothing proven
to copy, so it stays a local prefill. */}
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={onToggleGmSameAsOwner}
title={
identity?.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
identity?.owner.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: etradeOwner
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
}
/>
{/* Verifying a second person is only meaningful when the GM is
someone other than the owner. */}
{!gmSameAsOwner && identity && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={identity.gm}
required={identity.faydaRequired}
disabled={gmLinkPending}
/>
)}
{/* Fayda's name, email and phone claims are all optional, and the
manager's own verification has no account to fall back on the way the
owner's does — the person onboarding is not necessarily the manager.
Whatever the verification left empty is typed here, and required:
without it the submit fails on "Add your general manager name" with no
field anywhere to satisfy it. */}
{gaps.name && (
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
)}
{(gaps.email || gaps.phone) && (
<SimpleGrid cols={gaps.email && gaps.phone ? 2 : 1} spacing="md">
{gaps.email && (
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
)}
{gaps.phone && (
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
)}
</SimpleGrid>
)}
{/* Typed details survive only where Fayda cannot be required —
a foreign company's manager may hold no Fayda ID. Once
verified the API owns these fields, so they go away. */}
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
<>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
)}
</>
);
}

View File

@@ -1,217 +0,0 @@
import { Button, Divider, Group, SimpleGrid, Text, TextInput } from "@mantine/core";
import { Trash2 } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { SmartFileInput } from "@edr/ui-common";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PoaStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
requirePoa: boolean;
/** The owner represents the company themselves. */
poaSameAsOwner: boolean;
onTogglePoaSameAsOwner: (checked: boolean) => void;
/** A server-side "same as owner" declaration is in flight. */
poaLinkPending: boolean;
/** eTrade-registered owner, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
/**
* Which of the representative's details the Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
* requires exactly these in the schema — so every input rendered below is one
* the customer is actually asked to fill.
*/
gaps: { name: boolean; email: boolean; phone: boolean; address: boolean };
/** Drop a verified representative the company decided against. */
onRemovePoa: () => void;
removePending: boolean;
/** The DARS delegation paper is owed (a PoA exists, or the company forwards). */
delegationRequired: boolean;
/** Single-field upload setting carrying just the delegation letter. */
poaDocumentSetting?: FileUploadSetting;
documentFiles: Record<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
}
export default function PoaStep({
form,
identity,
requirePoa,
poaSameAsOwner,
onTogglePoaSameAsOwner,
poaLinkPending,
etradeOwner,
gaps,
onRemovePoa,
removePending,
delegationRequired,
poaDocumentSetting,
documentFiles,
uploadedDocumentKeys,
documentFieldErrors,
onDocumentFilesChange,
}: PoaStepProps) {
const {
register,
control,
formState: { errors },
} = form;
// Fayda's email/phone/address claims are optional and routinely come back
// empty, so a *verified* representative can still be missing the email and
// phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) —
// and the panel above renders no input for them, which dead-ends the step on
// "Add the poa email first". `gaps` is exactly what the verification did not
// supply: the API keeps those keys typeable, since a claim that returned
// nothing owns no value to protect (`faydaOwnedKeys`).
const needsEmail = gaps.email;
const needsPhone = gaps.phone;
// Fayda is mandatory for an Ethiopian company's representative, so there the
// link can only reuse a proven owner — with none there would be nothing to
// copy and the declaration could never satisfy the gate. A foreign company's
// owner is backed by a typed passport, so it prefills instead.
const linkNeedsVerifiedOwner =
(identity?.faydaRequired ?? false) && !identity?.owner.verified;
return (
<>
<Text size="sm" c="edr-muted">
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue."}{" "}
{poaSameAsOwner
? "You represent the company yourself, so no delegation paper is needed."
: "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."}
</Text>
{/* An owner who represents their own company is the ordinary
small-business case. Where the owner is Fayda-verified this reuses
that proven identity outright rather than sending the same human
through Fayda twice; where they are backed by a typed passport there
is nothing proven to copy, so it stays a local prefill. Either way it
is the declaration that waives the DARS paper. */}
{identity && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={onTogglePoaSameAsOwner}
disabled={poaLinkPending || (linkNeedsVerifiedOwner && !poaSameAsOwner)}
title={
identity.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
linkNeedsVerifiedOwner
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: identity.owner.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: etradeOwner
? "You represent the company yourself. Reuses the eTrade-registered owner's name plus the company email and phone as you entered them, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses your account's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
)}
{/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come
from the verification and are never typed here. Verifying a second
person is only meaningful when the representative is not the owner. */}
{identity && !poaSameAsOwner && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
disabled={poaLinkPending}
/>
)}
{/* A verification cannot be undone by clearing the form — it owns those
fields — and its mere existence makes the delegation paper due, which
then blocks the submit. So an optional representative needs a way
back out, here rather than only in settings (unreachable until
onboarding finishes). */}
{identity?.poa.verified && !requirePoa && !poaSameAsOwner && (
<Group justify="flex-end">
<Button
type="button"
variant="subtle"
color="red"
size="xs"
loading={removePending}
leftSection={<Trash2 size={14} />}
onClick={onRemovePoa}
>
Remove this representative
</Button>
</Group>
)}
{/* Whatever the Fayda claim did carry is shown on the panel above and
is never typed here — the verification owns it. */}
{gaps.name && (
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
)}
{(needsEmail || needsPhone) && (
<SimpleGrid cols={needsEmail && needsPhone ? 2 : 1} spacing="md">
{needsEmail && (
<TextInput
label="Representative's Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
)}
{needsPhone && (
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
)}
</SimpleGrid>
)}
{gaps.address && (
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
{/* The paper authorises the representative, so it shows once one
exists — or straight away for a freight forwarder, who owes it
either way and must not be failed on submit for a file the
step never offered. */}
{delegationRequired && poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={onDocumentFilesChange}
/>
</>
)}
</>
);
}

View File

@@ -0,0 +1,273 @@
import {
Alert,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Info, UserCheck, UserX } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { SmartFileInput } from "@edr/ui-common";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import RoleCard from "@/pages/settings/RoleCard";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type {
CompanyIdentityState,
PoaDeclaration,
} from "@/services/verifayda.service";
import type { FormData } from "../schema";
import SourcedField from "../SourcedField";
export interface RepresentationStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** Answer the power-of-attorney question (persisted server-side). */
onDeclare: (declared: PoaDeclaration) => void;
/** A declaration change is in flight. */
declarePending: boolean;
/**
* Which of the representative's details the Fayda verification did not
* supply. Same contract as OwnerStep's `gaps`: an input is rendered for
* exactly these, and the schema requires exactly these.
*/
gaps: { name: boolean; email: boolean; phone: boolean; address: boolean };
/** Single-field upload setting carrying just the DARS delegation letter. */
poaDocumentSetting?: FileUploadSetting;
documentFiles: Record<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
}
/**
* Who acts for this company — and, as a direct consequence, whose identity gets
* verified.
*
* A company proves itself through exactly one person. This step asks which:
* name a Power of Attorney and it is the representative who verifies (plus the
* DARS paper evidencing the delegation); say there is none and the owner
* verifies here instead. There is no third answer — "the owner represents the
* company themselves" IS "no".
*
* A freight forwarder is never asked. It signs on other companies' behalf, so a
* representative and the paper behind them are non-negotiable; the API forces
* the answer regardless of what the portal sends.
*/
export default function RepresentationStep({
form,
identity,
onDeclare,
declarePending,
gaps,
poaDocumentSetting,
documentFiles,
uploadedDocumentKeys,
documentFieldErrors,
onDocumentFilesChange,
}: RepresentationStepProps) {
const {
register,
control,
formState: { errors },
} = form;
if (!identity) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
const declared = identity.poaDeclared;
const locked = identity.poaDeclared === "yes" && identity.subject === "poa";
// Only the API knows whether the lock is the freight-forwarder rule; it
// reports the answer as "yes" for them no matter what is stored, so a company
// that cannot switch to "no" is one the API will refuse. Rather than
// duplicating the role check here, the "no" card simply reports the refusal.
const passportAccepted = identity.passportAccepted;
return (
<Stack gap="lg">
<Stack gap="xs">
<Text fw={600} size="lg" c="edr-text">
Does anyone hold power of attorney for this company?
</Text>
<Text size="sm" c="edr-muted">
Your answer decides whose identity we verify the representative's,
or the owner's.
</Text>
</Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Yes, we have a representative"
description="Someone other than the owner is authorised to act for the company. We'll verify their identity and ask for the DARS delegation paper."
icon={<UserCheck size={20} />}
selected={declared === "yes"}
onClick={
declarePending || declared === "yes"
? undefined
: () => onDeclare("yes")
}
/>
<RoleCard
label="No, the owner acts for us"
description="Nobody holds power of attorney. We'll verify the owner instead, and no delegation paper is needed."
icon={<UserX size={20} />}
selected={declared === "no"}
onClick={
declarePending || declared === "no"
? undefined
: () => onDeclare("no")
}
/>
</SimpleGrid>
{locked && (
<Alert color="blue" variant="light" icon={<Info size={18} />}>
As a freight forwarder you act on other companies' behalf, so a Power
of Attorney is required — this can't be set to "no" while you hold the
freight forwarder role.
</Alert>
)}
{declared === null && (
<Text size="sm" c="edr-muted">
Pick one to continue.
</Text>
)}
{/* ---------------------------------------------------------------- */}
{/* No representative → the owner is the one who verifies. */}
{/* ---------------------------------------------------------------- */}
{declared === "no" && (
<>
<Divider />
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={identity.owner}
required={!passportAccepted}
/>
{/* Fayda is an Ethiopian national ID, so a foreign company's owner
may hold none — a passport number proves them instead. Offered
alongside, not after: either one satisfies the gate. */}
{passportAccepted && !identity.owner.verified && (
<TextInput
label="Owner's Passport Number"
description="If the owner has no Fayda ID, their passport number proves their identity instead."
placeholder="P1234567"
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</>
)}
{/* ---------------------------------------------------------------- */}
{/* A representative → they verify, and the delegation is evidenced. */}
{/* ---------------------------------------------------------------- */}
{declared === "yes" && (
<>
<Divider />
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={!passportAccepted}
/>
{passportAccepted && !identity.poa.verified && (
<TextInput
label="Representative's Passport Number"
description="If your representative has no Fayda ID, their passport number proves their identity instead."
placeholder="P1234567"
error={errors.poaPassportNumber?.message}
{...register("poaPassportNumber")}
/>
)}
{/* Whatever the Fayda claim carried is shown on the panel above and
never typed here — the verification owns it. The rest is asked
for outright, because the API demands name/email/phone from any
declared representative (`REQUIRED_POA_FIELDS`). */}
<SourcedField
label="Representative's Name"
value={gaps.name ? "" : identity.poa.name}
source="Fayda"
>
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
</SourcedField>
<SimpleGrid
cols={{ base: 1, sm: gaps.email && gaps.phone ? 2 : 1 }}
spacing="md"
>
<SourcedField
label="Representative's Email"
value={gaps.email ? "" : identity.poa.email}
source="Fayda"
>
<TextInput
label="Representative's Email"
type="email"
placeholder="representative@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
</SourcedField>
<SourcedField
label="Representative's Phone"
value={gaps.phone ? "" : identity.poa.phone}
source="Fayda"
>
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
</SourcedField>
</SimpleGrid>
{gaps.address && (
<TextInput
label="Representative's Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
{poaDocumentSetting && (
<>
<Divider my="sm" />
<Text size="sm" c="edr-muted">
Upload the delegation paper authenticated by DARS. It is what
evidences that this person was actually delegated.
</Text>
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={onDocumentFilesChange}
/>
</>
)}
</>
)}
</Stack>
);
}

View File

@@ -24,14 +24,12 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import type { CompanyRegistrationData } from "@edr/types";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
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 { normalizeIdentityPhones } from "@/pages/accounts/companyProfileForm/helpers";
export const COMPANY_PROFILE_SCHEMA = z.object({
// eTrade-sourced and read-only, like the registration block below.
@@ -149,11 +147,6 @@ export default function TabCompanyProfile({
// Fayda stores the phone as the national registry holds it (often a local
// number), which neither this form's E.164 validation nor the API's
// `@IsValidPhone()` accepts. Normalize on read — same as the wizard.
const identity = useMemo(
() => normalizeIdentityPhones(profile?.identity),
[profile?.identity],
);
const verifiedIdentity = identity?.faydaRequired === true;
// companyAddress is composed from the (locked) eTrade address parts, not
// typed directly.
@@ -287,13 +280,6 @@ export default function TabCompanyProfile({
validationError ??
(mutation.isError ? extractApiError(mutation.error).message : null);
const pendingOwnerReview = Boolean(
(
profile?.pendingChanges as {
faydaIdentity?: Record<string, unknown>;
} | null
)?.faydaIdentity?.ownerFaydaSub,
);
// During onboarding the role selection gates the form: nothing else shows
// until the user picks Importer/Exporter or Freight Forwarder.
@@ -335,51 +321,9 @@ export default function TabCompanyProfile({
/>
</StepSection>
{identity && (
<StepSection
index={2}
title="Owner identity"
subtitle={
verifiedIdentity
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
: "The company owner's passport number."
}
status={
verifiedIdentity
? identity.owner.verified
? "done"
: identity.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity.passportRequired
? "blocked"
: "todo"
}
>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={identity.owner}
required={identity.faydaRequired}
disabled={mutation.isPending}
pendingReview={pendingOwnerReview}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</StepSection>
)}
<StepSection
index={3}
index={2}
title="Company TIN"
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
status={

View File

@@ -1,333 +0,0 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
import {
Alert,
Card,
Group,
Stack,
Title,
Text,
TextInput,
Button,
Grid,
} from "@mantine/core";
import { api } from "@/services/api";
import { verifaydaService } from "@/services/verifayda.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { ProfileResponse } from "@/types/profile";
// Optional, not unrequired: an Ethiopian company's GM is established through
// Fayda and never types these, so a blanket `min(1)` would fail a form that is
// correct. Presence is gated below, where the identity state says which route
// applies; zod only polices format for the companies that still type them.
const schema = z.object({
generalManagerName: z.string().optional(),
generalManagerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid GM email",
),
generalManagerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
interface TabGeneralManagerProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
/**
* The general manager's identity comes from Fayda: either verified in their
* own right, or declared to be the owner — very often the same human, which is
* what "Same as owner" is for. Typed details survive only for a foreign
* company, whose manager may hold no Fayda ID at all.
*/
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
const queryClient = useQueryClient();
const owner = profile.identity?.owner;
const identity = profile.identity;
const gm = identity?.gm;
const faydaRequired = identity?.faydaRequired ?? false;
const [gmSameAsOwner, setGmSameAsOwner] = useState(
identity?.gmSameAsOwner ?? false,
);
const defaultValues = useMemo((): FormData => {
return {
generalManagerName: profile.generalManagerName ?? "",
generalManagerEmail: profile.generalManagerEmail ?? "",
generalManagerPhone: profile.generalManagerPhone ?? "",
};
}, [profile]);
const {
register,
control,
handleSubmit,
reset,
setValue,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
values: defaultValues,
});
/**
* With a Fayda-verified owner the declaration is made server-side — the API
* copies the proven identity onto the GM — so nothing is typed here. Without
* one (a foreign company, whose owner is backed by a passport) there is
* nothing proven to copy and this stays a local prefill.
*/
const [linkPending, setLinkPending] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const toggleGmSameAsOwner = async (checked: boolean) => {
setGmSameAsOwner(checked);
setLinkError(null);
if (!owner?.verified) {
if (checked && owner) {
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
}
return;
}
setLinkPending(true);
try {
if (checked) await verifaydaService.setGmSameAsOwner();
else await verifaydaService.clearGmIdentity();
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
} catch (err) {
setGmSameAsOwner(!checked);
setLinkError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error ? err.message : "Could not update the general manager"),
);
} finally {
setLinkPending(false);
}
};
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
// "generalManagerEmail must be an email". A verified manager legitimately
// leaves the fields Fayda did supply blank here.
generalManagerName: data.generalManagerName || undefined,
generalManagerEmail: data.generalManagerEmail || undefined,
generalManagerPhone: data.generalManagerPhone || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
if (mode === "onboarding") onContinue?.();
},
});
const onSubmit = (data: FormData) => mutation.mutate(data);
// Nothing to save when Fayda owns the details: the verification and the
// "same as owner" declaration both write server-side, so the form would be
// posting empty strings over a proven identity.
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
// Except for what the verification never supplied. Fayda's name, email and
// phone claims are all optional, and a manager verified without them has no
// account to fall back on the way the owner does — so those stay typed, here
// as well as in onboarding, or a wrong value could never be corrected.
const gmGaps = {
name: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.name?.trim(),
email: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.email?.trim(),
phone: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.phone?.trim(),
};
const savable =
typedFieldsInUse || gmGaps.name || gmGaps.email || gmGaps.phone;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Briefcase size={20} />
<Title order={3}>General Manager</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Manage the general manager information
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={toggleGmSameAsOwner}
title={
owner?.verified ? "Same as verified owner" : "Same as business owner"
}
description={
owner?.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: "Reuse the owner's name, email and phone. Uncheck to enter different details."
}
/>
{linkError && (
<Alert color="red" variant="light" icon={<XCircle size={18} />}>
{linkError}
</Alert>
)}
{/* Verifying a second person only means something when the manager
is someone other than the owner. */}
{!gmSameAsOwner && gm && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={gm}
required={faydaRequired}
disabled={linkPending || mutation.isPending}
/>
)}
{/* Whatever the verification did not supply is typed instead — the
API keeps exactly those keys writable. */}
{gmGaps.name && (
<TextInput
label="Full Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
)}
{(gmGaps.email || gmGaps.phone) && (
<Grid>
{gmGaps.email && (
<Grid.Col span={gmGaps.phone ? 6 : 12}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
</Grid.Col>
)}
{gmGaps.phone && (
<Grid.Col span={gmGaps.email ? 6 : 12}>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
)}
</Grid>
)}
{/* Typed details survive only where Fayda cannot be required — a
foreign company's manager may hold no Fayda ID. Once verified the
API owns these fields and refuses edits, so they go away. */}
{!gmSameAsOwner && !gm?.verified && !faydaRequired && (
<>
<TextInput
label="Full Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
</Grid>
</>
)}
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>Saved successfully</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>Save failed</Text>
</Group>
)}
</Group>
<Group gap="md">
{mode === "edit" && typedFieldsInUse && (
<Button
type="button"
variant="outline"
disabled={mutation.isPending || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
)}
{/* Saving only means something while the details are typed: under
Fayda both routes write server-side, so a submit would post
empty strings at an identity the API owns and refuses to
overwrite. Onboarding still needs a way forward, so the button
becomes a plain Continue rather than disappearing. */}
{savable ? (
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
{mode === "onboarding" ? "Continue" : "Save Changes"}
</Button>
) : (
mode === "onboarding" && (
<Button type="button" onClick={() => onContinue?.()}>
Continue
</Button>
)
)}
</Group>
</Group>
</form>
</Card>
);
}

View File

@@ -0,0 +1,198 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { AlertTriangle, Briefcase, Save } from "lucide-react";
import {
Alert,
Button,
Card,
Grid,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField";
import type { ProfileResponse } from "@/types/profile";
// Optional, not unrequired: whatever a Fayda verification supplied is owned by
// the API and never typed here, so a blanket `min(1)` would fail a form that is
// correct. Presence is gated below, where the identity state says which fields
// are actually on screen; zod only polices format.
const schema = z.object({
ownerName: z.string().optional(),
ownerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid owner email",
),
ownerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
interface TabOwnerProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
/**
* The company's owner — meaning whoever the eTrade licence names as the
* business's manager. Not necessarily the legal owner, but the person the
* record has to match: the backoffice's check is that comparison.
*
* Their identity is Fayda-verified only when the company has NO Power of
* Attorney; when it names a representative it is the representative who
* verifies, and the owner's details are simply recorded (from eTrade, or typed
* here). Either way all three are required.
*/
export default function TabOwner({
profile,
mode = "edit",
onContinue,
}: TabOwnerProps) {
const queryClient = useQueryClient();
const 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";
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
ownerName: profile.ownerName ?? "",
ownerEmail: profile.ownerEmail ?? "",
ownerPhone: profile.ownerPhone ?? "",
},
});
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,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
if (mode === "onboarding") onContinue?.();
},
});
// 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;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Briefcase size={20} />
<Title order={3}>Company Owner</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
The person registered on your eTrade licence.
</Text>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
<Stack gap="md">
{identity?.ownerMatchesEtrade === false && (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={18} />}
title="This doesn't match your eTrade licence"
>
Your licence lists <strong>{identity.etradeManagerName}</strong>.
Our team checks this before approving changes.
</Alert>
)}
{ownerIsSubject && owner && (
<FaydaVerifyPanel
subject="owner"
title="Owner identity"
state={owner}
required={!identity?.passportAccepted}
pendingReview={profile.reviewStatus === "pending"}
/>
)}
<SourcedField label="Name" value={owner?.name} source="Fayda">
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.ownerName?.message}
{...register("ownerName")}
/>
</SourcedField>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField label="Email" value={owner?.email} source="Fayda">
<TextInput
label="Email"
type="email"
placeholder="owner@company.com"
error={errors.ownerEmail?.message}
{...register("ownerEmail")}
/>
</SourcedField>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField label="Phone" value={owner?.phone} source="Fayda">
<ControlledPhoneField
control={control}
name="ownerPhone"
label="Phone"
/>
</SourcedField>
</Grid.Col>
</Grid>
{savable && (
<Group justify="flex-end">
<Button
type="submit"
color="edr-green"
loading={mutation.isPending}
leftSection={<Save size={16} />}
>
{mode === "onboarding" ? "Save & continue" : "Save changes"}
</Button>
</Group>
)}
</Stack>
</form>
</Card>
);
}

View File

@@ -13,6 +13,7 @@ import {
Undo2,
UploadCloud,
UserCheck,
UserX,
XCircle,
} from "lucide-react";
import {
@@ -24,6 +25,7 @@ import {
Card,
Group,
Loader,
SimpleGrid,
Stack,
Title,
Text,
@@ -41,7 +43,7 @@ import {
} from "@/services/companies.service";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import RoleCard from "@/pages/settings/RoleCard";
import type { ProfileResponse } from "@/types/profile";
// The representative's name, email, phone and address all come from their
@@ -136,21 +138,17 @@ export default function TabPowerOfAttorney({
// company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified.
const identity = profile.identity;
const owner = identity?.owner;
const poaProvided = identity?.poa.verified ?? false;
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
identity?.poaSameAsOwner ?? false,
);
// The paper authorises the representative named above, so there is nothing
// for it to authorise until one has been verified — the upload is hidden
// until then, and requiring it while hidden would block the save on a
// control the customer cannot see. A freight forwarder is still held to
// having a PoA at all, by the verification gate on the panel and by the API.
//
// And nobody delegates to themselves: an owner representing their own company
// has no delegation to evidence, which is the same waiver the API applies in
// `assertPoaDelegationSatisfied`.
const letterRequired = poaProvided && !poaSameAsOwner;
// 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.
const declared = identity?.poaDeclared ?? null;
// The paper is owed exactly when the company says it has a representative —
// the same single rule `assertPoaDelegationSatisfied` enforces. Keying it on
// the verification instead would hide the upload from a foreign company whose
// representative proves themselves by passport, then fail the save for a file
// that was never offered.
const letterRequired = declared === "yes";
const letterMissing = letterRequired && !hasLetterAfterSave;
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
@@ -191,11 +189,12 @@ export default function TabPowerOfAttorney({
/**
* A verified representative cannot be removed by blanking the form — their
* fields are owned by the verification — so removal is its own action that
* clears the identity and the delegation paper together.
* fields are owned by the verification — so removal is answering the
* declaration "no", which clears the identity, the details and the
* delegation paper together. Refused by the API for a freight forwarder.
*/
const removeMutation = useMutation({
mutationFn: () => verifaydaService.removePoa(),
mutationFn: () => verifaydaService.setPoaDeclared("no"),
onSuccess: () => {
resetAll();
queryClient.invalidateQueries({
@@ -208,25 +207,20 @@ export default function TabPowerOfAttorney({
});
/**
* "Same as owner": the owner represents the company themselves. Always goes
* to the API, whichever credential backs the owner — the declaration is what
* waives the DARS paper, so it has to be recorded server-side even when there
* is no proven identity to copy.
* Answer the power-of-attorney question.
*
* Unchecking undoes the declaration only. It leaves the paper on file and is
* allowed for a freight forwarder, which is how one changes who represents
* it; "Remove representative" below is the harder action that takes the paper
* with it and is refused to a forwarder.
* "No" means the owner acts for the company themselves there is no
* delegation, so no DARS paper is owed and it is the OWNER whose identity is
* verified. The API tears the representative down when this is answered, and
* refuses "no" outright for a freight forwarder.
*/
const [linkPending, setLinkPending] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const togglePoaSameAsOwner = async (checked: boolean) => {
setPoaSameAsOwner(checked);
const declare = async (next: "yes" | "no") => {
setLinkError(null);
setLinkPending(true);
try {
if (checked) await verifaydaService.setPoaSameAsOwner();
else await verifaydaService.clearPoaSameAsOwner();
await verifaydaService.setPoaDeclared(next);
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
@@ -234,7 +228,6 @@ export default function TabPowerOfAttorney({
queryKey: api.companies.poaDelegation.queryKey(),
});
} catch (err) {
setPoaSameAsOwner(!checked);
setLinkError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
@@ -297,10 +290,10 @@ export default function TabPowerOfAttorney({
<Text c="edr-muted" size="sm" mb="lg">
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required."
: "Power of Attorney details are optional."}{" "}
{poaSameAsOwner
? "You represent the company yourself, so no delegation paper is needed."
: "If you name a representative, upload the DARS delegation paper authorising them."}
: "Tell us whether anyone is authorised to act for the company — your answer decides whose identity we verify."}{" "}
{declared === "no"
? "The owner acts for the company, so no delegation paper is needed."
: "A representative must be identified, and the DARS delegation paper authorising them uploaded."}
</Text>
{/* The owner representing their own company is the ordinary
@@ -309,30 +302,35 @@ export default function TabPowerOfAttorney({
mandatory it needs a verified owner first — there would be nothing
proven to copy, and a representative who could never satisfy the
gate. */}
{/* The declaration itself. "No" is not a lesser answer — it means the
owner acts for the company, so it is the OWNER who verifies and no
delegation paper is owed. A freight forwarder cannot choose it; the
API refuses and the error lands in `linkError`. */}
{identity && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={togglePoaSameAsOwner}
disabled={
linkPending ||
mutation.isPending ||
(!poaSameAsOwner &&
(identity.faydaRequired ?? false) &&
!owner?.verified)
}
title={
owner?.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
(identity.faydaRequired ?? false) && !owner?.verified
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: owner?.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses the owner's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
<RoleCard
label="Yes, we have a representative"
description="Someone other than the owner is authorised to act for the company. Their identity is verified and the DARS delegation paper is required."
icon={<UserCheck size={20} />}
selected={declared === "yes"}
onClick={
linkPending || declared === "yes"
? undefined
: () => void declare("yes")
}
/>
<RoleCard
label="No, the owner acts for us"
description="Nobody holds power of attorney. The owner's identity is verified instead, and no delegation paper is needed."
icon={<UserX size={20} />}
selected={declared === "no"}
onClick={
linkPending || declared === "no"
? undefined
: () => void declare("no")
}
/>
</SimpleGrid>
)}
{linkError && (
@@ -343,12 +341,12 @@ export default function TabPowerOfAttorney({
{/* Verifying a second person only means something when the
representative is someone other than the owner. */}
{identity && !poaSameAsOwner && (
{identity && declared === "yes" && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
required={!identity.passportAccepted}
disabled={mutation.isPending || linkPending}
/>
)}
@@ -359,7 +357,7 @@ export default function TabPowerOfAttorney({
verification and are shown on the panel above. Only a company
whose representative may hold no Fayda ID still types a
location. */}
{!poaProvided && !(identity?.faydaRequired ?? false) && (
{declared === "yes" && !poaProvided && (
<Grid>
<Grid.Col span={6}>
<TextInput
@@ -376,7 +374,7 @@ export default function TabPowerOfAttorney({
{/* ------------------------ Delegation letter ------------------------ */}
{/* The paper authorises the representative the verification named,
so it only has meaning once one exists. */}
{poaProvided && !poaSameAsOwner && (
{declared === "yes" && (
<Stack gap="sm" mt="xl">
<Group justify="space-between" align="center">
<Group gap="sm">
@@ -418,7 +416,7 @@ export default function TabPowerOfAttorney({
>
{requirePoa
? "Upload the DARS delegation paper before saving — it is required for freight forwarders."
: "Upload the DARS delegation paper for the representative you named, or clear the PoA details."}
: "Upload the DARS delegation paper for the representative you named, or answer \u201cthe owner acts for us\u201d instead."}
</Alert>
)}
@@ -551,12 +549,12 @@ export default function TabPowerOfAttorney({
)}
</Group>
<Group gap="md">
{/* Not offered against a "same as owner" declaration: unchecking
the card above is the way out of that one, and it leaves the
paper alone. */}
{/* Answering "the owner acts for us" is the same teardown, so
this is only a shortcut — and it is refused to a forwarder,
which cannot be without a representative. */}
{mode === "edit" &&
identity?.poa.verified &&
!poaSameAsOwner &&
declared === "yes" &&
!requirePoa && (
<Button
type="button"

View File

@@ -158,14 +158,17 @@ export interface OnboardingLicenseProfile {
uploaded: boolean;
}
/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */
/** Power of Attorney state, driven by the company's own declaration. */
export interface OnboardingPoaState {
required: boolean;
provided: boolean;
/**
* True when the DARS delegation paper is owed. False when the owner
* represents the company themselves — nobody delegates to themselves.
* True for a freight forwarder: it signs on other companies' behalf, so a
* representative is non-negotiable and the question is shown answered rather
* than asked.
*/
locked: boolean;
/** The company's answer. Null until it answers — itself an outstanding item. */
declared: "yes" | "no" | null;
/** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */
delegationLetterRequired: boolean;
delegationLetterUploaded: boolean;
/** True when a reviewer sent the DARS delegation paper back for correction. */
@@ -189,7 +192,7 @@ export interface OnboardingRequirements {
documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[];
poa: OnboardingPoaState;
/** Fayda verification state; `required` is false for a foreign company. */
/** The company's single identity verification, and whose it is. */
identity: CompanyIdentityState;
progress: { completed: number; total: number };
isComplete: boolean;

View File

@@ -3,14 +3,21 @@ import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
/**
* Which of the company's people a verification is for. The owner is who the
* company is proven through; the PoA and GM are personnel it names. The GM is
* very often the owner — "same as owner" reuses that verification rather than
* making the same human prove themselves twice.
* Which of the company's two people a verification is for.
*
* The **owner** is whoever the eTrade licence names as the business's manager
* — not necessarily the legal owner, but the person the record has to match.
* The **PoA** is who the company delegates to act for it.
*
* Exactly one of them is verified, chosen by the company's own answer to "does
* anyone hold power of attorney for you?" — see `poaDeclared`.
*/
export type IdentitySubject = "owner" | "poa" | "gm";
export type IdentitySubject = "owner" | "poa";
/** One person's Fayda verification state, as the API reports it. */
/** Whether the company named a representative. Null until it answers. */
export type PoaDeclaration = "yes" | "no";
/** One person's identity state, as the API reports it. */
export interface IdentityVerificationState {
verified: boolean;
name: string | null;
@@ -18,43 +25,42 @@ export interface IdentityVerificationState {
email: string | null;
address: string | null;
verifiedAt: string | null;
}
export interface OwnerIdentityState extends IdentityVerificationState {
/**
* Typed passport number — the foreign-company identity credential.
* Independent of Fayda: never written by a verification, and still required
* even if the owner also verifies.
* Typed passport number — the ALTERNATIVE to Fayda for a foreign company,
* never written by a verification. Only asked of whichever person carries
* the company's identity, and only when `passportAccepted`.
*/
passportNumber: string | null;
}
export interface CompanyIdentityState {
/**
* True when Fayda verification is mandatory — Ethiopian companies only.
* Doubles as "may this person be typed instead": Fayda is an Ethiopian
* national ID, so a foreign company's GM and PoA are offered the
* verification but fall back to typed details when they hold none.
* True for a foreign company: a typed passport number proves the identity
* just as a Fayda verification does. Fayda is an Ethiopian national ID, so an
* Ethiopian company has no alternative to it.
*/
faydaRequired: boolean;
/** True when the owner's passport number is mandatory — foreign companies only. */
passportRequired: boolean;
owner: OwnerIdentityState;
passportAccepted: boolean;
/**
* The company's answer to the power-of-attorney question. Null until it
* answers — which is itself outstanding, since the answer decides who
* verifies. Always "yes" for a freight forwarder, which cannot operate
* without a representative and is never asked.
*/
poaDeclared: PoaDeclaration | null;
/** Whose verification the company is gated on. Null while undeclared. */
subject: IdentitySubject | null;
owner: IdentityVerificationState;
poa: IdentityVerificationState;
/** True once `subject` is proven — Fayda-verified, or passport where accepted. */
identityProven: boolean;
/** The manager named on the eTrade licence, captured at lookup. */
etradeManagerName: string | null;
/**
* True when the representative is the owner themselves, declared through
* "same as owner". Waives the DARS delegation paper — nobody delegates to
* themselves — and, where the owner is Fayda-verified, backs `poa.verified`
* with the owner's sub.
* Does the owner the company put forward match the eTrade licence? This is
* the backoffice's check. Null when there is nothing to compare. Advisory:
* eTrade's and Fayda's transliterations rarely agree exactly.
*/
poaSameAsOwner: boolean;
/**
* General manager. `verified` covers both routes: the GM verifying in their
* own right, and the company declaring the GM is the owner (in which case
* `gmSameAsOwner` is set and the owner's Fayda sub backs it).
*/
gm: IdentityVerificationState;
gmSameAsOwner: boolean;
ownerMatchesEtrade: boolean | null;
complete: boolean;
}
@@ -127,65 +133,19 @@ export const verifaydaService = {
},
/**
* Declare the General Manager is the company's owner, reusing the owner's
* verified identity rather than making the same human verify twice. The copy
* happens server-side from the stored owner identity — the portal never
* supplies the values — and is refused until the owner is verified.
*/
setGmSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.post<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/gm/same-as-owner",
);
return unwrap(response.data);
},
/**
* Clear the GM's identity — the "same as owner" declaration or a verification
* of their own — leaving them open to be re-established either way.
*/
clearGmIdentity: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/gm",
);
return unwrap(response.data);
},
/**
* Declare the Power of Attorney is the company's owner. A Fayda-verified
* owner's identity is copied server-side (the portal never supplies it); a
* foreign company's owner has nothing proven to copy, so the API records the
* declaration and the form types the representative's details as usual.
* Answer whether anyone holds power of attorney for this company — the
* question that decides whose identity is verified.
*
* Either way the declaration is what waives the DARS delegation paper.
* Answering "no" tears the representative down server-side: their details,
* their verification, their passport number and the DARS delegation paper.
* Refused for a freight forwarder, which cannot operate without one.
*/
setPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.post<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/**
* Undo that declaration and the identity it copied, leaving the
* representative open to be verified in their own right. Unlike
* {@link removePoa} this is allowed for a freight forwarder — it is how they
* change who represents them — and leaves the delegation paper on file.
*/
clearPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/**
* Drop the Power of Attorney — verified identity, details and delegation
* paper together. A verified person's fields are locked, so blanking the form
* is no longer a way to remove them. Refused for a freight forwarder.
*/
removePoa: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/fayda/poa",
setPoaDeclared: async (
declared: PoaDeclaration,
): Promise<CompanyIdentityState> => {
const response = await client.patch<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa-declared",
{ declared },
);
return unwrap(response.data);
},

View File

@@ -30,19 +30,15 @@ export interface ProfileResponse {
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (resumes the verify step's state). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
/** The owner — whoever the eTrade licence names as the business's manager. */
ownerName: string | null;
ownerEmail: string | null;
ownerPhone: string | null;
/**
* Fayda verification state for the owner, the PoA and the general manager.
* `identity.faydaRequired` / `identity.passportRequired` is the
* Ethiopian/foreign switch: an Ethiopian company verifies all three with
* Fayda, while a foreign one proves its owner with a typed passport number
* and may type its GM and PoA, whose holders may have no Fayda ID.
*
* The `generalManager*` fields above are the same person's details written
* flat — a verification keeps them in step, since the booking, contract and
* train-scheduling notifiers mail `generalManagerEmail` directly.
* The company's single identity verification. `identity.subject` says whose
* it is (the PoA when one is declared, otherwise the owner);
* `identity.passportAccepted` is the Ethiopian/foreign switch — a foreign
* company may prove the same person with a typed passport number instead.
*/
identity: CompanyIdentityState;
poaName: string | null;
@@ -88,14 +84,18 @@ export interface UpdateProfilePayload {
contactPersonEmail?: string;
contactPersonPhone?: string;
contactVerifiedPhone?: string;
generalManagerName?: string;
generalManagerEmail?: string;
generalManagerPhone?: string;
ownerName?: string;
ownerEmail?: string;
ownerPhone?: string;
poaName?: string;
poaPhone?: string;
poaEmail?: string;
poaLocation?: string;
poaAddress?: string;
/** The owner's passport number — the foreign-company identity credential. */
/**
* Passport numbers — the ALTERNATIVE to Fayda for a foreign company. Only the
* one belonging to the declared identity subject is ever collected.
*/
ownerPassportNumber?: string;
poaPassportNumber?: string;
}