diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 0db784838..1b9af89f3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -53,7 +53,11 @@ function rabbitMQImport(): DynamicModule[] { SeatsModule, TicketsModule, CurrencyModule, - HttpModule.register({ timeout: 10_000 }), + // The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an + // OTP and can take tens of seconds). Keep this hop generous; overridable via env. + HttpModule.register({ + timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, + }), ...rabbitMQImport(), ], controllers: [PaymentsController, InternalPaymentsController], diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 39832d787..3992e140f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -37,8 +37,10 @@ export default function PaymentPage() { const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); - // CAC Bank OTP debit: capture the payer's mobile up front, then collect the SMS'd OTP. + // CAC Bank OTP debit: on Pay, collect the payer's mobile in a modal, then the SMS'd OTP. const [payerMobile, setPayerMobile] = useState(""); + const [phoneModalOpen, setPhoneModalOpen] = useState(false); + const [phoneError, setPhoneError] = useState(null); const [otpModalOpen, setOtpModalOpen] = useState(false); const [otpCode, setOtpCode] = useState(""); const [otpMessage, setOtpMessage] = useState(null); @@ -192,37 +194,51 @@ export default function PaymentPage() { - const handlePayment = async () => { - if (!selectedMethod || !bookingId) { - alert("Please select a payment method"); - return; - } - + // Fire the actual initiate. `mobile` is only used for CAC (OTP debit). + const startPayment = (mobile?: string) => { + if (!selectedMethod || !bookingId || !selectedPaymentMethod) return; setIsProcessing(true); setPaymentError(null); - - if (!selectedPaymentMethod) { - alert("Invalid payment method selected"); - setIsProcessing(false); - return; - } - - if (selectedMethod === 'CAC_BANK' && !payerMobile.trim()) { - setPaymentError("Please enter the mobile number to receive the OTP"); - setIsProcessing(false); - return; - } - paymentMutation.mutate({ bookingId, method: selectedMethod, paymentMethodId: selectedPaymentMethod.id, currency: displayCurrency, amountMinor: totalAmount, - payerAccount: selectedMethod === 'CAC_BANK' ? payerMobile.trim() : undefined, + payerAccount: selectedMethod === 'CAC_BANK' ? mobile?.trim() : undefined, }); }; + const handlePayment = () => { + if (!selectedMethod || !bookingId) { + alert("Please select a payment method"); + return; + } + if (!selectedPaymentMethod) { + alert("Invalid payment method selected"); + return; + } + setPaymentError(null); + + // CAC Bank needs the payer's mobile for the OTP — collect it in a modal before initiating. + if (selectedMethod === 'CAC_BANK') { + setPhoneError(null); + setPhoneModalOpen(true); + return; + } + + startPayment(); + }; + + const submitPhone = () => { + if (!payerMobile.trim()) { + setPhoneError("Please enter your mobile number"); + return; + } + setPhoneModalOpen(false); + startPayment(payerMobile); + }; + // Redirect if no booking data (but not during navigation) useEffect(() => { // Add a small delay to allow state to be set from previous page @@ -463,6 +479,49 @@ export default function PaymentPage() { )} + {/* CAC Bank — collect payer mobile before initiating */} + {phoneModalOpen && ( +
+
+
+ +

Your mobile number

+
+

+ CAC Bank will send a one-time password to this number to authorize the payment. +

+ { setPayerMobile(e.target.value); setPhoneError(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') submitPhone(); }} + placeholder="77 XX XX XX" + className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {phoneError && ( +

⚠️ {phoneError}

+ )} +
+ + +
+
+
+ )} + {/* CAC Bank OTP entry */} {otpModalOpen && (
@@ -567,27 +626,6 @@ export default function PaymentPage() { })}
)} - - {selectedMethod === 'CAC_BANK' && ( -
- -

- CAC Bank will send a one-time password to this number to authorize the debit. -

- setPayerMobile(e.target.value)} - placeholder="77 XX XX XX" - disabled={isProcessing} - className="w-full px-3 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" - /> -
- )} {/* Order summary inline — mobile only */} diff --git a/apps/edr-payment-api/src/config/cac.config.ts b/apps/edr-payment-api/src/config/cac.config.ts index 360e64299..3b07e6906 100644 --- a/apps/edr-payment-api/src/config/cac.config.ts +++ b/apps/edr-payment-api/src/config/cac.config.ts @@ -10,4 +10,7 @@ export default registerAs("cac", () => ({ currency: process.env.CAC_CURRENCY || "DJF", tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000), otpExpiryMs: Number(process.env.CAC_OTP_EXPIRY_MS || 10 * 60 * 1000), + // The bank's PaymentInitiateRequest sends an OTP by SMS and can be slow; the bank asked us + // to raise the client timeout. Generous default, overridable via env. + httpTimeoutMs: Number(process.env.CAC_HTTP_TIMEOUT_MS || 60_000), })); diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index a7904e4cc..0ebaaad05 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -180,7 +180,12 @@ export class IntentsService { ); } - if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) { + // REQUIRES_ACTION is the normal awaiting-OTP state; PROCESSING is tolerated so an intent + // that a poll/sweep nudged forward can still be confirmed. Terminal states are rejected. + if ( + intent.status !== ProviderPaymentStatus.REQUIRES_ACTION && + intent.status !== ProviderPaymentStatus.PROCESSING + ) { throw new BadRequestException( `Intent is not awaiting confirmation (status=${intent.status})`, ); diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts index ee20b0aa5..0f6ac2063 100644 --- a/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts @@ -14,6 +14,7 @@ export interface CacAuthConfig { username: string; password: string; tokenTtlMs: number; + httpTimeoutMs: number; } /** @@ -63,7 +64,7 @@ export class CacBankAuth { const res = await firstValueFrom( this.http.post(url, body, { headers: { "Content-Type": "application/json" }, - timeout: 10_000, + timeout: this.config.httpTimeoutMs, }), ); const token = res.data.accessToken; diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts index 07de0ae0d..df69c4b1f 100644 --- a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts @@ -194,7 +194,10 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit { * Verify a payment via GetPaymentByReferenceRequest, keyed on the paymentRequestId. This * is CAC's callback replacement: the bank sends no webhook, but the id is known from * initiate and the lookup accepts it, so a lost/failed confirm can still be reconciled. - * A settled payment carries a transactionNo; anything else is still pending. + * A settled payment carries a transactionNo; anything else means the OTP hasn't been + * confirmed yet — that's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING. + * Returning PROCESSING would let a poll/sweep advance the intent out of REQUIRES_ACTION + * and block the confirm() call. */ async queryStatus(paymentRequestId: string): Promise { const requestBody: CacGetPaymentByReferenceRequest = { @@ -218,13 +221,13 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit { } return { - status: ProviderPaymentStatus.PROCESSING, + status: ProviderPaymentStatus.REQUIRES_ACTION, rawResponse: response as unknown as Record, }; } catch (err) { if (err instanceof AxiosError && err.response?.status === 404) { return { - status: ProviderPaymentStatus.PROCESSING, + status: ProviderPaymentStatus.REQUIRES_ACTION, rawResponse: { notFound: true, reference: paymentRequestId }, }; } @@ -242,7 +245,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - timeout: 10_000, + timeout: this.httpTimeoutMs, // Keep the raw response text — 17-digit ids would lose precision under axios's // default JSON.parse. We parse losslessly with parseCacResponse. transformResponse: [(data) => data], @@ -294,6 +297,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit { username: this.username, password: this.password, tokenTtlMs: this.tokenTtlMs, + httpTimeoutMs: this.httpTimeoutMs, }); } return this.auth; @@ -341,4 +345,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit { private get otpExpiryMs(): number { return this.config.get("cac.otpExpiryMs") ?? 10 * 60 * 1000; } + private get httpTimeoutMs(): number { + return this.config.get("cac.httpTimeoutMs") ?? 60_000; + } }