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 fe40cf08f..30c323ac3 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 @@ -177,782 +177,451 @@ function makeService(overrides: Partial = {}) { return { service, ctx, deps, company }; } +describe("one company, one verified identity", () => { + it("refuses a verification before the company says who represents it", async () => { + const { service } = makeService(); + await expect( + service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); -describe("Fayda identity verification binds a person to the company", () => { - it("writes the verified identity", async () => { - const { service, ctx } = makeService(); - + it("writes the verified identity onto the declared subject", async () => { + const { service, ctx } = makeService({ attributes: { poaDeclared: "no" } }); const state = await service.completeIdentityVerification("user-1", { subject: "owner", code: "c", state: "s", }); + expect(state.owner.verified).toBe(true); + expect(state.owner.name).toBe("Haile Gebrselassie"); + expect(state.owner.email).toBe("haile@example.com"); + expect(state.owner.address).toBe("Addis Ababa"); expect(ctx.attributes.ownerFaydaSub).toBe("new-sub"); - expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie"); - expect(state.owner.verified).toBe(true); + expect(state.identityProven).toBe(true); + expect(state.complete).toBe(true); }); - it("fills every PoA detail from the payload, address included", async () => { - const { service, ctx } = makeService(); - - await service.completeIdentityVerification("user-1", { - subject: "poa", - code: "c", - state: "s", - }); - - expect(ctx.attributes.poaName).toBe("Haile Gebrselassie"); - expect(ctx.attributes.poaEmail).toBe("haile@example.com"); - expect(ctx.attributes.poaPhone).toBe("+251922000000"); - expect(ctx.attributes.poaAddress).toBe("Addis Ababa"); + it("refuses a verification for the person the declaration does not point at", async () => { + // A PoA-declared company gates on the PoA. An owner verification here would + // sit on the record looking proven while the gate stayed unsatisfied. + const { service } = makeService({ attributes: { poaDeclared: "yes" } }); + await expect( + service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); }); - it("verifies successfully even though Fayda returns no national ID number", async () => { - // Fayda's userinfo carries no FAN/FIN claim at all — this must be the - // normal, successful path, not an error. - const { service } = makeService({ - verification: { - purpose: "VERIFY", - verified: true, - sub: "x", - fullName: "No Fan Here", - }, - }); - - const state = await service.completeIdentityVerification("user-1", { - subject: "owner", - code: "c", - state: "s", - }); - - expect(state.owner.verified).toBe(true); - }); - - 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", - verified: true, - sub: "same-person", - fullName: "Abebe Bikila", - }, - }); - + it("verifies the representative when one is declared", async () => { + const { service, ctx } = makeService({ attributes: { poaDeclared: "yes" } }); const state = await service.completeIdentityVerification("user-1", { subject: "poa", code: "c", state: "s", }); - + expect(state.subject).toBe("poa"); 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 () => { - // 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, - }); - - await service.completeIdentityVerification("user-1", { - subject: "poa", - code: "c", - state: "s", - }); - - expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(state.poa.address).toBe("Addis Ababa"); + expect(state.identityProven).toBe(true); expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); }); - it("stages nothing for a verified field an approved company resubmits", async () => { - // Approving it could not move the live row — the verified value is written - // back over it — so it must never reach a reviewer as a pending change. - const { service, deps } = makeService({ - status: CompanyStatus.Active, - attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" }, - }); - - await expect( - service.updateProfile("user-1", { - companyEmail: "someone-else@example.com", - } as never), - ).resolves.toBeDefined(); - expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); - expect(deps.changeRequestRepo.update).not.toHaveBeenCalled(); - expect(deps.companiesRepo.update).not.toHaveBeenCalled(); - }); - - // The verified value wins, and it wins by overwriting rather than by - // rejecting: nobody types these fields, so a submission that disagrees is a - // stale form echoing itself back, not an edit. Failing it would block a save - // the customer never made — and leave them no way through, since re-verifying - // returns the same value they are being 400'd for. - it("overwrites a hand-renamed verified person with the verified name", async () => { + it("verifies successfully even though Fayda returns no national ID number", async () => { const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, - files: [paper()], - }); - - await expect( - service.updateProfile("user-1", { poaName: "Someone Else" } as never), - ).resolves.toBeDefined(); - expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName); - }); - - // Fayda's email and phone claims are optional — a verification can prove the - // person and return neither. Holding the company mirrors to "the owner is - // verified" rather than to "the verification supplied this value" would - // clobber the fallbacks the portal is built to send (account email, eTrade's - // registered phone) with nothing at all. OWNER_VERIFIED is exactly that - // shape: a sub, no contact details. - it("keeps company contact details a Fayda verification never supplied", async () => { - const { deps } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; - expect(patch.email).toBe("account@example.com"); - expect(patch.phone).toBe("+251911777777"); - }); - - // "Same as owner" copies `ownerEmail ?? null` onto the GM while setting - // `gmFaydaSub`. Locking that null made generalManagerEmail required by - // onboarding, hidden by the portal's link card and unwritable at once. - it("lets the GM's details be typed when the copied owner identity carried none", async () => { - const { service } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmSameAsOwner: true, - gmFaydaSub: "owner-sub", - generalManagerName: "Abebe Bikila", - generalManagerEmail: null, - generalManagerPhone: null, + attributes: { poaDeclared: "no" }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", }, }); - - await expect( - service.updateProfile("user-1", { - generalManagerEmail: "gm@example.com", - generalManagerPhone: "+251911888888", - } as never), - ).resolves.toBeDefined(); - }); - - // Fayda's email and phone claims are optional and routinely absent. The owner - // is who the company is reached through and the step renders no input for - // their contact details, so the onboarding account — already OTP-proven — - // stands in rather than leaving the company unreachable. - describe("account contact details stand in for absent Fayda claims", () => { - const noContactClaims = { - purpose: "VERIFY", - verified: true, - sub: "new-sub", - fullName: "Haile Gebrselassie", - address: "Addis Ababa", - }; - const account = { - email: "account@example.com", - phoneNumber: "+251911777777", - }; - - it("falls back to the account for an owner Fayda gave no email or phone", async () => { - const { service, ctx } = makeService({ verification: noContactClaims }); - - await service.completeIdentityVerification( - "user-1", - { subject: "owner", code: "c", state: "s" }, - account, - ); - - expect(ctx.attributes.ownerEmail).toBe("account@example.com"); - expect(ctx.attributes.ownerPhone).toBe("+251911777777"); - }); - - it("prefers the Fayda claim over the account when there is one", async () => { - const { service, ctx } = makeService(); - - await service.completeIdentityVerification( - "user-1", - { subject: "owner", code: "c", state: "s" }, - account, - ); - - expect(ctx.attributes.ownerEmail).toBe("haile@example.com"); - expect(ctx.attributes.ownerPhone).toBe("+251922000000"); - }); - - it("leaves the PoA alone — the account is not that person", async () => { - const { service, ctx } = makeService({ verification: noContactClaims }); - - await service.completeIdentityVerification( - "user-1", - { subject: "poa", code: "c", state: "s" }, - account, - ); - - expect(ctx.attributes.poaEmail).toBeUndefined(); - expect(ctx.attributes.poaPhone).toBeUndefined(); - }); - - // Owners verified before the fallback existed hold blank contacts. Copying - // those blanks onto the GM makes generalManagerEmail required by onboarding - // with no field anywhere to satisfy it. - it("fills the GM copy from the account when the stored owner has no contacts", async () => { - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - await service.setGmSameAsOwner("user-1", account); - - expect(ctx.attributes.gmEmail).toBe("account@example.com"); - expect(ctx.attributes.generalManagerEmail).toBe("account@example.com"); - expect(ctx.attributes.generalManagerPhone).toBe("+251911777777"); - }); - - it("keeps the stored owner contacts when the GM copy has them", async () => { - const { service, ctx } = makeService({ - attributes: { - ...OWNER_VERIFIED, - ownerEmail: "abebe@example.com", - ownerPhone: "+251911000111", - }, - }); - - await service.setGmSameAsOwner("user-1", account); - - expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); - expect(ctx.attributes.generalManagerPhone).toBe("+251911000111"); - }); - }); - - it("never locks or gates the general manager — it is not the verified subject", async () => { - // GM is a plain typed role; the portal offers a "same as owner" copy, but - // the backend must not treat it as identity-owned or require it verified. - const { service } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - await expect( - service.updateProfile("user-1", { - generalManagerName: "Someone Else", - generalManagerEmail: "someone@example.com", - generalManagerPhone: "+251911223344", - } as never), - ).resolves.toBeDefined(); - }); -}); - -describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => { - // The company is applying for the forwarder role, so it must not already - // hold it — createCompanyProfileForUser short-circuits on an existing profile - // and would never reach the gate. - const applyingForFf = { - profileTypes: [ProfileType.importer], - attributes: { ...POA_VERIFIED }, - files: [paper()], - }; - - it("blocks the forwarder role while the owner is unverified", async () => { - const { service } = makeService(applyingForFf); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("blocks the forwarder role while the PoA is unverified", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - attributes: { - ...OWNER_VERIFIED, - poaName: "Tirunesh Dibaba", - poaEmail: "t@example.com", - poaPhone: "+251911000000", - }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("grants the forwarder role once owner and PoA are both verified", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).resolves.toBeDefined(); - }); - - it("never asks a foreign company for Fayda, verified or not", async () => { - const { service } = makeService({ - nationality: CompanyNationality.Foreign, - }); - const state = await service.completeIdentityVerification("user-1", { subject: "owner", code: "c", state: "s", }); - - // Still lets the owner verify — a foreign owner verifying is allowed, just - // never required — but the passport is the thing that actually gates it. expect(state.owner.verified).toBe(true); - expect(state.faydaRequired).toBe(false); - expect(state.passportRequired).toBe(true); + expect(ctx.attributes.fanNumber).toBeUndefined(); }); - it("blocks the forwarder role for a foreign company with no owner passport", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - poaName: "Jean Dupont", - poaEmail: "jean@example.com", - poaPhone: "+33100000000", - }, - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - 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("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, - attributes: { - ownerPassportNumber: "P1234567", - poaName: "Jean Dupont", - poaEmail: "jean@example.com", - poaPhone: "+33100000000", - }, - 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", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { - // Verifying is optional for a foreign owner, but it does not waive the - // passport requirement — the two are independent credentials. - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - ...OWNER_VERIFIED, - poaName: "Jean Dupont", - poaEmail: "jean@example.com", - poaPhone: "+33100000000", - }, - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).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. + it("never stands the signed-in account in for an absent Fayda claim", async () => { + // The person onboarding is not necessarily the person on the licence. + // Stamping their address onto the owner made a required field a guess. 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 () => { - // 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 }, + attributes: { poaDeclared: "no" }, verification: { purpose: "VERIFY", verified: true, - sub: "owner-sub", - fullName: "Abebe Bikila", - email: "abebe@example.com", - phoneNumber: "+251911222333", + sub: "new-sub", + fullName: "Haile Gebrselassie", }, }); - const state = await service.completeIdentityVerification("user-1", { - subject: "gm", + subject: "owner", code: "c", state: "s", }); - - expect(state.gm.verified).toBe(true); - expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); - expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila"); + expect(state.owner.email).toBeNull(); + expect(state.owner.phone).toBeNull(); + expect(ctx.attributes.ownerEmail).toBeUndefined(); }); - 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("stages a verification for review on an approved company", async () => { + const { service, deps, ctx } = makeService({ + status: CompanyStatus.Active, + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + expect(deps.changeRequestRepo.create).toHaveBeenCalled(); + // The live row is untouched until a reviewer approves. + expect(ctx.attributes.ownerFaydaSub).toBe("owner-sub"); }); - 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: {} }); + it("overwrites a hand-renamed verified person with the verified name", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + const profile = await service.updateProfile("user-1", { + ownerName: "Someone Else", + } as never); + expect(profile.ownerName).toBe("Abebe Bikila"); + }); - await expect(service.setPoaSameAsOwner("user-1")).rejects.toBeInstanceOf( + it("keeps a field the verification never supplied typeable", async () => { + // Fayda's email claim is optional and `REQUIRED_COMPANY_INFO` demands one, + // so locking against an absent value would make it unfillable forever. + const { service, ctx } = makeService({ + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + await service.updateProfile("user-1", { + ownerEmail: "typed@example.com", + } as never); + expect(ctx.attributes.ownerEmail).toBe("typed@example.com"); + }); + + it("writes the owner's email onto the company, verified or not", async () => { + // `companies.email` is what the notification resolver reads first. Gating + // this on a Fayda verification left every foreign company without one. + const { service, deps } = makeService({ attributes: { poaDeclared: "no" } }); + await service.updateProfile("user-1", { + ownerEmail: "owner@example.com", + ownerPhone: "+251911223344", + } as never); + const patch = deps.companiesRepo.update.mock.calls.at(-1)?.[1] as Record< + string, + unknown + >; + expect(patch.email).toBe("owner@example.com"); + expect(patch.phone).toBe("+251911223344"); + }); +}); + +describe("the declaration decides who verifies", () => { + it("points at the owner when the company says it has no representative", async () => { + const { service, company } = makeService({ + attributes: { poaDeclared: "no" }, + }); + expect(service.getCompanyIdentityState(company() as never).subject).toBe( + "owner", + ); + }); + + it("points at the representative when it says it has one", async () => { + const { service, company } = makeService({ + attributes: { poaDeclared: "yes" }, + }); + expect(service.getCompanyIdentityState(company() as never).subject).toBe( + "poa", + ); + }); + + it("is null until the company answers, and nothing is proven yet", async () => { + const { service, company } = makeService({ attributes: OWNER_VERIFIED }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.poaDeclared).toBeNull(); + expect(state.subject).toBeNull(); + expect(state.identityProven).toBe(false); + expect(state.complete).toBe(false); + }); + + it('forces "yes" for a freight forwarder whatever is stored', async () => { + // A forwarder signs on other companies' behalf, so a representative is + // non-negotiable — including one that answered "no" before taking the role. + const { service, company } = makeService({ + attributes: { poaDeclared: "no" }, + profileTypes: [ProfileType.freightForwarder], + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.poaDeclared).toBe("yes"); + expect(state.subject).toBe("poa"); + }); + + it('refuses to set "no" for a freight forwarder', async () => { + const { service } = makeService({ + profileTypes: [ProfileType.freightForwarder], + }); + await expect(service.setPoaDeclared("user-1", "no")).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 }, + it('tears the representative down when answered "no"', async () => { + const { service, ctx, deps } = makeService({ + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, + files: [paper()], }); - - // 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); + const state = await service.setPoaDeclared("user-1", "no"); + expect(state.poaDeclared).toBe("no"); expect(state.poa.verified).toBe(false); + expect(ctx.attributes.poaName).toBeNull(); expect(ctx.attributes.poaFaydaSub).toBeNull(); - }); - - 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"); - }); - - // The mirror image, and the reason the fallback above is gated on the GM - // being unverified: a manager Fayda proved but supplied no email for types - // one instead, and the portal decides whether to render that input by asking - // whether the identity holds one. Reading the typed column back as part of - // the verified identity would answer "yes" the moment it was saved — the - // input would vanish and a typo could never be corrected. - it("keeps a verified GM's typed email out of the verified identity", async () => { - const { service, company } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmFaydaSub: "gm-sub", - gmFaydaVerifiedAt: "2026-07-03T00:00:00.000Z", - gmName: "Derartu Tulu", - generalManagerName: "Derartu Tulu", - generalManagerEmail: "typed@example.com", - }, - }); - - const state = service.getCompanyIdentityState(company() as never); - - expect(state.gm.verified).toBe(true); - expect(state.gm.name).toBe("Derartu Tulu"); - expect(state.gm.email).toBeNull(); - }); - - it("never stands the account in for a GM Fayda gave no email", async () => { - // Deliberate: the account is the person onboarding, not necessarily the - // manager. The portal asks for the email instead. - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED }, - verification: { - purpose: "VERIFY", - verified: true, - sub: "gm-sub", - fullName: "Derartu Tulu", - phoneNumber: "+251911222333", - }, - }); - - const state = await service.completeIdentityVerification( - "user-1", - { subject: "gm", code: "c", state: "s" }, - { email: "account@example.com", phoneNumber: "+251911777777" }, - ); - - expect(ctx.attributes.gmEmail).toBeUndefined(); - expect(state.gm.verified).toBe(true); - expect(state.gm.email).toBeNull(); - }); - - it("accepts the email typed for a GM whose verification carried none", async () => { - const { service, ctx } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmFaydaSub: "gm-sub", - gmName: "Derartu Tulu", - generalManagerName: "Derartu Tulu", - }, - }); - - await service.updateProfile("user-1", { - generalManagerEmail: "gm@example.com", - } as never); - - expect(ctx.attributes.generalManagerEmail).toBe("gm@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(); + // The paper evidenced a delegation that no longer exists. + expect(deps.filesService.remove).toHaveBeenCalledWith("file-1"); }); }); -/** - * Fayda's email and phone claims are optional, so a *verified* representative - * can still be missing the details `REQUIRED_POA_FIELDS` demands. The PoA step - * renders an input for whatever the verification did not supply — so onboarding - * has to report them outstanding, rather than letting a freight forwarder - * submit an incomplete representative and be refused its next PoA edit for it. - */ -describe("onboarding requirements name the PoA details Fayda did not supply", () => { - const POA_VERIFIED_NO_CONTACTS = { - poaFaydaSub: "poa-sub", - poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z", - poaName: "Tirunesh Dibaba", - }; +describe("foreign companies prove the same person by Fayda OR passport", () => { + const foreign = (attributes: Record) => + makeService({ nationality: CompanyNationality.Foreign, attributes }); - it("reports the missing email and phone for a freight forwarder", async () => { + it("accepts a passport number in place of Fayda", async () => { + const { service, company } = foreign({ + poaDeclared: "no", + ownerPassportNumber: "P1234567", + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.passportAccepted).toBe(true); + expect(state.identityProven).toBe(true); + }); + + it("accepts a Fayda verification instead — the passport is not additional", async () => { + const { service, company } = foreign({ poaDeclared: "no", ...OWNER_VERIFIED }); + expect( + service.getCompanyIdentityState(company() as never).identityProven, + ).toBe(true); + }); + + it("collects the passport of whichever person carries the identity", async () => { + const { service, company } = foreign({ + poaDeclared: "yes", + poaPassportNumber: "P7654321", + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.subject).toBe("poa"); + expect(state.identityProven).toBe(true); + }); + + it("is not satisfied by the OTHER person's passport", async () => { + // A PoA-represented company gates on the PoA; the owner's passport proves + // nobody relevant. + const { service, company } = foreign({ + poaDeclared: "yes", + ownerPassportNumber: "P1234567", + }); + expect( + service.getCompanyIdentityState(company() as never).identityProven, + ).toBe(false); + }); + + it("offers an Ethiopian company no passport alternative", async () => { + const { service, company } = makeService({ + attributes: { poaDeclared: "no", ownerPassportNumber: "P1234567" }, + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.passportAccepted).toBe(false); + expect(state.identityProven).toBe(false); + }); +}); + +describe("the owner is checked against the eTrade licence", () => { + it("matches ignoring case, punctuation and word order", async () => { + const { service, company } = makeService({ + attributes: { + poaDeclared: "no", + ownerName: "abebe bikila", + etradeManagerName: "BIKILA, Abebe", + }, + }); + expect( + service.getCompanyIdentityState(company() as never).ownerMatchesEtrade, + ).toBe(true); + }); + + it("flags a different person", async () => { + const { service, company } = makeService({ + attributes: { + poaDeclared: "no", + ownerName: "Haile Gebrselassie", + etradeManagerName: "Abebe Bikila", + }, + }); + expect( + service.getCompanyIdentityState(company() as never).ownerMatchesEtrade, + ).toBe(false); + }); + + it("reports null when there is nothing to compare", async () => { + // eTrade's ManagerNameEng is frequently blank; a null must not read as a + // mismatch, which would flag half the customer base. + const { service, company } = makeService({ + attributes: { poaDeclared: "no", ownerName: "Abebe Bikila" }, + }); + expect( + service.getCompanyIdentityState(company() as never).ownerMatchesEtrade, + ).toBeNull(); + }); + + it("never blocks on a mismatch — it is the reviewer's call", async () => { const { service } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED_NO_CONTACTS }, - profileTypes: [ProfileType.freightForwarder], + attributes: { + poaDeclared: "no", + ...OWNER_VERIFIED, + // The verified owner is a different human from the one on the licence. + ownerName: "Haile Gebrselassie", + etradeManagerName: "Abebe Bikila", + }, + }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.outstanding).not.toContain( + expect.stringContaining("eTrade"), + ); + }); +}); + +describe("the freight-forwarder gate", () => { + const addForwarder = (service: CompaniesService) => + service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]); + + it("blocks the role while the representative is unverified", async () => { + const { service } = makeService({ attributes: { poaDeclared: "yes" } }); + await expect(addForwarder(service)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("blocks the role on a company that answered no — it becomes yes", async () => { + // Taking the role forces the declaration, so a company that had answered + // "no" cannot inherit that answer past the gate. + const { service } = makeService({ + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + await expect(addForwarder(service)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("blocks the role without the DARS delegation paper", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, + files: [], + }); + await expect(addForwarder(service)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("grants the role once the representative is proven and the paper is on file", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, files: [paper()], }); + await expect(addForwarder(service)).resolves.toBeDefined(); + }); - const req = await service.getOnboardingRequirements("user-1"); + it("grants it to a foreign forwarder whose representative has a passport", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + attributes: { + poaDeclared: "yes", + ...POA_VERIFIED, + poaFaydaSub: undefined, + poaPassportNumber: "P7654321", + }, + files: [paper()], + }); + await expect(addForwarder(service)).resolves.toBeDefined(); + }); +}); - expect(req.poa.missingFields.map((f) => f.key)).toEqual([ +describe("onboarding requirements report exactly what is outstanding", () => { + it("asks the power-of-attorney question before anything else about identity", async () => { + const { service } = makeService(); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.declared).toBeNull(); + expect(reqs.outstanding).toContain( + "Tell us whether anyone holds power of attorney for your company", + ); + }); + + it("names the representative's missing details once one is declared", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "yes", poaName: "Tirunesh Dibaba" }, + }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.missingFields.map((f) => f.key)).toEqual([ "poaEmail", "poaPhone", ]); - expect(req.poa.complete).toBe(false); - expect(req.outstanding).toEqual( - expect.arrayContaining(["Add your poa email", "Add your poa phone"]), - ); }); - it("clears once they are typed", async () => { + it("asks nothing about a representative from a company that has none", async () => { const { service } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, - profileTypes: [ProfileType.freightForwarder], - files: [paper()], + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, }); - - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.poa.missingFields).toEqual([]); - expect(req.poa.complete).toBe(true); - expect(req.outstanding).not.toContain("Add your poa email"); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.missingFields).toEqual([]); + expect(reqs.poa.delegationLetterRequired).toBe(false); }); - // Fayda's email claim is optional and the GM's verification has no account to - // fall back on, so demanding one blocked a manager the government had already - // proved. `companyNotifyEmailExpr` resolves the address from the contact - // person or the registering account instead, so nothing needs this filled. - it("does not hold a company back for a general manager with no email", async () => { + it("demands the delegation paper from every declared representative", async () => { + // No waiver: the owner representing the company IS the "no" answer, so a + // "yes" always means a delegation that has to be evidenced. const { service } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmFaydaSub: "gm-sub", - generalManagerName: "Derartu Tulu", - generalManagerPhone: "+251911222333", - }, + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, + files: [], }); - - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.companyInfo.missingFields.map((f) => f.key)).not.toContain( - "generalManagerEmail", - ); - expect(req.outstanding).not.toContain("Add your general manager email"); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.delegationLetterRequired).toBe(true); + expect(reqs.isComplete).toBe(false); }); - it("still holds it back for the manager's name and phone", async () => { - const { service } = makeService({ attributes: { ...OWNER_VERIFIED } }); - - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.companyInfo.missingFields.map((f) => f.key)).toEqual( - expect.arrayContaining(["generalManagerName", "generalManagerPhone"]), + it("names the owner's missing details", async () => { + const { service } = makeService({ attributes: { poaDeclared: "no" } }); + const reqs = await service.getOnboardingRequirements("user-1"); + const missing = reqs.companyInfo.missingFields.map((f) => f.key); + expect(missing).toEqual( + expect.arrayContaining(["ownerName", "ownerEmail", "ownerPhone"]), ); }); - // An importer that never named a representative owes nothing here — the step - // is one it may walk straight past. - it("asks nothing of a company with no PoA at all", async () => { - const { service } = makeService({ attributes: { ...OWNER_VERIFIED } }); + it("reports the verification against the person it actually gates on", async () => { + const { service } = makeService({ attributes: { poaDeclared: "yes" } }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.outstanding).toContain( + "Verify your Power of Attorney with Fayda", + ); + }); - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.poa.missingFields).toEqual([]); - expect(req.poa.complete).toBe(true); + it("offers the passport alternative to a foreign company", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + attributes: { poaDeclared: "no" }, + }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.outstanding).toContain( + "Verify the person named on your eTrade licence with Fayda, or add their passport number", + ); }); }); 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 b85344e8e..96dc3ff53 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,21 +174,26 @@ 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. + it('owes nothing when the company answered "no representative"', async () => { + // "The owner represents the company themselves" is now expressed as the + // declaration being "no" — there is no delegation, so no paper is due. The + // representative's details are cleared with the answer, so there is nothing + // left to evidence either. const { service } = makeService({ - attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true }, + attributes: { ...VERIFIED_IDENTITIES, poaDeclared: "no" }, }); await expect( - service.updateProfile("user-1", POA as never), + service.updateProfile("user-1", {} as never), ).resolves.toBeDefined(); }); - it("grants the forwarder role to a self-represented company with no paper", async () => { + it("refuses the forwarder role without a paper, however it represents itself", async () => { + // The self-representation waiver is gone: a freight forwarder signs on + // other companies' behalf, so the delegation and the paper evidencing it + // are non-negotiable. const { service } = makeService({ - attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true }, + attributes: { ...VERIFIED_IDENTITIES, ...POA, poaDeclared: "yes" }, }); await expect( @@ -196,7 +201,7 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { "user-1", ProfileType.freightForwarder, ), - ).resolves.toBeDefined(); + ).rejects.toBeInstanceOf(BadRequestException); }); it("rejects a paper the reviewer sent back for correction", async () => {