diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx
index fa9c5919b..b9fdca622 100644
--- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx
+++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx
@@ -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: ,
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: ,
- 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: ,
+ title: "Who Acts For You",
+ description:
+ "Tell us whether anyone holds power of attorney — your answer decides whose identity we verify.",
},
contact: {
icon: ,
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
- poa: {
- icon: ,
- title: "Power of Attorney",
- description: "Optionally add a representative with power of attorney.",
- },
documents: {
icon: ,
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();
diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
index 8a2f1d12f..13b0d4025 100644
--- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
@@ -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: },
{ id: "company", label: "Company", icon: },
{ id: "contact", label: "Contact Person", icon: },
- { id: "gm", label: "General Manager", icon: },
+ { id: "owner", label: "Owner", icon: },
{ id: "poa", label: "Power of Attorney", icon: },
{ id: "documents", label: "Documents", icon: },
];
@@ -405,8 +402,8 @@ export default function SettingsPage() {
-
-
+
+
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
index 92e1a7118..93ab3cfc2 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
@@ -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({
resolver: (values, context, options) =>
- zodResolver(
- buildOnboardingSchema(
- identity?.passportRequired === true,
- requiredKeysRef.current,
- ),
- )(values, context, options),
+ zodResolver(buildOnboardingSchema(requiredKeysRef.current))(
+ 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,273 +346,91 @@ 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", "");
- }
+ 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) {
- 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.
- setDocumentFiles({ ...documentFiles, [POA_DELEGATION_FILE_KEY]: null });
- onIdentityChange?.();
} catch (err) {
setSaveError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.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(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 => {
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" && (
)}
- {step === "personnel" && (
-
+ )}
+
+ {step === "representation" && (
+
)}
{step === "contact" && (
- )}
-
- {step === "poa" && (
-
)}
@@ -1197,6 +883,7 @@ export default function CompanyProfileForm({
disabled={
isPending ||
saving ||
+ declarePending ||
(step === "documents" && !hasDocuments && loadingDocuments)
}
loading={isPending || saving}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx
new file mode 100644
index 000000000..480606db8
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx
@@ -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 = {
+ 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 (
+
+
+
+ {label}
+
+
+ {source}
+
+
+
+ {value}
+
+
+ {SOURCE_NOTE[source]}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts
index 20e889235..ca745a953 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts
+++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts
@@ -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 ?? "",
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts
index 95755e777..ebff24687 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts
+++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts
@@ -35,9 +35,9 @@ const values = (over: Partial = {}): 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");
});
});
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts
index 0017598e9..770e565b8 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts
@@ -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> = {
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 = {
- // 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: [],
};
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx
index 0473b26d1..f57d7c433 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx
@@ -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;
- /** 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({
0
- ? "done"
- : identity?.passportRequired
- ? "blocked"
- : "todo"
- }
- >
- {identity && (
- <>
-
- {identity.passportRequired && (
-
- )}
- >
- )}
-
-
- ;
/**
- * 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({
Contact Person
- {/* `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 && (
)}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx
new file mode 100644
index 000000000..7c7c05fec
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx
@@ -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;
+ 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 (
+
+
+ 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.
+
+
+ {!etradeOwner && !ownerVerified && (
+ }>
+ 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.
+
+ )}
+
+ {mismatch && (
+ }
+ title="This doesn't match your eTrade licence"
+ >
+ Your licence lists{" "}
+ {identity?.etradeManagerName}, but the name here is{" "}
+ {nameValue}. You can continue, but our team will
+ check this before approving your account — so make sure it's the
+ person the licence actually names.
+
+ )}
+
+
+
+
+
+
+ {/* 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`). */}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx
deleted file mode 100644
index d3324e0a7..000000000
--- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx
+++ /dev/null
@@ -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;
- 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 (
- <>
-
- General Manager
-
- {/* 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. */}
-
-
- {/* Verifying a second person is only meaningful when the GM is
- someone other than the owner. */}
- {!gmSameAsOwner && identity && (
-
- )}
-
- {/* 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 && (
-
- )}
- {(gaps.email || gaps.phone) && (
-
- {gaps.email && (
-
- )}
- {gaps.phone && (
-
- )}
-
- )}
-
- {/* 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 && (
- <>
-
-
-
-
-
- >
- )}
- >
- );
-}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx
deleted file mode 100644
index efece363b..000000000
--- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx
+++ /dev/null
@@ -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;
- 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;
- uploadedDocumentKeys?: string[];
- documentFieldErrors: Record;
- onDocumentFilesChange: (next: Record) => 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 (
- <>
-
- {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."}
-
-
- {/* 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 && (
-
- )}
-
- {/* 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 && (
-
- )}
- {/* 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 && (
-
- }
- onClick={onRemovePoa}
- >
- Remove this representative
-
-
- )}
- {/* Whatever the Fayda claim did carry is shown on the panel above and
- is never typed here — the verification owns it. */}
- {gaps.name && (
-
- )}
- {(needsEmail || needsPhone) && (
-
- {needsEmail && (
-
- )}
- {needsPhone && (
-
- )}
-
- )}
- {gaps.address && (
-
- )}
-
- {/* 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 && (
- <>
-
-
- >
- )}
- >
- );
-}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/RepresentationStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/RepresentationStep.tsx
new file mode 100644
index 000000000..52fd8c56d
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/RepresentationStep.tsx
@@ -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;
+ 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;
+ uploadedDocumentKeys?: string[];
+ documentFieldErrors: Record;
+ onDocumentFilesChange: (next: Record) => 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 (
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ Does anyone hold power of attorney for this company?
+
+
+ Your answer decides whose identity we verify — the representative's,
+ or the owner's.
+
+
+
+
+ }
+ selected={declared === "yes"}
+ onClick={
+ declarePending || declared === "yes"
+ ? undefined
+ : () => onDeclare("yes")
+ }
+ />
+ }
+ selected={declared === "no"}
+ onClick={
+ declarePending || declared === "no"
+ ? undefined
+ : () => onDeclare("no")
+ }
+ />
+
+
+ {locked && (
+ }>
+ 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.
+
+ )}
+
+ {declared === null && (
+
+ Pick one to continue.
+
+ )}
+
+ {/* ---------------------------------------------------------------- */}
+ {/* No representative → the owner is the one who verifies. */}
+ {/* ---------------------------------------------------------------- */}
+ {declared === "no" && (
+ <>
+
+
+ {/* 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 && (
+
+ )}
+ >
+ )}
+
+ {/* ---------------------------------------------------------------- */}
+ {/* A representative → they verify, and the delegation is evidenced. */}
+ {/* ---------------------------------------------------------------- */}
+ {declared === "yes" && (
+ <>
+
+
+ {passportAccepted && !identity.poa.verified && (
+
+ )}
+
+ {/* 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`). */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {gaps.address && (
+
+ )}
+
+ {poaDocumentSetting && (
+ <>
+
+
+ Upload the delegation paper authenticated by DARS. It is what
+ evidences that this person was actually delegated.
+
+
+ >
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
index b11fecefc..a359a3ff0 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
@@ -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;
- } | 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({
/>
- {identity && (
- 0
- ? "done"
- : identity.passportRequired
- ? "blocked"
- : "todo"
- }
- >
-
- {identity.passportRequired && (
-
- )}
-
- )}
!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;
-
-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({
- 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(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 (
-
-
-
- General Manager
-
-
- Manage the general manager information
-
-
-
-
- );
-}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx
new file mode 100644
index 000000000..2c03aaed3
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx
@@ -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;
+
+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({
+ 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 (
+
+
+
+ Company Owner
+
+
+ The person registered on your eTrade licence.
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx
index 40f118580..a3118ad28 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx
@@ -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(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({
{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."}
{/* 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 && (
-
+
+ }
+ selected={declared === "yes"}
+ onClick={
+ linkPending || declared === "yes"
+ ? undefined
+ : () => void declare("yes")
+ }
+ />
+ }
+ selected={declared === "no"}
+ onClick={
+ linkPending || declared === "no"
+ ? undefined
+ : () => void declare("no")
+ }
+ />
+
)}
{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" && (
)}
@@ -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 && (
@@ -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."}
)}
@@ -551,12 +549,12 @@ export default function TabPowerOfAttorney({
)}
- {/* 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 && (