Compare commits
5
Commits
4eeababec8
...
abb14d5768
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb14d5768 | ||
|
|
473f9d7415 | ||
|
|
ee6a408a1a | ||
|
|
ddac198ea7 | ||
|
|
3edbbe64ff |
@@ -77,7 +77,9 @@ export const ScreenShell = ({
|
||||
// Bottom spacing: if inside tab navigator, add TAB_BAR_HEIGHT + breathing room
|
||||
// If there's a fixedFooter, we don't necessarily need tab bar padding on the scroll view,
|
||||
// but let's leave it for safety or adjust based on layout.
|
||||
const tabBarPadding = withTabBar ? TAB_BAR_HEIGHT + 16 : 20;
|
||||
// A fixed footer already clears the tab bar (see below), so the scroll above
|
||||
// it only needs breathing room, not the bar's full height again.
|
||||
const tabBarPadding = withTabBar && !fixedFooter ? TAB_BAR_HEIGHT + 16 : 20;
|
||||
|
||||
const scrollContent = (
|
||||
<ScrollView
|
||||
@@ -149,7 +151,15 @@ export const ScreenShell = ({
|
||||
) : null}
|
||||
|
||||
{innerContent}
|
||||
{fixedFooter}
|
||||
{/* The tab bar is absolutely positioned over the bottom of the frame,
|
||||
so a footer laid out in flow would sit underneath it — the Pay and
|
||||
Continue buttons were rendering behind the bar. Lifting it by the
|
||||
bar's height keeps it above. */}
|
||||
{fixedFooter ? (
|
||||
<View style={withTabBar ? { paddingBottom: TAB_BAR_HEIGHT } : undefined}>
|
||||
{fixedFooter}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export const env = {
|
||||
/** Every documented endpoint lives under this prefix. */
|
||||
apiBaseUrl: `${API_ORIGIN}/api/v1`,
|
||||
/** Organisation the app is branded for; sent at registration and at sign-in. */
|
||||
orgId: 'ORG001',
|
||||
orgId: '107295',
|
||||
/**
|
||||
* Modules with no backend behind them yet — cards, loans, insurance, deposits,
|
||||
* the dashboard and the agent app — still resolve from fixtures. Registration,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeatureKey } from '../constants/featureKeys';
|
||||
import { FeatureKey } from '../constants/featureKeys';
|
||||
import type { FeatureDefinition, FeatureOverrides } from './types';
|
||||
|
||||
/**
|
||||
@@ -22,7 +22,14 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[FeatureKey.AUTH]: { enabled: true, description: 'Authentication module' },
|
||||
[FeatureKey.AUTH_LOGIN]: { enabled: true },
|
||||
[FeatureKey.AUTH_LOGIN_USER_ROLE]: { enabled: true, description: 'Customer login tab' },
|
||||
[FeatureKey.AUTH_LOGIN_AGENT_ROLE]: { enabled: true, description: 'Agent login tab' },
|
||||
[FeatureKey.AUTH_LOGIN_AGENT_ROLE]: {
|
||||
enabled: false,
|
||||
description: 'Agent login tab',
|
||||
reason:
|
||||
'Off with the agent app itself. This key is not under `agent`, so it does not ' +
|
||||
'inherit that kill — left on, the tab would offer a role whose app no longer ' +
|
||||
'exists, and RootNavigator would drop the agent into the customer app instead.',
|
||||
},
|
||||
[FeatureKey.AUTH_LOGIN_BIOMETRIC]: { enabled: true },
|
||||
[FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD]: { enabled: true },
|
||||
[FeatureKey.AUTH_REGISTER]: {
|
||||
@@ -251,7 +258,14 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------- Agent ----
|
||||
[FeatureKey.AGENT]: { enabled: true, description: 'Field-agent application' },
|
||||
[FeatureKey.AGENT]: {
|
||||
enabled: false,
|
||||
description: 'Field-agent application',
|
||||
reason:
|
||||
'Parked for now. Disabling this root retires the whole agent app — the 24 keys ' +
|
||||
'below inherit it, so they are left as they are and the module comes back in ' +
|
||||
'one edit. Pair with `auth.login.agentRole`, which sits outside this subtree.',
|
||||
},
|
||||
|
||||
[FeatureKey.AGENT_DASHBOARD]: { enabled: true },
|
||||
[FeatureKey.AGENT_DASHBOARD_MAIN]: { enabled: true },
|
||||
|
||||
@@ -69,6 +69,11 @@ describe('featureService', () => {
|
||||
|
||||
it('disables a single child without touching its parent or siblings', () => {
|
||||
featureService.setSource(FeatureSource.Remote, {
|
||||
// The agent app ships disabled, so its root is turned on here rather than
|
||||
// assumed. This test is about child-level isolation, not about which
|
||||
// products happen to be switched on in the static config — leaving that
|
||||
// implicit is what made it fail the day the agent app was parked.
|
||||
[FeatureKey.AGENT]: true,
|
||||
[FeatureKey.AGENT_CUSTOMERS_CREATE]: false,
|
||||
[FeatureKey.AGENT_CUSTOMERS_DELETE]: false,
|
||||
});
|
||||
@@ -208,6 +213,10 @@ describe('guardFeature', () => {
|
||||
});
|
||||
|
||||
it('runs the action while the feature is enabled', () => {
|
||||
// Same reason as above: the key is a fixture for the guard's behaviour, so
|
||||
// its subtree is enabled explicitly rather than inherited from the config.
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.AGENT]: true });
|
||||
|
||||
const action = jest.fn(() => 'ran');
|
||||
const guarded = guardFeature(FeatureKey.AGENT_CUSTOMERS_DELETE, action);
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { memo } from 'react';
|
||||
import Svg, { Path, Rect } from 'react-native-svg';
|
||||
|
||||
/**
|
||||
* A SIM card glyph, drawn in the same language as SimChooser's cards: the
|
||||
* chamfered top-right corner and the contact chip. Ionicons has no SIM icon,
|
||||
* and a phone or signal glyph says "device" rather than "the line your bank
|
||||
* knows you by".
|
||||
*/
|
||||
type Props = {
|
||||
size?: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const SimIconComponent = ({ size = 16, color }: Props) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M7 2.75 H14.5 L19.25 7.5 V19.25 A2 2 0 0 1 17.25 21.25 H6.75 A2 2 0 0 1 4.75 19.25 V4.75 A2 2 0 0 1 6.75 2.75 Z"
|
||||
stroke={color}
|
||||
strokeWidth={1.6}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Rect x={8} y={10.5} width={8} height={7} rx={1.2} stroke={color} strokeWidth={1.4} />
|
||||
<Path d="M8 14 H16 M12 10.5 V17.5" stroke={color} strokeWidth={1} />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const SimIcon = memo(SimIconComponent);
|
||||
@@ -44,6 +44,7 @@ import { BiometricNode } from '../components/BiometricNode';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
import { PreLoginActions } from '../components/PreLoginActions';
|
||||
import { LoginFooter } from '../components/LoginFooter';
|
||||
import { SimIcon } from '../components/SimIcon';
|
||||
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
|
||||
import type { AuthStackParamList } from '../../../navigation/types';
|
||||
import type { LoginRole } from '../../../types/app';
|
||||
@@ -301,6 +302,15 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
? `+91 ••••• •${lastProfile.mobileNumber.slice(-4)}`
|
||||
: undefined;
|
||||
|
||||
const maskedMobileRow = maskedMobile ? (
|
||||
<View style={styles.mobileRow}>
|
||||
<SimIcon size={14} color={theme.colors.onBrandMuted} />
|
||||
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
|
||||
{maskedMobile}
|
||||
</Text>
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const gradient = (
|
||||
<>
|
||||
<LinearGradient
|
||||
@@ -368,11 +378,7 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
>
|
||||
{lastProfile.fullName}
|
||||
</Text>
|
||||
{maskedMobile ? (
|
||||
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
|
||||
{maskedMobile}
|
||||
</Text>
|
||||
) : null}
|
||||
{maskedMobileRow}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -392,11 +398,7 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
no name, because it does not return one — so without this
|
||||
the screen asks for an MPIN and names nothing at all.
|
||||
*/}
|
||||
{maskedMobile ? (
|
||||
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
|
||||
{maskedMobile}
|
||||
</Text>
|
||||
) : null}
|
||||
{maskedMobileRow}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -418,6 +420,7 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
{!isReturningCustomer ? (
|
||||
<Input
|
||||
label="Registered mobile number"
|
||||
leftAccessory={<SimIcon size={18} color={theme.colors.textSecondary} />}
|
||||
prefix="+91"
|
||||
keyboardType="number-pad"
|
||||
maxLength={10}
|
||||
@@ -588,6 +591,11 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
mobileRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
heroBlock: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 4,
|
||||
|
||||
@@ -15,15 +15,15 @@ import type {
|
||||
/**
|
||||
* `MALFORMED_REQUEST` on an MPIN call is what a stale or wrong public key looks
|
||||
* like from the client — the server will not say more, because saying more
|
||||
* would leak whether the profile exists. So drop the cached key, re-fetch, and
|
||||
* try exactly once more. A second failure is a real problem worth surfacing.
|
||||
* would leak whether the profile exists. The key is fetched fresh on every
|
||||
* encryption, so a retry picks up a rotated key; try exactly once more.
|
||||
* A second failure is a real problem worth surfacing.
|
||||
*/
|
||||
const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await call();
|
||||
} catch (error) {
|
||||
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
||||
mpinCrypto.invalidate();
|
||||
return call();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -3,20 +3,11 @@ import forge from 'node-forge';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { apiClient } from '../api/apiClient';
|
||||
import { CbsApiError } from '../api/cbsErrors';
|
||||
import { endpoints } from '../api/endpoints';
|
||||
import type { PublicKeyResult } from '../../types/cbs';
|
||||
|
||||
/**
|
||||
* The server's key is configured rather than generated at startup, so it
|
||||
* survives restarts and can be pinned. It is only ever used as a fallback for
|
||||
* when the key fetch itself fails — a developer running their own backend gets
|
||||
* the fetched key, which is the safer default.
|
||||
*/
|
||||
export const PINNED_PUBLIC_KEY =
|
||||
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxhaxPpoewSn+NCHJuICPVmMCO9f3VXN8zMUFryUv5kcn9rCwQMF023o+xtQCljyJdDaJE805lkp9FMZRupSro9auwmGGHTP5fgX5arn5p21LjbcITBxVIh+Uvj1tNKdxhd69xbTf98LfWGK9DspbWd9ZPk6m00La13eMLFR3iBfljzyT9pkENQ5WSvtPrN3+6JS8K9DsCgj/3WbIwwWTPlIN1rXV6gQumFaGl5CVsuibom2tA9pHTjAJi80LLFWP3I7mMkCihvNZT0/eFIBf1WCiOixm4Rt7Xk+Yh05daazMWAJhav+btknQd1gV/1oLolb5uegaobKK5g+xyzA33wIDAQAB';
|
||||
|
||||
let cachedKey: forge.pki.rsa.PublicKey | null = null;
|
||||
|
||||
/** The key arrives as Base64 X.509 SubjectPublicKeyInfo (SPKI) DER. */
|
||||
const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
|
||||
const der = forge.util.decode64(base64Der);
|
||||
const asn1 = forge.asn1.fromDer(forge.util.createBuffer(der));
|
||||
@@ -24,36 +15,31 @@ const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
|
||||
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
|
||||
};
|
||||
|
||||
const fetchKey = async (): Promise<forge.pki.rsa.PublicKey> => {
|
||||
try {
|
||||
export const mpinCrypto = {
|
||||
/**
|
||||
* The server's *current* key, fetched fresh on every call — the backend
|
||||
* contract requires it before each login and each fund transfer, so a
|
||||
* rotated key is picked up immediately rather than failing until restart.
|
||||
*
|
||||
* No cache and no pinned fallback: encrypting to a key the server may no
|
||||
* longer hold only turns a clear "could not fetch the key" into an opaque
|
||||
* MALFORMED_REQUEST one round trip later.
|
||||
*/
|
||||
async getPublicKey(): Promise<forge.pki.rsa.PublicKey> {
|
||||
// A network failure here propagates as the usual NETWORK_ERROR /
|
||||
// REQUEST_TIMEOUT from the interceptor.
|
||||
const response = await apiClient.get<PublicKeyResult>(endpoints.crypto.publicKey);
|
||||
const publicKey = response.data?.publicKey;
|
||||
|
||||
if (publicKey) {
|
||||
return importKey(publicKey);
|
||||
if (!publicKey) {
|
||||
throw new CbsApiError({
|
||||
status: 0,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Could not fetch MPIN encryption key',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the pinned key.
|
||||
}
|
||||
|
||||
return importKey(PINNED_PUBLIC_KEY);
|
||||
};
|
||||
|
||||
export const mpinCrypto = {
|
||||
async getPublicKey(): Promise<forge.pki.rsa.PublicKey> {
|
||||
cachedKey = cachedKey ?? (await fetchKey());
|
||||
|
||||
return cachedKey;
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop the cached key so the next encryption re-fetches it. Call this after a
|
||||
* `MALFORMED_REQUEST`, which is what a stale or wrong key looks like from the
|
||||
* client side — the server cannot say "your key is wrong" without leaking
|
||||
* whether the profile exists.
|
||||
*/
|
||||
invalidate() {
|
||||
cachedKey = null;
|
||||
return importKey(publicKey);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -70,6 +56,10 @@ export const mpinCrypto = {
|
||||
* - `ts` is in *seconds*. Milliseconds read as ~55,000 years in the future
|
||||
* and the request is rejected.
|
||||
* - `nonce` must be fresh on every single call, or it is refused as a replay.
|
||||
*
|
||||
* The result is single-use and must be sent within 120 seconds: call this
|
||||
* immediately before each request, and never reuse one ciphertext for a
|
||||
* login and a transfer.
|
||||
*/
|
||||
async encryptMpin(mpin: string): Promise<string> {
|
||||
const publicKey = await this.getPublicKey();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { featureRemoteConfig } from '../../features/featureRemoteConfig';
|
||||
import { mpinCrypto } from '../security/mpinCrypto';
|
||||
import { sessionManager } from '../security/sessionManager';
|
||||
import { secureStorage } from './secureStorage';
|
||||
|
||||
@@ -72,7 +71,6 @@ export const purgeUserData = async () => {
|
||||
await featureRemoteConfig.forgetRoleScope().catch(() => {});
|
||||
|
||||
sessionManager.clear();
|
||||
mpinCrypto.invalidate();
|
||||
};
|
||||
|
||||
const clearAsyncStorage = async () => {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user