fix: onboarding validation

This commit is contained in:
Nathnael
2026-08-06 18:54:54 +00:00
parent ab7069e335
commit dd2b624aa6
11 changed files with 804 additions and 165 deletions

View File

@@ -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 } : {}),
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {};
}

View File

@@ -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();
});
});

View File

@@ -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: [],
};

View File

@@ -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 (

View File

@@ -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,
};
}