feat(freight-api): persist eTrade manager phone in identity

This commit is contained in:
ghost2023
2026-08-11 17:41:06 +03:00
parent 637a766118
commit e52d666647
12 changed files with 659 additions and 216 deletions

View File

@@ -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({
</Stack>
)}
{notFound && (
<Alert
icon={<AlertCircle size={16} />}
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.
</Alert>
)}
{notFound &&
(registrationOptional ? (
<Alert
icon={<Info size={16} />}
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.
</Alert>
) : (
<Alert
icon={<AlertCircle size={16} />}
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.
</Alert>
))}
{errorMessage && (
<Alert

View File

@@ -9,7 +9,6 @@ import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyRegistrationData } from "@edr/types";
import { toEthiopianE164 } from "@/components/PhoneField";
import { getMinFiles } from "@/types/fileUploadSettings";
import { api } from "@/services/api";
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
@@ -24,7 +23,10 @@ import {
import {
buildPayload,
firstPresent,
firstValidEmail,
firstValidPhone,
normalizeIdentityPhones,
resolveOwnerSources,
stepPayload,
toFormValues,
} from "./companyProfileForm/helpers";
@@ -136,7 +138,24 @@ export default function CompanyProfileForm({
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(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<ETradeStatus>("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<string, File | File[] | null>,
) => {
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={<AlertCircle size={18} />}
title={"Couldn't save this step"}
title={
saveError.kind === "save"
? "Couldn't save this step"
: "Please check this step"
}
>
{saveError}
{saveError.message}
</Alert>
)}
@@ -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={

View File

@@ -10,15 +10,15 @@ const SOURCE_NOTE: Record<FieldSource, string> = {
};
/**
* 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 (
<Stack gap={2}>

View File

@@ -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<OwnerField, FieldSource | null>;
/** What to display for an owned field — normalized as the payload will be. */
sourced: Record<OwnerField, string>;
}
/**
* 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<OwnerField, FieldSource | null> = {
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 ?? "",
};

View File

@@ -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 = {

View File

@@ -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<CompanyStep, (keyof FormData)[]> = {
// 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: [],

View File

@@ -44,6 +44,8 @@ export default function CompanyInfoStep({
formState: { errors },
} = form;
const region = watch("region") ?? "";
return (
<Stack gap="xl">
<StepSection
@@ -59,100 +61,120 @@ export default function CompanyInfoStep({
/>
</StepSection>
{cooperative ? (
<>
<StepSection
index={2}
title="Company TIN"
subtitle="A co-operative union or farm has no trade licence for us to look up, so we take the TIN as you give it."
status={watch("tinNumber")?.trim() && !errors.tinNumber ? "done" : "todo"}
>
<TextInput
aria-label="Company TIN"
placeholder="0012345678"
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
</StepSection>
<StepSection
index={3}
title="Registration details"
subtitle="Everything we'd normally read off an eTrade licence. We need it from you instead."
status={
watch("companyName")?.trim() && watch("region")?.trim()
{/* The TIN lookup runs for everyone, co-operative included. A co-op has
no trade licence, but plenty of them are on eTrade all the same — and
when the record is there it is better data than anything typed, so we
ask for it first and fall back to the form below rather than deciding
in advance that nothing will be found. What differs for a co-op is
only the consequence of finding nothing: expected, not an error. */}
<StepSection
index={2}
title="Company TIN"
subtitle={
cooperative
? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below."
: "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
}
status={
tinStatus === "taken"
? "blocked"
: cooperative
? watch("tinNumber")?.trim() && !errors.tinNumber
? "done"
: "todo"
}
>
<Stack gap="md">
<TextInput
label="Company Name"
placeholder="Registered name of the union or farm"
error={errors.companyName?.message}
{...register("companyName")}
/>
<Text size="sm" c="edr-muted">
Registered address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Select
label="Region"
placeholder="Select region"
data={[...ETHIOPIAN_REGIONS]}
searchable
value={watch("region") || null}
onChange={(v) =>
setValue("region", v ?? "", { shouldValidate: true })
}
error={errors.region?.message}
/>
<TextInput
label="Zone"
error={errors.zone?.message}
{...register("zone")}
/>
<TextInput
label="Woreda"
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
error={errors.kebele?.message}
{...register("kebele")}
/>
<TextInput
label="House No."
error={errors.houseNo?.message}
{...register("houseNo")}
/>
</SimpleGrid>
</Stack>
</StepSection>
</>
) : (
: tinVerified
? "done"
: "todo"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={onETradeDataLoaded}
onStatusChange={onETradeStatusChange}
onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
registrationOptional={cooperative}
/>
{!cooperative && tinVerified && (
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
)}
</StepSection>
{/* 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 && (
<StepSection
index={2}
title="Company TIN"
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
index={3}
title="Registration details"
subtitle={
hasRegistrationDetails
? "From eTrade. Correct anything that doesn't look right — for a co-operative these are yours to state."
: "Everything we'd normally read off an eTrade licence. We need it from you instead."
}
status={
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
watch("companyName")?.trim() && watch("region")?.trim()
? "done"
: "todo"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={onETradeDataLoaded}
onStatusChange={onETradeStatusChange}
onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
/>
{tinVerified && (
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
)}
<Stack gap="md">
<TextInput
label="Company Name"
placeholder="Registered name of the union or farm"
error={errors.companyName?.message}
{...register("companyName")}
/>
<Text size="sm" c="edr-muted">
Registered address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Select
label="Region"
placeholder="Select region"
// eTrade's own spelling may not be one of ours. Carrying it in
// as an option keeps the lookup's answer visible instead of
// silently blanking the field it just filled.
data={
region &&
!(ETHIOPIAN_REGIONS as readonly string[]).includes(region)
? [...ETHIOPIAN_REGIONS, region]
: [...ETHIOPIAN_REGIONS]
}
searchable
value={region || null}
onChange={(v) =>
setValue("region", v ?? "", { shouldValidate: true })
}
error={errors.region?.message}
/>
<TextInput
label="Zone"
error={errors.zone?.message}
{...register("zone")}
/>
<TextInput
label="Woreda"
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
error={errors.kebele?.message}
{...register("kebele")}
/>
<TextInput
label="House No."
error={errors.houseNo?.message}
{...register("houseNo")}
/>
</SimpleGrid>
</Stack>
</StepSection>
)}
</Stack>

View File

@@ -46,9 +46,14 @@ export default function ContactStep({
/>
)}
<SimpleGrid cols={2} spacing="md">
{/* 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. */}
<TextInput
label="Name"
placeholder="Jane Smith"
disabled={contactSameAsOwner}
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
@@ -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
/>
</SimpleGrid>

View File

@@ -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<FormData>;
@@ -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<OwnerField, FieldSource | null>;
/** The value to display for a field its source owns. */
sourced: Record<OwnerField, string>;
/** 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 (
<Stack gap="md">
<Text size="sm" c="edr-muted">
{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."}
</Text>
{/* 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 && (
<Alert color="blue" variant="light" icon={<Info size={18} />}>
Your eTrade licence didn't list a manager, so there's nothing for us
@@ -92,9 +105,8 @@ export default function OwnerStep({
<SourcedField
label="Owner's Name"
value={identity?.owner.name}
source="Fayda"
locked={locked.name}
value={sourced.name}
source={source.name}
>
<TextInput
label="Owner's Name"
@@ -110,9 +122,8 @@ export default function OwnerStep({
type it is required either way (`REQUIRED_COMPANY_INFO`). */}
<SourcedField
label="Owner's Email"
value={identity?.owner.email}
source="Fayda"
locked={locked.email}
value={sourced.email}
source={source.email}
>
<TextInput
label="Owner's Email"
@@ -125,9 +136,8 @@ export default function OwnerStep({
<SourcedField
label="Owner's Phone"
value={identity?.owner.phone}
source="Fayda"
locked={locked.phone}
value={sourced.phone}
source={source.phone}
>
<ControlledPhoneField
control={control}

View File

@@ -45,8 +45,14 @@ export interface RepresentationStepProps {
* Which of the representative's details the Fayda verification owns. Same
* contract as OwnerStep's `locked`: a locked field is read-only, every other
* one is an input, and the schema requires exactly the unlocked ones.
*
* There is deliberately no `address` here. Fayda's address claim is stored as
* `poaAddress`, which the portal never sends; the input below writes
* `poaLocation`, a different field the company states itself. Gating one on
* the other hid the only input for `poaLocation` from every verified
* representative whose Fayda record happened to carry an address.
*/
locked: { name: boolean; email: boolean; phone: boolean; address: boolean };
locked: { name: boolean; email: boolean; phone: boolean };
/**
* How a foreign company is proving the subject. Null until it picks — the
* either/or is a fork, not a fallback, so nothing below it renders until one
@@ -291,14 +297,12 @@ export default function RepresentationStep({
</SimpleGrid>
)}
{!locked.address && (
<TextInput
label="Representative's Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
<TextInput
label="Representative's Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
{poaDocumentSetting && (
<>

View File

@@ -159,8 +159,7 @@ export default function TabOwner({
<SourcedField
label="Name"
value={owner?.name}
source="Fayda"
locked={ownerLocked.name}
source={ownerLocked.name ? "Fayda" : null}
>
<TextInput
label="Name"
@@ -175,8 +174,7 @@ export default function TabOwner({
<SourcedField
label="Email"
value={owner?.email}
source="Fayda"
locked={ownerLocked.email}
source={ownerLocked.email ? "Fayda" : null}
>
<TextInput
label="Email"
@@ -191,8 +189,7 @@ export default function TabOwner({
<SourcedField
label="Phone"
value={owner?.phone}
source="Fayda"
locked={ownerLocked.phone}
source={ownerLocked.phone ? "Fayda" : null}
>
<ControlledPhoneField
control={control}

View File

@@ -55,6 +55,13 @@ export interface CompanyIdentityState {
identityProven: boolean;
/** The manager named on the eTrade licence, captured at lookup. */
etradeManagerName: string | null;
/**
* That manager's phone (E.164), from the same lookup. Together with the name
* this is what survives a refresh: the live lookup result does not, so
* without these two a resumed wizard cannot tell an eTrade-sourced owner from
* a typed one, and offers the licence's own data back as editable inputs.
*/
etradeManagerPhone: string | null;
/**
* Does the owner the company put forward match the eTrade licence? This is
* the backoffice's check. Null when there is nothing to compare. Advisory: