mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
feat(payment): integrate CAC Bank OTP payments into freight flows
CAC Bank is an OTP debit with no redirect and no webhook: initiate SMSes a code to the payer's mobile, and the charge only settles when that code is confirmed. The payment service already spoke it (passenger uses it); the freight side had the enum values but none of the flow. API: - PaymentClientService.confirmOtp forwards the code to POST /payments/intents/:id/confirm, mapping 400/404 to BadRequest so a mistyped code stays retryable instead of surfacing as a gateway failure. - PaymentService.confirmOtp is keyed by the LOCAL intent id (the invoice's paymentId) rather than the domain reference, so the right invoice settles when several share a booking. On success billing settles the invoice. - payInvoice rejects CAC_BANK without payerAccount before calling the gateway, and no longer runs the demo auto-settle for a COLLECT_OTP intent (it is not paid until the payer confirms). - POST /billing/my-invoices/:id/confirm — ownership-checked, and since warehouse fee invoices are central invoices it covers those too. Portal: - useInvoicePayment owns the whole flow (initiate, redirect-or-OTP, confirm) and replaces the five near-identical pay mutations at the call sites. - PaymentMethodModal gains the CAC Bank option, the payer mobile field, and the OTP step. Click-outside is disabled there so a stray click cannot drop the payer out of a live OTP window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
|
|||||||
expect(manager.update).not.toHaveBeenCalled();
|
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<string, unknown>) => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
|||||||
|
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||||
import {
|
import {
|
||||||
InvoiceDocumentModel,
|
InvoiceDocumentModel,
|
||||||
InvoiceDocumentService,
|
InvoiceDocumentService,
|
||||||
@@ -352,6 +352,34 @@ export class BillingService {
|
|||||||
return this.payInvoice(id, opts);
|
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<IntentStatusDto> {
|
||||||
|
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<IntentStatusDto> {
|
||||||
|
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). */
|
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||||
async documentForUser(
|
async documentForUser(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -1035,6 +1063,17 @@ export class BillingService {
|
|||||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
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({
|
const result = await this.payment.initiate({
|
||||||
referenceId: invoice.sourceId,
|
referenceId: invoice.sourceId,
|
||||||
source: invoice.source,
|
source: invoice.source,
|
||||||
@@ -1062,7 +1101,12 @@ export class BillingService {
|
|||||||
|
|
||||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||||
// billing must not simulate it. Kept commented for local demos only.
|
// 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({
|
await this.payment.handlePaymentEvent({
|
||||||
eventType: "payment.succeeded",
|
eventType: "payment.succeeded",
|
||||||
eventId: `demo-${result.intentId}`,
|
eventId: `demo-${result.intentId}`,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
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. */
|
/** Gateway options for paying an invoice from the customer portal. */
|
||||||
export class PayInvoiceDto {
|
export class PayInvoiceDto {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "../../common/resolve-auth-user-id";
|
} from "../../common/resolve-auth-user-id";
|
||||||
import { sendPdf } from "./billing.controller";
|
import { sendPdf } from "./billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
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,
|
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||||
@@ -96,4 +96,20 @@ export class PortalBillingController {
|
|||||||
failureUrl: dto.failureUrl,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { HttpService } from "@nestjs/axios";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { firstValueFrom } from "rxjs";
|
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<PaymentIntentSnapshot> {
|
||||||
|
try {
|
||||||
|
return await this.call<PaymentIntentSnapshot>(
|
||||||
|
"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<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||||
const url = `${this.baseUrl}${path}`;
|
const url = `${this.baseUrl}${path}`;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -374,6 +374,53 @@ export class PaymentService {
|
|||||||
return this.formatIntentStatus(refreshed ?? local);
|
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<IntentStatusDto> {
|
||||||
|
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
|
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||||
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||||
|
|||||||
@@ -201,6 +201,8 @@ export const URL_CONSTANTS = {
|
|||||||
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
|
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
|
||||||
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
||||||
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||||
|
CONFIRM_INVOICE_OTP: (id: string) =>
|
||||||
|
`/api/billing/my-invoices/${id}/confirm`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INVOICES: {
|
WAREHOUSE_INVOICES: {
|
||||||
|
|||||||
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal file
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal file
@@ -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<InitiateResponse>;
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
const [otpMessage, setOtpMessage] = useState<string | undefined>();
|
||||||
|
|
||||||
|
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<typeof useInvoicePayment>;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -10,9 +10,8 @@ import {
|
|||||||
PackagePlus,
|
PackagePlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
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 { invoicesService } from "@/services/invoices.service";
|
||||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
@@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
isPayable(inv.status),
|
isPayable(inv.status),
|
||||||
)?.id;
|
)?.id;
|
||||||
|
|
||||||
const payMutation = useMutation({
|
const pay = useInvoicePayment();
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
@@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payItem !== null}
|
opened={payItem !== null}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!payMutation.isPending) {
|
if (!pay.processing) {
|
||||||
setPayItem(null);
|
setPayItem(null);
|
||||||
payMutation.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
currency={undefined}
|
currency={undefined}
|
||||||
processing={payMutation.isPending}
|
processing={pay.processing}
|
||||||
error={
|
error={
|
||||||
payMutation.isError
|
pay.error ??
|
||||||
? payMutation.error instanceof Error
|
(payItemInvoices.length > 0 && !payableInvoiceId
|
||||||
? payMutation.error.message
|
? "No payable invoice found for this booking yet. Please refresh or contact support."
|
||||||
: "Could not start payment. Please try again."
|
: null)
|
||||||
: null
|
}
|
||||||
|
otp={pay.otp}
|
||||||
|
onConfirm={(method, payerAccount) =>
|
||||||
|
payableInvoiceId &&
|
||||||
|
pay.pay(payableInvoiceId, method, payerAccount)
|
||||||
}
|
}
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
|
||||||
/>
|
/>
|
||||||
</ModalSafeWrapper>
|
</ModalSafeWrapper>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
@@ -27,10 +27,7 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
import {
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
paymentsService,
|
|
||||||
type PaymentMethod,
|
|
||||||
} from "@/services/payments.service";
|
|
||||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
@@ -76,17 +73,7 @@ export default function InvoiceDetailPage() {
|
|||||||
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
|
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
|
||||||
// one of the signed-in customer's own invoices (unlike the admin-facing
|
// one of the signed-in customer's own invoices (unlike the admin-facing
|
||||||
// /payments/initiate, which takes any invoiceId with no ownership check).
|
// /payments/initiate, which takes any invoiceId with no ownership check).
|
||||||
const payMutation = useMutation({
|
const pay = useInvoicePayment();
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -249,7 +236,7 @@ export default function InvoiceDetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
leftSection={<CreditCard size={16} />}
|
leftSection={<CreditCard size={16} />}
|
||||||
loading={payMutation.isPending}
|
loading={pay.processing}
|
||||||
onClick={handlePay}
|
onClick={handlePay}
|
||||||
styles={{
|
styles={{
|
||||||
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
||||||
@@ -376,22 +363,19 @@ export default function InvoiceDetailPage() {
|
|||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payModalOpen}
|
opened={payModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!payMutation.isPending) {
|
if (!pay.processing) {
|
||||||
setPayModalOpen(false);
|
setPayModalOpen(false);
|
||||||
payMutation.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
amountLabel={formatCurrency(amountDue, invoice.currency)}
|
amountLabel={formatCurrency(amountDue, invoice.currency)}
|
||||||
currency={invoice.currency}
|
currency={invoice.currency}
|
||||||
processing={payMutation.isPending}
|
processing={pay.processing}
|
||||||
error={
|
error={pay.error}
|
||||||
payMutation.isError
|
otp={pay.otp}
|
||||||
? payMutation.error instanceof Error
|
onConfirm={(method, payerAccount) =>
|
||||||
? payMutation.error.message
|
pay.pay(id, method, payerAccount)
|
||||||
: "Could not start payment. Please try again."
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { Group, Tabs } from "@mantine/core";
|
import { Group, Tabs } from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
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 type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||||
@@ -41,6 +35,7 @@ import { StatusHero } from "./components/StatusHero";
|
|||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
|
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||||
|
|
||||||
export function ReadonlyBookingView({
|
export function ReadonlyBookingView({
|
||||||
booking,
|
booking,
|
||||||
@@ -53,7 +48,6 @@ export function ReadonlyBookingView({
|
|||||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||||
useScrollToHash();
|
useScrollToHash();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
|
||||||
const { viewer } = useFileViewer();
|
const { viewer } = useFileViewer();
|
||||||
|
|
||||||
// Re-book opens the New Shipment Booking form for the same contract, not the
|
// Re-book opens the New Shipment Booking form for the same contract, not the
|
||||||
@@ -63,44 +57,11 @@ export function ReadonlyBookingView({
|
|||||||
: "/contracts/new";
|
: "/contracts/new";
|
||||||
const onRebook = () => navigate(rebookTo);
|
const onRebook = () => navigate(rebookTo);
|
||||||
|
|
||||||
// Billing is invoice-centric — resolve the booking's currently payable
|
// Billing is invoice-centric — the shared hook resolves the booking's
|
||||||
// invoice (same query/key BookingPaymentPanel uses, so this shares its
|
// currently payable invoice (same query/key BookingPaymentPanel uses, so it
|
||||||
// cache) and pay it through the ownership-checked portal route.
|
// shares that cache), charges it through the ownership-checked portal route,
|
||||||
const { data: bookingInvoices = [] } = useQuery({
|
// and handles redirect vs CAC Bank OTP.
|
||||||
queryKey: ["booking-invoices", booking.id],
|
const pay = useBookingPayment(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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pricing = booking.pricingBreakdown;
|
const pricing = booking.pricingBreakdown;
|
||||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||||
@@ -171,7 +132,7 @@ export function ReadonlyBookingView({
|
|||||||
green
|
green
|
||||||
icon={<CreditCard size={16} />}
|
icon={<CreditCard size={16} />}
|
||||||
label="Pay now"
|
label="Pay now"
|
||||||
onClick={() => setPayModalOpen(true)}
|
onClick={pay.open}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -278,8 +239,8 @@ export function ReadonlyBookingView({
|
|||||||
<BookingPaymentPanel
|
<BookingPaymentPanel
|
||||||
booking={booking}
|
booking={booking}
|
||||||
pricing={pricing}
|
pricing={pricing}
|
||||||
onPay={() => setPayModalOpen(true)}
|
onPay={pay.open}
|
||||||
paying={payMutation.isPending}
|
paying={pay.processing}
|
||||||
showCountdown={showCountdown}
|
showCountdown={showCountdown}
|
||||||
/>
|
/>
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
@@ -301,24 +262,14 @@ export function ReadonlyBookingView({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payModalOpen}
|
opened={pay.modalOpen}
|
||||||
onClose={() => {
|
onClose={pay.close}
|
||||||
if (!payMutation.isPending) {
|
|
||||||
setPayModalOpen(false);
|
|
||||||
payMutation.reset();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||||
processing={payMutation.isPending}
|
processing={pay.processing}
|
||||||
error={
|
error={pay.error}
|
||||||
payMutation.isError
|
otp={pay.otp}
|
||||||
? payMutation.error instanceof Error
|
onConfirm={pay.confirm}
|
||||||
? payMutation.error.message
|
|
||||||
: "Could not start payment. Please try again."
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
|
||||||
/>
|
/>
|
||||||
{viewer}
|
{viewer}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
|
import {
|
||||||
import { Check, ShieldCheck } from "lucide-react";
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
|
||||||
import type { PaymentMethod } from "@/services/payments.service";
|
import type { PaymentMethod } from "@/services/payments.service";
|
||||||
|
|
||||||
interface ProviderOption {
|
interface ProviderOption {
|
||||||
method: PaymentMethod;
|
method: PaymentMethod;
|
||||||
label: string;
|
label: string;
|
||||||
description: 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 this provider settles in. */
|
||||||
currencies: string[];
|
currencies: string[];
|
||||||
accent: string;
|
accent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only Telebirr and Waafi are enabled for now.
|
// Only Telebirr, Waafi and CAC Bank are enabled for now.
|
||||||
const PROVIDERS: ProviderOption[] = [
|
const PROVIDERS: ProviderOption[] = [
|
||||||
{
|
{
|
||||||
method: "TELEBIRR",
|
method: "TELEBIRR",
|
||||||
@@ -32,8 +44,18 @@ const PROVIDERS: ProviderOption[] = [
|
|||||||
currencies: ["USD"],
|
currencies: ["USD"],
|
||||||
accent: "#2E5B96",
|
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,
|
* Pick the provider that settles in the booking's currency. USD → Waafi,
|
||||||
* ETB → Telebirr. Falls back to the first provider when unknown.
|
* ETB → Telebirr. Falls back to the first provider when unknown.
|
||||||
@@ -91,13 +113,27 @@ function ProviderRow({
|
|||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{option.logo ? (
|
||||||
src={option.logo}
|
<Image
|
||||||
alt={`${option.label} logo`}
|
src={option.logo}
|
||||||
w={52}
|
alt={`${option.label} logo`}
|
||||||
h={52}
|
w={52}
|
||||||
fit="cover"
|
h={52}
|
||||||
/>
|
fit="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Landmark size={24} color={option.accent} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
||||||
@@ -135,6 +171,7 @@ export function PaymentMethodModal({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
processing,
|
processing,
|
||||||
error,
|
error,
|
||||||
|
otp,
|
||||||
}: {
|
}: {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -142,12 +179,19 @@ export function PaymentMethodModal({
|
|||||||
amountLabel?: string;
|
amountLabel?: string;
|
||||||
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
||||||
currency?: string | null;
|
currency?: string | null;
|
||||||
onConfirm: (method: PaymentMethod) => void;
|
onConfirm: (method: PaymentMethod, payerAccount?: string) => void;
|
||||||
processing?: boolean;
|
processing?: boolean;
|
||||||
error?: string | null;
|
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<PaymentMethod>(providers[0].method);
|
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
|
||||||
|
const [mobile, setMobile] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
|
||||||
// Keep the selection valid when the currency (and therefore provider list) changes.
|
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,6 +200,89 @@ export function PaymentMethodModal({
|
|||||||
}
|
}
|
||||||
}, [providers, method]);
|
}, [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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={otp.cancel}
|
||||||
|
centered
|
||||||
|
radius={18}
|
||||||
|
size={420}
|
||||||
|
padding={0}
|
||||||
|
withCloseButton={false}
|
||||||
|
// A stray click must not drop the payer out of a live OTP window —
|
||||||
|
// Cancel is the only way back.
|
||||||
|
closeOnClickOutside={false}
|
||||||
|
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Box px={24} py={24}>
|
||||||
|
<Text fw={800} fz="18px" c="#10202F">
|
||||||
|
Enter OTP
|
||||||
|
</Text>
|
||||||
|
<Text mt={4} fz="13px" c="#7A8794">
|
||||||
|
{otp.message}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box mt={18}>
|
||||||
|
<PinInput
|
||||||
|
length={6}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
oneTimeCode
|
||||||
|
value={code}
|
||||||
|
onChange={setCode}
|
||||||
|
onComplete={(value) => otp.submit(value)}
|
||||||
|
aria-label="One-time password"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{otp.error && (
|
||||||
|
<Text mt={10} fz="12.5px" c="#C0392B" fw={600}>
|
||||||
|
{otp.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group gap={10} wrap="nowrap" mt={20}>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius={12}
|
||||||
|
onClick={otp.cancel}
|
||||||
|
disabled={otp.submitting}
|
||||||
|
styles={{
|
||||||
|
root: { height: 46, flex: "0 0 38%" },
|
||||||
|
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
radius={12}
|
||||||
|
color="edr-green"
|
||||||
|
loading={otp.submitting}
|
||||||
|
disabled={otp.submitting || code.trim().length === 0}
|
||||||
|
onClick={() => otp.submit(code.trim())}
|
||||||
|
styles={{
|
||||||
|
root: { height: 46, flex: 1 },
|
||||||
|
label: { fontSize: 14, fontWeight: 800 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm payment
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
@@ -215,6 +342,22 @@ export function PaymentMethodModal({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{needsMobile && (
|
||||||
|
<TextInput
|
||||||
|
mt={12}
|
||||||
|
label="Mobile number"
|
||||||
|
description="CAC Bank sends a one-time password to this number to authorise the debit."
|
||||||
|
placeholder="77xxxxxx"
|
||||||
|
value={mobile}
|
||||||
|
onChange={(e) => setMobile(e.currentTarget.value)}
|
||||||
|
disabled={processing}
|
||||||
|
styles={{
|
||||||
|
label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" },
|
||||||
|
description: { fontSize: 11.5 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -228,7 +371,9 @@ export function PaymentMethodModal({
|
|||||||
<Group gap={6} align="center" justify="center" mb={12}>
|
<Group gap={6} align="center" justify="center" mb={12}>
|
||||||
<ShieldCheck size={14} color="#0A8A5F" />
|
<ShieldCheck size={14} color="#0A8A5F" />
|
||||||
<Text fz="11.5px" c="#7A8794">
|
<Text fz="11.5px" c="#7A8794">
|
||||||
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"}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -248,15 +393,21 @@ export function PaymentMethodModal({
|
|||||||
<Button
|
<Button
|
||||||
radius={12}
|
radius={12}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
disabled={processing}
|
disabled={processing || !canSubmit}
|
||||||
loading={processing}
|
loading={processing}
|
||||||
onClick={() => onConfirm(method)}
|
onClick={() =>
|
||||||
|
onConfirm(method, needsMobile ? mobile.trim() : undefined)
|
||||||
|
}
|
||||||
styles={{
|
styles={{
|
||||||
root: { height: 48, flex: 1 },
|
root: { height: 48, flex: 1 },
|
||||||
label: { fontSize: 14, fontWeight: 800 },
|
label: { fontSize: 14, fontWeight: 800 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{processing ? "Redirecting…" : "Continue to payment"}
|
{processing
|
||||||
|
? needsMobile
|
||||||
|
? "Sending OTP…"
|
||||||
|
: "Redirecting…"
|
||||||
|
: "Continue to payment"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
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 { CreditCard, Download, FileText, Receipt } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
import {
|
import {
|
||||||
warehouseInvoicesService,
|
warehouseInvoicesService,
|
||||||
type PortalWarehouseInvoice,
|
type PortalWarehouseInvoice,
|
||||||
@@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
|
|
||||||
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
||||||
|
|
||||||
const payMutation = useMutation({
|
// Warehouse fees are charged through the warehouse route, but they are the
|
||||||
mutationFn: (method: PaymentMethod) => {
|
// same central invoices — so redirect vs CAC Bank OTP is the shared flow.
|
||||||
if (!payInvoice) throw new Error("No invoice selected for payment.");
|
const pay = useInvoicePayment((invoiceId, method, payerAccount) =>
|
||||||
return warehouseInvoicesService.payOnline(payInvoice.id, {
|
warehouseInvoicesService.payOnline(invoiceId, {
|
||||||
method,
|
method,
|
||||||
platform: "web",
|
platform: "web",
|
||||||
});
|
payerAccount,
|
||||||
},
|
}),
|
||||||
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;
|
|
||||||
|
|
||||||
const closePayModal = () => {
|
const closePayModal = () => {
|
||||||
if (!payMutation.isPending) {
|
if (!pay.processing) {
|
||||||
setPayInvoice(null);
|
setPayInvoice(null);
|
||||||
payMutation.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
||||||
}
|
}
|
||||||
currency={payInvoice?.currency}
|
currency={payInvoice?.currency}
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
onConfirm={(method, payerAccount) =>
|
||||||
processing={payMutation.isPending}
|
payInvoice && pay.pay(payInvoice.id, method, payerAccount)
|
||||||
error={payError}
|
}
|
||||||
|
processing={pay.processing}
|
||||||
|
error={pay.error}
|
||||||
|
otp={pay.otp}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function PayNowButton({
|
|||||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||||
processing={pay.processing}
|
processing={pay.processing}
|
||||||
error={pay.error}
|
error={pay.error}
|
||||||
|
otp={pay.otp}
|
||||||
onConfirm={pay.confirm}
|
onConfirm={pay.confirm}
|
||||||
/>
|
/>
|
||||||
</ModalSafeWrapper>
|
</ModalSafeWrapper>
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
import {
|
import { type PaymentMethod } from "@/services/payments.service";
|
||||||
paymentsService,
|
|
||||||
type PaymentMethod,
|
|
||||||
} from "@/services/payments.service";
|
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared payment flow for a single booking: opens the method modal, fires
|
* Shared payment flow for a single booking: opens the method modal, fires
|
||||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
||||||
* invoice, and redirects the browser to the provider (or the fallback
|
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
||||||
* checkout page). Reused by the booking detail page, the booking list, and
|
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
||||||
* the home page so "Pay now" behaves identically everywhere.
|
* the booking detail page, the booking list, and the home page so "Pay now"
|
||||||
|
* behaves identically everywhere.
|
||||||
*/
|
*/
|
||||||
export function useBookingPayment(bookingId: string) {
|
export function useBookingPayment(bookingId: string) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [noInvoice, setNoInvoice] = useState(false);
|
||||||
|
|
||||||
const { data: invoices = [] } = useQuery({
|
const { data: invoices = [] } = useQuery({
|
||||||
queryKey: ["booking-invoices", bookingId],
|
queryKey: ["booking-invoices", bookingId],
|
||||||
@@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) {
|
|||||||
});
|
});
|
||||||
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
||||||
|
|
||||||
const mutation = useMutation({
|
const flow = useInvoicePayment();
|
||||||
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 open = () => setModalOpen(true);
|
const open = () => setModalOpen(true);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (!mutation.isPending) {
|
if (!flow.processing) {
|
||||||
setModalOpen(false);
|
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 {
|
return {
|
||||||
modalOpen,
|
modalOpen,
|
||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
processing: mutation.isPending,
|
processing: flow.processing,
|
||||||
error,
|
error: noInvoice
|
||||||
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
? "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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,4 +73,10 @@ export const invoicesService = {
|
|||||||
});
|
});
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Submit the CAC Bank OTP for an invoice whose intent is awaiting confirmation. */
|
||||||
|
confirmOtp: async (id: string, otp: string): Promise<InitiateResponse> => {
|
||||||
|
const { data } = await client.post(B.CONFIRM_INVOICE_OTP(id), { otp });
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user