Compare commits

...
5 Commits
Author SHA1 Message Date
Abin ToppoandClaude Opus 5.5 abb14d5768 Implement IMPS fund transfer via initiate and verify-otp
Initiate runs the IMPS name inquiry and sends an OTP; verify-otp sends
ReqPay. The result screen now distinguishes success, failure and
pending reconciliation. The payer is temporarily fixed to the test
account 002002000100019, and the to-mobile form no longer blocks on
mobile/MMID format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:02:03 +05:30
Abin ToppoandClaude Opus 5.5 473f9d7415 Lift fixed footers above the absolutely positioned tab bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:02:03 +05:30
Abin ToppoandClaude Opus 5.5 ee6a408a1a Fetch the MPIN encryption key fresh before every encryption
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:02:03 +05:30
Abin ToppoandClaude Opus 5.5 ddac198ea7 Show a SIM icon beside the mobile number on the login screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:02:02 +05:30
Abin ToppoandClaude Opus 5.5 3edbbe64ff Point the app at org 107295 and park the agent app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:02:01 +05:30
18 changed files with 787 additions and 219 deletions
+12 -2
View File
@@ -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
View File
@@ -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,
+17 -3
View File
@@ -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 },
+9
View File
@@ -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);
+28
View File
@@ -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);
+18 -10
View File
@@ -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,
+3 -3
View File
@@ -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
+69 -3
View File
@@ -8,13 +8,19 @@ import type {
BeneficiaryValidateRequest,
BeneficiaryValidateResult,
CbsBeneficiary,
FundTransferInitiateRequest,
FundTransferInitiateResult,
FundTransferVerifyResult,
} from '../../../types/cbs';
import { isCbsApiError } from '../../../services/api/cbsErrors';
import { mpinCrypto } from '../../../services/security/mpinCrypto';
import { favoriteStore, toBeneficiary } from '../utils/beneficiaryMapper';
/**
* TEMPORARY — fixed payer account for payee validation.
* TEMPORARY — fixed payer account for payee validation and IMPS transfer
* initiate.
*
* Every validate call goes out with this as the payer, overriding whichever
* Every validate and initiate call goes out with this as the payer, overriding whichever
* account the customer selected on screen. The development CBS only recognises
* this account as a payer; the profile's own accounts come back
* `ACCOUNT_NOT_FOUND`, which reads on screen as the *payee* being unverifiable.
@@ -99,7 +105,65 @@ export const paymentsApi = baseApi.injectEndpoints({
invalidatesTags: ['Beneficiaries'],
}),
/** No transfer endpoint exists yet, so this stays on fixtures. */
/**
* Step one of an IMPS transfer: IMPS name inquiry, then an OTP. No debit.
*
* Retried once with a fresh key on MALFORMED_REQUEST, the same as sign-in.
* That is safe here and nowhere near ReqPay: the `requestId` makes a
* repeat return the existing transfer, and this call cannot move money.
*/
initiateFundTransfer: builder.mutation<
FundTransferInitiateResult,
Omit<FundTransferInitiateRequest, 'encryptedMpin'> & { mpin: string }
>({
queryFn: ({ mpin, ...request }) =>
resolveLive(async () => {
const send = async () => {
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
const response = await apiClient.post<FundTransferInitiateResult>(
endpoints.fundTransfers.initiate,
// See HARDCODED_PAYER_ACCOUNT — the caller's payer is discarded,
// so this is also the account that gets debited.
{ ...request, payer: { accountNumber: HARDCODED_PAYER_ACCOUNT }, encryptedMpin },
);
return response.data;
};
try {
return await send();
} catch (error) {
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
return send();
}
throw error;
}
}),
}),
/**
* Step two: consumes the OTP and sends ReqPay. Never retried by the app —
* a lost response here is resolved by the server's reconciliation job, and
* a second call is refused with TRANSFER_NOT_AWAITING_OTP anyway.
*/
verifyFundTransferOtp: builder.mutation<
FundTransferVerifyResult,
{ transferId: string; otp: string }
>({
queryFn: ({ transferId, otp }) =>
resolveLive(async () => {
const response = await apiClient.post<FundTransferVerifyResult>(
endpoints.fundTransfers.verifyOtp(transferId),
{ otp },
);
return response.data;
}),
invalidatesTags: ['Payments', 'Transactions'],
}),
/** NEFT, RTGS and own-account have no endpoint yet, so this stays on fixtures. */
submitBankTransfer: builder.mutation<BankTransferResponse, BankTransferRequest>({
queryFn: (req) =>
resolveWithMock({
@@ -121,4 +185,6 @@ export const {
useValidateBeneficiaryMutation,
useSaveBeneficiaryMutation,
useSubmitBankTransferMutation,
useInitiateFundTransferMutation,
useVerifyFundTransferOtpMutation,
} = paymentsApi;
+10
View File
@@ -110,6 +110,16 @@ export type PaymentsStackParamList = {
amount: number;
beneficiaryName: string;
isPending: boolean; // True for NEFT typically
/**
* The settled outcome. `pending` covers NEFT batching and an IMPS
* transfer whose result is being reconciled; `failed` is a refusal after
* the OTP was spent. Absent means derive it from `isPending`.
*/
outcome?: 'success' | 'pending' | 'failed';
/** The NPCI RRN, when IMPS returned one. */
rrn?: string;
/** Why a failed or pending transfer ended where it did. */
detail?: string;
};
};
+3 -3
View File
@@ -9,9 +9,9 @@ import {
} from './cbsErrors';
describe('error code catalogue', () => {
it('covers all 46 documented codes, with no duplicates', () => {
expect(CBS_ERROR_CODES).toHaveLength(46);
expect(new Set(CBS_ERROR_CODES).size).toBe(46);
it('covers all 48 documented codes, with no duplicates', () => {
expect(CBS_ERROR_CODES).toHaveLength(48);
expect(new Set(CBS_ERROR_CODES).size).toBe(48);
});
it('does not invite a retry when the bank withholds a balance', () => {
+12 -1
View File
@@ -51,6 +51,8 @@ export const CBS_ERROR_CODES = [
'SMS_DELIVERY_FAILED',
'OTP_PROVIDER_UNAVAILABLE',
'CONNECTOR_UNAVAILABLE',
'IMPS_REQUEST_REJECTED',
'TRANSFER_NOT_AWAITING_OTP',
] as const;
export type CbsErrorCode = (typeof CBS_ERROR_CODES)[number];
@@ -256,7 +258,7 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
},
OTP_ATTEMPTS_EXCEEDED: {
title: 'Too many wrong codes',
description: 'Start registration again to get a new code.',
description: 'Start again to get a new code.',
},
INTERNAL_ERROR: {
title: 'Something went wrong at the bank',
@@ -282,6 +284,15 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Payee checks are unavailable',
description: 'This is a server configuration issue, not a problem with the details you entered.',
},
IMPS_REQUEST_REJECTED: {
title: 'The payment network refused the request',
description: 'Nothing was sent. Please try again in a moment.',
retryable: true,
},
TRANSFER_NOT_AWAITING_OTP: {
title: 'This transfer is already finished',
description: 'It is no longer waiting for a code, so nothing was sent again. Check your transactions for its outcome.',
},
MMID_ALLOCATION_FAILED: {
title: 'We could not finish setting up your accounts',
description: 'Please try again shortly.',
+9
View File
@@ -69,6 +69,15 @@ export const endpoints = {
save: '/beneficiaries',
validate: '/beneficiaries/validate',
},
/**
* IMPS transfers, in two steps. `initiate` runs the name inquiry and sends an
* OTP but moves no money; `verifyOtp` consumes the OTP and sends ReqPay. The
* server never sends ReqPay before a verified OTP, and never retries it.
*/
fundTransfers: {
initiate: '/fund-transfers/initiate',
verifyOtp: (transferId: string) => `/fund-transfers/${transferId}/verify-otp`,
},
dashboard: {
summary: '/dashboard/summary',
},
+26 -36
View File
@@ -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();
-2
View File
@@ -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 () => {
+71
View File
@@ -405,6 +405,77 @@ export interface CbsBeneficiary {
status: string | null;
}
/* ── IMPS fund transfer ───────────────────────────────────────────────────── */
/**
* Where a transfer is in its lifecycle. SUCCESS, FAILED and MANUAL_REVIEW are
* terminal; PENDING_RECONCILIATION is resolved by the server's own ReqChkTxn
* job, never by the app.
*/
export type FundTransferStatus =
| 'PENDING_VALIDATION'
| 'PENDING_OTP'
| 'PROCESSING'
| 'SUCCESS'
| 'FAILED'
| 'PENDING_RECONCILIATION'
| 'MANUAL_REVIEW';
/**
* `POST /fund-transfers/initiate`. Runs the IMPS name inquiry and issues an
* OTP; it moves no money.
*
* `requestId` is the idempotency key: orgId + userId + requestId is unique, so
* a retry with the same id returns the same transfer rather than a second one.
* Generate it once per transfer attempt, and keep it across network retries.
*/
export interface FundTransferInitiateRequest {
requestId: string;
mode: BeneficiaryMode;
amount: number;
remark?: string;
encryptedMpin: string;
payer: { accountNumber: string };
payee: BeneficiaryPayee & { accountType?: string };
}
export interface FundTransferInitiateResult {
transferId: string;
requestId: string;
referenceId: string;
/** PENDING_OTP when the payee verified; FAILED when IMPS refused them. */
status: FundTransferStatus;
otpExpiresAt: string | null;
attemptsRemaining: number;
/** Test configuration only — must never be relied on in production. */
debugOtp?: string | null;
}
/**
* The decrypted ReqPay inner response from
* `POST /fund-transfers/{transferId}/verify-otp`. Read `txnStatus`, not the
* HTTP status: a failed debit still answers `success: true`.
*/
export interface FundTransferVerifyResult {
txnId?: string;
refId?: string;
/** The NPCI RRN — what the customer quotes to the bank. */
custRefNo?: string;
/** SUCCESS, FAILURE, or DEEMED (outcome not yet known). */
txnStatus: string;
errCode?: string;
errorDiscrp?: string;
payee?: {
name?: string;
accountNumber?: string;
ifsc?: string;
accountType?: string;
amount?: string;
responseCode?: string;
approvalNumber?: string;
};
}
/* ── Positive Pay ─────────────────────────────────────────────────────────── */
/**