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 7665d82eb..1c9d75487 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -408,6 +408,30 @@ export class CompaniesController { return this.companiesService.completeIdentityVerification(user.id, dto); } + @Post("identity/gm/same-as-owner") + @ApiOperation({ + summary: + "Declare the General Manager is the company's owner, copying the owner's verified identity across. " + + "Refused until the owner is Fayda-verified — there would be nothing proven to copy.", + }) + async setGmSameAsOwner( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.setGmSameAsOwner(user.id); + } + + @Delete("identity/gm") + @ApiOperation({ + summary: + "Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " + + "Leaves the GM open to be verified in their own right, or typed where Fayda is optional.", + }) + async clearGmIdentity( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.clearGmIdentity(user.id); + } + @Delete("identity/fayda/poa") @ApiOperation({ summary: 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 511c3aa25..32513df4f 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 @@ -21,9 +21,11 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation. * 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. + * The owner is NOT the general manager. The GM is proved the same way, by one + * of two routes — verifying in their own right, or being declared the owner, + * which reuses that verification rather than making one human prove themselves + * twice. It stays out of the trading gate either way: the GM names who to talk + * to, not what the company may do. */ interface Ctx { @@ -418,10 +420,12 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi ).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. + it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => { + // Fayda is an Ethiopian national ID, so only an Ethiopian company's + // representative can be held to it. A foreign company is offered the + // verification and uses it where its representative holds one, but a typed + // name stays sufficient — holding it to Fayda would leave a foreign + // company whose representative has no Fayda ID unable to trade at all. const { service } = makeService({ profileTypes: [ProfileType.importer], nationality: CompanyNationality.Foreign, @@ -434,6 +438,48 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi files: [paper()], }); + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("still refuses a foreign company that named no PoA at all", async () => { + // The typed fallback is a different credential, not a waiver: a freight + // forwarder acts on other companies' behalf and needs a representative + // whatever its nationality. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { ownerPassportNumber: "P1234567" }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => { + // The relaxation above is scoped to foreign companies only — an Ethiopian + // representative holds a Fayda ID, so typing a name must not substitute. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Ethiopian, + attributes: { + ...OWNER_VERIFIED, + poaName: "Abebe Bekele", + poaEmail: "abebe@example.com", + poaPhone: "+251911000000", + }, + files: [paper()], + }); + await expect( service.createCompanyProfileForUser( "user-1", @@ -463,4 +509,119 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi ), ).rejects.toBeInstanceOf(BadRequestException); }); + + // ------------------------------------------------------------------------- + // General manager + // ------------------------------------------------------------------------- + + it("reuses the owner's verified identity when the GM is declared the same person", async () => { + // The GM is very often the owner. Copying the proven identity is the whole + // point — asking one human to complete two verifications proves nothing + // extra, and typing the details instead would forge a verified badge. + const { service, ctx } = makeService({ + attributes: { + ...OWNER_VERIFIED, + ownerEmail: "abebe@example.com", + ownerPhone: "+251911222333", + }, + }); + + const state = await service.setGmSameAsOwner("user-1"); + + expect(state.gm.verified).toBe(true); + expect(state.gmSameAsOwner).toBe(true); + expect(state.gm.name).toBe("Abebe Bikila"); + expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); + // The notifiers mail the flat column, so a linked GM has to land there too. + expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); + }); + + it("refuses to declare the GM is the owner while the owner is unverified", async () => { + // Without a verification there is no proven identity to copy — only typed + // text, which would arrive wearing a badge it had not earned. + const { service } = makeService({ attributes: {} }); + + await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + 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. + const { service, ctx } = makeService({ + attributes: { ...OWNER_VERIFIED }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "owner-sub", + fullName: "Abebe Bikila", + email: "abebe@example.com", + phoneNumber: "+251911222333", + }, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "gm", + code: "c", + state: "s", + }); + + expect(state.gm.verified).toBe(true); + expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); + 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", + }, + }); + + await expect( + service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("reports a pre-existing typed GM as unverified rather than blank", async () => { + // Companies onboarded before the GM was verifiable have typed details and + // no gm* attributes. Those details are still what the notifiers mail, so + // they must survive — flagged unverified so the portal offers the upgrade. + const { service, company } = makeService({ + attributes: { + ...OWNER_VERIFIED, + generalManagerName: "Legacy Manager", + generalManagerEmail: "legacy@example.com", + }, + }); + + const state = service.getCompanyIdentityState(company() as never); + + expect(state.gm.verified).toBe(false); + expect(state.gm.name).toBe("Legacy Manager"); + expect(state.gm.email).toBe("legacy@example.com"); + }); + + it("does not let an unproven GM block the company from trading", async () => { + // The GM names who to talk to, not what the company may do. Capturing it + // through Fayda changed how it is collected, not whether it gates. + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.createCompanyProfileForUser("user-1", ProfileType.importer), + ).resolves.toBeDefined(); + }); }); 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 e0af07e0b..f21263c7f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -33,6 +33,7 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, CompleteIdentityVerificationDto, + IDENTITY_SUBJECTS, IdentitySubject, } from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; @@ -122,31 +123,47 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ /** * `attributes` key prefix per verifiable person. The owner is NOT the general - * manager — GM is a plain typed role (the portal offers a "same as owner" copy - * once the owner is verified), while the owner is who this verification - * actually proves. They're very often the same human; that's what the copy is - * for. + * manager: the owner is who the verification proves the company through, the + * GM is personnel it names. They're very often the same human, which is what + * the portal's "same as owner" copy is for. */ -const IDENTITY_PREFIX: Record = { +const IDENTITY_PREFIX: Record = { owner: "owner", poa: "poa", + 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 + * verified GM whose address the system never actually uses. + */ +const GM_TYPED_FIELDS = [ + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", +] as const; + /** * Identity fields a Fayda verification owns outright, per person. Once verified * these can no longer be typed — the government IdP is the source, so an edit * that disagrees with it is either a mistake or an attempt to launder the - * guarantee away. The GM fields are deliberately absent: GM is never itself - * Fayda-verified, so it stays freely editable regardless of the owner's state. + * guarantee away. + * + * The GM's entries are its typed columns: a verified GM is locked the same way + * the others are, while an unverified one (a foreign company's, or a record + * that predates this) stays freely editable. */ const IDENTITY_OWNED_FIELDS: Record = { owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], + gm: [...GM_TYPED_FIELDS], }; /** @@ -834,7 +851,7 @@ export class CompaniesService { // Renaming a Fayda-verified person by hand would launder the guarantee // away, so the fields the verification owns are refused once it exists. - for (const subject of ["owner", "poa"] as IdentitySubject[]) { + for (const subject of IDENTITY_SUBJECTS) { if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; for (const field of IDENTITY_OWNED_FIELDS[subject]) { const incoming = (dto as Record)[field]; @@ -2693,12 +2710,17 @@ 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. - 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.`, - ); + // 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.`, + ); + } } const now = new Date().toISOString(); @@ -2714,13 +2736,26 @@ export class CompaniesService { ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; + // A GM verification also lands on the typed columns the rest of the system + // already reads (the booking, train-scheduling and contract notifiers all + // mail `generalManagerEmail`), and clears any earlier "same as owner" + // declaration — verifying in their own right is the GM answering for + // themselves. + if (dto.subject === "gm") { + identity.gmSameAsOwner = false; + if (result.fullName) identity.generalManagerName = result.fullName; + if (result.email) identity.generalManagerEmail = result.email; + if (result.phoneNumber) + identity.generalManagerPhone = normalizeE164(result.phoneNumber); + } + // 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") { + // record. The PoA and GM are personnel — the company names its own + // representative and manager, and the delegation letter backing the PoA is + // what the reviewer sees — so those land live, matching their typed + // counterparts in `SELF_SERVICE_ATTRIBUTES`. + if (company.status === CompanyStatus.Active && dto.subject === "owner") { await this.stageIdentityChange(company, userId, identity); return this.getCompanyIdentityState(company); } @@ -2734,6 +2769,85 @@ export class CompaniesService { return this.getCompanyIdentityState(updated); } + /** + * Declare that the General Manager is the company's owner. + * + * The GM is very often the owner, and making that human verify twice buys + * nothing — the owner's verification already proves them. So this copies the + * owner's verified identity across rather than starting a second flow, and + * records `gmSameAsOwner` so the portal can show it as a declaration rather + * than as a verification the GM passed in their own right. + * + * Refused until the owner is actually verified: without that there is no + * proven identity to copy, only typed text that would arrive wearing a + * verified badge. + */ + async setGmSameAsOwner(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const attrs = company.attributes ?? {}; + const ownerSub = attrs.ownerFaydaSub as string | undefined; + if (!ownerSub) { + throw new BadRequestException( + "Verify the company owner with Fayda first — there is no proven identity to reuse yet.", + ); + } + + const copied: Record = { + gmSameAsOwner: true, + gmFaydaSub: ownerSub, + gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), + gmName: attrs.ownerName ?? null, + gmEmail: attrs.ownerEmail ?? null, + gmPhone: attrs.ownerPhone ?? null, + gmAddress: attrs.ownerAddress ?? null, + gmBirthdate: attrs.ownerBirthdate ?? null, + gmGender: attrs.ownerGender ?? null, + // Kept in step for the notifiers, same as a GM verification does. + generalManagerName: attrs.ownerName ?? null, + generalManagerEmail: attrs.ownerEmail ?? null, + generalManagerPhone: attrs.ownerPhone ?? null, + }; + + 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 "same as owner" declaration, clearing the copied identity so the + * GM can be verified in their own right (or typed, where Fayda is optional). + */ + async clearGmIdentity(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const attrs = { ...(company.attributes ?? {}) }; + for (const key of [ + "gmSameAsOwner", + "gmFaydaSub", + "gmFaydaVerifiedAt", + "gmName", + "gmEmail", + "gmPhone", + "gmAddress", + "gmBirthdate", + "gmGender", + ...GM_TYPED_FIELDS, + ]) { + 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. @@ -2864,14 +2978,28 @@ export class CompaniesService { ); } - // 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(), ); if (!opts.requirePoa && !poaNamed) return; + // Fayda is an Ethiopian national ID, so only an Ethiopian company's + // representative can be held to it. A foreign company is offered the + // verification and nominates a Fayda-holding representative where it can, + // but a typed name has to remain sufficient — otherwise a foreign company + // whose representative holds no Fayda ID could never trade at all. Mirrors + // `poaProven` in buildCompanyIdentityState; the two must agree. + if (state.passportRequired) { + if (!state.poa.verified && !state.poa.name?.trim()) { + throw new BadRequestException( + opts.requirePoa + ? "Name your Power of Attorney — a freight forwarder cannot operate without one." + : "Complete the Power of Attorney you named, or remove the representative.", + ); + } + return; + } + if (!state.poa.verified) { throw new BadRequestException( opts.requirePoa 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 a9988cd28..d41e61e8e 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 @@ -5,13 +5,15 @@ import { Company, CompanyNationality } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; /** - * The two people a company is verified through — its owner and its Power of - * Attorney. "Owner" is not the same as the General Manager: a company's GM is - * a plain typed role (with a "same as owner" copy the portal offers), while - * the owner is the person this verification proves. They're very often the - * same human, which is exactly what the copy is for. + * The three people a company is verified through — its owner, its Power of + * Attorney and its General Manager. The owner is the person the company's + * existence is proven by; the other two are personnel it names. + * + * The GM is very often the owner, which is what the portal's "same as owner" + * copy is for: that path reuses the owner's verified identity outright rather + * than asking the same human to verify twice. */ -export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; +export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const; export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; export class CompleteIdentityVerificationDto { @@ -73,6 +75,19 @@ export class CompanyIdentityStateDto { @ApiProperty({ type: IdentityVerificationStateDto }) poa!: IdentityVerificationStateDto; + @ApiProperty({ + type: IdentityVerificationStateDto, + description: + "General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.", + }) + gm!: IdentityVerificationStateDto; + + @ApiProperty({ + description: + "True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.", + }) + gmSameAsOwner!: boolean; + @ApiProperty({ description: "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", @@ -81,11 +96,28 @@ export class CompanyIdentityStateDto { } /** `attributes` key prefix per person. */ -const PREFIX: Record = { +const PREFIX: Record = { owner: "owner", poa: "poa", + gm: "gm", }; +/** + * Typed GM fields, kept in step with the Fayda-written ones. + * + * The GM predates this verification: its details are plain company columns + * that three notifier services mail (booking-lifecycle, train-scheduling and + * contract notifiers all read `company.generalManagerEmail`). A verification + * therefore writes BOTH — the `gm*` attributes carry the proof, these carry + * the value everything else already reads — and an unverified company keeps + * showing whatever was typed before this existed. + */ +const GM_TYPED_KEYS = { + name: "generalManagerName", + email: "generalManagerEmail", + phone: "generalManagerPhone", +} as const; + /** company.attributes keys that together mean "a PoA was entered". */ const POA_KEYS = [ "poaName", @@ -101,7 +133,7 @@ function stateFor( ): IdentityVerificationStateDto { const p = PREFIX[subject]; const read = (key: string) => (attrs[key] as string | undefined) ?? null; - return { + const state: IdentityVerificationStateDto = { verified: Boolean(read(`${p}FaydaSub`)), name: read(`${p}Name`), phone: read(`${p}Phone`), @@ -111,6 +143,18 @@ function stateFor( birthdate: read(`${p}Birthdate`), gender: read(`${p}Gender`), }; + if (subject !== "gm") return state; + + // Companies onboarded before the GM was verifiable have typed details and no + // `gm*` attributes at all. Report those rather than a blank card — they are + // still what the notifiers mail — leaving `verified` false so the portal + // offers the upgrade instead of pretending the identity is proven. + return { + ...state, + name: state.name ?? read(GM_TYPED_KEYS.name), + email: state.email ?? read(GM_TYPED_KEYS.email), + phone: state.phone ?? read(GM_TYPED_KEYS.phone), + }; } /** @@ -144,14 +188,34 @@ export function buildCompanyIdentityState( (p) => p.type === ProfileType.freightForwarder, ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); - // 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 gm = stateFor(attrs, "gm"); + const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); + const ownerProven = faydaRequired ? owner.verified : !passportRequired || Boolean(owner.passportNumber); - const complete = ownerProven && (!poaDue || poa.verified); - return { faydaRequired, passportRequired, owner, poa, complete }; + // Fayda is an Ethiopian national ID, so only an Ethiopian company's + // personnel can be held to it. A foreign company may nominate a + // representative who holds one — and is offered the verification — but a + // typed name has to remain sufficient, or a foreign company whose PoA has no + // Fayda ID could never finish onboarding. + const poaProven = faydaRequired + ? poa.verified + : poa.verified || Boolean(poa.name?.trim()); + + // The GM is deliberately absent from this verdict: it names who to talk to, + // not what the company may do, and it has never gated trading. Capturing it + // through Fayda changes how it is collected, not whether it is required. + const complete = ownerProven && (!poaDue || poaProven); + + return { + faydaRequired, + passportRequired, + owner, + poa, + gm, + gmSameAsOwner, + complete, + }; } diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index d75b07412..466307fe9 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -437,6 +437,10 @@ export default function OnboardingWizardDialog({ // stays a plain typed role. Mandatory (Fayda) for an Ethiopian company; // a foreign one requires a typed passport number for the owner instead. identity: requirementsQuery.data?.identity, + onIdentityChange: () => { + void profileQuery.refetch(); + void requirementsQuery.refetch(); + }, // Surface a failed final submit (license/document upload or complete) inside // the form — otherwise the server message (e.g. a 500) would be invisible on // the submit step. diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index ce357467e..df3511d8f 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -82,6 +82,12 @@ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean { case "contact": return !profile.contactPersonName || !profile.contactPersonPhone; case "gm": + // The GM is established through Fayda — verified in their own right or + // declared the same person as the owner — so the identity answers this, + // not the typed columns. A company that may still type them (foreign, + // whose manager may hold no Fayda ID) is judged on those instead. + if (profile.identity?.gm.verified) return false; + if (profile.identity?.faydaRequired) return true; return ( !profile.generalManagerName || !profile.generalManagerEmail || 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 a122b5440..17c88d792 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -42,6 +42,7 @@ import { toFormValues, } from "./companyProfileForm/helpers"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; +import { verifaydaService } from "@/services/verifayda.service"; import type { CompanyIdentityState } from "@/services/verifayda.service"; import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard"; import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard"; @@ -67,6 +68,7 @@ export default function CompanyProfileForm({ uploadedDocumentKeys, onUploadDocuments, identity, + onIdentityChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -106,6 +108,12 @@ export default function CompanyProfileForm({ >; /** Fayda verification state for the owner and the PoA (undefined until loaded). */ identity?: CompanyIdentityState; + /** + * Refetch the profile + requirements. Only the in-page identity actions need + * this — a Fayda verification navigates the whole tab away and comes back to + * a freshly booted app, so it has nothing to notify. + */ + onIdentityChange?: () => void; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -339,7 +347,11 @@ export default function CompanyProfileForm({ // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears // them and re-enables editing. - const [gmSameAsOwner, setGmSameAsOwner] = useState(false); + // Seeded from the server so a resumed draft reopens with the declaration the + // company already made, rather than an unticked box over a linked GM. + const [gmSameAsOwner, setGmSameAsOwner] = useState( + identity?.gmSameAsOwner ?? false, + ); const [contactSameAsGm, setContactSameAsGm] = useState(false); // General Manager source. The company step's email/phone are seeded from @@ -366,6 +378,10 @@ export default function CompanyProfileForm({ useEffect(() => { if (!gmSameAsOwner) return; + // A verified owner's identity is copied server-side and read back from + // `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, { @@ -374,18 +390,78 @@ export default function CompanyProfileForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]); - const toggleGmSameAsOwner = (checked: boolean) => { + /** + * "Same as owner" has two meanings depending on what backs the owner. + * + * A Fayda-verified owner is a proven identity, so the declaration is made + * server-side: the API copies that identity onto the GM and records what it + * did. Anything typed here would arrive wearing a verified badge it hadn't + * earned, which is exactly what the verification exists to prevent. + * + * A foreign company's owner is backed by a typed passport instead, so there + * is nothing proven to copy — that stays the local field-mirroring it has + * always been. + */ + const [gmLinkPending, setGmLinkPending] = useState(false); + const toggleGmSameAsOwner = async (checked: boolean) => { setGmSameAsOwner(checked); - if (!checked) { - setValue("generalManagerName", ""); - setValue("generalManagerEmail", ""); - setValue("generalManagerPhone", ""); + if (!identity?.owner.verified) { + if (!checked) { + setValue("generalManagerName", ""); + setValue("generalManagerEmail", ""); + setValue("generalManagerPhone", ""); + } + return; + } + + setGmLinkPending(true); + try { + if (checked) await verifaydaService.setGmSameAsOwner(); + else await verifaydaService.clearGmIdentity(); + onIdentityChange?.(); + } catch (err) { + setGmSameAsOwner(!checked); + setSaveError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error ? err.message : "Could not update the general manager"), + ); + } finally { + setGmLinkPending(false); } }; - const gmName = watch("generalManagerName"); - const gmEmail = watch("generalManagerEmail"); - const gmPhone = watch("generalManagerPhone"); + // 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. + const gmVerified = identity?.gm.verified ?? false; + const gmName = gmVerified + ? (identity?.gm.name ?? "") + : watch("generalManagerName"); + const gmEmail = gmVerified + ? (identity?.gm.email ?? "") + : watch("generalManagerEmail"); + const gmPhone = gmVerified + ? (identity?.gm.phone ?? "") + : watch("generalManagerPhone"); + + /** + * Whether the GM has been established at all — by verification, by the + * "same as owner" declaration, or (only where Fayda is optional) by typing. + * Fayda is an Ethiopian national ID, so a foreign company's GM may hold none. + */ + const gmTyped = Boolean( + watch("generalManagerName")?.trim() && + watch("generalManagerEmail")?.trim() && + watch("generalManagerPhone")?.trim(), + ); + const gmEstablished = + gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped); + + /** Same rule for the representative: verified, or typed where Fayda is optional. */ + const poaEstablished = + (identity?.poa.verified ?? false) || + (identity ? !identity.faydaRequired && Boolean(watch("poaName")?.trim()) : false); // 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. @@ -627,9 +703,23 @@ export default function CompanyProfileForm({ setSaveError("Verify the company owner's identity with Fayda before continuing."); return; } - if (step === "poa" && requirePoa && !identity?.poa.verified) { + // The GM is established through Fayda now, so the step gates on the + // identity rather than on typed text — same strength as the old required + // fields, different evidence. A foreign company's GM may hold no Fayda ID, + // so typed details still satisfy it there. + if (step === "personnel" && !gmEstablished) { setSaveError( - "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.", + identity?.faydaRequired + ? "Verify the general manager with Fayda, or tick “same as owner” if they are the company's owner." + : "Add the general manager's details, or verify them with Fayda.", + ); + return; + } + if (step === "poa" && requirePoa && !poaEstablished) { + setSaveError( + identity?.faydaRequired + ? "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 a Power of Attorney is required.", ); return; } @@ -764,10 +854,11 @@ export default function CompanyProfileForm({ General Manager - {/* GM is a plain typed role, not the person the Fayda - verification proves — the owner is (see the Company step). - They're very often the same human, which "same as owner" is - for once the owner has verified. */} + {/* The GM is very often the owner. Where the owner is + Fayda-verified this reuses that proven identity outright + rather than making the same human verify twice; where the + owner is backed by a typed passport there is nothing proven + to copy, so it stays a local prefill. */} - - - - - + )} + + {/* Typed details survive only where Fayda cannot be required — + a foreign company's manager may hold no Fayda ID. Once + verified the API owns these fields, so they go away. */} + {!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && ( + <> + + + + + + + )} )} @@ -872,14 +983,17 @@ export default function CompanyProfileForm({ required={requirePoa} /> )} - {/* The city is the one field the Fayda address claim does not - reliably decompose into, so it stays typed. */} - + {/* The address comes from the Fayda claim along with the name, + so it is shown on the panel rather than typed. Only a company + whose representative may hold no Fayda ID still types it. */} + {!identity?.poa.verified && !identity?.faydaRequired && ( + + )} {/* The paper authorises the representative the verification named, so it only has meaning once one exists. */} 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 e2842dbe6..ff404ebe3 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 @@ -69,12 +69,24 @@ export const onboardingSchema = z.object({ .string() .min(1, "Contact person phone is required") .refine(isValidPhone, "Enter a valid phone number"), - generalManagerName: z.string().min(1, "Manager name is required"), - generalManagerEmail: z.string().email("Invalid Manager email"), + // Optional here, not unrequired: the GM is now established by Fayda — either + // verified in their own right or declared the same person as the owner — so + // for an Ethiopian company these fields are never typed and would fail a + // blanket `min(1)`. Presence is gated per nationality in the step's own + // check, where the identity state is available; zod only polices format for + // the foreign companies that still type them. + generalManagerName: z.string().optional(), + generalManagerEmail: z + .string() + .optional() + .refine( + (v) => !v || z.string().email().safeParse(v).success, + "Invalid Manager email", + ), generalManagerPhone: z .string() - .min(1, "Manager phone is required") - .refine(isValidPhone, "Enter a valid phone number"), + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaName: z.string().optional(), poaPhone: z .string() diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx index ca4e3a506..7191d162c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react"; import { + Alert, Card, Group, Stack, @@ -15,17 +16,29 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; +import { verifaydaService } from "@/services/verifayda.service"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard"; +import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import type { ProfileResponse } from "@/types/profile"; +// Optional, not unrequired: an Ethiopian company's GM is established through +// Fayda and never types these, so a blanket `min(1)` would fail a form that is +// correct. Presence is gated below, where the identity state says which route +// applies; zod only polices format for the companies that still type them. const schema = z.object({ - generalManagerName: z.string().min(1, "GM name is required"), - generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerName: z.string().optional(), + generalManagerEmail: z + .string() + .optional() + .refine( + (v) => !v || z.string().email().safeParse(v).success, + "Invalid GM email", + ), generalManagerPhone: z .string() - .min(1, "GM phone is required") - .refine(isValidPhone, "Enter a valid phone number"), + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), }); type FormData = z.infer; @@ -37,16 +50,20 @@ interface TabGeneralManagerProps { } /** - * The general manager is a plain typed role, not the person the Fayda - * verification proves — the owner is. They're very often the same human, - * which is what "Same as owner" is for: once the owner has verified, this - * copies their name/email/phone in rather than making the customer re-type - * data the company already proved. + * The general manager's identity comes from Fayda: either verified in their + * own right, or declared to be the owner — very often the same human, which is + * what "Same as owner" is for. Typed details survive only for a foreign + * company, whose manager may hold no Fayda ID at all. */ export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) { const queryClient = useQueryClient(); const owner = profile.identity?.owner; - const [gmSameAsOwner, setGmSameAsOwner] = useState(false); + const identity = profile.identity; + const gm = identity?.gm; + const faydaRequired = identity?.faydaRequired ?? false; + const [gmSameAsOwner, setGmSameAsOwner] = useState( + identity?.gmSameAsOwner ?? false, + ); const defaultValues = useMemo((): FormData => { return { @@ -68,25 +85,45 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } values: defaultValues, }); - const toggleGmSameAsOwner = (checked: boolean) => { + /** + * With a Fayda-verified owner the declaration is made server-side — the API + * copies the proven identity onto the GM — so nothing is typed here. Without + * one (a foreign company, whose owner is backed by a passport) there is + * nothing proven to copy and this stays a local prefill. + */ + const [linkPending, setLinkPending] = useState(false); + const [linkError, setLinkError] = useState(null); + const toggleGmSameAsOwner = async (checked: boolean) => { setGmSameAsOwner(checked); - if (checked && owner) { - setValue("generalManagerName", owner.name ?? "", { shouldValidate: true }); - setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true }); - setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true }); + setLinkError(null); + if (!owner?.verified) { + if (checked && owner) { + setValue("generalManagerName", owner.name ?? "", { shouldValidate: true }); + setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true }); + setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true }); + } + return; + } + + setLinkPending(true); + try { + if (checked) await verifaydaService.setGmSameAsOwner(); + else await verifaydaService.clearGmIdentity(); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + } catch (err) { + setGmSameAsOwner(!checked); + setLinkError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error ? err.message : "Could not update the general manager"), + ); + } finally { + setLinkPending(false); } }; - // Keep the copy live while the checkbox is on — e.g. the owner re-verifies - // with updated details. - useEffect(() => { - if (!gmSameAsOwner || !owner) return; - setValue("generalManagerName", owner.name ?? ""); - setValue("generalManagerEmail", owner.email ?? ""); - setValue("generalManagerPhone", owner.phone ?? ""); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gmSameAsOwner, owner?.name, owner?.email, owner?.phone]); - const mutation = useMutation({ mutationFn: (data: FormData) => api.companies.updateProfile.call({ @@ -102,6 +139,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } const onSubmit = (data: FormData) => mutation.mutate(data); + // Nothing to save when Fayda owns the details: the verification and the + // "same as owner" declaration both write server-side, so the form would be + // posting empty strings over a proven identity. + const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired; + return ( @@ -114,40 +156,70 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
- {owner?.verified && ( - - )} - - - + {linkError && ( + }> + {linkError} + + )} + + {/* Verifying a second person only means something when the manager + is someone other than the owner. */} + {!gmSameAsOwner && gm && ( + + )} + + {/* Typed details survive only where Fayda cannot be required — a + foreign company's manager may hold no Fayda ID. Once verified the + API owns these fields and refuses edits, so they go away. */} + {!gmSameAsOwner && !gm?.verified && !faydaRequired && ( + <> - - - - - + + + + + + + + + + + )} - {mode === "edit" && ( + {mode === "edit" && typedFieldsInUse && ( + {/* Saving only means something while the details are typed: under + Fayda both routes write server-side, so a submit would post + empty strings at an identity the API owns and refuses to + overwrite. Onboarding still needs a way forward, so the button + becomes a plain Continue rather than disappearing. */} + {typedFieldsInUse ? ( + + ) : ( + mode === "onboarding" && ( + + ) + )}
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 08fdc8bc6..4c48ea238 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -263,19 +263,22 @@ export default function TabPowerOfAttorney({
- {/* 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. */} - - - - - + {/* Name, email, phone and address all come from the Fayda + verification and are shown on the panel above. Only a company + whose representative may hold no Fayda ID still types a + location. */} + {!poaProvided && !(identity?.faydaRequired ?? false) && ( + + + + + + )} {/* ------------------------ Delegation letter ------------------------ */} diff --git a/apps/edr-freight-web/portal/src/services/verifayda.service.ts b/apps/edr-freight-web/portal/src/services/verifayda.service.ts index e51f612a1..38b225918 100644 --- a/apps/edr-freight-web/portal/src/services/verifayda.service.ts +++ b/apps/edr-freight-web/portal/src/services/verifayda.service.ts @@ -3,11 +3,12 @@ import { unwrap } from "@/utils/endpoint"; import type { ApiResponse } from "@/types/apiResponse"; /** - * Which of the company's people a verification is for. The owner is NOT the - * general manager — GM is a plain typed role the portal offers a "same as - * owner" copy for, but only the owner and the PoA are ever Fayda-verified. + * Which of the company's people a verification is for. The owner is who the + * company is proven through; the PoA and GM are personnel it names. The GM is + * very often the owner — "same as owner" reuses that verification rather than + * making the same human prove themselves twice. */ -export type IdentitySubject = "owner" | "poa"; +export type IdentitySubject = "owner" | "poa" | "gm"; /** One person's Fayda verification state, as the API reports it. */ export interface IdentityVerificationState { @@ -29,12 +30,24 @@ export interface OwnerIdentityState extends IdentityVerificationState { } export interface CompanyIdentityState { - /** True when Fayda verification of the owner (and PoA) is mandatory — Ethiopian companies only. */ + /** + * True when Fayda verification is mandatory — Ethiopian companies only. + * Doubles as "may this person be typed instead": Fayda is an Ethiopian + * national ID, so a foreign company's GM and PoA are offered the + * verification but fall back to typed details when they hold none. + */ faydaRequired: boolean; /** True when the owner's passport number is mandatory — foreign companies only. */ passportRequired: boolean; owner: OwnerIdentityState; poa: IdentityVerificationState; + /** + * General manager. `verified` covers both routes: the GM verifying in their + * own right, and the company declaring the GM is the owner (in which case + * `gmSameAsOwner` is set and the owner's Fayda sub backs it). + */ + gm: IdentityVerificationState; + gmSameAsOwner: boolean; complete: boolean; } @@ -106,6 +119,30 @@ export const verifaydaService = { return unwrap(response.data); }, + /** + * Declare the General Manager is the company's owner, reusing the owner's + * verified identity rather than making the same human verify twice. The copy + * happens server-side from the stored owner identity — the portal never + * supplies the values — and is refused until the owner is verified. + */ + setGmSameAsOwner: async (): Promise => { + const response = await client.post>( + "/api/companies/identity/gm/same-as-owner", + ); + return unwrap(response.data); + }, + + /** + * Clear the GM's identity — the "same as owner" declaration or a verification + * of their own — leaving them open to be re-established either way. + */ + clearGmIdentity: async (): Promise => { + const response = await client.delete>( + "/api/companies/identity/gm", + ); + return unwrap(response.data); + }, + /** * Drop the Power of Attorney — verified identity, details and delegation * paper together. A verified person's fields are locked, so blanking the form diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index 7b7ce692c..8f126ea1a 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -36,11 +36,15 @@ export interface ProfileResponse { generalManagerEmail: string | null; generalManagerPhone: string | null; /** - * Fayda verification state for the owner and the PoA — not the general - * manager, which stays a plain typed role. `identity.faydaRequired` / - * `identity.passportRequired` is the Ethiopian/foreign switch: an Ethiopian - * company verifies the owner (and PoA) with Fayda; a foreign one requires a - * typed passport number for the owner instead. + * Fayda verification state for the owner, the PoA and the general manager. + * `identity.faydaRequired` / `identity.passportRequired` is the + * Ethiopian/foreign switch: an Ethiopian company verifies all three with + * Fayda, while a foreign one proves its owner with a typed passport number + * and may type its GM and PoA, whose holders may have no Fayda ID. + * + * The `generalManager*` fields above are the same person's details written + * flat — a verification keeps them in step, since the booking, contract and + * train-scheduling notifiers mail `generalManagerEmail` directly. */ identity: CompanyIdentityState; poaName: string | null;