From abb14d5768f83f4e411868f4d822f08d06861dfc Mon Sep 17 00:00:00 2001 From: Abin Toppo Date: Fri, 25 Sep 2026 16:02:03 +0530 Subject: [PATCH] Implement IMPS fund transfer via initiate and verify-otp Initiate runs the IMPS name inquiry and sends an OTP; verify-otp sends ReqPay. The result screen now distinguishes success, failure and pending reconciliation. The payer is temporarily fixed to the test account 002002000100019, and the to-mobile form no longer blocks on mobile/MMID format. Co-Authored-By: Claude Opus 5.5 --- .../screens/PaymentConfirmationScreen.tsx | 600 ++++++++++++++---- .../screens/TransactionSuccessScreen.tsx | 32 +- .../screens/TransferToMobileScreen.tsx | 22 +- src/modules/payments/services/paymentsApi.ts | 72 ++- src/navigation/types.ts | 10 + src/services/api/cbsErrors.test.ts | 6 +- src/services/api/cbsErrors.ts | 13 +- src/services/api/endpoints.ts | 9 + src/types/cbs.ts | 71 +++ 9 files changed, 673 insertions(+), 162 deletions(-) diff --git a/src/modules/payments/screens/PaymentConfirmationScreen.tsx b/src/modules/payments/screens/PaymentConfirmationScreen.tsx index 2c2e396..788931b 100644 --- a/src/modules/payments/screens/PaymentConfirmationScreen.tsx +++ b/src/modules/payments/screens/PaymentConfirmationScreen.tsx @@ -1,28 +1,87 @@ -import React, { useState } from 'react'; -import { StyleSheet, Text, View, ScrollView } from 'react-native'; +import 'react-native-get-random-values'; +import React, { useEffect, useRef, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { useDispatch, useSelector } from 'react-redux'; +import { v4 as uuidv4 } from 'uuid'; -import { ScreenShell, Button, Card, AppModal, Input } from '../../../components'; +import { ScreenShell, Button, Card, AppModal, InfoBanner, Input } from '../../../components'; import { + useInitiateFundTransferMutation, useSaveBeneficiaryMutation, useSubmitBankTransferMutation, useValidateBeneficiaryMutation, + useVerifyFundTransferOtpMutation, } from '../services/paymentsApi'; import { isPayeeVerified } from '../../../types/cbs'; -import type { BeneficiaryMode, BeneficiaryPayee } from '../../../types/cbs'; +import type { + BeneficiaryMode, + BeneficiaryPayee, + FundTransferInitiateResult, + FundTransferVerifyResult, +} from '../../../types/cbs'; +import type { ApiError } from '../../../types/api'; import { FeatureKey } from '../../../constants/featureKeys'; import { FeatureGate, guardFeature, isFeatureEnabled } from '../../../features'; import { beneficiariesActions, selectBeneficiaryById } from '../store/beneficiariesSlice'; import { useAccounts } from '../../home/hooks/useAccounts'; +import { CodeInput } from '../../auth/components/CodeInput'; +import { MPIN_LENGTH } from '../../../utils/validation'; +import { describeError } from '../../../services/api/cbsErrors'; +import { useApiErrorToast } from '../../../hooks/useApiErrorToast'; import { useTheme, fontFamily } from '../../../theme'; -import type { PaymentsStackParamList } from '../../../navigation/types'; +import type { AdHocRecipient, PaymentsStackParamList } from '../../../navigation/types'; type Props = NativeStackScreenProps; +const OTP_LENGTH = 6; + +/** Which modal, if any, is in front of the review. */ +type Step = 'review' | 'mpin' | 'otp'; + +/** + * A payee addressed by mobile is P2P, by account number P2A. The mode decides + * which field pair IMPS will accept, so it is derived from what the form + * actually collected rather than assumed. + */ +const toImpsPayee = ( + recipient: AdHocRecipient | undefined, +): { mode: BeneficiaryMode; payee: BeneficiaryPayee } | null => { + if (!recipient) { + return null; + } + + // The mobile form no longer checks its fields, so either key being present + // marks a P2P payee — IMPS is left to refuse an empty or malformed one. + if (recipient.mobileNumber !== undefined || recipient.mmid !== undefined) { + return { + mode: 'P2P', + payee: { + mobileNumber: recipient.mobileNumber ?? '', + mmid: recipient.mmid ?? '', + name: recipient.name, + }, + }; + } + + return recipient.accountNumber && recipient.ifsc + ? { + mode: 'P2A', + payee: { accountNumber: recipient.accountNumber, ifsc: recipient.ifsc, name: recipient.name }, + } + : null; +}; + +const secondsUntil = (iso: string | null | undefined) => + iso ? Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 1000)) : 0; + +const formatCountdown = (seconds: number) => + `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; + export function PaymentConfirmationScreen({ navigation, route }: Props) { const theme = useTheme(); const dispatch = useDispatch(); + const showApiError = useApiErrorToast(); const { beneficiaryId, transferType, @@ -51,135 +110,357 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) { const payeeMobile = beneficiary?.mobileNumber ?? recipient?.mobileNumber; const payeeMmid = beneficiary?.mmid ?? recipient?.mmid; const payeeIfsc = beneficiary?.ifsc ?? recipient?.ifsc; - - const [submitTransfer, { isLoading }] = useSubmitBankTransferMutation(); + + /** + * IMPS goes through the real two-step API: initiate (name inquiry + OTP), + * then verify-otp (ReqPay). NEFT, RTGS and own-account have no endpoint yet + * and stay on the fixture path below. + */ + const impsTarget = transferType === 'IMPS' ? toImpsPayee(recipient) : null; + + const [submitTransfer, { isLoading: isMockSubmitting }] = useSubmitBankTransferMutation(); + const [initiateTransfer, { isLoading: isInitiating }] = useInitiateFundTransferMutation(); + const [verifyOtp, { isLoading: isVerifying }] = useVerifyFundTransferOtpMutation(); const [saveBeneficiary] = useSaveBeneficiaryMutation(); const [validateBeneficiary] = useValidateBeneficiaryMutation(); - const [otpModalVisible, setOtpModalVisible] = useState(false); + + const [step, setStep] = useState('review'); + const [mpin, setMpin] = useState(''); const [otp, setOtp] = useState(''); + const [otpError, setOtpError] = useState(); + /** A failure that ended this attempt, kept on the review screen. */ + const [notice, setNotice] = useState(); + const [transfer, setTransfer] = useState(null); + const [secondsLeft, setSecondsLeft] = useState(0); + + /** + * The idempotency key. One per transfer attempt, kept across network retries + * of initiate so a lost response cannot create a second transfer, and + * dropped once the attempt is over so the next one is a new transfer rather + * than the server replaying the old, failed one. + */ + const requestIdRef = useRef(null); + const requestId = () => { + requestIdRef.current = requestIdRef.current ?? uuidv4(); + return requestIdRef.current; + }; + + const busy = isInitiating || isVerifying || isMockSubmitting; + + useEffect(() => { + if (step !== 'otp' || !transfer?.otpExpiresAt) { + return; + } + + setSecondsLeft(secondsUntil(transfer.otpExpiresAt)); + const timer = setInterval(() => setSecondsLeft(secondsUntil(transfer.otpExpiresAt)), 1000); + + return () => clearInterval(timer); + }, [step, transfer?.otpExpiresAt]); + + /** Ends the attempt: the next Confirm starts a new transfer from scratch. */ + const abandonAttempt = (message: string) => { + requestIdRef.current = null; + setTransfer(null); + setOtp(''); + setMpin(''); + setOtpError(undefined); + setStep('review'); + setNotice(message); + }; const handleConfirm = guardFeature(FeatureKey.PAYMENTS_TRANSFER_SUBMIT, () => { - setOtpModalVisible(true); + setNotice(undefined); + setOtp(''); + setOtpError(undefined); + // IMPS needs the MPIN first; the fixture rails go straight to their OTP. + setStep(impsTarget ? 'mpin' : 'otp'); }); - const finalSubmit = async () => { + /** + * Saving the payee is deliberately *after* the transfer succeeds, and + * deliberately not allowed to fail it: a payee the customer asked to keep is + * a convenience, and failing to store it must not turn a completed transfer + * into an error screen. The transfer is the thing with money attached. + */ + const savePayeeIfAsked = async () => { + const target = toImpsPayee(recipient); + + if (!saveRecipient || !sourceAccountNumber || !target) { + return; + } + + try { + // Validate before saving, the same order the Add Payee screen uses. The + // server does not enforce it, so this is the only thing standing between + // the customer and a saved payee nobody confirmed. + const verified = await validateBeneficiary({ + mode: target.mode, + payer: { accountNumber: sourceAccountNumber }, + payee: target.payee, + }).unwrap(); + + if (isPayeeVerified(verified)) { + const saved = await saveBeneficiary({ + mode: target.mode, + beneficiary: target.payee, + nickname: recipient?.name, + }).unwrap(); + + dispatch(beneficiariesActions.addBeneficiary(saved)); + } + // A refusal is left alone: the customer is on their way to the receipt, + // and they can add the payee from the payee list. + } catch { + // Swallowed on purpose — see above. + } + }; + + /* ── IMPS: step one — MPIN, name inquiry, OTP ──────────────────────────── */ + + const handleInitiate = async () => { + // No source-account check: the payer is fixed in paymentsApi for now. + if (!impsTarget || mpin.length !== MPIN_LENGTH) { + return; + } + + if (!isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_SUBMIT)) return; + + try { + const result = await initiateTransfer({ + requestId: requestId(), + mode: impsTarget.mode, + amount: Math.round(amount * 100) / 100, + remark: remarks, + mpin, + payer: { accountNumber: sourceAccountNumber ?? '' }, + // The backend's reference request sends SAVINGS for both modes; the + // forms do not ask for the payee's account type. + payee: { ...impsTarget.payee, accountType: 'SAVINGS' }, + }).unwrap(); + + setMpin(''); + + if (result.status !== 'PENDING_OTP') { + // IMPS did not recognise the payee. No OTP was issued and nothing + // left the account. + abandonAttempt( + impsTarget.mode === 'P2P' + ? 'IMPS could not verify this mobile number and MMID. Check them with the recipient and try again.' + : 'IMPS could not verify this account number and IFSC. Check them with the recipient and try again.', + ); + return; + } + + setTransfer(result); + setOtp(__DEV__ && result.debugOtp ? result.debugOtp : ''); + setStep('otp'); + } catch (error) { + const apiError = error as ApiError; + setMpin(''); + + // A wrong MPIN is the customer's to retry, so the MPIN sheet stays up. + // Everything else closes it: nothing was debited, but the attempt is over. + if (apiError.code === 'INVALID_CREDENTIALS') { + showApiError(apiError); + return; + } + + if (apiError.code === 'MPIN_LOCKED') { + abandonAttempt(describeError('MPIN_LOCKED').description); + return; + } + + // A network failure keeps the requestId, so trying again returns this + // same transfer instead of starting a second one. + if (apiError.code !== 'NETWORK_ERROR' && apiError.code !== 'REQUEST_TIMEOUT') { + requestIdRef.current = null; + } + + setStep('review'); + showApiError(apiError); + } + }; + + /* ── IMPS: step two — OTP, then ReqPay ─────────────────────────────────── */ + + const finishImps = (result: FundTransferVerifyResult) => { + const status = result.txnStatus?.toUpperCase(); + const common = { + transactionId: result.refId ?? transfer?.referenceId ?? '', + amount, + beneficiaryName: result.payee?.name ?? payeeName, + rrn: result.custRefNo, + }; + + if (status === 'SUCCESS') { + void savePayeeIfAsked(); + navigation.replace('TransactionSuccess', { ...common, isPending: false, outcome: 'success' }); + } else if (status === 'FAILURE') { + navigation.replace('TransactionSuccess', { + ...common, + isPending: false, + outcome: 'failed', + detail: result.errorDiscrp + ? `${result.errorDiscrp}. If your account was debited, the amount will be reversed.` + : 'If your account was debited, the amount will be reversed.', + }); + } else { + // DEEMED or anything unrecognised: the bank is reconciling it. + navigation.replace('TransactionSuccess', { + ...common, + isPending: true, + outcome: 'pending', + detail: + 'IMPS has not confirmed the outcome yet. Your bank is checking it and will update this transfer — do not send it again.', + }); + } + }; + + const handleVerifyOtp = async () => { + if (!transfer || otp.length !== OTP_LENGTH) { + return; + } + + if (!isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_SUBMIT)) return; + + setOtpError(undefined); + + try { + const result = await verifyOtp({ transferId: transfer.transferId, otp }).unwrap(); + setStep('review'); + finishImps(result); + } catch (error) { + const apiError = error as ApiError; + + switch (apiError.code) { + case 'OTP_INVALID': { + const remaining = Math.max(0, transfer.attemptsRemaining - 1); + setTransfer({ ...transfer, attemptsRemaining: remaining }); + setOtp(''); + setOtpError( + remaining > 0 + ? `That code is incorrect. ${remaining} attempt${remaining === 1 ? '' : 's'} left.` + : 'That code is incorrect.', + ); + return; + } + case 'OTP_EXPIRED': + abandonAttempt('The code expired, so this transfer was cancelled. Nothing was debited.'); + return; + case 'OTP_ATTEMPTS_EXCEEDED': + abandonAttempt('Too many wrong codes, so this transfer was cancelled. Nothing was debited.'); + return; + case 'TRANSFER_NOT_AWAITING_OTP': + abandonAttempt(describeError('TRANSFER_NOT_AWAITING_OTP').description); + return; + case 'IMPS_REQUEST_REJECTED': + // The outer Ack failed: IMPS never accepted the payment. + setStep('review'); + navigation.replace('TransactionSuccess', { + transactionId: transfer.referenceId, + amount, + beneficiaryName: payeeName, + isPending: false, + outcome: 'failed', + detail: 'The IMPS request could not be completed. Nothing was sent.', + }); + return; + case 'NETWORK_ERROR': + case 'REQUEST_TIMEOUT': + // The OTP may already be spent and ReqPay may already be out. The + // outcome is the server's to reconcile; retrying is not safe. + setStep('review'); + navigation.replace('TransactionSuccess', { + transactionId: transfer.referenceId, + amount, + beneficiaryName: payeeName, + isPending: true, + outcome: 'pending', + detail: + 'We lost contact before your bank answered. Check your transactions shortly before trying again.', + }); + return; + default: + abandonAttempt(apiError.message || describeError(apiError.code).description); + } + } + }; + + /* ── Fixture rails (NEFT, RTGS, own account) ──────────────────────────── */ + + const handleMockSubmit = async () => { if (otp !== '123456') return; // Mock OTP - // The modal is dismissible and its button is reachable while state is in - // flight, so the submit path is guarded as well as the control that opens it. if (!isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_SUBMIT)) return; - - setOtpModalVisible(false); - + + setStep('review'); + try { const response = await submitTransfer({ beneficiaryId: beneficiaryId ?? '', amount, transferType, remarks, - // The account the customer actually picked on the form, rather than a - // hardcoded one — this is the account that gets debited. sourceAccountId: sourceAccountNumber ?? '', }).unwrap(); - - /** - * Saving the payee is deliberately *after* the transfer succeeds, and - * deliberately not awaited into the same try: a payee the customer asked - * to keep is a convenience, and failing to store it must not turn a - * completed transfer into an error screen. The transfer is the thing - * with money attached. - */ - if (saveRecipient && sourceAccountNumber && recipient) { - /** - * A payee addressed by mobile is P2P, by account number P2A. The mode - * decides which field pair the two endpoints will accept, so it is - * derived from what the form actually collected rather than assumed. - */ - const mode: BeneficiaryMode = recipient.mobileNumber ? 'P2P' : 'P2A'; - const payee: BeneficiaryPayee = - mode === 'P2P' - ? { - mobileNumber: recipient.mobileNumber, - mmid: recipient.mmid, - name: recipient.name, - } - : { - accountNumber: recipient.accountNumber, - ifsc: recipient.ifsc, - name: recipient.name, - }; + void savePayeeIfAsked(); - const addressed = - mode === 'P2P' - ? Boolean(payee.mobileNumber && payee.mmid) - : Boolean(payee.accountNumber && payee.ifsc); - - if (addressed) { - try { - /** - * Validate before saving, the same order the Add Payee screen - * uses. The server does not enforce it, so this is the only thing - * standing between the customer and a saved payee nobody - * confirmed — and a saved payee is what they will reuse next time - * without looking. - */ - const verified = await validateBeneficiary({ - mode, - payer: { accountNumber: sourceAccountNumber }, - payee, - }).unwrap(); - - if (isPayeeVerified(verified)) { - const saved = await saveBeneficiary({ - mode, - beneficiary: payee, - nickname: recipient.name, - }).unwrap(); - - dispatch(beneficiariesActions.addBeneficiary(saved)); - } - // A refusal is left alone on purpose: the transfer has already - // gone through, and the customer is on their way to the receipt. - // Interrupting that to argue about an address-book entry would be - // the wrong moment. They can add the payee from the payee list. - } catch { - // Swallowed on purpose — see above. The payee can be added again - // from the payee list. - } - } - } - - navigation.navigate('TransactionSuccess', { transactionId: response.referenceId, amount, beneficiaryName: payeeName, isPending: response.status === 'Process_Pending', }); - } catch (e) { - console.warn("Transfer failed", e); + } catch (error) { + showApiError(error as ApiError); } }; + const closeModal = () => { + // A request in flight cannot be called back; closing now would hide its + // answer, not cancel it. + if (busy) return; + + setMpin(''); + setStep('review'); + }; + const DetailRow = ({ label, value }: { label: string; value: string }) => ( {label} - {value} + {value} ); + const amountLabel = `₹${amount.toLocaleString('en-IN')}`; + return ( - - + + {/* Submission is the last switchable step: with it off the review + screen still reads, but nothing can be sent. */} + +