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 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-09-25 16:02:03 +05:30
co-authored by Claude Opus 5.5
parent 473f9d7415
commit abb14d5768
9 changed files with 673 additions and 162 deletions
@@ -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<PaymentsStackParamList, 'PaymentConfirmation'>;
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<Step>('review');
const [mpin, setMpin] = useState('');
const [otp, setOtp] = useState('');
const [otpError, setOtpError] = useState<string | undefined>();
/** A failure that ended this attempt, kept on the review screen. */
const [notice, setNotice] = useState<string | undefined>();
const [transfer, setTransfer] = useState<FundTransferInitiateResult | null>(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<string | null>(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 }) => (
<View style={styles.detailRow}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>{label}</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary, fontFamily: fontFamily.bold ? '800' : '600' }]}>{value}</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{value}</Text>
</View>
);
const amountLabel = `₹${amount.toLocaleString('en-IN')}`;
return (
<ScreenShell title="Confirm Payment" subtitle="Review transaction details" contentContainerStyle={styles.container}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<ScreenShell
title="Confirm Payment"
subtitle="Review transaction details"
contentContainerStyle={styles.scrollContent}
// Pinned outside the scroll, like the transfer forms. Inside it, the
// button sat below the fold and behind the tab bar.
fixedFooter={
<View style={[styles.footer, { borderTopColor: theme.colors.cardBorder }]}>
{/* Submission is the last switchable step: with it off the review
screen still reads, but nothing can be sent. */}
<FeatureGate feature={FeatureKey.PAYMENTS_TRANSFER_SUBMIT}>
<Button title="Confirm & Pay" onPress={handleConfirm} loading={busy} disabled={busy} />
</FeatureGate>
</View>
}
>
<Card style={styles.confirmCard}>
<View style={styles.amountSection}>
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Total Amount</Text>
<Text style={[styles.amountValue, { color: theme.colors.primary }]}>₹{amount.toLocaleString('en-IN')}</Text>
<Text style={[styles.amountValue, { color: theme.colors.primary }]}>{amountLabel}</Text>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.detailsSection}>
<DetailRow label="Transfer Mode" value={transferType} />
<DetailRow label="To Beneficiary" value={payeeName} />
@@ -192,61 +473,105 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
</View>
</Card>
{notice ? <InfoBanner tone="danger" message={notice} /> : null}
<View style={[styles.noticeCard, { backgroundColor: `${theme.colors.info}10`, borderColor: theme.colors.info }]}>
<Text style={[styles.noticeText, { color: theme.colors.textPrimary }]}>
{sourceAccount
? `Funds will be debited from your ${sourceAccount.accountType} account XXXX ${sourceAccount.accountNumber.slice(-4)}.`
: 'Funds will be debited from your selected account.'}
{impsTarget ? ' The payee is verified with IMPS before anything is sent.' : ''}
</Text>
</View>
</ScrollView>
<View style={styles.footer}>
{/* Submission is the last switchable step: with it off the review screen
still reads, but nothing can be sent. */}
<FeatureGate feature={FeatureKey.PAYMENTS_TRANSFER_SUBMIT}>
<Button
title="Confirm & Pay"
onPress={handleConfirm}
loading={isLoading}
disabled={isLoading}
/>
</FeatureGate>
</View>
<AppModal
visible={otpModalVisible}
onClose={() => setOtpModalVisible(false)}
title="Secure Transaction"
>
<View style={{ gap: 16 }}>
<Text style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
Enter the 6-digit code sent to your registered mobile to authorize this ₹{amount.toLocaleString('en-IN')} transfer.
{/* ── MPIN (IMPS only) ─────────────────────────────────────────────── */}
<AppModal visible={step === 'mpin'} onClose={closeModal} title="Enter your MPIN">
<View style={styles.modalBody}>
<Text style={[styles.modalCopy, { color: theme.colors.textSecondary }]}>
{`We'll verify ${payeeName} with IMPS and send a code to your registered mobile. Nothing is debited yet.`}
</Text>
<Input
label="Transaction OTP"
placeholder="Enter OTP (Hint: 123456)"
keyboardType="number-pad"
maxLength={6}
value={otp}
onChangeText={setOtp}
<CodeInput
label="MPIN"
value={mpin}
onChangeText={setMpin}
length={MPIN_LENGTH}
secure
autoFocus
editable={!isInitiating}
onSubmit={handleInitiate}
/>
<Button
title="Authorize Payment"
onPress={finalSubmit}
disabled={otp.length !== 6 || isLoading}
loading={isLoading}
<Button
title="Verify payee & send OTP"
onPress={handleInitiate}
disabled={mpin.length !== MPIN_LENGTH || isInitiating}
loading={isInitiating}
/>
</View>
</AppModal>
{/* ── OTP ─────────────────────────────────────────────────────────── */}
<AppModal visible={step === 'otp'} onClose={closeModal} title="Authorise transfer">
<View style={styles.modalBody}>
<Text style={[styles.modalCopy, { color: theme.colors.textSecondary }]}>
{impsTarget
? `Payee verified by IMPS. Enter the 6-digit code sent to your registered mobile to send ${amountLabel}.`
: `Enter the 6-digit code sent to your registered mobile to authorize this ${amountLabel} transfer.`}
</Text>
{impsTarget ? (
<>
<CodeInput
label="Transaction OTP"
value={otp}
onChangeText={(value) => {
setOtpError(undefined);
setOtp(value);
}}
length={OTP_LENGTH}
autoFocus
error={otpError}
editable={!isVerifying}
onSubmit={handleVerifyOtp}
/>
{transfer?.otpExpiresAt ? (
<Text style={[styles.modalMeta, { color: theme.colors.textMuted }]}>
{secondsLeft > 0
? `Code expires in ${formatCountdown(secondsLeft)}`
: 'This code has expired'}
</Text>
) : null}
<Button
title={`Pay ${amountLabel}`}
onPress={handleVerifyOtp}
disabled={otp.length !== OTP_LENGTH || isVerifying}
loading={isVerifying}
/>
</>
) : (
<>
<Input
label="Transaction OTP"
placeholder="Enter OTP (Hint: 123456)"
keyboardType="number-pad"
maxLength={OTP_LENGTH}
value={otp}
onChangeText={setOtp}
/>
<Button
title="Authorize Payment"
onPress={handleMockSubmit}
disabled={otp.length !== OTP_LENGTH || isMockSubmitting}
loading={isMockSubmitting}
/>
</>
)}
</View>
</AppModal>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
padding: 16,
gap: 16,
@@ -289,6 +614,7 @@ const styles = StyleSheet.create({
value: {
fontSize: 14,
textAlign: 'right',
fontFamily: fontFamily.bold,
},
noticeCard: {
padding: 16,
@@ -301,9 +627,19 @@ const styles = StyleSheet.create({
lineHeight: 18,
textAlign: 'center',
},
modalBody: {
gap: 16,
},
modalCopy: {
fontSize: 14,
lineHeight: 20,
},
modalMeta: {
fontSize: 12,
textAlign: 'center',
},
footer: {
padding: 16,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.05)',
}
},
});
@@ -19,7 +19,17 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransactionSuccess'
export function TransactionSuccessScreen({ navigation, route }: Props) {
const theme = useTheme();
const { transactionId, amount, beneficiaryName, isPending } = route.params;
const { transactionId, amount, beneficiaryName, isPending: legacyPending, rrn, detail } = route.params;
const outcome = route.params.outcome ?? (legacyPending ? 'pending' : 'success');
const isPending = outcome === 'pending';
const isFailed = outcome === 'failed';
const title = isFailed ? 'Transfer Failed' : isPending ? 'Transfer Pending' : 'Transfer Successful';
const subtitle =
detail ??
(isPending
? 'NEFT process takes approx 30 mins to reflect.'
: `Amount has been successfully sent to ${beneficiaryName}.`);
const scale = useSharedValue(0);
const opacity = useSharedValue(0);
@@ -47,18 +57,16 @@ export function TransactionSuccessScreen({ navigation, route }: Props) {
return (
<ScreenShell title="Status" scrollable={false} contentContainerStyle={styles.container}>
<View style={styles.centerContent}>
<Animated.View style={[styles.iconContainer, { backgroundColor: isPending ? theme.colors.warning : theme.colors.success }, iconStyle]}>
<Ionicons name={isPending ? "time" : "checkmark"} size={60} color="#FFF" />
<Animated.View style={[styles.iconContainer, { backgroundColor: isFailed ? theme.colors.danger : isPending ? theme.colors.warning : theme.colors.success }, iconStyle]}>
<Ionicons name={isFailed ? "close" : isPending ? "time" : "checkmark"} size={60} color="#FFF" />
</Animated.View>
<Animated.View style={[styles.textWrapper, contentStyle]}>
<Text style={[styles.statusTitle, { color: theme.colors.textPrimary }]}>
{isPending ? 'Transfer Initiated' : 'Transfer Successful'}
{title}
</Text>
<Text style={[styles.statusSubtitle, { color: theme.colors.textSecondary }]}>
{isPending
? 'NEFT process takes approx 30 mins to reflect.'
: `Amount has been successfully sent to ${beneficiaryName}.`}
{subtitle}
</Text>
<Card style={styles.receiptCard}>
@@ -66,6 +74,16 @@ export function TransactionSuccessScreen({ navigation, route }: Props) {
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>Transaction ID</Text>
<Text style={[styles.receiptValue, { color: theme.colors.textPrimary }]}>{transactionId}</Text>
</View>
{rrn ? (
<View style={styles.receiptRow}>
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>IMPS RRN</Text>
<Text style={[styles.receiptValue, { color: theme.colors.textPrimary }]}>{rrn}</Text>
</View>
) : null}
<View style={styles.receiptRow}>
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>Paid to</Text>
<Text style={[styles.receiptValue, { color: theme.colors.textPrimary }]}>{beneficiaryName}</Text>
</View>
<View style={styles.receiptRow}>
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>Amount</Text>
<Text style={[styles.receiptValue, { color: theme.colors.primary, fontFamily: fontFamily.bold }]}>₹{amount.toLocaleString('en-IN')}</Text>
@@ -8,7 +8,6 @@ import { useAppSelector } from '../../../app/hooks';
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTransferForm } from '../hooks/useTransferForm';
import { TransferAmountFields } from '../components/TransferAmountFields';
import { isValidMmid, isValidPhone } from '../../../utils/validation';
import { useTheme, fontFamily } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
@@ -58,21 +57,14 @@ export default function TransferToMobileScreen({ navigation, route }: Props) {
setSaveAsBeneficiary(false);
}, [beneficiary]);
const mobileError =
mobileNumber.length > 0 && !isValidPhone(mobileNumber)
? 'Enter the 10-digit registered mobile number'
: undefined;
const mmidError =
mmid.length > 0 && !isValidMmid(mmid) ? 'An MMID is 7 digits' : undefined;
const canContinue =
Boolean(sourceAccountNumber) &&
isValidPhone(mobileNumber) &&
isValidMmid(mmid) &&
amountValid;
// No client-side checks on the recipient: the mobile number and MMID go
// straight to the IMPS name inquiry, which is the authority on whether they
// are valid. Only the amount is required, because the confirmation screen
// cannot show or send a transfer without one.
const canContinue = amountValid;
const handleContinue = () => {
if (!canContinue || !sourceAccountNumber) {
if (!canContinue) {
return;
}
@@ -124,7 +116,6 @@ export default function TransferToMobileScreen({ navigation, route }: Props) {
maxLength={10}
value={mobileNumber}
onChangeText={(value) => setMobileNumber(value.replace(/\D/g, ''))}
error={mobileError}
/>
<Input
@@ -134,7 +125,6 @@ export default function TransferToMobileScreen({ navigation, route }: Props) {
maxLength={7}
value={mmid}
onChangeText={(value) => setMmid(value.replace(/\D/g, ''))}
error={mmidError}
/>
<Input
+69 -3
View File
@@ -8,13 +8,19 @@ import type {
BeneficiaryValidateRequest,
BeneficiaryValidateResult,
CbsBeneficiary,
FundTransferInitiateRequest,
FundTransferInitiateResult,
FundTransferVerifyResult,
} from '../../../types/cbs';
import { isCbsApiError } from '../../../services/api/cbsErrors';
import { mpinCrypto } from '../../../services/security/mpinCrypto';
import { favoriteStore, toBeneficiary } from '../utils/beneficiaryMapper';
/**
* TEMPORARY — fixed payer account for payee validation.
* TEMPORARY — fixed payer account for payee validation and IMPS transfer
* initiate.
*
* Every validate call goes out with this as the payer, overriding whichever
* Every validate and initiate call goes out with this as the payer, overriding whichever
* account the customer selected on screen. The development CBS only recognises
* this account as a payer; the profile's own accounts come back
* `ACCOUNT_NOT_FOUND`, which reads on screen as the *payee* being unverifiable.
@@ -99,7 +105,65 @@ export const paymentsApi = baseApi.injectEndpoints({
invalidatesTags: ['Beneficiaries'],
}),
/** No transfer endpoint exists yet, so this stays on fixtures. */
/**
* Step one of an IMPS transfer: IMPS name inquiry, then an OTP. No debit.
*
* Retried once with a fresh key on MALFORMED_REQUEST, the same as sign-in.
* That is safe here and nowhere near ReqPay: the `requestId` makes a
* repeat return the existing transfer, and this call cannot move money.
*/
initiateFundTransfer: builder.mutation<
FundTransferInitiateResult,
Omit<FundTransferInitiateRequest, 'encryptedMpin'> & { mpin: string }
>({
queryFn: ({ mpin, ...request }) =>
resolveLive(async () => {
const send = async () => {
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
const response = await apiClient.post<FundTransferInitiateResult>(
endpoints.fundTransfers.initiate,
// See HARDCODED_PAYER_ACCOUNT — the caller's payer is discarded,
// so this is also the account that gets debited.
{ ...request, payer: { accountNumber: HARDCODED_PAYER_ACCOUNT }, encryptedMpin },
);
return response.data;
};
try {
return await send();
} catch (error) {
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
return send();
}
throw error;
}
}),
}),
/**
* Step two: consumes the OTP and sends ReqPay. Never retried by the app —
* a lost response here is resolved by the server's reconciliation job, and
* a second call is refused with TRANSFER_NOT_AWAITING_OTP anyway.
*/
verifyFundTransferOtp: builder.mutation<
FundTransferVerifyResult,
{ transferId: string; otp: string }
>({
queryFn: ({ transferId, otp }) =>
resolveLive(async () => {
const response = await apiClient.post<FundTransferVerifyResult>(
endpoints.fundTransfers.verifyOtp(transferId),
{ otp },
);
return response.data;
}),
invalidatesTags: ['Payments', 'Transactions'],
}),
/** NEFT, RTGS and own-account have no endpoint yet, so this stays on fixtures. */
submitBankTransfer: builder.mutation<BankTransferResponse, BankTransferRequest>({
queryFn: (req) =>
resolveWithMock({
@@ -121,4 +185,6 @@ export const {
useValidateBeneficiaryMutation,
useSaveBeneficiaryMutation,
useSubmitBankTransferMutation,
useInitiateFundTransferMutation,
useVerifyFundTransferOtpMutation,
} = paymentsApi;
+10
View File
@@ -110,6 +110,16 @@ export type PaymentsStackParamList = {
amount: number;
beneficiaryName: string;
isPending: boolean; // True for NEFT typically
/**
* The settled outcome. `pending` covers NEFT batching and an IMPS
* transfer whose result is being reconciled; `failed` is a refusal after
* the OTP was spent. Absent means derive it from `isPending`.
*/
outcome?: 'success' | 'pending' | 'failed';
/** The NPCI RRN, when IMPS returned one. */
rrn?: string;
/** Why a failed or pending transfer ended where it did. */
detail?: string;
};
};
+3 -3
View File
@@ -9,9 +9,9 @@ import {
} from './cbsErrors';
describe('error code catalogue', () => {
it('covers all 46 documented codes, with no duplicates', () => {
expect(CBS_ERROR_CODES).toHaveLength(46);
expect(new Set(CBS_ERROR_CODES).size).toBe(46);
it('covers all 48 documented codes, with no duplicates', () => {
expect(CBS_ERROR_CODES).toHaveLength(48);
expect(new Set(CBS_ERROR_CODES).size).toBe(48);
});
it('does not invite a retry when the bank withholds a balance', () => {
+12 -1
View File
@@ -51,6 +51,8 @@ export const CBS_ERROR_CODES = [
'SMS_DELIVERY_FAILED',
'OTP_PROVIDER_UNAVAILABLE',
'CONNECTOR_UNAVAILABLE',
'IMPS_REQUEST_REJECTED',
'TRANSFER_NOT_AWAITING_OTP',
] as const;
export type CbsErrorCode = (typeof CBS_ERROR_CODES)[number];
@@ -256,7 +258,7 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
},
OTP_ATTEMPTS_EXCEEDED: {
title: 'Too many wrong codes',
description: 'Start registration again to get a new code.',
description: 'Start again to get a new code.',
},
INTERNAL_ERROR: {
title: 'Something went wrong at the bank',
@@ -282,6 +284,15 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Payee checks are unavailable',
description: 'This is a server configuration issue, not a problem with the details you entered.',
},
IMPS_REQUEST_REJECTED: {
title: 'The payment network refused the request',
description: 'Nothing was sent. Please try again in a moment.',
retryable: true,
},
TRANSFER_NOT_AWAITING_OTP: {
title: 'This transfer is already finished',
description: 'It is no longer waiting for a code, so nothing was sent again. Check your transactions for its outcome.',
},
MMID_ALLOCATION_FAILED: {
title: 'We could not finish setting up your accounts',
description: 'Please try again shortly.',
+9
View File
@@ -69,6 +69,15 @@ export const endpoints = {
save: '/beneficiaries',
validate: '/beneficiaries/validate',
},
/**
* IMPS transfers, in two steps. `initiate` runs the name inquiry and sends an
* OTP but moves no money; `verifyOtp` consumes the OTP and sends ReqPay. The
* server never sends ReqPay before a verified OTP, and never retries it.
*/
fundTransfers: {
initiate: '/fund-transfers/initiate',
verifyOtp: (transferId: string) => `/fund-transfers/${transferId}/verify-otp`,
},
dashboard: {
summary: '/dashboard/summary',
},
+71
View File
@@ -405,6 +405,77 @@ export interface CbsBeneficiary {
status: string | null;
}
/* ── IMPS fund transfer ───────────────────────────────────────────────────── */
/**
* Where a transfer is in its lifecycle. SUCCESS, FAILED and MANUAL_REVIEW are
* terminal; PENDING_RECONCILIATION is resolved by the server's own ReqChkTxn
* job, never by the app.
*/
export type FundTransferStatus =
| 'PENDING_VALIDATION'
| 'PENDING_OTP'
| 'PROCESSING'
| 'SUCCESS'
| 'FAILED'
| 'PENDING_RECONCILIATION'
| 'MANUAL_REVIEW';
/**
* `POST /fund-transfers/initiate`. Runs the IMPS name inquiry and issues an
* OTP; it moves no money.
*
* `requestId` is the idempotency key: orgId + userId + requestId is unique, so
* a retry with the same id returns the same transfer rather than a second one.
* Generate it once per transfer attempt, and keep it across network retries.
*/
export interface FundTransferInitiateRequest {
requestId: string;
mode: BeneficiaryMode;
amount: number;
remark?: string;
encryptedMpin: string;
payer: { accountNumber: string };
payee: BeneficiaryPayee & { accountType?: string };
}
export interface FundTransferInitiateResult {
transferId: string;
requestId: string;
referenceId: string;
/** PENDING_OTP when the payee verified; FAILED when IMPS refused them. */
status: FundTransferStatus;
otpExpiresAt: string | null;
attemptsRemaining: number;
/** Test configuration only — must never be relied on in production. */
debugOtp?: string | null;
}
/**
* The decrypted ReqPay inner response from
* `POST /fund-transfers/{transferId}/verify-otp`. Read `txnStatus`, not the
* HTTP status: a failed debit still answers `success: true`.
*/
export interface FundTransferVerifyResult {
txnId?: string;
refId?: string;
/** The NPCI RRN — what the customer quotes to the bank. */
custRefNo?: string;
/** SUCCESS, FAILURE, or DEEMED (outcome not yet known). */
txnStatus: string;
errCode?: string;
errorDiscrp?: string;
payee?: {
name?: string;
accountNumber?: string;
ifsc?: string;
accountType?: string;
amount?: string;
responseCode?: string;
approvalNumber?: string;
};
}
/* ── Positive Pay ─────────────────────────────────────────────────────────── */
/**