diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 1dd28ca9c..78f8db410 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -456,6 +456,36 @@ export class CompaniesController { return this.companiesService.clearGmIdentity(user.id); } + @Post("identity/poa/same-as-owner") + @PortalCustomer() + @ApiOperation({ + summary: + "Declare the Power of Attorney is the company's owner, copying the owner's identity across. " + + "Waives the DARS delegation paper — nobody delegates to themselves. " + + "Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.", + }) + async setPoaSameAsOwner( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.setPoaSameAsOwner(user.id, { + email: user.email, + phoneNumber: user.phoneNumber, + }); + } + + @Delete("identity/poa/same-as-owner") + @PortalCustomer() + @ApiOperation({ + summary: + "Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " + + "Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.", + }) + async clearPoaSameAsOwner( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.clearPoaSameAsOwner(user.id); + } + @Delete("identity/fayda/poa") @PortalCustomer() @ApiOperation({ 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 4a53915ec..fe40cf08f 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 @@ -229,8 +229,10 @@ describe("Fayda identity verification binds a person to the company", () => { expect(state.owner.verified).toBe(true); }); - it("refuses to make one identity both owner and PoA", async () => { - const { service } = makeService({ + it("lets one identity be both owner and PoA", async () => { + // An owner who represents their own company is the ordinary small-business + // case, not a conflict — the same answer the GM has always been allowed. + const { service, ctx } = makeService({ attributes: { ownerFaydaSub: "same-person" }, verification: { purpose: "VERIFY", @@ -240,13 +242,14 @@ describe("Fayda identity verification binds a person to the company", () => { }, }); - await expect( - service.completeIdentityVerification("user-1", { - subject: "poa", - code: "c", - state: "s", - }), - ).rejects.toBeInstanceOf(BadRequestException); + const state = await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(state.poa.verified).toBe(true); + expect(ctx.attributes.poaFaydaSub).toBe("same-person"); }); it("stages an owner re-verification for review on an approved company", async () => { @@ -701,9 +704,8 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi }); it("lets the GM verify as the same human as the owner", async () => { - // The owner/PoA collision check exists because self-delegation is not - // delegation. It must not fire here: the GM being the owner is a supported - // answer, so verifying with the owner's own Fayda sub has to succeed. + // One human in every role is the ordinary small-business shape, so + // verifying with the owner's own Fayda sub has to succeed. const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED }, verification: { @@ -727,25 +729,42 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila"); }); - it("still refuses a PoA who is the owner", async () => { - // The GM exemption above must not have widened into the PoA. - const { service } = makeService({ - attributes: { ...OWNER_VERIFIED }, - verification: { - purpose: "VERIFY", - verified: true, - sub: "owner-sub", - fullName: "Abebe Bikila", - }, + it("declares the PoA is the owner, copying the verified identity across", async () => { + const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED } }); + + const state = await service.setPoaSameAsOwner("user-1"); + + expect(state.poaSameAsOwner).toBe(true); + expect(state.poa.verified).toBe(true); + expect(ctx.attributes.poaFaydaSub).toBe(OWNER_VERIFIED.ownerFaydaSub); + expect(ctx.attributes.poaName).toBe(OWNER_VERIFIED.ownerName); + }); + + it("refuses to declare the PoA is the owner while an Ethiopian owner is unverified", async () => { + // Its representative must be Fayda-verified, so a declaration here would + // record one that could never satisfy the gate. + const { service } = makeService({ attributes: {} }); + + await expect(service.setPoaSameAsOwner("user-1")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("undoes the PoA \"same as owner\" declaration without touching a real verification", async () => { + const { service, ctx } = makeService({ + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, }); - await expect( - service.completeIdentityVerification("user-1", { - subject: "poa", - code: "c", - state: "s", - }), - ).rejects.toBeInstanceOf(BadRequestException); + // No declaration in place: the verified representative must survive. + await service.clearPoaSameAsOwner("user-1"); + expect(ctx.attributes.poaFaydaSub).toBe(POA_VERIFIED.poaFaydaSub); + + await service.setPoaSameAsOwner("user-1"); + const state = await service.clearPoaSameAsOwner("user-1"); + + expect(state.poaSameAsOwner).toBe(false); + expect(state.poa.verified).toBe(false); + expect(ctx.attributes.poaFaydaSub).toBeNull(); }); it("reports a pre-existing typed GM as unverified rather than blank", 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 365bd4639..b85344e8e 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 @@ -174,6 +174,31 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { ).resolves.toBeDefined(); }); + it("waives the paper when the owner represents the company themselves", async () => { + // Nobody delegates to themselves, so a self-declared PoA owes no DARS + // paper — the representative's own details are still required. + const { service } = makeService({ + attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true }, + }); + + await expect( + service.updateProfile("user-1", POA as never), + ).resolves.toBeDefined(); + }); + + it("grants the forwarder role to a self-represented company with no paper", async () => { + const { service } = makeService({ + attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true }, + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + it("rejects a paper the reviewer sent back for correction", async () => { const { service } = makeService({ files: [paper("change_requested")] }); 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 72dcfdded..0ed91c9e3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -133,12 +133,6 @@ const IDENTITY_PREFIX: Record = { gm: "gm", }; -const IDENTITY_LABEL: Record = { - owner: "owner", - poa: "Power of Attorney", - gm: "General Manager", -}; - /** * Typed GM columns a GM verification also writes. Three notifier services mail * `company.generalManagerEmail` directly, so leaving these behind would mean a @@ -2046,7 +2040,13 @@ export class CompaniesService { (company.attributes?.[k] as string | undefined)?.trim(), ); const delegation = await this.getPoaDelegationState(company.id); - const delegationDue = poaRequired || poaProvided; + // "There is a representative" and "a paper is owed for them" used to be the + // same condition. They part company once the owner represents the company + // themselves: the representative's details are still required, but nobody + // delegates to themselves, so no DARS paper is due (`assertPoaDelegationSatisfied` + // returns on the same flag — the two must agree). + const poaDue = poaRequired || poaProvided; + const delegationDue = poaDue && !identity.poaSameAsOwner; // The representative's details normally arrive from their Fayda // verification — but Fayda's email and phone claims are optional and // routinely come back empty, and the PoA step renders an input for whatever @@ -2055,7 +2055,7 @@ export class CompaniesService { // nothing here let a freight forwarder finish onboarding with a // representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then // 400'd their next PoA edit for it. - const missingPoaFields = delegationDue + const missingPoaFields = poaDue ? REQUIRED_POA_FIELDS.filter( (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), ) @@ -2115,7 +2115,8 @@ export class CompaniesService { // The delegation paper plus the representative's own required details — // `completed` below subtracts every one of those it is still missing, so // leaving them out of the total would make the bar understate progress. - const poaItemCount = delegationDue ? 1 + REQUIRED_POA_FIELDS.length : 0; + const poaItemCount = + (poaDue ? REQUIRED_POA_FIELDS.length : 0) + (delegationDue ? 1 : 0); // One item per identity credential the company has to prove: the owner // always (Fayda for Ethiopian, passport for foreign), plus the PoA once // there is one — Fayda for an Ethiopian company, a named representative @@ -2127,11 +2128,10 @@ export class CompaniesService { const ownerCredentialProven = identity.faydaRequired ? identity.owner.verified : Boolean(identity.owner.passportNumber); - const identityItemCount = - (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0); + const identityItemCount = (ownerCredentialDue ? 1 : 0) + (poaDue ? 1 : 0); const missingIdentityCount = (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + - (delegationDue && !poaProven ? 1 : 0); + (poaDue && !poaProven ? 1 : 0); const total = requiredInfo.length + requiredDocCount + @@ -2159,6 +2159,7 @@ export class CompaniesService { poa: { required: poaRequired, provided: poaProvided, + delegationLetterRequired: delegationDue, delegationLetterUploaded: delegation.onFile, delegationLetterFlagged: delegation.flagged, missingFields: missingPoaFields, @@ -2707,6 +2708,12 @@ export class CompaniesService { } } + // Nobody delegates to themselves: an owner representing their own company + // has no delegation to evidence, so the DARS paper is not owed. The + // representative's own details are still required above — a forwarder's + // counterparties need someone to contact either way. + if (attributes?.poaSameAsOwner) return; + const { onFile, flagged } = await this.getPoaDelegationState( companyId, opts.ignoreFileIds, @@ -2783,21 +2790,11 @@ export class CompaniesService { ); } - // The owner delegating power of attorney to themselves is not a - // delegation — it would let one identity satisfy both halves of the check. - // Only owner/PoA collide this way: the GM is very often the owner, and - // saying so is a supported answer rather than a conflict, so it is left out - // of this check entirely. - if (dto.subject === "owner" || dto.subject === "poa") { - const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; - const otherSub = - company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; - if (otherSub && otherSub === result.sub) { - throw new BadRequestException( - `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, - ); - } - } + // An owner who is also the company's representative is a supported answer, + // not a conflict — the same way the GM is very often the owner. Small + // companies routinely have one human in all three roles, and the portal's + // "same as owner" cards exist precisely so they can say so. No identity + // here is refused for colliding with another. const now = new Date().toISOString(); @@ -2837,6 +2834,12 @@ export class CompaniesService { // mail `generalManagerEmail`), and clears any earlier "same as owner" // declaration — verifying in their own right is the GM answering for // themselves. + // Verifying the representative in their own right answers the question the + // "same as owner" declaration answered, so the declaration goes. + if (dto.subject === "poa") { + identity.poaSameAsOwner = false; + } + if (dto.subject === "gm") { identity.gmSameAsOwner = false; if (result.fullName) identity.generalManagerName = result.fullName; @@ -2953,6 +2956,118 @@ export class CompaniesService { return this.getCompanyIdentityState(updated); } + /** + * Declare that the company's Power of Attorney is its owner. + * + * An owner representing their own company is the ordinary case for a small + * business, so this is a supported answer rather than the conflict it used to + * be refused as. Two shapes, matching {@link setGmSameAsOwner}: + * + * - A Fayda-verified owner is a proven identity, so it is copied outright — + * the representative inherits the verification instead of the same human + * being sent through Fayda a second time. + * - A foreign company's owner is backed by a typed passport, so there is + * nothing proven to copy. The declaration is still recorded (it is what + * waives the DARS paper) and whatever owner details exist come across; the + * portal types the rest, which `poaProven` accepts for a foreign company. + * + * Refused for an Ethiopian company whose owner is not verified yet: Fayda is + * mandatory for its representative, so a declaration there would record a + * representative that could never satisfy the gate. + */ + async setPoaSameAsOwner( + userId: string, + /** Same fallback as {@link completeIdentityVerification} — an owner whose + * Fayda claims carried no email/phone has none stored, and copying blanks + * onto a freight forwarder's PoA would block the submit on + * `REQUIRED_POA_FIELDS`. */ + account?: { email?: string; phoneNumber?: string }, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const attrs = company.attributes ?? {}; + const state = buildCompanyIdentityState(company); + const ownerSub = attrs.ownerFaydaSub as string | undefined; + + if (state.faydaRequired && !ownerSub) { + throw new BadRequestException( + "Verify the company owner with Fayda first — there is no proven identity to reuse yet.", + ); + } + + const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email; + const ownerPhone = + (attrs.ownerPhone as string | undefined) || account?.phoneNumber; + + // Only non-blank values are copied: a blank here would overwrite something + // the portal typed for a foreign company, whose owner has no verified + // claims to draw on. + const copied: Record = { poaSameAsOwner: true }; + const copy = (key: string, value: unknown) => { + if (value !== null && value !== undefined && value !== "") + copied[key] = value; + }; + copy("poaName", attrs.ownerName); + copy("poaEmail", ownerEmail); + copy("poaPhone", ownerPhone ? normalizeE164(ownerPhone) : undefined); + copy("poaAddress", attrs.ownerAddress); + + if (ownerSub) { + copied.poaFaydaSub = ownerSub; + copied.poaFaydaVerifiedAt = + attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(); + copy("poaBirthdate", attrs.ownerBirthdate); + copy("poaGender", attrs.ownerGender); + } + + const updated = await this.companiesRepo.update(company.id, { + attributes: { ...attrs, ...copied }, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** + * Undo the PoA "same as owner" declaration, clearing the identity it copied + * so a different representative can be verified (or typed, for a foreign + * company). + * + * Separate from {@link removePoaIdentity}, which drops the representative and + * their paper and is refused to a freight forwarder. Undoing a declaration is + * how a forwarder changes its mind about who represents it, so it must stay + * open to them — the submit gate still refuses a forwarder that never names a + * replacement. The delegation paper is left alone for the same reason: the + * company still owes one, now for whoever comes next. + * + * A no-op when no declaration is in place: a stray call must not wipe a + * representative who verified in their own right. + */ + async clearPoaSameAsOwner(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const attrs = { ...(company.attributes ?? {}) }; + if (!attrs.poaSameAsOwner) return this.getCompanyIdentityState(company); + + attrs.poaSameAsOwner = false; + for (const key of [ + ...POA_ATTRIBUTES, + "poaFaydaSub", + "poaFaydaVerifiedAt", + "poaBirthdate", + "poaGender", + ]) { + attrs[key] = null; + } + + const updated = await this.companiesRepo.update(company.id, { + attributes: attrs, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + /** * Drop the Power of Attorney entirely — the verified identity, the details it * wrote and the delegation paper together. @@ -2974,7 +3089,7 @@ export class CompaniesService { ); } - const cleared: Record = {}; + const cleared: Record = { poaSameAsOwner: false }; for (const key of [ ...POA_ATTRIBUTES, "poaFaydaSub", 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 ae4a73a77..df39949d4 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 @@ -75,6 +75,12 @@ export class CompanyIdentityStateDto { @ApiProperty({ type: IdentityVerificationStateDto }) poa!: IdentityVerificationStateDto; + @ApiProperty({ + description: + "True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.", + }) + poaSameAsOwner!: boolean; + @ApiProperty({ type: IdentityVerificationStateDto, description: @@ -196,6 +202,7 @@ export function buildCompanyIdentityState( const gm = stateFor(attrs, "gm"); const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); + const poaSameAsOwner = Boolean(attrs.poaSameAsOwner); const ownerProven = faydaRequired ? owner.verified @@ -220,6 +227,7 @@ export function buildCompanyIdentityState( passportRequired, owner, poa, + poaSameAsOwner, gm, gmSameAsOwner, complete, diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index a8d2f24a2..9b69bb15d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -42,6 +42,12 @@ export interface OnboardingPoaState { required: boolean; /** True once any PoA detail has been entered. */ provided: boolean; + /** + * True when the DARS delegation paper is owed — a PoA exists (or is + * mandatory) and is not the owner themselves. An owner representing their own + * company delegates to nobody, so there is no delegation to evidence. + */ + delegationLetterRequired: boolean; /** True when the DARS delegation paper is stored for the company. */ delegationLetterUploaded: boolean; /** True when a reviewer sent the paper back for correction. */ 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 d15d88b92..5fd19fe4f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -355,28 +355,33 @@ export default function CompanyProfileForm({ // in flight), so the initial state above freezes at `false` — adopt the // server's declaration the moment it lands, or a resumed draft shows an // unticked box over a GM that is linked server-side. + const [poaSameAsOwner, setPoaSameAsOwner] = useState( + identity?.poaSameAsOwner ?? false, + ); const identityLoaded = useRef(false); useEffect(() => { if (!identity || identityLoaded.current) return; identityLoaded.current = true; setGmSameAsOwner(identity.gmSameAsOwner); + setPoaSameAsOwner(identity.poaSameAsOwner); }, [identity]); const [contactSameAsGm, setContactSameAsGm] = useState(false); - // A Fayda-verified owner outranks eTrade's registered owner — it's the - // higher-trust source, and the whole point of proving identity is to stop - // trusting typed/looked-up data for this. - const gmSourceName = firstPresent( + // Where the owner's details come from when they are copied onto someone else + // — the GM, or the representative. A Fayda-verified owner outranks eTrade's + // registered owner: it's the higher-trust source, and the whole point of + // proving identity is to stop trusting typed/looked-up data for this. + const ownerSourceName = firstPresent( identity?.owner.name, etradeOwner?.name, user.name?.en, ); - const gmSourceEmail = firstValidEmail(identity?.owner.email, user.email); + const ownerSourceEmail = firstValidEmail(identity?.owner.email, user.email); // Same reason as `derivedPhone`: this value is written into - // `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an - // unusable eTrade number here 400s the personnel step instead. - const gmSourcePhone = firstValidPhone( + // `generalManagerPhone` / `poaPhone`, which the API validates with + // `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead. + const ownerSourcePhone = firstValidPhone( identity?.owner.phone, etradeOwner?.phone, user.phoneNumber, @@ -388,13 +393,30 @@ export default function CompanyProfileForm({ // `identity.gm`; mirroring it into form fields here would send typed // values for something the API already owns. if (identity?.owner.verified) return; - setValue("generalManagerName", gmSourceName, { shouldValidate: true }); - setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true }); - setValue("generalManagerPhone", gmSourcePhone, { + setValue("generalManagerName", ownerSourceName, { shouldValidate: true }); + setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true }); + setValue("generalManagerPhone", ownerSourcePhone, { shouldValidate: true, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]); + }, [gmSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]); + + // The representative's half of the same copy. A verified owner's identity is + // copied server-side and read back from `identity.poa`, so only an owner + // backed by a typed passport is mirrored into form fields here — the same + // split the GM makes above, for the same reason. + // + // Only non-empty sources are written. A source the owner does not have is a + // gap the step renders an input for (see `poaGaps`), and this effect re-runs + // whenever any *other* source changes — so blanking here would wipe what the + // customer is typing into that input the moment an eTrade lookup lands. + useEffect(() => { + if (!poaSameAsOwner || identity?.owner.verified) return; + if (ownerSourceName) setValue("poaName", ownerSourceName, { shouldValidate: true }); + if (ownerSourceEmail) setValue("poaEmail", ownerSourceEmail, { shouldValidate: true }); + if (ownerSourcePhone) setValue("poaPhone", ownerSourcePhone, { shouldValidate: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [poaSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]); /** * "Same as owner" has two meanings depending on what backs the owner. @@ -439,6 +461,44 @@ export default function CompanyProfileForm({ } }; + /** + * The representative is the owner. Unlike the GM's card this always goes to + * the API, whichever backs the owner: the declaration itself is what waives + * the DARS delegation paper, so it has to be recorded server-side even when + * there is no proven identity to copy and the details are mirrored locally. + */ + const [poaLinkPending, setPoaLinkPending] = useState(false); + const togglePoaSameAsOwner = async (checked: boolean) => { + setSaveError(null); + setPoaSameAsOwner(checked); + setPoaLinkPending(true); + try { + if (checked) await verifaydaService.setPoaSameAsOwner(); + else { + await verifaydaService.clearPoaSameAsOwner(); + // Only the locally mirrored values are ours to clear; a copied identity + // is cleared by the call above. + if (!identity?.owner.verified) { + setValue("poaName", ""); + setValue("poaEmail", ""); + setValue("poaPhone", ""); + } + } + onIdentityChange?.(); + } catch (err) { + setPoaSameAsOwner(!checked); + setSaveError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error + ? err.message + : "Could not update the Power of Attorney"), + ); + } finally { + setPoaLinkPending(false); + } + }; + // Where the GM's details come from depends on how they were established: a // Fayda verification (or a "same as owner" declaration) owns them outright, // and only a company that may still type them falls back to form state. @@ -704,7 +764,12 @@ export default function CompanyProfileForm({ // verify with Fayda — the API demands it at completion either way. Keying // this on the verification alone hid the upload from a foreign forwarder and // then failed them on submit for a file they were never shown. - const delegationRequired = poaProvided || requirePoa; + // + // Unless the owner represents the company themselves: nobody delegates to + // themselves, so there is no delegation to evidence. Mirrors the API's own + // waiver in `assertPoaDelegationSatisfied` — the two must agree, or this + // demands a file the server would accept the submission without. + const delegationRequired = (poaProvided || requirePoa) && !poaSameAsOwner; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -735,12 +800,38 @@ export default function CompanyProfileForm({ // which is also the only case the API refuses to let anyone overwrite. const poaGap = (v?: string | null) => poaTypedAllowed && (!identity?.poa.verified || !v?.trim()); - const poaGaps = { - name: poaGap(identity?.poa.name), - email: poaGap(identity?.poa.email), - phone: poaGap(identity?.poa.phone), - address: poaGap(identity?.poa.address), - }; + /** + * "Same as owner" answers each field only as far as the owner actually has + * one. Fayda's name, email and phone claims are all optional, the account and + * eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still + * demands a name, an email and a phone — so anything the copy could not + * supply stays askable. Assuming the copy filled everything is what dead-ends + * the submit on "Add your poa phone" with no input anywhere to satisfy it. + * + * Keyed on the *source*, never on the field's current value: an input that + * disappears the moment the first character is typed into it is unusable. + * A verified owner's identity is copied server-side, so `identity.poa` is the + * source there; otherwise it is the same owner-derived values the mirror + * effect writes. + */ + const poaCopyGap = (copied?: string | null, mirrored?: string | null) => + identity?.owner.verified ? !copied?.trim() : !mirrored?.trim(); + const poaGaps = poaSameAsOwner + ? { + name: poaCopyGap(identity?.poa.name, ownerSourceName), + email: poaCopyGap(identity?.poa.email, ownerSourceEmail), + phone: poaCopyGap(identity?.poa.phone, ownerSourcePhone), + // The location is the one detail the API never demands, so a blank one + // dead-ends nothing — and asking for the owner's city under a card that + // says "same as owner" reads as a contradiction. + address: false, + } + : { + name: poaGap(identity?.poa.name), + email: poaGap(identity?.poa.email), + phone: poaGap(identity?.poa.phone), + address: poaGap(identity?.poa.address), + }; // The GM's own verification never falls back to the signed-in account — that // account is the person onboarding, not necessarily the manager — so a GM // verified with no email claim has nowhere else for one to come from. The @@ -1024,6 +1115,10 @@ export default function CompanyProfileForm({ form={form} identity={identity} requirePoa={requirePoa} + poaSameAsOwner={poaSameAsOwner} + onTogglePoaSameAsOwner={togglePoaSameAsOwner} + poaLinkPending={poaLinkPending} + etradeOwner={etradeOwner} gaps={poaGaps} onRemovePoa={removePoa} removePending={poaRemovePending} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx index da0dd6254..d7721d208 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx @@ -10,20 +10,27 @@ export function LinkCheckboxCard({ onToggle, title, description, + disabled = false, }: { checked: boolean; onToggle: (checked: boolean) => void; title: string; description: string; + /** Greys the card out and refuses the toggle — the link is not available yet. */ + disabled?: boolean; }) { return ( onToggle(!checked)} + onClick={() => !disabled && onToggle(!checked)} role="checkbox" aria-checked={checked} - className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked + aria-disabled={disabled} + className={`w-full rounded-lg border! p-3! text-left transition-colors! ${disabled ? "cursor-not-allowed! opacity-60!" : "" + } ${checked ? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!" - : "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!" + : disabled + ? "border-[var(--mantine-color-gray-3)]!" + : "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!" }`} > diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx index 6160b6690..efece363b 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx @@ -9,12 +9,20 @@ import type { FileUploadSetting } from "@/types/fileUploadSettings"; import type { CompanyIdentityState } from "@/services/verifayda.service"; import type { FormData } from "../schema"; +import { LinkCheckboxCard } from "../LinkCheckboxCard"; export interface PoaStepProps { form: UseFormReturn; identity?: CompanyIdentityState; /** This company holds a freight-forwarder profile, so the PoA is mandatory. */ requirePoa: boolean; + /** The owner represents the company themselves. */ + poaSameAsOwner: boolean; + onTogglePoaSameAsOwner: (checked: boolean) => void; + /** A server-side "same as owner" declaration is in flight. */ + poaLinkPending: boolean; + /** eTrade-registered owner, once a TIN lookup has succeeded. */ + etradeOwner: { name: string; phone: string } | null; /** * Which of the representative's details the Fayda verification did not * supply, and are therefore typed here. Computed by CompanyProfileForm, which @@ -39,6 +47,10 @@ export default function PoaStep({ form, identity, requirePoa, + poaSameAsOwner, + onTogglePoaSameAsOwner, + poaLinkPending, + etradeOwner, gaps, onRemovePoa, removePending, @@ -65,26 +77,64 @@ export default function PoaStep({ const needsEmail = gaps.email; const needsPhone = gaps.phone; + // Fayda is mandatory for an Ethiopian company's representative, so there the + // link can only reuse a proven owner — with none there would be nothing to + // copy and the declaration could never satisfy the gate. A foreign company's + // owner is backed by a typed passport, so it prefills instead. + const linkNeedsVerifiedOwner = + (identity?.faydaRequired ?? false) && !identity?.owner.verified; + return ( <> {requirePoa - ? "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."}{" "} - {/* The API refuses an owner who delegates to themselves — say so here, - or the customer only finds out after being sent to Fayda and back. */} - The representative must be someone other than the company's owner. + ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details are required." + : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue."}{" "} + {poaSameAsOwner + ? "You represent the company yourself, so no delegation paper is needed." + : "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."} + + {/* An owner who represents their own company is the ordinary + small-business case. Where the owner is Fayda-verified this reuses + that proven identity outright rather than sending the same human + through Fayda twice; where they are backed by a typed passport there + is nothing proven to copy, so it stays a local prefill. Either way it + is the declaration that waives the DARS paper. */} + {identity && ( + + )} + {/* 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 && ( + from the verification and are never typed here. Verifying a second + person is only meaningful when the representative is not the owner. */} + {identity && !poaSameAsOwner && ( )} {/* A verification cannot be undone by clearing the form — it owns those @@ -92,7 +142,7 @@ export default function PoaStep({ then blocks the submit. So an optional representative needs a way back out, here rather than only in settings (unreachable until onboarding finishes). */} - {identity?.poa.verified && !requirePoa && ( + {identity?.poa.verified && !requirePoa && !poaSameAsOwner && (