mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +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.
|
// replace it before the application counts as complete.
|
||||||
const flaggedDelegation = delegationDue && delegation.flagged;
|
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 = [
|
const outstanding = [
|
||||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||||
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
||||||
@@ -2074,8 +2079,19 @@ export class CompaniesService {
|
|||||||
...(identity.faydaRequired && !identity.owner.verified
|
...(identity.faydaRequired && !identity.owner.verified
|
||||||
? ["Verify the company owner's identity with Fayda"]
|
? ["Verify the company owner's identity with Fayda"]
|
||||||
: []),
|
: []),
|
||||||
...((poaRequired || poaProvided) && !identity.poa.verified
|
// Nationality-aware, exactly like `poaProven` in
|
||||||
? ["Verify your Power of Attorney's identity with Fayda"]
|
// 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
|
...(identity.passportRequired && !identity.owner.passportNumber
|
||||||
? ["Add the company owner's passport number"]
|
? ["Add the company owner's passport number"]
|
||||||
@@ -2089,7 +2105,10 @@ export class CompaniesService {
|
|||||||
const poaItemCount = delegationDue ? 1 : 0;
|
const poaItemCount = delegationDue ? 1 : 0;
|
||||||
// One item per identity credential the company has to prove: the owner
|
// One item per identity credential the company has to prove: the owner
|
||||||
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
// 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 =
|
const ownerCredentialDue =
|
||||||
identity.faydaRequired || identity.passportRequired;
|
identity.faydaRequired || identity.passportRequired;
|
||||||
const ownerCredentialProven = identity.faydaRequired
|
const ownerCredentialProven = identity.faydaRequired
|
||||||
@@ -2099,7 +2118,7 @@ export class CompaniesService {
|
|||||||
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||||
const missingIdentityCount =
|
const missingIdentityCount =
|
||||||
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||||
(delegationDue && !identity.poa.verified ? 1 : 0);
|
(delegationDue && !poaProven ? 1 : 0);
|
||||||
const total =
|
const total =
|
||||||
requiredInfo.length +
|
requiredInfo.length +
|
||||||
requiredDocCount +
|
requiredDocCount +
|
||||||
@@ -2767,7 +2786,13 @@ export class CompaniesService {
|
|||||||
// The verified payload owns the person's details from here on.
|
// The verified payload owns the person's details from here on.
|
||||||
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
||||||
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
|
...(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 } : {}),
|
...(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 { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
|
||||||
import { CompanyNationality } from '../entities/company.entity';
|
import { CompanyNationality } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
@@ -39,9 +47,13 @@ export class UpdateProfileDto {
|
|||||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
tin?: string;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(50)
|
@Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' })
|
||||||
vatNumber?: string;
|
vatNumber?: string;
|
||||||
|
|
||||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
// `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[] = [
|
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
|
||||||
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
||||||
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
||||||
|
// The ET hub's rows open the shipment clearance detail at this URL.
|
||||||
|
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
|
||||||
];
|
];
|
||||||
|
|
||||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||||
|
|||||||
@@ -26,9 +26,22 @@ interface ETradeInfoProps {
|
|||||||
onStatusChange?: (status: ETradeStatus) => void;
|
onStatusChange?: (status: ETradeStatus) => void;
|
||||||
/** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */
|
/** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */
|
||||||
onReset?: () => void;
|
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({
|
export default function ETradeInfo({
|
||||||
tin,
|
tin,
|
||||||
@@ -37,6 +50,7 @@ export default function ETradeInfo({
|
|||||||
onDataLoaded,
|
onDataLoaded,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onReset,
|
onReset,
|
||||||
|
alreadyVerified,
|
||||||
}: ETradeInfoProps) {
|
}: ETradeInfoProps) {
|
||||||
const mutation = useETradeData();
|
const mutation = useETradeData();
|
||||||
const isLoading = mutation.isPending;
|
const isLoading = mutation.isPending;
|
||||||
@@ -63,6 +77,13 @@ export default function ETradeInfo({
|
|||||||
// doesn't refire the lookup the moment this mounts.
|
// doesn't refire the lookup the moment this mounts.
|
||||||
const lastFetchedTin = useRef<string | null>(tin || null);
|
const lastFetchedTin = useRef<string | null>(tin || null);
|
||||||
useEffect(() => {
|
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) {
|
if (tin !== lastFetchedTin.current) {
|
||||||
// TIN moved away from whatever we last fetched — that result (verified
|
// TIN moved away from whatever we last fetched — that result (verified
|
||||||
// data, "taken", or an error) no longer describes this TIN. Drop it so
|
// data, "taken", or an error) no longer describes this TIN. Drop it so
|
||||||
@@ -82,12 +103,17 @@ export default function ETradeInfo({
|
|||||||
|
|
||||||
const apiError =
|
const apiError =
|
||||||
mutation.isError && mutation.error ? extractApiError(mutation.error) : null;
|
mutation.isError && mutation.error ? extractApiError(mutation.error) : null;
|
||||||
// A 400 here means eTrade simply has no record for this TIN.
|
// A 400 here usually means eTrade has no record for this TIN — but the API
|
||||||
const notFound = apiError?.statusCode === 400;
|
// 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 =
|
const errorMessage =
|
||||||
apiError && !notFound
|
apiError && !notFound
|
||||||
? apiError.message ||
|
? unreachable || !apiError.message
|
||||||
"We couldn't reach eTrade to fetch your company information. Please try again."
|
? "We couldn't reach eTrade to fetch your company information. Please try again in a moment."
|
||||||
|
: apiError.message
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const status: ETradeStatus = isLoading
|
const status: ETradeStatus = isLoading
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
} from "./companyProfileForm/schema";
|
} from "./companyProfileForm/schema";
|
||||||
import {
|
import {
|
||||||
buildPayload,
|
buildPayload,
|
||||||
|
firstPresent,
|
||||||
|
firstValidEmail,
|
||||||
|
firstValidPhone,
|
||||||
|
normalizeIdentityPhones,
|
||||||
stepPayload,
|
stepPayload,
|
||||||
toFormValues,
|
toFormValues,
|
||||||
} from "./companyProfileForm/helpers";
|
} from "./companyProfileForm/helpers";
|
||||||
@@ -45,6 +49,7 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
|||||||
import { verifaydaService } from "@/services/verifayda.service";
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||||
|
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
||||||
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
||||||
import StepSection from "./companyProfileForm/StepSection";
|
import StepSection from "./companyProfileForm/StepSection";
|
||||||
|
|
||||||
@@ -67,7 +72,7 @@ export default function CompanyProfileForm({
|
|||||||
submitError,
|
submitError,
|
||||||
uploadedDocumentKeys,
|
uploadedDocumentKeys,
|
||||||
onUploadDocuments,
|
onUploadDocuments,
|
||||||
identity,
|
identity: rawIdentity,
|
||||||
onIdentityChange,
|
onIdentityChange,
|
||||||
}: {
|
}: {
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
@@ -115,6 +120,15 @@ export default function CompanyProfileForm({
|
|||||||
*/
|
*/
|
||||||
onIdentityChange?: () => void;
|
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 [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
@@ -188,15 +202,20 @@ export default function CompanyProfileForm({
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
|
||||||
trigger,
|
trigger,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { errors },
|
getValues,
|
||||||
|
formState: { errors, dirtyFields },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
buildOnboardingSchema(identity?.passportRequired === true),
|
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: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
companyEmail: "",
|
companyEmail: "",
|
||||||
@@ -266,21 +285,28 @@ export default function CompanyProfileForm({
|
|||||||
phone: string;
|
phone: string;
|
||||||
} | null>(null);
|
} | 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 handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
||||||
|
const dirty = { shouldDirty: true } as const;
|
||||||
if (data.companyName) {
|
if (data.companyName) {
|
||||||
setValue("companyName", data.companyName, { shouldValidate: true });
|
setValue("companyName", data.companyName, {
|
||||||
|
shouldValidate: true,
|
||||||
|
...dirty,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setValue("licenceNumber", data.licenceNumber);
|
setValue("licenceNumber", data.licenceNumber, dirty);
|
||||||
setValue("statusDescription", data.statusDescription);
|
setValue("statusDescription", data.statusDescription, dirty);
|
||||||
setValue("dateRegistered", data.dateRegistered);
|
setValue("dateRegistered", data.dateRegistered, dirty);
|
||||||
setValue("renewedFrom", data.renewedFrom);
|
setValue("renewedFrom", data.renewedFrom, dirty);
|
||||||
setValue("renewalDate", data.renewalDate);
|
setValue("renewalDate", data.renewalDate, dirty);
|
||||||
setValue("renewedTo", data.renewedTo);
|
setValue("renewedTo", data.renewedTo, dirty);
|
||||||
setValue("region", data.region);
|
setValue("region", data.region, dirty);
|
||||||
setValue("zone", data.zone);
|
setValue("zone", data.zone, dirty);
|
||||||
setValue("woreda", data.woreda);
|
setValue("woreda", data.woreda, dirty);
|
||||||
setValue("kebele", data.kebele);
|
setValue("kebele", data.kebele, dirty);
|
||||||
setValue("houseNo", data.houseNo);
|
setValue("houseNo", data.houseNo, dirty);
|
||||||
// companyAddress is composed reactively from the address fields below, so
|
// companyAddress is composed reactively from the address fields below, so
|
||||||
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
||||||
// compose it here. companyPhone is derived below (identity → eTrade →
|
// compose it here. companyPhone is derived below (identity → eTrade →
|
||||||
@@ -292,6 +318,7 @@ export default function CompanyProfileForm({
|
|||||||
setValue(
|
setValue(
|
||||||
"etradePhone",
|
"etradePhone",
|
||||||
data.managerPhone || data.regularPhone || data.mobilePhone,
|
data.managerPhone || data.regularPhone || data.mobilePhone,
|
||||||
|
dirty,
|
||||||
);
|
);
|
||||||
|
|
||||||
setEtradeOwner({
|
setEtradeOwner({
|
||||||
@@ -321,28 +348,37 @@ export default function CompanyProfileForm({
|
|||||||
setEtradeOwner(null);
|
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
|
// 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
|
// registered number and the account email/phone are the fallbacks used
|
||||||
// before verification happens.
|
// 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(() => {
|
useEffect(() => {
|
||||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
if (derivedEmail) setValue("companyEmail", derivedEmail);
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [identity?.owner.email, user.email, rehydrate]);
|
}, [derivedEmail, rehydrate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(
|
if (derivedPhone) setValue("companyPhone", derivedPhone);
|
||||||
"companyPhone",
|
|
||||||
identity?.owner.phone ??
|
|
||||||
etradeOwner?.phone ??
|
|
||||||
toEthiopianE164(user.phoneNumber) ??
|
|
||||||
"",
|
|
||||||
{ shouldValidate: true },
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// "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
|
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||||
@@ -352,6 +388,16 @@ export default function CompanyProfileForm({
|
|||||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||||
identity?.gmSameAsOwner ?? false,
|
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);
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
|
|
||||||
// General Manager source. The company step's email/phone are seeded from
|
// 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
|
// 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
|
// higher-trust source, and the whole point of proving identity is to stop
|
||||||
// trusting typed/looked-up data for this.
|
// trusting typed/looked-up data for this.
|
||||||
const gmSourceName =
|
const gmSourceName = firstPresent(
|
||||||
identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? "";
|
identity?.owner.name,
|
||||||
const gmSourceEmail =
|
etradeOwner?.name,
|
||||||
identity?.owner.email ?? (companyEmail || user.email || "");
|
user.name?.en,
|
||||||
const gmSourcePhone =
|
);
|
||||||
identity?.owner.phone ??
|
|
||||||
companyPhone ??
|
const gmSourceEmail = firstValidEmail(
|
||||||
etradeOwner?.phone ??
|
identity?.owner.email,
|
||||||
toEthiopianE164(user.phoneNumber) ??
|
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(() => {
|
useEffect(() => {
|
||||||
if (!gmSameAsOwner) return;
|
if (!gmSameAsOwner) return;
|
||||||
@@ -458,10 +514,24 @@ export default function CompanyProfileForm({
|
|||||||
const gmEstablished =
|
const gmEstablished =
|
||||||
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
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 =
|
const poaEstablished =
|
||||||
(identity?.poa.verified ?? false) ||
|
(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
|
// 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.
|
// 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
|
// 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
|
// to authorise, and a freight forwarder is held on the verification gate
|
||||||
// below rather than on a file field it cannot yet fill.
|
// below rather than on a file field it cannot yet fill.
|
||||||
const poaProvided = identity?.poa.verified ?? false;
|
const poaProvided = (identity?.poa.verified ?? false) || poaTyped;
|
||||||
const delegationRequired = poaProvided;
|
// 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 =
|
const delegationPresent =
|
||||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||||
(() => {
|
(() => {
|
||||||
@@ -625,15 +699,63 @@ export default function CompanyProfileForm({
|
|||||||
return Array.isArray(v) ? v.length > 0 : v != null;
|
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. */
|
/** Validate + persist the current step, returning whether we may advance. */
|
||||||
const saveCurrentStep = async (): Promise<boolean> => {
|
const saveCurrentStep = async (): Promise<boolean> => {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
const isValid = await trigger(stepFields[step]);
|
const fields = fieldsForStep(step);
|
||||||
if (!isValid) return false;
|
const isValid = await trigger(fields);
|
||||||
|
if (!isValid) {
|
||||||
|
setSaveError(describeErrors(fields));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!onSaveStep) return true;
|
if (!onSaveStep) return true;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await onSaveStep(stepPayload(step, watch()));
|
const res = await onSaveStep(
|
||||||
|
stepPayload(step, getValues(), dirtyFields),
|
||||||
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setSaveError(res.error);
|
setSaveError(res.error);
|
||||||
return false;
|
return false;
|
||||||
@@ -676,7 +798,14 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSaveError(null);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// The TIN must resolve to a real eTrade record before anything else on
|
// The TIN must resolve to a real eTrade record before anything else on
|
||||||
@@ -728,15 +857,40 @@ export default function CompanyProfileForm({
|
|||||||
setDocumentFieldErrors({
|
setDocumentFieldErrors({
|
||||||
[POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required",
|
[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(
|
setSaveError(
|
||||||
requirePoa
|
[
|
||||||
? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
|
requirePoa
|
||||||
: "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
|
? "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;
|
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.
|
// Field steps validate + save before advancing.
|
||||||
const ok = await saveCurrentStep();
|
const ok = await saveCurrentStep();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -812,6 +966,42 @@ export default function CompanyProfileForm({
|
|||||||
{...register("ownerPassportNumber")}
|
{...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>
|
</StepSection>
|
||||||
@@ -835,6 +1025,7 @@ export default function CompanyProfileForm({
|
|||||||
onDataLoaded={handleETradeDataLoaded}
|
onDataLoaded={handleETradeDataLoaded}
|
||||||
onStatusChange={setTinStatus}
|
onStatusChange={setTinStatus}
|
||||||
onReset={handleETradeReset}
|
onReset={handleETradeReset}
|
||||||
|
alreadyVerified={hasRegistrationDetails}
|
||||||
/>
|
/>
|
||||||
{tinVerified && (
|
{tinVerified && (
|
||||||
<ETradeCompanyCard
|
<ETradeCompanyCard
|
||||||
@@ -924,7 +1115,10 @@ export default function CompanyProfileForm({
|
|||||||
<Text fw={600} size="sm" c="edr-text">
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
Contact Person
|
Contact Person
|
||||||
</Text>
|
</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
|
<LinkCheckboxCard
|
||||||
checked={contactSameAsGm}
|
checked={contactSameAsGm}
|
||||||
onToggle={toggleContactSameAsGm}
|
onToggle={toggleContactSameAsGm}
|
||||||
@@ -983,21 +1177,51 @@ export default function CompanyProfileForm({
|
|||||||
required={requirePoa}
|
required={requirePoa}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* The address comes from the Fayda claim along with the name,
|
{/* A verified representative's details come from the Fayda claim
|
||||||
so it is shown on the panel rather than typed. Only a company
|
and are shown on the panel above. Where Fayda cannot be
|
||||||
whose representative may hold no Fayda ID still types it. */}
|
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 && (
|
{!identity?.poa.verified && !identity?.faydaRequired && (
|
||||||
<TextInput
|
<>
|
||||||
label="PoA Location"
|
<TextInput
|
||||||
placeholder="City, Country"
|
label="Representative's Name"
|
||||||
error={errors.poaLocation?.message}
|
placeholder="Abebe Bikila"
|
||||||
{...register("poaLocation")}
|
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
|
{/* The paper authorises the representative, so it shows once one
|
||||||
named, so it only has meaning once one exists. */}
|
exists — or straight away for a freight forwarder, who owes it
|
||||||
{poaProvided && poaDocumentSetting && (
|
either way and must not be failed on submit for a file the
|
||||||
|
step never offered. */}
|
||||||
|
{delegationRequired && poaDocumentSetting && (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import { ReadOnlyField } from "./ReadOnlyField";
|
|||||||
* supplied a value, but falls back to an editable input when eTrade left it
|
* 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
|
* blank — otherwise a gap in eTrade's own data would leave the field
|
||||||
* permanently empty and the user stuck (zod requires all of these).
|
* 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({
|
function LockedField({
|
||||||
label,
|
label,
|
||||||
@@ -32,7 +37,7 @@ function LockedField({
|
|||||||
errors: FieldErrors<FormData>;
|
errors: FieldErrors<FormData>;
|
||||||
}) {
|
}) {
|
||||||
const value = watch(name) as string | undefined;
|
const value = watch(name) as string | undefined;
|
||||||
if (value && value.trim()) {
|
if (value && value.trim() && !errors[name]) {
|
||||||
return <ReadOnlyField label={label} value={value} />;
|
return <ReadOnlyField label={label} value={value} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -96,7 +101,12 @@ export default function ETradeCompanyCard({
|
|||||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
<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} />
|
<ReadOnlyField label="Region" value={region} />
|
||||||
) : (
|
) : (
|
||||||
<Controller
|
<Controller
|
||||||
|
|||||||
@@ -1,8 +1,72 @@
|
|||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
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. */
|
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
||||||
export const phoneDigits = (p?: string | null) =>
|
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(
|
export function stepPayload(
|
||||||
step: CompanyStep,
|
step: CompanyStep,
|
||||||
d: FormData,
|
d: FormData,
|
||||||
|
dirty: Partial<Record<keyof FormData, unknown>> = {},
|
||||||
): Partial<UpdateProfilePayload> {
|
): Partial<UpdateProfilePayload> {
|
||||||
switch (step) {
|
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 {
|
return {
|
||||||
companyName: d.companyName,
|
|
||||||
companyEmail: d.companyEmail,
|
companyEmail: d.companyEmail,
|
||||||
companyPhone: d.companyPhone,
|
companyPhone: d.companyPhone,
|
||||||
companyAddress: d.companyAddress,
|
companyAddress: d.companyAddress,
|
||||||
tin: d.tinNumber,
|
|
||||||
vatNumber: d.vatNumber,
|
vatNumber: d.vatNumber,
|
||||||
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
||||||
licenceNumber: d.licenceNumber,
|
...etrade,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case "personnel":
|
case "personnel":
|
||||||
return {
|
return {
|
||||||
generalManagerName: d.generalManagerName,
|
generalManagerName: d.generalManagerName,
|
||||||
@@ -86,7 +151,12 @@ export function stepPayload(
|
|||||||
contactPersonPhone: d.contactPersonPhone,
|
contactPersonPhone: d.contactPersonPhone,
|
||||||
};
|
};
|
||||||
case "poa":
|
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:
|
default:
|
||||||
return {};
|
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.
|
// can diverge without the backend's eTrade-authenticity check misfiring.
|
||||||
etradePhone: z.string().optional(),
|
etradePhone: z.string().optional(),
|
||||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
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
|
vatNumber: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "VAT number is required")
|
.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
|
// The owner's passport number — the foreign-company identity credential
|
||||||
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
|
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
|
||||||
// enforced in buildOnboardingSchema since that depends on `nationality`.
|
// 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)[]> = {
|
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||||
company: [
|
company: [
|
||||||
"companyName",
|
"companyName",
|
||||||
"companyEmail",
|
|
||||||
"companyPhone",
|
|
||||||
"companyAddress",
|
|
||||||
"etradePhone",
|
|
||||||
"tinNumber",
|
"tinNumber",
|
||||||
"vatNumber",
|
"vatNumber",
|
||||||
"ownerPassportNumber",
|
"ownerPassportNumber",
|
||||||
"licenceNumber",
|
|
||||||
"statusDescription",
|
|
||||||
"dateRegistered",
|
|
||||||
"renewedFrom",
|
|
||||||
"renewalDate",
|
|
||||||
"renewedTo",
|
|
||||||
"region",
|
"region",
|
||||||
"zone",
|
"zone",
|
||||||
"woreda",
|
"woreda",
|
||||||
@@ -167,7 +193,11 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"contactPersonEmail",
|
"contactPersonEmail",
|
||||||
"contactPersonPhone",
|
"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: [],
|
documents: [],
|
||||||
additional: [],
|
additional: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type {
|
import type {
|
||||||
CompanyProfileInput,
|
CompanyProfileInput,
|
||||||
@@ -30,6 +30,12 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
|||||||
import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo";
|
import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo";
|
||||||
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
||||||
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
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({
|
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -43,12 +49,12 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
|||||||
// no standalone input.
|
// no standalone input.
|
||||||
companyAddress: z.string().optional(),
|
companyAddress: z.string().optional(),
|
||||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
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
|
vatNumber: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.min(1, "VAT number is required")
|
||||||
.max(20, "VAT number is too long")
|
.regex(/^\d{10}$/, "VAT number must be exactly 10 digits"),
|
||||||
.optional()
|
|
||||||
.or(z.literal("")),
|
|
||||||
ownerPassportNumber: z.string().optional(),
|
ownerPassportNumber: z.string().optional(),
|
||||||
// Registration/address fields are eTrade-sourced — locked once eTrade
|
// Registration/address fields are eTrade-sourced — locked once eTrade
|
||||||
// supplies a value, editable only as an escape hatch when it doesn't
|
// 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>;
|
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 = [
|
* `etradePhone` is not on this form, so the shared list is filtered down to the
|
||||||
"companyName",
|
* keys it actually holds. Source of truth: `companyProfileForm/schema.ts`.
|
||||||
"licenceNumber",
|
*/
|
||||||
"statusDescription",
|
const ETRADE_FIELDS = SHARED_ETRADE_FIELDS.filter(
|
||||||
"dateRegistered",
|
(k): k is Exclude<typeof k, "etradePhone"> => k !== "etradePhone",
|
||||||
"renewedFrom",
|
);
|
||||||
"renewalDate",
|
|
||||||
"renewedTo",
|
|
||||||
"region",
|
|
||||||
"zone",
|
|
||||||
"woreda",
|
|
||||||
"kebele",
|
|
||||||
"houseNo",
|
|
||||||
] as const satisfies readonly (keyof CompanyProfileFormData)[];
|
|
||||||
|
|
||||||
interface TabCompanyProfileProps {
|
interface TabCompanyProfileProps {
|
||||||
profile?: ProfileResponse;
|
profile?: ProfileResponse;
|
||||||
@@ -166,32 +164,39 @@ export default function TabCompanyProfile({
|
|||||||
values: defaultValues,
|
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;
|
const verifiedIdentity = identity?.faydaRequired === true;
|
||||||
|
|
||||||
// companyEmail/companyPhone are the owner's verified contact details, never
|
// companyEmail/companyPhone are the owner's verified contact details, never
|
||||||
// typed — same derivation as the onboarding wizard, just fed from the saved
|
// typed — same derivation as the onboarding wizard, just fed from the saved
|
||||||
// profile instead of an in-progress form.
|
// profile instead of an in-progress form. `firstValid*` rather than `??`:
|
||||||
useEffect(() => {
|
// these claims are optional AND unreliable — eTrade's registered phone is
|
||||||
if (!user) return;
|
// free text that arrives as things like "09 " — and `??` stops at the
|
||||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
// first non-null, so junk became a read-only field the customer could not
|
||||||
shouldValidate: true,
|
// fix and a 400 on save. When nothing usable can be derived the fields below
|
||||||
});
|
// become editable instead of blocking.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
const derivedEmail = firstValidEmail(identity?.owner.email, user?.email);
|
||||||
}, [identity?.owner.email, user?.email]);
|
const derivedPhone = firstValidPhone(
|
||||||
|
identity?.owner.phone,
|
||||||
|
profile?.etradePhone,
|
||||||
|
user?.phoneNumber,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (derivedEmail) setValue("companyEmail", derivedEmail);
|
||||||
setValue(
|
|
||||||
"companyPhone",
|
|
||||||
identity?.owner.phone ??
|
|
||||||
profile?.etradePhone ??
|
|
||||||
toEthiopianE164(user.phoneNumber) ??
|
|
||||||
"",
|
|
||||||
{ shouldValidate: true },
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// companyAddress is composed from the (locked) eTrade address parts, not
|
||||||
// typed directly.
|
// typed directly.
|
||||||
@@ -249,7 +254,7 @@ export default function TabCompanyProfile({
|
|||||||
// on every save would otherwise trigger the server's eTrade
|
// on every save would otherwise trigger the server's eTrade
|
||||||
// authenticity re-check for no reason.
|
// authenticity re-check for no reason.
|
||||||
const etradeBundle: Record<string, string | undefined> = {};
|
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[key]) etradeBundle[key] = data[key];
|
||||||
}
|
}
|
||||||
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
|
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
|
||||||
@@ -296,13 +301,32 @@ export default function TabCompanyProfile({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: CompanyProfileFormData) => {
|
const onSubmit = (data: CompanyProfileFormData) => {
|
||||||
|
setValidationError(null);
|
||||||
if (isCreate && selectedRoles.length === 0) return;
|
if (isCreate && selectedRoles.length === 0) return;
|
||||||
mutation.mutate(data);
|
mutation.mutate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveErrorMessage = mutation.isError
|
/**
|
||||||
? extractApiError(mutation.error).message
|
* Without this, a failed validation made "Save Changes" a no-op: the fields
|
||||||
: null;
|
* 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(
|
const pendingOwnerReview = Boolean(
|
||||||
(profile?.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
(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."}
|
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<StepSection
|
<StepSection
|
||||||
index={1}
|
index={1}
|
||||||
@@ -341,9 +365,9 @@ export default function TabCompanyProfile({
|
|||||||
status={watch("vatNumber") ? "done" : "todo"}
|
status={watch("vatNumber") ? "done" : "todo"}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="VAT Number (optional)"
|
label="VAT Number"
|
||||||
placeholder="e.g. 0012345678"
|
placeholder="e.g. 0012345678"
|
||||||
maxLength={20}
|
maxLength={10}
|
||||||
error={errors.vatNumber?.message}
|
error={errors.vatNumber?.message}
|
||||||
{...register("vatNumber")}
|
{...register("vatNumber")}
|
||||||
/>
|
/>
|
||||||
@@ -389,9 +413,33 @@ export default function TabCompanyProfile({
|
|||||||
{...register("ownerPassportNumber")}
|
{...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">
|
<SimpleGrid cols={2} spacing="md">
|
||||||
<ReadOnlyField label="Company email" value={watch("companyEmail")} />
|
{derivedEmail ? (
|
||||||
<ReadOnlyField label="Company phone" value={watch("companyPhone")} />
|
<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>
|
</SimpleGrid>
|
||||||
</StepSection>
|
</StepSection>
|
||||||
)}
|
)}
|
||||||
@@ -410,6 +458,7 @@ export default function TabCompanyProfile({
|
|||||||
error={errors.tinNumber?.message}
|
error={errors.tinNumber?.message}
|
||||||
onDataLoaded={handleETradeDataLoaded}
|
onDataLoaded={handleETradeDataLoaded}
|
||||||
onStatusChange={setTinStatus}
|
onStatusChange={setTinStatus}
|
||||||
|
alreadyVerified={hasRegistrationDetails}
|
||||||
/>
|
/>
|
||||||
{tinVerified && (
|
{tinVerified && (
|
||||||
<EtradeLockedCard
|
<EtradeLockedCard
|
||||||
@@ -520,7 +569,9 @@ function EtradeLockedCard({
|
|||||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
<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} />
|
<ReadOnlyField label="Region" value={region} />
|
||||||
) : (
|
) : (
|
||||||
<RegionSelect control={control} error={errors.region?.message} />
|
<RegionSelect control={control} error={errors.region?.message} />
|
||||||
@@ -548,7 +599,9 @@ function LockedField({
|
|||||||
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
||||||
}) {
|
}) {
|
||||||
const value = watch(name) as string | undefined;
|
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 <ReadOnlyField label={label} value={value} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ function humanizeApiMessage(raw: string): string {
|
|||||||
return raw;
|
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 {
|
export function extractApiError(err: unknown): ApiError {
|
||||||
if (err && typeof err === "object") {
|
if (err && typeof err === "object") {
|
||||||
const obj = err as Record<string, unknown>;
|
const obj = err as Record<string, unknown>;
|
||||||
@@ -41,13 +51,10 @@ export function extractApiError(err: unknown): ApiError {
|
|||||||
if (response) {
|
if (response) {
|
||||||
const statusCode = response.status as number | undefined;
|
const statusCode = response.status as number | undefined;
|
||||||
const data = response.data as Record<string, unknown> | undefined;
|
const data = response.data as Record<string, unknown> | undefined;
|
||||||
|
const raw = asMessage(data?.message) || asMessage(data?.error);
|
||||||
return {
|
return {
|
||||||
code: (data?.message as string) || (data?.error as string) || "api_error",
|
code: raw || "api_error",
|
||||||
message: humanizeApiMessage(
|
message: humanizeApiMessage(raw || "An unexpected error occurred"),
|
||||||
(data?.message as string) ||
|
|
||||||
(data?.error as string) ||
|
|
||||||
"An unexpected error occurred",
|
|
||||||
),
|
|
||||||
statusCode,
|
statusCode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user