refactor(api): extract self-service attributes for immediate application and poa verification improvement

This commit is contained in:
Nathnael
2026-07-31 07:38:49 +00:00
parent 378b9b6b21
commit 5a02da91d2
9 changed files with 237 additions and 333 deletions

View File

@@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
* come from the verified payload, not typed. Fayda's userinfo carries no * come from the verified payload, not typed. Fayda's userinfo carries no
* national ID number, so none is collected or derived here. * national ID number, so none is collected or derived here.
* *
* - Ethiopian company: the owner (and its PoA, once named) is verified through * Only the OWNER's credential varies by nationality:
* Fayda, and their details can't be edited afterwards. * - Ethiopian company: the owner is verified through Fayda.
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead * - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
* supplies a typed passport number — required on its own, whether or not the * supplies a typed passport number — required on its own, whether or not the
* owner also completes a (purely optional) Fayda verification. * owner also completes a (purely optional) Fayda verification.
* *
* The PoA does not vary. A representative acts for the company inside Ethiopia
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
* is named, both nationalities must verify them, and their details come from
* the verified payload rather than the form.
*
* The owner is NOT the general manager — GM is a separate, plain typed role * The owner is NOT the general manager — GM is a separate, plain typed role
* the portal offers a "same as owner" copy for, but it is never itself * the portal offers a "same as owner" copy for, but it is never itself
* Fayda-verified or gated on. * Fayda-verified or gated on.
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
}, },
changeRequestRepo: { changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () => null), findPendingByCompanyId: jest.fn(async () => null),
findLatestOpenByCompanyId: jest.fn(async () => null),
findByCompanyId: jest.fn(async () => []), findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({ create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1", id: "cr-1",
@@ -229,9 +235,27 @@ describe("Fayda identity verification binds a person to the company", () => {
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
it("stages the change for review on an approved company", async () => { it("stages an owner re-verification for review on an approved company", async () => {
// Swapping the person who can act for a live company is exactly what the // The owner is the live company's identity proof, so re-verifying one is
// backoffice review exists for, so it must not rewrite the row directly. // exactly what the backoffice review exists for: it must not rewrite the
// row directly.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
});
it("applies a PoA verification live on an approved company", async () => {
// The PoA is personnel the company names for itself — the delegation paper
// is what a reviewer actually judges — so it does not go to review.
const { service, ctx, deps } = makeService({ const { service, ctx, deps } = makeService({
status: CompanyStatus.Active, status: CompanyStatus.Active,
}); });
@@ -242,8 +266,8 @@ describe("Fayda identity verification binds a person to the company", () => {
state: "s", state: "s",
}); });
expect(deps.changeRequestRepo.create).toHaveBeenCalled(); expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBeUndefined(); expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
}); });
it("refuses to rename a verified person by hand", async () => { it("refuses to rename a verified person by hand", async () => {
@@ -367,7 +391,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => { it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
ownerPassportNumber: "P1234567",
...POA_VERIFIED,
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("still requires a Fayda-verified PoA from a foreign company", async () => {
// The owner's credential is nationality-specific; the representative's is
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
// typed foreign name is not a representative the platform can accept.
const { service } = makeService({ const { service } = makeService({
profileTypes: [ProfileType.importer], profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign, nationality: CompanyNationality.Foreign,
@@ -385,7 +431,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
"user-1", "user-1",
ProfileType.freightForwarder, ProfileType.freightForwarder,
), ),
).resolves.toBeDefined(); ).rejects.toBeInstanceOf(BadRequestException);
}); });
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {

View File

@@ -81,6 +81,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
findPendingByCompanyId: jest.fn(async () => findPendingByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
), ),
findLatestOpenByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findByCompanyId: jest.fn(async () => []), findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({ create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1", id: "cr-1",

View File

@@ -81,6 +81,28 @@ const POA_ATTRIBUTES = [
"poaLocation", "poaLocation",
"poaAddress", "poaAddress",
] as const; ] as const;
/**
* Personnel an approved company maintains itself: its contact person, its
* general manager and its Power of Attorney. These name who to talk to, not
* what the company is allowed to do, so freezing the settings page until a
* reviewer gets to a new phone number costs more than it protects. They write
* straight to the live row even for an active company.
*
* The PoA's *delegation letter* is deliberately not here — the paper is the
* thing that actually evidences the delegation, so it still goes through
* review (see `uploadPoaDelegationLetter`), as does the owner's own identity.
*/
const SELF_SERVICE_ATTRIBUTES: readonly string[] = [
"contactPersonName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
"contactVerifiedPhone",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
...POA_ATTRIBUTES,
];
/** Mandatory once the company operates as a freight forwarder. */ /** Mandatory once the company operates as a freight forwarder. */
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaName", label: "PoA name" }, { key: "poaName", label: "PoA name" },
@@ -844,10 +866,11 @@ export class CompaniesService {
* *
* - Company not yet approved (onboarding) → write straight to the Company row, * - Company not yet approved (onboarding) → write straight to the Company row,
* as before. The company/role pending→approve gate already covers first-run. * as before. The company/role pending→approve gate already covers first-run.
* - Company already `active` → do NOT touch the live Company. Stage the edit in * - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
* a pending change request (merging into any open one) so a backoffice * still write straight through; everything else does NOT touch the live
* reviewer can approve (apply) or reject (with a note). This locks the * Company but is staged in a pending change request (merging into any open
* customer until the review resolves. * one) so a backoffice reviewer can approve (apply) or reject (with a
* note). Only the staged half locks the customer until the review resolves.
*/ */
async updateProfile( async updateProfile(
userId: string, userId: string,
@@ -882,9 +905,37 @@ export class CompaniesService {
return new ProfileResponseDto(profile, updated); return new ProfileResponseDto(profile, updated);
} }
// Approved company: stage the change for review, leaving the live row intact. // Approved company: personnel details apply immediately, the rest is staged
// for review with the live row left intact.
await this.assertTinAvailable(company, dto.tin); await this.assertTinAvailable(company, dto.tin);
const fields = this.pickDefined(dto); const fields = this.pickDefined(dto);
const selfService: Record<string, any> = {};
const staged: Record<string, any> = {};
for (const [key, value] of Object.entries(fields)) {
if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value;
else staged[key] = value;
}
let live = company;
if (Object.keys(selfService).length > 0) {
live =
(await this.companiesRepo.update(
company.id,
this.mapProfileDtoToCompanyUpdates(company, selfService),
)) ?? company;
live.companyProfiles = company.companyProfiles;
}
if (Object.keys(staged).length === 0) {
// Nothing a reviewer needs to see. Any request already open (a document
// upload, an owner verification) still surfaces so its banner survives —
// it just no longer gains fields it was never asked to review.
return new ProfileResponseDto(
profile,
live,
await this.changeRequestRepo.findLatestOpenByCompanyId(company.id),
);
}
const existing = await this.changeRequestRepo.findPendingByCompanyId( const existing = await this.changeRequestRepo.findPendingByCompanyId(
company.id, company.id,
@@ -894,7 +945,7 @@ export class CompaniesService {
if (existing) { if (existing) {
request = request =
(await this.changeRequestRepo.update(existing.id, { (await this.changeRequestRepo.update(existing.id, {
snapshot: { ...(existing.snapshot ?? {}), ...fields }, snapshot: { ...(existing.snapshot ?? {}), ...staged },
submittedBy: userId, submittedBy: userId,
submittedAt: now, submittedAt: now,
note: null, note: null,
@@ -910,7 +961,7 @@ export class CompaniesService {
); );
request = await this.changeRequestRepo.create({ request = await this.changeRequestRepo.create({
companyId: company.id, companyId: company.id,
snapshot: fields, snapshot: staged,
status: ChangeRequestStatus.Pending, status: ChangeRequestStatus.Pending,
submittedBy: userId, submittedBy: userId,
submittedAt: now, submittedAt: now,
@@ -922,8 +973,9 @@ export class CompaniesService {
); );
} }
// Live company is unchanged; surface the pending state for the settings page. // Only the personnel half (if any) landed; surface the pending state for
return new ProfileResponseDto(profile, company, request); // the settings page.
return new ProfileResponseDto(profile, live, request);
} }
/** List a company's change requests, newest first (backoffice review). */ /** List a company's change requests, newest first (backoffice review). */
@@ -1720,16 +1772,11 @@ export class CompaniesService {
const poaProvided = POA_ATTRIBUTES.some((k) => const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(), (company.attributes?.[k] as string | undefined)?.trim(),
); );
// An Ethiopian company does not type its PoA details at all — they arrive // No company types its PoA details — they arrive from the Fayda
// from the Fayda verification — so reporting them as missing fields would // verification whatever the nationality — so reporting them as missing
// ask for something the form no longer offers. The identity block below // fields would ask for something no form offers. The identity block below
// reports "verify your PoA" instead. // reports "verify your PoA" instead.
const missingPoaFields = const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
const delegation = await this.getPoaDelegationState(company.id); const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided; const delegationDue = poaRequired || poaProvided;
const missingDelegation = delegationDue && !delegation.onFile; const missingDelegation = delegationDue && !delegation.onFile;
@@ -1754,9 +1801,7 @@ 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"]
: []), : []),
...(identity.faydaRequired && ...((poaRequired || poaProvided) && !identity.poa.verified
(poaRequired || poaProvided) &&
!identity.poa.verified
? ["Verify your Power of Attorney's identity with Fayda"] ? ["Verify your Power of Attorney's identity with Fayda"]
: []), : []),
...(identity.passportRequired && !identity.owner.passportNumber ...(identity.passportRequired && !identity.owner.passportNumber
@@ -1768,26 +1813,20 @@ export class CompaniesService {
// fields, required documents, one license per operational profile, and the // fields, required documents, one license per operational profile, and the
// PoA details/paper whenever those are mandatory. // PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length; const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount = const poaItemCount = delegationDue ? 1 : 0;
(poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.length
: 0) + (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), the PoA once there // always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// is one and Fayda is what's mandatory here. // there is one — that one is Fayda whatever the nationality.
const identityItemCount = identity.faydaRequired const ownerCredentialDue =
? delegationDue identity.faydaRequired || identity.passportRequired;
? 2 const ownerCredentialProven = identity.faydaRequired
: 1 ? identity.owner.verified
: identity.passportRequired : Boolean(identity.owner.passportNumber);
? 1 const identityItemCount =
: 0; (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
const missingIdentityCount = identity.faydaRequired const missingIdentityCount =
? (identity.owner.verified ? 0 : 1) + (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
(delegationDue && !identity.poa.verified ? 1 : 0) (delegationDue && !identity.poa.verified ? 1 : 0);
: identity.passportRequired && !identity.owner.passportNumber
? 1
: 0;
const total = const total =
requiredInfo.length + requiredInfo.length +
requiredDocCount + requiredDocCount +
@@ -2453,11 +2492,13 @@ export class CompaniesService {
...(result.address ? { [`${prefix}Address`]: result.address } : {}), ...(result.address ? { [`${prefix}Address`]: result.address } : {}),
}; };
// An approved company's profile edits are staged for backoffice review, and // An approved company's *owner* is its identity proof, so re-verifying one
// swapping the person who can act for the company is exactly the kind of // is staged for backoffice review rather than quietly rewriting a live
// edit that review exists for — so a verification lands the same way an // record. The PoA is personnel — the company names its own representative,
// ordinary edit does, rather than quietly rewriting a live record. // and the delegation letter backing them is what the reviewer sees — so a
if (company.status === CompanyStatus.Active) { // PoA verification lands live, matching the typed PoA fields in
// `SELF_SERVICE_ATTRIBUTES`.
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
await this.stageIdentityChange(company, userId, identity); await this.stageIdentityChange(company, userId, identity);
return this.getCompanyIdentityState(company); return this.getCompanyIdentityState(company);
} }
@@ -2586,21 +2627,23 @@ export class CompaniesService {
): void { ): void {
const state = buildCompanyIdentityState(company); const state = buildCompanyIdentityState(company);
// Only the owner's credential is nationality-specific: Fayda for an
// Ethiopian company, a typed passport number for a foreign one.
if (state.passportRequired) { if (state.passportRequired) {
if (!state.owner.passportNumber) { if (!state.owner.passportNumber) {
throw new BadRequestException( throw new BadRequestException(
"Add the company owner's passport number before continuing.", "Add the company owner's passport number before continuing.",
); );
} }
return; } else if (!state.owner.verified) {
}
if (!state.owner.verified) {
throw new BadRequestException( throw new BadRequestException(
"Verify the company owner's identity with Fayda before continuing.", "Verify the company owner's identity with Fayda before continuing.",
); );
} }
// The representative is not. A PoA acts for the company inside Ethiopia
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
// a foreign company nominates one rather than typing a name.
const poaNamed = POA_ATTRIBUTES.some((k) => const poaNamed = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(), (company.attributes?.[k] as string | undefined)?.trim(),
); );

View File

@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
(p) => p.type === ProfileType.freightForwarder, (p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const complete = faydaRequired // Only the *owner's* credential is nationality-specific. A Power of Attorney
? owner.verified && (!poaDue || poa.verified) // acts for the company inside Ethiopia whoever owns it, so the PoA is always
// proven with Fayda — a foreign company nominates a representative who holds
// one rather than typing a name nothing backs.
const ownerProven = faydaRequired
? owner.verified
: !passportRequired || Boolean(owner.passportNumber); : !passportRequired || Boolean(owner.passportNumber);
const complete = ownerProven && (!poaDue || poa.verified);
return { faydaRequired, passportRequired, owner, poa, complete }; return { faydaRequired, passportRequired, owner, poa, complete };
} }

View File

@@ -287,9 +287,11 @@ export default function SettingsPage() {
icon={<Clock size={18} />} icon={<Clock size={18} />}
title="Changes submitted for review" title="Changes submitted for review"
> >
Your recent changes are awaiting administrator approval. Editing is Your recent changes are awaiting administrator approval. Company
disabled until the review is complete you'll be notified once it's details and documents can't be edited until the review is complete —
approved or if any changes are requested. you'll be notified once it's approved or if any changes are
requested. Your contact person, general manager and Power of
Attorney stay editable.
</Alert> </Alert>
)} )}
{reviewStatus === "rejected" && ( {reviewStatus === "rejected" && (
@@ -358,9 +360,17 @@ export default function SettingsPage() {
)} )}
</Tabs.Panel> </Tabs.Panel>
{/* While a change request is pending, every panel's inputs + submit {/* While a change request is pending, the reviewed panels' inputs +
buttons are disabled via the native fieldset; tab switching stays submit buttons are disabled via the native fieldset; tab switching
enabled so the customer can still review what they submitted. */} stays enabled so the customer can still review what they
submitted.
Personnel panels below (contact person, general manager, Power of
Attorney) are deliberately outside the lock: the API applies those
edits live rather than staging them, so locking them here would
re-impose the approval wait the API no longer does. The PoA's
delegation letter is still reviewed — that lock lives on the file
itself, not the panel. */}
<Tabs.Panel value="company"> <Tabs.Panel value="company">
<Fieldset disabled={locked} variant="unstyled" p={0}> <Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} /> <TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
@@ -368,19 +378,13 @@ export default function SettingsPage() {
<OperationalServicesCard profile={profile} /> <OperationalServicesCard profile={profile} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="contact"> <Tabs.Panel value="contact">
<Fieldset disabled={locked} variant="unstyled" p={0}> <TabContactPerson profile={profile} mode="edit" />
<TabContactPerson profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="gm"> <Tabs.Panel value="gm">
<Fieldset disabled={locked} variant="unstyled" p={0}> <TabGeneralManager profile={profile} mode="edit" />
<TabGeneralManager profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="poa"> <Tabs.Panel value="poa">
<Fieldset disabled={locked} variant="unstyled" p={0}> <TabPowerOfAttorney profile={profile} mode="edit" />
<TabPowerOfAttorney profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="documents"> <Tabs.Panel value="documents">
<Fieldset disabled={locked} variant="unstyled" p={0}> <Fieldset disabled={locked} variant="unstyled" p={0}>

View File

@@ -33,7 +33,6 @@ import {
buildOnboardingSchema, buildOnboardingSchema,
type CompanyStep, type CompanyStep,
type FormData, type FormData,
hasPoaDetails,
POA_DELEGATION_FILE_KEY, POA_DELEGATION_FILE_KEY,
stepFields, stepFields,
} from "./companyProfileForm/schema"; } from "./companyProfileForm/schema";
@@ -191,11 +190,7 @@ export default function CompanyProfileForm({
formState: { errors }, formState: { errors },
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver( resolver: zodResolver(
buildOnboardingSchema( buildOnboardingSchema(identity?.passportRequired === true),
requirePoa,
verifiedIdentity,
identity?.passportRequired === true,
),
), ),
defaultValues: { defaultValues: {
companyName: "", companyName: "",
@@ -321,7 +316,6 @@ export default function CompanyProfileForm({
// them and re-enables editing. // them and re-enables editing.
const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
const [contactSameAsGm, setContactSameAsGm] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false);
const [poaSameAsContact, setPoaSameAsContact] = 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
// eTrade (and the account email) but stay editable, so the link reads the // eTrade (and the account email) but stay editable, so the link reads the
@@ -367,9 +361,6 @@ export default function CompanyProfileForm({
const gmName = watch("generalManagerName"); const gmName = watch("generalManagerName");
const gmEmail = watch("generalManagerEmail"); const gmEmail = watch("generalManagerEmail");
const gmPhone = watch("generalManagerPhone"); const gmPhone = watch("generalManagerPhone");
const contactName = watch("contactPersonName");
const contactEmail = watch("contactPersonEmail");
const contactPhone = watch("contactPersonPhone");
// 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.
@@ -381,26 +372,6 @@ export default function CompanyProfileForm({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [contactSameAsGm, gmName, gmEmail, gmPhone]); }, [contactSameAsGm, gmName, gmEmail, gmPhone]);
// The contact-person step has no address of its own, so the linked PoA takes
// the company's composed address. poaLocation (the city) stays typed on the
// PoA step — the company step no longer has a location field to mirror.
const companyAddress = watch("companyAddress");
useEffect(() => {
if (!poaSameAsContact) return;
setValue("poaName", contactName ?? "");
setValue("poaEmail", contactEmail ?? "");
setValue("poaPhone", contactPhone ?? "");
setValue("poaAddress", companyAddress ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
poaSameAsContact,
contactName,
contactEmail,
contactPhone,
companyAddress,
]);
const toggleContactSameAsGm = (checked: boolean) => { const toggleContactSameAsGm = (checked: boolean) => {
setContactSameAsGm(checked); setContactSameAsGm(checked);
// Checked → the mirror effect fills the fields; unchecked → reset them. // Checked → the mirror effect fills the fields; unchecked → reset them.
@@ -411,17 +382,6 @@ export default function CompanyProfileForm({
} }
}; };
const togglePoaSameAsContact = (checked: boolean) => {
setPoaSameAsContact(checked);
if (!checked) {
setValue("poaName", "");
setValue("poaEmail", "");
setValue("poaPhone", "");
setValue("poaLocation", "");
setValue("poaAddress", "");
}
};
// The DARS delegation paper ships in the same nationality document set as the // The DARS delegation paper ships in the same nationality document set as the
// rest (the API guarantees it is there), but belongs on the PoA step next to // rest (the API guarantees it is there), but belongs on the PoA step next to
// the details it evidences — so it's split out here and the Documents step // the details it evidences — so it's split out here and the Documents step
@@ -551,7 +511,9 @@ export default function CompanyProfileForm({
// for a freight forwarder, whose PoA itself is mandatory. The API enforces // for a freight forwarder, whose PoA itself is mandatory. The API enforces
// the same rule on save, so skipping it here only costs the customer a // the same rule on save, so skipping it here only costs the customer a
// round-trip. // round-trip.
const poaProvided = hasPoaDetails(watch()); // A PoA exists exactly when one has been verified — the details are the
// verification's output, so there is nothing else that could stand for one.
const poaProvided = identity?.poa.verified ?? false;
const delegationRequired = requirePoa || poaProvided; const delegationRequired = requirePoa || poaProvided;
const delegationPresent = const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
@@ -638,12 +600,7 @@ export default function CompanyProfileForm({
setSaveError("Verify the company owner's identity with Fayda before continuing."); setSaveError("Verify the company owner's identity with Fayda before continuing.");
return; return;
} }
if ( if (step === "poa" && requirePoa && !identity?.poa.verified) {
step === "poa" &&
verifiedIdentity &&
requirePoa &&
!identity?.poa.verified
) {
setSaveError( setSaveError(
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.", "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
); );
@@ -876,71 +833,27 @@ export default function CompanyProfileForm({
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required." ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."} : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
</Text> </Text>
{/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come
from the verification and are never typed here. */}
{identity && ( {identity && (
<FaydaVerifyPanel <FaydaVerifyPanel
subject="poa" subject="poa"
title="Power of Attorney" title="Power of Attorney"
state={identity.poa} state={identity.poa}
required={identity.faydaRequired} required={requirePoa}
onVerified={() => onIdentityChange?.()} onVerified={() => onIdentityChange?.()}
/> />
)} )}
{!verifiedIdentity && watch("contactPersonName") && (
<LinkCheckboxCard
checked={poaSameAsContact}
onToggle={togglePoaSameAsContact}
title="Same as contact person"
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
/>
)}
{!verifiedIdentity && (
<>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</SimpleGrid>
</>
)}
{/* The city is the one field the Fayda address claim does not {/* The city is the one field the Fayda address claim does not
reliably decompose into, so it stays typed either way. */} reliably decompose into, so it stays typed. */}
{verifiedIdentity && ( <TextInput
<TextInput label="PoA Location"
label="PoA Location" placeholder="City, Country"
placeholder="City, Country" error={errors.poaLocation?.message}
error={errors.poaLocation?.message} {...register("poaLocation")}
{...register("poaLocation")} />
/>
)}
{poaDocumentSetting && ( {poaDocumentSetting && (
<> <>

View File

@@ -37,10 +37,8 @@ export function buildPayload(
generalManagerName: data.generalManagerName, generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail, generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone, generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined, // The representative's own details are written by their Fayda
poaPhone: data.poaPhone || undefined, // verification, so the city is all the form has to send.
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined, poaLocation: data.poaLocation || undefined,
}, },
}; };
@@ -88,13 +86,7 @@ export function stepPayload(
contactPersonPhone: d.contactPersonPhone, contactPersonPhone: d.contactPersonPhone,
}; };
case "poa": case "poa":
return { return { poaLocation: d.poaLocation || undefined };
poaName: d.poaName || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
default: default:
return {}; return {};
} }

View File

@@ -92,58 +92,28 @@ export type FormData = z.infer<typeof onboardingSchema>;
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */ /** fileKey of the delegation letter uploaded on the Power of Attorney step. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
export const POA_FIELDS = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const satisfies readonly (keyof FormData)[];
/** True once the customer has entered any Power of Attorney detail. */
export const hasPoaDetails = (d: Partial<FormData>) =>
POA_FIELDS.some((f) => d[f]?.trim());
/** /**
* A freight forwarder acts on other companies' behalf, so its PoA is mandatory * The PoA's identifying fields are never typed — they come from the Fayda
* rather than optional. Everyone else keeps the optional PoA — but once they * verification, whatever the company's nationality — so nothing here requires
* start filling it in, the identifying fields have to be complete (the * them. A freight forwarder's mandatory PoA is gated on the verification
* delegation-letter upload is enforced alongside this, in CompanyProfileForm, * itself, and its delegation letter alongside it, both in CompanyProfileForm
* since files live outside the form state). * (files live outside form state).
*
* That leaves the owner's passport number as the only conditional field.
*/ */
export function buildOnboardingSchema( export function buildOnboardingSchema(
requirePoa: boolean,
/**
* True when the PoA's identity fields come from a Fayda verification rather
* than the form (Ethiopian companies). Requiring them here would fail
* validation against inputs the step no longer renders — the verification
* itself is what the step gates on instead.
*/
faydaOwnedPoa = false,
/** True for a foreign company: the owner's passport number is mandatory. */ /** True for a foreign company: the owner's passport number is mandatory. */
passportRequired = false, passportRequired = false,
) { ) {
const poaRequired = requirePoa && !faydaOwnedPoa; if (!passportRequired) return onboardingSchema;
if (!poaRequired && !passportRequired) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => { return onboardingSchema.superRefine((d, ctx) => {
const required: [keyof FormData, string][] = []; if (!d.ownerPassportNumber?.trim()) {
if (poaRequired) { ctx.addIssue({
required.push( code: z.ZodIssueCode.custom,
["poaName", "PoA name is required for freight forwarders"], path: ["ownerPassportNumber"],
["poaEmail", "PoA email is required for freight forwarders"], message: "The owner's passport number is required",
["poaPhone", "PoA phone is required for freight forwarders"], });
);
}
if (passportRequired) {
required.push([
"ownerPassportNumber",
"The owner's passport number is required",
]);
}
for (const [path, message] of required) {
if (!d[path]?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
}
} }
}); });
} }
@@ -180,7 +150,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail", "contactPersonEmail",
"contactPersonPhone", "contactPersonPhone",
], ],
poa: [...POA_FIELDS], poa: ["poaLocation"],
documents: [], documents: [],
additional: [], additional: [],
}; };

View File

@@ -39,20 +39,15 @@ import {
type LicenseFile, type LicenseFile,
type LicenseFileStatus, type LicenseFileStatus,
} from "@/services/companies.service"; } from "@/services/companies.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service"; import { verifaydaService } from "@/services/verifayda.service";
import type { ProfileResponse } from "@/types/profile"; 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({ const schema = z.object({
poaName: z.string().optional(),
poaEmail: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaLocation: z.string().optional(), poaLocation: z.string().optional(),
poaAddress: z.string().optional(),
}); });
type FormData = z.infer<typeof schema>; type FormData = z.infer<typeof schema>;
@@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({
const { view, viewer } = useFileViewer(); const { view, viewer } = useFileViewer();
const uploadInputRef = useRef<HTMLInputElement>(null); const uploadInputRef = useRef<HTMLInputElement>(null);
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo(
return { (): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
poaName: profile.poaName ?? "", [profile],
poaEmail: profile.poaEmail ?? "", );
poaPhone: profile.poaPhone ?? "",
poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "",
};
}, [profile]);
const { const {
register, register,
control,
handleSubmit, handleSubmit,
reset, reset,
watch,
formState: { errors, isDirty }, formState: { errors, isDirty },
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({})); const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]); const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
// The letter is staged locally, not uploaded on pick. Uploading immediately // The letter is staged locally, not uploaded on pick: the paper is the one
// would open a change request, which locks the whole settings page (see // thing here that still goes to a reviewer, so picking it must not open a
// SettingsPage's `locked` fieldset) before the text fields could be saved. // change request before the customer has committed to the save. Save submits
// Save submits the file and the fields together, into one change request. // the file and the fields together.
const [pickedFile, setPickedFile] = useState<File | null>(null); const [pickedFile, setPickedFile] = useState<File | null>(null);
const [removeIds, setRemoveIds] = useState<string[]>([]); const [removeIds, setRemoveIds] = useState<string[]>([]);
const [saveBlocked, setSaveBlocked] = useState(false); const [saveBlocked, setSaveBlocked] = useState(false);
@@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({
const requirePoa = profile.companyProfiles.some( const requirePoa = profile.companyProfiles.some(
(p) => p.type === "freight_forwarder", (p) => p.type === "freight_forwarder",
); );
// An Ethiopian company does not type its representative's details — they // No company types its representative's details — they come from the Fayda
// come from the Fayda verification. A foreign company keeps the typed form: // verification whatever the nationality, since a representative acts for the
// its representative may hold no Fayda ID. // company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified.
const identity = profile.identity; const identity = profile.identity;
const verifiedIdentity = identity?.faydaRequired === true; const poaProvided = identity?.poa.verified ?? false;
const poaValues = watch([
"poaName",
"poaEmail",
"poaPhone",
"poaLocation",
"poaAddress",
]);
const poaProvided = verifiedIdentity
? (identity?.poa.verified ?? false)
: poaValues.some((v) => v?.trim());
const letterRequired = requirePoa || poaProvided; const letterRequired = requirePoa || poaProvided;
const letterMissing = letterRequired && !hasLetterAfterSave; const letterMissing = letterRequired && !hasLetterAfterSave;
@@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: FormData) => { mutationFn: async (data: FormData) => {
// Every identity field except the city is written by the verification, so // Every identity field except the city is written by the verification, so
// an Ethiopian company only ever saves the paper and the location here. // only the paper and the location are ever saved here.
const fields = verifiedIdentity const fields = { poaLocation: data.poaLocation || undefined };
? { poaLocation: data.poaLocation || undefined }
: {
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
poaAddress: data.poaAddress || undefined,
};
// A fresh upload already stages the removal of every paper on file, so // A fresh upload already stages the removal of every paper on file, so
// the explicit removals only need applying when no replacement was // the explicit removals only need applying when no replacement was
// picked. Saving the details after it means the API sees the new paper. // picked. Saving the details after it means the API sees the new paper.
@@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({
subject="poa" subject="poa"
title="Power of Attorney" title="Power of Attorney"
state={identity.poa} state={identity.poa}
required={identity.faydaRequired} required={requirePoa}
disabled={mutation.isPending} disabled={mutation.isPending}
pendingReview={Boolean(
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
?.faydaIdentity?.poaFaydaSub,
)}
onVerified={() => { onVerified={() => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(), queryKey: api.companies.getProfile.queryKey(),
@@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md"> <Stack gap="md">
{/* Name, email, phone and address are written by the Fayda {/* Name, email, phone and address are all written by the Fayda
verification for an Ethiopian company, so only the city — which verification, so only the city — which the address claim does
the address claim does not reliably decompose into — is typed. */} not reliably decompose into — is typed. */}
{!verifiedIdentity && (
<>
<TextInput
label="PoA Full Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</Grid.Col>
</Grid>
</>
)}
<Grid> <Grid>
<Grid.Col span={6}> <Grid.Col span={6}>
<TextInput <TextInput
@@ -342,16 +278,6 @@ export default function TabPowerOfAttorney({
{...register("poaLocation")} {...register("poaLocation")}
/> />
</Grid.Col> </Grid.Col>
{!verifiedIdentity && (
<Grid.Col span={6}>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</Grid.Col>
)}
</Grid> </Grid>
</Stack> </Stack>
@@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({
</Stack> </Stack>
)} )}
{profile.reviewStatus === "pending" && ( {/* Keyed on the paper's own staged status, not the company's
review state: the details on this tab now apply live, so a
pending review is just as likely to be about something else. */}
{letters.some((f) => f.status !== "live") && (
<Group gap={6} c="edr-amber-text"> <Group gap={6} c="edr-amber-text">
<Clock size={13} /> <Clock size={13} />
<Text size="xs" fw={500}> <Text size="xs" fw={500}>
@@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({
</Group> </Group>
<Group gap="md"> <Group gap="md">
{mode === "edit" && {mode === "edit" &&
verifiedIdentity &&
identity?.poa.verified && identity?.poa.verified &&
!requirePoa && ( !requirePoa && (
<Button <Button