diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c1b890719..029f2040b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -49,6 +49,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -221,6 +222,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + StampSettingsModule, ContractTemplatesModule, SupportContentModule, OtpModule, diff --git a/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts new file mode 100644 index 000000000..055c3771b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts @@ -0,0 +1,210 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Onboarding revamp: one company, one verified identity. + * + * The general manager is removed outright — it named who to talk to and gated + * nothing — and the company's people become its **owner** (whoever the eTrade + * licence names as the business's manager) and its **Power of Attorney**. + * Exactly one of them is identity-verified, chosen by the company's own answer + * to "does anyone hold power of attorney for you?", stored as + * `attributes.poaDeclared`. + * + * The order below matters — each step depends on data a later step destroys: + * + * 1. Rescue notification addresses. `companyNotifyEmailExpr` used to fall + * through to `attributes->>'generalManagerEmail'`, and `companies.email` was + * only ever written for a Fayda-verified owner — so every foreign company + * had none and was reached solely through that fallback. Promote it to the + * column before the key is stripped, or those companies stop receiving mail + * in silence. + * 2. Backfill the owner. `ownerName`/`ownerEmail`/`ownerPhone` are now required + * onboarding fields; without this every already-onboarded company would + * report three missing fields the moment it opened its settings page. + * 3. Resolve `poaSameAsOwner`. That flag waived the DARS delegation paper. It + * is gone, so the companies holding it must be re-expressed: + * - NOT a freight forwarder → "no PoA" (the owner represents themselves, + * nothing to delegate). Their PoA details are cleared. + * - A freight forwarder → "yes" and details KEPT. A forwarder signs on + * other companies' behalf, so a representative is non-negotiable and the + * waiver no longer exists. These companies will be asked for a + * delegation paper they were previously excused — an intentional, + * visible consequence, not an oversight. Count them before deploying. + * 4. Derive the declaration for everyone else, from whether PoA details exist. + * 5. Move drafts off the deleted "personnel" wizard step. + * 6. Only now drop the columns and strip the retired attribute keys. + * + * Irreversible by design: `down()` restores the columns' shape but cannot + * recover the values, and re-deriving `poaSameAsOwner` from `poaDeclared` would + * be a guess. + */ +export class RemoveGeneralManager3390000000000 implements MigrationInterface { + name = "RemoveGeneralManager3390000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Rescue the notification address before the key it lives under is gone. + await queryRunner.query(` + UPDATE freight.companies + SET email = COALESCE( + NULLIF(email, ''), + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', '') + ) + WHERE COALESCE(email, '') = '' + `); + + // 2. Backfill the owner from the best source each company actually has: + // its Fayda-verified owner claims (already under owner*), then the general + // manager it named, then its contact person. A company with none of these + // never finished onboarding, and will be asked on its next visit. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes + || jsonb_strip_nulls(jsonb_build_object( + 'ownerName', COALESCE( + NULLIF(attributes->>'ownerName', ''), + NULLIF(attributes->>'generalManagerName', ''), + NULLIF(attributes->>'contactPersonName', '') + ), + 'ownerEmail', COALESCE( + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', ''), + NULLIF(email, '') + ), + 'ownerPhone', COALESCE( + NULLIF(attributes->>'ownerPhone', ''), + NULLIF(attributes->>'generalManagerPhone', ''), + NULLIF(attributes->>'contactPersonPhone', ''), + NULLIF(phone, '') + ) + )) + WHERE attributes IS NOT NULL + `); + + // 2b. Capture eTrade's manager for the owner-vs-licence check the + // backoffice now makes. Nothing stored it before, so the best we have is + // the owner name itself — which makes existing companies read as "matches" + // rather than as a false mismatch on data nobody ever compared. The value + // is refreshed for real on the company's next eTrade lookup. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, '{etradeManagerName}', to_jsonb(attributes->>'ownerName') + ) + WHERE COALESCE(attributes->>'ownerName', '') <> '' + AND attributes->>'etradeManagerName' IS NULL + AND COALESCE(licence_number, '') <> '' + `); + + // 3a. Owner-represents-themselves, and NOT a forwarder → "no PoA". + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = (c.attributes - 'poaName' - 'poaPhone' - 'poaEmail' + - 'poaLocation' - 'poaAddress' - 'poaFaydaSub' + - 'poaFaydaVerifiedAt' - 'poaBirthdate' - 'poaGender') + || jsonb_build_object('poaDeclared', 'no') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND NOT EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 3b. Forwarders keep their representative and lose the waiver. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = c.attributes || jsonb_build_object('poaDeclared', 'yes') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 4. Everyone else: "yes" if a representative was named or the company is a + // forwarder, "no" if it finished onboarding without one. A company still + // mid-onboarding is left unanswered — it will be asked, which is the point. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'yes') + WHERE c.attributes->>'poaDeclared' IS NULL + AND ( + COALESCE(c.attributes->>'poaName', '') <> '' + OR COALESCE(c.attributes->>'poaPhone', '') <> '' + OR COALESCE(c.attributes->>'poaEmail', '') <> '' + OR COALESCE(c.attributes->>'poaLocation', '') <> '' + OR COALESCE(c.attributes->>'poaAddress', '') <> '' + OR EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + ) + `); + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'no') + WHERE c.attributes->>'poaDeclared' IS NULL + AND EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = c.id + AND ep.onboarding_completed = true + AND ep.deleted_at IS NULL + ) + `); + + // 5. The "personnel" (general manager) wizard step no longer exists; a + // draft resting on it would fall back to the very first step and make the + // customer walk the whole wizard again. + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'owner' + WHERE onboarding_step = 'personnel' + `); + + // 6. Retire the general manager and the flag it shared the model with. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes - 'generalManagerName' - 'generalManagerEmail' + - 'generalManagerPhone' - 'gmSameAsOwner' - 'gmFaydaSub' + - 'gmFaydaVerifiedAt' - 'gmName' - 'gmEmail' - 'gmPhone' + - 'gmAddress' - 'gmBirthdate' - 'gmGender' + - 'poaSameAsOwner' + WHERE attributes IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS general_manager_name, + DROP COLUMN IF EXISTS general_manager_email, + DROP COLUMN IF EXISTS general_manager_phone + `); + } + + /** + * Restores the columns' shape only. The values, the `gm*` attributes and the + * `poaSameAsOwner` flag are not recoverable — this migration folded them into + * `ownerEmail` / `poaDeclared`, and there is no way back that isn't a guess. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS general_manager_name varchar(100), + ADD COLUMN IF NOT EXISTS general_manager_email varchar(150), + ADD COLUMN IF NOT EXISTS general_manager_phone varchar(20) + `); + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'personnel' + WHERE onboarding_step = 'owner' + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts new file mode 100644 index 000000000..9556318f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table holding the one company stamp/seal image stamped onto + * generated invoice/receipt PDFs (see StampSettingsService / + * InvoiceDocumentService). Same single-row shape as exchange_settings; the + * app never inserts more than one row. + */ +export class StampSettings3400000000000 implements MigrationInterface { + name = "StampSettings3400000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.stamp_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + stamp_file_id uuid REFERENCES freight.files(id), + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts index c320a5d44..363cda3f0 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service"; * Standalone document infrastructure — generic HTML→PDF plus the shared * invoice/receipt renderer. Has no domain dependencies, so any module (billing, * warehouses, …) can import it to print invoices without coupling to the - * billing payment graph. + * billing payment graph. StampSettingsService is @Global (see + * StampSettingsModule) so InvoiceDocumentService can inject it without this + * module declaring an explicit import. */ @Module({ providers: [PdfRenderService, InvoiceDocumentService], diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 06d164bbd..72146491d 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -1,5 +1,6 @@ import { Injectable } from "@nestjs/common"; +import { StampSettingsService } from "../../stamp-settings/stamp-settings.service"; import { PdfRenderService } from "./pdf-render.service"; import { PdfColor, @@ -53,6 +54,13 @@ export interface InvoiceDocumentModel { totals: InvoiceDocumentTotal[]; /** Override the round seal text; defaults from kind/status. */ sealText?: string; + /** + * Company stamp image (data URL) to render instead of the plain text seal. + * Callers normally leave this unset — `InvoiceDocumentService.render()` + * fills it in from the single global stamp in StampSettingsService; set it + * explicitly only to override that default for one document. + */ + stampImageUrl?: string | null; } /** @@ -63,12 +71,21 @@ export interface InvoiceDocumentModel { */ @Injectable() export class InvoiceDocumentService { - constructor(private readonly pdf: PdfRenderService) {} + constructor( + private readonly pdf: PdfRenderService, + private readonly stampSettings: StampSettingsService, + ) {} async render( model: InvoiceDocumentModel, ): Promise<{ filename: string; buffer: Buffer }> { - const html = this.buildHtml(model); + const stampImageUrl = + model.stampImageUrl !== undefined + ? model.stampImageUrl + : await this.stampSettings.getStampImageUrl(); + const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl }; + + const html = this.buildHtml(resolvedModel); const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; return { filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, @@ -77,7 +94,11 @@ export class InvoiceDocumentService { // Chromium-less fallback: draw a genuine styled invoice (header, seal, // summary grid, line-item table, totals) from the model — not a flat // plain-text dump — so it still reads as a proper invoice document. - fallback: () => this.buildFallbackPdf(model), + // ponytail: still draws the plain vector seal, not the uploaded stamp + // image — embedding a raster image needs a new PDF XObject primitive + // in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to + // carry the real stamp too; today it's a rare degraded fallback. + fallback: () => this.buildFallbackPdf(resolvedModel), }), }; } @@ -218,6 +239,10 @@ export class InvoiceDocumentService { const showCategory = Boolean(model.categoryHeader); const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + const sealMarkup = model.stampImageUrl + ? `Company stamp` + : esc(sealText); + const sealClass = model.stampImageUrl ? "seal seal-image" : "seal"; const summaryRows = model.summary .map((row) => `
${esc(row.label)}${esc(row.value)}
`) @@ -256,6 +281,8 @@ export class InvoiceDocumentService { .meta { text-align: right; font-size: 12px; color: #475569; } .meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; } .seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; } + .seal.seal-image { border: none; border-radius: 0; opacity: 1; transform: none; } + .seal img { max-width: 100%; max-height: 100%; object-fit: contain; } .summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; } .summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; } .summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; } @@ -283,7 +310,7 @@ export class InvoiceDocumentService { Issued: ${esc(date(model.issuedAt))} -
${esc(sealText)}
+
${sealMarkup}
${summaryRows}
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 1b14845b2..d35400f1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -39,6 +39,7 @@ import { CompleteIdentityVerificationDto, } from "./dto/complete-identity-verification.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; +import { SetPoaDeclaredDto } from "./dto/set-poa-declared.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; import { @@ -265,6 +266,7 @@ export class CompaniesController { dto.companyType, dto.roles, dto.nationality, + dto.cooperative, ); return new CompanyInfoResponseDto(profile, company); } @@ -415,90 +417,31 @@ export class CompaniesController { @PortalCustomer() @ApiOperation({ summary: - "Bind a completed Fayda verification to the company's owner or Power of Attorney. " + + "Bind a completed Fayda verification to the company's single identity. " + "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " + + "`subject` must match the company's PoA declaration — the representative when one is named, otherwise the owner. " + "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, { - email: user.email, - phoneNumber: user.phoneNumber, - }); + return this.companiesService.completeIdentityVerification(user.id, dto); } - @Post("identity/gm/same-as-owner") + @Patch("identity/poa-declared") @PortalCustomer() @ApiOperation({ summary: - "Declare the General Manager is the company's owner, copying the owner's verified identity across. " + - "Refused until the owner is Fayda-verified — there would be nothing proven to copy.", + "Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified. " + + 'Answering "no" removes the representative entirely: their details, their verification, their passport number and the DARS delegation paper. ' + + 'Refused for a freight forwarder, which cannot operate without a representative (its answer is always "yes").', }) - async setGmSameAsOwner( + async setPoaDeclared( @CurrentUser() user: CurrentIamUser, + @Body() dto: SetPoaDeclaredDto, ): Promise { - return this.companiesService.setGmSameAsOwner(user.id, { - email: user.email, - phoneNumber: user.phoneNumber, - }); - } - - @Delete("identity/gm") - @PortalCustomer() - @ApiOperation({ - summary: - "Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " + - "Leaves the GM open to be verified in their own right, or typed where Fayda is optional.", - }) - async clearGmIdentity( - @CurrentUser() user: CurrentIamUser, - ): Promise { - return this.companiesService.clearGmIdentity(user.id); - } - - @Post("identity/poa/same-as-owner") - @PortalCustomer() - @ApiOperation({ - summary: - "Declare the Power of Attorney is the company's owner, copying the owner's identity across. " + - "Waives the DARS delegation paper — nobody delegates to themselves. " + - "Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.", - }) - async setPoaSameAsOwner( - @CurrentUser() user: CurrentIamUser, - ): Promise { - return this.companiesService.setPoaSameAsOwner(user.id, { - email: user.email, - phoneNumber: user.phoneNumber, - }); - } - - @Delete("identity/poa/same-as-owner") - @PortalCustomer() - @ApiOperation({ - summary: - "Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " + - "Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.", - }) - async clearPoaSameAsOwner( - @CurrentUser() user: CurrentIamUser, - ): Promise { - return this.companiesService.clearPoaSameAsOwner(user.id); - } - - @Delete("identity/fayda/poa") - @PortalCustomer() - @ApiOperation({ - 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); + return this.companiesService.setPoaDeclared(user.id, dto.declared); } @Patch("onboarding-step") diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index fe40cf08f..30c323ac3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -177,782 +177,451 @@ function makeService(overrides: Partial = {}) { return { service, ctx, deps, company }; } +describe("one company, one verified identity", () => { + it("refuses a verification before the company says who represents it", async () => { + const { service } = makeService(); + await expect( + service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); -describe("Fayda identity verification binds a person to the company", () => { - it("writes the verified identity", async () => { - const { service, ctx } = makeService(); - + it("writes the verified identity onto the declared subject", async () => { + const { service, ctx } = makeService({ attributes: { poaDeclared: "no" } }); const state = await service.completeIdentityVerification("user-1", { subject: "owner", code: "c", state: "s", }); + expect(state.owner.verified).toBe(true); + expect(state.owner.name).toBe("Haile Gebrselassie"); + expect(state.owner.email).toBe("haile@example.com"); + expect(state.owner.address).toBe("Addis Ababa"); expect(ctx.attributes.ownerFaydaSub).toBe("new-sub"); - expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie"); - expect(state.owner.verified).toBe(true); + expect(state.identityProven).toBe(true); + expect(state.complete).toBe(true); }); - it("fills every PoA detail from the payload, address included", async () => { - const { service, ctx } = makeService(); - - await service.completeIdentityVerification("user-1", { - subject: "poa", - code: "c", - state: "s", - }); - - expect(ctx.attributes.poaName).toBe("Haile Gebrselassie"); - expect(ctx.attributes.poaEmail).toBe("haile@example.com"); - expect(ctx.attributes.poaPhone).toBe("+251922000000"); - expect(ctx.attributes.poaAddress).toBe("Addis Ababa"); + it("refuses a verification for the person the declaration does not point at", async () => { + // A PoA-declared company gates on the PoA. An owner verification here would + // sit on the record looking proven while the gate stayed unsatisfied. + const { service } = makeService({ attributes: { poaDeclared: "yes" } }); + await expect( + service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); }); - it("verifies successfully even though Fayda returns no national ID number", async () => { - // Fayda's userinfo carries no FAN/FIN claim at all — this must be the - // normal, successful path, not an error. - const { service } = makeService({ - verification: { - purpose: "VERIFY", - verified: true, - sub: "x", - fullName: "No Fan Here", - }, - }); - - const state = await service.completeIdentityVerification("user-1", { - subject: "owner", - code: "c", - state: "s", - }); - - expect(state.owner.verified).toBe(true); - }); - - it("lets one identity be both owner and PoA", async () => { - // An owner who represents their own company is the ordinary small-business - // case, not a conflict — the same answer the GM has always been allowed. - const { service, ctx } = makeService({ - attributes: { ownerFaydaSub: "same-person" }, - verification: { - purpose: "VERIFY", - verified: true, - sub: "same-person", - fullName: "Abebe Bikila", - }, - }); - + it("verifies the representative when one is declared", async () => { + const { service, ctx } = makeService({ attributes: { poaDeclared: "yes" } }); const state = await service.completeIdentityVerification("user-1", { subject: "poa", code: "c", state: "s", }); - + expect(state.subject).toBe("poa"); expect(state.poa.verified).toBe(true); - expect(ctx.attributes.poaFaydaSub).toBe("same-person"); - }); - - it("stages an owner re-verification for review on an approved company", async () => { - // The owner is the live company's identity proof, so re-verifying one is - // exactly what the backoffice review exists for: it must not rewrite the - // row directly. - const { service, ctx, deps } = makeService({ - status: CompanyStatus.Active, - }); - - await service.completeIdentityVerification("user-1", { - subject: "owner", - code: "c", - state: "s", - }); - - expect(deps.changeRequestRepo.create).toHaveBeenCalled(); - expect(ctx.attributes.ownerFaydaSub).toBeUndefined(); - }); - - it("applies a PoA verification live on an approved company", async () => { - // The PoA is personnel the company names for itself — the delegation paper - // is what a reviewer actually judges — so it does not go to review. - const { service, ctx, deps } = makeService({ - status: CompanyStatus.Active, - }); - - await service.completeIdentityVerification("user-1", { - subject: "poa", - code: "c", - state: "s", - }); - - expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(state.poa.address).toBe("Addis Ababa"); + expect(state.identityProven).toBe(true); expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); }); - it("stages nothing for a verified field an approved company resubmits", async () => { - // Approving it could not move the live row — the verified value is written - // back over it — so it must never reach a reviewer as a pending change. - const { service, deps } = makeService({ - status: CompanyStatus.Active, - attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" }, - }); - - await expect( - service.updateProfile("user-1", { - companyEmail: "someone-else@example.com", - } as never), - ).resolves.toBeDefined(); - expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); - expect(deps.changeRequestRepo.update).not.toHaveBeenCalled(); - expect(deps.companiesRepo.update).not.toHaveBeenCalled(); - }); - - // The verified value wins, and it wins by overwriting rather than by - // rejecting: nobody types these fields, so a submission that disagrees is a - // stale form echoing itself back, not an edit. Failing it would block a save - // the customer never made — and leave them no way through, since re-verifying - // returns the same value they are being 400'd for. - it("overwrites a hand-renamed verified person with the verified name", async () => { + it("verifies successfully even though Fayda returns no national ID number", async () => { const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, - files: [paper()], - }); - - await expect( - service.updateProfile("user-1", { poaName: "Someone Else" } as never), - ).resolves.toBeDefined(); - expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName); - }); - - // Fayda's email and phone claims are optional — a verification can prove the - // person and return neither. Holding the company mirrors to "the owner is - // verified" rather than to "the verification supplied this value" would - // clobber the fallbacks the portal is built to send (account email, eTrade's - // registered phone) with nothing at all. OWNER_VERIFIED is exactly that - // shape: a sub, no contact details. - it("keeps company contact details a Fayda verification never supplied", async () => { - const { deps } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; - expect(patch.email).toBe("account@example.com"); - expect(patch.phone).toBe("+251911777777"); - }); - - // "Same as owner" copies `ownerEmail ?? null` onto the GM while setting - // `gmFaydaSub`. Locking that null made generalManagerEmail required by - // onboarding, hidden by the portal's link card and unwritable at once. - it("lets the GM's details be typed when the copied owner identity carried none", async () => { - const { service } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmSameAsOwner: true, - gmFaydaSub: "owner-sub", - generalManagerName: "Abebe Bikila", - generalManagerEmail: null, - generalManagerPhone: null, + attributes: { poaDeclared: "no" }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", }, }); - - await expect( - service.updateProfile("user-1", { - generalManagerEmail: "gm@example.com", - generalManagerPhone: "+251911888888", - } as never), - ).resolves.toBeDefined(); - }); - - // Fayda's email and phone claims are optional and routinely absent. The owner - // is who the company is reached through and the step renders no input for - // their contact details, so the onboarding account — already OTP-proven — - // stands in rather than leaving the company unreachable. - describe("account contact details stand in for absent Fayda claims", () => { - const noContactClaims = { - purpose: "VERIFY", - verified: true, - sub: "new-sub", - fullName: "Haile Gebrselassie", - address: "Addis Ababa", - }; - const account = { - email: "account@example.com", - phoneNumber: "+251911777777", - }; - - it("falls back to the account for an owner Fayda gave no email or phone", async () => { - const { service, ctx } = makeService({ verification: noContactClaims }); - - await service.completeIdentityVerification( - "user-1", - { subject: "owner", code: "c", state: "s" }, - account, - ); - - expect(ctx.attributes.ownerEmail).toBe("account@example.com"); - expect(ctx.attributes.ownerPhone).toBe("+251911777777"); - }); - - it("prefers the Fayda claim over the account when there is one", async () => { - const { service, ctx } = makeService(); - - await service.completeIdentityVerification( - "user-1", - { subject: "owner", code: "c", state: "s" }, - account, - ); - - expect(ctx.attributes.ownerEmail).toBe("haile@example.com"); - expect(ctx.attributes.ownerPhone).toBe("+251922000000"); - }); - - it("leaves the PoA alone — the account is not that person", async () => { - const { service, ctx } = makeService({ verification: noContactClaims }); - - await service.completeIdentityVerification( - "user-1", - { subject: "poa", code: "c", state: "s" }, - account, - ); - - expect(ctx.attributes.poaEmail).toBeUndefined(); - expect(ctx.attributes.poaPhone).toBeUndefined(); - }); - - // Owners verified before the fallback existed hold blank contacts. Copying - // those blanks onto the GM makes generalManagerEmail required by onboarding - // with no field anywhere to satisfy it. - it("fills the GM copy from the account when the stored owner has no contacts", async () => { - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - await service.setGmSameAsOwner("user-1", account); - - expect(ctx.attributes.gmEmail).toBe("account@example.com"); - expect(ctx.attributes.generalManagerEmail).toBe("account@example.com"); - expect(ctx.attributes.generalManagerPhone).toBe("+251911777777"); - }); - - it("keeps the stored owner contacts when the GM copy has them", async () => { - const { service, ctx } = makeService({ - attributes: { - ...OWNER_VERIFIED, - ownerEmail: "abebe@example.com", - ownerPhone: "+251911000111", - }, - }); - - await service.setGmSameAsOwner("user-1", account); - - expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); - expect(ctx.attributes.generalManagerPhone).toBe("+251911000111"); - }); - }); - - it("never locks or gates the general manager — it is not the verified subject", async () => { - // GM is a plain typed role; the portal offers a "same as owner" copy, but - // the backend must not treat it as identity-owned or require it verified. - const { service } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - await expect( - service.updateProfile("user-1", { - generalManagerName: "Someone Else", - generalManagerEmail: "someone@example.com", - generalManagerPhone: "+251911223344", - } as never), - ).resolves.toBeDefined(); - }); -}); - -describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => { - // The company is applying for the forwarder role, so it must not already - // hold it — createCompanyProfileForUser short-circuits on an existing profile - // and would never reach the gate. - const applyingForFf = { - profileTypes: [ProfileType.importer], - attributes: { ...POA_VERIFIED }, - files: [paper()], - }; - - it("blocks the forwarder role while the owner is unverified", async () => { - const { service } = makeService(applyingForFf); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("blocks the forwarder role while the PoA is unverified", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - attributes: { - ...OWNER_VERIFIED, - poaName: "Tirunesh Dibaba", - poaEmail: "t@example.com", - poaPhone: "+251911000000", - }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("grants the forwarder role once owner and PoA are both verified", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).resolves.toBeDefined(); - }); - - it("never asks a foreign company for Fayda, verified or not", async () => { - const { service } = makeService({ - nationality: CompanyNationality.Foreign, - }); - const state = await service.completeIdentityVerification("user-1", { subject: "owner", code: "c", state: "s", }); - - // Still lets the owner verify — a foreign owner verifying is allowed, just - // never required — but the passport is the thing that actually gates it. expect(state.owner.verified).toBe(true); - expect(state.faydaRequired).toBe(false); - expect(state.passportRequired).toBe(true); + expect(ctx.attributes.fanNumber).toBeUndefined(); }); - it("blocks the forwarder role for a foreign company with no owner passport", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - poaName: "Jean Dupont", - poaEmail: "jean@example.com", - poaPhone: "+33100000000", - }, - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - ownerPassportNumber: "P1234567", - ...POA_VERIFIED, - }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).resolves.toBeDefined(); - }); - - it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => { - // Fayda is an Ethiopian national ID, so only an Ethiopian company's - // representative can be held to it. A foreign company is offered the - // verification and uses it where its representative holds one, but a typed - // name stays sufficient — holding it to Fayda would leave a foreign - // company whose representative has no Fayda ID unable to trade at all. - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - ownerPassportNumber: "P1234567", - poaName: "Jean Dupont", - poaEmail: "jean@example.com", - poaPhone: "+33100000000", - }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).resolves.toBeDefined(); - }); - - it("still refuses a foreign company that named no PoA at all", async () => { - // The typed fallback is a different credential, not a waiver: a freight - // forwarder acts on other companies' behalf and needs a representative - // whatever its nationality. - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { ownerPassportNumber: "P1234567" }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => { - // The relaxation above is scoped to foreign companies only — an Ethiopian - // representative holds a Fayda ID, so typing a name must not substitute. - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Ethiopian, - attributes: { - ...OWNER_VERIFIED, - poaName: "Abebe Bekele", - poaEmail: "abebe@example.com", - poaPhone: "+251911000000", - }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { - // Verifying is optional for a foreign owner, but it does not waive the - // passport requirement — the two are independent credentials. - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - ...OWNER_VERIFIED, - poaName: "Jean Dupont", - poaEmail: "jean@example.com", - poaPhone: "+33100000000", - }, - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - // ------------------------------------------------------------------------- - // General manager - // ------------------------------------------------------------------------- - - it("reuses the owner's verified identity when the GM is declared the same person", async () => { - // The GM is very often the owner. Copying the proven identity is the whole - // point — asking one human to complete two verifications proves nothing - // extra, and typing the details instead would forge a verified badge. + it("never stands the signed-in account in for an absent Fayda claim", async () => { + // The person onboarding is not necessarily the person on the licence. + // Stamping their address onto the owner made a required field a guess. const { service, ctx } = makeService({ - attributes: { - ...OWNER_VERIFIED, - ownerEmail: "abebe@example.com", - ownerPhone: "+251911222333", - }, - }); - - const state = await service.setGmSameAsOwner("user-1"); - - expect(state.gm.verified).toBe(true); - expect(state.gmSameAsOwner).toBe(true); - expect(state.gm.name).toBe("Abebe Bikila"); - expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); - // The notifiers mail the flat column, so a linked GM has to land there too. - expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); - }); - - it("refuses to declare the GM is the owner while the owner is unverified", async () => { - // Without a verification there is no proven identity to copy — only typed - // text, which would arrive wearing a badge it had not earned. - const { service } = makeService({ attributes: {} }); - - await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf( - BadRequestException, - ); - }); - - it("lets the GM verify as the same human as the owner", async () => { - // One human in every role is the ordinary small-business shape, so - // verifying with the owner's own Fayda sub has to succeed. - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED }, + attributes: { poaDeclared: "no" }, verification: { purpose: "VERIFY", verified: true, - sub: "owner-sub", - fullName: "Abebe Bikila", - email: "abebe@example.com", - phoneNumber: "+251911222333", + sub: "new-sub", + fullName: "Haile Gebrselassie", }, }); - const state = await service.completeIdentityVerification("user-1", { - subject: "gm", + subject: "owner", code: "c", state: "s", }); - - expect(state.gm.verified).toBe(true); - expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); - expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila"); + expect(state.owner.email).toBeNull(); + expect(state.owner.phone).toBeNull(); + expect(ctx.attributes.ownerEmail).toBeUndefined(); }); - it("declares the PoA is the owner, copying the verified identity across", async () => { - const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED } }); - - const state = await service.setPoaSameAsOwner("user-1"); - - expect(state.poaSameAsOwner).toBe(true); - expect(state.poa.verified).toBe(true); - expect(ctx.attributes.poaFaydaSub).toBe(OWNER_VERIFIED.ownerFaydaSub); - expect(ctx.attributes.poaName).toBe(OWNER_VERIFIED.ownerName); + it("stages a verification for review on an approved company", async () => { + const { service, deps, ctx } = makeService({ + status: CompanyStatus.Active, + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + expect(deps.changeRequestRepo.create).toHaveBeenCalled(); + // The live row is untouched until a reviewer approves. + expect(ctx.attributes.ownerFaydaSub).toBe("owner-sub"); }); - it("refuses to declare the PoA is the owner while an Ethiopian owner is unverified", async () => { - // Its representative must be Fayda-verified, so a declaration here would - // record one that could never satisfy the gate. - const { service } = makeService({ attributes: {} }); + it("overwrites a hand-renamed verified person with the verified name", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + const profile = await service.updateProfile("user-1", { + ownerName: "Someone Else", + } as never); + expect(profile.ownerName).toBe("Abebe Bikila"); + }); - await expect(service.setPoaSameAsOwner("user-1")).rejects.toBeInstanceOf( + it("keeps a field the verification never supplied typeable", async () => { + // Fayda's email claim is optional and `REQUIRED_COMPANY_INFO` demands one, + // so locking against an absent value would make it unfillable forever. + const { service, ctx } = makeService({ + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + await service.updateProfile("user-1", { + ownerEmail: "typed@example.com", + } as never); + expect(ctx.attributes.ownerEmail).toBe("typed@example.com"); + }); + + it("writes the owner's email onto the company, verified or not", async () => { + // `companies.email` is what the notification resolver reads first. Gating + // this on a Fayda verification left every foreign company without one. + const { service, deps } = makeService({ attributes: { poaDeclared: "no" } }); + await service.updateProfile("user-1", { + ownerEmail: "owner@example.com", + ownerPhone: "+251911223344", + } as never); + const patch = deps.companiesRepo.update.mock.calls.at(-1)?.[1] as Record< + string, + unknown + >; + expect(patch.email).toBe("owner@example.com"); + expect(patch.phone).toBe("+251911223344"); + }); +}); + +describe("the declaration decides who verifies", () => { + it("points at the owner when the company says it has no representative", async () => { + const { service, company } = makeService({ + attributes: { poaDeclared: "no" }, + }); + expect(service.getCompanyIdentityState(company() as never).subject).toBe( + "owner", + ); + }); + + it("points at the representative when it says it has one", async () => { + const { service, company } = makeService({ + attributes: { poaDeclared: "yes" }, + }); + expect(service.getCompanyIdentityState(company() as never).subject).toBe( + "poa", + ); + }); + + it("is null until the company answers, and nothing is proven yet", async () => { + const { service, company } = makeService({ attributes: OWNER_VERIFIED }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.poaDeclared).toBeNull(); + expect(state.subject).toBeNull(); + expect(state.identityProven).toBe(false); + expect(state.complete).toBe(false); + }); + + it('forces "yes" for a freight forwarder whatever is stored', async () => { + // A forwarder signs on other companies' behalf, so a representative is + // non-negotiable — including one that answered "no" before taking the role. + const { service, company } = makeService({ + attributes: { poaDeclared: "no" }, + profileTypes: [ProfileType.freightForwarder], + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.poaDeclared).toBe("yes"); + expect(state.subject).toBe("poa"); + }); + + it('refuses to set "no" for a freight forwarder', async () => { + const { service } = makeService({ + profileTypes: [ProfileType.freightForwarder], + }); + await expect(service.setPoaDeclared("user-1", "no")).rejects.toBeInstanceOf( BadRequestException, ); }); - it("undoes the PoA \"same as owner\" declaration without touching a real verification", async () => { - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + it('tears the representative down when answered "no"', async () => { + const { service, ctx, deps } = makeService({ + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, + files: [paper()], }); - - // No declaration in place: the verified representative must survive. - await service.clearPoaSameAsOwner("user-1"); - expect(ctx.attributes.poaFaydaSub).toBe(POA_VERIFIED.poaFaydaSub); - - await service.setPoaSameAsOwner("user-1"); - const state = await service.clearPoaSameAsOwner("user-1"); - - expect(state.poaSameAsOwner).toBe(false); + const state = await service.setPoaDeclared("user-1", "no"); + expect(state.poaDeclared).toBe("no"); expect(state.poa.verified).toBe(false); + expect(ctx.attributes.poaName).toBeNull(); expect(ctx.attributes.poaFaydaSub).toBeNull(); - }); - - it("reports a pre-existing typed GM as unverified rather than blank", async () => { - // Companies onboarded before the GM was verifiable have typed details and - // no gm* attributes. Those details are still what the notifiers mail, so - // they must survive — flagged unverified so the portal offers the upgrade. - const { service, company } = makeService({ - attributes: { - ...OWNER_VERIFIED, - generalManagerName: "Legacy Manager", - generalManagerEmail: "legacy@example.com", - }, - }); - - const state = service.getCompanyIdentityState(company() as never); - - expect(state.gm.verified).toBe(false); - expect(state.gm.name).toBe("Legacy Manager"); - expect(state.gm.email).toBe("legacy@example.com"); - }); - - // The mirror image, and the reason the fallback above is gated on the GM - // being unverified: a manager Fayda proved but supplied no email for types - // one instead, and the portal decides whether to render that input by asking - // whether the identity holds one. Reading the typed column back as part of - // the verified identity would answer "yes" the moment it was saved — the - // input would vanish and a typo could never be corrected. - it("keeps a verified GM's typed email out of the verified identity", async () => { - const { service, company } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmFaydaSub: "gm-sub", - gmFaydaVerifiedAt: "2026-07-03T00:00:00.000Z", - gmName: "Derartu Tulu", - generalManagerName: "Derartu Tulu", - generalManagerEmail: "typed@example.com", - }, - }); - - const state = service.getCompanyIdentityState(company() as never); - - expect(state.gm.verified).toBe(true); - expect(state.gm.name).toBe("Derartu Tulu"); - expect(state.gm.email).toBeNull(); - }); - - it("never stands the account in for a GM Fayda gave no email", async () => { - // Deliberate: the account is the person onboarding, not necessarily the - // manager. The portal asks for the email instead. - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED }, - verification: { - purpose: "VERIFY", - verified: true, - sub: "gm-sub", - fullName: "Derartu Tulu", - phoneNumber: "+251911222333", - }, - }); - - const state = await service.completeIdentityVerification( - "user-1", - { subject: "gm", code: "c", state: "s" }, - { email: "account@example.com", phoneNumber: "+251911777777" }, - ); - - expect(ctx.attributes.gmEmail).toBeUndefined(); - expect(state.gm.verified).toBe(true); - expect(state.gm.email).toBeNull(); - }); - - it("accepts the email typed for a GM whose verification carried none", async () => { - const { service, ctx } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmFaydaSub: "gm-sub", - gmName: "Derartu Tulu", - generalManagerName: "Derartu Tulu", - }, - }); - - await service.updateProfile("user-1", { - generalManagerEmail: "gm@example.com", - } as never); - - expect(ctx.attributes.generalManagerEmail).toBe("gm@example.com"); - }); - - it("does not let an unproven GM block the company from trading", async () => { - // The GM names who to talk to, not what the company may do. Capturing it - // through Fayda changed how it is collected, not whether it gates. - const { service } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - await expect( - service.createCompanyProfileForUser("user-1", ProfileType.importer), - ).resolves.toBeDefined(); + // The paper evidenced a delegation that no longer exists. + expect(deps.filesService.remove).toHaveBeenCalledWith("file-1"); }); }); -/** - * Fayda's email and phone claims are optional, so a *verified* representative - * can still be missing the details `REQUIRED_POA_FIELDS` demands. The PoA step - * renders an input for whatever the verification did not supply — so onboarding - * has to report them outstanding, rather than letting a freight forwarder - * submit an incomplete representative and be refused its next PoA edit for it. - */ -describe("onboarding requirements name the PoA details Fayda did not supply", () => { - const POA_VERIFIED_NO_CONTACTS = { - poaFaydaSub: "poa-sub", - poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z", - poaName: "Tirunesh Dibaba", - }; +describe("foreign companies prove the same person by Fayda OR passport", () => { + const foreign = (attributes: Record) => + makeService({ nationality: CompanyNationality.Foreign, attributes }); - it("reports the missing email and phone for a freight forwarder", async () => { + it("accepts a passport number in place of Fayda", async () => { + const { service, company } = foreign({ + poaDeclared: "no", + ownerPassportNumber: "P1234567", + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.passportAccepted).toBe(true); + expect(state.identityProven).toBe(true); + }); + + it("accepts a Fayda verification instead — the passport is not additional", async () => { + const { service, company } = foreign({ poaDeclared: "no", ...OWNER_VERIFIED }); + expect( + service.getCompanyIdentityState(company() as never).identityProven, + ).toBe(true); + }); + + it("collects the passport of whichever person carries the identity", async () => { + const { service, company } = foreign({ + poaDeclared: "yes", + poaPassportNumber: "P7654321", + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.subject).toBe("poa"); + expect(state.identityProven).toBe(true); + }); + + it("is not satisfied by the OTHER person's passport", async () => { + // A PoA-represented company gates on the PoA; the owner's passport proves + // nobody relevant. + const { service, company } = foreign({ + poaDeclared: "yes", + ownerPassportNumber: "P1234567", + }); + expect( + service.getCompanyIdentityState(company() as never).identityProven, + ).toBe(false); + }); + + it("offers an Ethiopian company no passport alternative", async () => { + const { service, company } = makeService({ + attributes: { poaDeclared: "no", ownerPassportNumber: "P1234567" }, + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.passportAccepted).toBe(false); + expect(state.identityProven).toBe(false); + }); +}); + +describe("the owner is checked against the eTrade licence", () => { + it("matches ignoring case, punctuation and word order", async () => { + const { service, company } = makeService({ + attributes: { + poaDeclared: "no", + ownerName: "abebe bikila", + etradeManagerName: "BIKILA, Abebe", + }, + }); + expect( + service.getCompanyIdentityState(company() as never).ownerMatchesEtrade, + ).toBe(true); + }); + + it("flags a different person", async () => { + const { service, company } = makeService({ + attributes: { + poaDeclared: "no", + ownerName: "Haile Gebrselassie", + etradeManagerName: "Abebe Bikila", + }, + }); + expect( + service.getCompanyIdentityState(company() as never).ownerMatchesEtrade, + ).toBe(false); + }); + + it("reports null when there is nothing to compare", async () => { + // eTrade's ManagerNameEng is frequently blank; a null must not read as a + // mismatch, which would flag half the customer base. + const { service, company } = makeService({ + attributes: { poaDeclared: "no", ownerName: "Abebe Bikila" }, + }); + expect( + service.getCompanyIdentityState(company() as never).ownerMatchesEtrade, + ).toBeNull(); + }); + + it("never blocks on a mismatch — it is the reviewer's call", async () => { const { service } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED_NO_CONTACTS }, - profileTypes: [ProfileType.freightForwarder], + attributes: { + poaDeclared: "no", + ...OWNER_VERIFIED, + // The verified owner is a different human from the one on the licence. + ownerName: "Haile Gebrselassie", + etradeManagerName: "Abebe Bikila", + }, + }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.outstanding).not.toContain( + expect.stringContaining("eTrade"), + ); + }); +}); + +describe("the freight-forwarder gate", () => { + const addForwarder = (service: CompaniesService) => + service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]); + + it("blocks the role while the representative is unverified", async () => { + const { service } = makeService({ attributes: { poaDeclared: "yes" } }); + await expect(addForwarder(service)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("blocks the role on a company that answered no — it becomes yes", async () => { + // Taking the role forces the declaration, so a company that had answered + // "no" cannot inherit that answer past the gate. + const { service } = makeService({ + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + await expect(addForwarder(service)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("blocks the role without the DARS delegation paper", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, + files: [], + }); + await expect(addForwarder(service)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("grants the role once the representative is proven and the paper is on file", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, files: [paper()], }); + await expect(addForwarder(service)).resolves.toBeDefined(); + }); - const req = await service.getOnboardingRequirements("user-1"); + it("grants it to a foreign forwarder whose representative has a passport", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + attributes: { + poaDeclared: "yes", + ...POA_VERIFIED, + poaFaydaSub: undefined, + poaPassportNumber: "P7654321", + }, + files: [paper()], + }); + await expect(addForwarder(service)).resolves.toBeDefined(); + }); +}); - expect(req.poa.missingFields.map((f) => f.key)).toEqual([ +describe("onboarding requirements report exactly what is outstanding", () => { + it("asks the power-of-attorney question before anything else about identity", async () => { + const { service } = makeService(); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.declared).toBeNull(); + expect(reqs.outstanding).toContain( + "Tell us whether anyone holds power of attorney for your company", + ); + }); + + it("names the representative's missing details once one is declared", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "yes", poaName: "Tirunesh Dibaba" }, + }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.missingFields.map((f) => f.key)).toEqual([ "poaEmail", "poaPhone", ]); - expect(req.poa.complete).toBe(false); - expect(req.outstanding).toEqual( - expect.arrayContaining(["Add your poa email", "Add your poa phone"]), - ); }); - it("clears once they are typed", async () => { + it("asks nothing about a representative from a company that has none", async () => { const { service } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, - profileTypes: [ProfileType.freightForwarder], - files: [paper()], + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, }); - - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.poa.missingFields).toEqual([]); - expect(req.poa.complete).toBe(true); - expect(req.outstanding).not.toContain("Add your poa email"); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.missingFields).toEqual([]); + expect(reqs.poa.delegationLetterRequired).toBe(false); }); - // Fayda's email claim is optional and the GM's verification has no account to - // fall back on, so demanding one blocked a manager the government had already - // proved. `companyNotifyEmailExpr` resolves the address from the contact - // person or the registering account instead, so nothing needs this filled. - it("does not hold a company back for a general manager with no email", async () => { + it("demands the delegation paper from every declared representative", async () => { + // No waiver: the owner representing the company IS the "no" answer, so a + // "yes" always means a delegation that has to be evidenced. const { service } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmFaydaSub: "gm-sub", - generalManagerName: "Derartu Tulu", - generalManagerPhone: "+251911222333", - }, + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, + files: [], }); - - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.companyInfo.missingFields.map((f) => f.key)).not.toContain( - "generalManagerEmail", - ); - expect(req.outstanding).not.toContain("Add your general manager email"); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.delegationLetterRequired).toBe(true); + expect(reqs.isComplete).toBe(false); }); - it("still holds it back for the manager's name and phone", async () => { - const { service } = makeService({ attributes: { ...OWNER_VERIFIED } }); - - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.companyInfo.missingFields.map((f) => f.key)).toEqual( - expect.arrayContaining(["generalManagerName", "generalManagerPhone"]), + it("names the owner's missing details", async () => { + const { service } = makeService({ attributes: { poaDeclared: "no" } }); + const reqs = await service.getOnboardingRequirements("user-1"); + const missing = reqs.companyInfo.missingFields.map((f) => f.key); + expect(missing).toEqual( + expect.arrayContaining(["ownerName", "ownerEmail", "ownerPhone"]), ); }); - // An importer that never named a representative owes nothing here — the step - // is one it may walk straight past. - it("asks nothing of a company with no PoA at all", async () => { - const { service } = makeService({ attributes: { ...OWNER_VERIFIED } }); + it("reports the verification against the person it actually gates on", async () => { + const { service } = makeService({ attributes: { poaDeclared: "yes" } }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.outstanding).toContain( + "Verify your Power of Attorney with Fayda", + ); + }); - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.poa.missingFields).toEqual([]); - expect(req.poa.complete).toBe(true); + it("offers the passport alternative to a foreign company", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + attributes: { poaDeclared: "no" }, + }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.outstanding).toContain( + "Verify the person named on your eTrade licence with Fayda, or add their passport number", + ); }); }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index b85344e8e..96dc3ff53 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -174,21 +174,26 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { ).resolves.toBeDefined(); }); - it("waives the paper when the owner represents the company themselves", async () => { - // Nobody delegates to themselves, so a self-declared PoA owes no DARS - // paper — the representative's own details are still required. + it('owes nothing when the company answered "no representative"', async () => { + // "The owner represents the company themselves" is now expressed as the + // declaration being "no" — there is no delegation, so no paper is due. The + // representative's details are cleared with the answer, so there is nothing + // left to evidence either. const { service } = makeService({ - attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true }, + attributes: { ...VERIFIED_IDENTITIES, poaDeclared: "no" }, }); await expect( - service.updateProfile("user-1", POA as never), + service.updateProfile("user-1", {} as never), ).resolves.toBeDefined(); }); - it("grants the forwarder role to a self-represented company with no paper", async () => { + it("refuses the forwarder role without a paper, however it represents itself", async () => { + // The self-representation waiver is gone: a freight forwarder signs on + // other companies' behalf, so the delegation and the paper evidencing it + // are non-negotiable. const { service } = makeService({ - attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true }, + attributes: { ...VERIFIED_IDENTITIES, ...POA, poaDeclared: "yes" }, }); await expect( @@ -196,7 +201,7 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { "user-1", ProfileType.freightForwarder, ), - ).resolves.toBeDefined(); + ).rejects.toBeInstanceOf(BadRequestException); }); it("rejects a paper the reviewer sent back for correction", async () => { diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts index 5961be90f..ea81e4dbe 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -26,7 +26,13 @@ function makeService(existing: ExistingProfile[]) { })), softDelete: jest.fn(async () => undefined), }; - const companiesRepo = { update: jest.fn(async () => null) }; + // `findById` is only consulted when the co-operative flag is in play (adding + // a forwarder role, or setting the flag itself) — a plain company row is the + // right answer for every case here. + const companiesRepo = { + update: jest.fn(async () => null), + findById: jest.fn(async () => ({ id: "company-1", attributes: {} })), + }; const profilesRepo = { findByUserId: jest.fn(async () => ({ id: "external-1", diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 5dd55e704..cf2f2e84d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -24,6 +24,7 @@ import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { + COOPERATIVE_ONBOARDING_CODE, POA_DELEGATION_FILE_KEY, POA_DELEGATION_LABEL, POA_DELEGATION_PENDING_CODE, @@ -33,8 +34,13 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, CompleteIdentityVerificationDto, + ETRADE_MANAGER_NAME_KEY, + ETRADE_MANAGER_PHONE_KEY, IDENTITY_SUBJECTS, IdentitySubject, + POA_DECLARED_KEY, + PoaDeclaration, + readPoaDeclaration, } from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; @@ -55,6 +61,8 @@ import { CompanyNationality, CompanyStatus, CompanyType, + COOPERATIVE_KEY, + isCooperative, } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { @@ -93,15 +101,16 @@ const POA_ATTRIBUTES = [ "poaAddress", ] as const; /** - * Personnel an approved company maintains itself: its contact person, its - * general manager and its Power of Attorney. These name who to talk to, not - * what the company is allowed to do, so freezing the settings page until a - * reviewer gets to a new phone number costs more than it protects. They write - * straight to the live row even for an active company. + * The one person an approved company maintains itself: its contact person. + * That names who to talk to, not what the company is allowed to do, so freezing + * the settings page until a reviewer gets to a new phone number costs more than + * it protects. It writes straight to the live row even for an active company. * - * The PoA's *delegation letter* is deliberately not here — the paper is the - * thing that actually evidences the delegation, so it still goes through - * review (see `uploadPoaDelegationLetter`), as does the owner's own identity. + * The owner and the Power of Attorney are deliberately NOT here. Between them + * they carry the company's only identity verification — the owner is who the + * eTrade licence names, the PoA is who may act for the company — so an edit to + * either is exactly the kind of change a reviewer exists to see. Their + * delegation letter has always gone through review (`uploadPoaDelegationLetter`). */ const SELF_SERVICE_ATTRIBUTES: readonly string[] = [ "contactPersonName", @@ -109,12 +118,8 @@ const SELF_SERVICE_ATTRIBUTES: readonly string[] = [ "contactPersonEmail", "contactPersonPhone", "contactVerifiedPhone", - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - ...POA_ATTRIBUTES, ]; -/** Mandatory once the company operates as a freight forwarder. */ +/** Mandatory once the company names a Power of Attorney. */ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaName", label: "PoA name" }, { key: "poaEmail", label: "PoA email" }, @@ -122,42 +127,25 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ ]; /** - * `attributes` key prefix per verifiable person. The owner is NOT the general - * manager: the owner is who the verification proves the company through, the - * GM is personnel it names. They're very often the same human, which is what - * the portal's "same as owner" copy is for. + * `attributes` key prefix per person. The owner is whoever the eTrade licence + * names as the business's manager; the PoA is whoever the company delegates to. + * Exactly one of them carries the company's identity verification — which one + * is the company's own declaration (`poaDeclared`). */ const IDENTITY_PREFIX: Record = { owner: "owner", poa: "poa", - gm: "gm", }; -/** - * Typed GM columns a GM verification also writes. Three notifier services mail - * `company.generalManagerEmail` directly, so leaving these behind would mean a - * verified GM whose address the system never actually uses. - */ -const GM_TYPED_FIELDS = [ - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", -] as const; - /** * Identity fields a Fayda verification owns outright, per person. Once verified * these can no longer be typed — the government IdP is the source, so an edit * that disagrees with it is either a mistake or an attempt to launder the * guarantee away. - * - * The GM's entries are its typed columns: a verified GM is locked the same way - * the others are, while an unverified one (a foreign company's, or a record - * that predates this) stays freely editable. */ const IDENTITY_OWNED_FIELDS: Record = { owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], - gm: [...GM_TYPED_FIELDS], }; /** @@ -242,23 +230,29 @@ export class CompaniesService { label: "Contact person phone", get: (c) => c.attributes?.contactPersonPhone, }, + // The owner — whoever the eTrade licence names as the business's manager. + // All three are required whatever their source: the eTrade lookup fills + // the name and phone, a Fayda verification can fill all three, and the + // portal renders an input for whatever neither supplied. eTrade never + // returns an email and Fayda's email claim is optional, so in practice + // that field is usually typed — which is fine, because there IS an input + // for it. What there is no longer is a fallback to the signed-in account: + // the person onboarding is not necessarily the person on the licence, and + // silently stamping their address onto the owner made the record a guess. { - key: "generalManagerName", - label: "General manager name", - get: (c) => c.attributes?.generalManagerName, + key: "ownerName", + label: "Owner name", + get: (c) => c.attributes?.ownerName, }, - // The manager's EMAIL is deliberately absent. It was demanded because the - // notifiers were believed to mail it, and Fayda's email claim is optional - // — so a manager the government proved without one blocked the whole - // submission over an address nothing could produce. `companyNotifyEmailExpr` - // now resolves the address itself and falls through to the contact - // person's, then to the registering account's (which signup guarantees), - // so nothing depends on this being filled. It is still collected and still - // preferred when present; it just no longer holds the company hostage. { - key: "generalManagerPhone", - label: "General manager phone", - get: (c) => c.attributes?.generalManagerPhone, + key: "ownerEmail", + label: "Owner email", + get: (c) => c.attributes?.ownerEmail, + }, + { + key: "ownerPhone", + label: "Owner phone", + get: (c) => c.attributes?.ownerPhone, }, ]; @@ -271,6 +265,7 @@ export class CompaniesService { : "company_onboarding_documents_ethiopian"; } + async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { @@ -377,19 +372,41 @@ export class CompaniesService { companyType: CompanyType, roles: ProfileType[], nationality?: CompanyNationality, + cooperative?: boolean, ): Promise<{ profile: ExternalProfile; company: Company }> { // Already started — reuse the existing draft, just ensure roles exist and // keep the nationality up to date if it was (re)selected. const existing = await this.profilesRepo.findByUserId(identity.userId); if (existing) { const companyId = existing.company?.id ?? existing.companyId; + // Only load the row when the answer actually depends on it: to merge the + // flag into `attributes`, or to read a stored one the caller didn't send. + const needsCompany = + cooperative !== undefined || + roles.includes(ProfileType.freightForwarder); + const current = needsCompany + ? await this.companiesRepo.findById(companyId) + : null; + this.assertRolesAllowedForCooperative( + cooperative ?? isCooperative(current), + roles, + ); await this.syncCompanyProfiles(companyId, companyType, roles); - if (nationality) { - await this.companiesRepo.update(companyId, { nationality }); + const updates: Partial = {}; + if (nationality) updates.nationality = nationality; + if (cooperative !== undefined) { + updates.attributes = { + ...(current?.attributes ?? {}), + [COOPERATIVE_KEY]: cooperative, + }; + } + if (Object.keys(updates).length > 0) { + await this.companiesRepo.update(companyId, updates); } return this.getCompanyInfoByUserId(identity.userId); } + this.assertRolesAllowedForCooperative(cooperative === true, roles); const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); @@ -402,6 +419,7 @@ export class CompaniesService { country: "Ethiopia", nationality: nationality ?? CompanyNationality.Ethiopian, status: CompanyStatus.Pending, + ...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}), }); await this.profilesRepo.create({ @@ -419,6 +437,27 @@ export class CompaniesService { return this.getCompanyInfoByUserId(identity.userId); } + /** + * A co-operative union or farm cannot hold the freight-forwarder role. + * + * Forwarding is licensed work — the forwarder signs on other companies' + * behalf, which is why the role carries a mandatory Power of Attorney and a + * DARS delegation paper. A co-op is here precisely because it has no business + * licence, so the role is refused at the door rather than left to fail later + * at approval with a document it can never produce. + */ + private assertRolesAllowedForCooperative( + cooperative: boolean, + roles: ProfileType[], + ): void { + if (!cooperative) return; + if (roles.includes(ProfileType.freightForwarder)) { + throw new BadRequestException( + "A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.", + ); + } + } + /** * Reconcile the company's operational profiles with the roles the user has * selected: create the missing ones, drop the ones they deselected. @@ -768,6 +807,7 @@ export class CompaniesService { company: Company, dto: Partial & { faydaIdentity?: VerifiedIdentityAttributes; + etradeManager?: { name: string; phone: string }; }, ): Record { const companyUpdates: Record = {}; @@ -796,12 +836,10 @@ export class CompaniesService { attrUpdates.contactVerifiedPhone = normalizeE164( dto.contactVerifiedPhone, ); - if (dto.generalManagerName !== undefined) - attrUpdates.generalManagerName = dto.generalManagerName; - if (dto.generalManagerEmail !== undefined) - attrUpdates.generalManagerEmail = dto.generalManagerEmail; - if (dto.generalManagerPhone !== undefined) - attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone); + if (dto.ownerName !== undefined) attrUpdates.ownerName = dto.ownerName; + if (dto.ownerEmail !== undefined) attrUpdates.ownerEmail = dto.ownerEmail; + if (dto.ownerPhone !== undefined) + attrUpdates.ownerPhone = normalizeE164(dto.ownerPhone); if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; if (dto.poaPhone !== undefined) attrUpdates.poaPhone = normalizeE164(dto.poaPhone); @@ -829,11 +867,29 @@ 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. + // Plain typed fields — never Fayda-verified, so no lock ever applies. For a + // foreign company a passport number proves the person just as a Fayda + // verification does, so it is collected for whichever of the two carries + // the company's identity. if (dto.ownerPassportNumber !== undefined) attrUpdates.ownerPassportNumber = dto.ownerPassportNumber; + if (dto.poaPassportNumber !== undefined) + attrUpdates.poaPassportNumber = dto.poaPassportNumber; + + // eTrade's own manager, captured at lookup by `applyEtradeSourcedFields`. + // Never off the wire — the global pipe runs `forbidNonWhitelisted`, so this + // reaches us only from that method, the same guarantee `faydaIdentity` has. + // Stored apart from `ownerName`/`ownerPhone` so the two can be COMPARED: + // the company asserts an owner, eTrade states a manager, and the backoffice + // check is whether they are the same person (`ownerMatchesEtrade`). + if (dto.etradeManager) { + if (dto.etradeManager.name) + attrUpdates[ETRADE_MANAGER_NAME_KEY] = dto.etradeManager.name; + if (dto.etradeManager.phone) + attrUpdates[ETRADE_MANAGER_PHONE_KEY] = normalizeE164( + dto.etradeManager.phone, + ); + } // A verified identity overwrites the person's details. `faydaIdentity` // never comes off the wire — the global validation pipe runs with @@ -844,19 +900,17 @@ export class CompaniesService { Object.assign(attrUpdates, dto.faydaIdentity); } - // Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and - // phone claims are optional, so a verification can prove the person while - // supplying neither (see completeIdentityVerification's conditional - // spreads). The portal falls back to the account email / eTrade's - // registered phone in exactly that case and submits it on every save of - // the company step — locking against an absent value would 400 that - // forever, and re-verifying could never clear it because Fayda still has - // nothing to return. - if (attrUpdates.ownerFaydaSub) { - if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail; - if (attrUpdates.ownerPhone) - companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); - } + // The company's own contact columns follow the owner, verified or not. + // + // This used to be gated on `ownerFaydaSub`, which meant `companies.email` + // was only ever written for a Fayda-verified owner — so every foreign + // company (passport instead of Fayda) had none, and the notification + // resolver papered over it by falling through to the general manager's + // address. The GM is gone and the owner's email is now required outright, + // so this is simply where it lands. + if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail; + if (attrUpdates.ownerPhone) + companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); // Renaming a Fayda-verified person by hand would launder the guarantee // away, so the verification keeps these fields: a submission that disagrees @@ -874,12 +928,10 @@ export class CompaniesService { if (dto.faydaIdentity && field in dto.faydaIdentity) continue; const stored = company.attributes?.[field]; // A verification that supplied nothing for this field left no guarantee - // to protect, so it stays typeable. Matters most for the GM — - // `setGmSameAsOwner` copies `ownerEmail ?? null` onto - // `generalManagerEmail` while setting `gmFaydaSub`, and - // REQUIRED_COMPANY_INFO still demands that email, so holding a null - // here makes it required, hidden by the portal's "same as owner" card, - // and unwritable all at once. + // to protect, so it stays typeable. This is what makes the required + // owner email reachable: Fayda's email claim is optional, so a verified + // owner routinely has none stored — locking against that absence would + // make `REQUIRED_COMPANY_INFO` demand a field nobody could ever fill. if (stored === null || stored === undefined || stored === "") continue; attrUpdates[field] = stored; } @@ -965,9 +1017,7 @@ export class CompaniesService { 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), - }); + await this.assertPoaDelegationSatisfied(company, attributes); } if (company.status !== CompanyStatus.Active) { @@ -1615,12 +1665,13 @@ export class CompaniesService { // 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 }, - ); + // The row was loaded FOR UPDATE, so its relations are not populated — + // and `readPoaDeclaration` reads `companyProfiles` to force "yes" for a + // forwarder. This IS the forwarder profile being approved, so naming it + // is enough (and truthful) for both assertions below. + company.companyProfiles = company.companyProfiles ?? [existing]; + this.assertIdentityVerified(company); + await this.assertPoaDelegationSatisfied(company, company.attributes); } const [companyDocs, profileDocs] = await Promise.all([ @@ -1884,11 +1935,12 @@ export class CompaniesService { // 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 }); + this.assertRolesAllowedForCooperative(isCooperative(company), [type]); + const asForwarder = this.withProfileType(company, type); + this.assertIdentityVerified(asForwarder); await this.assertPoaDelegationSatisfied( - companyId, + asForwarder, await this.effectivePoaAttributes(company), - { requirePoa: true }, ); } @@ -1930,11 +1982,12 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created && type === ProfileType.freightForwarder) { - this.assertIdentityVerified(company, { requirePoa: true }); + this.assertRolesAllowedForCooperative(isCooperative(company), [type]); + const asForwarder = this.withProfileType(company, type); + this.assertIdentityVerified(asForwarder); await this.assertPoaDelegationSatisfied( - companyId, + asForwarder, await this.effectivePoaAttributes(company), - { requirePoa: true }, ); } if (!created) { @@ -1983,18 +2036,35 @@ export class CompaniesService { .filter((f) => !f.get(company)) .map((f) => ({ key: f.key, label: f.label })); - // 2. Nationality-based company documents + which are already uploaded. + // 2. Nationality-based company documents + which are already uploaded. A + // co-operative adds its own set on top: it provides everything its + // nationality demands, plus the papers standing in for the business licence + // it does not hold. + const cooperative = isCooperative(company); const documentSettingCode = this.documentSettingCodeFor( company.nationality, ); - const [setting, uploadedFiles] = await Promise.all([ + const [setting, coopSetting, uploadedFiles] = await Promise.all([ this.fileUploadSettingsService .getByCode(documentSettingCode) .catch(() => null), + cooperative + ? this.fileUploadSettingsService + .getByCode(COOPERATIVE_ONBOARDING_CODE) + .catch(() => null) + : Promise.resolve(null), this.filesService.findByResource(company.id, "companies"), ]); const uploadedCodes = new Set(uploadedFiles.map((f) => f.code)); - const documents = (setting?.fields ?? []) + // The co-op set is admin-managed and could name a fileKey the nationality + // set already carries; the nationality field wins so the same slot is never + // rendered (or required) twice. + const baseFields = setting?.fields ?? []; + const baseKeys = new Set(baseFields.map((f) => f.fileKey)); + const documents = [ + ...baseFields, + ...(coopSetting?.fields ?? []).filter((f) => !baseKeys.has(f.fileKey)), + ] .slice() .sort((a, b) => a.displayOrder - b.displayOrder) .map((f) => ({ @@ -2026,35 +2096,34 @@ export class CompaniesService { }; }), ); - const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + // A co-operative holds no business licence — that is the whole reason it + // skips the eTrade lookup — so the per-role licence is not owed. Its own + // document set (merged above) is what stands in for it. The profiles are + // still reported so the portal can show them; only the requirement lifts. + const missingLicenses = cooperative + ? [] + : licenseProfiles.filter((p) => !p.uploaded); - // 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 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(), - ); + // 4. Power of Attorney. Whether there is one at all is the company's own + // declaration — the question the wizard asks outright — and that answer is + // what decides whose identity gets verified, so an unanswered one is itself + // outstanding. A freight forwarder never gets to answer: it signs on other + // companies' behalf, so `readPoaDeclaration` forces "yes". + // + // Once there IS a representative, their details and the DARS delegation + // paper are both due. The paper is 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 poaDue = identity.poaDeclared === "yes"; const delegation = await this.getPoaDelegationState(company.id); - // "There is a representative" and "a paper is owed for them" used to be the - // same condition. They part company once the owner represents the company - // themselves: the representative's details are still required, but nobody - // delegates to themselves, so no DARS paper is due (`assertPoaDelegationSatisfied` - // returns on the same flag — the two must agree). - const poaDue = poaRequired || poaProvided; - const delegationDue = poaDue && !identity.poaSameAsOwner; + const delegationDue = poaDue; // The representative's details normally arrive from their Fayda // verification — but Fayda's email and phone claims are optional and // routinely come back empty, and the PoA step renders an input for whatever // the verification did not supply. So these are askable after all, and are - // reported outstanding once a PoA is required or provided; reporting - // nothing here let a freight forwarder finish onboarding with a - // representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then - // 400'd their next PoA edit for it. + // reported outstanding once a PoA is declared; reporting nothing here let a + // freight forwarder finish onboarding with a representative the API's own + // `REQUIRED_POA_FIELDS` calls incomplete, then 400'd their next PoA edit. const missingPoaFields = poaDue ? REQUIRED_POA_FIELDS.filter( (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), @@ -2065,10 +2134,15 @@ export class CompaniesService { // replace it before the application counts as complete. const flaggedDelegation = delegationDue && delegation.flagged; - // Mirrors `poaProven` in buildCompanyIdentityState — see the note there. - const poaProven = identity.faydaRequired - ? identity.poa.verified - : identity.poa.verified || Boolean(identity.poa.name?.trim()); + // 5. The single identity. Who proves it is `identity.subject`; how they may + // prove it is nationality-dependent (Fayda always, a passport number as an + // alternative for a foreign company). Both are derived once in + // buildCompanyIdentityState so this list can never disagree with the gate + // `assertIdentityVerified` actually enforces. + const identitySubjectLabel = + identity.subject === "poa" + ? "your Power of Attorney" + : "the person named on your eTrade licence"; const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), @@ -2086,58 +2160,38 @@ export class CompaniesService { `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.poaDeclared === null + ? ["Tell us whether anyone holds power of attorney for your company"] : []), - // Nationality-aware, exactly like `poaProven` in - // buildCompanyIdentityState and the check in `assertIdentityVerified`: - // Fayda is an Ethiopian national ID, so a foreign company's typed - // representative has to count. Demanding a verification here regardless - // made this list disagree with the rule actually enforced, and left a - // foreign freight forwarder unable to submit — asked for a Fayda - // verification its representative may have no way to obtain. - ...((poaRequired || poaProvided) && !poaProven + ...(identity.poaDeclared !== null && !identity.identityProven ? [ - identity.faydaRequired - ? "Verify your Power of Attorney's identity with Fayda" - : "Name your Power of Attorney, or verify them with Fayda", + identity.passportAccepted + ? `Verify ${identitySubjectLabel} with Fayda, or add their passport number` + : `Verify ${identitySubjectLabel} 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/paper whenever those are mandatory. + // fields, required documents, one license per operational profile, the PoA + // details/paper once declared, and the two identity items — answering the + // declaration, and proving the person it points at. const requiredDocCount = documents.filter((d) => d.isRequired).length; // The delegation paper plus the representative's own required details — // `completed` below subtracts every one of those it is still missing, so // leaving them out of the total would make the bar understate progress. const poaItemCount = (poaDue ? REQUIRED_POA_FIELDS.length : 0) + (delegationDue ? 1 : 0); - // One item per identity credential the company has to prove: the owner - // always (Fayda for Ethiopian, passport for foreign), plus the PoA once - // there is one — Fayda for an Ethiopian company, a named representative - // for a foreign one, same rule as `poaProven` above. Counting a foreign - // company's typed PoA as unproven here left the progress bar permanently - // short of 100% on an item it had already satisfied. - const ownerCredentialDue = - identity.faydaRequired || identity.passportRequired; - const ownerCredentialProven = identity.faydaRequired - ? identity.owner.verified - : Boolean(identity.owner.passportNumber); - const identityItemCount = (ownerCredentialDue ? 1 : 0) + (poaDue ? 1 : 0); const missingIdentityCount = - (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + - (poaDue && !poaProven ? 1 : 0); + (identity.poaDeclared === null ? 1 : 0) + + (identity.identityProven ? 0 : 1); const total = requiredInfo.length + requiredDocCount + - licenseProfiles.length + + (cooperative ? 0 : licenseProfiles.length) + poaItemCount + - identityItemCount; + // The declaration and the verification it selects. + 2; const completed = total - (missingInfo.length + @@ -2149,7 +2203,11 @@ export class CompaniesService { return new OnboardingRequirementsResponseDto({ documentSettingCode, + cooperativeDocumentSettingCode: cooperative + ? COOPERATIVE_ONBOARDING_CODE + : null, nationality: company.nationality ?? CompanyNationality.Ethiopian, + cooperative, companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo, @@ -2157,8 +2215,12 @@ export class CompaniesService { documents, licenseProfiles, poa: { - required: poaRequired, - provided: poaProvided, + // "Locked" rather than "required": a freight forwarder is not asked the + // question at all, everyone else answers it themselves. + locked: identity.poaDeclared === "yes" && (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ), + declared: identity.poaDeclared, delegationLetterRequired: delegationDue, delegationLetterUploaded: delegation.onFile, delegationLetterFlagged: delegation.flagged, @@ -2689,39 +2751,44 @@ export class CompaniesService { * judged against the files that would survive it (`ignoreFileIds`). */ private async assertPoaDelegationSatisfied( - companyId: string, + company: Company, attributes: Record | null | undefined, - opts: { requirePoa: boolean; ignoreFileIds?: string[] }, + opts: { ignoreFileIds?: string[] } = {}, ): Promise { + // The declaration is the whole gate. A company that says it has no + // representative owes nothing here; one that says it has owes the details + // AND the paper, with no exceptions — including a freight forwarder, for + // whom `readPoaDeclaration` forces "yes" regardless of what is stored. + const declared = readPoaDeclaration({ + attributes: attributes as Company["attributes"], + companyProfiles: company.companyProfiles, + }); + if (declared !== "yes") return; + + const isForwarder = (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ); 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 missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); + if (missing.length > 0) { + throw new BadRequestException( + (isForwarder + ? "A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. " + : "You told us someone holds power of attorney for this company. ") + + `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, + ); } - // Nobody delegates to themselves: an owner representing their own company - // has no delegation to evidence, so the DARS paper is not owed. The - // representative's own details are still required above — a forwarder's - // counterparties need someone to contact either way. - if (attributes?.poaSameAsOwner) return; - const { onFile, flagged } = await this.getPoaDelegationState( - companyId, + company.id, 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." : "."), + (isForwarder ? " — it is required for freight forwarders." : "."), ); } if (flagged) { @@ -2732,54 +2799,145 @@ export class CompaniesService { } } - /** 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) + // Identity verification (one per company) // - // 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. + // A company proves itself through exactly ONE person. Which one is its own + // declaration: the Power of Attorney when it names a representative, + // otherwise the owner — whoever the eTrade licence names as the business's + // manager. There is no general manager and no "same as owner" copy: an owner + // who represents their own company simply answers "no, nobody holds power of + // attorney", and verifies as the owner. + // + // A completed VeriFayda verification proves that person's name, phone, email + // and address (Fayda's userinfo carries no national ID number, so none is + // collected). Fayda is an Ethiopian national ID, so a foreign company may + // instead type a passport number for the same person — an alternative, not an + // addition. // --------------------------------------------------------------------------- /** - * 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. + * The company's identity state: both people, who currently carries the + * verification, whether it is proven, and whether the owner the company put + * forward matches the eTrade licence. `complete` answers the gate question + * directly so the portal, the onboarding requirements and the assertions + * above all read the same verdict — the derivation itself is shared with + * ProfileResponseDto and the backoffice company DTO. */ 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 + * Record whether anyone holds power of attorney for this company. + * + * This is the question that decides whose identity gets verified, so it is + * stored rather than inferred from "are any `poa*` keys set" — absence means + * "not asked yet", which is an outstanding onboarding item in its own right. + * + * Answering "no" tears the representative down: their details, their + * verification, their passport number and the DARS paper evidencing them all + * go. Leaving any of it behind would keep the company on the hook for a + * delegation it has just said does not exist. + * + * Refused for a freight forwarder — it signs on other companies' behalf, so a + * representative is non-negotiable. (`readPoaDeclaration` forces "yes" for + * them anyway; this is the honest error rather than a silently ignored write.) + */ + async setPoaDeclared( + userId: string, + declared: PoaDeclaration, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + + if ( + declared === "no" && + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + throw new BadRequestException( + "A freight forwarder acts on other companies' behalf, so it must have a Power of Attorney. Remove the freight forwarder role first.", + ); + } + + const attributes: Record = { + ...(company.attributes ?? {}), + [POA_DECLARED_KEY]: declared, + }; + + if (declared === "no") { + for (const key of [ + ...POA_ATTRIBUTES, + "poaFaydaSub", + "poaFaydaVerifiedAt", + "poaBirthdate", + "poaGender", + "poaPassportNumber", + ]) { + attributes[key] = null; + } + await this.deletePoaDelegationFiles(company.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); + } + + /** Drop every DARS paper on file — the delegation it evidenced is gone. */ + private async deletePoaDelegationFiles(companyId: string): Promise { + const records = await this.filesService.findByResource( + companyId, + 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(companyId, r.id); + } + } + } + + /** + * Complete a Fayda verification and bind the identity to the company. + * + * 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. + * + * The subject has to be the one the company's declaration calls for. A + * verification bound to the other person would sit on the record looking + * proven while the gate — which reads only the declared subject — stayed + * unsatisfied, and nothing in the portal would explain why. */ async completeIdentityVerification( userId: string, dto: CompleteIdentityVerificationDto, - /** - * The signed-in account, used as the owner's fallback contact details. - * Optional so the callers that only have a user id keep compiling — they - * simply get no fallback. - */ - account?: { email?: string; phoneNumber?: string }, ): Promise { const { company } = await this.getCompanyInfoByUserId(userId); + const state = buildCompanyIdentityState(company); const prefix = IDENTITY_PREFIX[dto.subject]; + if (state.subject === null) { + throw new BadRequestException( + "Tell us whether anyone holds power of attorney for this company first — the answer decides whose identity we verify.", + ); + } + if (state.subject !== dto.subject) { + throw new BadRequestException( + state.subject === "poa" + ? "This company is represented by a Power of Attorney, so it is their identity we need — not the owner's." + : "This company has no Power of Attorney, so it is the owner's identity we need.", + ); + } + const result = await this.verifaydaService.completeVerification({ code: dto.code, state: dto.state, @@ -2790,71 +2948,38 @@ export class CompaniesService { ); } - // An owner who is also the company's representative is a supported answer, - // not a conflict — the same way the GM is very often the owner. Small - // companies routinely have one human in all three roles, and the portal's - // "same as owner" cards exist precisely so they can say so. No identity - // here is refused for colliding with another. - - const now = new Date().toISOString(); - - // Fayda's email and phone claims are optional and routinely come back empty. - // For the owner that leaves the company with no contact details at all: the - // step renders no input for them (they are the verification's output), and - // "same as owner" then copies those blanks onto `generalManagerEmail` / - // `generalManagerPhone`, which `REQUIRED_COMPANY_INFO` demands at submit — - // an unfixable dead end. The account doing the onboarding is the one contact - // we always have, and it is already OTP-proven, so it stands in. + // Only what Fayda actually returned is written. Its email and phone claims + // are optional and routinely come back empty — the portal renders an input + // for whatever is missing and the customer fills it in. // - // Owner only: the PoA and the GM are other people, and the registering - // account's address is not theirs to wear. - const isOwner = dto.subject === "owner"; - const email = result.email || (isOwner ? account?.email : undefined); - const phone = - result.phoneNumber || (isOwner ? account?.phoneNumber : undefined); - + // There is deliberately NO fallback to the signed-in account. The person + // onboarding is not necessarily the person on the licence, so stamping + // their address onto the owner turned a required field into a guess that + // looked verified. const identity: VerifiedIdentityAttributes = { [`${prefix}FaydaSub`]: result.sub, - [`${prefix}FaydaVerifiedAt`]: now, + [`${prefix}FaydaVerifiedAt`]: new Date().toISOString(), [`${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 } : {}), - ...(email ? { [`${prefix}Email`]: email } : {}), + ...(result.email ? { [`${prefix}Email`]: result.email } : {}), // Fayda returns whatever the national registry holds, which is routinely a // local number ("0911223344"). Every typed phone in this service is stored // E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here // becomes a value the portal reads back and cannot resubmit. - ...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}), + ...(result.phoneNumber + ? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) } + : {}), ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; - // A GM verification also lands on the typed columns the rest of the system - // already reads (the booking, train-scheduling and contract notifiers all - // mail `generalManagerEmail`), and clears any earlier "same as owner" - // declaration — verifying in their own right is the GM answering for - // themselves. - // Verifying the representative in their own right answers the question the - // "same as owner" declaration answered, so the declaration goes. - if (dto.subject === "poa") { - identity.poaSameAsOwner = false; - } - - if (dto.subject === "gm") { - identity.gmSameAsOwner = false; - if (result.fullName) identity.generalManagerName = result.fullName; - if (result.email) identity.generalManagerEmail = result.email; - if (result.phoneNumber) - identity.generalManagerPhone = normalizeE164(result.phoneNumber); - } - - // An approved company's *owner* is its identity proof, so re-verifying one - // is staged for backoffice review rather than quietly rewriting a live - // record. The PoA and GM are personnel — the company names its own - // representative and manager, and the delegation letter backing the PoA is - // what the reviewer sees — so those land live, matching their typed - // counterparts in `SELF_SERVICE_ATTRIBUTES`. - if (company.status === CompanyStatus.Active && dto.subject === "owner") { + // An approved company's identity is what its approval rested on, so + // re-verifying is staged for backoffice review rather than quietly + // rewriting a live record. Both subjects go through review now: whichever + // one the declaration points at IS the company's proof, and the owner is + // additionally the person the reviewer checks against the eTrade licence. + if (company.status === CompanyStatus.Active) { await this.stageIdentityChange(company, userId, identity); return this.getCompanyIdentityState(company); } @@ -2868,261 +2993,6 @@ export class CompaniesService { return this.getCompanyIdentityState(updated); } - /** - * Declare that the General Manager is the company's owner. - * - * The GM is very often the owner, and making that human verify twice buys - * nothing — the owner's verification already proves them. So this copies the - * owner's verified identity across rather than starting a second flow, and - * records `gmSameAsOwner` so the portal can show it as a declaration rather - * than as a verification the GM passed in their own right. - * - * Refused until the owner is actually verified: without that there is no - * proven identity to copy, only typed text that would arrive wearing a - * verified badge. - */ - async setGmSameAsOwner( - userId: string, - /** Same fallback as {@link completeIdentityVerification}, for owners - * verified before that fallback existed — their stored contact details are - * blank, and copying blanks here would block the submit. */ - account?: { email?: string; phoneNumber?: string }, - ): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = company.attributes ?? {}; - const ownerSub = attrs.ownerFaydaSub as string | undefined; - if (!ownerSub) { - throw new BadRequestException( - "Verify the company owner with Fayda first — there is no proven identity to reuse yet.", - ); - } - - const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email || null; - const ownerPhone = (attrs.ownerPhone as string | undefined) || account?.phoneNumber || null; - - const copied: Record = { - gmSameAsOwner: true, - gmFaydaSub: ownerSub, - gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), - gmName: attrs.ownerName ?? null, - gmEmail: ownerEmail, - gmPhone: ownerPhone ? normalizeE164(ownerPhone) : null, - gmAddress: attrs.ownerAddress ?? null, - gmBirthdate: attrs.ownerBirthdate ?? null, - gmGender: attrs.ownerGender ?? null, - // Kept in step for the notifiers, same as a GM verification does. - generalManagerName: attrs.ownerName ?? null, - generalManagerEmail: ownerEmail, - generalManagerPhone: ownerPhone ? normalizeE164(ownerPhone) : null, - }; - - const updated = await this.companiesRepo.update(company.id, { - attributes: { ...attrs, ...copied }, - }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - - /** - * Undo the "same as owner" declaration, clearing the copied identity so the - * GM can be verified in their own right (or typed, where Fayda is optional). - */ - async clearGmIdentity(userId: string): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = { ...(company.attributes ?? {}) }; - for (const key of [ - "gmSameAsOwner", - "gmFaydaSub", - "gmFaydaVerifiedAt", - "gmName", - "gmEmail", - "gmPhone", - "gmAddress", - "gmBirthdate", - "gmGender", - ...GM_TYPED_FIELDS, - ]) { - attrs[key] = null; - } - - const updated = await this.companiesRepo.update(company.id, { - attributes: attrs, - }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - - /** - * Declare that the company's Power of Attorney is its owner. - * - * An owner representing their own company is the ordinary case for a small - * business, so this is a supported answer rather than the conflict it used to - * be refused as. Two shapes, matching {@link setGmSameAsOwner}: - * - * - A Fayda-verified owner is a proven identity, so it is copied outright — - * the representative inherits the verification instead of the same human - * being sent through Fayda a second time. - * - A foreign company's owner is backed by a typed passport, so there is - * nothing proven to copy. The declaration is still recorded (it is what - * waives the DARS paper) and whatever owner details exist come across; the - * portal types the rest, which `poaProven` accepts for a foreign company. - * - * Refused for an Ethiopian company whose owner is not verified yet: Fayda is - * mandatory for its representative, so a declaration there would record a - * representative that could never satisfy the gate. - */ - async setPoaSameAsOwner( - userId: string, - /** Same fallback as {@link completeIdentityVerification} — an owner whose - * Fayda claims carried no email/phone has none stored, and copying blanks - * onto a freight forwarder's PoA would block the submit on - * `REQUIRED_POA_FIELDS`. */ - account?: { email?: string; phoneNumber?: string }, - ): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = company.attributes ?? {}; - const state = buildCompanyIdentityState(company); - const ownerSub = attrs.ownerFaydaSub as string | undefined; - - if (state.faydaRequired && !ownerSub) { - throw new BadRequestException( - "Verify the company owner with Fayda first — there is no proven identity to reuse yet.", - ); - } - - const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email; - const ownerPhone = - (attrs.ownerPhone as string | undefined) || account?.phoneNumber; - - // Only non-blank values are copied: a blank here would overwrite something - // the portal typed for a foreign company, whose owner has no verified - // claims to draw on. - const copied: Record = { poaSameAsOwner: true }; - const copy = (key: string, value: unknown) => { - if (value !== null && value !== undefined && value !== "") - copied[key] = value; - }; - copy("poaName", attrs.ownerName); - copy("poaEmail", ownerEmail); - copy("poaPhone", ownerPhone ? normalizeE164(ownerPhone) : undefined); - copy("poaAddress", attrs.ownerAddress); - - if (ownerSub) { - copied.poaFaydaSub = ownerSub; - copied.poaFaydaVerifiedAt = - attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(); - copy("poaBirthdate", attrs.ownerBirthdate); - copy("poaGender", attrs.ownerGender); - } - - const updated = await this.companiesRepo.update(company.id, { - attributes: { ...attrs, ...copied }, - }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - - /** - * Undo the PoA "same as owner" declaration, clearing the identity it copied - * so a different representative can be verified (or typed, for a foreign - * company). - * - * Separate from {@link removePoaIdentity}, which drops the representative and - * their paper and is refused to a freight forwarder. Undoing a declaration is - * how a forwarder changes its mind about who represents it, so it must stay - * open to them — the submit gate still refuses a forwarder that never names a - * replacement. The delegation paper is left alone for the same reason: the - * company still owes one, now for whoever comes next. - * - * A no-op when no declaration is in place: a stray call must not wipe a - * representative who verified in their own right. - */ - async clearPoaSameAsOwner(userId: string): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = { ...(company.attributes ?? {}) }; - if (!attrs.poaSameAsOwner) return this.getCompanyIdentityState(company); - - attrs.poaSameAsOwner = false; - for (const key of [ - ...POA_ATTRIBUTES, - "poaFaydaSub", - "poaFaydaVerifiedAt", - "poaBirthdate", - "poaGender", - ]) { - attrs[key] = null; - } - - const updated = await this.companiesRepo.update(company.id, { - attributes: attrs, - }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - - /** - * Drop the Power of Attorney entirely — the verified identity, the details it - * wrote and the delegation paper together. - * - * 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 = { poaSameAsOwner: false }; - 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, @@ -3171,62 +3041,56 @@ export class CompaniesService { } /** - * 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. + * The company as it will be once `type` is one of its roles. + * + * Taking on the freight-forwarder role is checked BEFORE the profile row + * exists, and both assertions below read `companyProfiles` — a forwarder is + * what forces the PoA declaration to "yes". Judging the company as it stands + * would let one that answered "no" pick up the role and skip the very + * requirement the role exists to impose. Read-only; never persisted. */ - private assertIdentityVerified( - company: Company, - opts: { requirePoa: boolean }, - ): void { + private withProfileType(company: Company, type: ProfileType): Company { + const profiles = company.companyProfiles ?? []; + if (profiles.some((p) => p.type === type)) return company; + return { + ...company, + companyProfiles: [...profiles, { type } as CompanyProfile], + } as Company; + } + + /** + * The gate: the company's ONE identity must be proven. + * + * Which person that is comes from the company's own declaration — the + * representative when it names one, otherwise the owner (whoever the eTrade + * licence names as manager). How they prove it depends on nationality: Fayda + * for an Ethiopian company, Fayda *or* a typed passport number for a foreign + * one, whose people may hold no Fayda ID at all. + * + * 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): void { const state = buildCompanyIdentityState(company); - // Only the owner's credential is nationality-specific: Fayda for an - // Ethiopian company, a typed passport number for a foreign one. - if (state.passportRequired) { - if (!state.owner.passportNumber) { - throw new BadRequestException( - "Add the company owner's passport number before continuing.", - ); - } - } else if (!state.owner.verified) { + if (state.poaDeclared === null) { throw new BadRequestException( - "Verify the company owner's identity with Fayda before continuing.", + "Tell us whether anyone holds power of attorney for this company — the answer decides whose identity we verify.", ); } - const poaNamed = POA_ATTRIBUTES.some((k) => - (company.attributes?.[k] as string | undefined)?.trim(), + if (state.identityProven) return; + + const who = + state.subject === "poa" + ? "your Power of Attorney" + : "the person named on your eTrade licence"; + throw new BadRequestException( + state.passportAccepted + ? `Verify ${who} with Fayda, or add their passport number.` + : `Verify ${who} with Fayda before continuing.`, ); - if (!opts.requirePoa && !poaNamed) return; - - // Fayda is an Ethiopian national ID, so only an Ethiopian company's - // representative can be held to it. A foreign company is offered the - // verification and nominates a Fayda-holding representative where it can, - // but a typed name has to remain sufficient — otherwise a foreign company - // whose representative holds no Fayda ID could never trade at all. Mirrors - // `poaProven` in buildCompanyIdentityState; the two must agree. - if (state.passportRequired) { - if (!state.poa.verified && !state.poa.name?.trim()) { - throw new BadRequestException( - opts.requirePoa - ? "Name your Power of Attorney — a freight forwarder cannot operate without one." - : "Complete the Power of Attorney you named, or remove the representative.", - ); - } - return; - } - - if (!state.poa.verified) { - throw new BadRequestException( - opts.requirePoa - ? "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.", - ); - } } /** @@ -3350,12 +3214,9 @@ export class CompaniesService { // the representative it evidences is gone too (which, for an Active // company, means the clearing edit is already staged). await this.assertPoaDelegationSatisfied( - company.id, + company, await this.effectivePoaAttributes(company), - { - requirePoa: await this.isFreightForwarder(company.id), - ignoreFileIds: [fileId], - }, + { ignoreFileIds: [fileId] }, ); if (record.code === POA_DELEGATION_PENDING_CODE) { @@ -3583,8 +3444,15 @@ export class CompaniesService { */ private async applyEtradeSourcedFields( company: Company, - dto: UpdateProfileDto, + dto: UpdateProfileDto & { etradeManager?: { name: string; phone: string } }, ): Promise { + // A co-operative union or farm has a TIN but no business licence, so eTrade holds no + // record to check these against — the customer types the company name and + // the registered address themselves, and what they send IS the data. The + // check is skipped rather than failed: running the lookup would 400 every + // save with "no registration found for this TIN". + if (isCooperative(company)) return; + const touched = ETRADE_SOURCED_FIELDS.some( (key) => key !== "tin" && dto[key] !== undefined, ); @@ -3626,5 +3494,18 @@ export class CompaniesService { // (the onboarding/settings card lets the customer type it directly then). if (value) (dto as Record)[key] = value; } + + // Capture the licence's own manager alongside the registration it belongs + // to. NOT written onto `ownerName`/`ownerPhone`: those are what the company + // asserts (and what a Fayda verification owns), and overwriting them here + // would destroy the very difference the backoffice is asked to check. The + // portal prefills the owner from these, so they agree unless someone made + // them disagree — which is exactly the case worth surfacing. + if (registration.managerName || registration.managerPhone) { + dto.etradeManager = { + name: registration.managerName, + phone: registration.managerPhone, + }; + } } } diff --git a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts index 83fa539e3..71b60a268 100644 --- a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts +++ b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts @@ -23,9 +23,18 @@ export const COMPANY_FIELD_LABELS: Record = { contactPersonPhone: "Contact person phone", contactPersonEmail: "Contact person email", contactPersonPosition: "Contact person position", - generalManagerName: "General manager name", - generalManagerPhone: "General manager phone", - generalManagerEmail: "General manager email", + ownerName: "Owner name", + ownerPhone: "Owner phone", + ownerEmail: "Owner email", + ownerPassportNumber: "Owner passport number", + poaPassportNumber: "PoA passport number", + poaDeclared: "Has a Power of Attorney", + // Nothing writes these any more (the general manager was removed), but + // revisions and change requests filed before that still carry them — without + // the labels those rows render raw attribute keys to a reviewer. + generalManagerName: "General manager name (retired)", + generalManagerPhone: "General manager phone (retired)", + generalManagerEmail: "General manager email (retired)", poaName: "PoA name", poaPhone: "PoA phone", poaEmail: "PoA email", diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts index df39949d4..6fe1ecc74 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -5,21 +5,61 @@ import { Company, CompanyNationality } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; /** - * The three people a company is verified through — its owner, its Power of - * Attorney and its General Manager. The owner is the person the company's - * existence is proven by; the other two are personnel it names. + * The two people a company can be described through. * - * The GM is very often the owner, which is what the portal's "same as owner" - * copy is for: that path reuses the owner's verified identity outright rather - * than asking the same human to verify twice. + * The **owner** is whoever the eTrade TIN record names as the business's + * manager. Not necessarily the legal owner — eTrade's `ManagerNameEng` is + * simply the person on the licence — but that is the point: whoever the company + * puts forward here has to match the eTrade record, and the backoffice check is + * exactly that comparison (see `ownerMatchesEtrade`). + * + * The **Power of Attorney** is who the company delegates to act for it, when it + * delegates at all. + * + * Exactly ONE of them is identity-verified, and which one is decided by the + * company's own answer (see {@link PoaDeclaration}): the representative if + * there is one, otherwise the owner. There is no general manager — the concept + * was removed; it named who to talk to and gated nothing. */ -export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const; +export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; +/** + * The company's answer to "does anyone hold power of attorney for you?". + * + * Explicit rather than derived from "are any `poa*` keys set", because "no" is + * an answer that moves the verification onto the owner, while *absent* is a + * question the customer has not reached yet. Stored on `company.attributes` + * under {@link POA_DECLARED_KEY}. + * + * A freight forwarder never gets to answer: it signs on other companies' + * behalf, so a Power of Attorney (and the DARS paper evidencing it) is + * non-negotiable. {@link readPoaDeclaration} forces "yes" for them, which is + * why the declaration is read through that helper rather than off the blob. + */ +export const POA_DECLARATIONS = ["yes", "no"] as const; +export type PoaDeclaration = (typeof POA_DECLARATIONS)[number]; + +/** `company.attributes` key holding the {@link PoaDeclaration}. */ +export const POA_DECLARED_KEY = "poaDeclared"; + +/** + * `company.attributes` keys holding the eTrade record's own manager, captured + * at lookup time. + * + * Kept apart from `ownerName`/`ownerPhone` — which are what the *company* + * asserts, and what a Fayda verification overwrites — precisely so the two can + * be compared. Storing only one value would leave the reviewer comparing the + * owner field against itself. + */ +export const ETRADE_MANAGER_NAME_KEY = "etradeManagerName"; +export const ETRADE_MANAGER_PHONE_KEY = "etradeManagerPhone"; + export class CompleteIdentityVerificationDto { @ApiProperty({ enum: IDENTITY_SUBJECTS, - description: "Which of the company's people this verification is for.", + description: + "Which of the company's people this verification is for. Must match the company's PoA declaration — the representative when one is named, the owner when not.", }) @IsIn(IDENTITY_SUBJECTS) subject!: IdentitySubject; @@ -35,9 +75,11 @@ export class CompleteIdentityVerificationDto { state!: string; } -/** One person's verification state, as reported back to the portal. */ +/** One person's identity state, as reported back to the portal. */ export class IdentityVerificationStateDto { - @ApiProperty() verified!: boolean; + @ApiProperty({ description: "True once a Fayda verification is bound." }) + verified!: boolean; + @ApiProperty({ nullable: true }) name!: string | null; @ApiProperty({ nullable: true }) phone!: string | null; @ApiProperty({ nullable: true }) email!: string | null; @@ -45,13 +87,11 @@ export class IdentityVerificationStateDto { @ApiProperty({ nullable: true }) verifiedAt!: string | null; @ApiProperty({ nullable: true }) birthdate!: string | null; @ApiProperty({ nullable: true }) gender!: 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.", + "Typed passport number. Fayda is an Ethiopian national ID, so a foreign company proves the identity with either — this is the alternative, not an addition.", }) passportNumber!: string | null; } @@ -59,44 +99,55 @@ export class OwnerIdentityStateDto extends IdentityVerificationStateDto { export class CompanyIdentityStateDto { @ApiProperty({ description: - "True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.", + "True for a foreign company: a typed passport number proves the identity just as a Fayda verification does. An Ethiopian company must use Fayda.", }) - faydaRequired!: boolean; + passportAccepted!: boolean; @ApiProperty({ + enum: POA_DECLARATIONS, + nullable: true, 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.", + 'Whether the company named a Power of Attorney. Null until the customer answers — which is itself an outstanding onboarding item, since the answer decides who verifies.', }) - passportRequired!: boolean; + poaDeclared!: PoaDeclaration | null; - @ApiProperty({ type: OwnerIdentityStateDto }) - owner!: OwnerIdentityStateDto; + @ApiProperty({ + enum: IDENTITY_SUBJECTS, + nullable: true, + description: + "Who the company's single identity verification belongs to: the PoA when one is named, the owner when not. Null while the declaration is unanswered.", + }) + subject!: IdentitySubject | null; + + @ApiProperty({ type: IdentityVerificationStateDto }) + owner!: IdentityVerificationStateDto; @ApiProperty({ type: IdentityVerificationStateDto }) poa!: IdentityVerificationStateDto; @ApiProperty({ description: - "True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.", + "True once the subject is proven — Fayda-verified, or carrying a passport number where that is accepted.", }) - poaSameAsOwner!: boolean; + identityProven!: boolean; @ApiProperty({ - type: IdentityVerificationStateDto, + nullable: true, description: - "General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.", + "The manager named on the eTrade licence, captured at lookup. Null when eTrade returned none (ManagerNameEng is frequently blank).", }) - gm!: IdentityVerificationStateDto; + etradeManagerName!: string | null; + + @ApiProperty({ + nullable: true, + description: + "Does the owner the company put forward match the person on the eTrade licence? This is the backoffice's check. Null when there is nothing to compare — no eTrade manager on file, or no owner name yet. Advisory, not a gate: eTrade's Latin transliteration and Fayda's rarely agree character-for-character, so a reviewer decides.", + }) + ownerMatchesEtrade!: boolean | null; @ApiProperty({ description: - "True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.", - }) - gmSameAsOwner!: boolean; - - @ApiProperty({ - description: - "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", + "False while the declaration is unanswered or the subject is unproven. Field-level completeness (owner/PoA details, documents) is reported separately by the onboarding requirements.", }) complete!: boolean; } @@ -105,26 +156,9 @@ export class CompanyIdentityStateDto { const PREFIX: Record = { owner: "owner", poa: "poa", - gm: "gm", }; -/** - * Typed GM fields, kept in step with the Fayda-written ones. - * - * The GM predates this verification: its details are plain company columns - * that three notifier services mail (booking-lifecycle, train-scheduling and - * contract notifiers all read `company.generalManagerEmail`). A verification - * therefore writes BOTH — the `gm*` attributes carry the proof, these carry - * the value everything else already reads — and an unverified company keeps - * showing whatever was typed before this existed. - */ -const GM_TYPED_KEYS = { - name: "generalManagerName", - email: "generalManagerEmail", - phone: "generalManagerPhone", -} as const; - -/** company.attributes keys that together mean "a PoA was entered". */ +/** `company.attributes` keys that together mean "a representative was entered". */ const POA_KEYS = [ "poaName", "poaPhone", @@ -139,7 +173,7 @@ function stateFor( ): IdentityVerificationStateDto { const p = PREFIX[subject]; const read = (key: string) => (attrs[key] as string | undefined) ?? null; - const state: IdentityVerificationStateDto = { + return { verified: Boolean(read(`${p}FaydaSub`)), name: read(`${p}Name`), phone: read(`${p}Phone`), @@ -148,88 +182,111 @@ function stateFor( verifiedAt: read(`${p}FaydaVerifiedAt`), birthdate: read(`${p}Birthdate`), gender: read(`${p}Gender`), - }; - if (subject !== "gm" || state.verified) return state; - - // Companies onboarded before the GM was verifiable have typed details and no - // `gm*` attributes at all. Report those rather than a blank card — they are - // still what the notifiers mail — leaving `verified` false so the portal - // offers the upgrade instead of pretending the identity is proven. - // - // Only for such an unverified GM, which is the whole population this exists - // for. Merging the typed columns into a *verified* manager's state would read - // back the email the portal asked them to type when Fayda supplied none, and - // the input offering it — keyed on that value being absent — would vanish the - // moment it was saved, leaving a typo uncorrectable. - return { - ...state, - name: state.name ?? read(GM_TYPED_KEYS.name), - email: state.email ?? read(GM_TYPED_KEYS.email), - phone: state.phone ?? read(GM_TYPED_KEYS.phone), + passportNumber: read(`${p}PassportNumber`), }; } /** - * Derive both people's verification state from the company row. + * The company's PoA declaration, or null when it hasn't answered yet. * - * 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. + * A freight forwarder is never asked: it acts on other companies' behalf, so a + * representative and the DARS paper behind them are mandatory. Forcing it here + * — rather than only disabling the radio in the portal — is what stops a + * forwarder role added *after* onboarding from inheriting an old "no". + */ +export function readPoaDeclaration( + company: Pick, +): PoaDeclaration | null { + if ( + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + return "yes"; + } + const value = company.attributes?.[POA_DECLARED_KEY]; + if (value === "yes" || value === "no") return value; + + // No explicit answer, but the company holds a representative's details — + // so it has one, and owes everything a representative brings with them. + // + // Covers rows that predate the question (the migration derives the same way) + // and any write that reaches the attributes without going through + // `setPoaDeclared`. Without this, PoA details could be saved with the + // delegation paper silently unowed. Safe against a genuine "no": answering + // it clears these keys, so they cannot outlive the answer. + return POA_KEYS.some((k) => (company.attributes?.[k] as string | undefined)?.trim()) + ? "yes" + : null; +} + +/** + * Do two people's names refer to the same person, as far as a string can tell? + * + * Deliberately loose: eTrade returns uppercase Latin transliterations of + * Amharic names and Fayda returns its own, so exact equality would flag almost + * every company. Case, punctuation, extra whitespace and word ORDER are all + * ignored — "ABEBE KEBEDE TESFA" and "Tesfa, Abebe Kebede" match. Anything + * beyond that is the reviewer's call, which is why the verdict is advisory. + */ +export function ownerNameMatchesEtrade( + ownerName: string | null | undefined, + etradeName: string | null | undefined, +): boolean | null { + const words = (v: string | null | undefined) => + (v ?? "") + .toLowerCase() + .replace(/[^a-z0-9ሀ-፿\s]/g, " ") + .split(/\s+/) + .filter(Boolean) + .sort(); + const a = words(ownerName); + const b = words(etradeName); + if (a.length === 0 || b.length === 0) return null; + return a.length === b.length && a.every((w, i) => w === b[i]); +} + +/** + * Derive the company's identity state from its row. + * + * Pure and shared: `CompaniesService` gates on it, `ProfileResponseDto` and the + * backoffice's company DTO render from it, so the settings page, the onboarding + * wizard and the reviewer can never disagree with the rule the API 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; + // Fayda is an Ethiopian national ID. A foreign company's people may hold + // none, so a typed passport number stands in — either one proves the person, + // and holding both is fine. + const passportAccepted = company.nationality === CompanyNationality.Foreign; - const owner: OwnerIdentityStateDto = { - ...stateFor(attrs, "owner"), - passportNumber: read("ownerPassportNumber"), - }; + const owner = stateFor(attrs, "owner"); 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 poaDeclared = readPoaDeclaration(company); + const subject: IdentitySubject | null = + poaDeclared === "yes" ? "poa" : poaDeclared === "no" ? "owner" : null; - const gm = stateFor(attrs, "gm"); - const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); - const poaSameAsOwner = Boolean(attrs.poaSameAsOwner); + const proven = (s: IdentityVerificationStateDto) => + s.verified || (passportAccepted && Boolean(s.passportNumber?.trim())); - const ownerProven = faydaRequired - ? owner.verified - : !passportRequired || Boolean(owner.passportNumber); + const identityProven = + subject === null ? false : proven(subject === "poa" ? poa : owner); - // Fayda is an Ethiopian national ID, so only an Ethiopian company's - // personnel can be held to it. A foreign company may nominate a - // representative who holds one — and is offered the verification — but a - // typed name has to remain sufficient, or a foreign company whose PoA has no - // Fayda ID could never finish onboarding. - const poaProven = faydaRequired - ? poa.verified - : poa.verified || Boolean(poa.name?.trim()); - - // The GM is deliberately absent from this verdict: it names who to talk to, - // not what the company may do, and it has never gated trading. Capturing it - // through Fayda changes how it is collected, not whether it is required. - const complete = ownerProven && (!poaDue || poaProven); + const etradeManagerName = + (attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null; return { - faydaRequired, - passportRequired, + passportAccepted, + poaDeclared, + subject, owner, poa, - poaSameAsOwner, - gm, - gmSameAsOwner, - complete, + identityProven, + etradeManagerName, + ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName), + complete: subject !== null && identityProven, }; } 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 9b69bb15d..8d47dbb4e 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,7 +8,10 @@ * truth the wizard uses to auto-finish. */ -import { CompanyIdentityStateDto } from "./complete-identity-verification.dto"; +import { + CompanyIdentityStateDto, + PoaDeclaration, +} from "./complete-identity-verification.dto"; export interface OnboardingInfoField { key: string; @@ -38,15 +41,19 @@ export interface OnboardingLicenseProfile { } export interface OnboardingPoaState { - /** True when the company operates as a freight forwarder — PoA is mandatory. */ - required: boolean; - /** True once any PoA detail has been entered. */ - provided: boolean; /** - * True when the DARS delegation paper is owed — a PoA exists (or is - * mandatory) and is not the owner themselves. An owner representing their own - * company delegates to nobody, so there is no delegation to evidence. + * True when the company operates as a freight forwarder: it signs on other + * companies' behalf, so a Power of Attorney is non-negotiable and the portal + * renders the question answered and locked rather than asking it. */ + locked: boolean; + /** + * The company's answer to "does anyone hold power of attorney for you?". + * Null until it answers — which is itself outstanding, since the answer + * decides whose identity is verified. + */ + declared: PoaDeclaration | null; + /** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */ delegationLetterRequired: boolean; /** True when the DARS delegation paper is stored for the company. */ delegationLetterUploaded: boolean; @@ -61,7 +68,19 @@ export interface OnboardingPoaState { export class OnboardingRequirementsResponseDto { /** Resolved document setting code (by nationality) the docs were drawn from. */ documentSettingCode: string; + /** + * The co-operative document set, merged on top of the nationality one — null + * for every other company. `documents` below already carries the merged + * result; this is only so the portal can fetch the same extra fields when it + * renders the pickers from the file-settings endpoint. + */ + cooperativeDocumentSettingCode: string | null; nationality: string; + /** + * The company trades as a co-operative: no business licence, so no eTrade + * lookup, no per-role licence upload, and no freight-forwarder role. + */ + cooperative: boolean; /** Required company-information fields and whether each is filled. */ companyInfo: { @@ -99,7 +118,9 @@ export class OnboardingRequirementsResponseDto { constructor(init: Omit) { this.documentSettingCode = init.documentSettingCode; + this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode; this.nationality = init.nationality; + this.cooperative = init.cooperative; this.companyInfo = init.companyInfo; this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; 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 a70c52b5d..0d7293ab0 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 @@ -2,7 +2,7 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, } from "./complete-identity-verification.dto"; -import { Company } from "../entities/company.entity"; +import { Company, isCooperative } from "../entities/company.entity"; import { ExternalProfile } from "../entities/external-profile.entity"; import { ChangeRequestStatus, @@ -15,6 +15,12 @@ export class ProfileResponseDto { companyName: string; companyType: string; nationality: string | null; + /** + * The company trades as a co-operative: it has a TIN but no business licence, + * so the company step collects the registration by hand instead of fetching + * it from eTrade. + */ + cooperative: boolean; companyLocation: string; companyAddress: string | null; tinNumber: string; @@ -42,9 +48,10 @@ export class ProfileResponseDto { contactPersonPhone: string | null; /** Phone that passed SMS OTP verification (drives the verify-step resume). */ contactVerifiedPhone: string | null; - generalManagerName: string | null; - generalManagerEmail: string | null; - generalManagerPhone: string | null; + /** The owner — whoever the eTrade licence names as the business's manager. */ + ownerName: string | null; + ownerEmail: string | null; + ownerPhone: string | null; poaName: string | null; poaPhone: string | null; @@ -55,12 +62,13 @@ 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. + * The company's single identity verification, plus who it belongs to. + * + * `identity.subject` follows the company's PoA declaration — the + * representative when one is named, otherwise the owner. The settings tabs + * and the onboarding wizard render from it: `passportAccepted` says whether a + * typed passport number is an alternative to Fayda (foreign companies only), + * and `ownerMatchesEtrade` is the check the backoffice makes. */ identity: CompanyIdentityStateDto; @@ -84,6 +92,7 @@ export class ProfileResponseDto { this.companyName = company.name; this.companyType = company.type; this.nationality = company.nationality ?? null; + this.cooperative = isCooperative(company); this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? []; @@ -113,9 +122,9 @@ export class ProfileResponseDto { this.contactPersonEmail = attrs.contactPersonEmail ?? null; this.contactPersonPhone = attrs.contactPersonPhone ?? null; this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null; - this.generalManagerName = attrs.generalManagerName ?? null; - this.generalManagerEmail = attrs.generalManagerEmail ?? null; - this.generalManagerPhone = attrs.generalManagerPhone ?? null; + this.ownerName = attrs.ownerName ?? null; + this.ownerEmail = attrs.ownerEmail ?? null; + this.ownerPhone = attrs.ownerPhone ?? null; this.poaName = attrs.poaName ?? null; this.poaPhone = attrs.poaPhone ?? null; this.poaEmail = attrs.poaEmail ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index d705e7323..e75182889 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -3,6 +3,7 @@ import { CompanyType, CompanyStatus, CompanyNationality, + isCooperative, } from '../entities/company.entity'; import { CompanyProfile, @@ -55,6 +56,12 @@ export class ResponseCompanyDto { type: CompanyType; status: CompanyStatus; nationality?: CompanyNationality | null; + /** + * The company trades as a co-operative: no business licence, so its + * registration was typed rather than fetched from eTrade and there is no + * eTrade manager to check the owner against. + */ + cooperative: boolean; tin: string; vatNumber?: string | null; fanNumber?: string | null; @@ -89,9 +96,14 @@ export class ResponseCompanyDto { houseNo?: string | null; /** - * Owner/PoA Fayda verification state, shared with the portal - * (`buildCompanyIdentityState`) so backoffice never re-derives — or - * disagrees with — the rule the API actually enforces. + * The company's single identity verification, shared with the portal + * (`buildCompanyIdentityState`) so backoffice never re-derives — or disagrees + * with — the rule the API actually enforces. + * + * `subject` names whose verification it is (the PoA when one is declared, + * otherwise the owner), and `ownerMatchesEtrade` is the reviewer's check: + * does the owner the company put forward match the manager on the eTrade + * licence? Advisory — see the note on that field. */ identity: CompanyIdentityStateDto; @@ -105,6 +117,7 @@ export class ResponseCompanyDto { this.type = company.type; this.status = company.status; this.nationality = company.nationality ?? null; + this.cooperative = isCooperative(company); this.tin = company.tin; this.vatNumber = company.vatNumber; this.fanNumber = company.fanNumber; diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts new file mode 100644 index 000000000..37c54b254 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn } from "class-validator"; + +import { + POA_DECLARATIONS, + PoaDeclaration, +} from "./complete-identity-verification.dto"; + +export class SetPoaDeclaredDto { + @ApiProperty({ + enum: POA_DECLARATIONS, + description: + 'Whether anyone holds power of attorney for this company. "no" tears down any representative already recorded.', + }) + @IsIn(POA_DECLARATIONS) + declared!: PoaDeclaration; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts index 7687faab1..91fcb44a1 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -1,4 +1,10 @@ -import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator"; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsEnum, + IsOptional, +} from "class-validator"; import { CompanyNationality, CompanyType } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; @@ -14,4 +20,15 @@ export class StartOnboardingDto { @IsOptional() @IsEnum(CompanyNationality) nationality?: CompanyNationality; + + /** + * The company trades as a co-operative: it holds a TIN but no business + * licence, so there is no eTrade record to fetch its registration from. + * Chosen on the same step as the nationality and the roles, because it + * decides all three of what the next step asks for, which documents apply, + * and which roles are even available (a co-op cannot freight-forward). + */ + @IsOptional() + @IsBoolean() + cooperative?: boolean; } 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 c89326fa1..2273bf79a 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 @@ -5,7 +5,6 @@ import { MaxLength, IsEnum, IsIn, - Matches, } from "class-validator"; import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types"; import { CompanyNationality } from "../entities/company.entity"; @@ -36,20 +35,21 @@ export class UpdateProfileDto { @IsTin({ message: "TIN must be exactly 10 digits" }) tin?: string; - // Ethiopian VAT registration numbers are 10 digits (the same shape as the - // TIN), but some are issued with an 11th. Both portal forms enforce the same - // range; without it here the API happily stored whatever a stale client sent, - // and the two layers disagreed about what the column may hold. + // No shape check. Ethiopian VAT numbers are usually 10 or 11 digits, but a + // foreign company's is whatever its own tax authority issues — letters, + // dashes and any length — and a co-operative's registration numbering does + // not follow the trade-licence pattern either. The field is required (the + // portal enforces non-blank) but its content is not ours to police. @IsOptional() @IsString() - @Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" }) + @MaxLength(64) vatNumber?: 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. + // `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would + // have to come from a completed verification rather than be typed — and + // Fayda's userinfo carries no national ID number, so nothing produces one. + // The global validation pipe runs with forbidNonWhitelisted, so a client that + // still sends it gets a 400 telling it so. @IsOptional() @IsString() @@ -78,18 +78,31 @@ export class UpdateProfileDto { @IsValidPhone() contactVerifiedPhone?: string; + /** + * The owner — whoever the eTrade licence names as the business's manager. + * + * All three are required before onboarding can be submitted, whatever their + * source: the eTrade lookup prefills the name and phone, a Fayda + * verification can supply all three, and the portal renders an input for + * whatever neither did (eTrade returns no email at all, and Fayda's email + * claim is optional, so that one is usually typed). + * + * Locked once a Fayda verification supplied them — see + * `IDENTITY_OWNED_FIELDS` — but only field by field: a claim that came back + * empty owns nothing and stays typeable. + */ @IsOptional() @IsString() - generalManagerName?: string; + ownerName?: string; @IsOptional() @IsEmail() - generalManagerEmail?: string; + ownerEmail?: string; @IsOptional() @IsString() @IsValidPhone() - generalManagerPhone?: string; + ownerPhone?: string; @IsOptional() @IsString() @@ -113,15 +126,22 @@ export class UpdateProfileDto { 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. + * Passport numbers — the alternative identity credential for a foreign + * company, since Fayda is an Ethiopian national ID. Plain typed fields, never + * written or locked by a Fayda verification. + * + * Only the one belonging to the company's declared identity subject matters: + * the PoA's when a representative is named, the owner's otherwise. An + * Ethiopian company is not offered either — it must use Fayda. */ @IsOptional() @IsString() ownerPassportNumber?: string; + @IsOptional() + @IsString() + poaPassportNumber?: string; + @IsOptional() @IsString() @MaxLength(100) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index f254e0121..54c36bf41 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -32,6 +32,25 @@ export enum CompanyNationality { Foreign = "foreign", } +/** + * `attributes` key marking a co-operative union or farm. + * + * Such a company has a TIN but no business licence, so there is no eTrade record to + * look its registration up in — the company name, registered address and the + * owner are all typed instead of fetched, and the eTrade authenticity check is + * skipped rather than failed. It is a flag rather than a column because + * everything it changes is behavioural (which lookup runs, which documents + * apply, which roles are offered); nothing queries or joins on it. + */ +export const COOPERATIVE_KEY = "cooperative"; + +/** Is this a co-operative union or farm (a TIN, but no business licence)? */ +export function isCooperative( + company: Pick | null | undefined, +): boolean { + return company?.attributes?.[COOPERATIVE_KEY] === true; +} + @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"]) @@ -112,29 +131,11 @@ export class Company extends BaseEntity { }) contactPersonPhone?: string | null; - @Column({ - name: "general_manager_name", - type: "varchar", - length: 100, - nullable: true, - }) - generalManagerName?: string | null; - - @Column({ - name: "general_manager_email", - type: "varchar", - length: 150, - nullable: true, - }) - generalManagerEmail?: string | null; - - @Column({ - name: "general_manager_phone", - type: "varchar", - length: 20, - nullable: true, - }) - generalManagerPhone?: string | null; + // The general manager used to live here as three columns. It named who to + // talk to, gated nothing, and nothing ever populated the columns — the write + // path put the values in `attributes`. Removed in RemoveGeneralManager; the + // company's people are now its owner (whoever the eTrade licence names) and + // its Power of Attorney, both in `attributes`. @Column({ name: "website", type: "varchar", length: 200, nullable: true }) website?: string | null; 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 9651d4f37..21ddc4c91 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 @@ -17,6 +17,7 @@ import { } from "./interfaces/file-upload-settings.repository.interface"; import { COMPANY_ONBOARDING_CODE_PREFIX, + COOPERATIVE_ONBOARDING_CODE, POA_DELEGATION_FILE_KEY, poaDelegationField, } from "./poa-delegation.constants"; @@ -56,6 +57,10 @@ export class FileUploadSettingsService { */ private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting { if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting; + // The co-operative set is merged ON TOP of a nationality set that already + // carries the paper; injecting it here too would hand the portal the same + // slot twice. + if (setting.code === COOPERATIVE_ONBOARDING_CODE) return setting; const fields = setting.fields ?? []; if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) 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 index 7f8a34175..e8d593ded 100644 --- 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 @@ -26,6 +26,14 @@ 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_"; +/** + * The co-operative onboarding set. Unlike the nationality sets it is ADDITIVE — + * merged on top of the company's `_ethiopian`/`_foreign` set rather than + * replacing it — which is why the delegation paper is not injected into it: the + * set it is merged onto already carries one. + */ +export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`; + const POA_DELEGATION_HELP = "Delegation paper issued by the Documents Authentication and Registration " + "Service (DARS) delegating the representative named above. Upload the " + diff --git a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts index 886aa8b85..104456384 100644 --- a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts @@ -12,8 +12,8 @@ import { DataSource, EntityManager } from "typeorm"; * `companies.contact_person_phone` is deliberately NOT consulted: the live write * path stores that value in the `attributes` jsonb and has never populated the * column, so every reader of it was silently falling through to `phone` anyway. - * `companies.general_manager_email` is the same trap on the email side — see - * {@link companyNotifyEmailExpr}. + * The retired `general_manager_email` column was the same trap on the email + * side — see {@link companyNotifyEmailExpr}. */ /** @@ -50,24 +50,30 @@ export function companyNotifyPhoneExpr(alias: string): string { * SQL expression for the company's notification address, given the joined `pc` * alias. * - * `companies.email` alone is not enough: it is written from ONE place — a - * Fayda-verified owner's email claim — so a foreign company, whose owner proves - * identity by passport instead, never gets one. Readers papered over that with - * `COALESCE(email, general_manager_email)`, but that column has the same problem - * `contact_person_phone` has above: onboarding writes the value into the - * `attributes` jsonb and nothing has ever populated the column, so the fallback - * could not fire and the mail was dropped in silence. + * `companies.email` is now the owner's email, written on every profile save + * whether or not the owner verified with Fayda — and the owner's email is a + * required onboarding field, so a company that finished onboarding has one. + * (It used to be written ONLY for a Fayda-verified owner, which meant every + * foreign company had none; the gap was papered over with a + * `general_manager_email` leg that could never fire, because onboarding wrote + * that value into `attributes` and nothing ever populated the column.) * - * So: the company address, then the two the customer actually filled in during - * onboarding, then the account that registered them — which always has one, - * signup requires it. `NULLIF` because a blank jsonb key is not an address and - * `COALESCE` would happily stop on it. + * The `generalManagerEmail` attribute is still consulted, after the contact + * person: the general manager was removed, but companies onboarded before that + * may carry an address there and nowhere else. RemoveGeneralManager backfills + * `companies.email` from it, so this is belt-and-braces for rows that migration + * could not resolve. + * + * `NULLIF` because a blank jsonb key is not an address and `COALESCE` would + * happily stop on it. The account that registered the company is the last + * resort — signup guarantees it has one. */ export function companyNotifyEmailExpr(alias: string): string { return `COALESCE( NULLIF(${alias}.email, ''), - NULLIF(${alias}.attributes->>'generalManagerEmail', ''), + NULLIF(${alias}.attributes->>'ownerEmail', ''), NULLIF(${alias}.attributes->>'contactPersonEmail', ''), + NULLIF(${alias}.attributes->>'generalManagerEmail', ''), NULLIF(pc.email, '') )`; } diff --git a/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts b/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts new file mode 100644 index 000000000..96f86bf61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MinLength } from "class-validator"; + +export class UpdateStampSettingDto { + @ApiProperty({ description: "Stamp image as a base64 data URL (PNG/JPG)." }) + @IsString() + @MinLength(1) + stampImageBase64!: string; +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts b/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts new file mode 100644 index 000000000..7ab7a0e2c --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts @@ -0,0 +1,24 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +import { FileRecord } from "../../files/entities/file.entity"; + +/** + * Single-row table holding the one company stamp/seal image stamped onto + * generated invoice/receipt PDFs (see InvoiceDocumentService). Mirrors the + * exchange_settings single-row pattern — `get()` lazily creates the row, and + * there is never more than one. + */ +@Entity({ schema: "freight", name: "stamp_settings" }) +export class StampSetting extends BaseEntity { + @Column({ name: "stamp_file_id", type: "uuid", nullable: true }) + stampFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: "stamp_file_id" }) + stampFile?: FileRecord | null; + + /** IAM user id of the last operator to set/clear the stamp. */ + @Column({ name: "updated_by_id", type: "uuid", nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts new file mode 100644 index 000000000..f03d55058 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Delete, Get, Put } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { UpdateStampSettingDto } from "./dto/update-stamp-setting.dto"; +import { StampSettingsService } from "./stamp-settings.service"; + +@ApiTags("stamp-settings") +@ApiBearerAuth() +@Controller("stamp-settings") +export class StampSettingsController { + constructor(private readonly service: StampSettingsService) {} + + @Get() + @BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" }) + get() { + return this.service.getView(); + } + + @Put() + @BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: "Replace the company stamp" }) + update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) { + return this.service.setStamp(dto.stampImageBase64, user?.id ?? null); + } + + @Delete() + @BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin]) + @ApiOperation({ + summary: "Clear the company stamp (invoices fall back to the plain seal)", + }) + clear(@CurrentUser() user: TCurrentUser) { + return this.service.clearStamp(user?.id ?? null); + } +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts new file mode 100644 index 000000000..6c9fc3a36 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts @@ -0,0 +1,23 @@ +import { Global, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { FilesModule } from "../files/files.module"; +import { MinioModule } from "../minio/minio.module"; +import { StampSetting } from "./entities/stamp-setting.entity"; +import { StampSettingsController } from "./stamp-settings.controller"; +import { StampSettingsRepository } from "./stamp-settings.repository"; +import { StampSettingsService } from "./stamp-settings.service"; + +/** + * Global so DocumentsModule (invoice PDF rendering) can inject + * {@link StampSettingsService} without pulling in a circular billing/warehouse + * dependency — same reasoning as ExchangeSettingsModule. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([StampSetting]), FilesModule, MinioModule], + controllers: [StampSettingsController], + providers: [StampSettingsRepository, StampSettingsService], + exports: [StampSettingsService], +}) +export class StampSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts new file mode 100644 index 000000000..0ca4cfb68 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; + +import { StampSetting } from "./entities/stamp-setting.entity"; + +@Injectable() +export class StampSettingsRepository extends BaseRepository { + constructor( + @InjectRepository(StampSetting) + repo: Repository, + ) { + super(repo); + } + + /** The single settings row, with its stamp file joined, or null before first upload. */ + findSingleton(): Promise { + return this.repository.findOne({ where: {}, relations: ["stampFile"] }); + } +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts new file mode 100644 index 000000000..01352e133 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Readable } from "stream"; +import { DataSource } from "typeorm"; + +import { FilesService } from "../files/files.service"; +import { FileRecord } from "../files/entities/file.entity"; +import { MinioService } from "../minio/minio.service"; +import { StampSettingsRepository } from "./stamp-settings.repository"; +import { StampSetting } from "./entities/stamp-setting.entity"; + +export interface StampSettingView { + stampImageUrl: string | null; + updatedById: string | null; + updatedAt: Date | null; +} + +/** + * Owns the single `stamp_settings` row: the one company stamp/seal image used + * on generated invoice/receipt PDFs (see InvoiceDocumentService). Same + * single-row shape as ExchangeSettingsService, but the value is an uploaded + * image (via FilesService) rather than a scalar. + */ +@Injectable() +export class StampSettingsService { + private readonly logger = new Logger(StampSettingsService.name); + + constructor( + private readonly repository: StampSettingsRepository, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + private readonly dataSource: DataSource, + ) {} + + /** The settings row, created empty on first access. */ + async get(): Promise { + const existing = await this.repository.findSingleton(); + if (existing) return existing; + return this.repository.create({ stampFileId: null, updatedById: null }); + } + + /** Current stamp, with the image inlined as a data URL (or null if unset). */ + async getView(): Promise { + const setting = await this.get(); + return { + stampImageUrl: await this.inlineImageUrl(setting.stampFile?.url), + updatedById: setting.updatedById ?? null, + updatedAt: setting.updatedAt ?? null, + }; + } + + /** + * The stamp image for embedding into invoice PDFs. Never throws — invoice + * generation must succeed even if the stamp lookup fails; callers fall back + * to the programmatic seal when this returns null. + */ + async getStampImageUrl(): Promise { + try { + const setting = await this.get(); + return await this.inlineImageUrl(setting.stampFile?.url); + } catch (err) { + this.logger.warn( + `Could not load company stamp for PDF rendering: ${(err as Error).message}`, + ); + return null; + } + } + + /** Replace the stamp image, storing it in MinIO via FilesService. */ + async setStamp( + stampImageBase64: string, + updatedById?: string | null, + ): Promise { + const current = await this.get(); + const previousFileId = current.stampFileId ?? null; + + const fileRecord = await this.filesService.upload({ + resourceId: current.id, + resource: "stamp_settings", + code: "stamp", + file: this.toUploadFile(stampImageBase64), + uploadedByUserId: updatedById ?? null, + }); + + await this.repository.update(current.id, { + stampFileId: fileRecord.id, + updatedById: updatedById ?? null, + }); + + if (previousFileId && previousFileId !== fileRecord.id) { + await this.dataSource.getRepository(FileRecord).delete(previousFileId); + } + + this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`); + return this.getView(); + } + + /** Clear the stamp (invoices fall back to the programmatic seal). */ + async clearStamp(updatedById?: string | null): Promise { + const current = await this.get(); + const previousFileId = current.stampFileId ?? null; + + await this.repository.update(current.id, { + stampFileId: null, + updatedById: updatedById ?? null, + }); + + if (previousFileId) { + await this.dataSource.getRepository(FileRecord).delete(previousFileId); + } + + return this.getView(); + } + + private toUploadFile(base64: string): Express.Multer.File { + const raw = base64.includes(",") ? base64.split(",")[1]! : base64; + const buffer = Buffer.from(raw, "base64"); + return { + fieldname: "stamp", + originalname: "company-stamp.png", + encoding: "7bit", + mimetype: "image/png", + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: "", + filename: "", + path: "", + }; + } + + private async inlineImageUrl(url?: string | null): Promise { + if (!url) return null; + if (url.startsWith("data:")) return url; + try { + const objectName = this.minioService.getObjectNameFromUrl(url); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + return `data:image/png;base64,${buffer.toString("base64")}`; + } catch { + return url; + } + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on("error", reject); + stream.on("end", () => resolve(Buffer.concat(chunks))); + }); + } +} diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 9cf1ef0cd..174a32341 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -157,9 +157,6 @@ async function main() { email: 'negad-indode-demo@edr.local', contactPersonName: 'Marshalling Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: 'negad-indode-demo@edr.local', - generalManagerPhone: '251900000202', }), )); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 2e234bcf0..91848dff4 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -247,9 +247,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { website: null, contactPersonName: 'First Last Mile Demo', contactPersonPhone: '251900000101', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000101', }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 20d2a3f8b..4f259c28c 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -324,9 +324,6 @@ export class DemoBookingsSeeder { website: null, contactPersonName: "Train Scheduling", contactPersonPhone: "251900000001", - generalManagerName: "Demo Manager", - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: "251900000001", }, { conflictPaths: { tin: true } }, ); 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 512229706..abc4e613c 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 @@ -154,6 +154,31 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // }, // ]; +/** + * Extra documents a co-operative union or farm provides, merged on top of its + * nationality set. It has a TIN but no business licence, so the papers that + * evidence the co-operative itself stand in for the trade licence every other + * company uploads. + * + * Only the registration certificate is seeded, and the set is admin-managed + * like every other onboarding set — what these members must actually produce + * is a backoffice decision, edited in the file-settings editor. + */ +const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [ + { + fileKey: "cooperative_registration_certificate", + fileLabel: "Co-operative Union / Farm Registration Certificate", + helpText: + "Certificate issued by the co-operative promotion agency that registered the union or farm.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 1, + }, +]; + interface OnboardingDocumentSetting { code: string; label: string; @@ -176,6 +201,14 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ entity: "customer", fields: FOREIGN_ONBOARDING_FIELDS, }, + // Additive, not a nationality of its own: a union or farm still uploads + // everything its nationality set demands, and these on top. + { + code: "company_onboarding_documents_cooperative", + label: "Co-operative union / farm onboarding documents (additional)", + entity: "customer", + fields: COOPERATIVE_ONBOARDING_FIELDS, + }, // Legacy per-company-type codes — removed, unused by any resolver or portal // lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live). // { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 6f8df81bb..66ad60aa3 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1187,6 +1187,26 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:dropdown:manage", "Manage dropdown settings", ), + perm( + "b4b00002-0001-4000-8000-000000000001", + "edr_freight_app:settings:stamp:view", + "View stamp settings", + ), + perm( + "b4b00002-0001-4000-8000-000000000002", + "edr_freight_app:settings:stamp:manage", + "Manage stamp settings", + ), + perm( + "b4b00003-0001-4000-8000-000000000001", + "edr_freight_app:settings:invoice_stamp:view", + "View invoice stamp settings", + ), + perm( + "b4b00003-0001-4000-8000-000000000002", + "edr_freight_app:settings:invoice_stamp:manage", + "Manage invoice stamp settings", + ), perm( "b4c00001-0001-4000-8000-000000000001", "edr_freight_app:audit:view", @@ -1876,6 +1896,16 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:dropdown:view", manage: "edr_freight_app:settings:dropdown:manage", }, + stamp: { + view: "edr_freight_app:settings:stamp:view", + manage: "edr_freight_app:settings:stamp:manage", + }, + // Company stamp/seal image stamped onto invoice/receipt PDFs — separate + // from `stamp` above, which is the per-employee approval-record teeter. + invoiceStamp: { + view: "edr_freight_app:settings:invoice_stamp:view", + manage: "edr_freight_app:settings:invoice_stamp:manage", + }, exchangeRate: { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index 654f4f77f..a658ec13d 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -175,9 +175,6 @@ export class PaidImportExportMileDemoSeeder { website: null, contactPersonName: 'Paid Mile Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000202', }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 00d5e0ddb..58b8286f0 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -51,6 +51,8 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature"; +import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; @@ -117,6 +119,20 @@ import { findActiveSidebarLabel, } from "@/components/layout/sidebar-sections"; +/** + * The per-shipment clearance detail page is the shared destination of three + * hubs (Operations → Clearance, Clearance Documents, Self-Clearance Review), + * none of which are gated on `bookings:clearance_view`. Gating the detail on + * that key alone bounced reviewers back to their landing page (Bookings) the + * moment they opened a row, so accept any key that can reach a hub. + */ +const CLEARANCE_DETAIL_PERMS = [ + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.opsClearanceReview, +]; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); @@ -285,9 +301,7 @@ const App = () => { + } @@ -321,9 +335,7 @@ const App = () => { + } @@ -773,6 +785,24 @@ const App = () => { } /> + + + + } + /> + + + + } + /> - + {/* Whoever the eTrade licence names as the business's manager. */} + diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index d86e0079c..65c4f25e3 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -43,9 +43,17 @@ export const FIELD_LABELS: Record = { contactPersonPosition: "Contact position", contactPersonEmail: "Contact email", contactPersonPhone: "Contact phone", - generalManagerName: "General manager", - generalManagerEmail: "GM email", - generalManagerPhone: "GM phone", + ownerName: "Owner name", + ownerEmail: "Owner email", + ownerPhone: "Owner phone", + poaDeclared: "Has a Power of Attorney", + poaPassportNumber: "PoA passport number", + // Nothing writes these any more — the general manager was removed — but + // change requests filed before that still carry them, and without a label + // the reviewer sees a raw attribute key. + generalManagerName: "General manager (retired)", + generalManagerEmail: "GM email (retired)", + generalManagerPhone: "GM phone (retired)", poaName: "PoA name", poaPhone: "PoA phone", poaEmail: "PoA email", @@ -79,9 +87,9 @@ export function currentValue(company: Company, key: string): string { nationality: c.nationality, contactPersonName: c.contactPersonName ?? attrs.contactPersonName, contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone, - generalManagerName: c.generalManagerName ?? attrs.generalManagerName, - generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail, - generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone, + ownerName: c.ownerName ?? attrs.ownerName, + ownerEmail: c.ownerEmail ?? attrs.ownerEmail, + ownerPhone: c.ownerPhone ?? attrs.ownerPhone, }; const v = key in map ? map[key] : (c[key] ?? attrs[key]); return v === null || v === undefined || v === "" ? "—" : String(v); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 83e80d11e..1b523c8c3 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -485,6 +485,18 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] icon: , permission: FREIGHT_PERMS.settings.dropdown.view, }, + { + label: "Stamp settings", + href: "/dashboard/stamp-settings", + icon: , + permission: FREIGHT_PERMS.settings.stamp.view, + }, + { + label: "Invoice stamp", + href: "/dashboard/invoice-stamp-settings", + icon: , + permission: FREIGHT_PERMS.settings.invoiceStamp.view, + }, { label: "Contract templates", href: "/dashboard/contract-templates", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts new file mode 100644 index 000000000..9d1594602 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts @@ -0,0 +1,45 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { stampSettingsService } from "@/services/stampSettings.service"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; + +const QUERY_KEY = ["stampSettings"]; + +export const useStampSettingsQuery = () => + useQuery({ + queryKey: QUERY_KEY, + queryFn: () => stampSettingsService.get(), + }); + +export const useSetStamp = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: (stampImageBase64: string) => + stampSettingsService.set(stampImageBase64), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("stampSettings.updated", "Company stamp updated")); + }, + onError: handleError, + }); +}; + +export const useClearStamp = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: () => stampSettingsService.clear(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("stampSettings.cleared", "Company stamp removed")); + }, + onError: handleError, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index b74c7bef9..89d1d089b 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -320,6 +320,16 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:dropdown:view", manage: "edr_freight_app:settings:dropdown:manage", }, + stamp: { + view: "edr_freight_app:settings:stamp:view", + manage: "edr_freight_app:settings:stamp:manage", + }, + // Company stamp/seal image stamped onto invoice/receipt PDFs — separate + // from `stamp` above, which is the per-employee approval-record teeter. + invoiceStamp: { + view: "edr_freight_app:settings:invoice_stamp:view", + manage: "edr_freight_app:settings:invoice_stamp:manage", + }, exchangeRate: { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index f31ce02e9..a6367786a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -17,6 +17,7 @@ import { Text, } from "@mantine/core"; import { + AlertTriangle, ArrowLeft, ArrowRight, Banknote, @@ -638,8 +639,9 @@ export default function CustomerDetailPage() { const hasPoaDetails = poaFields.some((f) => f.value?.trim()); // Shared with the portal (buildCompanyIdentityState) — same derivation, so // this page can never disagree with the rule the API actually enforces. - const ownerIdentity = company?.identity?.owner; - const poaIdentity = company?.identity?.poa; + const identityState = company?.identity; + const ownerIdentity = identityState?.owner; + const poaIdentity = identityState?.poa; const hasEtradeRecord = Boolean(company?.licenceNumber?.trim()); // A freight forwarder acts on other companies' behalf, so its PoA — details // and DARS delegation paper both — is mandatory rather than optional. @@ -647,7 +649,7 @@ export default function CustomerDetailPage() { (p) => p.type === "freight_forwarder", ); const delegationMissing = - (hasPoaDetails || poaMandatory) && poaLive.length === 0; + company?.identity?.poaDeclared === "yes" && poaLive.length === 0; if (isLoading) { return ( @@ -820,6 +822,16 @@ export default function CustomerDetailPage() { : undefined } /> + {/* Why this company's registration was typed rather than + fetched, and why it carries no business licence. */} + @@ -834,18 +846,9 @@ export default function CustomerDetailPage() { value={company.contactPersonPhone} /> - - - + + + @@ -912,6 +915,11 @@ export default function CustomerDetailPage() { Owner identity + {identityState?.subject === "owner" && ( + + Verifies for this company + + )} {ownerIdentity?.verified ? ( Fayda verified @@ -922,6 +930,41 @@ export default function CustomerDetailPage() { )} + + {/* THE check: is the owner the company put forward the person + the eTrade licence actually names? Advisory — eTrade and + Fayda transliterate Amharic names differently, so this is a + prompt to look, not a verdict. */} + {identityState?.ownerMatchesEtrade === false ? ( + } + title="Does not match the eTrade licence" + > + The licence names{" "} + {identityState.etradeManagerName}, but this + company recorded {company.ownerName}. + + ) : identityState?.ownerMatchesEtrade === true ? ( + + Matches the eTrade licence + + ) : company.cooperative ? ( + + A co-operative union or farm holds no trade licence, so + there is no eTrade record to check the owner against. + + ) : ( + + No eTrade manager name on file to compare against. + + )} {ownerIdentity?.verified ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx new file mode 100644 index 000000000..407218d70 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { Button } from "@/shared/common/ui/button"; +import { Save, Trash2 } from "lucide-react"; + +import { StampUpload } from "@/components/contracts/StampUpload"; +import { + useClearStamp, + useSetStamp, + useStampSettingsQuery, +} from "@/hooks/useStampSettings"; + +/** + * The one company stamp/seal stamped onto every generated invoice/receipt + * PDF (InvoiceDocumentService). Single global image — no per-employee choice. + */ +export default function InvoiceStampSettingsPage() { + const { data, isLoading } = useStampSettingsQuery(); + const setStamp = useSetStamp(); + const clearStamp = useClearStamp(); + const [draft, setDraft] = useState(null); + + useEffect(() => { + setDraft(null); + }, [data?.stampImageUrl]); + + const value = draft !== null ? draft : (data?.stampImageUrl ?? null); + const dirty = draft !== null && draft !== data?.stampImageUrl; + + const handleSave = async () => { + if (!draft) return; + await setStamp.mutateAsync(draft); + }; + + const handleClear = async () => { + if (!data?.stampImageUrl) return; + await clearStamp.mutateAsync(); + }; + + return ( +
+ + + Invoice stamp + + Stamped onto every generated invoice and receipt PDF. Replacing it + here changes it everywhere at once — there is no per-invoice or + per-user choice. + + + + + +
+ + {data?.stampImageUrl && !dirty && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx index 3035d58c7..a6855f540 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx @@ -609,10 +609,18 @@ const UploadTeeterAndSignature = () => { )} - {/* Teeter Tab */} + {/* Teeter Tab — single active stamp only: remove the current one to upload a replacement. */} {teeters.length > 0 && (
+ {teeters.length > 1 && ( +

+ {t( + "signatureUpload.multipleStampsWarning", + "Only one stamp is allowed. Remove the extras below to keep a single active stamp.", + )} +

+ )} {teeters.map(({ id, url }) => (

@@ -635,6 +643,7 @@ const UploadTeeterAndSignature = () => {

)} + {teeters.length === 0 && (
{!stampBlocks && !showLanguagePicker && (
+ )} diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index eb86dd752..5b3cc4527 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -24,7 +24,7 @@ const cleanParams = (params: object) => ), ); -/** Lift attributes JSONB into the flat contact/manager fields the UI reads. */ +/** Lift attributes JSONB into the flat contact/owner fields the UI reads. */ function mapCompany(dto: Record): Company { const attrs = (dto.attributes as Record | null) ?? {}; return { @@ -32,9 +32,9 @@ function mapCompany(dto: Record): Company { companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [], contactPersonName: (attrs.contactPersonName as string | null) ?? null, contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null, - generalManagerName: (attrs.generalManagerName as string | null) ?? null, - generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null, - generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null, + ownerName: (attrs.ownerName as string | null) ?? null, + ownerEmail: (attrs.ownerEmail as string | null) ?? null, + ownerPhone: (attrs.ownerPhone as string | null) ?? null, poaName: (attrs.poaName as string | null) ?? null, poaEmail: (attrs.poaEmail as string | null) ?? null, poaPhone: (attrs.poaPhone as string | null) ?? null, diff --git a/apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts new file mode 100644 index 000000000..c88d4e08b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts @@ -0,0 +1,31 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import type { ApiResponse } from "@/types/apiResponse"; + +const BASE = "/stamp-settings"; + +/** Company stamp/seal used on generated invoice/receipt PDFs. */ +export interface StampSettings { + stampImageUrl: string | null; + updatedById: string | null; + updatedAt: string | null; +} + +export const stampSettingsService = { + get: async (): Promise => { + const response = await client.get>(BASE); + return unwrap(response.data); + }, + + set: async (stampImageBase64: string): Promise => { + const response = await client.put>(BASE, { + stampImageBase64, + }); + return unwrap(response.data); + }, + + clear: async (): Promise => { + const response = await client.delete>(BASE); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 7ae93a1b7..dd1734ad6 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -64,9 +64,9 @@ export interface BookingCompany { email?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null; - generalManagerName?: string | null; - generalManagerEmail?: string | null; - generalManagerPhone?: string | null; + ownerName?: string | null; + ownerEmail?: string | null; + ownerPhone?: string | null; website?: string | null; } diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index cd2d67842..87a71bf6e 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -179,23 +179,34 @@ export interface IdentityVerificationState { verifiedAt: string | null; birthdate: string | null; gender: string | null; -} - -/** Mirrors `OwnerIdentityStateDto`. */ -export interface OwnerIdentityState extends IdentityVerificationState { + /** Typed passport number — the foreign-company alternative to Fayda. */ passportNumber: string | null; } /** - * Owner/PoA Fayda verification, shared with the portal's derivation + * The company's single identity verification, shared with the portal's derivation * (`buildCompanyIdentityState`) so backoffice never re-derives — or * disagrees with — the rule the API actually enforces. */ export interface CompanyIdentityState { - faydaRequired: boolean; - passportRequired: boolean; - owner: OwnerIdentityState; + /** Foreign company: a passport number proves the person as Fayda would. */ + passportAccepted: boolean; + /** Whether the company named a representative. Null = never answered. */ + poaDeclared: "yes" | "no" | null; + /** Whose verification the company is gated on — PoA if declared, else owner. */ + subject: "owner" | "poa" | null; + owner: IdentityVerificationState; poa: IdentityVerificationState; + identityProven: boolean; + /** The manager named on the eTrade licence, captured at lookup. */ + etradeManagerName: string | null; + /** + * Does the owner the company put forward match the eTrade licence? + * THE reviewer check. Null when there is nothing to compare. Advisory — + * eTrade and Fayda transliterate Amharic names differently, so a `false` is + * "look at this", not "reject this". + */ + ownerMatchesEtrade: boolean | null; complete: boolean; } @@ -211,14 +222,21 @@ export interface Company { fanNumber?: string | null; country: string; nationality?: CompanyNationality | null; + /** + * A co-operative union or farm: a TIN but no trade licence, so its + * registration was typed rather than fetched from eTrade, there is no eTrade + * manager to check the owner against, and it holds no freight-forwarder role. + */ + cooperative?: boolean; address?: string | null; phone?: string | null; email?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null; - generalManagerName?: string | null; - generalManagerEmail?: string | null; - generalManagerPhone?: string | null; + /** The owner — whoever the eTrade licence names as the business's manager. */ + ownerName?: string | null; + ownerEmail?: string | null; + ownerPhone?: string | null; poaName?: string | null; poaEmail?: string | null; poaPhone?: string | null; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 56273351d..acedc57f5 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import { useEffect, useRef, useState } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, Building2, Download } from "lucide-react"; +import { AlertCircle, Building2, Download, Info } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; @@ -54,6 +54,14 @@ interface ETradeInfoProps { * than silently snapping to eTrade's first one. */ selectedLicenceNumber?: string; + /** + * A record is worth having but not required — a co-operative union or farm + * registers on a TIN alone, so eTrade may legitimately hold nothing for it. + * The lookup still runs (plenty of co-operatives DO have a record, and it + * beats typing), but "not found" stops being a red dead end and becomes the + * expected outcome, with the form below to fill in by hand. + */ + registrationOptional?: boolean; } // Digits, not just length: a 10-character non-numeric TIN used to fire a lookup @@ -70,6 +78,7 @@ export default function ETradeInfo({ onReset, alreadyVerified, selectedLicenceNumber, + registrationOptional = false, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; @@ -327,16 +336,26 @@ export default function ETradeInfo({ )} - {notFound && ( - } - color="red" - title="No matching business record" - > - This TIN isn't registered with eTrade. Check the number — we can't - continue without a matching business record. - - )} + {notFound && + (registrationOptional ? ( + } + color="blue" + title="Nothing on file at eTrade for this TIN" + > + That's expected without a trade licence. Fill in your registration + details below and we'll take them as you give them. + + ) : ( + } + color="red" + title="No matching business record" + > + This TIN isn't registered with eTrade. Check the number — we can't + continue without a matching business record. + + ))} {errorMessage && ( , title: "Company Information", description: - "Confirm your VAT number, verify the owner's identity, and we'll pull your registration from eTrade.", + "Confirm your VAT number and we'll pull your registration straight from eTrade.", }, - personnel: { + owner: { icon: , - title: "General Manager", - description: "Who is the general manager of the company?", + title: "Company Owner", + description: + "The person registered on your eTrade licence. We fill in what eTrade and Fayda gave us.", + }, + representation: { + icon: , + title: "Who Acts For You", + description: + "Tell us whether anyone holds power of attorney — your answer decides whose identity we verify.", }, contact: { icon: , title: "Contact Person", description: "Who should we reach out to about this account?", }, - poa: { - icon: , - title: "Power of Attorney", - description: "Optionally add a representative with power of attorney.", - }, documents: { icon: , title: "Upload Documents", @@ -157,6 +165,15 @@ export default function OnboardingWizardDialog({ const [roles, setRoles] = useState( existingProfiles.map((p) => p.type), ); + const [cooperative, setCooperative] = useState( + company?.company?.attributes?.cooperative === true, + ); + // Ticking the box drops a role the company can no longer hold, rather than + // letting Continue fail on a selection the API refuses. + const handleCooperativeChange = useCallback((checked: boolean) => { + setCooperative(checked); + if (checked) setRoles((prev) => prev.filter((r) => r !== "freight_forwarder")); + }, []); const [documentFiles, setDocumentFiles] = useState< Record >({}); @@ -207,10 +224,11 @@ export default function OnboardingWizardDialog({ companyType: string; roles: ProfileTypeValue[]; nationality?: CompanyNationality; + cooperative?: boolean; }) => api.companies.startOnboarding.call(vars), onSuccess: async () => { // Nationality drives the server-resolved identity requirements (Fayda vs - // passport), the document set and the GM/PoA copy — all read from + // passport), the document set and the PoA copy — all read from // onboardingRequirements/profile. Re-entering role selection can change // it, so both must be refetched alongside getInfo or the form step would // keep rendering the previous nationality's requirements. @@ -289,6 +307,7 @@ export default function OnboardingWizardDialog({ resumedRef.current = true; setRoles(existingProfiles.map((p) => p.type)); setNationality(savedNationality); + setCooperative(company?.company?.attributes?.cooperative === true); // Resume into the form only when profiles exist; otherwise send the user to // role selection so the missing operational profiles get created. setPhase(hasOperationalProfiles ? "form" : "nationality-role"); @@ -303,8 +322,9 @@ export default function OnboardingWizardDialog({ companyType: companyTypeForRoles(roles), roles: roles as ProfileTypeValue[], nationality: nationality ?? undefined, + cooperative, }); - }, [roles, nationality, startMutation]); + }, [roles, nationality, cooperative, startMutation]); // Back from the form's first step returns to nationality/role selection. // Safe to re-enter: startOnboarding is idempotent — it reuses the existing @@ -414,15 +434,21 @@ export default function OnboardingWizardDialog({ const requiredDocsMissing = requirementDocuments.some( (d) => d.isRequired && !d.uploaded, ); - // The PoA gets the same treatment: a resumed draft that predates the - // delegation-letter requirement (or a forwarder whose PoA is blank) must land - // back on the PoA step, where both the details and the letter are entered. - const poaIncomplete = requirementsQuery.data?.poa?.complete === false; + // The representation step gets the same treatment. An unanswered + // power-of-attorney question, or a declared representative still missing + // details or the DARS paper, must land the customer back on the step where + // all of that is entered — including a draft that predates the question + // existing at all, whose `declared` comes back null. + const representationIncomplete = + requirementsQuery.data?.poa?.declared == null || + requirementsQuery.data?.poa?.complete === false || + requirementsQuery.data?.identity?.identityProven === false; // Each unmet requirement lowers the ceiling; resume never moves forward. let ceiling = FORM_STEPS.length - 1; if (requiredDocsMissing) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents")); - if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa")); + if (representationIncomplete) + ceiling = Math.min(ceiling, FORM_STEPS.indexOf("representation")); const effectiveResumeStep: FormStep = FORM_STEPS[ Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling) @@ -446,10 +472,19 @@ 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. + // The company's single identity verification, and whose it is. Fayda is + // mandatory for an Ethiopian company; a foreign one may instead type a + // passport number for the same person. identity: requirementsQuery.data?.identity, + // Server-confirmed, not the local checkbox: the flag is only real once + // startOnboarding has persisted it, and the form's whole company step + // branches on it. + cooperative: requirementsQuery.data?.cooperative ?? cooperative, + extraDocumentSettingCode: + requirementsQuery.data?.cooperativeDocumentSettingCode ?? null, + // A freight forwarder cannot answer the power-of-attorney question — the + // API forces "yes" — so the step offers no way to change it. + declarationLocked: requirementsQuery.data?.poa?.locked ?? false, onIdentityChange: () => { void profileQuery.refetch(); void requirementsQuery.refetch(); @@ -517,6 +552,16 @@ export default function OnboardingWizardDialog({ onChange={setNationality} embedded /> + {/* A co-operative union or farm registers on a TIN alone. It + changes what the next step asks for (typed registration, no + eTrade lookup), which documents apply, and which roles are on + offer — so it is answered here, alongside the other two. */} + handleCooperativeChange(e.currentTarget.checked)} + label="We're a co-operative union or farm" + description="For members with a TIN but no business licence. You'll type your registration details instead of us pulling them from eTrade, and upload your co-operative papers in place of a trade licence." + /> What does your company do?(multiple) @@ -524,6 +569,9 @@ export default function OnboardingWizardDialog({ value={roles} onChange={setRoles} embedded + // Forwarding is licensed work — a co-op holds no licence, so + // the role is not offered rather than refused later. + excludeTypes={cooperative ? ["freight_forwarder"] : undefined} /> {startError && ( diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 8a2f1d12f..13b0d4025 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -49,49 +49,46 @@ import TabAccount from "./settings/TabAccount"; import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabContactPerson from "./settings/TabContactPerson"; import TabDocuments from "./settings/TabDocuments"; -import TabGeneralManager from "./settings/TabGeneralManager"; +import TabOwner from "./settings/TabOwner"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; type SettingsTab = | "account" | "company" | "contact" - | "gm" + | "owner" | "poa" | "documents"; /** A section is "incomplete" when its required fields aren't filled in yet. */ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean { switch (tabId) { - 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.companyAddress || identityIncomplete; - } + case "company": + return !profile.companyAddress; case "contact": return !profile.contactPersonName || !profile.contactPersonPhone; - case "gm": - // The GM is established through Fayda — verified in their own right or - // declared the same person as the owner — so the identity answers this, - // not the typed columns. A company that may still type them (foreign, - // whose manager may hold no Fayda ID) is judged on those instead. - if (profile.identity?.gm.verified) return false; - if (profile.identity?.faydaRequired) return true; + case "owner": + // The owner is whoever the eTrade licence names. All three details are + // required whatever supplied them, and the identity verification lives + // on whichever person the PoA declaration points at — flagged here when + // it is the owner and still unproven. return ( - !profile.generalManagerName || - !profile.generalManagerEmail || - !profile.generalManagerPhone + !profile.ownerName || + !profile.ownerEmail || + !profile.ownerPhone || + (profile.identity?.subject === "owner" && + !profile.identity.identityProven) + ); + case "poa": + // Unanswered is itself incomplete — the answer decides whose identity is + // verified — as is a declared representative who has not proved theirs. + if (profile.identity?.poaDeclared == null) return true; + return ( + profile.identity.subject === "poa" && !profile.identity.identityProven ); case "account": // Account fields live on the IAM user, not the company profile, and are // always populated (signup requires them) — nothing to nag about here. - case "poa": case "documents": return false; } @@ -101,7 +98,7 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ { id: "account", label: "Account", icon: }, { id: "company", label: "Company", icon: }, { id: "contact", label: "Contact Person", icon: }, - { id: "gm", label: "General Manager", icon: }, + { id: "owner", label: "Owner", icon: }, { id: "poa", label: "Power of Attorney", icon: }, { id: "documents", label: "Documents", icon: }, ]; @@ -405,8 +402,8 @@ export default function SettingsPage() { - - + + diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 92e1a7118..59fcc2223 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -9,7 +9,6 @@ import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; -import { toEthiopianE164 } from "@/components/PhoneField"; import { getMinFiles } from "@/types/fileUploadSettings"; import { api } from "@/services/api"; import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep"; @@ -27,15 +26,21 @@ import { firstValidEmail, firstValidPhone, normalizeIdentityPhones, + resolveOwnerSources, stepPayload, toFormValues, } from "./companyProfileForm/helpers"; import { verifaydaService } from "@/services/verifayda.service"; -import type { CompanyIdentityState } from "@/services/verifayda.service"; +import type { + CompanyIdentityState, + PoaDeclaration, +} from "@/services/verifayda.service"; import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep"; -import PersonnelStep from "./companyProfileForm/steps/PersonnelStep"; +import OwnerStep from "./companyProfileForm/steps/OwnerStep"; import ContactStep from "./companyProfileForm/steps/ContactStep"; -import PoaStep from "./companyProfileForm/steps/PoaStep"; +import RepresentationStep, { + type IdentityMethod, +} from "./companyProfileForm/steps/RepresentationStep"; import DocumentsStep from "./companyProfileForm/steps/DocumentsStep"; export default function CompanyProfileForm({ @@ -59,6 +64,9 @@ export default function CompanyProfileForm({ onUploadDocuments, identity: rawIdentity, onIdentityChange, + cooperative = false, + declarationLocked = false, + extraDocumentSettingCode, }: { documentSettingCode: string; documentFiles?: Record; @@ -96,7 +104,7 @@ export default function CompanyProfileForm({ onUploadDocuments?: () => Promise< { ok: true } | { ok: false; error: string } >; - /** Fayda verification state for the owner and the PoA (undefined until loaded). */ + /** The company's single identity verification (undefined until loaded). */ identity?: CompanyIdentityState; /** * Refetch the profile + requirements. Only the in-page identity actions need @@ -104,6 +112,20 @@ export default function CompanyProfileForm({ * a freshly booted app, so it has nothing to notify. */ onIdentityChange?: () => void; + /** + * The company trades as a co-operative: a TIN but no business licence, so the + * eTrade lookup is replaced by typed registration details, the per-role + * licence upload is not owed, and its own document set applies on top of the + * nationality one. + */ + cooperative?: boolean; + /** + * The company operates as a freight forwarder, so the power-of-attorney + * answer is forced to "yes" and cannot be changed here. + */ + declarationLocked?: boolean; + /** Additional document set merged in (the co-operative one), if any. */ + extraDocumentSettingCode?: string | null; }) { // A Fayda claim carries the phone as the national registry holds it, which is // often a local number the form's E.164 validation (and the API's @@ -116,7 +138,24 @@ export default function CompanyProfileForm({ const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); + /** + * What is holding this step back. + * + * `kind` only picks the title. Most of these never reach the API at all — + * they are the step's own gates (a TIN eTrade hasn't confirmed, an + * unanswered declaration, a missing paper) — and heading every one of them + * "Couldn't save this step" told the customer a request failed when none was + * made, which reads as a bug on our side rather than a field to go fix. + */ + const [saveError, setSaveError] = useState<{ + kind: "check" | "save"; + message: string; + } | null>(null); + /** A step gate or a validation failure: something on screen to correct. */ + const failCheck = (message: string) => + setSaveError({ kind: "check", message }); + /** The API refused the save; the message is the server's, shown verbatim. */ + const failSave = (message: string) => setSaveError({ kind: "save", message }); // Live eTrade lookup status, reported up by ETradeInfo — drives the Continue // gate on the company step. const [tinStatus, setTinStatus] = useState("idle"); @@ -148,7 +187,7 @@ export default function CompanyProfileForm({ // Follow a parent-driven resume correction: if initialStep changes (the wizard // re-clamps it back once onboarding requirements load — e.g. a required - // document is still missing, so it must not skip ahead to Business License), + // document is still missing, so it must not skip ahead to the documents step), // adopt it, but only while the user hasn't started navigating themselves. const lastInitialStep = useRef(initialStep); useEffect(() => { @@ -167,22 +206,36 @@ export default function CompanyProfileForm({ const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; - const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( + const { data: nationalitySetting, isLoading: loadingDocuments } = useQuery( api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false, }), ); - - // A freight forwarder signs on other companies' behalf, so its Power of - // Attorney (details + DARS delegation paper) is mandatory rather than optional. - const requirePoa = (roleProfiles ?? []).some( - (p) => p.type === "freight_forwarder", + // A co-operative's own documents come as a second, additive set — it uploads + // everything its nationality demands, plus the papers standing in for the + // business licence it does not hold. The API merges the same two sets when it + // decides what is outstanding. + const { data: extraSetting } = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: extraDocumentSettingCode ?? "" }, + enabled: Boolean(extraDocumentSettingCode), + refetchOnMount: false, + }), ); - // 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 uploadSetting = useMemo(() => { + if (!nationalitySetting) return nationalitySetting; + if (!extraSetting?.fields?.length) return nationalitySetting; + // Nationality wins a fileKey collision, so a slot is never rendered twice. + const seen = new Set(nationalitySetting.fields.map((f) => f.fileKey)); + return { + ...nationalitySetting, + fields: [ + ...nationalitySetting.fields, + ...extraSetting.fields.filter((f) => !seen.has(f.fileKey)), + ], + }; + }, [nationalitySetting, extraSetting]); // Which fields the current step renders an input for and therefore requires. // Filled in further down (it depends on values this form owns), and read at @@ -192,16 +245,15 @@ export default function CompanyProfileForm({ const form = useForm({ resolver: (values, context, options) => - zodResolver( - buildOnboardingSchema( - identity?.passportRequired === true, - requiredKeysRef.current, - ), - )(values, context, options), + zodResolver(buildOnboardingSchema(requiredKeysRef.current))( + values, + context, + options, + ), // `values` below re-seeds the form whenever the profile is refetched — and - // an in-page identity action (ticking "same as owner") refetches it. Without - // this, that reset silently throws away whatever the customer was part-way - // through typing on the current step. + // an in-page identity action (answering the PoA question) refetches it. + // Without this, that reset silently throws away whatever the customer was + // part-way through typing on the current step. resetOptions: { keepDirtyValues: true, keepErrors: true }, defaultValues: { companyName: "", @@ -210,6 +262,7 @@ export default function CompanyProfileForm({ tinNumber: "", vatNumber: "", ownerPassportNumber: "", + poaPassportNumber: "", licenceNumber: "", statusDescription: "", dateRegistered: "", @@ -225,12 +278,11 @@ export default function CompanyProfileForm({ contactPersonPosition: "", contactPersonEmail: "", contactPersonPhone: "", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", + ownerName: "", + ownerEmail: "", + ownerPhone: "", poaName: "", poaPhone: "", - poaAddress: "", poaEmail: "", poaLocation: "", }, @@ -248,14 +300,23 @@ export default function CompanyProfileForm({ formState: { dirtyFields }, } = form; - // The contact person's email still just seeds from the account and stays editable. + // Retire the alert the moment the customer starts acting on it. It was only + // ever cleared on navigation, so a red "fix these fields" banner sat above a + // form they had already fixed, right until they pressed Continue again — + // which reads as an error the page is refusing to let go of. useEffect(() => { - if (!user?.email) return; - if (!watch("contactPersonEmail")) { - setValue("contactPersonEmail", user.email); - } + const sub = watch(() => setSaveError(null)); + return () => sub.unsubscribe(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user?.email, rehydrate]); + }, []); + + // Nothing seeds the contact person's email. It used to be prefilled from the + // signed-in account, on the same assumption the owner fields were cleared of: + // the person doing the onboarding is routinely not the person the company + // wants contacted. Prefilled and editable is still prefilled — it is accepted + // as-is far more often than it is corrected, so the account address ends up on + // file as the company's contact by default rather than by anyone's decision. + // The field is optional, so an empty one costs nothing. // Keep the (hidden, derived) company address in sync with the editable address // fields — so it reflects both the eTrade auto-fill and any later user edits, @@ -269,22 +330,63 @@ export default function CompanyProfileForm({ const composed = [houseNo, kebele, woreda, zone, region] .filter((part) => part && part.trim()) .join(", "); + // No parts means nothing to compose from — the lookup hasn't landed yet, or + // this is the render before rehydration. Writing "" here would replace a + // saved address with a blank on the next company-step save. + if (!composed) return; setValue("companyAddress", composed); // eslint-disable-next-line react-hooks/exhaustive-deps }, [region, zone, woreda, kebele, houseNo]); - // The business owner/manager pulled from eTrade — powers "Use owner as - // manager" on the General Manager step. Null until a TIN lookup succeeds. - const [etradeOwner, setEtradeOwner] = useState<{ + // The manager eTrade lists for this licence, as returned by a lookup made in + // THIS session. This is the person the owner step is about — "owner" here + // means whoever the licence names, and the backoffice checks the stored owner + // against exactly this. + const [liveEtradeOwner, setLiveEtradeOwner] = useState<{ name: string; phone: string; } | null>(null); + // The TIN moved off the last verified lookup, so the persisted manager below + // describes a licence this company is no longer claiming. Without this, a + // reset would re-lock the owner fields against stale data the moment the + // memo below fell back to the server's copy. + const [etradeCleared, setEtradeCleared] = useState(false); + + /** + * The eTrade manager, live lookup or not. + * + * The lookup result dies with the page, but the fact that the owner came from + * eTrade must not — a resumed wizard that has forgotten it renders the + * licence's own name and phone as empty, typeable inputs, which is both a + * regression of the read-only rule and an invitation to overwrite the record + * the backoffice checks against. The API captured the manager at lookup time + * for exactly this, so a resume reads it back from the identity state. + */ + const etradeOwner = useMemo(() => { + if (liveEtradeOwner) return liveEtradeOwner; + if (etradeCleared) return null; + const name = identity?.etradeManagerName?.trim() ?? ""; + // Normalized on the way out of storage, and dropped if it cannot be — the + // stored value is eTrade's free text, and a resume must reach the same + // conclusion about it as the lookup did. + const phone = firstValidPhone(identity?.etradeManagerPhone); + return name || phone ? { name, phone } : null; + }, [ + liveEtradeOwner, + etradeCleared, + identity?.etradeManagerName, + identity?.etradeManagerPhone, + ]); + + /** A lookup has actually filled the registration fields this session. */ + const etradeFilledRef = useRef(false); // `shouldDirty` is what marks the eTrade bundle as "re-verified this session"; // `stepPayload` sends those keys only when dirty, so an unchanged record is // never echoed back to the API (which would make it re-query eTrade). const handleETradeDataLoaded = (data: CompanyRegistrationData) => { const dirty = { shouldDirty: true } as const; + etradeFilledRef.current = true; if (data.companyName) { setValue("companyName", data.companyName, { shouldValidate: true, @@ -302,32 +404,66 @@ export default function CompanyProfileForm({ setValue("woreda", data.woreda, dirty); setValue("kebele", data.kebele, dirty); setValue("houseNo", data.houseNo, dirty); - // companyAddress is composed reactively from the address fields below, so - // setting region/zone/woreda/kebele/houseNo above is enough — no need to - // compose it here. companyPhone is derived below (identity → eTrade → - // account), not set directly here. + // companyAddress is composed reactively from the address fields above. // etradePhone is the raw number eTrade returned for this TIN — kept as its - // own field (distinct from companyPhone, which prefers the Fayda-verified - // owner's phone) so the backend's "matches eTrade's current record" check - // always compares against what eTrade actually said, not the owner's phone. + // own field (distinct from the owner's phone) so the backend's "matches + // eTrade's current record" check always compares against what eTrade + // actually said. setValue( "etradePhone", data.managerPhone || data.regularPhone || data.mobilePhone, dirty, ); - setEtradeOwner({ + // The owner's phone is only eTrade's to own if eTrade gave a usable one. + // The licence desk's field is free text, so it may hold "09 " — which + // normalizes to something non-empty and invalid. Dropping it here leaves the + // owner step with an empty, required, editable phone input, which is the + // honest state: eTrade has nothing we can use. + const owner = { name: data.managerName, - phone: toEthiopianE164( + phone: firstValidPhone( data.managerPhone || data.regularPhone || data.mobilePhone, ), - }); + }; + setEtradeCleared(false); + setLiveEtradeOwner(owner); + + // What eTrade said, verbatim — this replaces whatever is in the field + // rather than only filling a gap. The owner step shows an eTrade-sourced + // name and phone read-only, so leaving an older value in place would send + // the API something the customer is no longer shown and cannot correct. + // + // A Fayda verification still outranks the licence: it owns those fields + // server-side, so overwriting them here would only produce a value the API + // discards on the way in. + if (owner.name && !identity?.owner.name?.trim()) { + setValue("ownerName", owner.name, { shouldValidate: true, ...dirty }); + } + if (owner.phone && !identity?.owner.phone?.trim()) { + setValue("ownerPhone", owner.phone, { shouldValidate: true, ...dirty }); + } }; // TIN changed since the last successful lookup — the registration/address // fields it filled in describe the OLD TIN, not this one, so clear them // rather than leaving them stale on screen. + // + // The owner goes too: different licences under one TIN can list different + // managers, so a prefill from the previous pick is someone else's name. + // Only the prefill is cleared — a Fayda-verified owner is the API's to own. const handleETradeReset = () => { + // ...unless there was no prefill to clear. A co-operative now runs the same + // lookup as everyone else, but it types these fields itself when eTrade + // holds nothing — and wiping them because the customer went back to fix a + // digit of their TIN would throw away an address they had just typed by + // hand, over a lookup that never filled anything in the first place. + if (cooperative && !etradeFilledRef.current) { + setLiveEtradeOwner(null); + setEtradeCleared(true); + return; + } + etradeFilledRef.current = false; setValue("licenceNumber", ""); setValue("statusDescription", ""); setValue("dateRegistered", ""); @@ -340,273 +476,159 @@ export default function CompanyProfileForm({ setValue("kebele", ""); setValue("houseNo", ""); setValue("etradePhone", ""); - setEtradeOwner(null); - }; - - // "Same as …" links. A checked card prefills the target step's fields from the - // source step and disables them (kept mirrored while linked); unchecking clears - // them and re-enables editing. - // Seeded from the server so a resumed draft reopens with the declaration the - // company already made, rather than an unticked box over a linked GM. - const [gmSameAsOwner, setGmSameAsOwner] = useState( - identity?.gmSameAsOwner ?? false, - ); - // `identity` is undefined on the first render (the requirements query is still - // in flight), so the initial state above freezes at `false` — adopt the - // server's declaration the moment it lands, or a resumed draft shows an - // unticked box over a GM that is linked server-side. - const [poaSameAsOwner, setPoaSameAsOwner] = useState( - identity?.poaSameAsOwner ?? false, - ); - const identityLoaded = useRef(false); - useEffect(() => { - if (!identity || identityLoaded.current) return; - identityLoaded.current = true; - setGmSameAsOwner(identity.gmSameAsOwner); - setPoaSameAsOwner(identity.poaSameAsOwner); - }, [identity]); - const [contactSameAsGm, setContactSameAsGm] = useState(false); - - // Where the owner's details come from when they are copied onto someone else - // — the GM, or the representative. A Fayda-verified owner outranks eTrade's - // registered owner: it's the higher-trust source, and the whole point of - // proving identity is to stop trusting typed/looked-up data for this. - const ownerSourceName = firstPresent( - identity?.owner.name, - etradeOwner?.name, - user.name?.en, - ); - - const ownerSourceEmail = firstValidEmail(identity?.owner.email, user.email); - // Same reason as `derivedPhone`: this value is written into - // `generalManagerPhone` / `poaPhone`, which the API validates with - // `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead. - const ownerSourcePhone = firstValidPhone( - identity?.owner.phone, - etradeOwner?.phone, - user.phoneNumber, - ); - - useEffect(() => { - if (!gmSameAsOwner) return; - // A verified owner's identity is copied server-side and read back from - // `identity.gm`; mirroring it into form fields here would send typed - // values for something the API already owns. - if (identity?.owner.verified) return; - setValue("generalManagerName", ownerSourceName, { shouldValidate: true }); - setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true }); - setValue("generalManagerPhone", ownerSourcePhone, { - shouldValidate: true, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gmSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]); - - // The representative's half of the same copy. A verified owner's identity is - // copied server-side and read back from `identity.poa`, so only an owner - // backed by a typed passport is mirrored into form fields here — the same - // split the GM makes above, for the same reason. - // - // Only non-empty sources are written. A source the owner does not have is a - // gap the step renders an input for (see `poaGaps`), and this effect re-runs - // whenever any *other* source changes — so blanking here would wipe what the - // customer is typing into that input the moment an eTrade lookup lands. - useEffect(() => { - if (!poaSameAsOwner || identity?.owner.verified) return; - if (ownerSourceName) setValue("poaName", ownerSourceName, { shouldValidate: true }); - if (ownerSourceEmail) setValue("poaEmail", ownerSourceEmail, { shouldValidate: true }); - if (ownerSourcePhone) setValue("poaPhone", ownerSourcePhone, { shouldValidate: true }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [poaSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]); - - /** - * "Same as owner" has two meanings depending on what backs the owner. - * - * A Fayda-verified owner is a proven identity, so the declaration is made - * server-side: the API copies that identity onto the GM and records what it - * did. Anything typed here would arrive wearing a verified badge it hadn't - * earned, which is exactly what the verification exists to prevent. - * - * A foreign company's owner is backed by a typed passport instead, so there - * is nothing proven to copy — that stays the local field-mirroring it has - * always been. - */ - const [gmLinkPending, setGmLinkPending] = useState(false); - const toggleGmSameAsOwner = async (checked: boolean) => { - setGmSameAsOwner(checked); if (!identity?.owner.verified) { - if (!checked) { - setValue("generalManagerName", ""); - setValue("generalManagerEmail", ""); - setValue("generalManagerPhone", ""); - } - return; - } - - setGmLinkPending(true); - try { - if (checked) await verifaydaService.setGmSameAsOwner(); - else await verifaydaService.clearGmIdentity(); - onIdentityChange?.(); - } catch (err) { - setGmSameAsOwner(!checked); - setSaveError( - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? - (err instanceof Error - ? err.message - : "Could not update the general manager"), - ); - } finally { - setGmLinkPending(false); + if (getValues("ownerName") === etradeOwner?.name) setValue("ownerName", ""); + if (getValues("ownerPhone") === etradeOwner?.phone) + setValue("ownerPhone", ""); } + setLiveEtradeOwner(null); + setEtradeCleared(true); }; /** - * The representative is the owner. Unlike the GM's card this always goes to - * the API, whichever backs the owner: the declaration itself is what waives - * the DARS delegation paper, so it has to be recorded server-side even when - * there is no proven identity to copy and the details are mirrored locally. + * Answer the power-of-attorney question. + * + * Persisted server-side rather than held in form state: the answer decides + * whose identity the API gates on, and answering "no" tears down any + * representative already recorded (details, verification and DARS paper + * together) — none of which the form could do on its own. */ - const [poaLinkPending, setPoaLinkPending] = useState(false); - const togglePoaSameAsOwner = async (checked: boolean) => { + const [declarePending, setDeclarePending] = useState(false); + const handleDeclare = async (declared: PoaDeclaration) => { setSaveError(null); - setPoaSameAsOwner(checked); - setPoaLinkPending(true); + setDeclarePending(true); try { - if (checked) await verifaydaService.setPoaSameAsOwner(); - else { - await verifaydaService.clearPoaSameAsOwner(); - // Only the locally mirrored values are ours to clear; a copied identity - // is cleared by the call above. - if (!identity?.owner.verified) { - setValue("poaName", ""); - setValue("poaEmail", ""); - setValue("poaPhone", ""); + await verifaydaService.setPoaDeclared(declared); + if (declared === "no") { + // The paper is deleted server-side with the representative; a copy + // still sitting in the picker would be re-uploaded on the next step. + setDocumentFiles({ ...documentFiles, [POA_DELEGATION_FILE_KEY]: null }); + // The representative's details go the same way. Answering "no" nulls + // every PoA attribute server-side, but the refetch below cannot undo + // what is still in the form: `keepDirtyValues` protects exactly the + // values the customer typed, so the next step save would send them + // straight back — and the API accepts them without complaint, because + // its PoA rules only run when the declaration is "yes". The company + // ended up on file with no representative and a full set of their + // details. + for (const field of [ + "poaName", + "poaEmail", + "poaPhone", + "poaLocation", + "poaPassportNumber", + ] as const) { + setValue(field, "", { shouldDirty: false, shouldValidate: false }); } } onIdentityChange?.(); } catch (err) { - setPoaSameAsOwner(!checked); - setSaveError( + failSave( (err as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? (err instanceof Error ? err.message - : "Could not update the Power of Attorney"), + : "Could not save your answer"), ); } finally { - setPoaLinkPending(false); + setDeclarePending(false); } }; - // Where the GM's details come from depends on how they were established: a - // Fayda verification (or a "same as owner" declaration) owns them outright, - // and only a company that may still type them falls back to form state. - // A verified GM's identity wins, but Fayda's email and phone claims are - // optional: what the verification did not supply is typed on this step, and - // the API deliberately does not read those back onto the identity (they are - // not proven), so the form value is the only place they exist. - const gmVerified = identity?.gm.verified ?? false; - const gmName = gmVerified - ? firstPresent(identity?.gm.name, watch("generalManagerName")) - : watch("generalManagerName"); - const gmEmail = gmVerified - ? firstPresent(identity?.gm.email, watch("generalManagerEmail")) - : watch("generalManagerEmail"); - const gmPhone = gmVerified - ? firstPresent(identity?.gm.phone, watch("generalManagerPhone")) - : watch("generalManagerPhone"); - /** - * Whether the GM has been established at all — by verification, by the - * "same as owner" declaration, or (only where Fayda is optional) by typing. - * Fayda is an Ethiopian national ID, so a foreign company's GM may hold none. + * Who owns each of the owner's fields — and therefore which of them are shown + * read-only rather than as inputs. See `resolveOwnerSources`; the rule it + * encodes pairs with `requiredKeys` below, which requires exactly the fields + * no source owns. */ - // Matches what the API actually demands (`REQUIRED_COMPANY_INFO`): a name and - // a phone. The email is collected but optional — a manager proved through - // Fayda may have no email claim, and the notify resolver no longer needs one. - const gmTyped = Boolean( - watch("generalManagerName")?.trim() && - watch("generalManagerPhone")?.trim(), + const ownerVerified = identity?.owner.verified ?? false; + const poaVerified = identity?.poa.verified ?? false; + const usableEmail = (value?: string | null) => Boolean(firstValidEmail(value)); + const usablePhone = (value?: string | null) => Boolean(firstValidPhone(value)); + const { source: ownerSource, sourced: ownerSourced } = resolveOwnerSources( + identity, + etradeOwner, ); - const gmEstablished = - gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped); - /** - * Same rule for the representative: verified, or entered where Fayda is - * optional. - * - * Matches the API's own rule (`REQUIRED_POA_FIELDS`): a typed representative - * counts once they have a name, an email and a phone. The step now renders - * inputs for all three, so this is something the customer can actually - * satisfy — previously it gated on `poaName`, for which no input existed - * anywhere, leaving a foreign freight forwarder permanently stuck. - */ - const poaTyped = Boolean( - watch("poaName")?.trim() && - watch("poaEmail")?.trim() && - watch("poaPhone")?.trim(), - ); - const poaEstablished = - (identity?.poa.verified ?? false) || - (identity ? !identity.faydaRequired && poaTyped : false); - - /** - * Drop an optional representative the company no longer wants. - * - * Verifying a PoA is one click on a step that calls itself optional, and it - * is not reversible from the form: the verification owns the fields (so - * blanking them is refused), and its mere existence makes the DARS paper due - * — which then blocks the submit AND clamps the resume back to this step. The - * settings page has the same escape hatch, but `/settings` is off-limits - * until onboarding finishes, so without this the customer is stuck. - * - * Not offered to a freight forwarder: the API refuses (they must have one). - */ - const [poaRemovePending, setPoaRemovePending] = useState(false); - const removePoa = async () => { - setSaveError(null); - setPoaRemovePending(true); - try { - await verifaydaService.removePoa(); - for (const key of [ - "poaName", - "poaEmail", - "poaPhone", - "poaAddress", - "poaLocation", - ] as const) { - setValue(key, "", { shouldDirty: false }); - } - // The paper is deleted server-side with the identity; a copy still - // sitting in the picker would be re-uploaded on the documents step. - setDocumentFiles({ ...documentFiles, [POA_DELEGATION_FILE_KEY]: null }); - onIdentityChange?.(); - } catch (err) { - setSaveError( - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? - (err instanceof Error - ? err.message - : "Could not remove the Power of Attorney"), - ); - } finally { - setPoaRemovePending(false); + // Keep the form holding exactly what the read-only rows show. The two are + // filled from different places — the rows from the licence, the fields from + // the lookup that ran or from the rehydrated profile — and only the fields + // are submitted, so a divergence would send the API something the customer + // was never shown and had no input to correct. eTrade wins it, which is what + // "not editable" has to mean for a value already on file. + useEffect(() => { + if (ownerSource.name === "eTrade" && getValues("ownerName") !== ownerSourced.name) { + setValue("ownerName", ownerSourced.name, { shouldValidate: true }); } + if ( + ownerSource.phone === "eTrade" && + getValues("ownerPhone") !== ownerSourced.phone + ) { + setValue("ownerPhone", ownerSourced.phone, { shouldValidate: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ownerSource.name, ownerSource.phone, ownerSourced.name, ownerSourced.phone]); + // No `address`: Fayda's address claim is `poaAddress`, which the portal never + // sends — the step's only address-shaped input writes `poaLocation`, which is + // the company's own statement and stays typeable however well Fayda knows + // where the person lives. + // Same validity test as the owner's: a Fayda claim the schema would reject is + // not a claim this step can hide the input behind. + const poaLocked = { + name: poaVerified && Boolean(identity?.poa.name?.trim()), + email: poaVerified && usableEmail(identity?.poa.email), + phone: poaVerified && usablePhone(identity?.poa.phone), }; + /** + * How a foreign company chose to prove its subject: Fayda, or a passport. + * + * An either/or rather than a fallback, so nothing is asked until one side is + * picked. Seeded from what already happened — a completed verification or a + * saved passport number is itself the answer — and only then held locally, + * because the choice is a UI fork with nothing to persist: what the API + * stores is the proof, not the route taken to it. + */ + const [identityMethod, setIdentityMethod] = useState( + null, + ); + const passportSaved = Boolean( + identity?.subject === "poa" + ? identity?.poa.passportNumber?.trim() + : identity?.owner.passportNumber?.trim(), + ); + const subjectVerified = identity?.subject === "poa" ? poaVerified : ownerVerified; + const effectiveMethod: IdentityMethod | null = !identity?.passportAccepted + ? "fayda" // An Ethiopian company has no choice to make. + : subjectVerified + ? "fayda" + : (identityMethod ?? (passportSaved ? "passport" : null)); + + // The owner's name from whichever source established them — powers the + // contact step's "same as owner" card. + // + // Email and phone are picked by validity, not mere presence. Both are copied + // into contact fields the schema requires to be well-formed, and a Fayda + // claim or an eTrade record routinely carries something that is neither empty + // nor usable ("09 "). Taking it would fail the contact step on a value + // the customer never typed and — while the copy is linked — cannot edit. + const ownerName = firstPresent(identity?.owner.name, watch("ownerName")); + const ownerEmail = firstValidEmail(identity?.owner.email, watch("ownerEmail")); + const ownerPhone = firstValidPhone(identity?.owner.phone, watch("ownerPhone")); + + const [contactSameAsOwner, setContactSameAsOwner] = useState(false); // While linked, mirror the source values into the (disabled) target fields so // the copy stays current even if the user goes back and edits the source. useEffect(() => { - if (!contactSameAsGm) return; - setValue("contactPersonName", gmName ?? "", { shouldValidate: true }); - setValue("contactPersonEmail", gmEmail ?? ""); - setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true }); + if (!contactSameAsOwner) return; + setValue("contactPersonName", ownerName, { shouldValidate: true }); + setValue("contactPersonEmail", ownerEmail); + setValue("contactPersonPhone", ownerPhone, { shouldValidate: true }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contactSameAsGm, gmName, gmEmail, gmPhone]); + }, [contactSameAsOwner, ownerName, ownerEmail, ownerPhone]); - const toggleContactSameAsGm = (checked: boolean) => { - setContactSameAsGm(checked); + const toggleContactSameAsOwner = (checked: boolean) => { + setContactSameAsOwner(checked); // Checked → the mirror effect fills the fields; unchecked → reset them. if (!checked) { setValue("contactPersonName", ""); @@ -616,10 +638,10 @@ export default function CompanyProfileForm({ }; // 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. + // rest (the API guarantees it is there), but belongs on the representation + // 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, ); @@ -643,8 +665,6 @@ export default function CompanyProfileForm({ [uploadSetting, poaDocumentField], ); - const hasDocuments = Boolean(documentsSetting?.fields?.length); - // Hard verification for the documents step: required company-level // documents and a business license per operational profile must both be // present before the user can continue. @@ -670,9 +690,12 @@ export default function CompanyProfileForm({ return errs; }; - // Every role needs at least one license file (existing or newly selected). + // Every role needs at least one license file (existing or newly selected) — + // except a co-operative's, which holds no business licence at all. Its own + // document set is what stands in, and the API lifts the same requirement. const validateLicenses = (): Record => { const errs: Record = {}; + if (cooperative) return errs; for (const p of roleProfiles ?? []) { const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0; const hasExisting = p.existingFiles.length > 0; @@ -687,6 +710,9 @@ export default function CompanyProfileForm({ next: Record, ) => { setDocumentFiles(next); + // Files live outside the form, so the watch subscription above never sees + // them — the "upload the required documents" alert has to be retired here. + setSaveError(null); setDocumentFieldErrors((prev) => { if (Object.keys(prev).length === 0) return prev; const updated = { ...prev }; @@ -713,8 +739,7 @@ export default function CompanyProfileForm({ // The registration/license details come straight from the eTrade lookup and // are not user-editable — shown as a read-only confirmation once a TIN lookup - // (or rehydration) has filled them in. The address fields below are separate: - // user-entered and required. We watch the values so the display stays current. + // (or rehydration) has filled them in. const registration = watch([ "licenceNumber", "statusDescription", @@ -726,57 +751,26 @@ export default function CompanyProfileForm({ const hasRegistrationDetails = registration.some((v) => v && v.trim()); // A previously-saved (rehydrated) TIN counts as verified without a refetch — // the registration fields being populated at all is proof it passed before. - const tinVerified = tinStatus === "verified" || hasRegistrationDetails; + // A co-operative never runs the lookup, so there is nothing to be verified + // against; its TIN is validated by the schema like any other typed field. + const tinVerified = + cooperative || tinStatus === "verified" || hasRegistrationDetails; // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. const stepOrder: CompanyStep[] = [ "company", - "personnel", + "owner", + "representation", "contact", - "poa", "documents", ]; const currentIdx = stepOrder.indexOf(step); - // The DARS delegation paper is what proves the representative was actually - // delegated, so it's required the moment a PoA exists. The API enforces the - // same rule on save, so skipping it here only costs the customer a - // round-trip. - // - // "Exists" is the API's own test (`POA_ATTRIBUTES.some(...)`): ANY detail, - // verified or typed. Requiring a complete typed representative here instead - // hid the upload from a customer who had entered only a name — for whom the - // API still demands the paper, and whose resume would then be clamped back to - // this step with nothing on it to fill. - const poaAnyDetail = [ - identity?.poa.name, - identity?.poa.email, - identity?.poa.phone, - identity?.poa.address, - watch("poaName"), - watch("poaEmail"), - watch("poaPhone"), - watch("poaLocation"), - ].some((v) => v?.trim()); - const poaProvided = (identity?.poa.verified ?? false) || poaAnyDetail; - // A freight forwarder owes the paper whether or not its representative could - // verify with Fayda — the API demands it at completion either way. Keying - // this on the verification alone hid the upload from a foreign forwarder and - // then failed them on submit for a file they were never shown. - // - // Unless the owner represents the company themselves: nobody delegates to - // themselves, so there is no delegation to evidence. Mirrors the API's own - // waiver in `assertPoaDelegationSatisfied` — the two must agree, or this - // demands a file the server would accept the submission without. - // - // Split from `poaDue` — "there is a representative, so their details are - // owed" — because a self-PoA keeps the second while dropping the first. The - // API draws the same line (`poaDue` / `delegationDue` in - // getOnboardingRequirements); anything that is about the *details* must key - // on `poaDue`, only the paper keys on this. - const poaDue = poaProvided || requirePoa; - const delegationRequired = poaDue && !poaSameAsOwner; + // The DARS delegation paper is owed exactly when the company says it has a + // representative. The API enforces the same rule on save, so skipping it here + // only costs the customer a round-trip. + const delegationRequired = identity?.poaDeclared === "yes"; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -785,93 +779,44 @@ export default function CompanyProfileForm({ })(); /** - * What a Fayda verification did NOT supply, per person. + * Is the company's one identity proven? * - * Fayda's email, phone and address claims are optional and routinely come - * back empty, so a *verified* person can still be missing details the API - * demands (`REQUIRED_POA_FIELDS`, `REQUIRED_COMPANY_INFO`). Those gaps are - * typed instead — the API keeps exactly the keys a claim left empty typeable, - * since a claim that returned nothing owns no value to protect. - * - * Computed here, once, and handed to both the step (which renders an input - * per gap) and the schema (which requires exactly those) — a field is - * required if and only if there is an input on screen to fix it in. + * `identity.identityProven` is the server's verdict, but it is a step behind + * a passport number the customer has just typed and not yet saved — so the + * live form value counts too. Blocking on the stale server value would refuse + * to advance past a field the customer has visibly filled in. */ - // Where Fayda is mandatory an unverified representative must verify rather - // than be typed, so nothing is offered until the verification lands. - const poaTypedAllowed = - !identity || identity.poa.verified || !identity.faydaRequired; - // A representative the verification never proved holds only typed details — - // the API leaves those unlocked, so the inputs stay on screen and stay - // editable. Only a *verified* PoA hides the fields their claim did fill, - // which is also the only case the API refuses to let anyone overwrite. - const poaGap = (v?: string | null) => - poaTypedAllowed && (!identity?.poa.verified || !v?.trim()); - /** - * "Same as owner" answers each field only as far as the owner actually has - * one. Fayda's name, email and phone claims are all optional, the account and - * eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still - * demands a name, an email and a phone — so anything the copy could not - * supply stays askable. Assuming the copy filled everything is what dead-ends - * the submit on "Add your poa phone" with no input anywhere to satisfy it. - * - * Keyed on the *source*, never on the field's current value: an input that - * disappears the moment the first character is typed into it is unusable. - * A verified owner's identity is copied server-side, so `identity.poa` is the - * source there; otherwise it is the same owner-derived values the mirror - * effect writes. - */ - const poaCopyGap = (copied?: string | null, mirrored?: string | null) => - identity?.owner.verified ? !copied?.trim() : !mirrored?.trim(); - const poaGaps = poaSameAsOwner - ? { - name: poaCopyGap(identity?.poa.name, ownerSourceName), - email: poaCopyGap(identity?.poa.email, ownerSourceEmail), - phone: poaCopyGap(identity?.poa.phone, ownerSourcePhone), - // The location is the one detail the API never demands, so a blank one - // dead-ends nothing — and asking for the owner's city under a card that - // says "same as owner" reads as a contradiction. - address: false, - } - : { - name: poaGap(identity?.poa.name), - email: poaGap(identity?.poa.email), - phone: poaGap(identity?.poa.phone), - address: poaGap(identity?.poa.address), - }; - // The GM's own verification never falls back to the signed-in account — that - // account is the person onboarding, not necessarily the manager — so a GM - // verified with no email claim has nowhere else for one to come from. The - // name claim is optional too, and `REQUIRED_COMPANY_INFO` demands it, so it - // gets the same treatment rather than dead-ending the submit. - // "Same as owner" is exempt: the API copies the owner's (account-backed) - // contact details across, so there is no gap and no input. - const gmGaps = { - name: !gmSameAsOwner && gmVerified && !identity?.gm.name?.trim(), - email: !gmSameAsOwner && gmVerified && !identity?.gm.email?.trim(), - phone: !gmSameAsOwner && gmVerified && !identity?.gm.phone?.trim(), - }; + const passportField = + identity?.subject === "poa" ? "poaPassportNumber" : "ownerPassportNumber"; + const identityProven = + (identity?.identityProven ?? false) || + ((identity?.passportAccepted ?? false) && + Boolean(watch(passportField)?.trim())); const requiredKeys: (keyof FormData)[] = []; - if (step === "personnel") { - // The manager's email is offered but not demanded: the API dropped it from - // `REQUIRED_COMPANY_INFO` once the notify resolver stopped depending on it. - // The name and phone are still required there, so they are still required - // here. - if (gmGaps.name) requiredKeys.push("generalManagerName"); - if (gmGaps.phone) requiredKeys.push("generalManagerPhone"); - } else if (step === "poa" && poaDue) { - // Only once a PoA is required or provided: an untouched optional PoA is - // still a step the customer may walk straight past. - // - // `poaDue`, NOT `delegationRequired`: the paper is waived for a self-PoA - // but `REQUIRED_POA_FIELDS` is not, and the API reports every one of them - // missing (`missingPoaFields` keys on its own `poaDue`) — which fails the - // submit and clamps the resume back here. Keying this on the paper let the - // customer walk past an input this step had already put on screen. - if (poaGaps.name) requiredKeys.push("poaName"); - if (poaGaps.email) requiredKeys.push("poaEmail"); - if (poaGaps.phone) requiredKeys.push("poaPhone"); + if (step === "company" && cooperative) { + // A co-operative has no eTrade record, so the fields every other company + // gets read-only from the licence are typed here — and are therefore + // required here. House number stays optional: plenty of addresses have none. + requiredKeys.push("companyName", "region", "zone", "woreda", "kebele"); + } else if (step === "owner") { + // All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input + // is rendered for each one a Fayda verification does not own. + if (!ownerSource.name) requiredKeys.push("ownerName"); + if (!ownerSource.email) requiredKeys.push("ownerEmail"); + if (!ownerSource.phone) requiredKeys.push("ownerPhone"); + } else if ( + step === "representation" && + identity?.poaDeclared === "yes" && + // The details are only on screen once the person is established — before + // that the step is still asking how to prove them, and requiring a name + // with no input rendered is the dead Continue button this rule exists to + // prevent. + (poaVerified || effectiveMethod === "passport") + ) { + if (!poaLocked.name) requiredKeys.push("poaName"); + if (!poaLocked.email) requiredKeys.push("poaEmail"); + if (!poaLocked.phone) requiredKeys.push("poaPhone"); } requiredKeysRef.current = requiredKeys; @@ -879,19 +824,15 @@ export default function CompanyProfileForm({ * Collect the messages for a set of fields into one sentence. * * A failed `trigger()` used to return silently, so Continue simply did - * nothing — and every field whose input is conditionally rendered (or derived - * and never rendered at all) turned into an invisible dead end. Naming the - * failures is the whole point: the ones worth reporting are exactly the ones - * with no error text on screen to read. + * nothing — and every field whose input is conditionally rendered turned into + * an invisible dead end. Naming the failures is the whole point: the ones + * worth reporting are exactly the ones with no error text on screen to read. */ const describeErrors = (fields: (keyof FormData)[]): string => { // Re-parse rather than read `errors`: that's the render-time snapshot, and // this runs immediately after an `await trigger()` that has not re-rendered // yet, so the closure would still be holding the previous attempt's state. - const parsed = buildOnboardingSchema( - identity?.passportRequired === true, - requiredKeys, - ).safeParse(getValues()); + const parsed = buildOnboardingSchema(requiredKeys).safeParse(getValues()); const wanted = new Set(fields as string[]); const messages = parsed.success ? [] @@ -903,24 +844,13 @@ export default function CompanyProfileForm({ : "Some details on this step are incomplete. Please review the fields above."; }; - /** - * The fields this step actually validates. `stepFields` covers what the step - * always renders; the company step additionally exposes company email/phone - * as inputs when nothing could be derived for them, and a field is validated - * exactly when the customer can see and fix it. - */ - const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => { - if (s !== "company" || !identity) return stepFields[s]; - return [...stepFields.company]; - }; - /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { setSaveError(null); - const fields = fieldsForStep(step); + const fields = stepFields[step]; const isValid = await trigger(fields); if (!isValid) { - setSaveError(describeErrors(fields)); + failCheck(describeErrors(fields)); return false; } if (!onSaveStep) return true; @@ -928,7 +858,7 @@ export default function CompanyProfileForm({ try { const res = await onSaveStep(stepPayload(step, getValues(), dirtyFields)); if (!res.ok) { - setSaveError(res.error); + failSave(res.error); return false; } return true; @@ -951,7 +881,7 @@ export default function CompanyProfileForm({ ) { setDocumentFieldErrors(docErrors); setLicenseFieldErrors(licenseErrors); - setSaveError("Please upload all required documents before continuing."); + failCheck("Please upload all required documents before continuing."); return; } @@ -960,7 +890,7 @@ export default function CompanyProfileForm({ try { const res = await onUploadDocuments(); if (!res.ok) { - setSaveError(res.error); + failSave(res.error); return; } } finally { @@ -969,92 +899,78 @@ export default function CompanyProfileForm({ } setSaveError(null); - // Deliberately NOT `handleSubmit`: that re-validated all 34 schema fields - // — including every field belonging to a step that isn't on screen — and - // on failure did nothing at all, no alert and no navigation, which is the + // Deliberately NOT `handleSubmit`: that re-validated every schema field — + // including every field belonging to a step that isn't on screen — and on + // failure did nothing at all, no alert and no navigation, which is the // "Submit for review" button that appears dead. Each step has already - // validated and saved its own fields, and the API's `markOnboardingComplete` - // is the authority on what is still outstanding; its message reaches the - // customer through `submitError`. + // validated and saved its own fields, and the API's + // `markOnboardingComplete` is the authority on what is still outstanding; + // its message reaches the customer through `submitError`. onSubmit(buildPayload(getValues(), user)); return; } // The TIN must resolve to a real eTrade record before anything else on // this step is even worth validating — gates here rather than through zod. + // A co-operative is exempt: it has no licence for eTrade to hold, so + // `tinVerified` is true for it and only the duplicate-TIN check applies. if (step === "company" && tinStatus === "taken") { - setSaveError( + failCheck( "This TIN is already registered to another company account.", ); return; } if (step === "company" && !tinVerified) { - setSaveError( + failCheck( tinStatus === "choose-business" ? "This TIN holds more than one business licence — pick the one you're registering as." : "We need to confirm your TIN with eTrade before continuing.", ); return; } - // Fayda verification is proved outside the form state, so it gates here - // rather than through zod. The passport number is a plain typed field — - // buildOnboardingSchema already requires it when passportRequired, so - // saveCurrentStep()'s trigger() below catches that; checking the stale - // server-side identity.owner.passportNumber here would block a value the - // user just typed but hasn't saved yet. - if ( - step === "company" && - identity?.faydaRequired && - !identity.owner.verified - ) { - setSaveError( - "Verify the company owner's identity with Fayda before continuing.", + // The declaration decides whose identity is verified, so it has to be + // answered before the verification below can mean anything. + if (step === "representation" && !identity?.poaDeclared) { + failCheck( + "Tell us whether anyone holds power of attorney for this company.", ); return; } - // The GM is established through Fayda now, so the step gates on the - // identity rather than on typed text — same strength as the old required - // fields, different evidence. A foreign company's GM may hold no Fayda ID, - // so typed details still satisfy it there. - if (step === "personnel" && !gmEstablished) { - setSaveError( - identity?.faydaRequired - ? "Verify the general manager with Fayda, or tick “same as owner” if they are the company's owner." - : "Add the general manager's details, or verify them with Fayda.", + // The verification itself is proved outside the form state, so it gates + // here rather than through zod. + if (step === "representation" && !identityProven) { + const who = + identity?.subject === "poa" + ? "your Power of Attorney" + : "the company owner"; + failCheck( + identity?.passportAccepted + ? `Verify ${who} with Fayda, or enter their passport number.` + : `Verify ${who} with Fayda before continuing.`, ); return; } - if (step === "poa" && requirePoa && !poaEstablished) { - setSaveError( - identity?.faydaRequired - ? "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda." - : "Freight forwarders act on other companies' behalf, so a Power of Attorney is required.", - ); - return; - } - // The PoA step also gates on a file, which lives outside the form state. - if (step === "poa" && delegationRequired && !delegationPresent) { + // The step also gates on a file, which lives outside the form state. + if (step === "representation" && delegationRequired && !delegationPresent) { setDocumentFieldErrors({ [POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required", }); // Validate the text fields too, so every problem shows at once. - const fieldsOk = await trigger(stepFields.poa); - setSaveError( + const fieldsOk = await trigger(stepFields.representation); + failCheck( [ - requirePoa - ? "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.", - fieldsOk ? null : describeErrors(stepFields.poa), + "Upload the DARS delegation paper for the representative you named.", + fieldsOk ? null : describeErrors(stepFields.representation), ] .filter(Boolean) .join(" "), ); return; } - // The API will not accept the PoA's details until the paper evidencing the - // delegation is actually on file, so the selection made on this step has to - // be uploaded before the save — not held back until the documents step, - // which is unreachable while this save keeps failing. - if (step === "poa" && delegationRequired && onUploadDocuments) { + // The API will not accept the representative's details until the paper + // evidencing the delegation is actually on file, so the selection made on + // this step has to be uploaded before the save — not held back until the + // documents step, which is unreachable while this save keeps failing. + if (step === "representation" && delegationRequired && onUploadDocuments) { const pending = documentFiles[POA_DELEGATION_FILE_KEY]; const hasPending = Array.isArray(pending) ? pending.length > 0 @@ -1064,7 +980,7 @@ export default function CompanyProfileForm({ try { const res = await onUploadDocuments(); if (!res.ok) { - setSaveError(res.error); + failSave(res.error); return; } } finally { @@ -1092,57 +1008,51 @@ export default function CompanyProfileForm({ {step === "company" && ( )} - {step === "personnel" && ( - + )} + + {step === "representation" && ( + )} {step === "contact" && ( - )} - - {step === "poa" && ( - )} @@ -1154,7 +1064,10 @@ export default function CompanyProfileForm({ uploadedDocumentKeys={uploadedDocumentKeys} documentFieldErrors={documentFieldErrors} onDocumentFilesChange={handleDocumentFilesChange} - roleProfiles={roleProfiles} + // A co-operative union or farm holds no business licence, so the + // per-role upload cards are not shown at all — offering a slot + // nothing can fill reads as an unfinishable step. + roleProfiles={cooperative ? [] : roleProfiles} licenseFiles={licenseFiles} licenseFieldErrors={licenseFieldErrors} onLicenseFilesChange={handleLicenseFilesChange} @@ -1166,9 +1079,13 @@ export default function CompanyProfileForm({ color="red" variant="light" icon={} - title={"Couldn't save this step"} + title={ + saveError.kind === "save" + ? "Couldn't save this step" + : "Please check this step" + } > - {saveError} + {saveError.message} )} @@ -1197,7 +1114,12 @@ export default function CompanyProfileForm({ disabled={ isPending || saving || - (step === "documents" && !hasDocuments && loadingDocuments) + declarePending || + // Nothing to validate against until the document set lands, so + // pressing Continue now would report every required upload as + // satisfied. `hasDocuments` used to be in here too, which let + // the button go live mid-load the moment the set resolved. + (step === "documents" && loadingDocuments) } loading={isPending || saving} rightSection={ diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx new file mode 100644 index 000000000..2a42c424a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx @@ -0,0 +1,61 @@ +import { Badge, Group, Stack, Text } from "@mantine/core"; +import type { ReactNode } from "react"; + +/** Where a locked value came from, shown as a badge next to it. */ +export type FieldSource = "eTrade" | "Fayda"; + +const SOURCE_NOTE: Record = { + eTrade: "From your eTrade licence", + Fayda: "From the Fayda verification", +}; + +/** + * One person-detail field that an outside source may have taken ownership of. + * + * `source` — not "does a value exist" — decides which side renders. That + * distinction is the whole point: `ownerName` holds a value the moment the + * customer types it and the step saves, and keying off presence meant the input + * they had just filled in turned into a read-only badge as soon as they + * navigated away and back, with no way to correct it. Only a real source owns a + * field: a Fayda verification (the API refuses to overwrite those) or the + * eTrade licence (the record the backoffice checks the company against). + * + * It pairs with `requiredKeys` in CompanyProfileForm, which requires exactly + * the fields that fall through to `children`: **a field is required if and only + * if there is an input on screen to satisfy it.** + */ +export default function SourcedField({ + label, + value, + source, + children, +}: { + label: string; + /** The value to display when a source owns this field. */ + value?: string | null; + /** The owning source, or null while the field is still the customer's. */ + source: FieldSource | null; + /** The input rendered whenever the field is still the customer's to fill. */ + children: ReactNode; +}) { + if (!source || !value?.trim()) return <>{children}; + + return ( + + + + {label} + + + {source} + + + + {value} + + + {SOURCE_NOTE[source]} + + + ); +} 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 20e889235..623f75e1a 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 @@ -4,6 +4,8 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyIdentityState } from "@/services/verifayda.service"; import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField"; +import type { FieldSource } from "./SourcedField"; + import { ETRADE_BUNDLE_FIELDS, type CompanyStep, @@ -69,7 +71,6 @@ export function normalizeIdentityPhones( ...identity, owner: fix(identity.owner), poa: fix(identity.poa), - gm: fix(identity.gm), }; } @@ -82,6 +83,73 @@ export const samePhone = (a?: string | null, b?: string | null) => { return da.length === 9 && da === phoneDigits(b); }; +/** The owner details an outside source can take ownership of. */ +export type OwnerField = "name" | "email" | "phone"; + +export interface OwnerSources { + /** Which source owns each field, or null where it is still the customer's. */ + source: Record; + /** What to display for an owned field — normalized as the payload will be. */ + sourced: Record; +} + +/** + * Who owns each of the owner's fields, and with what value. + * + * Two sources can own a field, and they rank. A Fayda verification owns + * whatever its claims filled (the API refuses to overwrite those), and the + * eTrade licence owns the manager's name and phone: that record is the thing + * the backoffice checks the company against, so it is reported, not proposed. + * Neither is typeable. Everything left over is the customer's — an editable + * input, required precisely because there is an input for it. Fayda outranks + * eTrade on the same person: the stronger claim, and the one the API keeps. + * + * Two things deliberately do NOT take ownership. + * + * A value merely being present. It exists the moment the customer types one and + * the step saves — so a presence test turned the input they had just filled + * into a read-only badge on the way back through the wizard, and dropped the + * field out of `requiredKeys` at the same time. + * + * And a value that isn't usable. Both sources hold contact details as free + * text: Fayda's phone is whatever the national registry recorded, eTrade's is + * whatever was typed at the licence desk ("09 " is a real answer, and it + * normalizes to a non-empty, invalid `+2519`). Locking one of those behind a + * read-only row leaves the customer told to fix a field with no input, or the + * step saved with a value the API rejects. So a source owns an email or a phone + * only if what it supplies holds up as one; otherwise the field falls through + * to an input and is required like any other. Names have no format to fail, so + * presence is the whole test there. + */ +export function resolveOwnerSources( + identity: CompanyIdentityState | undefined, + etradeOwner: { name: string; phone: string } | null, +): OwnerSources { + const verified = identity?.owner.verified ?? false; + const faydaName = verified && Boolean(identity?.owner.name?.trim()); + const faydaEmail = verified ? firstValidEmail(identity?.owner.email) : ""; + const faydaPhone = verified ? firstValidPhone(identity?.owner.phone) : ""; + const etradeName = etradeOwner?.name?.trim() ?? ""; + const etradePhone = firstValidPhone(etradeOwner?.phone); + + const source: Record = { + name: faydaName ? "Fayda" : etradeName ? "eTrade" : null, + // eTrade never returns an email for the manager, so this one is Fayda's or + // it is the customer's to type. + email: faydaEmail ? "Fayda" : null, + phone: faydaPhone ? "Fayda" : etradePhone ? "eTrade" : null, + }; + + return { + source, + sourced: { + name: source.name === "Fayda" ? (identity?.owner.name?.trim() ?? "") : etradeName, + email: faydaEmail, + phone: source.phone === "Fayda" ? faydaPhone : etradePhone, + }, + }; +} + /** Mask all but the first 7 chars of an E.164 phone for display. */ export const maskPhone = (p: string) => p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; @@ -97,15 +165,14 @@ export function buildPayload( vatNumber: data.vatNumber, attributes: { ownerPassportNumber: data.ownerPassportNumber || undefined, + poaPassportNumber: data.poaPassportNumber || undefined, contactPersonName: data.contactPersonName, contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, contactPersonPhone: data.contactPersonPhone, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: data.generalManagerPhone, - // The representative's own details are written by their Fayda - // verification, so the city is all the form has to send. + ownerName: data.ownerName, + ownerEmail: data.ownerEmail, + ownerPhone: data.ownerPhone, poaLocation: data.poaLocation || undefined, }, }; @@ -135,23 +202,28 @@ export function stepPayload( } if (dirty.tinNumber) etrade.tin = d.tinNumber; return { - companyAddress: d.companyAddress, + // Composed from the address parts, so it is only as complete as they + // are. Sending it while they are still empty (the lookup hasn't landed, + // or eTrade left them blank) would overwrite a stored address with a + // degraded version of itself — an absent key means "untouched". + ...(d.companyAddress?.trim() + ? { companyAddress: d.companyAddress } + : {}), vatNumber: d.vatNumber, - ownerPassportNumber: d.ownerPassportNumber || undefined, ...etrade, }; } - case "personnel": + case "owner": // `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and // undefined, so an empty string is validated and 400s with - // "generalManagerEmail must be an email". An Ethiopian company never types - // these — the GM comes from the Fayda verification (or the "same as owner" - // declaration), so the form fields are legitimately blank and would fail a - // step that has no input to fix. + // "ownerEmail must be an email". A field the eTrade lookup or the Fayda + // claim already filled is legitimately blank in the form — it has no + // input — so sending "" would fail a step with nothing on screen to fix. return { - generalManagerName: d.generalManagerName || undefined, - generalManagerEmail: d.generalManagerEmail || undefined, - generalManagerPhone: d.generalManagerPhone || undefined, + ownerName: d.ownerName || undefined, + ownerEmail: d.ownerEmail || undefined, + ownerPhone: d.ownerPhone || undefined, + ownerPassportNumber: d.ownerPassportNumber || undefined, }; case "contact": return { @@ -160,12 +232,16 @@ export function stepPayload( contactPersonEmail: d.contactPersonEmail || undefined, contactPersonPhone: d.contactPersonPhone, }; - case "poa": + case "representation": return { poaName: d.poaName || undefined, poaEmail: d.poaEmail || undefined, poaPhone: d.poaPhone || undefined, poaLocation: d.poaLocation || undefined, + poaPassportNumber: d.poaPassportNumber || undefined, + // The step renders one passport input, for whichever person the + // declaration made the identity subject — so it has to save both. + ownerPassportNumber: d.ownerPassportNumber || undefined, }; default: return {}; @@ -183,6 +259,7 @@ export function toFormValues(p: ProfileResponse): FormData { tinNumber: tin, vatNumber: p.vatNumber ?? "", ownerPassportNumber: p.identity?.owner.passportNumber ?? "", + poaPassportNumber: p.identity?.poa.passportNumber ?? "", licenceNumber: p.licenceNumber ?? "", statusDescription: p.statusDescription ?? "", dateRegistered: p.dateRegistered ?? "", @@ -198,12 +275,11 @@ export function toFormValues(p: ProfileResponse): FormData { contactPersonPosition: p.contactPersonPosition ?? "", contactPersonEmail: p.contactPersonEmail ?? "", contactPersonPhone: p.contactPersonPhone ?? "", - generalManagerName: p.generalManagerName ?? "", - generalManagerEmail: p.generalManagerEmail ?? "", - generalManagerPhone: p.generalManagerPhone ?? "", + ownerName: p.ownerName ?? "", + ownerEmail: p.ownerEmail ?? "", + ownerPhone: p.ownerPhone ?? "", poaName: p.poaName ?? "", poaPhone: p.poaPhone ?? "", - poaAddress: p.poaAddress ?? "", poaEmail: p.poaEmail ?? "", poaLocation: p.poaLocation ?? "", }; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts index 95755e777..cd957eec7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts @@ -6,6 +6,7 @@ import { firstValidEmail, firstValidPhone, normalizeIdentityPhones, + resolveOwnerSources, stepPayload, } from "./helpers"; import type { FormData } from "./schema"; @@ -35,9 +36,9 @@ const values = (over: Partial = {}): FormData => contactPersonPosition: "", contactPersonEmail: "", contactPersonPhone: "+251911223344", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", + ownerName: "", + ownerEmail: "", + ownerPhone: "", poaName: "", poaPhone: "", poaAddress: "", @@ -65,18 +66,16 @@ describe("VAT number", () => { ).toBeUndefined(); }); - it("rejects twelve digits", () => { - expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe( - "VAT number must be 10 or 11 digits", - ); - }); - - // `.length(10)` used to pass this, so a ten-letter string reached the API. - it("rejects ten non-digits", () => { - expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe( - "VAT number must be 10 or 11 digits", - ); - }); + // No shape rule any more: a foreign tax authority's VAT number carries + // letters and dashes, and a co-operative union's registration numbering + // follows the trade-licence pattern not at all. Length and alphabet are not + // ours to police — only presence is. + it.each(["001234567890", "GB123456789", "ET-2024/0091"])( + "accepts %s", + (vat) => { + expect(errorFor(values({ vatNumber: vat }), "vatNumber")).toBeUndefined(); + }, + ); it("rejects blank", () => { expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe( @@ -104,25 +103,88 @@ describe("stepFields", () => { // The regression this whole change exists to prevent: a step must not gate on // a field it renders no input for, or Continue fails with the error attached // to nothing on screen. - it("never gates the company step on a derived or read-only field", () => { - const unreachable = [ - "etradePhone", + // + // Listing a field on a step is no longer the gate — `requiredKeys` is. The + // registration fields appear on the company step because a co-operative union + // or farm types them, and a licensed company gets them read-only from eTrade; + // the base schema must accept them blank either way. + it("never gates the company step on a field with no input", () => { + const derived = ["etradePhone", "licenceNumber", "statusDescription"]; + expect(stepFields.company.filter((f) => derived.includes(f))).toEqual([]); + }); + + it("leaves the registration fields optional in the base schema", () => { + for (const field of ["companyName", "region", "zone", "woreda", "kebele"] as const) { + expect(errorFor(values({ [field]: "" }), field)).toBeUndefined(); + } + }); + + it("requires the registration fields once a co-operative types them", () => { + const parsed = buildOnboardingSchema([ "companyName", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", "region", "zone", "woreda", "kebele", - "houseNo", - ]; - expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual( - [], + ]).safeParse( + values({ companyName: "", region: "", zone: "", woreda: "", kebele: "" }), ); + expect(parsed.success).toBe(false); + const paths = parsed.success + ? [] + : parsed.error.issues.map((i) => String(i.path[0])); + expect(paths).toEqual( + expect.arrayContaining([ + "companyName", + "region", + "zone", + "woreda", + "kebele", + ]), + ); + }); +}); + +describe("the identity subject's passport", () => { + // The representation step renders ONE passport input, for whoever the + // power-of-attorney answer made the identity subject. When the answer is "no + // PoA" that is the owner — so the owner's passport number is typed on the + // representation step and has to be carried by it. It used to belong to the + // owner step alone, which is already behind the customer by then: the number + // was typed, dropped, and the final submit failed `assertIdentityVerified` + // naming a field they could see was filled in. + it("is carried by the step that renders the input", () => { + expect(stepFields.representation).toContain("ownerPassportNumber"); + expect(stepFields.representation).toContain("poaPassportNumber"); + }); + + it("saves the owner's passport from the representation step", () => { + const payload = stepPayload( + "representation", + values({ ownerPassportNumber: "P1234567" }), + ); + expect(payload.ownerPassportNumber).toBe("P1234567"); + }); + + it("still omits it when there is none, rather than sending an empty string", () => { + const payload = stepPayload( + "representation", + values({ ownerPassportNumber: "" }), + ); + expect(payload.ownerPassportNumber).toBeUndefined(); + }); +}); + +describe("stepPayload (representation)", () => { + // `poaAddress` is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS` server-side). + // The portal states `poaLocation` instead and must never send the other. + it("sends the company's stated location, never the Fayda address", () => { + const payload = stepPayload( + "representation", + values({ poaLocation: "Dire Dawa, Ethiopia" }), + ); + expect(payload.poaLocation).toBe("Dire Dawa, Ethiopia"); + expect("poaAddress" in payload).toBe(false); }); }); @@ -131,31 +193,29 @@ describe("buildOnboardingSchema (conditionally required fields)", () => { data: FormData, required: (keyof FormData)[], ): (keyof FormData)[] => { - const parsed = buildOnboardingSchema(false, required).safeParse(data); + const parsed = buildOnboardingSchema(required).safeParse(data); return parsed.success ? [] : (parsed.error.issues.map((i) => i.path[0]) as (keyof FormData)[]); }; - // Fayda's email/phone claims are optional: the step renders an input for what - // the verification did not supply, and requires exactly those. Nothing else — - // a field with no input on screen must never fail Continue. + // eTrade returns no email and Fayda's email/phone claims are optional: the + // step renders an input for what no source supplied, and requires exactly + // those. Nothing else — a field with no input on screen must never fail + // Continue. it("requires only the keys it is handed", () => { - const issues = issuesFor(values(), [ - "generalManagerEmail", - "generalManagerPhone", - ]); - expect(issues).toEqual(["generalManagerEmail", "generalManagerPhone"]); + const issues = issuesFor(values(), ["ownerEmail", "ownerPhone"]); + expect(issues).toEqual(["ownerEmail", "ownerPhone"]); }); it("passes once those keys are filled", () => { expect( issuesFor( values({ - generalManagerEmail: "gm@example.com", - generalManagerPhone: "+251911223344", + ownerEmail: "owner@example.com", + ownerPhone: "+251911223344", }), - ["generalManagerEmail", "generalManagerPhone"], + ["ownerEmail", "ownerPhone"], ), ).toEqual([]); }); @@ -165,9 +225,7 @@ describe("buildOnboardingSchema (conditionally required fields)", () => { }); it("names the field in the message, so it reads under its own input", () => { - const parsed = buildOnboardingSchema(false, ["poaEmail"]).safeParse( - values(), - ); + const parsed = buildOnboardingSchema(["poaEmail"]).safeParse(values()); expect(parsed.success).toBe(false); if (parsed.success) return; expect(parsed.error.issues[0]?.message).toBe( @@ -196,39 +254,43 @@ describe("stepPayload (company)", () => { // Still only the dirty ones. expect(payload.licenceNumber).toBeUndefined(); }); + + // The address is composed from the parts, so it is only ever as complete as + // they are. An absent key means "untouched" to the API; sending a blank one + // would replace a stored address with nothing. + it("omits a blank composed address rather than clearing the stored one", () => { + const payload = stepPayload("company", values({ companyAddress: "" }), {}); + expect("companyAddress" in payload).toBe(false); + }); }); -describe("stepPayload (personnel)", () => { - // An Ethiopian company never types the GM — Fayda (or "same as owner") owns - // those fields — so the form holds "". `@IsOptional()` on the DTO skips only - // null/undefined, so an empty string is validated and comes back as - // "generalManagerEmail must be an email", on a step that renders no input. - it("omits blank GM fields instead of sending empty strings", () => { +describe("stepPayload (owner)", () => { + // A Fayda claim owns whatever it supplied, so the form holds "" for those. + // `@IsOptional()` on the DTO skips only null/undefined, so an empty string is + // validated and comes back as "ownerEmail must be an email" — on a step that + // renders no input for it. + it("omits blank owner fields instead of sending empty strings", () => { const payload = stepPayload( - "personnel", - values({ - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", - }), + "owner", + values({ ownerName: "", ownerEmail: "", ownerPhone: "" }), ); - expect(payload.generalManagerName).toBeUndefined(); - expect(payload.generalManagerEmail).toBeUndefined(); - expect(payload.generalManagerPhone).toBeUndefined(); + expect(payload.ownerName).toBeUndefined(); + expect(payload.ownerEmail).toBeUndefined(); + expect(payload.ownerPhone).toBeUndefined(); }); - it("still sends typed GM details (foreign company)", () => { + it("sends the owner details the customer typed or eTrade prefilled", () => { const payload = stepPayload( - "personnel", + "owner", values({ - generalManagerName: "Abebe Bikila", - generalManagerEmail: "gm@example.com", - generalManagerPhone: "+251911223344", + ownerName: "Abebe Bikila", + ownerEmail: "owner@example.com", + ownerPhone: "+251911223344", }), ); - expect(payload.generalManagerName).toBe("Abebe Bikila"); - expect(payload.generalManagerEmail).toBe("gm@example.com"); - expect(payload.generalManagerPhone).toBe("+251911223344"); + expect(payload.ownerName).toBe("Abebe Bikila"); + expect(payload.ownerEmail).toBe("owner@example.com"); + expect(payload.ownerPhone).toBe("+251911223344"); }); }); @@ -268,13 +330,117 @@ describe("firstValidEmail", () => { }); }); +describe("resolveOwnerSources", () => { + const identity = (over: { + verified?: boolean; + name?: string | null; + email?: string | null; + phone?: string | null; + }): CompanyIdentityState => + ({ + passportAccepted: false, + poaDeclared: "no", + subject: "owner", + owner: { + verified: over.verified ?? true, + name: over.name ?? null, + email: over.email ?? null, + phone: over.phone ?? null, + address: null, + verifiedAt: null, + passportNumber: null, + }, + poa: { + verified: false, + name: null, + email: null, + phone: null, + address: null, + verifiedAt: null, + passportNumber: null, + }, + identityProven: false, + etradeManagerName: null, + etradeManagerPhone: null, + ownerMatchesEtrade: null, + complete: false, + }) as CompanyIdentityState; + + it("locks what each source supplied, Fayda outranking eTrade", () => { + const { source, sourced } = resolveOwnerSources( + identity({ + name: "Abebe Bikila", + email: "owner@example.com", + phone: "+251911223344", + }), + { name: "A. Bikila", phone: "+251911999888" }, + ); + expect(source).toEqual({ name: "Fayda", email: "Fayda", phone: "Fayda" }); + expect(sourced.phone).toBe("+251911223344"); + }); + + // The point of the whole exercise: a field is read-only only if what the + // source gave can actually be submitted. Otherwise the customer is shown a + // badge holding a value the API will reject, with no input to fix it. + it("falls back to an input when Fayda's phone claim is unusable", () => { + const { source, sourced } = resolveOwnerSources( + identity({ name: "Abebe Bikila", phone: "09 " }), + null, + ); + expect(source.phone).toBeNull(); + expect(sourced.phone).toBe(""); + }); + + it("falls back to an input when Fayda's email claim is malformed", () => { + const { source } = resolveOwnerSources( + identity({ name: "Abebe Bikila", email: "not-an-email" }), + null, + ); + expect(source.email).toBeNull(); + }); + + it("falls back to an input when eTrade's manager phone is unusable", () => { + const { source, sourced } = resolveOwnerSources(undefined, { + name: "Abebe Bikila", + phone: "09 ", + }); + // The name is still eTrade's — names have no format to fail. + expect(source.name).toBe("eTrade"); + expect(source.phone).toBeNull(); + expect(sourced.phone).toBe(""); + }); + + it("takes eTrade's phone where Fayda has none, normalized", () => { + const { source, sourced } = resolveOwnerSources( + identity({ verified: false }), + { name: "Abebe Bikila", phone: "0911223344" }, + ); + expect(source.phone).toBe("eTrade"); + expect(sourced.phone).toBe("+251911223344"); + }); + + it("owns nothing when the verification never happened", () => { + const { source } = resolveOwnerSources( + identity({ + verified: false, + name: "Abebe Bikila", + email: "owner@example.com", + phone: "+251911223344", + }), + null, + ); + expect(source).toEqual({ name: null, email: null, phone: null }); + }); +}); + describe("normalizeIdentityPhones", () => { it("converts a local Fayda phone claim to E.164", () => { const identity = { - faydaRequired: true, - passportRequired: false, + passportAccepted: false, + poaDeclared: "yes", + subject: "poa", owner: { - verified: true, + verified: false, name: "A", phone: "0911223344", email: null, @@ -283,28 +449,22 @@ describe("normalizeIdentityPhones", () => { passportNumber: null, }, poa: { - verified: false, - name: null, - phone: null, - email: null, - address: null, - verifiedAt: null, - }, - gm: { - verified: false, - name: null, + verified: true, + name: "B", phone: "251911223344", email: null, address: null, verifiedAt: null, + passportNumber: null, }, - gmSameAsOwner: false, - complete: false, + identityProven: true, + etradeManagerName: null, + ownerMatchesEtrade: null, + complete: true, } as CompanyIdentityState; const fixed = normalizeIdentityPhones(identity)!; expect(fixed.owner.phone).toBe("+251911223344"); - expect(fixed.gm.phone).toBe("+251911223344"); - expect(fixed.poa.phone).toBeNull(); + expect(fixed.poa.phone).toBe("+251911223344"); }); }); 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 0017598e9..b274761b0 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 @@ -5,9 +5,9 @@ import { isValidPhone } from "@/components/PhoneField"; export type CompanyStep = | "company" - | "personnel" + | "owner" + | "representation" | "contact" - | "poa" | "documents" | "additional"; @@ -22,25 +22,33 @@ export const onboardingSchema = z.object({ // can diverge without the backend's eTrade-authenticity check misfiring. etradePhone: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), - // `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits. - vatNumber: z - .string() - .min(1, "VAT number is required") - .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 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`. + // Required, but no shape check. Ethiopian VAT numbers are usually 10 or 11 + // digits; a foreign company's is whatever its own tax authority issues, and a + // co-operative's registration numbering follows neither. A format rule here + // only ever rejected valid numbers we had no business judging. + vatNumber: z.string().min(1, "VAT number is required"), + // Passport numbers — the alternative identity credential for a foreign + // company (Fayda is an Ethiopian national ID). Only the one belonging to the + // declared identity subject is ever asked for, and only when that person has + // not verified with Fayda — so requiredness is decided per render and lives + // in `requiredKeys`, not here. ownerPassportNumber: z.string().optional(), + poaPassportNumber: z.string().optional(), licenceNumber: z.string().optional(), statusDescription: z.string().optional(), dateRegistered: z.string().optional(), renewedFrom: z.string().optional(), renewalDate: z.string().optional(), renewedTo: z.string().optional(), - // The registered address comes from eTrade and nowhere else — the form - // renders these read-only, so requiring them would be a Continue button that - // fails on a field with no input to fix it. A gap in eTrade's own data stays - // a gap rather than becoming a customer-typed claim wearing eTrade's badge. + // The registered address normally comes from eTrade and nowhere else — the + // form renders these read-only, so requiring them would be a Continue button + // that fails on a field with no input to fix it. A gap in eTrade's own data + // stays a gap rather than becoming a customer-typed claim wearing eTrade's + // badge. + // + // A co-operative is the exception: it has no business licence, so there is no + // eTrade record at all and these ARE typed. Requiredness follows the same + // invariant as everywhere else — it is decided per render, in `requiredKeys`. region: z.string().optional(), zone: z.string().optional(), woreda: z.string().optional(), @@ -57,21 +65,23 @@ export const onboardingSchema = z.object({ .string() .min(1, "Contact person phone is required") .refine(isValidPhone, "Enter a valid phone number"), - // Optional here, not unrequired: the GM is now established by Fayda — either - // verified in their own right or declared the same person as the owner — so - // for an Ethiopian company these fields are never typed and would fail a - // blanket `min(1)`. Presence is gated per nationality in the step's own - // check, where the identity state is available; zod only polices format for - // the foreign companies that still type them. - generalManagerName: z.string().optional(), - generalManagerEmail: z + // The owner — whoever the eTrade licence names as the business's manager. + // + // Optional here, not unrequired: the eTrade lookup fills the name and phone, + // and a Fayda verification can fill all three, so on a well-supplied company + // none of them is typed and a blanket `min(1)` would fail a step with no + // input on screen. What IS required is decided per render — a field is + // required exactly when the step renders an input for it (`requiredKeys`). + // zod only polices format here. + ownerName: z.string().optional(), + ownerEmail: z .string() .optional() .refine( (v) => !v || z.string().email().safeParse(v).success, - "Invalid Manager email", + "Invalid owner email", ), - generalManagerPhone: z + ownerPhone: z .string() .optional() .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), @@ -80,7 +90,6 @@ export const onboardingSchema = z.object({ .string() .optional() .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), - poaAddress: z.string().optional(), poaEmail: z .string() .optional() @@ -88,6 +97,10 @@ export const onboardingSchema = z.object({ (v) => !v || z.string().email().safeParse(v).success, "Invalid email address", ), + // Where the representative is based, as the company states it. Deliberately + // NOT `poaAddress`: that one is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS`) + // the verification writes and the portal must never send — the two used to + // sit side by side here, with the Fayda address silently hiding this input. poaLocation: z.string().optional(), }); @@ -102,45 +115,45 @@ export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; * message has to be built here rather than attached to the base schema. */ const CONDITIONAL_LABELS: Partial> = { + // Typed only by a co-operative — every other company gets these from eTrade. + companyName: "Company name", + region: "Region", + zone: "Zone", + woreda: "Woreda", + kebele: "Kebele", poaName: "Representative's name", poaEmail: "Representative's email", poaPhone: "Representative's phone", - generalManagerName: "General manager's name", - generalManagerEmail: "General manager's email", - generalManagerPhone: "General manager's phone", + poaPassportNumber: "Representative's passport number", + ownerName: "Owner's name", + ownerEmail: "Owner's email", + ownerPhone: "Owner's phone", + ownerPassportNumber: "Owner's passport number", }; /** - * The PoA's and GM's identifying fields normally come from their Fayda - * verification, so nothing in the base schema requires them. But Fayda's email - * and phone claims are optional and routinely come back empty, and the steps - * render an input for whatever the verification did not supply — so those - * fields become mandatory exactly then. + * The owner's and the representative's identifying fields arrive from three + * places — the eTrade lookup, a Fayda verification, or the customer typing them + * — and which one supplies what varies per company. eTrade returns no email at + * all; Fayda's email and phone claims are optional and routinely come back + * empty. So nothing in the base schema requires them, and the steps render an + * input for whatever no source supplied. * * `requiredKeys` is that decision, made by CompanyProfileForm from the same - * state that drives the rendering: a field is required iff an input exists for - * it. Passing it in (rather than deriving it here) is what keeps the two from - * drifting into a Continue button that fails on a field nobody can see. + * state that drives the rendering: **a field is required iff an input exists + * for it**. Passing it in (rather than deriving it here) is what keeps the two + * from drifting into a Continue button that fails on a field nobody can see. */ export function buildOnboardingSchema( - /** True for a foreign company: the owner's passport number is mandatory. */ - passportRequired = false, /** Fields the current step renders an input for and must not leave blank. */ requiredKeys: readonly (keyof FormData)[] = [], ) { - if (!passportRequired && requiredKeys.length === 0) return onboardingSchema; + if (requiredKeys.length === 0) return onboardingSchema; return onboardingSchema.superRefine((d, ctx) => { - if (passportRequired && !d.ownerPassportNumber?.trim()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["ownerPassportNumber"], - message: "The owner's passport number is required", - }); - } for (const key of requiredKeys) { if (d[key]?.trim()) continue; ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", path: [key], message: `${CONDITIONAL_LABELS[key] ?? key} is required`, }); @@ -184,25 +197,46 @@ export const ETRADE_BUNDLE_FIELDS = [ * (`REQUIRED_COMPANY_INFO`), and reports it with a message. */ export const stepFields: Record = { - // Only the three fields this step actually renders an input for. The company - // name and the registered address are eTrade's, shown read-only. - company: ["tinNumber", "vatNumber", "ownerPassportNumber"], - personnel: [ - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", + // Only what this step actually renders an input for. The company name and the + // registered address are eTrade's, shown read-only — except for a + // co-operative, which types them (added per render via `requiredKeys`). + company: [ + "tinNumber", + "vatNumber", + "companyName", + "region", + "zone", + "woreda", + "kebele", + "houseNo", ], + // `REQUIRED_COMPANY_INFO` demands all three server-side, so the step offers + // an input wherever eTrade and Fayda between them left a gap. + owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerPassportNumber"], contact: [ "contactPersonName", "contactPersonPosition", "contactPersonEmail", "contactPersonPhone", ], - // The API requires poaName/poaEmail/poaPhone from a freight forwarder - // (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the - // representative isn't proven by Fayda — otherwise the save is rejected - // naming fields the form never rendered. - poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"], + // The API requires poaName/poaEmail/poaPhone once a representative is + // declared (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever + // Fayda didn't supply them — otherwise the save is rejected naming fields the + // form never rendered. + // `ownerPassportNumber` belongs here as much as the PoA's: the step renders + // whichever passport input the declaration calls for, and when the answer is + // "no PoA" that is the owner's. Leaving it out meant the number was typed on + // this step, validated by nothing, and dropped by `stepPayload` — so the + // final submit failed `assertIdentityVerified` over a field two steps back + // that the customer could see was filled in. + representation: [ + "poaName", + "poaEmail", + "poaPhone", + "poaLocation", + "poaPassportNumber", + "ownerPassportNumber", + ], documents: [], additional: [], }; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx index 0473b26d1..b89e0e920 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -1,12 +1,10 @@ -import { Stack, TextInput } from "@mantine/core"; +import { Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core"; import type { UseFormReturn } from "react-hook-form"; -import type { CompanyRegistrationData } from "@edr/types"; -import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; +import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types"; import ETradeInfo, { type ETradeStatus, } from "@/components/onboarding/ETradeInfo"; -import type { CompanyIdentityState } from "@/services/verifayda.service"; import type { FormData } from "../schema"; import ETradeCompanyCard from "../ETradeCompanyCard"; @@ -14,14 +12,16 @@ import StepSection from "../StepSection"; export interface CompanyInfoStepProps { form: UseFormReturn; - /** Fayda verification state, phone-normalized by the parent. */ - identity?: CompanyIdentityState; - /** True when Fayda (not a passport) is what this company must prove with. */ - verifiedIdentity: boolean; tinStatus: ETradeStatus; tinVerified: boolean; /** Registration fields are already populated (a lookup passed, now or earlier). */ hasRegistrationDetails: boolean; + /** + * The company is a co-operative union or farm: it has a TIN but no business + * licence, so eTrade holds no record to look up and the registration is typed + * here instead. + */ + cooperative?: boolean; onETradeDataLoaded: (data: CompanyRegistrationData) => void; onETradeStatusChange: (status: ETradeStatus) => void; onETradeReset: () => void; @@ -29,11 +29,10 @@ export interface CompanyInfoStepProps { export default function CompanyInfoStep({ form, - identity, - verifiedIdentity, tinStatus, tinVerified, hasRegistrationDetails, + cooperative = false, onETradeDataLoaded, onETradeStatusChange, onETradeReset, @@ -41,77 +40,51 @@ export default function CompanyInfoStep({ const { register, watch, + setValue, formState: { errors }, } = form; + const region = watch("region") ?? ""; + return ( = 10 && !errors.vatNumber - ? "done" - : "todo" - } + status={watch("vatNumber")?.trim() && !errors.vatNumber ? "done" : "todo"} > + {/* The TIN lookup runs for everyone, co-operative included. A co-op has + no trade licence, but plenty of them are on eTrade all the same — and + when the record is there it is better data than anything typed, so we + ask for it first and fall back to the form below rather than deciding + in advance that nothing will be found. What differs for a co-op is + only the consequence of finding nothing: expected, not an error. */} 0 - ? "done" - : identity?.passportRequired - ? "blocked" - : "todo" - } - > - {identity && ( - <> - - {identity.passportRequired && ( - - )} - - )} - - - - {tinVerified && ( + {!cooperative && tinVerified && ( )} + + {/* A co-operative keeps its typed registration section either way. When + the lookup found something these arrive prefilled — still editable, + because for a co-op they are the customer's own statement rather than + the licence's, and the API takes them as given (`applyEtradeSourcedFields` + skips co-operatives entirely). */} + {cooperative && ( + + + + + Registered address + + +