diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 56273351d..acedc57f5 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import { useEffect, useRef, useState } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, Building2, Download } from "lucide-react"; +import { AlertCircle, Building2, Download, Info } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; @@ -54,6 +54,14 @@ interface ETradeInfoProps { * than silently snapping to eTrade's first one. */ selectedLicenceNumber?: string; + /** + * A record is worth having but not required — a co-operative union or farm + * registers on a TIN alone, so eTrade may legitimately hold nothing for it. + * The lookup still runs (plenty of co-operatives DO have a record, and it + * beats typing), but "not found" stops being a red dead end and becomes the + * expected outcome, with the form below to fill in by hand. + */ + registrationOptional?: boolean; } // Digits, not just length: a 10-character non-numeric TIN used to fire a lookup @@ -70,6 +78,7 @@ export default function ETradeInfo({ onReset, alreadyVerified, selectedLicenceNumber, + registrationOptional = false, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; @@ -327,16 +336,26 @@ export default function ETradeInfo({ )} - {notFound && ( - } - color="red" - title="No matching business record" - > - This TIN isn't registered with eTrade. Check the number — we can't - continue without a matching business record. - - )} + {notFound && + (registrationOptional ? ( + } + color="blue" + title="Nothing on file at eTrade for this TIN" + > + That's expected without a trade licence. Fill in your registration + details below and we'll take them as you give them. + + ) : ( + } + color="red" + title="No matching business record" + > + This TIN isn't registered with eTrade. Check the number — we can't + continue without a matching business record. + + ))} {errorMessage && ( (initialStep ?? "company"); const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); + /** + * What is holding this step back. + * + * `kind` only picks the title. Most of these never reach the API at all — + * they are the step's own gates (a TIN eTrade hasn't confirmed, an + * unanswered declaration, a missing paper) — and heading every one of them + * "Couldn't save this step" told the customer a request failed when none was + * made, which reads as a bug on our side rather than a field to go fix. + */ + const [saveError, setSaveError] = useState<{ + kind: "check" | "save"; + message: string; + } | null>(null); + /** A step gate or a validation failure: something on screen to correct. */ + const failCheck = (message: string) => + setSaveError({ kind: "check", message }); + /** The API refused the save; the message is the server's, shown verbatim. */ + const failSave = (message: string) => setSaveError({ kind: "save", message }); // Live eTrade lookup status, reported up by ETradeInfo — drives the Continue // gate on the company step. const [tinStatus, setTinStatus] = useState("idle"); @@ -264,7 +283,6 @@ export default function CompanyProfileForm({ ownerPhone: "", poaName: "", poaPhone: "", - poaAddress: "", poaEmail: "", poaLocation: "", }, @@ -282,14 +300,23 @@ export default function CompanyProfileForm({ formState: { dirtyFields }, } = form; - // The contact person's email still just seeds from the account and stays editable. + // Retire the alert the moment the customer starts acting on it. It was only + // ever cleared on navigation, so a red "fix these fields" banner sat above a + // form they had already fixed, right until they pressed Continue again — + // which reads as an error the page is refusing to let go of. useEffect(() => { - if (!user?.email) return; - if (!watch("contactPersonEmail")) { - setValue("contactPersonEmail", user.email); - } + const sub = watch(() => setSaveError(null)); + return () => sub.unsubscribe(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user?.email, rehydrate]); + }, []); + + // Nothing seeds the contact person's email. It used to be prefilled from the + // signed-in account, on the same assumption the owner fields were cleared of: + // the person doing the onboarding is routinely not the person the company + // wants contacted. Prefilled and editable is still prefilled — it is accepted + // as-is far more often than it is corrected, so the account address ends up on + // file as the company's contact by default rather than by anyone's decision. + // The field is optional, so an empty one costs nothing. // Keep the (hidden, derived) company address in sync with the editable address // fields — so it reflects both the eTrade auto-fill and any later user edits, @@ -303,23 +330,63 @@ export default function CompanyProfileForm({ const composed = [houseNo, kebele, woreda, zone, region] .filter((part) => part && part.trim()) .join(", "); + // No parts means nothing to compose from — the lookup hasn't landed yet, or + // this is the render before rehydration. Writing "" here would replace a + // saved address with a blank on the next company-step save. + if (!composed) return; setValue("companyAddress", composed); // eslint-disable-next-line react-hooks/exhaustive-deps }, [region, zone, woreda, kebele, houseNo]); - // 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<{ + // The manager eTrade lists for this licence, as returned by a lookup made in + // THIS session. 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 [liveEtradeOwner, setLiveEtradeOwner] = useState<{ name: string; phone: string; } | null>(null); + // The TIN moved off the last verified lookup, so the persisted manager below + // describes a licence this company is no longer claiming. Without this, a + // reset would re-lock the owner fields against stale data the moment the + // memo below fell back to the server's copy. + const [etradeCleared, setEtradeCleared] = useState(false); + + /** + * The eTrade manager, live lookup or not. + * + * The lookup result dies with the page, but the fact that the owner came from + * eTrade must not — a resumed wizard that has forgotten it renders the + * licence's own name and phone as empty, typeable inputs, which is both a + * regression of the read-only rule and an invitation to overwrite the record + * the backoffice checks against. The API captured the manager at lookup time + * for exactly this, so a resume reads it back from the identity state. + */ + const etradeOwner = useMemo(() => { + if (liveEtradeOwner) return liveEtradeOwner; + if (etradeCleared) return null; + const name = identity?.etradeManagerName?.trim() ?? ""; + // Normalized on the way out of storage, and dropped if it cannot be — the + // stored value is eTrade's free text, and a resume must reach the same + // conclusion about it as the lookup did. + const phone = firstValidPhone(identity?.etradeManagerPhone); + return name || phone ? { name, phone } : null; + }, [ + liveEtradeOwner, + etradeCleared, + identity?.etradeManagerName, + identity?.etradeManagerPhone, + ]); + + /** A lookup has actually filled the registration fields this session. */ + const etradeFilledRef = useRef(false); // `shouldDirty` is what marks the eTrade bundle as "re-verified this session"; // `stepPayload` sends those keys only when dirty, so an unchanged record is // never echoed back to the API (which would make it re-query eTrade). const handleETradeDataLoaded = (data: CompanyRegistrationData) => { const dirty = { shouldDirty: true } as const; + etradeFilledRef.current = true; if (data.companyName) { setValue("companyName", data.companyName, { shouldValidate: true, @@ -348,23 +415,32 @@ export default function CompanyProfileForm({ dirty, ); + // The owner's phone is only eTrade's to own if eTrade gave a usable one. + // The licence desk's field is free text, so it may hold "09 " — which + // normalizes to something non-empty and invalid. Dropping it here leaves the + // owner step with an empty, required, editable phone input, which is the + // honest state: eTrade has nothing we can use. const owner = { name: data.managerName, - phone: toEthiopianE164( + phone: firstValidPhone( data.managerPhone || data.regularPhone || data.mobilePhone, ), }; - setEtradeOwner(owner); + setEtradeCleared(false); + setLiveEtradeOwner(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()) { + // What eTrade said, verbatim — this replaces whatever is in the field + // rather than only filling a gap. The owner step shows an eTrade-sourced + // name and phone read-only, so leaving an older value in place would send + // the API something the customer is no longer shown and cannot correct. + // + // A Fayda verification still outranks the licence: it owns those fields + // server-side, so overwriting them here would only produce a value the API + // discards on the way in. + if (owner.name && !identity?.owner.name?.trim()) { setValue("ownerName", owner.name, { shouldValidate: true, ...dirty }); } - if (owner.phone && !getValues("ownerPhone")?.trim()) { + if (owner.phone && !identity?.owner.phone?.trim()) { setValue("ownerPhone", owner.phone, { shouldValidate: true, ...dirty }); } }; @@ -377,6 +453,17 @@ export default function CompanyProfileForm({ // 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 = () => { + // ...unless there was no prefill to clear. A co-operative now runs the same + // lookup as everyone else, but it types these fields itself when eTrade + // holds nothing — and wiping them because the customer went back to fix a + // digit of their TIN would throw away an address they had just typed by + // hand, over a lookup that never filled anything in the first place. + if (cooperative && !etradeFilledRef.current) { + setLiveEtradeOwner(null); + setEtradeCleared(true); + return; + } + etradeFilledRef.current = false; setValue("licenceNumber", ""); setValue("statusDescription", ""); setValue("dateRegistered", ""); @@ -394,7 +481,8 @@ export default function CompanyProfileForm({ if (getValues("ownerPhone") === etradeOwner?.phone) setValue("ownerPhone", ""); } - setEtradeOwner(null); + setLiveEtradeOwner(null); + setEtradeCleared(true); }; /** @@ -415,10 +503,27 @@ export default function CompanyProfileForm({ // 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 }); + // The representative's details go the same way. Answering "no" nulls + // every PoA attribute server-side, but the refetch below cannot undo + // what is still in the form: `keepDirtyValues` protects exactly the + // values the customer typed, so the next step save would send them + // straight back — and the API accepts them without complaint, because + // its PoA rules only run when the declaration is "yes". The company + // ended up on file with no representative and a full set of their + // details. + for (const field of [ + "poaName", + "poaEmail", + "poaPhone", + "poaLocation", + "poaPassportNumber", + ] as const) { + setValue(field, "", { shouldDirty: false, shouldValidate: false }); + } } onIdentityChange?.(); } catch (err) { - setSaveError( + failSave( (err as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? (err instanceof Error @@ -431,32 +536,48 @@ export default function CompanyProfileForm({ }; /** - * Which fields a verification owns, 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. Everything it did NOT fill stays - * the customer's: an editable input, prefilled from eTrade or from what was - * saved earlier, and required precisely because there is an input for it. - * - * Keyed off `verified`, deliberately, not off "does a value exist". A value - * exists the moment eTrade prefills the owner or the customer types one and - * the step saves — so a presence test turned the input they had just filled - * into a read-only badge on the way back through the wizard, and dropped the - * field out of `requiredKeys` at the same time. Only a verification locks. + * Who owns each of the owner's fields — and therefore which of them are shown + * read-only rather than as inputs. See `resolveOwnerSources`; the rule it + * encodes pairs with `requiredKeys` below, which requires exactly the fields + * no source owns. */ const ownerVerified = identity?.owner.verified ?? false; const poaVerified = identity?.poa.verified ?? false; - const ownerLocked = { - name: ownerVerified && Boolean(identity?.owner.name?.trim()), - email: ownerVerified && Boolean(identity?.owner.email?.trim()), - phone: ownerVerified && Boolean(identity?.owner.phone?.trim()), - }; + const usableEmail = (value?: string | null) => Boolean(firstValidEmail(value)); + const usablePhone = (value?: string | null) => Boolean(firstValidPhone(value)); + const { source: ownerSource, sourced: ownerSourced } = resolveOwnerSources( + identity, + etradeOwner, + ); + + // Keep the form holding exactly what the read-only rows show. The two are + // filled from different places — the rows from the licence, the fields from + // the lookup that ran or from the rehydrated profile — and only the fields + // are submitted, so a divergence would send the API something the customer + // was never shown and had no input to correct. eTrade wins it, which is what + // "not editable" has to mean for a value already on file. + useEffect(() => { + if (ownerSource.name === "eTrade" && getValues("ownerName") !== ownerSourced.name) { + setValue("ownerName", ownerSourced.name, { shouldValidate: true }); + } + if ( + ownerSource.phone === "eTrade" && + getValues("ownerPhone") !== ownerSourced.phone + ) { + setValue("ownerPhone", ownerSourced.phone, { shouldValidate: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ownerSource.name, ownerSource.phone, ownerSourced.name, ownerSourced.phone]); + // No `address`: Fayda's address claim is `poaAddress`, which the portal never + // sends — the step's only address-shaped input writes `poaLocation`, which is + // the company's own statement and stays typeable however well Fayda knows + // where the person lives. + // Same validity test as the owner's: a Fayda claim the schema would reject is + // not a claim this step can hide the input behind. const poaLocked = { name: poaVerified && Boolean(identity?.poa.name?.trim()), - email: poaVerified && Boolean(identity?.poa.email?.trim()), - phone: poaVerified && Boolean(identity?.poa.phone?.trim()), - address: poaVerified && Boolean(identity?.poa.address?.trim()), + email: poaVerified && usableEmail(identity?.poa.email), + phone: poaVerified && usablePhone(identity?.poa.phone), }; /** @@ -485,9 +606,15 @@ export default function CompanyProfileForm({ // The owner's name from whichever source established them — powers the // contact step's "same as owner" card. + // + // Email and phone are picked by validity, not mere presence. Both are copied + // into contact fields the schema requires to be well-formed, and a Fayda + // claim or an eTrade record routinely carries something that is neither empty + // nor usable ("09 "). Taking it would fail the contact step on a value + // the customer never typed and — while the copy is linked — cannot edit. 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 ownerEmail = firstValidEmail(identity?.owner.email, watch("ownerEmail")); + const ownerPhone = firstValidPhone(identity?.owner.phone, watch("ownerPhone")); const [contactSameAsOwner, setContactSameAsOwner] = useState(false); // While linked, mirror the source values into the (disabled) target fields so @@ -538,8 +665,6 @@ export default function CompanyProfileForm({ [uploadSetting, poaDocumentField], ); - const hasDocuments = Boolean(documentsSetting?.fields?.length); - // Hard verification for the documents step: required company-level // documents and a business license per operational profile must both be // present before the user can continue. @@ -585,6 +710,9 @@ export default function CompanyProfileForm({ next: Record, ) => { setDocumentFiles(next); + // Files live outside the form, so the watch subscription above never sees + // them — the "upload the required documents" alert has to be retired here. + setSaveError(null); setDocumentFieldErrors((prev) => { if (Object.keys(prev).length === 0) return prev; const updated = { ...prev }; @@ -674,9 +802,9 @@ export default function CompanyProfileForm({ } else if (step === "owner") { // All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input // is rendered for each one a Fayda verification does not own. - if (!ownerLocked.name) requiredKeys.push("ownerName"); - if (!ownerLocked.email) requiredKeys.push("ownerEmail"); - if (!ownerLocked.phone) requiredKeys.push("ownerPhone"); + if (!ownerSource.name) requiredKeys.push("ownerName"); + if (!ownerSource.email) requiredKeys.push("ownerEmail"); + if (!ownerSource.phone) requiredKeys.push("ownerPhone"); } else if ( step === "representation" && identity?.poaDeclared === "yes" && @@ -722,7 +850,7 @@ export default function CompanyProfileForm({ const fields = stepFields[step]; const isValid = await trigger(fields); if (!isValid) { - setSaveError(describeErrors(fields)); + failCheck(describeErrors(fields)); return false; } if (!onSaveStep) return true; @@ -730,7 +858,7 @@ export default function CompanyProfileForm({ try { const res = await onSaveStep(stepPayload(step, getValues(), dirtyFields)); if (!res.ok) { - setSaveError(res.error); + failSave(res.error); return false; } return true; @@ -753,7 +881,7 @@ export default function CompanyProfileForm({ ) { setDocumentFieldErrors(docErrors); setLicenseFieldErrors(licenseErrors); - setSaveError("Please upload all required documents before continuing."); + failCheck("Please upload all required documents before continuing."); return; } @@ -762,7 +890,7 @@ export default function CompanyProfileForm({ try { const res = await onUploadDocuments(); if (!res.ok) { - setSaveError(res.error); + failSave(res.error); return; } } finally { @@ -786,13 +914,13 @@ export default function CompanyProfileForm({ // A co-operative is exempt: it has no licence for eTrade to hold, so // `tinVerified` is true for it and only the duplicate-TIN check applies. if (step === "company" && tinStatus === "taken") { - setSaveError( + failCheck( "This TIN is already registered to another company account.", ); return; } if (step === "company" && !tinVerified) { - setSaveError( + failCheck( tinStatus === "choose-business" ? "This TIN holds more than one business licence — pick the one you're registering as." : "We need to confirm your TIN with eTrade before continuing.", @@ -802,7 +930,7 @@ export default function CompanyProfileForm({ // 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( + failCheck( "Tell us whether anyone holds power of attorney for this company.", ); return; @@ -814,7 +942,7 @@ export default function CompanyProfileForm({ identity?.subject === "poa" ? "your Power of Attorney" : "the company owner"; - setSaveError( + failCheck( identity?.passportAccepted ? `Verify ${who} with Fayda, or enter their passport number.` : `Verify ${who} with Fayda before continuing.`, @@ -828,7 +956,7 @@ export default function CompanyProfileForm({ }); // Validate the text fields too, so every problem shows at once. const fieldsOk = await trigger(stepFields.representation); - setSaveError( + failCheck( [ "Upload the DARS delegation paper for the representative you named.", fieldsOk ? null : describeErrors(stepFields.representation), @@ -852,7 +980,7 @@ export default function CompanyProfileForm({ try { const res = await onUploadDocuments(); if (!res.ok) { - setSaveError(res.error); + failSave(res.error); return; } } finally { @@ -895,7 +1023,8 @@ export default function CompanyProfileForm({ form={form} identity={identity} etradeOwner={etradeOwner} - locked={ownerLocked} + source={ownerSource} + sourced={ownerSourced} cooperative={cooperative} /> )} @@ -950,9 +1079,13 @@ export default function CompanyProfileForm({ color="red" variant="light" icon={} - title={"Couldn't save this step"} + title={ + saveError.kind === "save" + ? "Couldn't save this step" + : "Please check this step" + } > - {saveError} + {saveError.message} )} @@ -982,7 +1115,11 @@ export default function CompanyProfileForm({ isPending || saving || declarePending || - (step === "documents" && !hasDocuments && loadingDocuments) + // Nothing to validate against until the document set lands, so + // pressing Continue now would report every required upload as + // satisfied. `hasDocuments` used to be in here too, which let + // the button go live mid-load the moment the set resolved. + (step === "documents" && loadingDocuments) } loading={isPending || saving} rightSection={ 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 index 3b3601711..2a42c424a 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx @@ -10,15 +10,15 @@ const SOURCE_NOTE: Record = { }; /** - * One person-detail field that a verification may have taken ownership of. + * One person-detail field that an outside source may have taken ownership of. * - * `locked` — not "does a value exist" — decides which side renders. That + * `source` — not "does a value exist" — decides which side renders. That * distinction is the whole point: `ownerName` holds a value the moment the - * eTrade lookup prefills it or the customer types it and the step saves, and - * keying off presence meant the input a customer had just filled in turned into - * a read-only badge as soon as they navigated away and back, with no way to - * correct it. Only a Fayda verification actually owns a field — the API refuses - * to overwrite those — so only those lock. + * customer types it and the step saves, and keying off presence meant the input + * they had just filled in turned into a read-only badge as soon as they + * navigated away and back, with no way to correct it. Only a real source owns a + * field: a Fayda verification (the API refuses to overwrite those) or the + * eTrade licence (the record the backoffice checks the company against). * * It pairs with `requiredKeys` in CompanyProfileForm, which requires exactly * the fields that fall through to `children`: **a field is required if and only @@ -28,19 +28,17 @@ export default function SourcedField({ label, value, source, - locked, children, }: { label: string; - /** The value to display when locked. */ + /** The value to display when a source owns this field. */ value?: string | null; - source: FieldSource; - /** A verification owns this field: show it read-only instead of an input. */ - locked: boolean; + /** The owning source, or null while the field is still the customer's. */ + source: FieldSource | null; /** The input rendered whenever the field is still the customer's to fill. */ children: ReactNode; }) { - if (!locked || !value?.trim()) return <>{children}; + if (!source || !value?.trim()) return <>{children}; return ( 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 ca745a953..623f75e1a 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 @@ -4,6 +4,8 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyIdentityState } from "@/services/verifayda.service"; import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField"; +import type { FieldSource } from "./SourcedField"; + import { ETRADE_BUNDLE_FIELDS, type CompanyStep, @@ -81,6 +83,73 @@ export const samePhone = (a?: string | null, b?: string | null) => { return da.length === 9 && da === phoneDigits(b); }; +/** The owner details an outside source can take ownership of. */ +export type OwnerField = "name" | "email" | "phone"; + +export interface OwnerSources { + /** Which source owns each field, or null where it is still the customer's. */ + source: Record; + /** What to display for an owned field — normalized as the payload will be. */ + sourced: Record; +} + +/** + * Who owns each of the owner's fields, and with what value. + * + * Two sources can own a field, and they rank. A Fayda verification owns + * whatever its claims filled (the API refuses to overwrite those), and the + * eTrade licence owns the manager's name and phone: that record is the thing + * the backoffice checks the company against, so it is reported, not proposed. + * Neither is typeable. Everything left over is the customer's — an editable + * input, required precisely because there is an input for it. Fayda outranks + * eTrade on the same person: the stronger claim, and the one the API keeps. + * + * Two things deliberately do NOT take ownership. + * + * A value merely being present. It exists the moment the customer types one and + * the step saves — so a presence test turned the input they had just filled + * into a read-only badge on the way back through the wizard, and dropped the + * field out of `requiredKeys` at the same time. + * + * And a value that isn't usable. Both sources hold contact details as free + * text: Fayda's phone is whatever the national registry recorded, eTrade's is + * whatever was typed at the licence desk ("09 " is a real answer, and it + * normalizes to a non-empty, invalid `+2519`). Locking one of those behind a + * read-only row leaves the customer told to fix a field with no input, or the + * step saved with a value the API rejects. So a source owns an email or a phone + * only if what it supplies holds up as one; otherwise the field falls through + * to an input and is required like any other. Names have no format to fail, so + * presence is the whole test there. + */ +export function resolveOwnerSources( + identity: CompanyIdentityState | undefined, + etradeOwner: { name: string; phone: string } | null, +): OwnerSources { + const verified = identity?.owner.verified ?? false; + const faydaName = verified && Boolean(identity?.owner.name?.trim()); + const faydaEmail = verified ? firstValidEmail(identity?.owner.email) : ""; + const faydaPhone = verified ? firstValidPhone(identity?.owner.phone) : ""; + const etradeName = etradeOwner?.name?.trim() ?? ""; + const etradePhone = firstValidPhone(etradeOwner?.phone); + + const source: Record = { + name: faydaName ? "Fayda" : etradeName ? "eTrade" : null, + // eTrade never returns an email for the manager, so this one is Fayda's or + // it is the customer's to type. + email: faydaEmail ? "Fayda" : null, + phone: faydaPhone ? "Fayda" : etradePhone ? "eTrade" : null, + }; + + return { + source, + sourced: { + name: source.name === "Fayda" ? (identity?.owner.name?.trim() ?? "") : etradeName, + email: faydaEmail, + phone: source.phone === "Fayda" ? faydaPhone : etradePhone, + }, + }; +} + /** Mask all but the first 7 chars of an E.164 phone for display. */ export const maskPhone = (p: string) => p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; @@ -133,7 +202,13 @@ export function stepPayload( } if (dirty.tinNumber) etrade.tin = d.tinNumber; return { - companyAddress: d.companyAddress, + // Composed from the address parts, so it is only as complete as they + // are. Sending it while they are still empty (the lookup hasn't landed, + // or eTrade left them blank) would overwrite a stored address with a + // degraded version of itself — an absent key means "untouched". + ...(d.companyAddress?.trim() + ? { companyAddress: d.companyAddress } + : {}), vatNumber: d.vatNumber, ...etrade, }; @@ -164,6 +239,9 @@ export function stepPayload( poaPhone: d.poaPhone || undefined, poaLocation: d.poaLocation || undefined, poaPassportNumber: d.poaPassportNumber || undefined, + // The step renders one passport input, for whichever person the + // declaration made the identity subject — so it has to save both. + ownerPassportNumber: d.ownerPassportNumber || undefined, }; default: return {}; @@ -202,7 +280,6 @@ export function toFormValues(p: ProfileResponse): FormData { ownerPhone: p.ownerPhone ?? "", poaName: p.poaName ?? "", poaPhone: p.poaPhone ?? "", - poaAddress: p.poaAddress ?? "", poaEmail: p.poaEmail ?? "", poaLocation: p.poaLocation ?? "", }; 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 4b5b7cdb7..cd957eec7 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 @@ -6,6 +6,7 @@ import { firstValidEmail, firstValidPhone, normalizeIdentityPhones, + resolveOwnerSources, stepPayload, } from "./helpers"; import type { FormData } from "./schema"; @@ -144,6 +145,49 @@ describe("stepFields", () => { }); }); +describe("the identity subject's passport", () => { + // The representation step renders ONE passport input, for whoever the + // power-of-attorney answer made the identity subject. When the answer is "no + // PoA" that is the owner — so the owner's passport number is typed on the + // representation step and has to be carried by it. It used to belong to the + // owner step alone, which is already behind the customer by then: the number + // was typed, dropped, and the final submit failed `assertIdentityVerified` + // naming a field they could see was filled in. + it("is carried by the step that renders the input", () => { + expect(stepFields.representation).toContain("ownerPassportNumber"); + expect(stepFields.representation).toContain("poaPassportNumber"); + }); + + it("saves the owner's passport from the representation step", () => { + const payload = stepPayload( + "representation", + values({ ownerPassportNumber: "P1234567" }), + ); + expect(payload.ownerPassportNumber).toBe("P1234567"); + }); + + it("still omits it when there is none, rather than sending an empty string", () => { + const payload = stepPayload( + "representation", + values({ ownerPassportNumber: "" }), + ); + expect(payload.ownerPassportNumber).toBeUndefined(); + }); +}); + +describe("stepPayload (representation)", () => { + // `poaAddress` is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS` server-side). + // The portal states `poaLocation` instead and must never send the other. + it("sends the company's stated location, never the Fayda address", () => { + const payload = stepPayload( + "representation", + values({ poaLocation: "Dire Dawa, Ethiopia" }), + ); + expect(payload.poaLocation).toBe("Dire Dawa, Ethiopia"); + expect("poaAddress" in payload).toBe(false); + }); +}); + describe("buildOnboardingSchema (conditionally required fields)", () => { const issuesFor = ( data: FormData, @@ -210,6 +254,14 @@ describe("stepPayload (company)", () => { // Still only the dirty ones. expect(payload.licenceNumber).toBeUndefined(); }); + + // The address is composed from the parts, so it is only ever as complete as + // they are. An absent key means "untouched" to the API; sending a blank one + // would replace a stored address with nothing. + it("omits a blank composed address rather than clearing the stored one", () => { + const payload = stepPayload("company", values({ companyAddress: "" }), {}); + expect("companyAddress" in payload).toBe(false); + }); }); describe("stepPayload (owner)", () => { @@ -278,6 +330,109 @@ describe("firstValidEmail", () => { }); }); +describe("resolveOwnerSources", () => { + const identity = (over: { + verified?: boolean; + name?: string | null; + email?: string | null; + phone?: string | null; + }): CompanyIdentityState => + ({ + passportAccepted: false, + poaDeclared: "no", + subject: "owner", + owner: { + verified: over.verified ?? true, + name: over.name ?? null, + email: over.email ?? null, + phone: over.phone ?? null, + address: null, + verifiedAt: null, + passportNumber: null, + }, + poa: { + verified: false, + name: null, + email: null, + phone: null, + address: null, + verifiedAt: null, + passportNumber: null, + }, + identityProven: false, + etradeManagerName: null, + etradeManagerPhone: null, + ownerMatchesEtrade: null, + complete: false, + }) as CompanyIdentityState; + + it("locks what each source supplied, Fayda outranking eTrade", () => { + const { source, sourced } = resolveOwnerSources( + identity({ + name: "Abebe Bikila", + email: "owner@example.com", + phone: "+251911223344", + }), + { name: "A. Bikila", phone: "+251911999888" }, + ); + expect(source).toEqual({ name: "Fayda", email: "Fayda", phone: "Fayda" }); + expect(sourced.phone).toBe("+251911223344"); + }); + + // The point of the whole exercise: a field is read-only only if what the + // source gave can actually be submitted. Otherwise the customer is shown a + // badge holding a value the API will reject, with no input to fix it. + it("falls back to an input when Fayda's phone claim is unusable", () => { + const { source, sourced } = resolveOwnerSources( + identity({ name: "Abebe Bikila", phone: "09 " }), + null, + ); + expect(source.phone).toBeNull(); + expect(sourced.phone).toBe(""); + }); + + it("falls back to an input when Fayda's email claim is malformed", () => { + const { source } = resolveOwnerSources( + identity({ name: "Abebe Bikila", email: "not-an-email" }), + null, + ); + expect(source.email).toBeNull(); + }); + + it("falls back to an input when eTrade's manager phone is unusable", () => { + const { source, sourced } = resolveOwnerSources(undefined, { + name: "Abebe Bikila", + phone: "09 ", + }); + // The name is still eTrade's — names have no format to fail. + expect(source.name).toBe("eTrade"); + expect(source.phone).toBeNull(); + expect(sourced.phone).toBe(""); + }); + + it("takes eTrade's phone where Fayda has none, normalized", () => { + const { source, sourced } = resolveOwnerSources( + identity({ verified: false }), + { name: "Abebe Bikila", phone: "0911223344" }, + ); + expect(source.phone).toBe("eTrade"); + expect(sourced.phone).toBe("+251911223344"); + }); + + it("owns nothing when the verification never happened", () => { + const { source } = resolveOwnerSources( + identity({ + verified: false, + name: "Abebe Bikila", + email: "owner@example.com", + phone: "+251911223344", + }), + null, + ); + expect(source).toEqual({ name: null, email: null, phone: null }); + }); +}); + describe("normalizeIdentityPhones", () => { it("converts a local Fayda phone claim to E.164", () => { const identity = { 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 d642e48c7..b274761b0 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 @@ -90,7 +90,6 @@ export const onboardingSchema = z.object({ .string() .optional() .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), - poaAddress: z.string().optional(), poaEmail: z .string() .optional() @@ -98,6 +97,10 @@ export const onboardingSchema = z.object({ (v) => !v || z.string().email().safeParse(v).success, "Invalid email address", ), + // Where the representative is based, as the company states it. Deliberately + // NOT `poaAddress`: that one is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS`) + // the verification writes and the portal must never send — the two used to + // sit side by side here, with the Fayda address silently hiding this input. poaLocation: z.string().optional(), }); @@ -220,12 +223,19 @@ export const stepFields: Record = { // 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. + // `ownerPassportNumber` belongs here as much as the PoA's: the step renders + // whichever passport input the declaration calls for, and when the answer is + // "no PoA" that is the owner's. Leaving it out meant the number was typed on + // this step, validated by nothing, and dropped by `stepPayload` — so the + // final submit failed `assertIdentityVerified` over a field two steps back + // that the customer could see was filled in. representation: [ "poaName", "poaEmail", "poaPhone", "poaLocation", "poaPassportNumber", + "ownerPassportNumber", ], 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 5e4ad7f85..b89e0e920 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 @@ -44,6 +44,8 @@ export default function CompanyInfoStep({ formState: { errors }, } = form; + const region = watch("region") ?? ""; + return ( - {cooperative ? ( - <> - - - - - - - - - Registered address - - - + setValue("region", v ?? "", { shouldValidate: true }) + } + error={errors.region?.message} + /> + + + + + + )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/ContactStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/ContactStep.tsx index b03e879f1..6c28161b8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/ContactStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/ContactStep.tsx @@ -46,9 +46,14 @@ export default function ContactStep({ /> )} + {/* Disabled while linked, not merely prefilled: the mirror effect + rewrites these from the owner whenever the owner changes, so an edit + made here would be silently thrown away the next time it fires. + Position is the customer's either way — the owner has no equivalent. */} @@ -64,6 +69,7 @@ export default function ContactStep({ label="Email (Optional)" type="email" placeholder="contact@company.com" + disabled={contactSameAsOwner} error={errors.contactPersonEmail?.message} {...register("contactPersonEmail")} /> @@ -71,6 +77,7 @@ export default function ContactStep({ control={control} name="contactPersonPhone" label="Phone" + disabled={contactSameAsOwner} required /> 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 index 7861f4818..4035b8aaf 100644 --- 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 @@ -6,7 +6,10 @@ import { ControlledPhoneField } from "@/components/PhoneField"; import type { CompanyIdentityState } from "@/services/verifayda.service"; import type { FormData } from "../schema"; -import SourcedField from "../SourcedField"; +import SourcedField, { type FieldSource } from "../SourcedField"; + +/** The owner details this step is responsible for. */ +type OwnerField = "name" | "email" | "phone"; export interface OwnerStepProps { form: UseFormReturn; @@ -14,13 +17,19 @@ export interface OwnerStepProps { /** eTrade's registered manager, once a TIN lookup has succeeded. */ etradeOwner: { name: string; phone: string } | null; /** - * Which of the owner's details a Fayda verification owns. A locked field is - * shown read-only; every other one is an editable input, prefilled from - * eTrade or from what was saved earlier. CompanyProfileForm computes this and - * requires exactly the unlocked fields, so every input on screen is one the - * customer is actually asked to fill and nothing is required that has none. + * Which source owns each of the owner's details, or null where none does. + * + * A sourced field is shown read-only with its provenance; every other one is + * an editable input. Neither the eTrade licence nor a Fayda verification is + * the customer's to retype — the first is the record the backoffice checks + * this company against, the second is the government's. CompanyProfileForm + * computes this and requires exactly the unsourced fields, so every input on + * screen is one the customer is actually asked to fill and nothing is + * required that has none. */ - locked: { name: boolean; email: boolean; phone: boolean }; + source: Record; + /** The value to display for a field its source owns. */ + sourced: Record; /** A co-operative union or farm: no licence, so no eTrade record to match. */ cooperative?: boolean; } @@ -44,7 +53,8 @@ export default function OwnerStep({ form, identity, etradeOwner, - locked, + source, + sourced, cooperative = false, }: OwnerStepProps) { const { @@ -64,11 +74,14 @@ export default function OwnerStep({ return ( - {cooperative - ? "The person who runs the co-operative union or farm. We have no licence record to fill these in from, so we need all of them from you." - : "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."} + {cooperative && !etradeOwner + ? "The person who runs the co-operative union or farm. eTrade held no record for your TIN, so we need all of these from you." + : "These are the details of the person registered on your eTrade licence. What eTrade and Fayda gave us is shown as they gave it; anything they left blank we need from you."} + {/* A co-operative is not told its licence listed no manager — it has no + licence. Its own "nothing came back" case is covered by the line + above. */} {!cooperative && !etradeOwner && !ownerVerified && ( }> Your eTrade licence didn't list a manager, so there's nothing for us @@ -92,9 +105,8 @@ export default function OwnerStep({ )} - {!locked.address && ( - - )} + {poaDocumentSetting && ( <> diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx index 9399c5efc..def3126f1 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx @@ -159,8 +159,7 @@ export default function TabOwner({