diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index e7682879b..dbf2bd4fd 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => { expect(manager.update).not.toHaveBeenCalled(); }); }); + +describe("BillingService — CAC Bank (OTP debit)", () => { + const openInvoice = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "PREPAID", + invoiceNumber: "INV-20260101-00001", + currency: "USD", + balanceAmount: 500, + totalAmount: 500, + paymentId: "intent-1", + dueAt: null, + }; + + const build = (payment: Record) => { + const repo = { + findOne: jest.fn().mockResolvedValue(openInvoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { getRepository: () => repo } as never, + {} as never, + {} as never, + makeEvents() as never, + payment as never, + {} as never, + {} as never, + ); + return { service, repo }; + }; + + it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => { + const initiate = jest.fn(); + const { service } = build({ initiate }); + + await expect( + service.payInvoice("inv-1", { method: "CAC_BANK" }), + ).rejects.toThrow(/payerAccount/); + expect(initiate).not.toHaveBeenCalled(); + }); + + it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => { + const handlePaymentEvent = jest.fn(); + const { service } = build({ + initiate: jest.fn().mockResolvedValue({ + intentId: "intent-1", + immediateSuccess: false, + response: { + intentId: "intent-1", + status: "REQUIRES_ACTION", + clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" }, + }, + }), + handlePaymentEvent, + }); + + await service.payInvoice("inv-1", { + method: "CAC_BANK", + payerAccount: "77123456", + }); + + expect(handlePaymentEvent).not.toHaveBeenCalled(); + }); + + it("confirms the OTP against the intent stamped on the invoice", async () => { + const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" }); + const { service } = build({ confirmOtp }); + + await service.confirmInvoiceOtp("inv-1", "123456"); + + expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ca43295d1..525ccfefc 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm"; import { CompaniesService } from "../companies/companies.service"; import { PaymentService } from "../payment/payment.service"; -import { InitiateResponseDto } from "../payment/payments.dto"; +import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, @@ -352,6 +352,34 @@ export class BillingService { return this.payInvoice(id, opts); } + /** + * Submit the CAC Bank OTP for one of the customer's own invoices + * (ownership-checked). Settlement of the invoice happens inside the payment + * service when the OTP succeeds. + */ + async confirmInvoiceOtpForUser( + id: string, + userId: string, + otp: string, + ): Promise { + await this.findByIdForUser(id, userId); + return this.confirmInvoiceOtp(id, otp); + } + + /** OTP confirmation by invoice id — the intent is the one stamped at initiate. */ + async confirmInvoiceOtp( + invoiceId: string, + otp: string, + ): Promise { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: invoiceId } }); + if (!invoice?.paymentId) { + throw new NotFoundException("No payment to confirm for this invoice"); + } + return this.payment.confirmOtp(invoice.paymentId, otp); + } + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ async documentForUser( id: string, @@ -1035,6 +1063,17 @@ export class BillingService { throw new BadRequestException("Invoice has no outstanding balance."); } + // CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is + // required up front (the payment service rejects it otherwise, as a 502 here). + if ( + (opts.method ?? "").toUpperCase() === "CAC_BANK" && + !opts.payerAccount?.trim() + ) { + throw new BadRequestException( + "payerAccount (mobile number) is required for CAC Bank", + ); + } + const result = await this.payment.initiate({ referenceId: invoice.sourceId, source: invoice.source, @@ -1062,7 +1101,12 @@ export class BillingService { // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept commented for local demos only. - if (!result.immediateSuccess) { + // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the + // code — so the demo shortcut must never fire for it. + if ( + !result.immediateSuccess && + result.response.clientAction?.type !== "COLLECT_OTP" + ) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", eventId: `demo-${result.intentId}`, diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts index c29160ab7..354dd004e 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts @@ -1,5 +1,13 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsOptional, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */ +export class ConfirmOtpDto { + @ApiProperty({ description: "One-time password SMSed by the bank." }) + @IsString() + @IsNotEmpty() + otp!: string; +} /** Gateway options for paying an invoice from the customer portal. */ export class PayInvoiceDto { diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 94e917754..981233df0 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -18,7 +18,7 @@ import { } from "../../common/resolve-auth-user-id"; import { sendPdf } from "./billing.controller"; import { BillingService } from "./billing.service"; -import { PayInvoiceDto } from "./dto/pay-invoice.dto"; +import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto"; /** * Customer-facing billing endpoints. Unlike {@link BillingController} (admin, @@ -96,4 +96,20 @@ export class PortalBillingController { failureUrl: dto.failureUrl, }); } + + @Post("my-invoices/:id/confirm") + @ApiOperation({ + summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", + }) + confirmOtp( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: ConfirmOtpDto, + ) { + return this.billingService.confirmInvoiceOtpForUser( + id, + resolveAuthUserId(user), + dto.otp, + ); + } } 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 7f72d431a..c13f79f5e 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 @@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation. * come from the verified payload, not typed. Fayda's userinfo carries no * national ID number, so none is collected or derived here. * - * - Ethiopian company: the owner (and its PoA, once named) is verified through - * Fayda, and their details can't be edited afterwards. + * Only the OWNER's credential varies by nationality: + * - Ethiopian company: the owner is verified through Fayda. * - Foreign company: Fayda is an Ethiopian national ID, so the owner instead * supplies a typed passport number — required on its own, whether or not the * owner also completes a (purely optional) Fayda verification. * + * The PoA does not vary. A representative acts for the company inside Ethiopia + * whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one + * is named, both nationalities must verify them, and their details come from + * the verified payload rather than the form. + * * The owner is NOT the general manager — GM is a separate, plain typed role * the portal offers a "same as owner" copy for, but it is never itself * Fayda-verified or gated on. @@ -107,6 +112,7 @@ function makeService(overrides: Partial = {}) { }, changeRequestRepo: { findPendingByCompanyId: jest.fn(async () => null), + findLatestOpenByCompanyId: jest.fn(async () => null), findByCompanyId: jest.fn(async () => []), create: jest.fn(async (row: Record) => ({ id: "cr-1", @@ -229,9 +235,27 @@ describe("Fayda identity verification binds a person to the company", () => { ).rejects.toBeInstanceOf(BadRequestException); }); - it("stages the change for review on an approved company", async () => { - // Swapping the person who can act for a live company is exactly what the - // backoffice review exists for, so it must not rewrite the row directly. + 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, }); @@ -242,8 +266,8 @@ describe("Fayda identity verification binds a person to the company", () => { state: "s", }); - expect(deps.changeRequestRepo.create).toHaveBeenCalled(); - expect(ctx.attributes.poaFaydaSub).toBeUndefined(); + expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); }); it("refuses to rename a verified person by hand", async () => { @@ -367,7 +391,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi ).rejects.toBeInstanceOf(BadRequestException); }); - it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => { + 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("still requires a Fayda-verified PoA from a foreign company", async () => { + // The owner's credential is nationality-specific; the representative's is + // not. A PoA acts for the company inside Ethiopia whoever owns it, so a + // typed foreign name is not a representative the platform can accept. const { service } = makeService({ profileTypes: [ProfileType.importer], nationality: CompanyNationality.Foreign, @@ -385,7 +431,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi "user-1", ProfileType.freightForwarder, ), - ).resolves.toBeDefined(); + ).rejects.toBeInstanceOf(BadRequestException); }); it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 9e1c76477..27c42e581 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 @@ -81,6 +81,9 @@ function makeService(overrides: Partial = {}) { findPendingByCompanyId: jest.fn(async () => ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, ), + findLatestOpenByCompanyId: jest.fn(async () => + ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, + ), findByCompanyId: jest.fn(async () => []), create: jest.fn(async (row: Record) => ({ id: "cr-1", 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 50cc648ad..c198d149f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -81,6 +81,28 @@ const POA_ATTRIBUTES = [ "poaLocation", "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 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. + */ +const SELF_SERVICE_ATTRIBUTES: readonly string[] = [ + "contactPersonName", + "contactPersonPosition", + "contactPersonEmail", + "contactPersonPhone", + "contactVerifiedPhone", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", + ...POA_ATTRIBUTES, +]; /** Mandatory once the company operates as a freight forwarder. */ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaName", label: "PoA name" }, @@ -844,10 +866,11 @@ export class CompaniesService { * * - Company not yet approved (onboarding) → write straight to the Company row, * as before. The company/role pending→approve gate already covers first-run. - * - Company already `active` → do NOT touch the live Company. Stage the edit in - * a pending change request (merging into any open one) so a backoffice - * reviewer can approve (apply) or reject (with a note). This locks the - * customer until the review resolves. + * - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`) + * still write straight through; everything else does NOT touch the live + * Company but is staged in a pending change request (merging into any open + * one) so a backoffice reviewer can approve (apply) or reject (with a + * note). Only the staged half locks the customer until the review resolves. */ async updateProfile( userId: string, @@ -882,9 +905,37 @@ export class CompaniesService { return new ProfileResponseDto(profile, updated); } - // Approved company: stage the change for review, leaving the live row intact. + // Approved company: personnel details apply immediately, the rest is staged + // for review with the live row left intact. await this.assertTinAvailable(company, dto.tin); const fields = this.pickDefined(dto); + const selfService: Record = {}; + const staged: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value; + else staged[key] = value; + } + + let live = company; + if (Object.keys(selfService).length > 0) { + live = + (await this.companiesRepo.update( + company.id, + this.mapProfileDtoToCompanyUpdates(company, selfService), + )) ?? company; + live.companyProfiles = company.companyProfiles; + } + + if (Object.keys(staged).length === 0) { + // Nothing a reviewer needs to see. Any request already open (a document + // upload, an owner verification) still surfaces so its banner survives — + // it just no longer gains fields it was never asked to review. + return new ProfileResponseDto( + profile, + live, + await this.changeRequestRepo.findLatestOpenByCompanyId(company.id), + ); + } const existing = await this.changeRequestRepo.findPendingByCompanyId( company.id, @@ -894,7 +945,7 @@ export class CompaniesService { if (existing) { request = (await this.changeRequestRepo.update(existing.id, { - snapshot: { ...(existing.snapshot ?? {}), ...fields }, + snapshot: { ...(existing.snapshot ?? {}), ...staged }, submittedBy: userId, submittedAt: now, note: null, @@ -910,7 +961,7 @@ export class CompaniesService { ); request = await this.changeRequestRepo.create({ companyId: company.id, - snapshot: fields, + snapshot: staged, status: ChangeRequestStatus.Pending, submittedBy: userId, submittedAt: now, @@ -922,8 +973,9 @@ export class CompaniesService { ); } - // Live company is unchanged; surface the pending state for the settings page. - return new ProfileResponseDto(profile, company, request); + // Only the personnel half (if any) landed; surface the pending state for + // the settings page. + return new ProfileResponseDto(profile, live, request); } /** List a company's change requests, newest first (backoffice review). */ @@ -1720,16 +1772,11 @@ export class CompaniesService { const poaProvided = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); - // An Ethiopian company does not type its PoA details at all — they arrive - // from the Fayda verification — so reporting them as missing fields would - // ask for something the form no longer offers. The identity block below + // No company types its PoA details — they arrive from the Fayda + // verification whatever the nationality — so reporting them as missing + // fields would ask for something no form offers. The identity block below // reports "verify your PoA" instead. - const missingPoaFields = - poaRequired && !identity.faydaRequired - ? REQUIRED_POA_FIELDS.filter( - (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), - ) - : []; + const missingPoaFields: typeof REQUIRED_POA_FIELDS = []; const delegation = await this.getPoaDelegationState(company.id); const delegationDue = poaRequired || poaProvided; const missingDelegation = delegationDue && !delegation.onFile; @@ -1754,9 +1801,7 @@ export class CompaniesService { ...(identity.faydaRequired && !identity.owner.verified ? ["Verify the company owner's identity with Fayda"] : []), - ...(identity.faydaRequired && - (poaRequired || poaProvided) && - !identity.poa.verified + ...((poaRequired || poaProvided) && !identity.poa.verified ? ["Verify your Power of Attorney's identity with Fayda"] : []), ...(identity.passportRequired && !identity.owner.passportNumber @@ -1768,26 +1813,20 @@ export class CompaniesService { // fields, required documents, one license per operational profile, and the // PoA details/paper whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; - const poaItemCount = - (poaRequired && !identity.faydaRequired - ? REQUIRED_POA_FIELDS.length - : 0) + (delegationDue ? 1 : 0); + const poaItemCount = delegationDue ? 1 : 0; // One item per identity credential the company has to prove: the owner - // always (Fayda for Ethiopian, passport for foreign), the PoA once there - // is one and Fayda is what's mandatory here. - const identityItemCount = identity.faydaRequired - ? delegationDue - ? 2 - : 1 - : identity.passportRequired - ? 1 - : 0; - const missingIdentityCount = identity.faydaRequired - ? (identity.owner.verified ? 0 : 1) + - (delegationDue && !identity.poa.verified ? 1 : 0) - : identity.passportRequired && !identity.owner.passportNumber - ? 1 - : 0; + // always (Fayda for Ethiopian, passport for foreign), plus the PoA once + // there is one — that one is Fayda whatever the nationality. + const ownerCredentialDue = + identity.faydaRequired || identity.passportRequired; + const ownerCredentialProven = identity.faydaRequired + ? identity.owner.verified + : Boolean(identity.owner.passportNumber); + const identityItemCount = + (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0); + const missingIdentityCount = + (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + + (delegationDue && !identity.poa.verified ? 1 : 0); const total = requiredInfo.length + requiredDocCount + @@ -2453,11 +2492,13 @@ export class CompaniesService { ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; - // An approved company's profile edits are staged for backoffice review, and - // swapping the person who can act for the company is exactly the kind of - // edit that review exists for — so a verification lands the same way an - // ordinary edit does, rather than quietly rewriting a live record. - if (company.status === CompanyStatus.Active) { + // An approved company's *owner* is its identity proof, so re-verifying one + // is staged for backoffice review rather than quietly rewriting a live + // record. The PoA is personnel — the company names its own representative, + // and the delegation letter backing them is what the reviewer sees — so a + // PoA verification lands live, matching the typed PoA fields in + // `SELF_SERVICE_ATTRIBUTES`. + if (company.status === CompanyStatus.Active && dto.subject !== "poa") { await this.stageIdentityChange(company, userId, identity); return this.getCompanyIdentityState(company); } @@ -2586,21 +2627,23 @@ export class CompaniesService { ): 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.", ); } - return; - } - - if (!state.owner.verified) { + } else if (!state.owner.verified) { throw new BadRequestException( "Verify the company owner's identity with Fayda before continuing.", ); } + // The representative is not. A PoA acts for the company inside Ethiopia + // whoever owns it, so they are always an Ethiopian holding a Fayda ID — + // a foreign company nominates one rather than typing a name. const poaNamed = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); 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 6e7468c8a..a9988cd28 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 @@ -144,9 +144,14 @@ export function buildCompanyIdentityState( (p) => p.type === ProfileType.freightForwarder, ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); - const complete = faydaRequired - ? owner.verified && (!poaDue || poa.verified) + // Only the *owner's* credential is nationality-specific. A Power of Attorney + // acts for the company inside Ethiopia whoever owns it, so the PoA is always + // proven with Fayda — a foreign company nominates a representative who holds + // one rather than typing a name nothing backs. + const ownerProven = faydaRequired + ? owner.verified : !passportRequired || Boolean(owner.passportNumber); + const complete = ownerProven && (!poaDue || poa.verified); return { faydaRequired, passportRequired, owner, poa, complete }; } diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 81d1a2a0d..c0fc6fc09 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -1,4 +1,9 @@ -import { BadGatewayException, Injectable, Logger } from "@nestjs/common"; +import { + BadGatewayException, + BadRequestException, + Injectable, + Logger, +} from "@nestjs/common"; import { HttpService } from "@nestjs/axios"; import { AxiosError } from "axios"; import { firstValueFrom } from "rxjs"; @@ -67,6 +72,33 @@ export class PaymentClientService { } } + /** + * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider + * (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service; + * surface that as a BadRequest (retryable) rather than a 502, so the payer can + * re-enter the code. + */ + async confirmOtp(intentId: string, otp: string): Promise { + try { + return await this.call( + "POST", + `/payments/intents/${intentId}/confirm`, + { otp }, + ); + } catch (err) { + // `call` re-throws raw 404s and masks every other 4xx as BadGateway; an + // unknown intent or a bad OTP is client-fixable, so translate both to 400. + if (err instanceof AxiosError && err.response?.status === 404) { + throw new BadRequestException("PaymentIntent not found"); + } + if (err instanceof BadGatewayException) { + const detail = err.message.replace(/^Payment service error: /, ""); + throw new BadRequestException(detail); + } + throw err; + } + } + private async call(method: "GET" | "POST", path: string, body?: unknown): Promise { const url = `${this.baseUrl}${path}`; try { diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 05267746d..961aa32bd 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] { @Module({ imports: [ - HttpModule.register({ timeout: 10_000 }), + // CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old + // 10s cap 502'd every CAC charge while the bank was still working — + // orphaning an intent the payer had already been texted about. Matches + // the passenger API's budget. + HttpModule.register({ + timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, + }), ConfigModule, forwardRef(() => BillingModule), // forwardRef(() => TrainSchedulingModule), diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts new file mode 100644 index 000000000..ed0f8da58 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -0,0 +1,190 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { of, throwError } from "rxjs"; +import { AxiosError, AxiosHeaders } from "axios"; +import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types"; + +import { PaymentClientService } from "./payment-client.service"; +import { PaymentService } from "./payment.service"; + +/** Local intent projection row (the invoice's `paymentId` points at this). */ +function localIntent(overrides: Record = {}) { + return { + id: "intent-1", + refId: "booking-1", + referenceType: PaymentReferenceType.SHIPMENT, + status: "action-required", + method: "cac-bank", + merchantOrderId: "EDR_INV_1", + clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" }, + ...overrides, + }; +} + +function makeRepo(rows: Record[]) { + const store = [...rows]; + return { + findOneBy: jest.fn((where: Record) => + Promise.resolve( + store.find((r) => + Object.entries(where).every(([k, v]) => r[k] === v), + ) ?? null, + ), + ), + update: jest.fn((where: { id: string }, data: Record) => { + const row = store.find((r) => r.id === where.id); + if (row) Object.assign(row, data); + return Promise.resolve(undefined); + }), + }; +} + +describe("PaymentService.confirmOtp", () => { + const build = ( + client: Partial, + rows = [localIntent()], + ) => { + const repo = makeRepo(rows); + const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) }; + const service = new PaymentService( + repo as never, + client as never, + billing as never, + ); + return { service, repo, billing }; + }; + + it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => { + const paidAt = "2026-07-31T10:00:00.000Z"; + const { service, repo, billing } = build({ + getIntentByReference: jest + .fn() + .mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }), + confirmOtp: jest.fn().mockResolvedValue({ + intentId: "gw-1", + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: "11709363209530624", + paidAt, + }), + }); + + const result = await service.confirmOtp("intent-1", "8280"); + + expect(repo.update).toHaveBeenCalledWith( + { id: "intent-1" }, + expect.objectContaining({ + status: "success", + transactionId: "11709363209530624", + }), + ); + // Billing settles the invoice linked by this intent id. + expect(billing.settleByPaymentId).toHaveBeenCalledWith( + "intent-1", + "11709363209530624", + new Date(paidAt), + ); + expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED); + }); + + it("forwards the OTP against the GATEWAY intent id, not the local one", async () => { + const confirmOtp = jest + .fn() + .mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION }); + const { service } = build({ + getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }), + confirmOtp, + }); + + await service.confirmOtp("intent-1", "8280"); + + expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280"); + }); + + it("leaves the intent open and does not settle when the OTP is not accepted", async () => { + const { service, repo, billing } = build({ + getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }), + confirmOtp: jest.fn().mockResolvedValue({ + status: ProviderPaymentStatus.REQUIRES_ACTION, + failureMessage: "OTP confirmation failed", + }), + }); + + const result = await service.confirmOtp("intent-1", "0000"); + + expect(billing.settleByPaymentId).not.toHaveBeenCalled(); + expect(repo.update).toHaveBeenCalledWith( + { id: "intent-1" }, + expect.objectContaining({ status: "action-required" }), + ); + expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION); + }); + + it("404s when the gateway has no active intent for the reference", async () => { + const { service } = build({ + getIntentByReference: jest.fn().mockResolvedValue(null), + confirmOtp: jest.fn(), + }); + + await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); + +describe("PaymentClientService.confirmOtp", () => { + const axiosErr = (status: number, message: string) => + new AxiosError( + `Request failed with status code ${status}`, + undefined, + undefined, + undefined, + { + status, + statusText: "", + data: { message }, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }, + ); + + const build = (request: jest.Mock) => + new PaymentClientService({ request } as never); + + it("posts the OTP to the payment service intent-confirm route", async () => { + const request = jest + .fn() + .mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } })); + + const result = await build(request).confirmOtp("gw-1", "8280"); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/payments/intents/gw-1/confirm"), + data: { otp: "8280" }, + }), + ); + expect(result.status).toBe("SUCCEEDED"); + }); + + it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => { + const request = jest + .fn() + .mockReturnValue( + throwError(() => axiosErr(400, "OTP confirmation failed")), + ); + + await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => { + const request = jest + .fn() + .mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found"))); + + await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 6cb359dba..0c166d164 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -374,6 +374,53 @@ export class PaymentService { return this.formatIntentStatus(refreshed ?? local); } + /** + * Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent + * id (the invoice's `paymentId`) so the right invoice settles even when several + * invoices share a domain reference. The active gateway intent is looked up by + * reference, the OTP is forwarded, and the projection is refreshed. On success + * billing settles the linked invoice (idempotent — the outbox path converges too). + * A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry. + */ + async confirmOtp(intentId: string, otp: string): Promise { + const local = await this.paymentRepo.findOneBy({ id: intentId }); + if (!local) throw new NotFoundException("PaymentIntent not found"); + + const snapshot = await this.paymentClient.getIntentByReference( + (local.referenceType as PaymentReferenceType) ?? + PaymentReferenceType.SHIPMENT, + local.refId, + ); + if (!snapshot) { + throw new NotFoundException("No active payment to confirm"); + } + + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); + + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markIntentSucceeded(local.id, { + providerTxnId: confirmed.providerTxnId, + paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined, + notify: true, + }); + } else { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(confirmed.status), + failerCode: confirmed.failureCode ?? undefined, + failureMessage: confirmed.failureMessage ?? undefined, + }, + ); + } + + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); + } + /** * Mark a gateway intent paid and (by default) notify billing to settle the * linked invoice. Idempotent — no-op when already success. Pass `notify: false` diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index c6544c072..928281042 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -201,6 +201,8 @@ export const URL_CONSTANTS = { MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`, MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`, PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`, + CONFIRM_INVOICE_OTP: (id: string) => + `/api/billing/my-invoices/${id}/confirm`, }, WAREHOUSE_INVOICES: { diff --git a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts new file mode 100644 index 000000000..04c91a683 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts @@ -0,0 +1,115 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { useState } from "react"; + +import { invoicesService } from "@/services/invoices.service"; +import { + paymentsService, + type InitiateResponse, + type PaymentMethod, +} from "@/services/payments.service"; + +/** How the invoice is charged — overridable for warehouse fee invoices. */ +type InitiateFn = ( + invoiceId: string, + method: PaymentMethod, + payerAccount?: string, +) => Promise; + +const payViaBilling: InitiateFn = (invoiceId, method, payerAccount) => + invoicesService.pay(invoiceId, { method, platform: "web", payerAccount }); + +/** The server's message (`{ message }` / `{ message: [] }`), or a fallback. */ +function apiMessage(err: unknown, fallback: string): string { + const message = (err as AxiosError<{ message?: string | string[] }>)?.response + ?.data?.message; + const first = Array.isArray(message) ? message[0] : message; + return first || fallback; +} + +/** + * One payment flow for every "pay this invoice" entry point: initiate, then + * either redirect to the provider or — for CAC Bank, an OTP debit with no + * redirect — collect the SMS'd code and confirm it in-app. Pass `initiate` to + * charge through a different endpoint (warehouse fee invoices); OTP + * confirmation always goes through billing, which owns the intent either way. + */ +export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { + const [otpInvoiceId, setOtpInvoiceId] = useState(null); + const [otpMessage, setOtpMessage] = useState(); + + const payMutation = useMutation({ + mutationFn: (vars: { + invoiceId: string; + method: PaymentMethod; + payerAccount?: string; + }) => initiate(vars.invoiceId, vars.method, vars.payerAccount), + onSuccess: (data, vars) => { + if (data?.clientAction?.type === "COLLECT_OTP") { + setOtpMessage( + data.clientAction.message ?? "Enter the OTP sent to your phone", + ); + setOtpInvoiceId(vars.invoiceId); + return; + } + window.location.href = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrlForInvoice({ + invoiceId: vars.invoiceId, + method: vars.method, + }); + }, + }); + + const otpMutation = useMutation({ + mutationFn: (otp: string) => + invoicesService.confirmOtp(otpInvoiceId as string, otp), + // Settled — reload so the invoice/booking re-reads its now-paid state. + onSuccess: () => { + setOtpInvoiceId(null); + window.location.reload(); + }, + }); + + const reset = () => { + payMutation.reset(); + otpMutation.reset(); + setOtpInvoiceId(null); + }; + + return { + processing: payMutation.isPending, + error: payMutation.isError + ? apiMessage( + payMutation.error, + payMutation.error instanceof Error + ? payMutation.error.message + : "Could not start payment. Please try again.", + ) + : null, + pay: (invoiceId: string, method: PaymentMethod, payerAccount?: string) => + payMutation.mutate({ invoiceId, method, payerAccount }), + reset, + /** Drives the modal's OTP step; `open` only for CAC Bank. */ + otp: { + open: otpInvoiceId !== null, + message: otpMessage, + submitting: otpMutation.isPending, + // A wrong/expired OTP is a 400 — keep the step open so the payer retries. + error: otpMutation.isError + ? apiMessage( + otpMutation.error, + "Invalid or expired OTP. Please try again.", + ) + : null, + submit: (otp: string) => otpMutation.mutate(otp), + cancel: () => { + otpMutation.reset(); + setOtpInvoiceId(null); + }, + }, + }; +} + +export type InvoicePaymentFlow = ReturnType; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx index ecd3d7df2..9581a4f3d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core"; import { AlertTriangle, @@ -10,9 +10,8 @@ import { PackagePlus, } from "lucide-react"; -import { api } from "@/services/api"; import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper"; -import { paymentsService, type PaymentMethod } from "@/services/payments.service"; +import { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { invoicesService } from "@/services/invoices.service"; import { isPayable } from "@/pages/billing/invoice-ui"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; @@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { isPayable(inv.status), )?.id; - const payMutation = useMutation({ - mutationFn: (method: PaymentMethod) => { - if (!payableInvoiceId) { - throw new Error( - "No payable invoice found for this booking yet. Please refresh or contact support.", - ); - } - return api.invoices.pay.call({ - id: payableInvoiceId, - payload: { method, platform: "web" }, - }); - }, - onSuccess: (data, method) => { - const url = - data?.clientAction?.type === "REDIRECT" && data.clientAction.url - ? data.clientAction.url - : paymentsService.checkoutUrlForInvoice({ - invoiceId: payableInvoiceId!, - method, - }); - window.location.href = url; - }, - }); + const pay = useInvoicePayment(); if (items.length === 0) return null; @@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { { - if (!payMutation.isPending) { + if (!pay.processing) { setPayItem(null); - payMutation.reset(); + pay.reset(); } }} currency={undefined} - processing={payMutation.isPending} + processing={pay.processing} error={ - payMutation.isError - ? payMutation.error instanceof Error - ? payMutation.error.message - : "Could not start payment. Please try again." - : null + pay.error ?? + (payItemInvoices.length > 0 && !payableInvoiceId + ? "No payable invoice found for this booking yet. Please refresh or contact support." + : null) + } + otp={pay.otp} + onConfirm={(method, payerAccount) => + payableInvoiceId && + pay.pay(payableInvoiceId, method, payerAccount) } - onConfirm={(method) => payMutation.mutate(method)} /> diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index ba19e34ce..8bbe59284 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -287,9 +287,11 @@ export default function SettingsPage() { icon={} title="Changes submitted for review" > - Your recent changes are awaiting administrator approval. Editing is - disabled until the review is complete — you'll be notified once it's - approved or if any changes are requested. + Your recent changes are awaiting administrator approval. Company + details and documents can't be edited until the review is complete — + you'll be notified once it's approved or if any changes are + requested. Your contact person, general manager and Power of + Attorney stay editable. )} {reviewStatus === "rejected" && ( @@ -358,9 +360,17 @@ export default function SettingsPage() { )} - {/* While a change request is pending, every panel's inputs + submit - buttons are disabled via the native fieldset; tab switching stays - enabled so the customer can still review what they submitted. */} + {/* While a change request is pending, the reviewed panels' inputs + + submit buttons are disabled via the native fieldset; tab switching + stays enabled so the customer can still review what they + submitted. + + Personnel panels below (contact person, general manager, Power of + Attorney) are deliberately outside the lock: the API applies those + edits live rather than staging them, so locking them here would + re-impose the approval wait the API no longer does. The PoA's + delegation letter is still reviewed — that lock lives on the file + itself, not the panel. */}
@@ -368,19 +378,13 @@ 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 e79d06bf6..9c62b494d 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -33,7 +33,6 @@ import { buildOnboardingSchema, type CompanyStep, type FormData, - hasPoaDetails, POA_DELEGATION_FILE_KEY, stepFields, } from "./companyProfileForm/schema"; @@ -191,11 +190,7 @@ export default function CompanyProfileForm({ formState: { errors }, } = useForm({ resolver: zodResolver( - buildOnboardingSchema( - requirePoa, - verifiedIdentity, - identity?.passportRequired === true, - ), + buildOnboardingSchema(identity?.passportRequired === true), ), defaultValues: { companyName: "", @@ -321,7 +316,6 @@ export default function CompanyProfileForm({ // them and re-enables editing. const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false); - const [poaSameAsContact, setPoaSameAsContact] = useState(false); // General Manager source. The company step's email/phone are seeded from // eTrade (and the account email) but stay editable, so the link reads the @@ -367,9 +361,6 @@ export default function CompanyProfileForm({ const gmName = watch("generalManagerName"); const gmEmail = watch("generalManagerEmail"); const gmPhone = watch("generalManagerPhone"); - const contactName = watch("contactPersonName"); - const contactEmail = watch("contactPersonEmail"); - const contactPhone = watch("contactPersonPhone"); // 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. @@ -381,26 +372,6 @@ export default function CompanyProfileForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [contactSameAsGm, gmName, gmEmail, gmPhone]); - // The contact-person step has no address of its own, so the linked PoA takes - // the company's composed address. poaLocation (the city) stays typed on the - // PoA step — the company step no longer has a location field to mirror. - const companyAddress = watch("companyAddress"); - - useEffect(() => { - if (!poaSameAsContact) return; - setValue("poaName", contactName ?? ""); - setValue("poaEmail", contactEmail ?? ""); - setValue("poaPhone", contactPhone ?? ""); - setValue("poaAddress", companyAddress ?? ""); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - poaSameAsContact, - contactName, - contactEmail, - contactPhone, - companyAddress, - ]); - const toggleContactSameAsGm = (checked: boolean) => { setContactSameAsGm(checked); // Checked → the mirror effect fills the fields; unchecked → reset them. @@ -411,17 +382,6 @@ export default function CompanyProfileForm({ } }; - const togglePoaSameAsContact = (checked: boolean) => { - setPoaSameAsContact(checked); - if (!checked) { - setValue("poaName", ""); - setValue("poaEmail", ""); - setValue("poaPhone", ""); - setValue("poaLocation", ""); - setValue("poaAddress", ""); - } - }; - // 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 @@ -551,7 +511,9 @@ export default function CompanyProfileForm({ // for a freight forwarder, whose PoA itself is mandatory. The API enforces // the same rule on save, so skipping it here only costs the customer a // round-trip. - const poaProvided = hasPoaDetails(watch()); + // A PoA exists exactly when one has been verified — the details are the + // verification's output, so there is nothing else that could stand for one. + const poaProvided = identity?.poa.verified ?? false; const delegationRequired = requirePoa || poaProvided; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || @@ -638,12 +600,7 @@ export default function CompanyProfileForm({ setSaveError("Verify the company owner's identity with Fayda before continuing."); return; } - if ( - step === "poa" && - verifiedIdentity && - requirePoa && - !identity?.poa.verified - ) { + if (step === "poa" && requirePoa && !identity?.poa.verified) { setSaveError( "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.", ); @@ -876,71 +833,27 @@ export default function CompanyProfileForm({ ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required." : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."} + {/* A representative acts for the company inside Ethiopia + whoever owns it, so the PoA is proven with Fayda regardless of + nationality — their name, email, phone and address all come + from the verification and are never typed here. */} {identity && ( onIdentityChange?.()} /> )} - {!verifiedIdentity && watch("contactPersonName") && ( - - )} - {!verifiedIdentity && ( - <> - - - - - - - - - - - )} {/* The city is the one field the Fayda address claim does not - reliably decompose into, so it stays typed either way. */} - {verifiedIdentity && ( - - )} + reliably decompose into, so it stays typed. */} + {poaDocumentSetting && ( <> diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index 944177ae3..f1a9f4301 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 @@ -37,10 +37,8 @@ export function buildPayload( generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, generalManagerPhone: data.generalManagerPhone, - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaAddress: data.poaAddress || undefined, - poaEmail: data.poaEmail || undefined, + // The representative's own details are written by their Fayda + // verification, so the city is all the form has to send. poaLocation: data.poaLocation || undefined, }, }; @@ -88,13 +86,7 @@ export function stepPayload( contactPersonPhone: d.contactPersonPhone, }; case "poa": - return { - poaName: d.poaName || undefined, - poaPhone: d.poaPhone || undefined, - poaEmail: d.poaEmail || undefined, - poaLocation: d.poaLocation || undefined, - poaAddress: d.poaAddress || undefined, - }; + return { poaLocation: d.poaLocation || undefined }; default: return {}; } 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 4732678cc..2cd06ef8f 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 @@ -92,58 +92,28 @@ export type FormData = z.infer; /** fileKey of the delegation letter uploaded on the Power of Attorney step. */ export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; -export const POA_FIELDS = [ - "poaName", - "poaPhone", - "poaEmail", - "poaLocation", - "poaAddress", -] as const satisfies readonly (keyof FormData)[]; - -/** True once the customer has entered any Power of Attorney detail. */ -export const hasPoaDetails = (d: Partial) => - POA_FIELDS.some((f) => d[f]?.trim()); /** - * A freight forwarder acts on other companies' behalf, so its PoA is mandatory - * rather than optional. Everyone else keeps the optional PoA — but once they - * start filling it in, the identifying fields have to be complete (the - * delegation-letter upload is enforced alongside this, in CompanyProfileForm, - * since files live outside the form state). + * The PoA's identifying fields are never typed — they come from the Fayda + * verification, whatever the company's nationality — so nothing here requires + * them. A freight forwarder's mandatory PoA is gated on the verification + * itself, and its delegation letter alongside it, both in CompanyProfileForm + * (files live outside form state). + * + * That leaves the owner's passport number as the only conditional field. */ export function buildOnboardingSchema( - requirePoa: boolean, - /** - * True when the PoA's identity fields come from a Fayda verification rather - * than the form (Ethiopian companies). Requiring them here would fail - * validation against inputs the step no longer renders — the verification - * itself is what the step gates on instead. - */ - faydaOwnedPoa = false, /** True for a foreign company: the owner's passport number is mandatory. */ passportRequired = false, ) { - const poaRequired = requirePoa && !faydaOwnedPoa; - if (!poaRequired && !passportRequired) return onboardingSchema; + if (!passportRequired) return onboardingSchema; return onboardingSchema.superRefine((d, ctx) => { - const required: [keyof FormData, string][] = []; - if (poaRequired) { - required.push( - ["poaName", "PoA name is required for freight forwarders"], - ["poaEmail", "PoA email is required for freight forwarders"], - ["poaPhone", "PoA phone is required for freight forwarders"], - ); - } - if (passportRequired) { - required.push([ - "ownerPassportNumber", - "The owner's passport number is required", - ]); - } - for (const [path, message] of required) { - if (!d[path]?.trim()) { - ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message }); - } + if (!d.ownerPassportNumber?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ownerPassportNumber"], + message: "The owner's passport number is required", + }); } }); } @@ -180,7 +150,7 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - poa: [...POA_FIELDS], + poa: ["poaLocation"], documents: [], additional: [], }; diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 8c0bbb884..30cb1597d 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Alert, Box, @@ -27,10 +27,7 @@ import toast from "react-hot-toast"; import { api } from "@/services/api"; import { invoicesService } from "@/services/invoices.service"; -import { - paymentsService, - type PaymentMethod, -} from "@/services/payments.service"; +import { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { warehouseInvoicesService } from "@/services/warehouse-invoices.service"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; import { saveBlob } from "@/utils/download"; @@ -76,17 +73,7 @@ export default function InvoiceDetailPage() { // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges // one of the signed-in customer's own invoices (unlike the admin-facing // /payments/initiate, which takes any invoiceId with no ownership check). - const payMutation = useMutation({ - mutationFn: (method: PaymentMethod) => - api.invoices.pay.call({ id, payload: { method, platform: "web" } }), - onSuccess: (data, method) => { - const redirectUrl = - data?.clientAction?.type === "REDIRECT" && data.clientAction.url - ? data.clientAction.url - : paymentsService.checkoutUrlForInvoice({ invoiceId: id, method }); - window.location.href = redirectUrl; - }, - }); + const pay = useInvoicePayment(); if (isLoading) { return ( @@ -249,7 +236,7 @@ export default function InvoiceDetailPage() { radius="md" size="md" leftSection={} - loading={payMutation.isPending} + loading={pay.processing} onClick={handlePay} styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 }, @@ -376,22 +363,19 @@ export default function InvoiceDetailPage() { { - if (!payMutation.isPending) { + if (!pay.processing) { setPayModalOpen(false); - payMutation.reset(); + pay.reset(); } }} amountLabel={formatCurrency(amountDue, invoice.currency)} currency={invoice.currency} - processing={payMutation.isPending} - error={ - payMutation.isError - ? payMutation.error instanceof Error - ? payMutation.error.message - : "Could not start payment. Please try again." - : null + processing={pay.processing} + error={pay.error} + otp={pay.otp} + onConfirm={(method, payerAccount) => + pay.pay(id, method, payerAccount) } - onConfirm={(method) => payMutation.mutate(method)} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 9d9116bab..e04b8f2cd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,14 +1,8 @@ import { Group, Tabs } from "@mantine/core"; -import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, FileText, LayoutGrid } from "lucide-react"; -import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { api } from "@/services/api"; import { useFileViewer } from "@/hooks/useFileViewer"; -import { invoicesService } from "@/services/invoices.service"; -import { paymentsService, type PaymentMethod } from "@/services/payments.service"; -import { isPayable } from "@/pages/billing/invoice-ui"; import type { Freight } from "@edr/types"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; @@ -41,6 +35,7 @@ import { StatusHero } from "./components/StatusHero"; import { SupportCard } from "./components/SupportCard"; import { fmtDate, isNegative, priceTotal } from "./utils"; import { useScrollToHash } from "@/hooks/useScrollToHash"; +import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment"; export function ReadonlyBookingView({ booking, @@ -53,7 +48,6 @@ export function ReadonlyBookingView({ // Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice. useScrollToHash(); const status = booking.status as string; - const [payModalOpen, setPayModalOpen] = useState(false); const { viewer } = useFileViewer(); // Re-book opens the New Shipment Booking form for the same contract, not the @@ -63,44 +57,11 @@ export function ReadonlyBookingView({ : "/contracts/new"; const onRebook = () => navigate(rebookTo); - // Billing is invoice-centric — resolve the booking's currently payable - // invoice (same query/key BookingPaymentPanel uses, so this shares its - // cache) and pay it through the ownership-checked portal route. - const { data: bookingInvoices = [] } = useQuery({ - queryKey: ["booking-invoices", booking.id], - queryFn: () => invoicesService.listForSource("booking", booking.id), - }); - const payableInvoiceId = bookingInvoices.find((inv) => - isPayable(inv.status), - )?.id; - - // POST /billing/my-invoices/:id/pay creates the intent and returns the - // provider's redirect URL (clientAction.url). Send the browser straight - // there; fall back to the public /payments/checkout page if no redirect - // URL came back. - const payMutation = useMutation({ - mutationFn: (method: PaymentMethod) => { - if (!payableInvoiceId) { - throw new Error( - "No payable invoice found for this booking yet. Please refresh or contact support.", - ); - } - return api.invoices.pay.call({ - id: payableInvoiceId, - payload: { method, platform: "web" }, - }); - }, - onSuccess: (data, method) => { - const redirectUrl = - data?.clientAction?.type === "REDIRECT" && data.clientAction.url - ? data.clientAction.url - : paymentsService.checkoutUrlForInvoice({ - invoiceId: payableInvoiceId!, - method, - }); - window.location.href = redirectUrl; - }, - }); + // Billing is invoice-centric — the shared hook resolves the booking's + // currently payable invoice (same query/key BookingPaymentPanel uses, so it + // shares that cache), charges it through the ownership-checked portal route, + // and handles redirect vs CAC Bank OTP. + const pay = useBookingPayment(booking.id); const pricing = booking.pricingBreakdown; // A general contract is paid once it's FULLY_EXECUTED (signed) — it never @@ -171,7 +132,7 @@ export function ReadonlyBookingView({ green icon={} label="Pay now" - onClick={() => setPayModalOpen(true)} + onClick={pay.open} /> )} @@ -278,8 +239,8 @@ export function ReadonlyBookingView({ setPayModalOpen(true)} - paying={payMutation.isPending} + onPay={pay.open} + paying={pay.processing} showCountdown={showCountdown} /> { - if (!payMutation.isPending) { - setPayModalOpen(false); - payMutation.reset(); - } - }} + opened={pay.modalOpen} + onClose={pay.close} amountLabel={pricing ? priceTotal(pricing) : undefined} currency={pricing?.currency ?? booking.paymentCurrency} - processing={payMutation.isPending} - error={ - payMutation.isError - ? payMutation.error instanceof Error - ? payMutation.error.message - : "Could not start payment. Please try again." - : null - } - onConfirm={(method) => payMutation.mutate(method)} + processing={pay.processing} + error={pay.error} + otp={pay.otp} + onConfirm={pay.confirm} /> {viewer} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 6e62888c8..0cc6d7bff 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -1,20 +1,32 @@ -import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core"; -import { Check, ShieldCheck } from "lucide-react"; +import { + Box, + Button, + Group, + Image, + Modal, + PinInput, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { Check, Landmark, ShieldCheck } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment"; import type { PaymentMethod } from "@/services/payments.service"; interface ProviderOption { method: PaymentMethod; label: string; description: string; - logo: string; + /** Logo asset; falls back to a bank glyph when the provider has none. */ + logo?: string; /** Currencies this provider settles in. */ currencies: string[]; accent: string; } -// Only Telebirr and Waafi are enabled for now. +// Only Telebirr, Waafi and CAC Bank are enabled for now. const PROVIDERS: ProviderOption[] = [ { method: "TELEBIRR", @@ -32,8 +44,18 @@ const PROVIDERS: ProviderOption[] = [ currencies: ["USD"], accent: "#2E5B96", }, + { + method: "CAC_BANK", + label: "CAC Bank", + description: "Djibouti bank debit · confirmed by SMS OTP", + currencies: ["USD"], + accent: "#8A5A17", + }, ]; +/** Providers that debit against an SMS OTP instead of redirecting to a page. */ +const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK"; + /** * Pick the provider that settles in the booking's currency. USD → Waafi, * ETB → Telebirr. Falls back to the first provider when unknown. @@ -91,13 +113,27 @@ function ProviderRow({ backgroundColor: "#fff", }} > - {`${option.label} + {option.logo ? ( + {`${option.label} + ) : ( + + + + )} @@ -135,6 +171,7 @@ export function PaymentMethodModal({ onConfirm, processing, error, + otp, }: { opened: boolean; onClose: () => void; @@ -142,12 +179,19 @@ export function PaymentMethodModal({ amountLabel?: string; /** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */ currency?: string | null; - onConfirm: (method: PaymentMethod) => void; + onConfirm: (method: PaymentMethod, payerAccount?: string) => void; processing?: boolean; error?: string | null; + /** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */ + otp?: InvoicePaymentFlow["otp"]; }) { - const providers = useMemo(() => providersForCurrency(currency), [currency]); + const providers = useMemo( + () => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)), + [currency, otp], + ); const [method, setMethod] = useState(providers[0].method); + const [mobile, setMobile] = useState(""); + const [code, setCode] = useState(""); // Keep the selection valid when the currency (and therefore provider list) changes. useEffect(() => { @@ -156,6 +200,89 @@ export function PaymentMethodModal({ } }, [providers, method]); + // A fresh OTP round always starts empty. + useEffect(() => { + if (otp?.open) setCode(""); + }, [otp?.open]); + + // CAC Bank debits the account behind this number and SMSes the OTP to it. + const needsMobile = isOtpMethod(method); + const canSubmit = !needsMobile || mobile.trim().length > 0; + + if (otp?.open) { + return ( + + + + Enter OTP + + + {otp.message} + + + + otp.submit(value)} + aria-label="One-time password" + /> + + + {otp.error && ( + + {otp.error} + + )} + + + + + + + + ); + } + return ( ))} + + {needsMobile && ( + setMobile(e.currentTarget.value)} + disabled={processing} + styles={{ + label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" }, + description: { fontSize: 11.5 }, + }} + /> + )} {/* Footer */} @@ -228,7 +371,9 @@ export function PaymentMethodModal({ - Secured · you'll be redirected to your provider to pay + {needsMobile + ? "Secured · you'll confirm with the OTP sent to your phone" + : "Secured · you'll be redirected to your provider to pay"} @@ -248,15 +393,21 @@ export function PaymentMethodModal({ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index 927f1dbfb..a146dfb11 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -1,10 +1,10 @@ import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { CreditCard, Download, FileText, Receipt } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; -import { paymentsService, type PaymentMethod } from "@/services/payments.service"; +import { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { warehouseInvoicesService, type PortalWarehouseInvoice, @@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { const [payInvoice, setPayInvoice] = useState(null); - const payMutation = useMutation({ - mutationFn: (method: PaymentMethod) => { - if (!payInvoice) throw new Error("No invoice selected for payment."); - return warehouseInvoicesService.payOnline(payInvoice.id, { - method, - platform: "web", - }); - }, - onSuccess: (data, method) => { - if (!payInvoice) return; - // Redirect to the provider (or the fallback checkout page) — same as the - // booking "Pay now" flow, so behaviour is identical everywhere. - const redirectUrl = - data?.clientAction?.type === "REDIRECT" && data.clientAction.url - ? data.clientAction.url - : paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method }); - window.location.href = redirectUrl; - }, - }); - - const payError = payMutation.isError - ? payMutation.error instanceof Error - ? payMutation.error.message - : "Could not start payment. Please try again." - : null; + // Warehouse fees are charged through the warehouse route, but they are the + // same central invoices — so redirect vs CAC Bank OTP is the shared flow. + const pay = useInvoicePayment((invoiceId, method, payerAccount) => + warehouseInvoicesService.payOnline(invoiceId, { + method, + platform: "web", + payerAccount, + }), + ); const closePayModal = () => { - if (!payMutation.isPending) { + if (!pay.processing) { setPayInvoice(null); - payMutation.reset(); + pay.reset(); } }; @@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined } currency={payInvoice?.currency} - onConfirm={(method) => payMutation.mutate(method)} - processing={payMutation.isPending} - error={payError} + onConfirm={(method, payerAccount) => + payInvoice && pay.pay(payInvoice.id, method, payerAccount) + } + processing={pay.processing} + error={pay.error} + otp={pay.otp} /> ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx index fca38aec7..7a9949e8a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -55,6 +55,7 @@ export function PayNowButton({ currency={pricing?.currency ?? booking.paymentCurrency} processing={pay.processing} error={pay.error} + otp={pay.otp} onConfirm={pay.confirm} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts index 9725fea12..bb1bd33ab 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts @@ -1,23 +1,22 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; -import { api } from "@/services/api"; -import { - paymentsService, - type PaymentMethod, -} from "@/services/payments.service"; +import { useInvoicePayment } from "@/hooks/useInvoicePayment"; +import { type PaymentMethod } from "@/services/payments.service"; import { invoicesService } from "@/services/invoices.service"; import { isPayable } from "@/pages/billing/invoice-ui"; /** * Shared payment flow for a single booking: opens the method modal, fires * POST /billing/my-invoices/:id/pay for the booking's currently payable - * invoice, and redirects the browser to the provider (or the fallback - * checkout page). Reused by the booking detail page, the booking list, and - * the home page so "Pay now" behaves identically everywhere. + * invoice, and redirects the browser to the provider (or, for CAC Bank, an + * OTP debit with no redirect, collects the SMS'd code in the modal). Reused by + * the booking detail page, the booking list, and the home page so "Pay now" + * behaves identically everywhere. */ export function useBookingPayment(bookingId: string) { const [modalOpen, setModalOpen] = useState(false); + const [noInvoice, setNoInvoice] = useState(false); const { data: invoices = [] } = useQuery({ queryKey: ["booking-invoices", bookingId], @@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) { }); const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id; - const mutation = useMutation({ - mutationFn: (method: PaymentMethod) => { - if (!payableInvoiceId) { - throw new Error( - "No payable invoice found for this booking yet. Please refresh or contact support.", - ); - } - return api.invoices.pay.call({ - id: payableInvoiceId, - payload: { method, platform: "web" }, - }); - }, - onSuccess: (data, method) => { - const redirectUrl = - data?.clientAction?.type === "REDIRECT" && data.clientAction.url - ? data.clientAction.url - : paymentsService.checkoutUrlForInvoice({ - invoiceId: payableInvoiceId!, - method, - }); - window.location.href = redirectUrl; - }, - }); + const flow = useInvoicePayment(); const open = () => setModalOpen(true); const close = () => { - if (!mutation.isPending) { + if (!flow.processing) { setModalOpen(false); - mutation.reset(); + setNoInvoice(false); + flow.reset(); } }; - const error = mutation.isError - ? mutation.error instanceof Error - ? mutation.error.message - : "Could not start payment. Please try again." - : null; - return { modalOpen, open, close, - processing: mutation.isPending, - error, - confirm: (method: PaymentMethod) => mutation.mutate(method), + processing: flow.processing, + error: noInvoice + ? "No payable invoice found for this booking yet. Please refresh or contact support." + : flow.error, + otp: flow.otp, + confirm: (method: PaymentMethod, payerAccount?: string) => { + if (!payableInvoiceId) { + setNoInvoice(true); + return; + } + setNoInvoice(false); + flow.pay(payableInvoiceId, method, payerAccount); + }, }; } diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index 520e29d42..a3ef08078 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -39,20 +39,15 @@ import { type LicenseFile, type LicenseFileStatus, } from "@/services/companies.service"; -import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import { verifaydaService } from "@/services/verifayda.service"; import type { ProfileResponse } from "@/types/profile"; +// The representative's name, email, phone and address all come from their +// Fayda verification — a PoA is always an Ethiopian holding one — so the city +// is the only detail this form owns. const schema = z.object({ - poaName: z.string().optional(), - poaEmail: z.string().optional(), - poaPhone: z - .string() - .optional() - .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaLocation: z.string().optional(), - poaAddress: z.string().optional(), }); type FormData = z.infer; @@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({ const { view, viewer } = useFileViewer(); const uploadInputRef = useRef(null); - const defaultValues = useMemo((): FormData => { - return { - poaName: profile.poaName ?? "", - poaEmail: profile.poaEmail ?? "", - poaPhone: profile.poaPhone ?? "", - poaLocation: profile.poaLocation ?? "", - poaAddress: profile.poaAddress ?? "", - }; - }, [profile]); + const defaultValues = useMemo( + (): FormData => ({ poaLocation: profile.poaLocation ?? "" }), + [profile], + ); const { register, - control, handleSubmit, reset, - watch, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), @@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({ const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({})); const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]); - // The letter is staged locally, not uploaded on pick. Uploading immediately - // would open a change request, which locks the whole settings page (see - // SettingsPage's `locked` fieldset) before the text fields could be saved. - // Save submits the file and the fields together, into one change request. + // The letter is staged locally, not uploaded on pick: the paper is the one + // thing here that still goes to a reviewer, so picking it must not open a + // change request before the customer has committed to the save. Save submits + // the file and the fields together. const [pickedFile, setPickedFile] = useState(null); const [removeIds, setRemoveIds] = useState([]); const [saveBlocked, setSaveBlocked] = useState(false); @@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({ const requirePoa = profile.companyProfiles.some( (p) => p.type === "freight_forwarder", ); - // An Ethiopian company does not type its representative's details — they - // come from the Fayda verification. A foreign company keeps the typed form: - // its representative may hold no Fayda ID. + // No company types its representative's details — they come from the Fayda + // verification whatever the nationality, since a representative acts for the + // company inside Ethiopia either way. A PoA therefore exists exactly when one + // has been verified. const identity = profile.identity; - const verifiedIdentity = identity?.faydaRequired === true; - - const poaValues = watch([ - "poaName", - "poaEmail", - "poaPhone", - "poaLocation", - "poaAddress", - ]); - const poaProvided = verifiedIdentity - ? (identity?.poa.verified ?? false) - : poaValues.some((v) => v?.trim()); + const poaProvided = identity?.poa.verified ?? false; const letterRequired = requirePoa || poaProvided; const letterMissing = letterRequired && !hasLetterAfterSave; @@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({ const mutation = useMutation({ mutationFn: async (data: FormData) => { // Every identity field except the city is written by the verification, so - // an Ethiopian company only ever saves the paper and the location here. - const fields = verifiedIdentity - ? { poaLocation: data.poaLocation || undefined } - : { - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - poaAddress: data.poaAddress || undefined, - }; + // only the paper and the location are ever saved here. + const fields = { poaLocation: data.poaLocation || undefined }; // A fresh upload already stages the removal of every paper on file, so // the explicit removals only need applying when no replacement was // picked. Saving the details after it means the API sees the new paper. @@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({ subject="poa" title="Power of Attorney" state={identity.poa} - required={identity.faydaRequired} + required={requirePoa} disabled={mutation.isPending} - pendingReview={Boolean( - (profile.pendingChanges as { faydaIdentity?: Record } | null) - ?.faydaIdentity?.poaFaydaSub, - )} onVerified={() => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey(), @@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
- {/* Name, email, phone and address are written by the Fayda - verification for an Ethiopian company, so only the city — which - the address claim does not reliably decompose into — is typed. */} - {!verifiedIdentity && ( - <> - - - - - - - - - - - - )} - + {/* Name, email, phone and address are all written by the Fayda + verification, so only the city — which the address claim does + not reliably decompose into — is typed. */} - {!verifiedIdentity && ( - - - - )} @@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({ )} - {profile.reviewStatus === "pending" && ( + {/* Keyed on the paper's own staged status, not the company's + review state: the details on this tab now apply live, so a + pending review is just as likely to be about something else. */} + {letters.some((f) => f.status !== "live") && ( @@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({ {mode === "edit" && - verifiedIdentity && identity?.poa.verified && !requirePoa && (