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 && ( - - - - )} - {/* 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 - - -
- - - - {linkError && ( - }> - {linkError} - - )} - - {/* Verifying a second person only means something when the manager - is someone other than the owner. */} - {!gmSameAsOwner && gm && ( - - )} - - {/* Whatever the verification did not supply is typed instead — the - API keeps exactly those keys writable. */} - {gmGaps.name && ( - - )} - {(gmGaps.email || gmGaps.phone) && ( - - {gmGaps.email && ( - - - - )} - {gmGaps.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 and refuses edits, so they go away. */} - {!gmSameAsOwner && !gm?.verified && !faydaRequired && ( - <> - - - - - - - - - - - - )} - - - - - {mutation.isSuccess && ( - - - Saved successfully - - )} - {mutation.isError && ( - - - Save failed - - )} - - - {mode === "edit" && typedFieldsInUse && ( - - )} - {/* Saving only means something while the details are typed: under - Fayda both routes write server-side, so a submit would post - empty strings at an identity the API owns and refuses to - overwrite. Onboarding still needs a way forward, so the button - becomes a plain Continue rather than disappearing. */} - {savable ? ( - - ) : ( - mode === "onboarding" && ( - - ) - )} - - -
-
- ); -} 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. + + +
mutation.mutate(data))}> + + {identity?.ownerMatchesEtrade === false && ( + } + title="This doesn't match your eTrade licence" + > + Your licence lists {identity.etradeManagerName}. + Our team checks this before approving changes. + + )} + + {ownerIsSubject && owner && ( + + )} + + + + + + + + + + + + + + + + + + + {savable && ( + + + + )} + +
+
+ ); +} 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 && (