diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 7f72d431a..c13f79f5e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -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 * national ID number, so none is collected or derived here. * - * - Ethiopian company: the owner (and its PoA, once named) is verified through - * Fayda, and their details can't be edited afterwards. + * Only the OWNER's credential varies by nationality: + * - Ethiopian company: the owner is verified through Fayda. * - 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 * 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 portal offers a "same as owner" copy for, but it is never itself * Fayda-verified or gated on. @@ -107,6 +112,7 @@ function makeService(overrides: Partial = {}) { }, changeRequestRepo: { findPendingByCompanyId: jest.fn(async () => null), + findLatestOpenByCompanyId: jest.fn(async () => null), findByCompanyId: jest.fn(async () => []), create: jest.fn(async (row: Record) => ({ id: "cr-1", @@ -229,9 +235,27 @@ describe("Fayda identity verification binds a person to the company", () => { ).rejects.toBeInstanceOf(BadRequestException); }); - it("stages the change for review on an approved company", async () => { - // Swapping the person who can act for a live company is exactly what the - // backoffice review exists for, so it must not rewrite the row directly. + it("stages an owner re-verification for review on an approved company", async () => { + // The owner is the live company's identity proof, so re-verifying one is + // 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({ status: CompanyStatus.Active, }); @@ -242,8 +266,8 @@ describe("Fayda identity verification binds a person to the company", () => { state: "s", }); - expect(deps.changeRequestRepo.create).toHaveBeenCalled(); - expect(ctx.attributes.poaFaydaSub).toBeUndefined(); + expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); }); 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); }); - 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({ profileTypes: [ProfileType.importer], nationality: CompanyNationality.Foreign, @@ -385,7 +431,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi "user-1", ProfileType.freightForwarder, ), - ).resolves.toBeDefined(); + ).rejects.toBeInstanceOf(BadRequestException); }); it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 9e1c76477..27c42e581 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -81,6 +81,9 @@ function makeService(overrides: Partial = {}) { findPendingByCompanyId: jest.fn(async () => 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 () => []), create: jest.fn(async (row: Record) => ({ id: "cr-1", diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 50cc648ad..c198d149f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -81,6 +81,28 @@ const POA_ATTRIBUTES = [ "poaLocation", "poaAddress", ] 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. */ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaName", label: "PoA name" }, @@ -844,10 +866,11 @@ export class CompaniesService { * * - Company not yet approved (onboarding) → write straight to the Company row, * 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 - * a pending change request (merging into any open one) so a backoffice - * reviewer can approve (apply) or reject (with a note). This locks the - * customer until the review resolves. + * - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`) + * still write straight through; everything else does NOT touch the live + * Company but is staged in a pending change request (merging into any open + * 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( userId: string, @@ -882,9 +905,37 @@ export class CompaniesService { 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); const fields = this.pickDefined(dto); + const selfService: Record = {}; + const staged: Record = {}; + 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( company.id, @@ -894,7 +945,7 @@ export class CompaniesService { if (existing) { request = (await this.changeRequestRepo.update(existing.id, { - snapshot: { ...(existing.snapshot ?? {}), ...fields }, + snapshot: { ...(existing.snapshot ?? {}), ...staged }, submittedBy: userId, submittedAt: now, note: null, @@ -910,7 +961,7 @@ export class CompaniesService { ); request = await this.changeRequestRepo.create({ companyId: company.id, - snapshot: fields, + snapshot: staged, status: ChangeRequestStatus.Pending, submittedBy: userId, submittedAt: now, @@ -922,8 +973,9 @@ export class CompaniesService { ); } - // Live company is unchanged; surface the pending state for the settings page. - return new ProfileResponseDto(profile, company, request); + // Only the personnel half (if any) landed; surface the pending state for + // the settings page. + return new ProfileResponseDto(profile, live, request); } /** List a company's change requests, newest first (backoffice review). */ @@ -1720,16 +1772,11 @@ export class CompaniesService { const poaProvided = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); - // An Ethiopian company does not type its PoA details at all — they arrive - // from the Fayda verification — so reporting them as missing fields would - // ask for something the form no longer offers. The identity block below + // No company types its PoA details — they arrive from the Fayda + // verification whatever the nationality — so reporting them as missing + // fields would ask for something no form offers. The identity block below // reports "verify your PoA" instead. - const missingPoaFields = - poaRequired && !identity.faydaRequired - ? REQUIRED_POA_FIELDS.filter( - (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), - ) - : []; + const missingPoaFields: typeof REQUIRED_POA_FIELDS = []; const delegation = await this.getPoaDelegationState(company.id); const delegationDue = poaRequired || poaProvided; const missingDelegation = delegationDue && !delegation.onFile; @@ -1754,9 +1801,7 @@ export class CompaniesService { ...(identity.faydaRequired && !identity.owner.verified ? ["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"] : []), ...(identity.passportRequired && !identity.owner.passportNumber @@ -1768,26 +1813,20 @@ export class CompaniesService { // fields, required documents, one license per operational profile, and the // PoA details/paper whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; - const poaItemCount = - (poaRequired && !identity.faydaRequired - ? REQUIRED_POA_FIELDS.length - : 0) + (delegationDue ? 1 : 0); + const poaItemCount = delegationDue ? 1 : 0; // One item per identity credential the company has to prove: the owner - // always (Fayda for Ethiopian, passport for foreign), the PoA once there - // is one and Fayda is what's mandatory here. - const identityItemCount = identity.faydaRequired - ? delegationDue - ? 2 - : 1 - : identity.passportRequired - ? 1 - : 0; - const missingIdentityCount = identity.faydaRequired - ? (identity.owner.verified ? 0 : 1) + - (delegationDue && !identity.poa.verified ? 1 : 0) - : identity.passportRequired && !identity.owner.passportNumber - ? 1 - : 0; + // always (Fayda for Ethiopian, passport for foreign), plus the PoA once + // there is one — that one is Fayda whatever the nationality. + const ownerCredentialDue = + identity.faydaRequired || identity.passportRequired; + const ownerCredentialProven = identity.faydaRequired + ? identity.owner.verified + : Boolean(identity.owner.passportNumber); + const identityItemCount = + (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0); + const missingIdentityCount = + (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + + (delegationDue && !identity.poa.verified ? 1 : 0); const total = requiredInfo.length + requiredDocCount + @@ -2453,11 +2492,13 @@ export class CompaniesService { ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; - // An approved company's profile edits are staged for backoffice review, and - // swapping the person who can act for the company is exactly the kind of - // edit that review exists for — so a verification lands the same way an - // ordinary edit does, rather than quietly rewriting a live record. - if (company.status === CompanyStatus.Active) { + // An approved company's *owner* is its identity proof, so re-verifying one + // is staged for backoffice review rather than quietly rewriting a live + // record. The PoA is personnel — the company names its own representative, + // and the delegation letter backing them is what the reviewer sees — so a + // 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); return this.getCompanyIdentityState(company); } @@ -2586,21 +2627,23 @@ export class CompaniesService { ): void { 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.owner.passportNumber) { throw new BadRequestException( "Add the company owner's passport number before continuing.", ); } - return; - } - - if (!state.owner.verified) { + } else if (!state.owner.verified) { throw new BadRequestException( "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) => (company.attributes?.[k] as string | undefined)?.trim(), ); diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts index 6e7468c8a..a9988cd28 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -144,9 +144,14 @@ export function buildCompanyIdentityState( (p) => p.type === ProfileType.freightForwarder, ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); - const complete = faydaRequired - ? owner.verified && (!poaDue || poa.verified) + // Only the *owner's* credential is nationality-specific. A Power of Attorney + // 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); + const complete = ownerProven && (!poaDue || poa.verified); return { faydaRequired, passportRequired, owner, poa, complete }; } diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index ba19e34ce..8bbe59284 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -287,9 +287,11 @@ export default function SettingsPage() { icon={} title="Changes submitted for review" > - Your recent changes are awaiting administrator approval. Editing is - disabled until the review is complete — you'll be notified once it's - approved or if any changes are requested. + Your recent changes are awaiting administrator approval. Company + details and documents can't be edited until the review is complete — + 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. )} {reviewStatus === "rejected" && ( @@ -358,9 +360,17 @@ export default function SettingsPage() { )} - {/* While a change request is pending, every panel's inputs + submit - buttons are disabled via the native fieldset; tab switching stays - enabled so the customer can still review what they submitted. */} + {/* While a change request is pending, the reviewed panels' inputs + + submit buttons are disabled via the native fieldset; tab switching + 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. */}
@@ -368,19 +378,13 @@ export default function SettingsPage() { -
- -
+
-
- -
+
-
- -
+
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index e79d06bf6..9c62b494d 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -33,7 +33,6 @@ import { buildOnboardingSchema, type CompanyStep, type FormData, - hasPoaDetails, POA_DELEGATION_FILE_KEY, stepFields, } from "./companyProfileForm/schema"; @@ -191,11 +190,7 @@ export default function CompanyProfileForm({ formState: { errors }, } = useForm({ resolver: zodResolver( - buildOnboardingSchema( - requirePoa, - verifiedIdentity, - identity?.passportRequired === true, - ), + buildOnboardingSchema(identity?.passportRequired === true), ), defaultValues: { companyName: "", @@ -321,7 +316,6 @@ export default function CompanyProfileForm({ // them and re-enables editing. const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false); - const [poaSameAsContact, setPoaSameAsContact] = useState(false); // 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 @@ -367,9 +361,6 @@ export default function CompanyProfileForm({ const gmName = watch("generalManagerName"); const gmEmail = watch("generalManagerEmail"); 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 // 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 }, [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) => { setContactSameAsGm(checked); // 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 // 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 @@ -551,7 +511,9 @@ export default function CompanyProfileForm({ // 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 // 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 delegationPresent = (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."); return; } - if ( - step === "poa" && - verifiedIdentity && - requirePoa && - !identity?.poa.verified - ) { + if (step === "poa" && requirePoa && !identity?.poa.verified) { setSaveError( "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." : "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."} + {/* 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 && ( onIdentityChange?.()} /> )} - {!verifiedIdentity && watch("contactPersonName") && ( - - )} - {!verifiedIdentity && ( - <> - - - - - - - - - - - )} {/* The city is the one field the Fayda address claim does not - reliably decompose into, so it stays typed either way. */} - {verifiedIdentity && ( - - )} + reliably decompose into, so it stays typed. */} + {poaDocumentSetting && ( <> diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index 944177ae3..f1a9f4301 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -37,10 +37,8 @@ export function buildPayload( generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, generalManagerPhone: data.generalManagerPhone, - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaAddress: data.poaAddress || undefined, - poaEmail: data.poaEmail || undefined, + // The representative's own details are written by their Fayda + // verification, so the city is all the form has to send. poaLocation: data.poaLocation || undefined, }, }; @@ -88,13 +86,7 @@ export function stepPayload( contactPersonPhone: d.contactPersonPhone, }; case "poa": - return { - poaName: d.poaName || undefined, - poaPhone: d.poaPhone || undefined, - poaEmail: d.poaEmail || undefined, - poaLocation: d.poaLocation || undefined, - poaAddress: d.poaAddress || undefined, - }; + return { poaLocation: d.poaLocation || undefined }; default: return {}; } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 4732678cc..2cd06ef8f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -92,58 +92,28 @@ export type FormData = z.infer; /** fileKey of the delegation letter uploaded on the Power of Attorney step. */ 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) => - POA_FIELDS.some((f) => d[f]?.trim()); /** - * A freight forwarder acts on other companies' behalf, so its PoA is mandatory - * rather than optional. Everyone else keeps the optional PoA — but once they - * start filling it in, the identifying fields have to be complete (the - * delegation-letter upload is enforced alongside this, in CompanyProfileForm, - * since files live outside the form state). + * The PoA's identifying fields are never typed — they come from the Fayda + * verification, whatever the company's nationality — so nothing here requires + * them. A freight forwarder's mandatory PoA is gated on the verification + * itself, and its delegation letter alongside it, both in CompanyProfileForm + * (files live outside form state). + * + * That leaves the owner's passport number as the only conditional field. */ 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. */ passportRequired = false, ) { - const poaRequired = requirePoa && !faydaOwnedPoa; - if (!poaRequired && !passportRequired) return onboardingSchema; + if (!passportRequired) return onboardingSchema; return onboardingSchema.superRefine((d, ctx) => { - const required: [keyof FormData, string][] = []; - if (poaRequired) { - required.push( - ["poaName", "PoA name is required for freight forwarders"], - ["poaEmail", "PoA email is required for freight forwarders"], - ["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 }); - } + if (!d.ownerPassportNumber?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ownerPassportNumber"], + message: "The owner's passport number is required", + }); } }); } @@ -180,7 +150,7 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - poa: [...POA_FIELDS], + poa: ["poaLocation"], documents: [], additional: [], }; diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index 520e29d42..a3ef08078 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -39,20 +39,15 @@ import { type LicenseFile, type LicenseFileStatus, } from "@/services/companies.service"; -import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import { verifaydaService } from "@/services/verifayda.service"; 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({ - 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(), - poaAddress: z.string().optional(), }); type FormData = z.infer; @@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({ const { view, viewer } = useFileViewer(); const uploadInputRef = useRef(null); - const defaultValues = useMemo((): FormData => { - return { - poaName: profile.poaName ?? "", - poaEmail: profile.poaEmail ?? "", - poaPhone: profile.poaPhone ?? "", - poaLocation: profile.poaLocation ?? "", - poaAddress: profile.poaAddress ?? "", - }; - }, [profile]); + const defaultValues = useMemo( + (): FormData => ({ poaLocation: profile.poaLocation ?? "" }), + [profile], + ); const { register, - control, handleSubmit, reset, - watch, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), @@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({ const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({})); const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]); - // The letter is staged locally, not uploaded on pick. Uploading immediately - // would open a change request, which locks the whole settings page (see - // SettingsPage's `locked` fieldset) before the text fields could be saved. - // Save submits the file and the fields together, into one change request. + // The letter is staged locally, not uploaded on pick: the paper is the one + // thing here that still goes to a reviewer, so picking it must not open a + // change request before the customer has committed to the save. Save submits + // the file and the fields together. const [pickedFile, setPickedFile] = useState(null); const [removeIds, setRemoveIds] = useState([]); const [saveBlocked, setSaveBlocked] = useState(false); @@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({ const requirePoa = profile.companyProfiles.some( (p) => p.type === "freight_forwarder", ); - // An Ethiopian company does not type its representative's details — they - // come from the Fayda verification. A foreign company keeps the typed form: - // its representative may hold no Fayda ID. + // 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 verifiedIdentity = identity?.faydaRequired === true; - - const poaValues = watch([ - "poaName", - "poaEmail", - "poaPhone", - "poaLocation", - "poaAddress", - ]); - const poaProvided = verifiedIdentity - ? (identity?.poa.verified ?? false) - : poaValues.some((v) => v?.trim()); + const poaProvided = identity?.poa.verified ?? false; const letterRequired = requirePoa || poaProvided; const letterMissing = letterRequired && !hasLetterAfterSave; @@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({ const mutation = useMutation({ mutationFn: async (data: FormData) => { // Every identity field except the city is written by the verification, so - // an Ethiopian company only ever saves the paper and the location here. - const fields = verifiedIdentity - ? { poaLocation: data.poaLocation || undefined } - : { - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - poaAddress: data.poaAddress || undefined, - }; + // only the paper and the location are ever saved here. + const fields = { 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. @@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({ subject="poa" title="Power of Attorney" state={identity.poa} - required={identity.faydaRequired} + required={requirePoa} disabled={mutation.isPending} - pendingReview={Boolean( - (profile.pendingChanges as { faydaIdentity?: Record } | null) - ?.faydaIdentity?.poaFaydaSub, - )} onVerified={() => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey(), @@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
- {/* Name, email, phone and address are written by the Fayda - verification for an Ethiopian company, so only the city — which - the address claim does not reliably decompose into — is typed. */} - {!verifiedIdentity && ( - <> - - - - - - - - - - - - )} - + {/* Name, email, phone and address are all written by the Fayda + verification, so only the city — which the address claim does + not reliably decompose into — is typed. */} - {!verifiedIdentity && ( - - - - )} @@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({ )} - {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") && ( @@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({ {mode === "edit" && - verifiedIdentity && identity?.poa.verified && !requirePoa && (