From dcda8d7d3765306e90ff973d6336e4984f9d129f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 13:44:06 +0000 Subject: [PATCH 1/8] feat(freight-api): drop fan claim from fayda verification esignet userinfo carries no national id number. keep sub/name/email/ phone/address, remove fanClaims config and the hard-fail gate that would've blocked every real verification. Co-Authored-By: Claude Sonnet 5 --- apps/edr-freight-api/.env.example | 3 +++ .../src/config/fayda.config.ts | 9 +++++++-- .../src/modules/verifayda/verifayda.dto.ts | 19 +++++++++++++++---- .../modules/verifayda/verifayda.service.ts | 18 ++++++++++++++---- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 58fb60b18..d6a39bfed 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -93,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64= FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete # OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +# OAuth redirect_uri for the customer portal (its own origin — must also be +# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset. +FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer FAYDA_SCOPE=openid profile email phone address FAYDA_ACR_VALUES=mosip:idp:acr:generated-code diff --git a/apps/edr-freight-api/src/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts index a25289159..30525dd01 100644 --- a/apps/edr-freight-api/src/config/fayda.config.ts +++ b/apps/edr-freight-api/src/config/fayda.config.ts @@ -15,7 +15,7 @@ export interface FaydaJwk { qi?: string; } -export type FaydaPlatform = 'WEB' | 'MOBILE'; +export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL'; export interface FaydaConfig { enabled: boolean; @@ -25,8 +25,10 @@ export interface FaydaConfig { userInfoEndpoint: string; /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ redirectUri: string; - /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + /** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */ webRedirectUri: string; + /** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */ + portalRedirectUri: string; privateJwk: FaydaJwk; scope: string; acrValues: string; @@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => { const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; + const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri; if (!enabled) { return { enabled: false, @@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, scope, acrValues, @@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), scope, acrValues, diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts index 1885b11e9..f9ffe04cb 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts @@ -13,14 +13,14 @@ export class StartVerificationDto { purpose?: 'LOGIN' | 'VERIFY'; @ApiPropertyOptional({ - enum: ['WEB', 'MOBILE'], + enum: ['WEB', 'MOBILE', 'PORTAL'], default: 'WEB', description: - 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB (backoffice) uses FAYDA_WEB_REDIRECT_URI, PORTAL uses FAYDA_PORTAL_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. All land on the same /complete handling.', }) @IsOptional() - @IsIn(['WEB', 'MOBILE']) - platform?: 'WEB' | 'MOBILE'; + @IsIn(['WEB', 'MOBILE', 'PORTAL']) + platform?: 'WEB' | 'MOBILE' | 'PORTAL'; @ApiPropertyOptional({ type: Boolean, @@ -57,6 +57,12 @@ export class CompleteVerificationResultDto { agentId?: string; }; + @ApiPropertyOptional({ + description: + 'Fayda OIDC subject — the stable key a verified identity is stored under (VERIFY flow). Pairwise pseudonymous.', + }) + sub?: string; + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; @@ -74,6 +80,11 @@ export class CompleteVerificationResultDto { @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) gender?: string; + @ApiPropertyOptional({ + description: 'Verified address from Fayda, English rendering (VERIFY flow).', + }) + address?: string; + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) userDataSaved?: boolean; diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index 6e3e2e095..16441bd0d 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -56,11 +56,15 @@ export interface CompleteVerificationResult { promptPasswordSetup?: boolean; iamUserId?: string; user?: FaydaUserSummary; + /** Fayda OIDC subject — the stable key a verified identity is stored under. */ + sub?: string; fullName?: string; email?: string; phoneNumber?: string; birthdate?: string; gender?: string; + /** Verified address, English rendering (falls back to Amharic). */ + address?: string; userDataSaved?: boolean; } @@ -125,11 +129,15 @@ export class VerifaydaService { }); } - /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + /** + * Each client lands on its own registered redirect_uri: MOBILE on the base + * one, the customer portal on its own origin, everything else (backoffice) on + * the web one. All three must be registered with eSignet. + */ private redirectUriForPlatform(platform?: FaydaPlatform): string { - return platform === 'MOBILE' - ? this.faydaConfig.redirectUri - : this.faydaConfig.webRedirectUri; + if (platform === 'MOBILE') return this.faydaConfig.redirectUri; + if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri; + return this.faydaConfig.webRedirectUri; } async completeVerification( @@ -210,11 +218,13 @@ export class VerifaydaService { result = { purpose: 'VERIFY', verified: true, + sub: normalized.sub, fullName: normalized.fullName, email: normalized.email, phoneNumber: normalized.phoneNumber, birthdate: normalized.birthdate, gender: normalized.gender, + address: normalized.addressEn ?? normalized.addressAm, userDataSaved, iamUserId: iamUserId ?? undefined, token: sessionToken?.token, From 68f6ec8c5f64b107de1d2de96b8b5b4ad50fc923 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 13:44:23 +0000 Subject: [PATCH 2/8] feat(freight-api): gate poa paper, fayda identity, foreign passport - dars delegation paper mandatory wherever poa state changes (named, removed, forwarder role applied for/approved), not just onboarding - ethiopian companies verify owner (and poa, once named) via fayda; identity, not general manager, is the verified subject - foreign companies require a typed owner passport number instead, independent of an optional fayda verification - fanNumber removed from client-writable dtos; server-derived only Co-Authored-By: Claude Sonnet 5 --- .../modules/companies/companies.controller.ts | 30 + .../src/modules/companies/companies.module.ts | 3 + .../modules/companies/companies.service.ts | 601 ++++++++++++++++-- .../dto/complete-identity-verification.dto.ts | 148 +++++ .../onboarding-requirements-response.dto.ts | 16 +- .../companies/dto/profile-response.dto.ts | 15 + .../companies/dto/update-profile.dto.ts | 19 +- .../file-upload-settings.service.ts | 21 + .../poa-delegation.constants.ts | 52 ++ .../src/seed/file-upload-settings.seeder.ts | 30 +- 10 files changed, 871 insertions(+), 64 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts 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 8e3be4d1a..825c332e5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; +import { + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, +} from "./dto/complete-identity-verification.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -378,6 +382,32 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } + @Post("identity/fayda/complete") + @ApiOperation({ + summary: + "Bind a completed Fayda verification to the company's owner or Power of Attorney. " + + "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " + + "The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.", + }) + async completeIdentityVerification( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CompleteIdentityVerificationDto, + ): Promise { + return this.companiesService.completeIdentityVerification(user.id, dto); + } + + @Delete("identity/fayda/poa") + @ApiOperation({ + summary: + "Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " + + "Refused while the company holds a freight forwarder role, which cannot operate without a representative.", + }) + async removePoaIdentity( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.removePoaIdentity(user.id); + } + @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 73826689a..450222657 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; +import { VerifaydaModule } from "../verifayda/verifayda.module"; @Module({ imports: [ @@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service"; // imports this module back for portal recipient targeting, hence forwardRef. NotificationsModule, forwardRef(() => NotificationInboxModule), + // Fayda identity verification for the company's owner and PoA. + VerifaydaModule, ], controllers: [CompaniesController], providers: [ 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 6650dfca5..cf03d8d50 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -17,6 +17,18 @@ import { import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; +import { + POA_DELEGATION_FILE_KEY, + POA_DELEGATION_LABEL, + POA_DELEGATION_PENDING_CODE, +} from "../file-upload-settings/poa-delegation.constants"; +import { VerifaydaService } from "../verifayda/verifayda.service"; +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, + IdentitySubject, +} from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; @@ -58,10 +70,6 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; -/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ -const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; -/** Code for a PoA letter staged in an open change request (not yet live). */ -const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; /** FileRecord resource that company-level documents are stored under. */ const COMPANY_RESOURCE = "companies"; /** company.attributes keys that together mean "a PoA was entered". */ @@ -79,6 +87,40 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaPhone", label: "PoA phone" }, ]; +/** + * `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. + */ +const IDENTITY_PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +const IDENTITY_LABEL: Record = { + owner: "owner", + poa: "Power of Attorney", +}; + +/** + * 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. + */ +const IDENTITY_OWNED_FIELDS: Record = { + owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], + poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], +}; + +/** The attributes a verification writes, for one person. */ +interface VerifiedIdentityAttributes { + [key: string]: unknown; +} + export interface UserIdentity { userId: string; firstName: string; @@ -100,6 +142,7 @@ export class CompaniesService { private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, private readonly dataSource: DataSource, + private readonly verifaydaService: VerifaydaService, ) { } /** @@ -599,7 +642,9 @@ export class CompaniesService { */ private mapProfileDtoToCompanyUpdates( company: Company, - dto: Partial, + dto: Partial & { + faydaIdentity?: VerifiedIdentityAttributes; + }, ): Record { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; @@ -617,7 +662,6 @@ export class CompaniesService { if (dto.tin !== undefined && dto.tin !== company.tin) companyUpdates.tin = dto.tin; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; - if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber; if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; @@ -661,6 +705,47 @@ export class CompaniesService { if (dto.etradePhone !== undefined) companyUpdates.etradePhone = normalizeE164(dto.etradePhone); + // A plain typed field — never Fayda-verified, so no lock ever applies to + // it. Independent of the owner's verification: still required for a + // foreign company even if the owner also verifies with Fayda. + if (dto.ownerPassportNumber !== undefined) + attrUpdates.ownerPassportNumber = dto.ownerPassportNumber; + + // A verified identity overwrites the person's details. `faydaIdentity` + // never comes off the wire — the global validation pipe runs with + // forbidNonWhitelisted, so a client that sends it is rejected outright; it + // only reaches here from completeIdentityVerification, directly or through + // a staged snapshot. + if (dto.faydaIdentity) { + Object.assign(attrUpdates, dto.faydaIdentity); + } + + // 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[]) { + if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; + for (const field of IDENTITY_OWNED_FIELDS[subject]) { + const incoming = (dto as Record)[field]; + if (incoming === undefined) continue; + // The verification itself is allowed to write them; anything else is + // compared against what is already stored, not against the value this + // same call just copied into the patch. Phones are compared normalized: + // a form that re-renders +251911000000 as 0911000000 is echoing the + // stored value back, not trying to change it. + if (dto.faydaIdentity && field in dto.faydaIdentity) continue; + const stored = company.attributes?.[field]; + const same = field.endsWith("Phone") + ? normalizeE164(String(incoming)) === + normalizeE164(String(stored ?? "")) + : incoming === stored; + if (!same) { + throw new BadRequestException( + `${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`, + ); + } + } + } + companyUpdates.attributes = attrUpdates; return companyUpdates; } @@ -702,6 +787,19 @@ export class CompaniesService { ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + // Naming (or renaming) a Power of Attorney is one of the writes that can + // leave the company with a representative and nothing evidencing them, so + // it is gated here. Edits that don't touch the PoA are left alone — a + // company carrying legacy details must not be locked out of every other + // field until it produces a paper. + if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) { + const attributes = this.mapProfileDtoToCompanyUpdates(company, dto) + .attributes as Record; + await this.assertPoaDelegationSatisfied(company.id, attributes, { + requirePoa: await this.isFreightForwarder(company.id), + }); + } + if (company.status !== CompanyStatus.Active) { await this.assertTinAvailable(company, dto.tin); const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto); @@ -1127,11 +1225,24 @@ export class CompaniesService { // blacklist skip all this — staff must always be able to act against a bad // account. return this.dataSource.transaction(async (manager) => { - await manager.findOne(Company, { + const company = await manager.findOne(Company, { where: { id: existing.companyId }, lock: { mode: "pessimistic_write" }, }); + // Putting a forwarder into service without a Power of Attorney backed by + // a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is + // the last place it has to be checked — the role may have been applied + // for before the paper was withdrawn. + if (company && existing.type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + company.id, + company.attributes, + { requirePoa: true }, + ); + } + const [companyDocs, profileDocs] = await Promise.all([ this.filesService.findWithOpenChangeRequest( [existing.companyId], @@ -1382,6 +1493,18 @@ export class CompaniesService { ); if (existing) continue; + // A forwarder signs on other companies' behalf, so it cannot be taken on + // without a Power of Attorney and its DARS paper — checked here so the + // customer is told at the point of asking, not at review. + if (type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } + // Self-service role adds start Pending and carry no reference — a reference // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ @@ -1419,6 +1542,14 @@ export class CompaniesService { } let created = await this.companyProfilesRepo.findByType(companyId, type); + if (!created && type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } if (!created) { // New self-service roles start Pending (awaiting backoffice approval) and // carry no reference until approved. @@ -1453,11 +1584,17 @@ export class CompaniesService { userId: string, ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + const identity = this.getCompanyIdentityState(company); - // 1. Required company-information fields. - const missingInfo = this.REQUIRED_COMPANY_INFO.filter( - (f) => !f.get(company), - ).map((f) => ({ key: f.key, label: f.label })); + // 1. Required company-information fields. The FAN is never one of them — + // Fayda verification doesn't produce a FAN, so it's never collected as + // part of onboarding at all (see the identity block below). + const requiredInfo = this.REQUIRED_COMPANY_INFO.filter( + (f) => f.key !== "fanNumber", + ); + const missingInfo = requiredInfo + .filter((f) => !f.get(company)) + .map((f) => ({ key: f.key, label: f.label })); // 2. Nationality-based company documents + which are already uploaded. const documentSettingCode = this.documentSettingCodeFor(company.nationality); @@ -1504,26 +1641,31 @@ export class CompaniesService { // 4. Power of Attorney. Optional in general, but a freight forwarder acts on // other companies' behalf so its PoA is mandatory. Either way, a PoA that - // has been entered must be evidenced by the delegation letter. + // has been entered must be evidenced by the DARS delegation paper — a legal + // requirement, so unlike the documents above it does not depend on the + // upload set carrying a field for it (see poa-delegation.constants.ts). const poaRequired = (company.companyProfiles ?? []).some( (p) => p.type === ProfileType.freightForwarder, ); const poaProvided = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); - const missingPoaFields = poaRequired - ? REQUIRED_POA_FIELDS.filter( - (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), - ) - : []; - // Only gate on the letter once the document set actually carries the field. - const delegationField = (setting?.fields ?? []).find( - (f) => f.fileKey === POA_DELEGATION_FILE_KEY, - ); - const missingDelegation = - Boolean(delegationField) && - (poaRequired || poaProvided) && - !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + // An Ethiopian company does not type its PoA details at all — they arrive + // from the Fayda verification — so reporting them as missing fields would + // ask for something the form no longer offers. The identity block below + // reports "verify your PoA" instead. + const missingPoaFields = + poaRequired && !identity.faydaRequired + ? REQUIRED_POA_FIELDS.filter( + (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), + ) + : []; + const delegation = await this.getPoaDelegationState(company.id); + const delegationDue = poaRequired || poaProvided; + const missingDelegation = delegationDue && !delegation.onFile; + // A paper the reviewer sent back is not evidence — the customer has to + // replace it before the application counts as complete. + const flaggedDelegation = delegationDue && delegation.flagged; const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), @@ -1534,29 +1676,62 @@ export class CompaniesService { ), ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), ...(missingDelegation - ? ["Upload the delegation letter for your Power of Attorney"] + ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] + : []), + ...(flaggedDelegation + ? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`] + : []), + ...(identity.faydaRequired && !identity.owner.verified + ? ["Verify the company owner's identity with Fayda"] + : []), + ...(identity.faydaRequired && + (poaRequired || poaProvided) && + !identity.poa.verified + ? ["Verify your Power of Attorney's identity with Fayda"] + : []), + ...(identity.passportRequired && !identity.owner.passportNumber + ? ["Add the company owner's passport number"] : []), ]; // Progress spans every required item the user has to satisfy: company-info // fields, required documents, one license per operational profile, and the - // PoA details/letter whenever those are mandatory. + // PoA details/paper whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; const poaItemCount = - (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + - (delegationField && (poaRequired || poaProvided) ? 1 : 0); + (poaRequired && !identity.faydaRequired + ? REQUIRED_POA_FIELDS.length + : 0) + (delegationDue ? 1 : 0); + // One item per identity credential the company has to prove: the owner + // always (Fayda for Ethiopian, passport for foreign), the PoA once there + // is one and Fayda is what's mandatory here. + const identityItemCount = identity.faydaRequired + ? delegationDue + ? 2 + : 1 + : identity.passportRequired + ? 1 + : 0; + const missingIdentityCount = identity.faydaRequired + ? (identity.owner.verified ? 0 : 1) + + (delegationDue && !identity.poa.verified ? 1 : 0) + : identity.passportRequired && !identity.owner.passportNumber + ? 1 + : 0; const total = - this.REQUIRED_COMPANY_INFO.length + + requiredInfo.length + requiredDocCount + licenseProfiles.length + - poaItemCount; + poaItemCount + + identityItemCount; const completed = total - (missingInfo.length + missingDocs.length + missingLicenses.length + missingPoaFields.length + - (missingDelegation ? 1 : 0)); + (missingDelegation || flaggedDelegation ? 1 : 0) + + missingIdentityCount); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1567,10 +1742,15 @@ export class CompaniesService { poa: { required: poaRequired, provided: poaProvided, - delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + delegationLetterUploaded: delegation.onFile, + delegationLetterFlagged: delegation.flagged, missingFields: missingPoaFields, - complete: missingPoaFields.length === 0 && !missingDelegation, + complete: + missingPoaFields.length === 0 && + !missingDelegation && + !flaggedDelegation, }, + identity, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, @@ -2044,15 +2224,350 @@ export class CompaniesService { } // --------------------------------------------------------------------------- - // Power of Attorney delegation letter + // Power of Attorney delegation paper (DARS) // // A company-level document that follows the same staged-review model as the // business license: on an approved (Active) company an upload lands under the - // pending code and the live letter is flagged for removal, so the reviewer + // pending code and the live paper is flagged for removal, so the reviewer // sees both and approval swaps them atomically. During onboarding it goes live. // --------------------------------------------------------------------------- - /** The company's PoA letter(s), with each file's review status resolved. */ + /** + * What the company has on file towards its DARS delegation paper. A paper + * staged for review counts as "on file" — it is the customer's whole + * obligation discharged; whether it is good enough is the reviewer's call, + * recorded as `flagged`. + */ + private async getPoaDelegationState( + companyId: string, + ignoreFileIds: string[] = [], + ): Promise<{ onFile: boolean; flagged: boolean }> { + const records = ( + await this.filesService.findByResource(companyId, COMPANY_RESOURCE) + ).filter( + (r) => + (r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE) && + !ignoreFileIds.includes(r.id), + ); + return { + onFile: records.length > 0, + flagged: records.some((r) => r.reviewStatus === "change_requested"), + }; + } + + /** + * The rule behind EDRFREIGHT-358: a company that names a Power of Attorney + * must evidence it with a DARS delegation paper, and a freight forwarder — + * which signs on other companies' behalf — must have both, verified. + * + * This is enforced at every write that can break the pairing (PoA details + * saved, paper removed, forwarder role applied for or approved) rather than + * only at onboarding submission, which is what let a company that finished + * onboarding as an importer pick up the forwarder role with neither. + * + * `attributes` is the state being written, which is not always the state on + * the row yet — a staged change request carries it, and a removal has to be + * judged against the files that would survive it (`ignoreFileIds`). + */ + private async assertPoaDelegationSatisfied( + companyId: string, + attributes: Record | null | undefined, + opts: { requirePoa: boolean; ignoreFileIds?: string[] }, + ): Promise { + const read = (key: string) => + (attributes?.[key] as string | undefined)?.trim(); + const poaProvided = POA_ATTRIBUTES.some((k) => read(k)); + if (!opts.requirePoa && !poaProvided) return; + + if (opts.requirePoa) { + const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); + if (missing.length > 0) { + throw new BadRequestException( + `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` + + `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, + ); + } + } + + const { onFile, flagged } = await this.getPoaDelegationState( + companyId, + opts.ignoreFileIds, + ); + if (!onFile) { + throw new BadRequestException( + `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` + + (opts.requirePoa ? " — it is required for freight forwarders." : "."), + ); + } + if (flagged) { + throw new BadRequestException( + `The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` + + `Re-upload it before continuing.`, + ); + } + } + + /** Does this company operate as a freight forwarder? */ + private async isFreightForwarder(companyId: string): Promise { + const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + return profiles.some((p) => p.type === ProfileType.freightForwarder); + } + + // --------------------------------------------------------------------------- + // Fayda identity verification (owner / PoA) + // + // A completed VeriFayda verification proves a person's name, phone, email + // and address — Fayda's userinfo carries no national ID number, so none of + // that is collected here. For an Ethiopian company both the owner and its + // PoA (once named) must be verified before the company can trade. Fayda is + // an Ethiopian national ID system, so a foreign company's owner proves + // identity with a typed passport number instead — required on its own + // terms, not waived by an owner who happens to verify with Fayda too. + // --------------------------------------------------------------------------- + + /** + * Verification state for both people, plus whether it is mandatory here. + * `complete` answers the gate question directly so the portal, the onboarding + * requirements and the assertions below all read the same verdict — the + * derivation itself is shared with ProfileResponseDto. + */ + getCompanyIdentityState(company: Company): CompanyIdentityStateDto { + return buildCompanyIdentityState(company); + } + + /** + * Complete a Fayda verification and bind the identity to one of the company's + * people. The portal starts the flow through the shared + * `POST /fayda/verification/start` and only tells us which person it was for + * here, at completion — so the verifayda module stays generic and its session + * table needs no company-specific column. + */ + async completeIdentityVerification( + userId: string, + dto: CompleteIdentityVerificationDto, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const prefix = IDENTITY_PREFIX[dto.subject]; + + const result = await this.verifaydaService.completeVerification({ + code: dto.code, + state: dto.state, + }); + if (!result.verified || !result.sub) { + throw new BadRequestException( + "Fayda could not verify this identity. Start the verification again.", + ); + } + + // 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.`, + ); + } + + const now = new Date().toISOString(); + const identity: VerifiedIdentityAttributes = { + [`${prefix}FaydaSub`]: result.sub, + [`${prefix}FaydaVerifiedAt`]: now, + [`${prefix}Birthdate`]: result.birthdate ?? null, + [`${prefix}Gender`]: result.gender ?? null, + // The verified payload owns the person's details from here on. + ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), + ...(result.email ? { [`${prefix}Email`]: result.email } : {}), + ...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}), + ...(dto.subject === "poa" && result.address + ? { poaAddress: result.address } + : {}), + }; + + // An approved company's profile edits are staged for backoffice review, and + // swapping the person who can act for the company is exactly the kind of + // edit that review exists for — so a verification lands the same way an + // ordinary edit does, rather than quietly rewriting a live record. + if (company.status === CompanyStatus.Active) { + await this.stageIdentityChange(company, userId, identity); + return this.getCompanyIdentityState(company); + } + + const updated = await this.companiesRepo.update(company.id, { + attributes: { ...(company.attributes ?? {}), ...identity }, + }); + 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. + * + * Only the PoA can go: a company always has an owner, and a freight forwarder + * always has a representative. Once a PoA is Fayda-verified its + * fields are locked, so blanking the form is no longer a way out — without + * this the customer would be stuck with a representative they cannot remove. + */ + async removePoaIdentity(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + if ( + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + throw new BadRequestException( + "A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.", + ); + } + + const cleared: Record = {}; + for (const key of [ + ...POA_ATTRIBUTES, + "poaFaydaSub", + "poaFaydaVerifiedAt", + "poaBirthdate", + "poaGender", + ]) { + cleared[key] = null; + } + const attributes = { ...(company.attributes ?? {}), ...cleared }; + + // The paper evidences a representative who no longer exists. + const records = await this.filesService.findByResource( + company.id, + COMPANY_RESOURCE, + ); + for (const r of records) { + if ( + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE + ) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(company.id, r.id); + } + } + + const updated = await this.companiesRepo.update(company.id, { attributes }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** Stage a verified identity onto the company's pending change request. */ + private async stageIdentityChange( + company: Company, + userId: string, + identity: VerifiedIdentityAttributes, + ): Promise { + const existing = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const now = new Date(); + const snapshot = { + ...(existing?.snapshot ?? {}), + faydaIdentity: { + ...(((existing?.snapshot ?? {}) as Record) + .faydaIdentity ?? {}), + ...identity, + }, + }; + if (existing) { + await this.changeRequestRepo.update(existing.id, { + snapshot, + submittedBy: userId, + submittedAt: now, + note: null, + }); + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + return; + } + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const request = await this.changeRequestRepo.create({ + companyId: company.id, + snapshot, + status: ChangeRequestStatus.Pending, + submittedBy: userId, + submittedAt: now, + }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); + } + + /** + * The gate: an Ethiopian company's owner must be Fayda-verified, and so must + * its Power of Attorney once it has one; a foreign company's owner must carry + * a passport number instead. Called from the same places as + * `assertPoaDelegationSatisfied` — the two rules describe the same moment + * (who may act for this company, and on what evidence) and drifting them + * apart is how one of them ends up unenforced. + */ + private assertIdentityVerified( + company: Company, + opts: { requirePoa: boolean }, + ): void { + const state = buildCompanyIdentityState(company); + + if (state.passportRequired) { + if (!state.owner.passportNumber) { + throw new BadRequestException( + "Add the company owner's passport number before continuing.", + ); + } + return; + } + + if (!state.owner.verified) { + throw new BadRequestException( + "Verify the company owner's identity with Fayda before continuing.", + ); + } + + const poaNamed = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + if (!opts.requirePoa && !poaNamed) return; + + if (!state.poa.verified) { + throw new BadRequestException( + opts.requirePoa + ? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one." + : "Verify the Power of Attorney you named with Fayda, or remove the representative.", + ); + } + } + + /** + * The PoA details the company is heading for: its live attributes with any + * pending change-request snapshot laid over them. An Active company's edits + * are staged rather than written, so the live row on its own would judge the + * customer against details they have already asked to change. + */ + private async effectivePoaAttributes( + company: Company, + ): Promise> { + const pending = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const snapshot = (pending?.snapshot ?? {}) as Record; + const staged: Record = {}; + for (const key of POA_ATTRIBUTES) { + if (key in snapshot) staged[key] = snapshot[key]; + } + return { ...(company.attributes ?? {}), ...staged }; + } + + /** The company's PoA paper(s), with each file's review status resolved. */ async listPoaDelegationFiles( userId: string, ): Promise { @@ -2149,6 +2664,18 @@ export class CompaniesService { throw new NotFoundException(`Delegation letter ${fileId} not found`); } + // Taking the paper away is the other half of the pairing: allowed only once + // the representative it evidences is gone too (which, for an Active + // company, means the clearing edit is already staged). + await this.assertPoaDelegationSatisfied( + company.id, + await this.effectivePoaAttributes(company), + { + requirePoa: await this.isFreightForwarder(company.id), + ignoreFileIds: [fileId], + }, + ); + if (record.code === POA_DELEGATION_PENDING_CODE) { await this.filesService.remove(fileId); await this.withdrawDocumentIntent(company.id, fileId); 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 new file mode 100644 index 000000000..dbcc76471 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -0,0 +1,148 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn, IsString, IsNotEmpty } from "class-validator"; + +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. + */ +export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; +export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; + +export class CompleteIdentityVerificationDto { + @ApiProperty({ + enum: IDENTITY_SUBJECTS, + description: "Which of the company's people this verification is for.", + }) + @IsIn(IDENTITY_SUBJECTS) + subject!: IdentitySubject; + + @ApiProperty({ description: "Authorization code from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + code!: string; + + @ApiProperty({ description: "CSRF state from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + state!: string; +} + +/** One person's verification state, as reported back to the portal. */ +export class IdentityVerificationStateDto { + @ApiProperty() verified!: boolean; + @ApiProperty({ nullable: true }) name!: string | null; + @ApiProperty({ nullable: true }) phone!: string | null; + @ApiProperty({ nullable: true }) email!: string | null; + @ApiProperty({ nullable: true }) address!: string | null; + @ApiProperty({ nullable: true }) verifiedAt!: string | null; +} + +export class OwnerIdentityStateDto extends IdentityVerificationStateDto { + @ApiProperty({ + nullable: true, + description: + "Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.", + }) + passportNumber!: string | null; +} + +export class CompanyIdentityStateDto { + @ApiProperty({ + description: + "True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.", + }) + faydaRequired!: boolean; + + @ApiProperty({ + description: + "True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.", + }) + passportRequired!: boolean; + + @ApiProperty({ type: OwnerIdentityStateDto }) + owner!: OwnerIdentityStateDto; + + @ApiProperty({ type: IdentityVerificationStateDto }) + poa!: IdentityVerificationStateDto; + + @ApiProperty({ + description: + "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", + }) + complete!: boolean; +} + +/** `attributes` key prefix per person. */ +const PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_KEYS = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; + +function stateFor( + attrs: Record, + subject: IdentitySubject, +): IdentityVerificationStateDto { + const p = PREFIX[subject]; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + return { + verified: Boolean(read(`${p}FaydaSub`)), + name: read(`${p}Name`), + phone: read(`${p}Phone`), + email: read(`${p}Email`), + address: read(`${p}Address`), + verifiedAt: read(`${p}FaydaVerifiedAt`), + }; +} + +/** + * Derive both people's verification state from the company row. + * + * Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto` + * renders from it, so the settings page and the onboarding wizard can never + * disagree with the rule the API actually enforces. + */ +export function buildCompanyIdentityState( + company: Company, +): CompanyIdentityStateDto { + const attrs = company.attributes ?? {}; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + + // Fayda is an Ethiopian national ID — a foreign company's owner may not hold + // one, so a typed passport number is the mandatory credential there instead. + // The two are mutually exclusive by nationality but independently tracked, + // since a foreign owner verifying with Fayda doesn't waive the passport. + const foreign = company.nationality === CompanyNationality.Foreign; + const faydaRequired = !foreign; + const passportRequired = foreign; + + const owner: OwnerIdentityStateDto = { + ...stateFor(attrs, "owner"), + passportNumber: read("ownerPassportNumber"), + }; + const poa = stateFor(attrs, "poa"); + const poaDue = + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); + + const complete = faydaRequired + ? owner.verified && (!poaDue || poa.verified) + : !passportRequired || Boolean(owner.passportNumber); + + return { faydaRequired, passportRequired, owner, poa, complete }; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index da908a177..a8d2f24a2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -8,6 +8,8 @@ * truth the wizard uses to auto-finish. */ +import { CompanyIdentityStateDto } from "./complete-identity-verification.dto"; + export interface OnboardingInfoField { key: string; label: string; @@ -40,11 +42,13 @@ export interface OnboardingPoaState { required: boolean; /** True once any PoA detail has been entered. */ provided: boolean; - /** True when the delegation letter is stored for the company. */ + /** True when the DARS delegation paper is stored for the company. */ delegationLetterUploaded: boolean; + /** True when a reviewer sent the paper back for correction. */ + delegationLetterFlagged: boolean; /** PoA details still missing (only populated when `required`). */ missingFields: OnboardingInfoField[]; - /** False while the PoA step still owes details or a delegation letter. */ + /** False while the PoA step still owes details or an uncorrected paper. */ complete: boolean; } @@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto { /** Power of Attorney state, so the wizard needn't re-derive the rule. */ poa: OnboardingPoaState; + /** + * Fayda verification state for the company's people. `required` is false for + * a foreign company, which is never gated on it — the portal renders the + * typed personnel forms in that case and the verify panels otherwise. + */ + identity: CompanyIdentityStateDto; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto { this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; this.poa = init.poa; + this.identity = init.identity; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 89ab954e7..6072268dc 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,3 +1,7 @@ +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, +} from "./complete-identity-verification.dto"; import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; import { @@ -52,6 +56,16 @@ export class ProfileResponseDto { profileId: string; + /** + * Fayda verification state for the company's owner and PoA — not the general + * manager, which is a separate typed role. The settings tabs and the + * onboarding wizard render from `identity.faydaRequired` / + * `identity.passportRequired`: an Ethiopian company verifies the owner (and + * PoA) instead of typing their details; a foreign one requires a typed + * passport number instead. + */ + identity: CompanyIdentityStateDto; + /** * Open profile-edit review, if any. `reviewStatus === "pending"` locks the * settings page; `"rejected"` surfaces the note and prefills the (declined) @@ -124,5 +138,6 @@ export class ProfileResponseDto { : null; this.reviewNote = openReview?.note ?? null; this.pendingChanges = openReview?.snapshot ?? null; + this.identity = buildCompanyIdentityState(company); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index ba3e27aeb..9f7d1ed39 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -44,10 +44,11 @@ export class UpdateProfileDto { @MaxLength(50) vatNumber?: string; - @IsOptional() - @IsString() - @MaxLength(16) - fanNumber?: string; + // `fanNumber` is deliberately absent: the FAN is the Fayda number of the + // company's PoA (or its general manager), so it is derived from a completed + // Fayda verification rather than typed. The global validation pipe runs with + // forbidNonWhitelisted, so a client that still sends it gets a 400 telling it + // so — see CompaniesService.completeIdentityVerification. @IsOptional() @IsString() @@ -110,6 +111,16 @@ export class UpdateProfileDto { @IsString() poaAddress?: string; + /** + * The owner's passport number — the identity credential for a foreign + * company, since Fayda is an Ethiopian national ID. Plain typed field, never + * written or locked by a Fayda verification: still required even if the + * owner also verifies. + */ + @IsOptional() + @IsString() + ownerPassportNumber?: string; + @IsOptional() @IsString() @MaxLength(100) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 947bb5ffb..9651d4f37 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -15,6 +15,11 @@ import { FILE_UPLOAD_SETTINGS_REPOSITORY, IFileUploadSettingsRepository, } from "./interfaces/file-upload-settings.repository.interface"; +import { + COMPANY_ONBOARDING_CODE_PREFIX, + POA_DELEGATION_FILE_KEY, + poaDelegationField, +} from "./poa-delegation.constants"; @Injectable() export class FileUploadSettingsService { @@ -40,6 +45,22 @@ export class FileUploadSettingsService { async getByCode(code: string): Promise { const setting = await this.repository.findByCode(code); if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return this.withPoaDelegationField(setting); + } + + /** + * Company onboarding sets always carry the DARS delegation paper, whether or + * not anyone configured a row for it — see poa-delegation.constants.ts. Every + * consumer (the portal's PoA step, the onboarding gate) reads the set through + * here, so this is the single place the field can be guaranteed. + */ + private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting { + if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting; + const fields = setting.fields ?? []; + if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting; + + const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0); + setting.fields = [...fields, poaDelegationField(lastOrder + 1)]; return setting; } diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts new file mode 100644 index 000000000..9085cc018 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -0,0 +1,52 @@ +import { FileUploadField } from "./entities/file-upload-field.entity"; + +/** + * The DARS delegation paper — the document that evidences a company's Power of + * Attorney (EDRFREIGHT-358). + * + * Every other onboarding document is admin-managed: the rows in + * `file_upload_fields` are edited from the backoffice file-settings editor and + * the seeder deliberately inserts none. This one is different — a company that + * names a PoA must produce a delegation paper authenticated by the Documents + * Authentication and Registration Service, and that is a legal requirement + * rather than a configuration choice. So the field is defined here in code and + * injected into the company onboarding sets on read: no row to forget to seed, + * and deleting one in the editor cannot silently switch the requirement off. + */ + +/** FileRecord `code` (and upload field key) of the live delegation paper. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** Code for a delegation paper staged in an open change request (not yet live). */ +export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; + +/** Customer-facing name of the document, used by the API and both web apps. */ +export const POA_DELEGATION_LABEL = "DARS Delegation Paper"; + +/** Prefix of the setting codes the field is injected into. */ +export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_"; + +const POA_DELEGATION_HELP = + "Delegation paper issued by the Documents Authentication and Registration " + + "Service (DARS) delegating the representative named above. Upload the " + + "authenticated copy — a plain letter is not accepted."; + +/** + * The field descriptor. `isRequired` stays false because the paper is only due + * once a PoA has actually been named (or the company operates as a freight + * forwarder) — a rule that spans form fields as well as files, so it is + * enforced in CompaniesService rather than by this flag. + */ +export function poaDelegationField(displayOrder: number): FileUploadField { + return { + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: POA_DELEGATION_LABEL, + helpText: POA_DELEGATION_HELP, + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder, + } as FileUploadField; +} diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index fa74f9043..87e9819cf 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; +import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants"; interface OnboardingField { fileKey: string; @@ -17,27 +18,14 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; -/** fileKey of the delegation letter attached to the Power of Attorney step. */ -export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; - /** - * Seeded as optional: the delegation letter is only mandatory once a PoA has - * been entered, or when the company operates as a freight forwarder. That rule - * spans form fields as well as files, so it lives in the onboarding gate - * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + * Listed in the sets below only so the reference defaults stay a complete + * picture of a company onboarding form. Unlike every other field here, the DARS + * delegation paper is not admin-managed: `FileUploadSettingsService.getByCode` + * injects it from poa-delegation.constants.ts whether or not a row exists. */ -const poaDelegationField = (displayOrder: number): OnboardingField => ({ - fileKey: POA_DELEGATION_FILE_KEY, - fileLabel: "PoA Delegation Letter", - helpText: - "Signed letter in which the General Manager delegates the representative named above.", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder, -}); +const poaDelegationDefault = (displayOrder: number): OnboardingField => + poaDelegationField(displayOrder) as unknown as OnboardingField; /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ @@ -75,7 +63,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, - poaDelegationField(4), + poaDelegationDefault(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -124,7 +112,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, - poaDelegationField(5), + poaDelegationDefault(5), ]; /** Legacy combined set, kept for the older per-company-type codes. */ From 68ef8dc5d72813c67948afa8426b14311746ab92 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 13:44:29 +0000 Subject: [PATCH 3/8] test(freight-api): cover poa delegation and fayda identity gates Co-Authored-By: Claude Sonnet 5 --- .../companies.fayda-identity.spec.ts | 412 ++++++++++++++++++ .../companies.poa-delegation.spec.ts | 242 ++++++++++ 2 files changed, 654 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts 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 new file mode 100644 index 000000000..7f72d431a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -0,0 +1,412 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyNationality, CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * A person's identity is proved through Fayda: name, email, phone and address + * come from the verified payload, not typed. Fayda's userinfo carries no + * national ID number, so none is collected or derived here. + * + * - Ethiopian company: the owner (and its PoA, once named) is verified through + * Fayda, and their details can't be edited afterwards. + * - Foreign company: Fayda is an Ethiopian national ID, so the owner instead + * supplies a typed passport number — required on its own, whether or not the + * owner also completes a (purely optional) Fayda verification. + * + * The 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. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + nationality: CompanyNationality; + verification: Record; +} + +const OWNER_VERIFIED = { + ownerFaydaSub: "owner-sub", + ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z", + ownerName: "Abebe Bikila", +}; + +const POA_VERIFIED = { + poaFaydaSub: "poa-sub", + poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z", + poaName: "Tirunesh Dibaba", + poaEmail: "tirunesh@example.com", + poaPhone: "+251911000000", +}; + +const paper = () => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus: null, +}); + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + nationality: CompanyNationality.Ethiopian, + verification: { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", + email: "haile@example.com", + phoneNumber: "+251922000000", + address: "Addis Ababa", + birthdate: "1973-04-18", + gender: "Male", + }, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + nationality: ctx.nationality, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + if (patch.attributes) + ctx.attributes = patch.attributes as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => null), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async () => null), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + verifayda: { + completeVerification: jest.fn(async () => ctx.verification), + }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + deps.verifayda as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps, company }; +} + +describe("Fayda identity verification binds a person to the company", () => { + it("writes the verified identity", async () => { + const { service, ctx } = makeService(); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(ctx.attributes.ownerFaydaSub).toBe("new-sub"); + expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie"); + expect(state.owner.verified).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("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("refuses to make one identity both owner and PoA", async () => { + const { service } = makeService({ + attributes: { ownerFaydaSub: "same-person" }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "same-person", + fullName: "Abebe Bikila", + }, + }); + + await expect( + service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("stages the change for review on an approved company", async () => { + // Swapping the person who can act for a live company is exactly what the + // backoffice review exists for, so it must not rewrite the row directly. + const { service, ctx, deps } = makeService({ + status: CompanyStatus.Active, + }); + + await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(deps.changeRequestRepo.create).toHaveBeenCalled(); + expect(ctx.attributes.poaFaydaSub).toBeUndefined(); + }); + + it("refuses to rename a verified person by hand", async () => { + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + files: [paper()], + }); + + await expect( + service.updateProfile("user-1", { poaName: "Someone Else" } as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + 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); + }); + + 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 with an owner passport and no Fayda at all", async () => { + 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 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); + }); +}); 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 new file mode 100644 index 000000000..9e1c76477 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -0,0 +1,242 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS + * delegation paper on file. The rule used to live only in the onboarding + * wizard's completion check, so every other write that could break the pairing + * — saving PoA details, deleting the paper, picking up the forwarder role — + * went unguarded. These cover those writes. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + pendingSnapshot: Record | null; +} + +const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" }; + +/** + * The forwarder role is gated on Fayda-verified identities as well as on the + * delegation paper. These tests are about the paper, so they run against a + * company whose identities are already verified — the identity rule itself is + * covered in companies.fayda-identity.spec.ts. + */ +const VERIFIED_IDENTITIES = { + ownerFaydaSub: "owner-sub", + poaFaydaSub: "poa-sub", +}; + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + pendingSnapshot: null, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + ctx.attributes = (patch.attributes ?? + ctx.attributes) as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => + ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, + ), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async (id: string) => + ctx.files.find((f) => f.id === id) + ? { + ...ctx.files.find((f) => f.id === id), + resource: "companies", + resourceId: "company-1", + name: "dars.pdf", + } + : null, + ), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + {} as never, + ); + + // getCompanyInfoByUserId does its own lookups; the stubs above are enough for + // the PoA paths, so short-circuit it rather than mock the whole graph. + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps }; +} + +const paper = (reviewStatus: string | null = null) => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus, +}); + +describe("PoA delegation paper is enforced wherever PoA state changes", () => { + it("rejects PoA details saved with no paper on file", async () => { + const { service } = makeService(); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("accepts PoA details once the paper is on file", async () => { + const { service } = makeService({ files: [paper()] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).resolves.toBeDefined(); + }); + + it("rejects a paper the reviewer sent back for correction", async () => { + const { service } = makeService({ files: [paper("change_requested")] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("leaves edits that don't touch the PoA alone", async () => { + // A company carrying legacy details must not be locked out of every other + // field until it produces a paper. + const { service } = makeService({ attributes: { ...POA }, files: [] }); + + await expect( + service.updateProfile("user-1", { companyEmail: "x@y.com" } as never), + ).resolves.toBeDefined(); + }); + + it("refuses to remove the paper while the PoA is still named", async () => { + const { service } = makeService({ + attributes: { ...POA }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("allows removing the paper once the PoA has been cleared", async () => { + const { service } = makeService({ attributes: {}, files: [paper()] }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("judges the removal against a staged clear, not the live row", async () => { + // An Active company's edits are staged for review rather than written, so + // the live attributes still carry the PoA the customer just cleared. + const { service } = makeService({ + status: CompanyStatus.Active, + attributes: { ...POA }, + pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("refuses the forwarder role to a company with no PoA", async () => { + const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role once PoA details and paper are both in place", async () => { + const { service } = makeService({ + attributes: { ...POA, ...VERIFIED_IDENTITIES }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); +}); From f1431a83537f8f3449b0b486b46229712dee75e0 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 28 Jul 2026 13:44:51 +0000 Subject: [PATCH 4/8] feat(freight-portal): fayda verify ui, owner/gm split, foreign passport - FaydaVerifyPanel + /callback popup flow for owner and poa - general manager is a plain typed role again, offers "same as verified owner" copy instead of being fayda-verified itself - company step gates on owner verification (ethiopian) or typed passport number (foreign); poa step gates on poa verification - settings tabs (company profile, general manager, poa) updated to match Co-Authored-By: Claude Sonnet 5 --- apps/edr-freight-web/portal/src/App.tsx | 4 + .../src/components/FaydaVerifyPanel.tsx | 212 ++++++++++++++++++ .../onboarding/OnboardingWizardDialog.tsx | 8 + .../portal/src/pages/FaydaCallbackPage.tsx | 56 +++++ .../portal/src/pages/SettingsPage.tsx | 13 +- .../src/pages/accounts/CompanyProfileForm.tsx | 201 ++++++++++++----- .../accounts/companyProfileForm/helpers.ts | 6 +- .../accounts/companyProfileForm/schema.ts | 42 +++- .../src/pages/settings/TabCompanyProfile.tsx | 49 ++-- .../src/pages/settings/TabDocuments.tsx | 2 +- .../src/pages/settings/TabGeneralManager.tsx | 40 +++- .../src/pages/settings/TabPowerOfAttorney.tsx | 198 +++++++++++----- .../portal/src/services/companies.service.ts | 5 + .../portal/src/services/verifayda.service.ts | 94 ++++++++ .../portal/src/types/profile.ts | 11 + 15 files changed, 790 insertions(+), 151 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx create mode 100644 apps/edr-freight-web/portal/src/services/verifayda.service.ts diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 72674363c..3a010663c 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -55,6 +55,7 @@ import NewShipmentPage from "./pages/contracts/NewShipmentPage"; import NewShipmentRequestPage from "./pages/contracts/NewShipmentRequestPage"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; +import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import TrackingPage from "./pages/tracking/TrackingPage"; @@ -262,6 +263,9 @@ const App = () => { element={} /> {/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */} + {/* Fayda (eSignet) redirect_uri — runs in the verification popup and + relays the code/state back to the form that opened it. */} + } /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx new file mode 100644 index 000000000..6897a1a33 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -0,0 +1,212 @@ +import { useEffect, useRef, useState } from "react"; +import { + Alert, + Badge, + Button, + Card, + Group, + SimpleGrid, + Stack, + Text, +} from "@mantine/core"; +import { BadgeCheck, ShieldCheck, XCircle } from "lucide-react"; + +import { + verifaydaService, + type CompanyIdentityState, + type FaydaCallbackMessage, + type IdentitySubject, + type IdentityVerificationState, +} from "@/services/verifayda.service"; + +interface FaydaVerifyPanelProps { + subject: IdentitySubject; + /** Heading — "General Manager" / "Power of Attorney". */ + title: string; + /** What this person's verification is currently known to be. */ + state?: IdentityVerificationState; + /** + * False for a foreign company: verification is offered but nothing is gated + * on it, so the panel says so rather than nagging. + */ + required: boolean; + /** Called with the fresh company-wide state once a verification lands. */ + onVerified: (next: CompanyIdentityState) => void; + disabled?: boolean; +} + +function formatDate(iso: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString(); +} + +/** + * Verify one of the company's people through Fayda and show what came back. + * + * The identity is proved in an eSignet popup; that popup lands on /callback, + * which relays the code+state here by postMessage. This window then completes + * the exchange — once, in one place — and the API writes the person's name, + * phone, email and address from the verified payload. Nothing on this panel + * is typed. + */ +export default function FaydaVerifyPanel({ + subject, + title, + state, + required, + onVerified, + disabled, +}: FaydaVerifyPanelProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // The listener closes over `subject`; keep it in a ref so remounting the + // panel between steps can't complete a verification against the wrong person. + const subjectRef = useRef(subject); + subjectRef.current = subject; + + useEffect(() => { + const onMessage = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== "fayda-callback") return; + + if (event.data.error) { + setLoading(false); + setError(event.data.errorDescription ?? event.data.error); + return; + } + if (!event.data.code || !event.data.state) return; + + try { + const next = await verifaydaService.completeIdentity( + subjectRef.current, + event.data.code, + event.data.state, + ); + setError(null); + onVerified(next); + } catch (err) { + setError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error ? err.message : "Verification failed"), + ); + } finally { + setLoading(false); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const startVerification = async () => { + setError(null); + setLoading(true); + try { + const authorizationUrl = await verifaydaService.start(); + const popup = window.open( + authorizationUrl, + "fayda-verify", + "width=480,height=760,noopener=no", + ); + if (!popup) { + setLoading(false); + setError("Pop-up blocked — allow pop-ups for this site and try again."); + } + // Loading stays on until the popup posts back. + } catch (err) { + setLoading(false); + setError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err instanceof Error ? err.message : "Could not start verification"), + ); + } + }; + + const verified = state?.verified ?? false; + + return ( + + + + + + {title} identity + + {verified ? ( + } + > + Fayda verified + + ) : ( + required && ( + + Verification required + + ) + )} + + + + + {!verified && ( + + {required + ? "Verify this person with Fayda. Their name, phone and address come from the verification — there is nothing to fill in by hand." + : "Optional for a foreign company. If this person holds a Fayda ID, verifying it fills in their details."} + + )} + + {verified && state && ( + + + + + + + + )} + + {error && ( + }> + {error} + + )} + + ); +} + +function VerifiedField({ + label, + value, +}: { + label: string; + value: string | null; +}) { + if (!value) return null; + return ( + + + {label} + + + {value} + + + ); +} 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 55c63ac52..4ac5f6c55 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -424,6 +424,14 @@ export default function OnboardingWizardDialog({ onLicenseChange: setLicenseFiles, uploadedDocumentKeys, onUploadDocuments: handleUploadDocuments, + // Fayda verification state for the owner and the PoA — the general manager + // 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/FaydaCallbackPage.tsx b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx new file mode 100644 index 000000000..c25dc836a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { Center, Loader, Stack, Text } from "@mantine/core"; + +import type { FaydaCallbackMessage } from "@/services/verifayda.service"; + +/** + * Landing page for the portal's eSignet redirect_uri + * (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). Runs inside the + * verification popup: relays ?code&state (or ?error) to the window that opened + * it via postMessage, then closes itself. The opener performs the completion + * call so the single-use session is only consumed once, in one place. + */ +export default function FaydaCallbackPage() { + const [standalone, setStandalone] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const message: FaydaCallbackMessage = { + type: "fayda-callback", + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + error: params.get("error") ?? undefined, + errorDescription: params.get("error_description") ?? undefined, + }; + + if (window.opener && window.opener !== window) { + (window.opener as Window).postMessage(message, window.location.origin); + window.close(); + } else { + // Opened as a full-page redirect instead of a popup — nothing to relay to. + setStandalone(true); + } + }, []); + + return ( +
+ + {standalone ? ( + <> + Verification window lost its parent page + + Close this tab and start the verification again from the form. + + + ) : ( + <> + + + Completing Fayda verification… + + + )} + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 755691607..5680499c7 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -63,13 +63,22 @@ type SettingsTab = /** A section is "incomplete" when its required fields aren't filled in yet. */ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean { switch (tabId) { - case "company": + case "company": { + // Identity proof lives here: the owner's Fayda verification for an + // Ethiopian company, or the owner's typed passport number for a foreign + // one. + const identity = profile.identity; + const identityIncomplete = identity + ? (identity.faydaRequired && !identity.owner.verified) || + (identity.passportRequired && !identity.owner.passportNumber) + : false; return ( !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || - !profile.fanNumber + identityIncomplete ); + } case "contact": return !profile.contactPersonName || !profile.contactPersonPhone; case "gm": 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 dd68c7006..b068c0a6f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -9,11 +9,10 @@ import { Stack, Text, TextInput, - Tooltip, } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, ArrowLeft, ArrowRight, Info } from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; @@ -43,6 +42,8 @@ import { stepPayload, toFormValues, } from "./companyProfileForm/helpers"; +import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; +import type { CompanyIdentityState } from "@/services/verifayda.service"; import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard"; import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField"; @@ -65,6 +66,8 @@ export default function CompanyProfileForm({ submitError, uploadedDocumentKeys, onUploadDocuments, + identity, + onIdentityChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -102,6 +105,10 @@ export default function CompanyProfileForm({ onUploadDocuments?: () => Promise< { ok: true } | { ok: false; error: string } >; + /** Fayda verification state for the owner and the PoA (undefined until loaded). */ + identity?: CompanyIdentityState; + /** Refetch the profile + requirements once a verification lands. */ + onIdentityChange?: () => void; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -161,10 +168,14 @@ export default function CompanyProfileForm({ ); // A freight forwarder signs on other companies' behalf, so its Power of - // Attorney (details + delegation letter) is mandatory rather than optional. + // Attorney (details + DARS delegation paper) is mandatory rather than optional. const requirePoa = (roleProfiles ?? []).some( (p) => p.type === "freight_forwarder", ); + // Fayda is an Ethiopian national ID: an Ethiopian company verifies its owner + // and PoA instead of typing their details, a foreign one keeps the typed + // forms (plus a mandatory owner passport number). + const verifiedIdentity = identity?.faydaRequired === true; const { register, @@ -175,7 +186,13 @@ export default function CompanyProfileForm({ setValue, formState: { errors }, } = useForm({ - resolver: zodResolver(buildOnboardingSchema(requirePoa)), + resolver: zodResolver( + buildOnboardingSchema( + requirePoa, + verifiedIdentity, + identity?.passportRequired === true, + ), + ), defaultValues: { companyName: "", companyEmail: "", @@ -184,7 +201,7 @@ export default function CompanyProfileForm({ companyAddress: "", tinNumber: "", vatNumber: "", - fanNumber: "", + ownerPassportNumber: "", licenceNumber: "", statusDescription: "", dateRegistered: "", @@ -302,10 +319,19 @@ export default function CompanyProfileForm({ // field of its own, so it falls back to the registering user's account name. const companyEmail = watch("companyEmail"); const companyPhone = watch("companyPhone"); - const gmSourceName = etradeOwner?.name ?? user.name?.en ?? ""; - const gmSourceEmail = companyEmail || user.email || ""; + // A Fayda-verified owner outranks eTrade's registered owner — it's the + // higher-trust source, and the whole point of proving identity is to stop + // trusting typed/looked-up data for this. + const gmSourceName = + identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? ""; + const gmSourceEmail = + identity?.owner.email ?? (companyEmail || user.email || ""); const gmSourcePhone = - companyPhone || etradeOwner?.phone || toEthiopianE164(user.phoneNumber) || ""; + identity?.owner.phone ?? + companyPhone ?? + etradeOwner?.phone ?? + toEthiopianE164(user.phoneNumber) ?? + ""; useEffect(() => { if (!gmSameAsOwner) return; @@ -387,10 +413,11 @@ export default function CompanyProfileForm({ } }; - // The delegation letter is seeded into the same nationality document set as - // the rest, but belongs on the PoA step next to the details it evidences — - // so it's split out here and the Documents step renders the remainder. Both - // halves share `documentFiles`, so the existing bulk upload still carries it. + // The DARS delegation paper ships in the same nationality document set as the + // rest (the API guarantees it is there), but belongs on the PoA step next to + // the details it evidences — so it's split out here and the Documents step + // renders the remainder. Both halves share `documentFiles`, so the existing + // bulk upload still carries it. const poaDocumentField = uploadSetting?.fields?.find( (f) => f.fileKey === POA_DELEGATION_FILE_KEY, ); @@ -507,13 +534,13 @@ export default function CompanyProfileForm({ ]; const currentIdx = stepOrder.indexOf(step); - // The delegation letter is what proves the representative was actually + // The DARS delegation paper is what proves the representative was actually // delegated, so it's required the moment a PoA exists — and unconditionally - // for a freight forwarder, whose PoA itself is mandatory. Skipped entirely - // when the document set predates the field (seeder not yet re-run). + // for a freight forwarder, whose PoA itself is mandatory. The API enforces + // the same rule on save, so skipping it here only costs the customer a + // round-trip. const poaProvided = hasPoaDetails(watch()); - const delegationRequired = - Boolean(poaDocumentField) && (requirePoa || poaProvided); + const delegationRequired = requirePoa || poaProvided; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -572,15 +599,41 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + // The owner's identity is proved outside the form state too, so it gates + // here rather than through zod. + if ( + step === "company" && + identity && + ((identity.faydaRequired && !identity.owner.verified) || + (identity.passportRequired && !identity.owner.passportNumber)) + ) { + setSaveError( + identity.faydaRequired + ? "Verify the company owner's identity with Fayda before continuing." + : "Add the company owner's passport number before continuing.", + ); + return; + } + if ( + step === "poa" && + verifiedIdentity && + requirePoa && + !identity?.poa.verified + ) { + setSaveError( + "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.", + ); + return; + } // The PoA step also gates on a file, which lives outside the form state. if (step === "poa" && delegationRequired && !delegationPresent) { setDocumentFieldErrors({ - [POA_DELEGATION_FILE_KEY]: "Delegation letter is required", + [POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required", }); setSaveError( requirePoa - ? "Freight forwarders must provide Power of Attorney details and a delegation letter." - : "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.", + ? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper." + : "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.", ); // Fall through to validate the text fields too, so every problem shows at once. await trigger(stepFields.poa); @@ -639,39 +692,34 @@ export default function CompanyProfileForm({ error={errors.companyLocation?.message} {...register("companyLocation")} /> - - - - FAN Number (16 digits) - - - - - } - placeholder="1234567890123456" - maxLength={16} - error={errors.fanNumber?.message} - {...register("fanNumber")} - /> - + + + {identity && ( + <> + onIdentityChange?.()} + /> + {identity.passportRequired && ( + + )} + + )} {hasRegistrationDetails && ( <> @@ -778,14 +826,24 @@ 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. */} {requirePoa - ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required." - : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."} + ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required." + : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."} - {watch("contactPersonName") && ( + {identity && ( + onIdentityChange?.()} + /> + )} + {!verifiedIdentity && watch("contactPersonName") && ( )} + {!verifiedIdentity && ( + <> + + )} + {/* The city is the one field the Fayda address claim does not + reliably decompose into, so it stays typed either way. */} + {verifiedIdentity && ( + + )} {poaDocumentSetting && ( <> diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index 8f8a24eca..b6b7fbbf0 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -29,8 +29,8 @@ export function buildPayload( companyAddress: data.companyAddress, tin: data.tinNumber, vatNumber: data.vatNumber, - fanNumber: data.fanNumber, attributes: { + ownerPassportNumber: data.ownerPassportNumber || undefined, contactPersonName: data.contactPersonName, contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, @@ -62,7 +62,7 @@ export function stepPayload( companyAddress: d.companyAddress, tin: d.tinNumber, vatNumber: d.vatNumber, - fanNumber: d.fanNumber, + ownerPassportNumber: d.ownerPassportNumber || undefined, licenceNumber: d.licenceNumber, statusDescription: d.statusDescription, dateRegistered: d.dateRegistered, @@ -114,7 +114,7 @@ export function toFormValues(p: ProfileResponse): FormData { companyAddress: p.companyAddress ?? "", tinNumber: tin, vatNumber: p.vatNumber ?? "", - fanNumber: p.fanNumber ?? "", + ownerPassportNumber: p.identity?.owner.passportNumber ?? "", licenceNumber: p.licenceNumber ?? "", statusDescription: p.statusDescription ?? "", dateRegistered: p.dateRegistered ?? "", 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 275b8a4fa..dab536bba 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 @@ -27,7 +27,10 @@ export const onboardingSchema = z.object({ .string() .min(1, "VAT number is required") .length(10, "VAT number must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + // The owner's passport number — the foreign-company identity credential + // (Fayda is an Ethiopian national ID). Required only for a foreign company; + // enforced in buildOnboardingSchema since that depends on `nationality`. + ownerPassportNumber: z.string().optional(), licenceNumber: z.string().optional(), statusDescription: z.string().optional(), dateRegistered: z.string().optional(), @@ -109,14 +112,35 @@ export const hasPoaDetails = (d: Partial) => * delegation-letter upload is enforced alongside this, in CompanyProfileForm, * since files live outside the form state). */ -export function buildOnboardingSchema(requirePoa: boolean) { - if (!requirePoa) return onboardingSchema; +export function buildOnboardingSchema( + requirePoa: boolean, + /** + * True when the PoA's identity fields come from a Fayda verification rather + * than the form (Ethiopian companies). Requiring them here would fail + * validation against inputs the step no longer renders — the verification + * itself is what the step gates on instead. + */ + faydaOwnedPoa = false, + /** True for a foreign company: the owner's passport number is mandatory. */ + passportRequired = false, +) { + const poaRequired = requirePoa && !faydaOwnedPoa; + if (!poaRequired && !passportRequired) return onboardingSchema; return onboardingSchema.superRefine((d, ctx) => { - const required: [keyof FormData, string][] = [ - ["poaName", "PoA name is required for freight forwarders"], - ["poaEmail", "PoA email is required for freight forwarders"], - ["poaPhone", "PoA phone is required for freight forwarders"], - ]; + const required: [keyof FormData, string][] = []; + if (poaRequired) { + required.push( + ["poaName", "PoA name is required for freight forwarders"], + ["poaEmail", "PoA email is required for freight forwarders"], + ["poaPhone", "PoA phone is required for freight forwarders"], + ); + } + if (passportRequired) { + required.push([ + "ownerPassportNumber", + "The owner's passport number is required", + ]); + } for (const [path, message] of required) { if (!d[path]?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message }); @@ -134,7 +158,7 @@ export const stepFields: Record = { "companyAddress", "tinNumber", "vatNumber", - "fanNumber", + "ownerPassportNumber", "licenceNumber", "statusDescription", "dateRegistered", diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 597dfa509..57d589bdc 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -22,6 +22,7 @@ import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import OnboardingRoleSelect from "./OnboardingRoleSelect"; +import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -33,13 +34,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), vatNumber: z .string() .trim() .max(20, "VAT number is too long") .optional() .or(z.literal("")), + ownerPassportNumber: z.string().optional(), }); export type CompanyProfileFormData = z.infer; @@ -68,8 +69,8 @@ export default function TabCompanyProfile({ companyLocation: profile.companyLocation, companyAddress: profile.companyAddress ?? "", tinNumber: profile.tinNumber, - fanNumber: profile.fanNumber ?? "", vatNumber: profile.vatNumber ?? "", + ownerPassportNumber: profile.identity?.owner.passportNumber ?? "", }; } return { @@ -79,8 +80,8 @@ export default function TabCompanyProfile({ companyLocation: "", companyAddress: "", tinNumber: "", - fanNumber: "", vatNumber: "", + ownerPassportNumber: "", }; }, [profile]); @@ -104,8 +105,10 @@ export default function TabCompanyProfile({ companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, - fanNumber: data.fanNumber, vatNumber: data.vatNumber ?? "", + ...(data.ownerPassportNumber !== undefined + ? { ownerPassportNumber: data.ownerPassportNumber } + : {}), }; if (isCreate) { @@ -222,18 +225,6 @@ export default function TabCompanyProfile({ {...register("tinNumber")} /> - - - - - - + + {profile?.identity && ( + <> + + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }) + } + /> + {profile.identity.passportRequired && ( + + )} + + )} + void; } +/** + * 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. + */ export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) { const queryClient = useQueryClient(); + const owner = profile.identity?.owner; + const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const defaultValues = useMemo((): FormData => { return { @@ -51,12 +61,32 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } control, handleSubmit, reset, + setValue, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), values: defaultValues, }); + const toggleGmSameAsOwner = (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 }); + } + }; + + // 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({ @@ -84,6 +114,14 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
+ {owner?.verified && ( + + )} 0; // A freight forwarder signs on other companies' behalf, so its PoA — details - // and delegation letter both — is mandatory rather than optional. + // and DARS delegation paper both — is mandatory rather than optional. const requirePoa = profile.companyProfiles.some( (p) => p.type === "freight_forwarder", ); + // An Ethiopian company does not type its representative's details — they + // come from the Fayda verification. A foreign company keeps the typed form: + // its representative may hold no Fayda ID. + const identity = profile.identity; + const verifiedIdentity = identity?.faydaRequired === true; + const poaValues = watch([ "poaName", "poaEmail", @@ -147,7 +155,9 @@ export default function TabPowerOfAttorney({ "poaLocation", "poaAddress", ]); - const poaProvided = poaValues.some((v) => v?.trim()); + const poaProvided = verifiedIdentity + ? (identity?.poa.verified ?? false) + : poaValues.some((v) => v?.trim()); const letterRequired = requirePoa || poaProvided; const letterMissing = letterRequired && !hasLetterAfterSave; @@ -155,22 +165,32 @@ export default function TabPowerOfAttorney({ const mutation = useMutation({ mutationFn: async (data: FormData) => { - // A fresh upload already stages the removal of every live letter, so the - // explicit removals only need applying when no replacement was picked. + // Every identity field except the city is written by the verification, so + // an Ethiopian company only ever saves the paper and the location here. + const fields = verifiedIdentity + ? { poaLocation: data.poaLocation || undefined } + : { + poaName: data.poaName || undefined, + poaPhone: data.poaPhone || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + poaAddress: data.poaAddress || undefined, + }; + // A fresh upload already stages the removal of every paper on file, so + // the explicit removals only need applying when no replacement was + // picked. Saving the details after it means the API sees the new paper. if (pickedFile) { await companiesService.uploadPoaDelegation(pickedFile); - } else { - for (const fileId of removeIds) { - await companiesService.removePoaDelegation(fileId); - } + return api.companies.updateProfile.call(fields); } - return api.companies.updateProfile.call({ - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - poaAddress: data.poaAddress || undefined, - }); + // Nothing replacing it, so the details go first: the API judges a removal + // against the PoA the customer is keeping, and clearing both together is + // the only way it will let the paper go. + const saved = await api.companies.updateProfile.call(fields); + for (const fileId of removeIds) { + await companiesService.removePoaDelegation(fileId); + } + return saved; }, onSuccess: () => { setPickedFile(null); @@ -185,6 +205,24 @@ export default function TabPowerOfAttorney({ }, }); + /** + * A verified representative cannot be removed by blanking the form — their + * fields are owned by the verification — so removal is its own action that + * clears the identity and the delegation paper together. + */ + const removeMutation = useMutation({ + mutationFn: () => verifaydaService.removePoa(), + onSuccess: () => { + resetAll(); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: api.companies.poaDelegation.queryKey(), + }); + }, + }); + const onSubmit = (data: FormData) => { // The letter lives outside the form state, so it's gated here rather than // in the zod resolver. @@ -234,37 +272,62 @@ export default function TabPowerOfAttorney({ {requirePoa - ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its delegation letter are required." - : "Power of Attorney details are optional. If you name a representative, upload the delegation letter authorising them."} + ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its DARS delegation paper are required." + : "Power of Attorney details are optional. If you name a representative, upload the DARS delegation paper authorising them."} + {identity && ( + { + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: api.companies.poaDelegation.queryKey(), + }); + }} + /> + )} + - - - - + {/* Name, email, phone and address are written by the Fayda + verification for an Ethiopian company, so only the city — which + the address claim does not reliably decompose into — is typed. */} + {!verifiedIdentity && ( + <> - - - - - + + + + + + + + + + + )} @@ -275,14 +338,16 @@ export default function TabPowerOfAttorney({ {...register("poaLocation")} /> - - - + {!verifiedIdentity && ( + + + + )} @@ -292,7 +357,7 @@ export default function TabPowerOfAttorney({ - Delegation letter + DARS delegation paper - The signed letter in which the General Manager delegates the - representative above. Submitted to EDR for review together with the - details; it takes effect once approved. + The delegation paper issued by the Documents Authentication and + Registration Service (DARS) for the representative above — the + authenticated copy, not a plain letter. Submitted to EDR for review + together with the details; it takes effect once approved. {saveBlocked && letterMissing && ( @@ -326,8 +392,8 @@ export default function TabPowerOfAttorney({ icon={} > {requirePoa - ? "Upload the delegation letter before saving — it is required for freight forwarders." - : "Upload the delegation letter for the representative you named, or clear the PoA details."} + ? "Upload the DARS delegation paper before saving — it is required for freight forwarders." + : "Upload the DARS delegation paper for the representative you named, or clear the PoA details."} )} @@ -345,7 +411,7 @@ export default function TabPowerOfAttorney({ }} > - No delegation letter uploaded. + No DARS delegation paper uploaded. ) : ( @@ -397,7 +463,7 @@ export default function TabPowerOfAttorney({ setPickedFile(null)} > @@ -414,7 +480,7 @@ export default function TabPowerOfAttorney({ - Awaiting EDR review — this letter takes effect once approved. + Awaiting EDR review — this paper takes effect once approved. )} @@ -456,6 +522,20 @@ export default function TabPowerOfAttorney({ )} + {mode === "edit" && + verifiedIdentity && + identity?.poa.verified && + !requirePoa && ( + + )} {mode === "edit" && (