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
|
// 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,
|
// 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.
|
// 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 = (
|
const scrollContent = (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -149,7 +151,15 @@ export const ScreenShell = ({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{innerContent}
|
{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>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ export const env = {
|
|||||||
/** Every documented endpoint lives under this prefix. */
|
/** Every documented endpoint lives under this prefix. */
|
||||||
apiBaseUrl: `${API_ORIGIN}/api/v1`,
|
apiBaseUrl: `${API_ORIGIN}/api/v1`,
|
||||||
/** Organisation the app is branded for; sent at registration and at sign-in. */
|
/** 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,
|
* Modules with no backend behind them yet — cards, loans, insurance, deposits,
|
||||||
* the dashboard and the agent app — still resolve from fixtures. Registration,
|
* 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';
|
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]: { enabled: true, description: 'Authentication module' },
|
||||||
[FeatureKey.AUTH_LOGIN]: { enabled: true },
|
[FeatureKey.AUTH_LOGIN]: { enabled: true },
|
||||||
[FeatureKey.AUTH_LOGIN_USER_ROLE]: { enabled: true, description: 'Customer login tab' },
|
[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_BIOMETRIC]: { enabled: true },
|
||||||
[FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD]: { enabled: true },
|
[FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD]: { enabled: true },
|
||||||
[FeatureKey.AUTH_REGISTER]: {
|
[FeatureKey.AUTH_REGISTER]: {
|
||||||
@@ -251,7 +258,14 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// --------------------------------------------------------------- Agent ----
|
// --------------------------------------------------------------- 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]: { enabled: true },
|
||||||
[FeatureKey.AGENT_DASHBOARD_MAIN]: { 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', () => {
|
it('disables a single child without touching its parent or siblings', () => {
|
||||||
featureService.setSource(FeatureSource.Remote, {
|
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_CREATE]: false,
|
||||||
[FeatureKey.AGENT_CUSTOMERS_DELETE]: false,
|
[FeatureKey.AGENT_CUSTOMERS_DELETE]: false,
|
||||||
});
|
});
|
||||||
@@ -208,6 +213,10 @@ describe('guardFeature', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('runs the action while the feature is enabled', () => {
|
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 action = jest.fn(() => 'ran');
|
||||||
const guarded = guardFeature(FeatureKey.AGENT_CUSTOMERS_DELETE, action);
|
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 { CodeInput } from '../components/CodeInput';
|
||||||
import { PreLoginActions } from '../components/PreLoginActions';
|
import { PreLoginActions } from '../components/PreLoginActions';
|
||||||
import { LoginFooter } from '../components/LoginFooter';
|
import { LoginFooter } from '../components/LoginFooter';
|
||||||
|
import { SimIcon } from '../components/SimIcon';
|
||||||
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
|
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
|
||||||
import type { AuthStackParamList } from '../../../navigation/types';
|
import type { AuthStackParamList } from '../../../navigation/types';
|
||||||
import type { LoginRole } from '../../../types/app';
|
import type { LoginRole } from '../../../types/app';
|
||||||
@@ -301,6 +302,15 @@ export default function LoginScreen({ navigation }: Props) {
|
|||||||
? `+91 ••••• •${lastProfile.mobileNumber.slice(-4)}`
|
? `+91 ••••• •${lastProfile.mobileNumber.slice(-4)}`
|
||||||
: undefined;
|
: 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 = (
|
const gradient = (
|
||||||
<>
|
<>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
@@ -368,11 +378,7 @@ export default function LoginScreen({ navigation }: Props) {
|
|||||||
>
|
>
|
||||||
{lastProfile.fullName}
|
{lastProfile.fullName}
|
||||||
</Text>
|
</Text>
|
||||||
{maskedMobile ? (
|
{maskedMobileRow}
|
||||||
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
|
|
||||||
{maskedMobile}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -392,11 +398,7 @@ export default function LoginScreen({ navigation }: Props) {
|
|||||||
no name, because it does not return one — so without this
|
no name, because it does not return one — so without this
|
||||||
the screen asks for an MPIN and names nothing at all.
|
the screen asks for an MPIN and names nothing at all.
|
||||||
*/}
|
*/}
|
||||||
{maskedMobile ? (
|
{maskedMobileRow}
|
||||||
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
|
|
||||||
{maskedMobile}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -418,6 +420,7 @@ export default function LoginScreen({ navigation }: Props) {
|
|||||||
{!isReturningCustomer ? (
|
{!isReturningCustomer ? (
|
||||||
<Input
|
<Input
|
||||||
label="Registered mobile number"
|
label="Registered mobile number"
|
||||||
|
leftAccessory={<SimIcon size={18} color={theme.colors.textSecondary} />}
|
||||||
prefix="+91"
|
prefix="+91"
|
||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
@@ -588,6 +591,11 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
},
|
},
|
||||||
|
mobileRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
heroBlock: {
|
heroBlock: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ import type {
|
|||||||
/**
|
/**
|
||||||
* `MALFORMED_REQUEST` on an MPIN call is what a stale or wrong public key looks
|
* `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
|
* 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
|
* would leak whether the profile exists. The key is fetched fresh on every
|
||||||
* try exactly once more. A second failure is a real problem worth surfacing.
|
* 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> => {
|
const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
return await call();
|
return await call();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
||||||
mpinCrypto.invalidate();
|
|
||||||
return call();
|
return call();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,87 @@
|
|||||||
import React, { useState } from 'react';
|
import 'react-native-get-random-values';
|
||||||
import { StyleSheet, Text, View, ScrollView } from 'react-native';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
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 {
|
import {
|
||||||
|
useInitiateFundTransferMutation,
|
||||||
useSaveBeneficiaryMutation,
|
useSaveBeneficiaryMutation,
|
||||||
useSubmitBankTransferMutation,
|
useSubmitBankTransferMutation,
|
||||||
useValidateBeneficiaryMutation,
|
useValidateBeneficiaryMutation,
|
||||||
|
useVerifyFundTransferOtpMutation,
|
||||||
} from '../services/paymentsApi';
|
} from '../services/paymentsApi';
|
||||||
import { isPayeeVerified } from '../../../types/cbs';
|
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 { FeatureKey } from '../../../constants/featureKeys';
|
||||||
import { FeatureGate, guardFeature, isFeatureEnabled } from '../../../features';
|
import { FeatureGate, guardFeature, isFeatureEnabled } from '../../../features';
|
||||||
import { beneficiariesActions, selectBeneficiaryById } from '../store/beneficiariesSlice';
|
import { beneficiariesActions, selectBeneficiaryById } from '../store/beneficiariesSlice';
|
||||||
import { useAccounts } from '../../home/hooks/useAccounts';
|
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 { useTheme, fontFamily } from '../../../theme';
|
||||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
import type { AdHocRecipient, PaymentsStackParamList } from '../../../navigation/types';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentConfirmation'>;
|
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) {
|
export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const showApiError = useApiErrorToast();
|
||||||
const {
|
const {
|
||||||
beneficiaryId,
|
beneficiaryId,
|
||||||
transferType,
|
transferType,
|
||||||
@@ -51,135 +110,357 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
|||||||
const payeeMobile = beneficiary?.mobileNumber ?? recipient?.mobileNumber;
|
const payeeMobile = beneficiary?.mobileNumber ?? recipient?.mobileNumber;
|
||||||
const payeeMmid = beneficiary?.mmid ?? recipient?.mmid;
|
const payeeMmid = beneficiary?.mmid ?? recipient?.mmid;
|
||||||
const payeeIfsc = beneficiary?.ifsc ?? recipient?.ifsc;
|
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 [saveBeneficiary] = useSaveBeneficiaryMutation();
|
||||||
const [validateBeneficiary] = useValidateBeneficiaryMutation();
|
const [validateBeneficiary] = useValidateBeneficiaryMutation();
|
||||||
const [otpModalVisible, setOtpModalVisible] = useState(false);
|
|
||||||
|
const [step, setStep] = useState<Step>('review');
|
||||||
|
const [mpin, setMpin] = useState('');
|
||||||
const [otp, setOtp] = 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, () => {
|
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
|
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;
|
if (!isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_SUBMIT)) return;
|
||||||
|
|
||||||
setOtpModalVisible(false);
|
setStep('review');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await submitTransfer({
|
const response = await submitTransfer({
|
||||||
beneficiaryId: beneficiaryId ?? '',
|
beneficiaryId: beneficiaryId ?? '',
|
||||||
amount,
|
amount,
|
||||||
transferType,
|
transferType,
|
||||||
remarks,
|
remarks,
|
||||||
// The account the customer actually picked on the form, rather than a
|
|
||||||
// hardcoded one — this is the account that gets debited.
|
|
||||||
sourceAccountId: sourceAccountNumber ?? '',
|
sourceAccountId: sourceAccountNumber ?? '',
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
|
|
||||||
/**
|
void savePayeeIfAsked();
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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', {
|
navigation.navigate('TransactionSuccess', {
|
||||||
transactionId: response.referenceId,
|
transactionId: response.referenceId,
|
||||||
amount,
|
amount,
|
||||||
beneficiaryName: payeeName,
|
beneficiaryName: payeeName,
|
||||||
isPending: response.status === 'Process_Pending',
|
isPending: response.status === 'Process_Pending',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.warn("Transfer failed", e);
|
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 }) => (
|
const DetailRow = ({ label, value }: { label: string; value: string }) => (
|
||||||
<View style={styles.detailRow}>
|
<View style={styles.detailRow}>
|
||||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>{label}</Text>
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const amountLabel = `₹${amount.toLocaleString('en-IN')}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenShell title="Confirm Payment" subtitle="Review transaction details" contentContainerStyle={styles.container}>
|
<ScreenShell
|
||||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
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}>
|
<Card style={styles.confirmCard}>
|
||||||
<View style={styles.amountSection}>
|
<View style={styles.amountSection}>
|
||||||
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Total Amount</Text>
|
<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>
|
||||||
|
|
||||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||||
|
|
||||||
<View style={styles.detailsSection}>
|
<View style={styles.detailsSection}>
|
||||||
<DetailRow label="Transfer Mode" value={transferType} />
|
<DetailRow label="Transfer Mode" value={transferType} />
|
||||||
<DetailRow label="To Beneficiary" value={payeeName} />
|
<DetailRow label="To Beneficiary" value={payeeName} />
|
||||||
@@ -192,61 +473,105 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{notice ? <InfoBanner tone="danger" message={notice} /> : null}
|
||||||
|
|
||||||
<View style={[styles.noticeCard, { backgroundColor: `${theme.colors.info}10`, borderColor: theme.colors.info }]}>
|
<View style={[styles.noticeCard, { backgroundColor: `${theme.colors.info}10`, borderColor: theme.colors.info }]}>
|
||||||
<Text style={[styles.noticeText, { color: theme.colors.textPrimary }]}>
|
<Text style={[styles.noticeText, { color: theme.colors.textPrimary }]}>
|
||||||
{sourceAccount
|
{sourceAccount
|
||||||
? `Funds will be debited from your ${sourceAccount.accountType} account XXXX ${sourceAccount.accountNumber.slice(-4)}.`
|
? `Funds will be debited from your ${sourceAccount.accountType} account XXXX ${sourceAccount.accountNumber.slice(-4)}.`
|
||||||
: 'Funds will be debited from your selected account.'}
|
: 'Funds will be debited from your selected account.'}
|
||||||
|
{impsTarget ? ' The payee is verified with IMPS before anything is sent.' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View style={styles.footer}>
|
{/* ── MPIN (IMPS only) ─────────────────────────────────────────────── */}
|
||||||
{/* Submission is the last switchable step: with it off the review screen
|
<AppModal visible={step === 'mpin'} onClose={closeModal} title="Enter your MPIN">
|
||||||
still reads, but nothing can be sent. */}
|
<View style={styles.modalBody}>
|
||||||
<FeatureGate feature={FeatureKey.PAYMENTS_TRANSFER_SUBMIT}>
|
<Text style={[styles.modalCopy, { color: theme.colors.textSecondary }]}>
|
||||||
<Button
|
{`We'll verify ${payeeName} with IMPS and send a code to your registered mobile. Nothing is debited yet.`}
|
||||||
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.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<CodeInput
|
||||||
label="Transaction OTP"
|
label="MPIN"
|
||||||
placeholder="Enter OTP (Hint: 123456)"
|
value={mpin}
|
||||||
keyboardType="number-pad"
|
onChangeText={setMpin}
|
||||||
maxLength={6}
|
length={MPIN_LENGTH}
|
||||||
value={otp}
|
secure
|
||||||
onChangeText={setOtp}
|
autoFocus
|
||||||
|
editable={!isInitiating}
|
||||||
|
onSubmit={handleInitiate}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
title="Authorize Payment"
|
title="Verify payee & send OTP"
|
||||||
onPress={finalSubmit}
|
onPress={handleInitiate}
|
||||||
disabled={otp.length !== 6 || isLoading}
|
disabled={mpin.length !== MPIN_LENGTH || isInitiating}
|
||||||
loading={isLoading}
|
loading={isInitiating}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</AppModal>
|
</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>
|
</ScreenShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
gap: 16,
|
gap: 16,
|
||||||
@@ -289,6 +614,7 @@ const styles = StyleSheet.create({
|
|||||||
value: {
|
value: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
|
fontFamily: fontFamily.bold,
|
||||||
},
|
},
|
||||||
noticeCard: {
|
noticeCard: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
@@ -301,9 +627,19 @@ const styles = StyleSheet.create({
|
|||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
modalBody: {
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
modalCopy: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
modalMeta: {
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: 'rgba(0,0,0,0.05)',
|
},
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,17 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransactionSuccess'
|
|||||||
|
|
||||||
export function TransactionSuccessScreen({ navigation, route }: Props) {
|
export function TransactionSuccessScreen({ navigation, route }: Props) {
|
||||||
const theme = useTheme();
|
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 scale = useSharedValue(0);
|
||||||
const opacity = useSharedValue(0);
|
const opacity = useSharedValue(0);
|
||||||
@@ -47,18 +57,16 @@ export function TransactionSuccessScreen({ navigation, route }: Props) {
|
|||||||
return (
|
return (
|
||||||
<ScreenShell title="Status" scrollable={false} contentContainerStyle={styles.container}>
|
<ScreenShell title="Status" scrollable={false} contentContainerStyle={styles.container}>
|
||||||
<View style={styles.centerContent}>
|
<View style={styles.centerContent}>
|
||||||
<Animated.View style={[styles.iconContainer, { backgroundColor: isPending ? theme.colors.warning : theme.colors.success }, iconStyle]}>
|
<Animated.View style={[styles.iconContainer, { backgroundColor: isFailed ? theme.colors.danger : isPending ? theme.colors.warning : theme.colors.success }, iconStyle]}>
|
||||||
<Ionicons name={isPending ? "time" : "checkmark"} size={60} color="#FFF" />
|
<Ionicons name={isFailed ? "close" : isPending ? "time" : "checkmark"} size={60} color="#FFF" />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View style={[styles.textWrapper, contentStyle]}>
|
<Animated.View style={[styles.textWrapper, contentStyle]}>
|
||||||
<Text style={[styles.statusTitle, { color: theme.colors.textPrimary }]}>
|
<Text style={[styles.statusTitle, { color: theme.colors.textPrimary }]}>
|
||||||
{isPending ? 'Transfer Initiated' : 'Transfer Successful'}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.statusSubtitle, { color: theme.colors.textSecondary }]}>
|
<Text style={[styles.statusSubtitle, { color: theme.colors.textSecondary }]}>
|
||||||
{isPending
|
{subtitle}
|
||||||
? 'NEFT process takes approx 30 mins to reflect.'
|
|
||||||
: `Amount has been successfully sent to ${beneficiaryName}.`}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Card style={styles.receiptCard}>
|
<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.receiptLabel, { color: theme.colors.textSecondary }]}>Transaction ID</Text>
|
||||||
<Text style={[styles.receiptValue, { color: theme.colors.textPrimary }]}>{transactionId}</Text>
|
<Text style={[styles.receiptValue, { color: theme.colors.textPrimary }]}>{transactionId}</Text>
|
||||||
</View>
|
</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}>
|
<View style={styles.receiptRow}>
|
||||||
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>Amount</Text>
|
<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>
|
<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 { selectBeneficiaryById } from '../store/beneficiariesSlice';
|
||||||
import { useTransferForm } from '../hooks/useTransferForm';
|
import { useTransferForm } from '../hooks/useTransferForm';
|
||||||
import { TransferAmountFields } from '../components/TransferAmountFields';
|
import { TransferAmountFields } from '../components/TransferAmountFields';
|
||||||
import { isValidMmid, isValidPhone } from '../../../utils/validation';
|
|
||||||
import { useTheme, fontFamily } from '../../../theme';
|
import { useTheme, fontFamily } from '../../../theme';
|
||||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||||
|
|
||||||
@@ -58,21 +57,14 @@ export default function TransferToMobileScreen({ navigation, route }: Props) {
|
|||||||
setSaveAsBeneficiary(false);
|
setSaveAsBeneficiary(false);
|
||||||
}, [beneficiary]);
|
}, [beneficiary]);
|
||||||
|
|
||||||
const mobileError =
|
// No client-side checks on the recipient: the mobile number and MMID go
|
||||||
mobileNumber.length > 0 && !isValidPhone(mobileNumber)
|
// straight to the IMPS name inquiry, which is the authority on whether they
|
||||||
? 'Enter the 10-digit registered mobile number'
|
// are valid. Only the amount is required, because the confirmation screen
|
||||||
: undefined;
|
// cannot show or send a transfer without one.
|
||||||
const mmidError =
|
const canContinue = amountValid;
|
||||||
mmid.length > 0 && !isValidMmid(mmid) ? 'An MMID is 7 digits' : undefined;
|
|
||||||
|
|
||||||
const canContinue =
|
|
||||||
Boolean(sourceAccountNumber) &&
|
|
||||||
isValidPhone(mobileNumber) &&
|
|
||||||
isValidMmid(mmid) &&
|
|
||||||
amountValid;
|
|
||||||
|
|
||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
if (!canContinue || !sourceAccountNumber) {
|
if (!canContinue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +116,6 @@ export default function TransferToMobileScreen({ navigation, route }: Props) {
|
|||||||
maxLength={10}
|
maxLength={10}
|
||||||
value={mobileNumber}
|
value={mobileNumber}
|
||||||
onChangeText={(value) => setMobileNumber(value.replace(/\D/g, ''))}
|
onChangeText={(value) => setMobileNumber(value.replace(/\D/g, ''))}
|
||||||
error={mobileError}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@@ -134,7 +125,6 @@ export default function TransferToMobileScreen({ navigation, route }: Props) {
|
|||||||
maxLength={7}
|
maxLength={7}
|
||||||
value={mmid}
|
value={mmid}
|
||||||
onChangeText={(value) => setMmid(value.replace(/\D/g, ''))}
|
onChangeText={(value) => setMmid(value.replace(/\D/g, ''))}
|
||||||
error={mmidError}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -8,13 +8,19 @@ import type {
|
|||||||
BeneficiaryValidateRequest,
|
BeneficiaryValidateRequest,
|
||||||
BeneficiaryValidateResult,
|
BeneficiaryValidateResult,
|
||||||
CbsBeneficiary,
|
CbsBeneficiary,
|
||||||
|
FundTransferInitiateRequest,
|
||||||
|
FundTransferInitiateResult,
|
||||||
|
FundTransferVerifyResult,
|
||||||
} from '../../../types/cbs';
|
} from '../../../types/cbs';
|
||||||
|
import { isCbsApiError } from '../../../services/api/cbsErrors';
|
||||||
|
import { mpinCrypto } from '../../../services/security/mpinCrypto';
|
||||||
import { favoriteStore, toBeneficiary } from '../utils/beneficiaryMapper';
|
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
|
* account the customer selected on screen. The development CBS only recognises
|
||||||
* this account as a payer; the profile's own accounts come back
|
* this account as a payer; the profile's own accounts come back
|
||||||
* `ACCOUNT_NOT_FOUND`, which reads on screen as the *payee* being unverifiable.
|
* `ACCOUNT_NOT_FOUND`, which reads on screen as the *payee* being unverifiable.
|
||||||
@@ -99,7 +105,65 @@ export const paymentsApi = baseApi.injectEndpoints({
|
|||||||
invalidatesTags: ['Beneficiaries'],
|
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>({
|
submitBankTransfer: builder.mutation<BankTransferResponse, BankTransferRequest>({
|
||||||
queryFn: (req) =>
|
queryFn: (req) =>
|
||||||
resolveWithMock({
|
resolveWithMock({
|
||||||
@@ -121,4 +185,6 @@ export const {
|
|||||||
useValidateBeneficiaryMutation,
|
useValidateBeneficiaryMutation,
|
||||||
useSaveBeneficiaryMutation,
|
useSaveBeneficiaryMutation,
|
||||||
useSubmitBankTransferMutation,
|
useSubmitBankTransferMutation,
|
||||||
|
useInitiateFundTransferMutation,
|
||||||
|
useVerifyFundTransferOtpMutation,
|
||||||
} = paymentsApi;
|
} = paymentsApi;
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ export type PaymentsStackParamList = {
|
|||||||
amount: number;
|
amount: number;
|
||||||
beneficiaryName: string;
|
beneficiaryName: string;
|
||||||
isPending: boolean; // True for NEFT typically
|
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';
|
} from './cbsErrors';
|
||||||
|
|
||||||
describe('error code catalogue', () => {
|
describe('error code catalogue', () => {
|
||||||
it('covers all 46 documented codes, with no duplicates', () => {
|
it('covers all 48 documented codes, with no duplicates', () => {
|
||||||
expect(CBS_ERROR_CODES).toHaveLength(46);
|
expect(CBS_ERROR_CODES).toHaveLength(48);
|
||||||
expect(new Set(CBS_ERROR_CODES).size).toBe(46);
|
expect(new Set(CBS_ERROR_CODES).size).toBe(48);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not invite a retry when the bank withholds a balance', () => {
|
it('does not invite a retry when the bank withholds a balance', () => {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export const CBS_ERROR_CODES = [
|
|||||||
'SMS_DELIVERY_FAILED',
|
'SMS_DELIVERY_FAILED',
|
||||||
'OTP_PROVIDER_UNAVAILABLE',
|
'OTP_PROVIDER_UNAVAILABLE',
|
||||||
'CONNECTOR_UNAVAILABLE',
|
'CONNECTOR_UNAVAILABLE',
|
||||||
|
'IMPS_REQUEST_REJECTED',
|
||||||
|
'TRANSFER_NOT_AWAITING_OTP',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type CbsErrorCode = (typeof CBS_ERROR_CODES)[number];
|
export type CbsErrorCode = (typeof CBS_ERROR_CODES)[number];
|
||||||
@@ -256,7 +258,7 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
|||||||
},
|
},
|
||||||
OTP_ATTEMPTS_EXCEEDED: {
|
OTP_ATTEMPTS_EXCEEDED: {
|
||||||
title: 'Too many wrong codes',
|
title: 'Too many wrong codes',
|
||||||
description: 'Start registration again to get a new code.',
|
description: 'Start again to get a new code.',
|
||||||
},
|
},
|
||||||
INTERNAL_ERROR: {
|
INTERNAL_ERROR: {
|
||||||
title: 'Something went wrong at the bank',
|
title: 'Something went wrong at the bank',
|
||||||
@@ -282,6 +284,15 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
|||||||
title: 'Payee checks are unavailable',
|
title: 'Payee checks are unavailable',
|
||||||
description: 'This is a server configuration issue, not a problem with the details you entered.',
|
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: {
|
MMID_ALLOCATION_FAILED: {
|
||||||
title: 'We could not finish setting up your accounts',
|
title: 'We could not finish setting up your accounts',
|
||||||
description: 'Please try again shortly.',
|
description: 'Please try again shortly.',
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ export const endpoints = {
|
|||||||
save: '/beneficiaries',
|
save: '/beneficiaries',
|
||||||
validate: '/beneficiaries/validate',
|
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: {
|
dashboard: {
|
||||||
summary: '/dashboard/summary',
|
summary: '/dashboard/summary',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,20 +3,11 @@ import forge from 'node-forge';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { apiClient } from '../api/apiClient';
|
import { apiClient } from '../api/apiClient';
|
||||||
|
import { CbsApiError } from '../api/cbsErrors';
|
||||||
import { endpoints } from '../api/endpoints';
|
import { endpoints } from '../api/endpoints';
|
||||||
import type { PublicKeyResult } from '../../types/cbs';
|
import type { PublicKeyResult } from '../../types/cbs';
|
||||||
|
|
||||||
/**
|
/** The key arrives as Base64 X.509 SubjectPublicKeyInfo (SPKI) DER. */
|
||||||
* 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;
|
|
||||||
|
|
||||||
const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
|
const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
|
||||||
const der = forge.util.decode64(base64Der);
|
const der = forge.util.decode64(base64Der);
|
||||||
const asn1 = forge.asn1.fromDer(forge.util.createBuffer(der));
|
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;
|
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchKey = async (): Promise<forge.pki.rsa.PublicKey> => {
|
export const mpinCrypto = {
|
||||||
try {
|
/**
|
||||||
|
* 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 response = await apiClient.get<PublicKeyResult>(endpoints.crypto.publicKey);
|
||||||
const publicKey = response.data?.publicKey;
|
const publicKey = response.data?.publicKey;
|
||||||
|
|
||||||
if (publicKey) {
|
if (!publicKey) {
|
||||||
return importKey(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);
|
return importKey(publicKey);
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +56,10 @@ export const mpinCrypto = {
|
|||||||
* - `ts` is in *seconds*. Milliseconds read as ~55,000 years in the future
|
* - `ts` is in *seconds*. Milliseconds read as ~55,000 years in the future
|
||||||
* and the request is rejected.
|
* and the request is rejected.
|
||||||
* - `nonce` must be fresh on every single call, or it is refused as a replay.
|
* - `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> {
|
async encryptMpin(mpin: string): Promise<string> {
|
||||||
const publicKey = await this.getPublicKey();
|
const publicKey = await this.getPublicKey();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
import { featureRemoteConfig } from '../../features/featureRemoteConfig';
|
import { featureRemoteConfig } from '../../features/featureRemoteConfig';
|
||||||
import { mpinCrypto } from '../security/mpinCrypto';
|
|
||||||
import { sessionManager } from '../security/sessionManager';
|
import { sessionManager } from '../security/sessionManager';
|
||||||
import { secureStorage } from './secureStorage';
|
import { secureStorage } from './secureStorage';
|
||||||
|
|
||||||
@@ -72,7 +71,6 @@ export const purgeUserData = async () => {
|
|||||||
await featureRemoteConfig.forgetRoleScope().catch(() => {});
|
await featureRemoteConfig.forgetRoleScope().catch(() => {});
|
||||||
|
|
||||||
sessionManager.clear();
|
sessionManager.clear();
|
||||||
mpinCrypto.invalidate();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearAsyncStorage = async () => {
|
const clearAsyncStorage = async () => {
|
||||||
|
|||||||
@@ -405,6 +405,77 @@ export interface CbsBeneficiary {
|
|||||||
status: string | null;
|
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 ─────────────────────────────────────────────────────────── */
|
/* ── Positive Pay ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user