fix(portal): bring the settings tabs in line with the wizard's rules

The Company, Owner, Contact and Power of Attorney tabs had drifted from the
onboarding wizard while the manual-registration routes were added, and each
divergence locked somebody out of their own account:

- VAT was validated as 10-or-11 digits here and by onboarding's rule there. A
  foreign company's VAT is its own tax authority's and a co-operative's follows
  neither, so the stricter copy failed every save on the tab, not just the VAT
  field. The schema now reuses onboarding's, with a test pinning both rules.
- Fayda's phone claims are normalized on read, as the wizard does — the
  registry holds local numbers that neither the form nor the API's
  @IsValidPhone() accepts.
- The eTrade manager is shown read-only, because that record is exactly what
  the backoffice checks the company against; leaving it editable let a customer
  overwrite the value under review.
- A co-operative's per-role licence cards are hidden on the Documents tab. The
  API lifts the requirement, so the slots could never be filled or cleared.
This commit is contained in:
Nathnael
2026-08-18 11:37:58 +00:00
parent 431bd3c98b
commit 0608f8a7ea
6 changed files with 560 additions and 91 deletions

View File

@@ -11,6 +11,7 @@ import {
Button,
Card,
Group,
Select,
SimpleGrid,
Stack,
Text,
@@ -20,18 +21,22 @@ import {
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { FieldErrors, UseFormRegister, UseFormSetValue } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { CompanyRegistrationData } from "@edr/types";
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
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 {
ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS,
onboardingSchema,
} from "@/pages/accounts/companyProfileForm/schema";
export const COMPANY_PROFILE_SCHEMA = z.object({
const BASE_COMPANY_PROFILE_SCHEMA = z.object({
// eTrade-sourced and read-only, like the registration block below.
companyName: z.string().optional(),
companyLocation: z.string().min(1, "Location is required"),
@@ -39,12 +44,13 @@ 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()
.min(1, "VAT number is required")
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
// Onboarding's own rule, reused rather than restated: the two forms write the
// same column, and the copy here had drifted into a 10-or-11-digit check that
// onboarding and the API both refuse to make. A foreign company's VAT is
// whatever its tax authority issues and a co-operative's follows neither, so
// the stricter copy locked those customers out of their own Company tab
// entirely — every save on it, not just the VAT.
vatNumber: onboardingSchema.shape.vatNumber,
ownerPassportNumber: z.string().optional(),
// Registration/address fields are eTrade-sourced and never typed by hand —
// not even when eTrade leaves one blank, so none of them may be required
@@ -62,7 +68,48 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
houseNo: z.string().optional(),
});
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
/**
* The registration fields a manual-registration company types by hand.
*
* eTrade holds no record for a co-operative union or farm, nor for a foreign
* investor on an Investment Commission licence, so the block every other
* company gets read-only off the licence is typed by these two — and is
* therefore required of them, exactly as the onboarding wizard requires it.
* House number stays optional: plenty of addresses have none.
*/
const TYPED_REGISTRATION_LABELS = {
companyName: "Company name",
region: "Region",
zone: "Zone",
woreda: "Woreda",
kebele: "Kebele",
} as const;
/**
* `manualRegistration` is the only thing that changes here, and it changes the
* same way it does in the wizard: **a field is required iff there is an input
* on screen for it.** For an eTrade company these are read-only rows, so
* requiring one would be a Save button failing on a field with nothing to fix.
*/
export const buildCompanyProfileSchema = (manualRegistration: boolean) =>
manualRegistration
? BASE_COMPANY_PROFILE_SCHEMA.superRefine((d, ctx) => {
for (const key of Object.keys(
TYPED_REGISTRATION_LABELS,
) as (keyof typeof TYPED_REGISTRATION_LABELS)[]) {
if (d[key]?.trim()) continue;
ctx.addIssue({
code: "custom",
path: [key],
message: `${TYPED_REGISTRATION_LABELS[key]} is required`,
});
}
})
: BASE_COMPANY_PROFILE_SCHEMA;
export type CompanyProfileFormData = z.infer<
typeof BASE_COMPANY_PROFILE_SCHEMA
>;
/**
* `etradePhone` is not on this form, so the shared list is filtered down to the
@@ -86,6 +133,17 @@ export default function TabCompanyProfile({
}: TabCompanyProfileProps) {
const queryClient = useQueryClient();
const isCreate = mode === "create";
/**
* eTrade has nothing to say about this company — a co-operative holds no
* business licence, a foreign investor's is the Investment Commission's — so
* its registration was typed during onboarding and has to stay editable here.
* Rendering the eTrade card instead left that data invisible and frozen: the
* one company that owns its registration details was the one that could not
* see them.
*/
const manualRegistration = Boolean(
profile?.cooperative || profile?.investorLicence,
);
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
@@ -140,7 +198,7 @@ export default function TabCompanyProfile({
setValue,
formState: { errors, isDirty, dirtyFields },
} = useForm<CompanyProfileFormData>({
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
resolver: zodResolver(buildCompanyProfileSchema(manualRegistration)),
values: defaultValues,
});
@@ -315,7 +373,6 @@ export default function TabCompanyProfile({
<TextInput
label="VAT Number"
placeholder="e.g. 0012345678"
maxLength={11}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
@@ -325,13 +382,21 @@ export default function TabCompanyProfile({
<StepSection
index={2}
title="Company TIN"
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
subtitle={
manualRegistration
? "We'll check eTrade for your TIN. If it holds nothing — the usual case here — the details below stay yours to state."
: "Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
}
status={
tinVerified
? "done"
: tinStatus === "taken"
? "blocked"
: "todo"
tinStatus === "taken"
? "blocked"
: manualRegistration
? watch("tinNumber")?.trim() && !errors.tinNumber
? "done"
: "todo"
: tinVerified
? "done"
: "todo"
}
>
<ETradeInfo
@@ -342,12 +407,33 @@ export default function TabCompanyProfile({
onStatusChange={setTinStatus}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
registrationOptional={manualRegistration}
/>
{tinVerified && (
{!manualRegistration && tinVerified && (
<EtradeLockedCard tin={watch("tinNumber")} watch={watch} />
)}
</StepSection>
{manualRegistration && (
<StepSection
index={3}
title="Registration details"
subtitle="Everything we'd normally read off an eTrade licence. Our team checks what you state here against the documents you upload."
status={
watch("companyName")?.trim() && watch("region")?.trim()
? "done"
: "todo"
}
>
<TypedRegistrationFields
register={register}
watch={watch}
setValue={setValue}
errors={errors}
/>
</StepSection>
)}
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
@@ -409,6 +495,86 @@ export default function TabCompanyProfile({
);
}
/**
* The registration a manual-registration company states for itself.
*
* Deliberately editable, unlike `EtradeLockedCard` below: nothing here came
* from a licence, so there is no verified record to protect — it is the
* customer's own claim, checked by a reviewer against the papers they upload.
* Same fields, same rules and same region list as the onboarding wizard's
* company step, so a co-operative or investor sees one story in both places.
*/
function TypedRegistrationFields({
register,
watch,
setValue,
errors,
}: {
register: UseFormRegister<CompanyProfileFormData>;
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
setValue: UseFormSetValue<CompanyProfileFormData>;
errors: FieldErrors<CompanyProfileFormData>;
}) {
const region = watch("region") ?? "";
return (
<Stack gap="md">
<TextInput
label="Company Name"
placeholder="Registered name of the company"
error={errors.companyName?.message}
{...register("companyName")}
/>
<Text size="sm" c="edr-muted">
Registered address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Select
label="Region"
placeholder="Select region"
// A value eTrade (or an earlier save) supplied may not be spelled the
// way our list spells it. Carrying it in as an option keeps it visible
// rather than silently blanking a field nobody touched.
data={
region && !(ETHIOPIAN_REGIONS as readonly string[]).includes(region)
? [...ETHIOPIAN_REGIONS, region]
: [...ETHIOPIAN_REGIONS]
}
searchable
value={region || null}
onChange={(v) =>
setValue("region", v ?? "", {
shouldValidate: true,
shouldDirty: true,
})
}
error={errors.region?.message}
/>
<TextInput
label="Zone"
error={errors.zone?.message}
{...register("zone")}
/>
<TextInput
label="Woreda"
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
error={errors.kebele?.message}
{...register("kebele")}
/>
<TextInput
label="House No."
error={errors.houseNo?.message}
{...register("houseNo")}
/>
</SimpleGrid>
</Stack>
);
}
/**
* The verified eTrade record, rendered strictly read-only — same rule as
* onboarding's ETradeCompanyCard: nothing here is typeable, not even a field

View File

@@ -5,20 +5,30 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { CheckCircle2, Save, User, XCircle } from "lucide-react";
import {
Button,
Card,
Group,
SimpleGrid,
Stack,
Title,
Text,
TextInput,
Button,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile";
// The same four fields onboarding collects, with the same requiredness. The
// position and the email were captured by the wizard and then had no input
// here at all — stored, invisible, and impossible to correct.
const schema = z.object({
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPosition: z.string().optional(),
contactPersonEmail: z
.string()
.email("Invalid email address")
.optional()
.or(z.literal("")),
contactPersonPhone: z
.string()
.min(1, "Contact person phone is required")
@@ -39,6 +49,8 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
const defaultValues = useMemo((): FormData => {
return {
contactPersonName: profile.contactPersonName ?? "",
contactPersonPosition: profile.contactPersonPosition ?? "",
contactPersonEmail: profile.contactPersonEmail ?? "",
contactPersonPhone: profile.contactPersonPhone ?? "",
};
}, [profile]);
@@ -58,6 +70,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
contactPersonName: data.contactPersonName,
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
// and undefined, so an empty string is validated and 400s with
// "contactPersonEmail must be an email".
contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone,
}),
onSuccess: () => {
@@ -80,19 +97,36 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Full Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label="Full Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<TextInput
label="Position (Optional)"
placeholder="Operations Lead"
error={errors.contactPersonPosition?.message}
{...register("contactPersonPosition")}
/>
</SimpleGrid>
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Phone Number"
required
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label="Email (Optional)"
type="email"
placeholder="contact@company.com"
error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")}
/>
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Phone Number"
required
/>
</SimpleGrid>
</Stack>
<Group

View File

@@ -171,7 +171,11 @@ export default function TabDocuments({
return errs;
};
const licenseProfiles = profile.companyProfiles;
// A co-operative union or farm holds no business licence — that is the whole
// reason it uploads its own document set instead — so the API lifts the
// per-role requirement and these cards are not shown. Offering an upload slot
// nothing can ever fill reads as an outstanding task that cannot be finished.
const licenseProfiles = profile.cooperative ? [] : profile.companyProfiles;
// Company documents render through SmartFileInput, which is keyed by field and
// has no per-file review slot. Surfacing the outstanding corrections as one

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -19,6 +20,11 @@ import { api } from "@/services/api";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField";
import {
firstValidPhone,
normalizeIdentityPhones,
resolveOwnerSources,
} from "@/pages/accounts/companyProfileForm/helpers";
import type { ProfileResponse } from "@/types/profile";
// Optional, not unrequired: whatever a Fayda verification supplied is owned by
@@ -27,6 +33,10 @@ import type { ProfileResponse } from "@/types/profile";
// are actually on screen; zod only polices format.
const schema = z.object({
ownerName: z.string().optional(),
// The alternative credential for a foreign company's owner — asked for only
// when they are the identity subject and have not verified with Fayda, so
// like the three below it is optional here and gated on what is rendered.
ownerPassportNumber: z.string().optional(),
ownerEmail: z
.string()
.optional()
@@ -64,20 +74,41 @@ export default function TabOwner({
onContinue,
}: TabOwnerProps) {
const queryClient = useQueryClient();
const identity = profile.identity;
// Fayda reports a phone as the national registry holds it, routinely a local
// number that neither this form nor the API's `@IsValidPhone()` accepts.
// Normalize once on read, exactly as the wizard does.
const identity = useMemo(
() => normalizeIdentityPhones(profile.identity),
[profile.identity],
);
const owner = identity?.owner;
// Only the person the declaration points at carries the verification, so the
// panel is offered here only when that person is the owner.
const ownerIsSubject = identity?.subject === "owner";
// A Fayda verification owns what its claims filled — the API refuses to
// overwrite those, so they show read-only. Anything it left blank stays
// editable here, whatever value is currently stored.
const ownerVerified = owner?.verified ?? false;
const ownerLocked = {
name: ownerVerified && Boolean(owner?.name?.trim()),
email: ownerVerified && Boolean(owner?.email?.trim()),
phone: ownerVerified && Boolean(owner?.phone?.trim()),
};
/**
* The manager the eTrade licence names, read back from what the lookup
* captured. The wizard shows these read-only for the same reason this tab
* must: that record is what the backoffice checks the company against, so it
* is reported, not retyped. Leaving it editable here let a customer overwrite
* the very value the review compares against.
*/
const etradeOwner = useMemo(() => {
const name = identity?.etradeManagerName?.trim() ?? "";
// Dropped if it cannot normalize — eTrade's is free text ("09 " is a
// real answer), and an unusable number must fall through to an input rather
// than lock the field behind a value the API would reject.
const phone = firstValidPhone(identity?.etradeManagerPhone);
return name || phone ? { name, phone } : null;
}, [identity?.etradeManagerName, identity?.etradeManagerPhone]);
// Who owns each field, and with what value — the wizard's own rule, reused so
// the two cannot disagree about which details are the customer's to edit.
const { source: ownerSource, sourced: ownerSourced } = resolveOwnerSources(
identity,
etradeOwner,
);
const {
register,
@@ -90,19 +121,23 @@ export default function TabOwner({
ownerName: profile.ownerName ?? "",
ownerEmail: profile.ownerEmail ?? "",
ownerPhone: profile.ownerPhone ?? "",
ownerPassportNumber: profile.identity?.owner.passportNumber ?? "",
},
});
const mutation = useMutation({
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
// and undefined, so an empty string is validated and 400s with
// "ownerEmail must be an email". A verified owner legitimately leaves
// the fields Fayda did supply blank here.
ownerName: data.ownerName || undefined,
ownerEmail: data.ownerEmail || undefined,
ownerPhone: data.ownerPhone || undefined,
// A field a source owns is submitted as that source has it, not as the
// form happens to hold it: the read-only row is what the customer was
// shown, and only the fields are sent. `|| undefined`, never "": the
// DTO's `@IsOptional()` skips null and undefined, so an empty string is
// validated and 400s with "ownerEmail must be an email", and a verified
// owner legitimately leaves the fields Fayda supplied blank here.
ownerName: (ownerSourced.name || data.ownerName) || undefined,
ownerEmail: (ownerSourced.email || data.ownerEmail) || undefined,
ownerPhone: (ownerSourced.phone || data.ownerPhone) || undefined,
ownerPassportNumber: data.ownerPassportNumber || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({
@@ -112,15 +147,21 @@ export default function TabOwner({
},
});
// What the verification did NOT supply. Fayda's email and phone claims are
// optional, so a verified owner can still be missing details the API demands
// — the API leaves exactly those keys typeable, and so does this form.
const gaps = {
name: !owner?.name?.trim(),
email: !owner?.email?.trim(),
phone: !owner?.phone?.trim(),
};
const savable = gaps.name || gaps.email || gaps.phone;
// A foreign owner who is the identity subject and has not verified proves
// themselves with a passport instead — the same either/or the wizard offers.
// Without an input here the only route to it was the onboarding wizard, which
// an onboarded company can no longer reach.
const passportAskable =
(identity?.passportAccepted ?? false) && ownerIsSubject && !ownerVerified;
// Save is offered when there is at least one input on screen to save. A field
// an outside source owns has none, so an owner fully supplied by Fayda and
// eTrade has nothing to submit.
const savable =
!ownerSource.name ||
!ownerSource.email ||
!ownerSource.phone ||
passportAskable;
return (
<Card padding="lg">
@@ -158,8 +199,8 @@ export default function TabOwner({
<SourcedField
label="Name"
value={owner?.name}
source={ownerLocked.name ? "Fayda" : null}
value={ownerSourced.name}
source={ownerSource.name}
>
<TextInput
label="Name"
@@ -173,8 +214,8 @@ export default function TabOwner({
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Email"
value={owner?.email}
source={ownerLocked.email ? "Fayda" : null}
value={ownerSourced.email}
source={ownerSource.email}
>
<TextInput
label="Email"
@@ -188,8 +229,8 @@ export default function TabOwner({
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Phone"
value={owner?.phone}
source={ownerLocked.phone ? "Fayda" : null}
value={ownerSourced.phone}
source={ownerSource.phone}
>
<ControlledPhoneField
control={control}
@@ -200,6 +241,16 @@ export default function TabOwner({
</Grid.Col>
</Grid>
{passportAskable && (
<TextInput
label="Owner's Passport Number"
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
placeholder="P1234567"
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
{savable && (
<Group justify="flex-end">
<Button

View File

@@ -42,18 +42,68 @@ import {
type LicenseFileStatus,
} from "@/services/companies.service";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import {
firstValidEmail,
firstValidPhone,
normalizeIdentityPhones,
} from "@/pages/accounts/companyProfileForm/helpers";
import { verifaydaService } from "@/services/verifayda.service";
import RoleCard from "@/pages/settings/RoleCard";
import type { ProfileResponse } from "@/types/profile";
// The representative's name, email, phone and address all come from their
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
// is the only detail this form owns.
const schema = z.object({
poaLocation: z.string().optional(),
});
/** The representative's details that a Fayda verification may or may not own. */
const POA_LABELS = {
poaName: "Representative's name",
poaEmail: "Representative's email",
poaPhone: "Representative's phone",
} as const;
type FormData = z.infer<typeof schema>;
type PoaField = keyof typeof POA_LABELS;
/**
* The representative's details.
*
* Optional here, not unrequired. Whatever their Fayda verification supplied is
* owned by the API and shown read-only, so a blanket `min(1)` would fail a form
* that is correct — but Fayda's email and phone claims are optional and
* routinely come back empty, and the API's own `REQUIRED_POA_FIELDS` demands
* all three once a representative is declared. So requiredness is decided per
* render, exactly as the wizard decides it: **a field is required iff there is
* an input on screen for it.** This form used to assume the verification always
* supplied everything and rendered no inputs at all, which left a company
* reported incomplete with nowhere to fix it.
*/
const buildSchema = (required: readonly PoaField[]) =>
z
.object({
poaName: z.string().optional(),
poaEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid email address",
),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaPassportNumber: z.string().optional(),
poaLocation: z.string().optional(),
})
.superRefine((d, ctx) => {
for (const key of required) {
if (d[key]?.trim()) continue;
ctx.addIssue({
code: "custom",
path: [key],
message: `${POA_LABELS[key]} is required`,
});
}
});
type FormData = z.infer<ReturnType<typeof buildSchema>>;
const LETTER_ACCEPT = ".pdf,.png,.jpg,.jpeg";
@@ -96,18 +146,63 @@ export default function TabPowerOfAttorney({
const { view, viewer } = useFileViewer();
const uploadInputRef = useRef<HTMLInputElement>(null);
// Fayda holds a phone as the national registry does, often a local number the
// form's E.164 validation would reject. Normalize on read, as the wizard does.
const identity = useMemo(
() => normalizeIdentityPhones(profile.identity),
[profile.identity],
);
const poa = identity?.poa;
const poaVerified = poa?.verified ?? false;
/**
* Which details the verification owns. Same test as the wizard's: presence is
* not enough for an email or a phone, because Fayda's claims are free text and
* one the schema would reject is not a claim an input can be hidden behind.
*/
const locked = {
name: poaVerified && Boolean(poa?.name?.trim()),
email: poaVerified && Boolean(firstValidEmail(poa?.email)),
phone: poaVerified && Boolean(firstValidPhone(poa?.phone)),
};
const declaredYes = (identity?.poaDeclared ?? null) === "yes";
const passportAccepted = identity?.passportAccepted ?? false;
/**
* The person exists to describe. Before a verification lands there is nothing
* to attach details to — and asking for a name the verification is about to
* overwrite is the trap the wizard avoids by the same rule. A foreign company
* whose representative may hold no Fayda ID is the exception: the passport is
* the proof, so its details are typed from the start.
*/
const established = declaredYes && (poaVerified || passportAccepted);
const requiredPoaFields: PoaField[] = [];
if (established) {
if (!locked.name) requiredPoaFields.push("poaName");
if (!locked.email) requiredPoaFields.push("poaEmail");
if (!locked.phone) requiredPoaFields.push("poaPhone");
}
const defaultValues = useMemo(
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
(): FormData => ({
poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "",
poaPhone: profile.poaPhone ?? "",
poaPassportNumber: profile.identity?.poa.passportNumber ?? "",
poaLocation: profile.poaLocation ?? "",
}),
[profile],
);
const {
register,
control,
handleSubmit,
reset,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
resolver: zodResolver(buildSchema(requiredPoaFields)),
values: defaultValues,
});
@@ -133,12 +228,6 @@ export default function TabPowerOfAttorney({
const requirePoa = profile.companyProfiles.some(
(p) => p.type === "freight_forwarder",
);
// No company types its representative's details — they come from the Fayda
// verification whatever the nationality, since a representative acts for the
// company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified.
const identity = profile.identity;
const poaProvided = identity?.poa.verified ?? false;
// Whether there is a representative at all is the company's own declaration,
// held server-side — it decides whose identity the API gates on, so it is
// never local state here.
@@ -155,9 +244,17 @@ export default function TabPowerOfAttorney({
const mutation = useMutation({
mutationFn: async (data: FormData) => {
// Every identity field except the city is written by the verification, so
// only the paper and the location are ever saved here.
const fields = { poaLocation: data.poaLocation || undefined };
// Whatever the verification did NOT supply is this form's to save, plus
// the paper. A field it owns is legitimately blank here (there is no
// input), and `|| undefined` keeps that blank out of the payload — the
// DTO's `@IsOptional()` skips null and undefined, never "".
const fields = {
poaName: data.poaName || undefined,
poaEmail: data.poaEmail || undefined,
poaPhone: data.poaPhone || undefined,
poaPassportNumber: data.poaPassportNumber || undefined,
poaLocation: data.poaLocation || undefined,
};
// A fresh upload already stages the removal of every paper on file, so
// the explicit removals only need applying when no replacement was
// picked. Saving the details after it means the API sees the new paper.
@@ -355,15 +452,62 @@ export default function TabPowerOfAttorney({
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
{/* Name, email, phone and address all come from the Fayda
verification and are shown on the panel above. Only a company
whose representative may hold no Fayda ID still types a
location. */}
{declared === "yes" && !poaProvided && (
<Grid>
<Grid.Col span={6}>
{/* Whatever the verification supplied is on the panel above; only
what it left blank is asked for here. A foreign representative
who holds no Fayda ID proves themselves by passport instead —
the same either/or the onboarding wizard offers, which an
onboarded company can no longer reach. */}
{established && passportAccepted && !poaVerified && (
<TextInput
label="Representative's Passport Number"
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
placeholder="P1234567"
error={errors.poaPassportNumber?.message}
{...register("poaPassportNumber")}
/>
)}
{established && !locked.name && (
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
)}
{established && (!locked.email || !locked.phone) && (
<SimpleGrid
cols={{ base: 1, sm: !locked.email && !locked.phone ? 2 : 1 }}
spacing="md"
>
{!locked.email && (
<TextInput
label="PoA Location"
label="Representative's Email"
type="email"
placeholder="representative@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
)}
{!locked.phone && (
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
)}
</SimpleGrid>
)}
{/* The company's own statement of where the representative is
based — never Fayda's `poaAddress`, which the portal does not
send — so it stays typeable however well Fayda knows them. */}
{established && (
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<TextInput
label="Representative's Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { buildCompanyProfileSchema } from "./TabCompanyProfile";
/**
* The two rules this schema exists to keep in step with onboarding: what counts
* as a VAT number, and who has to type their registration.
*/
const base = {
companyName: "Acme plc",
companyLocation: "Addis Ababa",
companyAddress: "",
tinNumber: "0012345678",
vatNumber: "0012345678",
region: "Addis Ababa",
zone: "Bole",
woreda: "03",
kebele: "07",
houseNo: "",
};
describe("buildCompanyProfileSchema", () => {
it("accepts a VAT number no Ethiopian format rule would allow", () => {
// The whole point of reusing onboarding's rule: a foreign company's VAT is
// its own tax authority's, and the stricter copy locked it out of the tab.
const parsed = buildCompanyProfileSchema(false).safeParse({
...base,
vatNumber: "GB123456789",
});
expect(parsed.success).toBe(true);
});
it("still requires a VAT number", () => {
const parsed = buildCompanyProfileSchema(false).safeParse({
...base,
vatNumber: "",
});
expect(parsed.success).toBe(false);
});
it("does not require the registration block off an eTrade company", () => {
// It is read-only for them, so requiring it would fail a save on a field
// with no input to fix it.
const parsed = buildCompanyProfileSchema(false).safeParse({
...base,
companyName: "",
region: "",
zone: "",
woreda: "",
kebele: "",
});
expect(parsed.success).toBe(true);
});
it("requires it of a company that types it by hand", () => {
const parsed = buildCompanyProfileSchema(true).safeParse({
...base,
region: "",
});
expect(parsed.success).toBe(false);
expect(parsed.error?.issues.map((i) => i.path[0])).toContain("region");
});
it("leaves the house number optional either way", () => {
expect(
buildCompanyProfileSchema(true).safeParse({ ...base, houseNo: "" })
.success,
).toBe(true);
});
});