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
-
-
-
-
-
- >
- ) : (
+ : tinVerified
+ ? "done"
+ : "todo"
+ }
+ >
+
+ {!cooperative && tinVerified && (
+
+ )}
+
+
+ {/* A co-operative keeps its typed registration section either way. When
+ the lookup found something these arrive prefilled — still editable,
+ because for a co-op they are the customer's own statement rather than
+ the licence's, and the API takes them as given (`applyEtradeSourcedFields`
+ skips co-operatives entirely). */}
+ {cooperative && (
-
- {tinVerified && (
-
- )}
+
+
+
+ Registered address
+
+
+
+
)}
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({