mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(freight-api): persist eTrade manager phone in identity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user