mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: onboarding validation
This commit is contained in:
@@ -2057,6 +2057,11 @@ export class CompaniesService {
|
||||
// replace it before the application counts as complete.
|
||||
const flaggedDelegation = delegationDue && delegation.flagged;
|
||||
|
||||
// Mirrors `poaProven` in buildCompanyIdentityState — see the note there.
|
||||
const poaProven = identity.faydaRequired
|
||||
? identity.poa.verified
|
||||
: identity.poa.verified || Boolean(identity.poa.name?.trim());
|
||||
|
||||
const outstanding = [
|
||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
||||
@@ -2074,8 +2079,19 @@ export class CompaniesService {
|
||||
...(identity.faydaRequired && !identity.owner.verified
|
||||
? ["Verify the company owner's identity with Fayda"]
|
||||
: []),
|
||||
...((poaRequired || poaProvided) && !identity.poa.verified
|
||||
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||
// Nationality-aware, exactly like `poaProven` in
|
||||
// buildCompanyIdentityState and the check in `assertIdentityVerified`:
|
||||
// Fayda is an Ethiopian national ID, so a foreign company's typed
|
||||
// representative has to count. Demanding a verification here regardless
|
||||
// made this list disagree with the rule actually enforced, and left a
|
||||
// foreign freight forwarder unable to submit — asked for a Fayda
|
||||
// verification its representative may have no way to obtain.
|
||||
...((poaRequired || poaProvided) && !poaProven
|
||||
? [
|
||||
identity.faydaRequired
|
||||
? "Verify your Power of Attorney's identity with Fayda"
|
||||
: "Name your Power of Attorney, or verify them with Fayda",
|
||||
]
|
||||
: []),
|
||||
...(identity.passportRequired && !identity.owner.passportNumber
|
||||
? ["Add the company owner's passport number"]
|
||||
@@ -2089,7 +2105,10 @@ export class CompaniesService {
|
||||
const poaItemCount = delegationDue ? 1 : 0;
|
||||
// One item per identity credential the company has to prove: the owner
|
||||
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
||||
// there is one — that one is Fayda whatever the nationality.
|
||||
// there is one — Fayda for an Ethiopian company, a named representative
|
||||
// for a foreign one, same rule as `poaProven` above. Counting a foreign
|
||||
// company's typed PoA as unproven here left the progress bar permanently
|
||||
// short of 100% on an item it had already satisfied.
|
||||
const ownerCredentialDue =
|
||||
identity.faydaRequired || identity.passportRequired;
|
||||
const ownerCredentialProven = identity.faydaRequired
|
||||
@@ -2099,7 +2118,7 @@ export class CompaniesService {
|
||||
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||
const missingIdentityCount =
|
||||
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||
(delegationDue && !identity.poa.verified ? 1 : 0);
|
||||
(delegationDue && !poaProven ? 1 : 0);
|
||||
const total =
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
@@ -2767,7 +2786,13 @@ export class CompaniesService {
|
||||
// The verified payload owns the person's details from here on.
|
||||
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
||||
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
|
||||
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
|
||||
// Fayda returns whatever the national registry holds, which is routinely a
|
||||
// local number ("0911223344"). Every typed phone in this service is stored
|
||||
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
|
||||
// becomes a value the portal reads back and cannot resubmit.
|
||||
...(result.phoneNumber
|
||||
? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) }
|
||||
: {}),
|
||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEmail,
|
||||
MaxLength,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
|
||||
import { CompanyNationality } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
@@ -39,9 +47,13 @@ export class UpdateProfileDto {
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
|
||||
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
|
||||
// TIN. Both portal forms enforce that; without it here the API happily stored
|
||||
// whatever a stale client sent, and the two layers disagreed about what the
|
||||
// column may hold.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
@Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' })
|
||||
vatNumber?: string;
|
||||
|
||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||
|
||||
@@ -647,6 +647,8 @@ const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
|
||||
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
||||
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
||||
// The ET hub's rows open the shipment clearance detail at this URL.
|
||||
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
|
||||
];
|
||||
|
||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||
|
||||
@@ -26,9 +26,22 @@ interface ETradeInfoProps {
|
||||
onStatusChange?: (status: ETradeStatus) => void;
|
||||
/** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */
|
||||
onReset?: () => void;
|
||||
/**
|
||||
* This TIN already passed eTrade in an earlier session (the saved profile
|
||||
* carries its registration details), so adopt it on arrival instead of
|
||||
* re-querying. Rehydration lands the TIN after the first render, which used
|
||||
* to look exactly like the customer typing a new one: every reopen fired a
|
||||
* live lookup that could fail on an outage, and re-marked eTrade's fields as
|
||||
* freshly verified so they were resubmitted on the next save. "Get Data"
|
||||
* stays available for a deliberate re-verify.
|
||||
*/
|
||||
alreadyVerified?: boolean;
|
||||
}
|
||||
|
||||
const isValidTin = (tin: string) => tin.length === 10;
|
||||
// Digits, not just length: a 10-character non-numeric TIN used to fire a lookup
|
||||
// that could only fail, and the failure was then reported as "this TIN isn't
|
||||
// registered with eTrade" instead of "that isn't a TIN".
|
||||
const isValidTin = (tin: string) => /^\d{10}$/.test(tin);
|
||||
|
||||
export default function ETradeInfo({
|
||||
tin,
|
||||
@@ -37,6 +50,7 @@ export default function ETradeInfo({
|
||||
onDataLoaded,
|
||||
onStatusChange,
|
||||
onReset,
|
||||
alreadyVerified,
|
||||
}: ETradeInfoProps) {
|
||||
const mutation = useETradeData();
|
||||
const isLoading = mutation.isPending;
|
||||
@@ -63,6 +77,13 @@ export default function ETradeInfo({
|
||||
// doesn't refire the lookup the moment this mounts.
|
||||
const lastFetchedTin = useRef<string | null>(tin || null);
|
||||
useEffect(() => {
|
||||
// A rehydrated TIN that eTrade already accepted: adopt it silently. Doing
|
||||
// this before the change-detection below also keeps `onReset` from firing,
|
||||
// which would wipe the very registration details that prove it passed.
|
||||
if (alreadyVerified && lastFetchedTin.current === null && isValidTin(tin)) {
|
||||
lastFetchedTin.current = tin;
|
||||
return;
|
||||
}
|
||||
if (tin !== lastFetchedTin.current) {
|
||||
// TIN moved away from whatever we last fetched — that result (verified
|
||||
// data, "taken", or an error) no longer describes this TIN. Drop it so
|
||||
@@ -82,12 +103,17 @@ export default function ETradeInfo({
|
||||
|
||||
const apiError =
|
||||
mutation.isError && mutation.error ? extractApiError(mutation.error) : null;
|
||||
// A 400 here means eTrade simply has no record for this TIN.
|
||||
const notFound = apiError?.statusCode === 400;
|
||||
// A 400 here usually means eTrade has no record for this TIN — but the API
|
||||
// also wraps its own transport failures as a 400 ("Failed to fetch company
|
||||
// info from eTrade: …"), and reporting an outage as "this TIN isn't
|
||||
// registered" sends the customer off to re-check a number that was fine.
|
||||
const unreachable = /failed to fetch/i.test(apiError?.message ?? "");
|
||||
const notFound = apiError?.statusCode === 400 && !unreachable;
|
||||
const errorMessage =
|
||||
apiError && !notFound
|
||||
? apiError.message ||
|
||||
"We couldn't reach eTrade to fetch your company information. Please try again."
|
||||
? unreachable || !apiError.message
|
||||
? "We couldn't reach eTrade to fetch your company information. Please try again in a moment."
|
||||
: apiError.message
|
||||
: null;
|
||||
|
||||
const status: ETradeStatus = isLoading
|
||||
|
||||
@@ -38,6 +38,10 @@ import {
|
||||
} from "./companyProfileForm/schema";
|
||||
import {
|
||||
buildPayload,
|
||||
firstPresent,
|
||||
firstValidEmail,
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
stepPayload,
|
||||
toFormValues,
|
||||
} from "./companyProfileForm/helpers";
|
||||
@@ -45,6 +49,7 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
||||
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
||||
import StepSection from "./companyProfileForm/StepSection";
|
||||
|
||||
@@ -67,7 +72,7 @@ export default function CompanyProfileForm({
|
||||
submitError,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments,
|
||||
identity,
|
||||
identity: rawIdentity,
|
||||
onIdentityChange,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
@@ -115,6 +120,15 @@ export default function CompanyProfileForm({
|
||||
*/
|
||||
onIdentityChange?: () => void;
|
||||
}) {
|
||||
// A Fayda claim carries the phone as the national registry holds it, which is
|
||||
// often a local number the form's E.164 validation (and the API's
|
||||
// `@IsValidPhone()`) would reject — for a value the customer never typed and
|
||||
// has no field to correct. Normalize once, here, so every read below is safe.
|
||||
const identity = useMemo(
|
||||
() => normalizeIdentityPhones(rawIdentity),
|
||||
[rawIdentity],
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
@@ -188,15 +202,20 @@ export default function CompanyProfileForm({
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
formState: { errors, dirtyFields },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(
|
||||
buildOnboardingSchema(identity?.passportRequired === true),
|
||||
),
|
||||
// `values` below re-seeds the form whenever the profile is refetched — and
|
||||
// an in-page identity action (ticking "same as owner") refetches it. Without
|
||||
// this, that reset silently throws away whatever the customer was part-way
|
||||
// through typing on the current step.
|
||||
resetOptions: { keepDirtyValues: true, keepErrors: true },
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
@@ -266,21 +285,28 @@ export default function CompanyProfileForm({
|
||||
phone: string;
|
||||
} | null>(null);
|
||||
|
||||
// `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;
|
||||
if (data.companyName) {
|
||||
setValue("companyName", data.companyName, { shouldValidate: true });
|
||||
setValue("companyName", data.companyName, {
|
||||
shouldValidate: true,
|
||||
...dirty,
|
||||
});
|
||||
}
|
||||
setValue("licenceNumber", data.licenceNumber);
|
||||
setValue("statusDescription", data.statusDescription);
|
||||
setValue("dateRegistered", data.dateRegistered);
|
||||
setValue("renewedFrom", data.renewedFrom);
|
||||
setValue("renewalDate", data.renewalDate);
|
||||
setValue("renewedTo", data.renewedTo);
|
||||
setValue("region", data.region);
|
||||
setValue("zone", data.zone);
|
||||
setValue("woreda", data.woreda);
|
||||
setValue("kebele", data.kebele);
|
||||
setValue("houseNo", data.houseNo);
|
||||
setValue("licenceNumber", data.licenceNumber, dirty);
|
||||
setValue("statusDescription", data.statusDescription, dirty);
|
||||
setValue("dateRegistered", data.dateRegistered, dirty);
|
||||
setValue("renewedFrom", data.renewedFrom, dirty);
|
||||
setValue("renewalDate", data.renewalDate, dirty);
|
||||
setValue("renewedTo", data.renewedTo, dirty);
|
||||
setValue("region", data.region, dirty);
|
||||
setValue("zone", data.zone, dirty);
|
||||
setValue("woreda", data.woreda, dirty);
|
||||
setValue("kebele", data.kebele, dirty);
|
||||
setValue("houseNo", data.houseNo, dirty);
|
||||
// companyAddress is composed reactively from the address fields below, so
|
||||
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
||||
// compose it here. companyPhone is derived below (identity → eTrade →
|
||||
@@ -292,6 +318,7 @@ export default function CompanyProfileForm({
|
||||
setValue(
|
||||
"etradePhone",
|
||||
data.managerPhone || data.regularPhone || data.mobilePhone,
|
||||
dirty,
|
||||
);
|
||||
|
||||
setEtradeOwner({
|
||||
@@ -321,28 +348,37 @@ export default function CompanyProfileForm({
|
||||
setEtradeOwner(null);
|
||||
};
|
||||
|
||||
// companyEmail/companyPhone are no longer typed — the Fayda-verified owner
|
||||
// companyEmail/companyPhone are derived, not typed — the Fayda-verified owner
|
||||
// is the highest-trust source (that's the whole point of verifying), eTrade's
|
||||
// registered number and the account email/phone are the fallbacks used
|
||||
// before verification happens.
|
||||
//
|
||||
// `firstValid*`, not `??`: these sources are optional AND unreliable. Fayda's
|
||||
// email/phone claims can come back empty, and eTrade's registered phone is
|
||||
// free text that arrives as things like "09 " (→ "+2519"). `??` stops
|
||||
// at the first non-null, so a junk value became a field with no input and a
|
||||
// 400 from the API on a value the customer never typed. Skip anything that
|
||||
// isn't usable and fall through.
|
||||
//
|
||||
// When every source really is unusable the fields become editable below
|
||||
// rather than blocking — the API requires a company email and phone at
|
||||
// submit (REQUIRED_COMPANY_INFO), so leaving no way to supply them is a dead
|
||||
// end.
|
||||
const derivedEmail = firstValidEmail(identity?.owner.email, user.email);
|
||||
const derivedPhone = firstValidPhone(
|
||||
identity?.owner.phone,
|
||||
etradeOwner?.phone,
|
||||
user.phoneNumber,
|
||||
);
|
||||
useEffect(() => {
|
||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
if (derivedEmail) setValue("companyEmail", derivedEmail);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.email, user.email, rehydrate]);
|
||||
}, [derivedEmail, rehydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
"companyPhone",
|
||||
identity?.owner.phone ??
|
||||
etradeOwner?.phone ??
|
||||
toEthiopianE164(user.phoneNumber) ??
|
||||
"",
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
if (derivedPhone) setValue("companyPhone", derivedPhone);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber, rehydrate]);
|
||||
}, [derivedPhone, rehydrate]);
|
||||
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
@@ -352,6 +388,16 @@ export default function CompanyProfileForm({
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||
identity?.gmSameAsOwner ?? false,
|
||||
);
|
||||
// `identity` is undefined on the first render (the requirements query is still
|
||||
// in flight), so the initial state above freezes at `false` — adopt the
|
||||
// server's declaration the moment it lands, or a resumed draft shows an
|
||||
// unticked box over a GM that is linked server-side.
|
||||
const identityLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!identity || identityLoaded.current) return;
|
||||
identityLoaded.current = true;
|
||||
setGmSameAsOwner(identity.gmSameAsOwner);
|
||||
}, [identity]);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
|
||||
// General Manager source. The company step's email/phone are seeded from
|
||||
@@ -365,16 +411,26 @@ export default function CompanyProfileForm({
|
||||
// A Fayda-verified owner outranks eTrade's registered owner — it's the
|
||||
// higher-trust source, and the whole point of proving identity is to stop
|
||||
// trusting typed/looked-up data for this.
|
||||
const gmSourceName =
|
||||
identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? "";
|
||||
const gmSourceEmail =
|
||||
identity?.owner.email ?? (companyEmail || user.email || "");
|
||||
const gmSourcePhone =
|
||||
identity?.owner.phone ??
|
||||
companyPhone ??
|
||||
etradeOwner?.phone ??
|
||||
toEthiopianE164(user.phoneNumber) ??
|
||||
"";
|
||||
const gmSourceName = firstPresent(
|
||||
identity?.owner.name,
|
||||
etradeOwner?.name,
|
||||
user.name?.en,
|
||||
);
|
||||
|
||||
const gmSourceEmail = firstValidEmail(
|
||||
identity?.owner.email,
|
||||
companyEmail,
|
||||
user.email,
|
||||
);
|
||||
// Same reason as `derivedPhone`: this value is written into
|
||||
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an
|
||||
// unusable eTrade number here 400s the personnel step instead.
|
||||
const gmSourcePhone = firstValidPhone(
|
||||
identity?.owner.phone,
|
||||
companyPhone,
|
||||
etradeOwner?.phone,
|
||||
user.phoneNumber,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmSameAsOwner) return;
|
||||
@@ -458,10 +514,24 @@ export default function CompanyProfileForm({
|
||||
const gmEstablished =
|
||||
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
||||
|
||||
/** Same rule for the representative: verified, or typed where Fayda is optional. */
|
||||
/**
|
||||
* Same rule for the representative: verified, or entered where Fayda is
|
||||
* optional.
|
||||
*
|
||||
* Matches the API's own rule (`REQUIRED_POA_FIELDS`): a typed representative
|
||||
* counts once they have a name, an email and a phone. The step now renders
|
||||
* inputs for all three, so this is something the customer can actually
|
||||
* satisfy — previously it gated on `poaName`, for which no input existed
|
||||
* anywhere, leaving a foreign freight forwarder permanently stuck.
|
||||
*/
|
||||
const poaTyped = Boolean(
|
||||
watch("poaName")?.trim() &&
|
||||
watch("poaEmail")?.trim() &&
|
||||
watch("poaPhone")?.trim(),
|
||||
);
|
||||
const poaEstablished =
|
||||
(identity?.poa.verified ?? false) ||
|
||||
(identity ? !identity.faydaRequired && Boolean(watch("poaName")?.trim()) : false);
|
||||
(identity ? !identity.faydaRequired && poaTyped : false);
|
||||
|
||||
// While linked, mirror the source values into the (disabled) target fields so
|
||||
// the copy stays current even if the user goes back and edits the source.
|
||||
@@ -616,8 +686,12 @@ export default function CompanyProfileForm({
|
||||
// Until then the upload is hidden: there is no representative for the paper
|
||||
// to authorise, and a freight forwarder is held on the verification gate
|
||||
// below rather than on a file field it cannot yet fill.
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const delegationRequired = poaProvided;
|
||||
const poaProvided = (identity?.poa.verified ?? false) || poaTyped;
|
||||
// A freight forwarder owes the paper whether or not its representative could
|
||||
// verify with Fayda — the API demands it at completion either way. Keying
|
||||
// this on the verification alone hid the upload from a foreign forwarder and
|
||||
// then failed them on submit for a file they were never shown.
|
||||
const delegationRequired = poaProvided || requirePoa;
|
||||
const delegationPresent =
|
||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||
(() => {
|
||||
@@ -625,15 +699,63 @@ export default function CompanyProfileForm({
|
||||
return Array.isArray(v) ? v.length > 0 : v != null;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Collect the messages for a set of fields into one sentence.
|
||||
*
|
||||
* A failed `trigger()` used to return silently, so Continue simply did
|
||||
* nothing — and every field whose input is conditionally rendered (or derived
|
||||
* and never rendered at all) turned into an invisible dead end. Naming the
|
||||
* failures is the whole point: the ones worth reporting are exactly the ones
|
||||
* with no error text on screen to read.
|
||||
*/
|
||||
const describeErrors = (fields: (keyof FormData)[]): string => {
|
||||
// Re-parse rather than read `errors`: that's the render-time snapshot, and
|
||||
// this runs immediately after an `await trigger()` that has not re-rendered
|
||||
// yet, so the closure would still be holding the previous attempt's state.
|
||||
const parsed = buildOnboardingSchema(
|
||||
identity?.passportRequired === true,
|
||||
).safeParse(getValues());
|
||||
const wanted = new Set<string>(fields as string[]);
|
||||
const messages = parsed.success
|
||||
? []
|
||||
: parsed.error.issues
|
||||
.filter((i) => wanted.has(String(i.path[0])))
|
||||
.map((i) => i.message);
|
||||
return messages.length > 0
|
||||
? `Please fix: ${[...new Set(messages)].join(", ")}.`
|
||||
: "Some details on this step are incomplete. Please review the fields above.";
|
||||
};
|
||||
|
||||
/**
|
||||
* The fields this step actually validates. `stepFields` covers what the step
|
||||
* always renders; the company step additionally exposes company email/phone
|
||||
* as inputs when nothing could be derived for them, and a field is validated
|
||||
* exactly when the customer can see and fix it.
|
||||
*/
|
||||
const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => {
|
||||
if (s !== "company" || !identity) return stepFields[s];
|
||||
return [
|
||||
...stepFields.company,
|
||||
...(derivedEmail ? [] : (["companyEmail"] as const)),
|
||||
...(derivedPhone ? [] : (["companyPhone"] as const)),
|
||||
];
|
||||
};
|
||||
|
||||
/** Validate + persist the current step, returning whether we may advance. */
|
||||
const saveCurrentStep = async (): Promise<boolean> => {
|
||||
setSaveError(null);
|
||||
const isValid = await trigger(stepFields[step]);
|
||||
if (!isValid) return false;
|
||||
const fields = fieldsForStep(step);
|
||||
const isValid = await trigger(fields);
|
||||
if (!isValid) {
|
||||
setSaveError(describeErrors(fields));
|
||||
return false;
|
||||
}
|
||||
if (!onSaveStep) return true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await onSaveStep(stepPayload(step, watch()));
|
||||
const res = await onSaveStep(
|
||||
stepPayload(step, getValues(), dirtyFields),
|
||||
);
|
||||
if (!res.ok) {
|
||||
setSaveError(res.error);
|
||||
return false;
|
||||
@@ -676,7 +798,14 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
|
||||
setSaveError(null);
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
// Deliberately NOT `handleSubmit`: that re-validated all 34 schema fields
|
||||
// — including every field belonging to a step that isn't on screen — and
|
||||
// on failure did nothing at all, no alert and no navigation, which is the
|
||||
// "Submit for review" button that appears dead. Each step has already
|
||||
// validated and saved its own fields, and the API's `markOnboardingComplete`
|
||||
// is the authority on what is still outstanding; its message reaches the
|
||||
// customer through `submitError`.
|
||||
onSubmit(buildPayload(getValues(), user));
|
||||
return;
|
||||
}
|
||||
// The TIN must resolve to a real eTrade record before anything else on
|
||||
@@ -728,15 +857,40 @@ export default function CompanyProfileForm({
|
||||
setDocumentFieldErrors({
|
||||
[POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required",
|
||||
});
|
||||
// Validate the text fields too, so every problem shows at once.
|
||||
const fieldsOk = await trigger(stepFields.poa);
|
||||
setSaveError(
|
||||
requirePoa
|
||||
? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
|
||||
: "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
|
||||
[
|
||||
requirePoa
|
||||
? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
|
||||
: "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
|
||||
fieldsOk ? null : describeErrors(stepFields.poa),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
// Fall through to validate the text fields too, so every problem shows at once.
|
||||
await trigger(stepFields.poa);
|
||||
return;
|
||||
}
|
||||
// The API will not accept the PoA's details until the paper evidencing the
|
||||
// delegation is actually on file, so the selection made on this step has to
|
||||
// be uploaded before the save — not held back until the documents step,
|
||||
// which is unreachable while this save keeps failing.
|
||||
if (step === "poa" && delegationRequired && onUploadDocuments) {
|
||||
const pending = documentFiles[POA_DELEGATION_FILE_KEY];
|
||||
const hasPending = Array.isArray(pending) ? pending.length > 0 : pending != null;
|
||||
if (hasPending) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await onUploadDocuments();
|
||||
if (!res.ok) {
|
||||
setSaveError(res.error);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Field steps validate + save before advancing.
|
||||
const ok = await saveCurrentStep();
|
||||
if (!ok) return;
|
||||
@@ -812,6 +966,42 @@ export default function CompanyProfileForm({
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
{/* Normally derived from the verified owner (falling back
|
||||
to eTrade and the account), and shown read-only. Fayda's
|
||||
email and phone claims are optional though, so when
|
||||
every source comes up empty these become typeable —
|
||||
the API requires both at submit, and having no input
|
||||
for them is otherwise an unrecoverable dead end. */}
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{derivedEmail ? (
|
||||
<ReadOnlyField
|
||||
label="Company email"
|
||||
value={derivedEmail}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
description="We couldn't find one on your verified identity or account — please enter it."
|
||||
placeholder="company@example.com"
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
)}
|
||||
{derivedPhone ? (
|
||||
<ReadOnlyField
|
||||
label="Company phone"
|
||||
value={derivedPhone}
|
||||
/>
|
||||
) : (
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
label="Company Phone"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</StepSection>
|
||||
@@ -835,6 +1025,7 @@ export default function CompanyProfileForm({
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
onStatusChange={setTinStatus}
|
||||
onReset={handleETradeReset}
|
||||
alreadyVerified={hasRegistrationDetails}
|
||||
/>
|
||||
{tinVerified && (
|
||||
<ETradeCompanyCard
|
||||
@@ -924,7 +1115,10 @@ export default function CompanyProfileForm({
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Contact Person
|
||||
</Text>
|
||||
{watch("generalManagerName") && (
|
||||
{/* `gmName`, not the raw form field: a Fayda-verified GM never
|
||||
fills `generalManagerName`, so gating on it hid this card from
|
||||
every Ethiopian company — the majority case. */}
|
||||
{gmName && (
|
||||
<LinkCheckboxCard
|
||||
checked={contactSameAsGm}
|
||||
onToggle={toggleContactSameAsGm}
|
||||
@@ -983,21 +1177,51 @@ export default function CompanyProfileForm({
|
||||
required={requirePoa}
|
||||
/>
|
||||
)}
|
||||
{/* The address comes from the Fayda claim along with the name,
|
||||
so it is shown on the panel rather than typed. Only a company
|
||||
whose representative may hold no Fayda ID still types it. */}
|
||||
{/* A verified representative's details come from the Fayda claim
|
||||
and are shown on the panel above. Where Fayda cannot be
|
||||
required — a foreign company whose representative may hold no
|
||||
Fayda ID — they are typed here instead. They have to be: the
|
||||
API refuses to save a freight forwarder's PoA without a name,
|
||||
email and phone (`REQUIRED_POA_FIELDS`), and before this the
|
||||
step rendered no input for any of them, so the customer was
|
||||
told to "add the poa name, poa email, poa phone" with nowhere
|
||||
to add them. */}
|
||||
{!identity?.poa.verified && !identity?.faydaRequired && (
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<>
|
||||
<TextInput
|
||||
label="Representative's Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Representative's Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="Representative's Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The paper authorises the representative the verification
|
||||
named, so it only has meaning once one exists. */}
|
||||
{poaProvided && poaDocumentSetting && (
|
||||
{/* The paper authorises the representative, so it shows once one
|
||||
exists — or straight away for a freight forwarder, who owes it
|
||||
either way and must not be failed on submit for a file the
|
||||
step never offered. */}
|
||||
{delegationRequired && poaDocumentSetting && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<SmartFileInput
|
||||
|
||||
@@ -17,6 +17,11 @@ import { ReadOnlyField } from "./ReadOnlyField";
|
||||
* supplied a value, but falls back to an editable input when eTrade left it
|
||||
* blank — otherwise a gap in eTrade's own data would leave the field
|
||||
* permanently empty and the user stuck (zod requires all of these).
|
||||
*
|
||||
* A value that fails validation unlocks the same way. eTrade (or a row saved
|
||||
* before the current rules) can supply something the schema rejects, and a
|
||||
* rejected value rendered read-only is a step that can never be completed and
|
||||
* never says why.
|
||||
*/
|
||||
function LockedField({
|
||||
label,
|
||||
@@ -32,7 +37,7 @@ function LockedField({
|
||||
errors: FieldErrors<FormData>;
|
||||
}) {
|
||||
const value = watch(name) as string | undefined;
|
||||
if (value && value.trim()) {
|
||||
if (value && value.trim() && !errors[name]) {
|
||||
return <ReadOnlyField label={label} value={value} />;
|
||||
}
|
||||
return (
|
||||
@@ -96,7 +101,12 @@ export default function ETradeCompanyCard({
|
||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
||||
{region && region.trim() ? (
|
||||
{/* Membership of the catalog, not mere presence: eTrade's normalizer
|
||||
returns null for a region it doesn't recognise, and older rows can
|
||||
hold a spelling that isn't in the list. Showing such a value
|
||||
read-only left the customer with a required field they could not
|
||||
correct. */}
|
||||
{(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? (
|
||||
<ReadOnlyField label="Region" value={region} />
|
||||
) : (
|
||||
<Controller
|
||||
|
||||
@@ -1,8 +1,72 @@
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
|
||||
|
||||
import type { CompanyStep, FormData } from "./schema";
|
||||
import { ETRADE_BUNDLE_FIELDS, type CompanyStep, type FormData } from "./schema";
|
||||
|
||||
/**
|
||||
* First value that is actually present.
|
||||
*
|
||||
* `??` is wrong for these: an identity claim Fayda returned as an empty string
|
||||
* is not a value, but it isn't null either, so `??` would stop there and hand
|
||||
* the form a blank it has no input to fix.
|
||||
*/
|
||||
export const firstPresent = (...values: (string | null | undefined)[]): string =>
|
||||
values.find((v) => v && v.trim())?.trim() ?? "";
|
||||
|
||||
/**
|
||||
* First candidate that is actually a usable phone number, normalized to E.164.
|
||||
*
|
||||
* Presence is not enough here. eTrade's registered phone is free text and comes
|
||||
* back as things like `"09 "`, which normalizes to `+2519` — non-empty,
|
||||
* so a "first present" pick would take it, hand it to a field with no input,
|
||||
* and have the API reject the whole save with
|
||||
* "companyPhone must be a valid international phone number" for something the
|
||||
* customer never typed. Skip a source that cannot produce a valid number and
|
||||
* fall through to the next one.
|
||||
*/
|
||||
export const firstValidPhone = (
|
||||
...values: (string | null | undefined)[]
|
||||
): string => {
|
||||
for (const raw of values) {
|
||||
if (!raw || !raw.trim()) continue;
|
||||
const e164 = toEthiopianE164(raw);
|
||||
if (e164 && isValidPhone(e164)) return e164;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/** Same idea for email: a malformed claim must not become an unfixable field. */
|
||||
export const firstValidEmail = (
|
||||
...values: (string | null | undefined)[]
|
||||
): string => {
|
||||
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return values.find((v) => v && ok.test(v.trim()))?.trim() ?? "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Fayda reports a person's phone as the national registry holds it, which is
|
||||
* routinely a local number ("0911223344"). Every phone the forms validate and
|
||||
* submit is E.164, so normalize on the way in — the API now stores new
|
||||
* verifications normalized, but rows verified before that still hold raw claims.
|
||||
*/
|
||||
export function normalizeIdentityPhones(
|
||||
identity?: CompanyIdentityState,
|
||||
): CompanyIdentityState | undefined {
|
||||
if (!identity) return identity;
|
||||
const fix = <T extends { phone: string | null }>(person: T): T => ({
|
||||
...person,
|
||||
phone: person.phone ? toEthiopianE164(person.phone) : person.phone,
|
||||
});
|
||||
return {
|
||||
...identity,
|
||||
owner: fix(identity.owner),
|
||||
poa: fix(identity.poa),
|
||||
gm: fix(identity.gm),
|
||||
};
|
||||
}
|
||||
|
||||
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
||||
export const phoneDigits = (p?: string | null) =>
|
||||
@@ -44,34 +108,35 @@ export function buildPayload(
|
||||
};
|
||||
}
|
||||
|
||||
/** Map one wizard step's form values to the profile-update payload it saves. */
|
||||
/**
|
||||
* Map one wizard step's form values to the profile-update payload it saves.
|
||||
*
|
||||
* `dirty` is react-hook-form's `dirtyFields`. The eTrade-owned keys (and the
|
||||
* TIN) ride along only when the customer actually changed them this session —
|
||||
* see `ETRADE_BUNDLE_FIELDS`. Everything else is unconditional: the API treats
|
||||
* an absent key as "untouched", so omitting a field never clears it.
|
||||
*/
|
||||
export function stepPayload(
|
||||
step: CompanyStep,
|
||||
d: FormData,
|
||||
dirty: Partial<Record<keyof FormData, unknown>> = {},
|
||||
): Partial<UpdateProfilePayload> {
|
||||
switch (step) {
|
||||
case "company":
|
||||
case "company": {
|
||||
const etrade: Partial<UpdateProfilePayload> = {};
|
||||
for (const key of ETRADE_BUNDLE_FIELDS) {
|
||||
if (dirty[key]) (etrade as Record<string, unknown>)[key] = d[key];
|
||||
}
|
||||
if (dirty.tinNumber) etrade.tin = d.tinNumber;
|
||||
return {
|
||||
companyName: d.companyName,
|
||||
companyEmail: d.companyEmail,
|
||||
companyPhone: d.companyPhone,
|
||||
companyAddress: d.companyAddress,
|
||||
tin: d.tinNumber,
|
||||
vatNumber: d.vatNumber,
|
||||
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
||||
licenceNumber: d.licenceNumber,
|
||||
statusDescription: d.statusDescription,
|
||||
dateRegistered: d.dateRegistered,
|
||||
renewedFrom: d.renewedFrom,
|
||||
renewalDate: d.renewalDate,
|
||||
renewedTo: d.renewedTo,
|
||||
region: d.region,
|
||||
zone: d.zone,
|
||||
woreda: d.woreda,
|
||||
kebele: d.kebele,
|
||||
houseNo: d.houseNo,
|
||||
etradePhone: d.etradePhone,
|
||||
...etrade,
|
||||
};
|
||||
}
|
||||
case "personnel":
|
||||
return {
|
||||
generalManagerName: d.generalManagerName,
|
||||
@@ -86,7 +151,12 @@ export function stepPayload(
|
||||
contactPersonPhone: d.contactPersonPhone,
|
||||
};
|
||||
case "poa":
|
||||
return { poaLocation: d.poaLocation || undefined };
|
||||
return {
|
||||
poaName: d.poaName || undefined,
|
||||
poaEmail: d.poaEmail || undefined,
|
||||
poaPhone: d.poaPhone || undefined,
|
||||
poaLocation: d.poaLocation || undefined,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { onboardingSchema, stepFields } from "./schema";
|
||||
import {
|
||||
firstPresent,
|
||||
firstValidEmail,
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
stepPayload,
|
||||
} from "./helpers";
|
||||
import type { FormData } from "./schema";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
|
||||
/** A minimally-valid form, so each case can vary one field at a time. */
|
||||
const values = (over: Partial<FormData> = {}): FormData =>
|
||||
({
|
||||
companyName: "Acme PLC",
|
||||
companyEmail: "acme@example.com",
|
||||
companyPhone: "+251911223344",
|
||||
companyAddress: "1, Bole, Bole, Addis Ababa",
|
||||
etradePhone: "+251911223344",
|
||||
tinNumber: "0012345678",
|
||||
vatNumber: "0012345678",
|
||||
ownerPassportNumber: "",
|
||||
licenceNumber: "LIC-1",
|
||||
statusDescription: "Active",
|
||||
dateRegistered: "2020-01-01",
|
||||
renewedFrom: "",
|
||||
renewalDate: "",
|
||||
renewedTo: "",
|
||||
region: "Addis Ababa",
|
||||
zone: "Bole",
|
||||
woreda: "03",
|
||||
kebele: "07",
|
||||
houseNo: "1",
|
||||
contactPersonName: "Jane Smith",
|
||||
contactPersonPosition: "",
|
||||
contactPersonEmail: "",
|
||||
contactPersonPhone: "+251911223344",
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
poaName: "",
|
||||
poaPhone: "",
|
||||
poaAddress: "",
|
||||
poaEmail: "",
|
||||
poaLocation: "",
|
||||
...over,
|
||||
}) as FormData;
|
||||
|
||||
const errorFor = (data: FormData, field: keyof FormData) => {
|
||||
const parsed = onboardingSchema.safeParse(data);
|
||||
if (parsed.success) return undefined;
|
||||
return parsed.error.issues.find((i) => i.path[0] === field)?.message;
|
||||
};
|
||||
|
||||
describe("VAT number", () => {
|
||||
it("accepts exactly ten digits", () => {
|
||||
expect(errorFor(values({ vatNumber: "0012345678" }), "vatNumber")).toBeUndefined();
|
||||
});
|
||||
|
||||
// `.length(10)` used to pass this, so a ten-letter string reached the API.
|
||||
it("rejects ten non-digits", () => {
|
||||
expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe(
|
||||
"VAT number must be exactly 10 digits",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects blank", () => {
|
||||
expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe(
|
||||
"VAT number is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("region", () => {
|
||||
it("rejects a spelling outside the catalog", () => {
|
||||
expect(errorFor(values({ region: "Addis Abeba City" }), "region")).toBe(
|
||||
"Region is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepFields", () => {
|
||||
// The regression this whole change exists to prevent: a step must not gate on
|
||||
// a field it renders no input for, or Continue fails with the error attached
|
||||
// to nothing on screen.
|
||||
it("never gates the company step on a derived or read-only field", () => {
|
||||
const unreachable = [
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyAddress",
|
||||
"etradePhone",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
];
|
||||
expect(
|
||||
stepFields.company.filter((f) => unreachable.includes(f)),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepPayload (company)", () => {
|
||||
it("omits the eTrade bundle when nothing was re-verified", () => {
|
||||
const payload = stepPayload("company", values(), {});
|
||||
expect(payload.tin).toBeUndefined();
|
||||
expect(payload.region).toBeUndefined();
|
||||
expect(payload.licenceNumber).toBeUndefined();
|
||||
// The customer's own fields still save.
|
||||
expect(payload.vatNumber).toBe("0012345678");
|
||||
});
|
||||
|
||||
it("includes the bundle and the TIN once they are dirty", () => {
|
||||
const payload = stepPayload("company", values(), {
|
||||
tinNumber: true,
|
||||
region: true,
|
||||
});
|
||||
expect(payload.tin).toBe("0012345678");
|
||||
expect(payload.region).toBe("Addis Ababa");
|
||||
// Still only the dirty ones.
|
||||
expect(payload.licenceNumber).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstPresent", () => {
|
||||
it("skips empty strings rather than stopping at them", () => {
|
||||
expect(firstPresent("", " ", "second@example.com")).toBe(
|
||||
"second@example.com",
|
||||
);
|
||||
expect(firstPresent(null, undefined, "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstValidPhone", () => {
|
||||
// Observed live: eTrade returned "09 " for a real TIN. It normalizes to
|
||||
// "+2519", which is non-empty — so a presence check took it, put it in a field
|
||||
// with no input, and the API rejected the whole step.
|
||||
it("skips an eTrade number that cannot make a valid E.164", () => {
|
||||
expect(firstValidPhone("09 ", "+251911223344")).toBe("+251911223344");
|
||||
});
|
||||
|
||||
it("normalizes a local number it can use", () => {
|
||||
expect(firstValidPhone("0911223344")).toBe("+251911223344");
|
||||
});
|
||||
|
||||
it("returns empty when no source is usable, so the field falls back to an input", () => {
|
||||
expect(firstValidPhone("09 ", "", null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstValidEmail", () => {
|
||||
it("skips a malformed claim", () => {
|
||||
expect(firstValidEmail("not-an-email", "real@example.com")).toBe(
|
||||
"real@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeIdentityPhones", () => {
|
||||
it("converts a local Fayda phone claim to E.164", () => {
|
||||
const identity = {
|
||||
faydaRequired: true,
|
||||
passportRequired: false,
|
||||
owner: { verified: true, name: "A", phone: "0911223344", email: null, address: null, verifiedAt: null, passportNumber: null },
|
||||
poa: { verified: false, name: null, phone: null, email: null, address: null, verifiedAt: null },
|
||||
gm: { verified: false, name: null, phone: "251911223344", email: null, address: null, verifiedAt: null },
|
||||
gmSameAsOwner: false,
|
||||
complete: false,
|
||||
} as CompanyIdentityState;
|
||||
|
||||
const fixed = normalizeIdentityPhones(identity)!;
|
||||
expect(fixed.owner.phone).toBe("+251911223344");
|
||||
expect(fixed.gm.phone).toBe("+251911223344");
|
||||
expect(fixed.poa.phone).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,11 @@ export const onboardingSchema = z.object({
|
||||
// can diverge without the backend's eTrade-authenticity check misfiring.
|
||||
etradePhone: z.string().optional(),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
// `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits.
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.length(10, "VAT number must be exactly 10 digits"),
|
||||
.regex(/^\d{10}$/, "VAT number must be exactly 10 digits"),
|
||||
// The owner's passport number — the foreign-company identity credential
|
||||
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
|
||||
// enforced in buildOnboardingSchema since that depends on `nationality`.
|
||||
@@ -134,22 +135,47 @@ export function buildOnboardingSchema(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys eTrade owns. They are only resent when the customer actually
|
||||
* re-verified the TIN this session: the API reacts to *any* of them by issuing
|
||||
* a live eTrade lookup (`applyEtradeSourcedFields`) whose transport failures
|
||||
* come back as a 400, so echoing unchanged values back would let an eTrade
|
||||
* outage block a save the customer never made.
|
||||
*/
|
||||
export const ETRADE_BUNDLE_FIELDS = [
|
||||
"companyName",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
"region",
|
||||
"zone",
|
||||
"woreda",
|
||||
"kebele",
|
||||
"houseNo",
|
||||
"etradePhone",
|
||||
] as const satisfies readonly (keyof FormData)[];
|
||||
|
||||
/**
|
||||
* What each step validates before it may advance.
|
||||
*
|
||||
* Hard rule: a key belongs here only if that step renders an input the customer
|
||||
* can actually correct it in. `companyEmail`/`companyPhone` are derived from the
|
||||
* Fayda identity / eTrade / the account and have no input of their own, and the
|
||||
* read-only eTrade fields cannot be edited at all — listing them meant a value
|
||||
* the customer never typed could fail zod with its error message attached to
|
||||
* nothing on screen, which reads as a Continue button that silently does
|
||||
* nothing. The server still enforces its own required-field list at submit
|
||||
* (`REQUIRED_COMPANY_INFO`), and reports it with a message.
|
||||
*/
|
||||
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
company: [
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyAddress",
|
||||
"etradePhone",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"ownerPassportNumber",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
"region",
|
||||
"zone",
|
||||
"woreda",
|
||||
@@ -167,7 +193,11 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
],
|
||||
poa: ["poaLocation"],
|
||||
// The API requires poaName/poaEmail/poaPhone from a freight forwarder
|
||||
// (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the
|
||||
// representative isn't proven by Fayda — otherwise the save is rejected
|
||||
// naming fields the form never rendered.
|
||||
poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"],
|
||||
documents: [],
|
||||
additional: [],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CompanyProfileInput,
|
||||
@@ -30,6 +30,12 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo";
|
||||
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
||||
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
||||
import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema";
|
||||
import {
|
||||
firstValidEmail,
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
} from "@/pages/accounts/companyProfileForm/helpers";
|
||||
|
||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
@@ -43,12 +49,12 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
// no standalone input.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
// Same rule as onboarding — the two forms write the same column, so they must
|
||||
// not disagree about what is acceptable in it.
|
||||
vatNumber: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(20, "VAT number is too long")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
.min(1, "VAT number is required")
|
||||
.regex(/^\d{10}$/, "VAT number must be exactly 10 digits"),
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
// Registration/address fields are eTrade-sourced — locked once eTrade
|
||||
// supplies a value, editable only as an escape hatch when it doesn't
|
||||
@@ -72,21 +78,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
|
||||
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
||||
|
||||
/** UpdateProfilePayload keys eTrade owns — only resent when the customer re-verified them this session. */
|
||||
const ETRADE_BUNDLE_FIELDS = [
|
||||
"companyName",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
"region",
|
||||
"zone",
|
||||
"woreda",
|
||||
"kebele",
|
||||
"houseNo",
|
||||
] as const satisfies readonly (keyof CompanyProfileFormData)[];
|
||||
/**
|
||||
* `etradePhone` is not on this form, so the shared list is filtered down to the
|
||||
* keys it actually holds. Source of truth: `companyProfileForm/schema.ts`.
|
||||
*/
|
||||
const ETRADE_FIELDS = SHARED_ETRADE_FIELDS.filter(
|
||||
(k): k is Exclude<typeof k, "etradePhone"> => k !== "etradePhone",
|
||||
);
|
||||
|
||||
interface TabCompanyProfileProps {
|
||||
profile?: ProfileResponse;
|
||||
@@ -166,32 +164,39 @@ export default function TabCompanyProfile({
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
const identity = profile?.identity;
|
||||
// Fayda stores the phone as the national registry holds it (often a local
|
||||
// number), which neither this form's E.164 validation nor the API's
|
||||
// `@IsValidPhone()` accepts. Normalize on read — same as the wizard.
|
||||
const identity = useMemo(
|
||||
() => normalizeIdentityPhones(profile?.identity),
|
||||
[profile?.identity],
|
||||
);
|
||||
const verifiedIdentity = identity?.faydaRequired === true;
|
||||
|
||||
// companyEmail/companyPhone are the owner's verified contact details, never
|
||||
// typed — same derivation as the onboarding wizard, just fed from the saved
|
||||
// profile instead of an in-progress form.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.email, user?.email]);
|
||||
// profile instead of an in-progress form. `firstValid*` rather than `??`:
|
||||
// these claims are optional AND unreliable — eTrade's registered phone is
|
||||
// free text that arrives as things like "09 " — and `??` stops at the
|
||||
// first non-null, so junk became a read-only field the customer could not
|
||||
// fix and a 400 on save. When nothing usable can be derived the fields below
|
||||
// become editable instead of blocking.
|
||||
const derivedEmail = firstValidEmail(identity?.owner.email, user?.email);
|
||||
const derivedPhone = firstValidPhone(
|
||||
identity?.owner.phone,
|
||||
profile?.etradePhone,
|
||||
user?.phoneNumber,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setValue(
|
||||
"companyPhone",
|
||||
identity?.owner.phone ??
|
||||
profile?.etradePhone ??
|
||||
toEthiopianE164(user.phoneNumber) ??
|
||||
"",
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
if (derivedEmail) setValue("companyEmail", derivedEmail);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.phone, profile?.etradePhone, user?.phoneNumber]);
|
||||
}, [derivedEmail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (derivedPhone) setValue("companyPhone", derivedPhone);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [derivedPhone]);
|
||||
|
||||
// companyAddress is composed from the (locked) eTrade address parts, not
|
||||
// typed directly.
|
||||
@@ -249,7 +254,7 @@ export default function TabCompanyProfile({
|
||||
// on every save would otherwise trigger the server's eTrade
|
||||
// authenticity re-check for no reason.
|
||||
const etradeBundle: Record<string, string | undefined> = {};
|
||||
for (const key of ETRADE_BUNDLE_FIELDS) {
|
||||
for (const key of ETRADE_FIELDS) {
|
||||
if (dirtyFields[key]) etradeBundle[key] = data[key];
|
||||
}
|
||||
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
|
||||
@@ -296,13 +301,32 @@ export default function TabCompanyProfile({
|
||||
});
|
||||
|
||||
const onSubmit = (data: CompanyProfileFormData) => {
|
||||
setValidationError(null);
|
||||
if (isCreate && selectedRoles.length === 0) return;
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
const saveErrorMessage = mutation.isError
|
||||
? extractApiError(mutation.error).message
|
||||
: null;
|
||||
/**
|
||||
* Without this, a failed validation made "Save Changes" a no-op: the fields
|
||||
* the schema requires are largely eTrade-sourced and rendered read-only, so
|
||||
* their error messages had nowhere to appear and the button simply did
|
||||
* nothing. Name them instead.
|
||||
*/
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const onInvalid = (formErrors: typeof errors) => {
|
||||
const messages = Object.values(formErrors)
|
||||
.map((e) => e?.message)
|
||||
.filter((m): m is string => Boolean(m));
|
||||
setValidationError(
|
||||
messages.length > 0
|
||||
? `Please fix: ${[...new Set(messages)].join(", ")}.`
|
||||
: "Some details are incomplete. Please review the fields above.",
|
||||
);
|
||||
};
|
||||
|
||||
const saveErrorMessage =
|
||||
validationError ??
|
||||
(mutation.isError ? extractApiError(mutation.error).message : null);
|
||||
|
||||
const pendingOwnerReview = Boolean(
|
||||
(profile?.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
||||
@@ -333,7 +357,7 @@ export default function TabCompanyProfile({
|
||||
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
|
||||
<Stack gap="xl">
|
||||
<StepSection
|
||||
index={1}
|
||||
@@ -341,9 +365,9 @@ export default function TabCompanyProfile({
|
||||
status={watch("vatNumber") ? "done" : "todo"}
|
||||
>
|
||||
<TextInput
|
||||
label="VAT Number (optional)"
|
||||
label="VAT Number"
|
||||
placeholder="e.g. 0012345678"
|
||||
maxLength={20}
|
||||
maxLength={10}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
@@ -389,9 +413,33 @@ export default function TabCompanyProfile({
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
{/* Read-only while the verified owner (or eTrade, or the account)
|
||||
supplies them. Fayda's email/phone claims are optional, so
|
||||
when nothing can be derived these become typeable — the API
|
||||
requires both, and showing an empty read-only field is a save
|
||||
that can never succeed. */}
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<ReadOnlyField label="Company email" value={watch("companyEmail")} />
|
||||
<ReadOnlyField label="Company phone" value={watch("companyPhone")} />
|
||||
{derivedEmail ? (
|
||||
<ReadOnlyField label="Company email" value={derivedEmail} />
|
||||
) : (
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
description="We couldn't find one on your verified identity or account — please enter it."
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
)}
|
||||
{derivedPhone ? (
|
||||
<ReadOnlyField label="Company phone" value={derivedPhone} />
|
||||
) : (
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
label="Company Phone"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</StepSection>
|
||||
)}
|
||||
@@ -410,6 +458,7 @@ export default function TabCompanyProfile({
|
||||
error={errors.tinNumber?.message}
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
onStatusChange={setTinStatus}
|
||||
alreadyVerified={hasRegistrationDetails}
|
||||
/>
|
||||
{tinVerified && (
|
||||
<EtradeLockedCard
|
||||
@@ -520,7 +569,9 @@ function EtradeLockedCard({
|
||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
||||
{region?.trim() ? (
|
||||
{/* Membership of the catalog, not mere presence — a stored spelling
|
||||
outside the list is otherwise uncorrectable. */}
|
||||
{(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? (
|
||||
<ReadOnlyField label="Region" value={region} />
|
||||
) : (
|
||||
<RegionSelect control={control} error={errors.region?.message} />
|
||||
@@ -548,7 +599,9 @@ function LockedField({
|
||||
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
||||
}) {
|
||||
const value = watch(name) as string | undefined;
|
||||
if (value?.trim()) {
|
||||
// A value that fails validation unlocks too — rendering a rejected value
|
||||
// read-only is a save that can never succeed and never says why.
|
||||
if (value?.trim() && !errors[name]) {
|
||||
return <ReadOnlyField label={label} value={value} />;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -34,6 +34,16 @@ function humanizeApiMessage(raw: string): string {
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* NestJS's ValidationPipe reports every failed constraint at once, so `message`
|
||||
* arrives as a string[] rather than a string. Flatten it — passing the array
|
||||
* through left the UI rendering its entries run together with no separator.
|
||||
*/
|
||||
function asMessage(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.filter(Boolean).join(". ");
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function extractApiError(err: unknown): ApiError {
|
||||
if (err && typeof err === "object") {
|
||||
const obj = err as Record<string, unknown>;
|
||||
@@ -41,13 +51,10 @@ export function extractApiError(err: unknown): ApiError {
|
||||
if (response) {
|
||||
const statusCode = response.status as number | undefined;
|
||||
const data = response.data as Record<string, unknown> | undefined;
|
||||
const raw = asMessage(data?.message) || asMessage(data?.error);
|
||||
return {
|
||||
code: (data?.message as string) || (data?.error as string) || "api_error",
|
||||
message: humanizeApiMessage(
|
||||
(data?.message as string) ||
|
||||
(data?.error as string) ||
|
||||
"An unexpected error occurred",
|
||||
),
|
||||
code: raw || "api_error",
|
||||
message: humanizeApiMessage(raw || "An unexpected error occurred"),
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user