Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
@@ -0,0 +1,58 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { Card, Input } from '../../../components';
interface TransferAmountFieldsProps {
amount: string;
onAmountChange: (value: string) => void;
remarks: string;
onRemarksChange: (value: string) => void;
error?: string;
disabled?: boolean;
}
/**
* The amount and remarks block that closes every transfer form.
*
* It deliberately carries no "available balance" line. No CBS endpoint returns
* a balance, so any figure printed here would be either stale or invented —
* and a wrong balance under an amount field is worse than none, because the
* customer would budget against it.
*/
export const TransferAmountFields = ({
amount,
onAmountChange,
remarks,
onRemarksChange,
error,
disabled = false,
}: TransferAmountFieldsProps) => (
<Card style={styles.card}>
<Input
label="Amount (₹)"
placeholder="0.00"
keyboardType="decimal-pad"
value={amount}
onChangeText={onAmountChange}
error={error}
editable={!disabled}
/>
<Input
label="Remarks (optional)"
placeholder="What's this for?"
value={remarks}
onChangeText={onRemarksChange}
maxLength={50}
editable={!disabled}
/>
</Card>
);
const styles = StyleSheet.create({
card: {
padding: 16,
gap: 18,
},
});
@@ -0,0 +1,69 @@
import { useMemo, useState } from 'react';
import { useAccountSelection } from '../../home/hooks/useAccounts';
/**
* The half of a transfer form that is the same whichever way the money is
* addressed: which account it leaves, how much, and the note against it.
*
* The account list is read from `GET /accounts` on every mount — never from
* Redux, and never carried in from the previous screen.
*
* There is no balance check here, and that is not an omission. No endpoint in
* the CBS API returns a balance, so the app has nothing to compare an amount
* against; inventing a limit would be worse than having none, because a
* customer refused by the app has no way to find out they were refused
* wrongly. The bank rejects an overdrawn transfer on submission, and that
* refusal is the authoritative one.
*/
export const useTransferForm = () => {
const { accounts, selectedAccountNumber, setSelectedAccountNumber, selectedAccount, isLoading, refetch } =
useAccountSelection();
const [amount, setAmount] = useState('');
const [remarks, setRemarks] = useState('');
/** Grouping separators are allowed in, but never reach the parsed value. */
const parsedAmount = useMemo(() => Number.parseFloat(amount.replace(/,/g, '')), [amount]);
const amountError = useMemo(() => {
if (amount.length === 0) {
return undefined;
}
if (Number.isNaN(parsedAmount) || parsedAmount <= 0) {
return 'Enter a valid amount';
}
return undefined;
}, [amount, parsedAmount]);
const amountValid = !amountError && parsedAmount > 0;
const handleAmountChange = (value: string) => {
// One decimal point, digits either side, at most two paise. Filtering on
// the way in beats validating after the fact: the field cannot hold
// something the parser would then have to reject.
const cleaned = value.replace(/[^0-9.]/g, '');
const [rupees = '', ...rest] = cleaned.split('.');
const paise = rest.join('').slice(0, 2);
setAmount(rest.length ? `${rupees}.${paise}` : rupees);
};
return {
accounts,
selectedAccount,
sourceAccountNumber: selectedAccountNumber,
setSourceAccountNumber: setSelectedAccountNumber,
accountsLoading: isLoading,
refetchAccounts: refetch,
amount,
handleAmountChange,
parsedAmount,
amountError,
amountValid,
remarks,
setRemarks,
};
};
@@ -1,154 +1,488 @@
import React, { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Input, Button, Card, AppModal } from '../../../components';
import { useAddBeneficiaryMutation } from '../services/paymentsApi';
import type { PaymentsStackParamList } from '../../../navigation/types';
import { useDispatch } from 'react-redux';
import { AccountSelector, AppModal, Button, Card, Input, ScreenShell } from '../../../components';
import { useAppDispatch } from '../../../app/hooks';
import { useTheme, fontFamily } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useToast } from '../../../hooks/useToast';
import type { ApiError } from '../../../types/api';
import type {
BeneficiaryMode,
BeneficiaryPayee,
BeneficiarySaveRequest,
BeneficiaryValidateResult,
} from '../../../types/cbs';
import { isPayeeVerified } from '../../../types/cbs';
import { useAccountSelection } from '../../home/hooks/useAccounts';
import {
isValidAccountNumber,
isValidIfsc,
isValidMmid,
isValidPhone,
} from '../../../utils/validation';
import { useSaveBeneficiaryMutation, useValidateBeneficiaryMutation } from '../services/paymentsApi';
import { beneficiariesActions } from '../store/beneficiariesSlice';
import type { PaymentsStackParamList } from '../../../navigation/types';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'AddBeneficiary'>;
export default function AddBeneficiaryScreen({ navigation }: Props) {
const dispatch = useDispatch();
const [addBen, { isLoading }] = useAddBeneficiaryMutation();
const MODES: Array<{ id: BeneficiaryMode; label: string; hint: string }> = [
{ id: 'P2A', label: 'Account + IFSC', hint: 'Send to a bank account' },
{ id: 'P2P', label: 'Mobile + MMID', hint: 'Send by IMPS to a mobile number' },
];
export default function AddBeneficiaryScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const toast = useToast();
const showApiError = useApiErrorToast();
const [validatePayee, validateState] = useValidateBeneficiaryMutation();
const [savePayee, saveState] = useSaveBeneficiaryMutation();
/**
* Validate is made from one of the customer's own accounts — IMPS asks who
* is enquiring, and the server rejects a payer that is not the caller's.
*/
const {
accounts,
selectedAccountNumber: payerAccountNumber,
setSelectedAccountNumber: setPayerAccountNumber,
} = useAccountSelection();
const [mode, setMode] = useState<BeneficiaryMode>('P2A');
const [form, setForm] = useState({
name: '',
accountNumber: '',
confirmAccountNumber: '',
ifsc: '',
mobileNumber: '',
mmid: '',
nickname: '',
});
const [otpModalVisible, setOtpModalVisible] = useState(false);
const [otp, setOtp] = useState('');
const [otpError, setOtpError] = useState<string | undefined>(undefined);
/** What IMPS said, held for the confirmation step. */
const [resolved, setResolved] = useState<BeneficiaryValidateResult | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const isValid =
form.name.trim().length > 2 &&
form.accountNumber.length > 6 &&
form.accountNumber === form.confirmAccountNumber &&
form.ifsc.length === 11;
const isBusy = validateState.isLoading || saveState.isLoading;
const handleSubmit = () => {
if (!isValid) return;
setOtp('');
setOtpError(undefined);
setOtpModalVisible(true);
const update = (patch: Partial<typeof form>) => {
setForm((current) => ({ ...current, ...patch }));
setFieldErrors({});
};
const closeOtpModal = () => {
setOtpModalVisible(false);
setOtp('');
setOtpError(undefined);
const switchMode = (next: BeneficiaryMode) => {
setMode(next);
setFieldErrors({});
setResolved(null);
};
const confirmOTPAndSave = async () => {
if (otp !== '123456') {
setOtpError('That code is incorrect. Use 123456 for this demo.');
const accountsMatch =
form.confirmAccountNumber.length === 0 ||
form.accountNumber === form.confirmAccountNumber;
const isValid = useMemo(() => {
if (form.nickname.length > 60) {
return false;
}
// Both endpoints require a name, and neither will accept an empty one.
if (!form.name.trim() || form.name.trim().length > 120) {
return false;
}
if (!payerAccountNumber) {
return false;
}
if (mode === 'P2A') {
return (
isValidAccountNumber(form.accountNumber) &&
form.accountNumber === form.confirmAccountNumber &&
isValidIfsc(form.ifsc)
);
}
return isValidPhone(form.mobileNumber) && isValidMmid(form.mmid);
}, [form, mode, payerAccountNumber]);
/** The routing half, which both endpoints take in the same shape. */
const buildPayee = (): BeneficiaryPayee =>
mode === 'P2A'
? {
accountNumber: form.accountNumber,
ifsc: form.ifsc.toUpperCase(),
name: form.name.trim(),
}
: {
mobileNumber: form.mobileNumber,
mmid: form.mmid,
name: form.name.trim(),
};
const buildSaveRequest = (): BeneficiarySaveRequest => ({
mode,
beneficiary: buildPayee(),
nickname: form.nickname.trim() || undefined,
});
const handleApiError = (error: unknown) => {
const apiError = error as ApiError | undefined;
if (apiError?.fieldErrors) {
setFieldErrors(apiError.fieldErrors);
}
showApiError(apiError);
};
/**
* Step one: resolve the payee without saving anything. Showing the account
* holder's real name before the save is the whole point — a mistyped account
* number is the most common way money reaches the wrong person.
*/
const handleVerify = async () => {
if (!isValid || isBusy) {
return;
}
setOtpModalVisible(false);
if (!payerAccountNumber) {
return;
}
try {
const nickname = form.nickname.trim() || undefined;
const newBeneficiaryObj = {
name: form.name.trim(),
bankName: form.ifsc.toUpperCase().startsWith('HDFC') ? 'HDFC Bank' : 'State Bank of India', // mock logic
accountMasked: form.accountNumber.slice(-4).padStart(8, 'X'),
nickname,
};
const result = await validatePayee({
mode,
payer: { accountNumber: payerAccountNumber },
payee: buildPayee(),
}).unwrap();
const payload = await addBen(newBeneficiaryObj).unwrap();
// Save into the beneficiaries slice — the single source of truth that
// every payments screen reads, so the new payee shows up immediately.
dispatch(beneficiariesActions.addBeneficiary({ ...payload, nickname }));
// The switch answers 200 whether or not it recognised the payee, so the
// outcome is in the body. Treating a refusal as a pass would put an
// unverified payee in front of the customer as a confirmed one.
if (!isPayeeVerified(result)) {
toast(
'Payee could not be verified',
'error',
result.errorDiscrp || 'The bank did not recognise these details. Check and try again.',
);
return;
}
setResolved(result);
} catch (error) {
handleApiError(error);
}
};
/** Step two: save. Same body — the bank looks the payee up again itself. */
const handleConfirmSave = async () => {
if (isBusy) {
return;
}
try {
const saved = await savePayee(buildSaveRequest()).unwrap();
dispatch(beneficiariesActions.addBeneficiary(saved));
setResolved(null);
toast('Payee added', 'success', `${saved.name} is saved to your list.`);
navigation.goBack();
} catch (e) {
console.warn("Failed to add beneficiary", e);
setOtpError('We could not save this payee. Please try again.');
setOtpModalVisible(true);
} catch (error) {
const apiError = error as ApiError | undefined;
// Already in the list is not really a failure — land the customer where
// they can see it rather than leaving them on a form that cannot proceed.
if (apiError?.code === 'BENEFICIARY_ALREADY_ADDED') {
setResolved(null);
showApiError(apiError);
navigation.goBack();
return;
}
setResolved(null);
handleApiError(error);
}
};
return (
<ScreenShell title="Add Beneficiary" subtitle="Securely save a payee for future transfers" contentContainerStyle={styles.container}>
<ScreenShell
title="Add Payee"
subtitle="We confirm the name with the bank before saving"
contentContainerStyle={styles.container}
>
<View style={styles.modeRow}>
{MODES.map((option) => {
const active = option.id === mode;
return (
<Pressable
key={option.id}
onPress={() => switchMode(option.id)}
disabled={isBusy}
style={[
styles.modeChip,
{
borderColor: active ? theme.colors.primary : theme.colors.cardBorder,
backgroundColor: active ? `${theme.colors.primary}12` : 'transparent',
},
]}
>
<Text
style={[
styles.modeLabel,
{ color: active ? theme.colors.primary : theme.colors.textSecondary },
]}
>
{option.label}
</Text>
<Text style={[styles.modeHint, { color: theme.colors.textMuted }]}>
{option.hint}
</Text>
</Pressable>
);
})}
</View>
{accounts.length > 1 ? (
<Card style={styles.formCard}>
{/* IMPS asks who is enquiring, so the check is made from one of the
customer's own accounts. With only one there is nothing to pick. */}
<AccountSelector
label="Verify using"
accounts={accounts}
selectedAccountNumber={payerAccountNumber}
onSelect={setPayerAccountNumber}
disabled={isBusy}
/>
</Card>
) : null}
<Card style={styles.formCard}>
<Input
label="Beneficiary Full Name"
placeholder="As per bank records"
value={form.name}
onChangeText={(v) => setForm({ ...form, name: v })}
/>
<Input
label="Account Number"
placeholder="Enter 9-18 digit account number"
secureTextEntry
keyboardType="number-pad"
value={form.accountNumber}
onChangeText={(v) => setForm({ ...form, accountNumber: v })}
/>
<Input
label="Confirm Account Number"
placeholder="Re-enter account number"
keyboardType="number-pad"
value={form.confirmAccountNumber}
onChangeText={(v) => setForm({ ...form, confirmAccountNumber: v })}
error={form.confirmAccountNumber.length > 0 && form.accountNumber !== form.confirmAccountNumber ? "Account numbers must match" : undefined}
/>
<Input
label="IFSC Code"
placeholder="e.g. SBIN0001234"
autoCapitalize="characters"
maxLength={11}
value={form.ifsc}
onChangeText={(v) => setForm({ ...form, ifsc: v })}
error={form.ifsc.length > 0 && form.ifsc.length !== 11 ? 'IFSC code must be 11 characters' : undefined}
label="Payee name"
placeholder="As it appears on their bank records"
maxLength={120}
value={form.name}
onChangeText={(value) => update({ name: value })}
editable={!isBusy}
error={fieldErrors.name}
hint="Both checks need a name to compare against."
/>
<Input
label="Nickname (Optional)"
{mode === 'P2A' ? (
<>
<Input
label="Account number"
placeholder="5 to 20 digits"
keyboardType="number-pad"
maxLength={20}
value={form.accountNumber}
onChangeText={(value) => update({ accountNumber: value.replace(/\D/g, '') })}
editable={!isBusy}
error={
fieldErrors.accountNumber ??
(form.accountNumber.length > 0 && !isValidAccountNumber(form.accountNumber)
? 'An account number is 5 to 20 digits'
: undefined)
}
/>
<Input
label="Confirm account number"
placeholder="Re-enter account number"
keyboardType="number-pad"
maxLength={20}
value={form.confirmAccountNumber}
onChangeText={(value) =>
update({ confirmAccountNumber: value.replace(/\D/g, '') })
}
editable={!isBusy}
error={accountsMatch ? undefined : 'Account numbers must match'}
/>
<Input
label="IFSC code"
placeholder="e.g. HDFC0001234"
autoCapitalize="characters"
maxLength={15}
value={form.ifsc}
onChangeText={(value) => update({ ifsc: value.replace(/[^A-Za-z0-9]/g, '') })}
editable={!isBusy}
error={
fieldErrors.ifsc ??
(form.ifsc.length > 0 && !isValidIfsc(form.ifsc)
? 'An IFSC code is 6 to 15 letters and digits'
: undefined)
}
/>
</>
) : (
<>
<Input
label="Mobile number"
placeholder="10-digit mobile number"
keyboardType="number-pad"
maxLength={10}
value={form.mobileNumber}
onChangeText={(value) => update({ mobileNumber: value.replace(/\D/g, '') })}
editable={!isBusy}
error={
fieldErrors.mobileNumber ??
(form.mobileNumber.length > 0 && !isValidPhone(form.mobileNumber)
? 'Enter a 10-digit mobile number'
: undefined)
}
/>
<Input
label="MMID"
placeholder="7-digit MMID"
keyboardType="number-pad"
maxLength={7}
value={form.mmid}
onChangeText={(value) => update({ mmid: value.replace(/\D/g, '') })}
editable={!isBusy}
error={
fieldErrors.mmid ??
(form.mmid.length > 0 && !isValidMmid(form.mmid)
? 'An MMID is exactly 7 digits'
: undefined)
}
/>
</>
)}
<Input
label="Nickname (optional)"
placeholder="e.g. Landlord, Office"
maxLength={60}
value={form.nickname}
onChangeText={(v) => setForm({ ...form, nickname: v })}
onChangeText={(value) => update({ nickname: value })}
editable={!isBusy}
error={fieldErrors.nickname}
/>
</Card>
<Button
title="Verify & Save"
onPress={handleSubmit}
disabled={!isValid || isLoading}
loading={isLoading}
style={styles.submitBtn}
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.primary}0D` }]}>
<Ionicons name="shield-checkmark-outline" size={18} color={theme.colors.primary} />
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
We check these details with IMPS first, then the bank confirms them again when you
save. The stored name is the bank's answer, not what you typed — only the nickname is
yours.
</Text>
</View>
<Button
title="Verify payee"
onPress={handleVerify}
disabled={!isValid || isBusy}
loading={validateState.isLoading}
style={styles.submitBtn}
/>
<AppModal
visible={otpModalVisible}
onClose={closeOtpModal}
title="Bank Authentication"
visible={resolved !== null}
onClose={() => setResolved(null)}
title="Is this the right person?"
>
<View style={{ gap: 16 }}>
<Input
label="Enter 6-digit OTP"
placeholder="OTP sent to mobile (Hint: 123456)"
keyboardType="number-pad"
maxLength={6}
value={otp}
onChangeText={(v) => {
setOtp(v);
if (otpError) setOtpError(undefined);
}}
error={otpError}
/>
<Button
title="Confirm Addition"
onPress={confirmOTPAndSave}
disabled={otp.length !== 6 || isLoading}
loading={isLoading}
/>
</View>
{resolved && (
<View style={styles.confirmBody}>
<View style={[styles.confirmCard, { borderColor: theme.colors.cardBorder }]}>
{/* IMPS returns the holder's name already masked — this is the
bank's answer, not the name the customer typed. */}
<Text style={[styles.confirmName, { color: theme.colors.textPrimary }]}>
{resolved.maskedName}
</Text>
<Text style={[styles.confirmBank, { color: theme.colors.success }]}>
{`Verified by IMPS${resolved.accountType ? ` · ${resolved.accountType}` : ''}`}
</Text>
<View style={[styles.confirmDivider, { backgroundColor: theme.colors.cardBorder }]} />
{/* The routing details are the customer's own entry, echoed back
so the confirmation is against what they actually typed. */}
{mode === 'P2A' ? (
<>
<View style={styles.confirmRow}>
<Text style={[styles.confirmLabel, { color: theme.colors.textSecondary }]}>
Account
</Text>
<Text style={[styles.confirmValue, { color: theme.colors.textPrimary }]}>
{`XXXX ${form.accountNumber.slice(-4)}`}
</Text>
</View>
<View style={styles.confirmRow}>
<Text style={[styles.confirmLabel, { color: theme.colors.textSecondary }]}>
IFSC
</Text>
<Text style={[styles.confirmValue, { color: theme.colors.textPrimary }]}>
{form.ifsc.toUpperCase()}
</Text>
</View>
</>
) : (
<>
<View style={styles.confirmRow}>
<Text style={[styles.confirmLabel, { color: theme.colors.textSecondary }]}>
Mobile
</Text>
<Text style={[styles.confirmValue, { color: theme.colors.textPrimary }]}>
{`+91 ${form.mobileNumber}`}
</Text>
</View>
<View style={styles.confirmRow}>
<Text style={[styles.confirmLabel, { color: theme.colors.textSecondary }]}>
MMID
</Text>
<Text style={[styles.confirmValue, { color: theme.colors.textPrimary }]}>
{form.mmid}
</Text>
</View>
</>
)}
{form.nickname.trim() ? (
<View style={styles.confirmRow}>
<Text style={[styles.confirmLabel, { color: theme.colors.textSecondary }]}>
Nickname
</Text>
<Text style={[styles.confirmValue, { color: theme.colors.textPrimary }]}>
{form.nickname.trim()}
</Text>
</View>
) : null}
{resolved.refId ? (
<View style={styles.confirmRow}>
<Text style={[styles.confirmLabel, { color: theme.colors.textSecondary }]}>
Reference
</Text>
<Text style={[styles.confirmValue, { color: theme.colors.textMuted }]}>
{resolved.refId}
</Text>
</View>
) : null}
</View>
{/* Saving resolves the payee a second time, through the bank rather
than the switch. The two can disagree, and the saved name is
the bank's — so this is not merely a confirmation tap. */}
<Button
title="Confirm and save"
onPress={handleConfirmSave}
loading={saveState.isLoading}
disabled={isBusy}
/>
<Button
title="Edit details"
variant="ghost"
onPress={() => setResolved(null)}
disabled={saveState.isLoading}
/>
</View>
)}
</AppModal>
</ScreenShell>
);
@@ -157,14 +491,77 @@ export default function AddBeneficiaryScreen({ navigation }: Props) {
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
flexGrow: 1,
},
modeRow: {
flexDirection: 'row',
gap: 10,
},
modeChip: {
flex: 1,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 2,
},
modeLabel: {
fontSize: 14,
fontFamily: fontFamily.bold,
},
modeHint: {
fontSize: 11,
},
formCard: {
padding: 16,
gap: 16,
},
noteCard: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
padding: 14,
borderRadius: 12,
},
noteText: {
flex: 1,
fontSize: 13,
lineHeight: 18,
},
submitBtn: {
marginTop: 'auto',
paddingTop: 32,
}
height: 54,
},
confirmBody: {
gap: 12,
},
confirmCard: {
borderWidth: 1,
borderRadius: 12,
padding: 16,
gap: 8,
},
confirmName: {
fontSize: 20,
fontFamily: fontFamily.bold,
},
confirmBank: {
fontSize: 14,
},
confirmDivider: {
height: 1,
marginVertical: 4,
},
confirmRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
confirmLabel: {
fontSize: 13,
},
confirmValue: {
fontSize: 14,
fontFamily: fontFamily.bold,
},
});
@@ -1,92 +1,181 @@
import React, { useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useEffect, useMemo, useState } from 'react';
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Card, Input } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { selectBeneficiaries } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import { Card, EmptyState, Input, Loader, ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { describeError } from '../../../services/api/cbsErrors';
import type { ApiError } from '../../../types/api';
import type { Beneficiary } from '../../../types/app';
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
import {
beneficiariesActions,
selectBeneficiaries,
selectBeneficiariesHydrated,
toggleBeneficiaryFavorite,
} from '../store/beneficiariesSlice';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { transferRouteForBeneficiary } from '../utils/transferRouting';
import type { PaymentsStackParamList } from '../../../navigation/types';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'BeneficiariesList'>;
type Row = { kind: 'header'; id: string; title: string } | { kind: 'payee'; id: string; payee: Beneficiary };
export default function BeneficiariesListScreen({ navigation }: Props) {
const theme = useTheme();
const dispatch = useAppDispatch();
const beneficiaries = useAppSelector(selectBeneficiaries);
const hydrated = useAppSelector(selectBeneficiariesHydrated);
const [search, setSearch] = useState('');
const filtered = beneficiaries.filter(b =>
b.name.toLowerCase().includes(search.toLowerCase()) ||
b.bankName.toLowerCase().includes(search.toLowerCase()) ||
(b.nickname && b.nickname.toLowerCase().includes(search.toLowerCase()))
const { data, isLoading, isFetching, refetch, error } = useGetBeneficiariesQuery();
// The slice stays the single source of truth every payments screen reads, so
// the query result is mirrored into it rather than read directly here.
useEffect(() => {
if (data) {
dispatch(beneficiariesActions.setBeneficiaries(data));
}
}, [data, dispatch]);
const rows = useMemo<Row[]>(() => {
const term = search.trim().toLowerCase();
const matches = beneficiaries.filter(
(payee) =>
payee.name.toLowerCase().includes(term) ||
payee.bankName.toLowerCase().includes(term) ||
(payee.nickname ?? '').toLowerCase().includes(term),
);
const favorites = matches.filter((payee) => payee.favorite);
const others = matches.filter((payee) => !payee.favorite);
return [
...(favorites.length > 0
? [{ kind: 'header' as const, id: 'header_fav', title: 'Favourites' }]
: []),
...favorites.map((payee) => ({ kind: 'payee' as const, id: payee.id, payee })),
...(others.length > 0
? [{ kind: 'header' as const, id: 'header_all', title: 'All saved payees' }]
: []),
...others.map((payee) => ({ kind: 'payee' as const, id: payee.id, payee })),
];
}, [beneficiaries, search]);
const apiError = error as ApiError | undefined;
const renderPayee = (payee: Beneficiary) => (
<Card style={styles.cardWrapper}>
<View style={styles.payeeItem}>
<Pressable
onPress={() => navigation.navigate(...transferRouteForBeneficiary(payee))}
style={styles.payeePressable}
>
<View style={[styles.avatar, { backgroundColor: `${theme.colors.primary}15` }]}>
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>
{payee.name.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.payeeDetails}>
<Text style={[styles.payee, { color: theme.colors.textPrimary }]}>
{payee.nickname ? `${payee.nickname} (${payee.name})` : payee.name}
</Text>
<Text style={[styles.meta, { color: theme.colors.textSecondary }]}>
{payee.bankName} • {payee.accountMasked}
{payee.mode === 'P2P' && payee.mmid ? ` • MMID ${payee.mmid}` : ''}
</Text>
</View>
</Pressable>
<Pressable
onPress={() => dispatch(toggleBeneficiaryFavorite(payee.id))}
hitSlop={10}
style={styles.starButton}
>
<Ionicons
name={payee.favorite ? 'star' : 'star-outline'}
size={20}
color={payee.favorite ? theme.colors.warning : theme.colors.textMuted}
/>
</Pressable>
</View>
</Card>
);
const favorites = filtered.filter(b => b.favorite);
const others = filtered.filter(b => !b.favorite);
const handleSelect = (id: string) => {
navigation.navigate('TransferTypeSelection', { beneficiaryId: id });
};
const renderItem = ({ item }: { item: typeof beneficiaries[0] }) => (
<Pressable onPress={() => handleSelect(item.id)} style={styles.payeeItem}>
<View style={[styles.avatar, { backgroundColor: `${theme.colors.primary}15` }]}>
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{item.name[0]}</Text>
</View>
<View style={styles.payeeDetails}>
<Text style={[styles.payee, { color: theme.colors.textPrimary }]}>
{item.nickname ? `${item.nickname} (${item.name})` : item.name}
</Text>
<Text style={[styles.meta, { color: theme.colors.textSecondary }]}>
{item.bankName} • {item.accountMasked}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={theme.colors.textMuted} />
</Pressable>
);
const showSpinner = isLoading && !hydrated;
return (
<ScreenShell title="Beneficiaries" subtitle="Select a payee to send money" scrollable={false} contentContainerStyle={styles.container}>
<View style={styles.searchWrap}>
<Input
placeholder="Search name, nickname, bank..."
value={search}
onChangeText={setSearch}
leftAccessory={<Ionicons name="search" size={20} color={theme.colors.textMuted} />}
<ScreenShell
title="Payees"
subtitle="Select a payee to send money"
scrollable={false}
contentContainerStyle={styles.container}
>
{showSpinner ? (
<Loader />
) : apiError && !hydrated ? (
<EmptyState
title={describeError(apiError.code).title}
description={apiError.message || describeError(apiError.code).description}
actionLabel="Try again"
onAction={refetch}
/>
</View>
) : (
<>
<View style={styles.searchWrap}>
<Input
placeholder="Search name, nickname, bank..."
value={search}
onChangeText={setSearch}
leftAccessory={
<Ionicons name="search" size={20} color={theme.colors.textMuted} />
}
/>
</View>
<FlatList
data={[{ id: 'header_fav' } as any, ...favorites, { id: 'header_all' } as any, ...others]}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => {
if (item.id === 'header_fav') {
return favorites.length > 0 ? (
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Favorites ⭐</Text>
</View>
) : null;
}
if (item.id === 'header_all') {
return others.length > 0 ? (
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>All Saved Payees</Text>
</View>
) : null;
}
return (
<Card style={styles.cardWrapper}>
{renderItem({ item })}
</Card>
);
}}
/>
<FlatList
data={rows}
keyExtractor={(row) => row.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl
refreshing={isFetching && hydrated}
onRefresh={refetch}
tintColor={theme.colors.primary}
/>
}
ListEmptyComponent={
<EmptyState
title={search ? 'No matching payees' : 'No saved payees yet'}
description={
search
? 'Try a different name, nickname or bank.'
: 'Add a payee once and their details are confirmed with the bank each time you pay.'
}
actionLabel={search ? undefined : 'Add a payee'}
onAction={search ? undefined : () => navigation.navigate('AddBeneficiary')}
/>
}
renderItem={({ item }) =>
item.kind === 'header' ? (
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
{item.title}
</Text>
</View>
) : (
renderPayee(item.payee)
)
}
/>
</>
)}
<Pressable
<Pressable
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
onPress={() => navigation.navigate('AddBeneficiary')}
>
@@ -106,6 +195,7 @@ const styles = StyleSheet.create({
},
listContent: {
paddingBottom: TAB_BAR_HEIGHT + 16,
flexGrow: 1,
},
sectionHeader: {
marginTop: 16,
@@ -114,7 +204,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
@@ -123,6 +213,12 @@ const styles = StyleSheet.create({
padding: 12,
},
payeeItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
payeePressable: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
@@ -136,7 +232,7 @@ const styles = StyleSheet.create({
},
avatarText: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
payeeDetails: {
flex: 1,
@@ -144,11 +240,14 @@ const styles = StyleSheet.create({
},
payee: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
meta: {
fontSize: 13,
},
starButton: {
padding: 6,
},
fab: {
position: 'absolute',
bottom: TAB_BAR_HEIGHT + 20,
@@ -0,0 +1,500 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
FlatList,
Pressable,
RefreshControl,
Share,
StyleSheet,
Text,
View,
} from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { AppModal, Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
import { useToast } from '../../../hooks/useToast';
import { useAppSelector } from '../../../app/hooks';
import { selectSessionMobile } from '../../auth/store/authSelectors';
import { accountTypeLabel, useAccounts } from '../../home/hooks/useAccounts';
import { useTheme, fontFamily } from '../../../theme';
import type { CbsAccount } from '../../../types/cbs';
import type { PaymentsStackParamList } from '../../../navigation/types';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'ImpsMmid'>;
/** What the customer has asked for locally, keyed by account number. */
type PendingAction = 'cancel' | 'generate';
/** Spaces the 7 digits so the customer can read them back over a phone. */
const groupMmid = (mmid: string) => `${mmid.slice(0, 4)} ${mmid.slice(4)}`;
/**
* The customer's own MMIDs — the code someone else needs in order to send them
* money over IMPS.
*
* Only accounts that actually hold an MMID are listed. The profile also carries
* deposit accounts (FD, RD), and the bank allocates no MMID against those
* because they cannot receive an IMPS credit — a row for one would be a row
* with nothing on it and no action that could ever populate it.
*
* The linked mobile sits once at the top rather than on every card. It is the
* profile's number, identical for every account, and repeating it per row made
* it read like a per-account property.
*
* Read from `/accounts`, re-read on every mount. The cancel and generate
* actions have no endpoint behind them yet, so they record the request locally
* and say so; nothing claims the bank has acted. When those endpoints land,
* replace `confirmAction` and drop the local `pending` map.
*/
export default function ImpsMmidScreen(_props: Props) {
const theme = useTheme();
const toast = useToast();
const canCancel = useFeature(FeatureKey.PAYMENTS_MMID_CANCEL);
const canGenerate = useFeature(FeatureKey.PAYMENTS_MMID_GENERATE);
// Re-read on every mount; account data is never held between screens.
const { accounts, isLoading, isFetching, refetch, error } = useAccounts();
const mobileNumber = useAppSelector(selectSessionMobile);
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
const [pending, setPending] = useState<Record<string, PendingAction>>({});
const [confirming, setConfirming] = useState<
{ account: CbsAccount; action: PendingAction } | null
>(null);
/** Accounts the bank has allocated an MMID against. */
const mmidAccounts = useMemo(
() => accounts.filter((account) => Boolean(account.mmid)),
[accounts],
);
// Indian mobile grouping is 5 + 5.
const linkedMobile = mobileNumber
? `+91 ${mobileNumber.slice(0, 5)} ${mobileNumber.slice(5)}`
: undefined;
const confirmAction = useCallback(() => {
if (!confirming) {
return;
}
const { account, action } = confirming;
setPending((current) => ({ ...current, [account.accountNumber]: action }));
setConfirming(null);
// Deliberately worded as a request rather than a result: there is no
// endpoint yet, so the app must not report an outcome the bank has not
// given it.
const tail = `XXXX ${account.accountNumber.slice(-4)}`;
if (action === 'cancel') {
toast(
'Cancellation requested',
'info',
`We have noted your request to cancel the MMID on ${tail}.`,
);
} else {
toast('New MMID requested', 'info', `We have noted your request for a new MMID on ${tail}.`);
}
}, [confirming, toast]);
/**
* Hands the remitter both halves at once. An MMID on its own moves no money —
* it only works alongside the mobile number — so sharing one without the
* other just means a second message.
*/
const shareDetails = useCallback(
async (account: CbsAccount) => {
if (!account.mmid) {
return;
}
const lines = [
'To send me money over IMPS you need both of these:',
linkedMobile ? `Mobile: ${linkedMobile}` : null,
`MMID: ${account.mmid}`,
].filter(Boolean);
try {
await Share.share({ message: lines.join('\n') });
} catch {
// The customer dismissed the sheet, or the platform refused it. Either
// way there is nothing to report — the details are still on screen.
}
},
[linkedMobile],
);
const renderAccount = useCallback(
({ item }: { item: CbsAccount }) => {
const key = item.accountNumber;
const masked = `XXXX ${item.accountNumber.slice(-4)}`;
const isRevealed = Boolean(revealed[key]);
const requested = pending[key];
const live = item.status === 'ACTIVE' && !requested;
return (
<Card style={styles.accountCard}>
<View style={styles.accountHeader}>
<View style={[styles.accountIcon, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="wallet-outline" size={20} color={theme.colors.primary} />
</View>
<View style={styles.accountCopy}>
<Text style={[styles.accountType, { color: theme.colors.primary }]}>
{accountTypeLabel(item.accountType).toUpperCase()}
</Text>
<Text style={[styles.accountNumber, { color: theme.colors.textPrimary }]}>
{masked}
</Text>
</View>
<View
style={[
styles.statusPill,
{
backgroundColor: live
? `${theme.colors.success}1A`
: theme.colors.surfaceMuted,
},
]}
>
<Text
style={[
styles.statusText,
{ color: live ? theme.colors.success : theme.colors.textSecondary },
]}
>
{requested === 'cancel'
? 'CANCELLING'
: requested === 'generate'
? 'NEW ONE REQUESTED'
: item.status}
</Text>
</View>
</View>
<View style={[styles.mmidWell, { backgroundColor: theme.colors.surfaceMuted }]}>
<View style={styles.mmidCopy}>
<Text style={[styles.mmidLabel, { color: theme.colors.primary }]}>MMID</Text>
<Text style={[styles.mmidValue, { color: theme.colors.textPrimary }]}>
{isRevealed && item.mmid ? groupMmid(item.mmid) : '•••• •••'}
</Text>
</View>
<Pressable
onPress={() => setRevealed((current) => ({ ...current, [key]: !isRevealed }))}
hitSlop={10}
accessibilityRole="switch"
accessibilityState={{ checked: isRevealed }}
accessibilityLabel={isRevealed ? `Hide MMID for ${masked}` : `Show MMID for ${masked}`}
style={[styles.revealButton, { backgroundColor: theme.colors.surface }]}
>
<Ionicons
name={isRevealed ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={theme.colors.primary}
/>
</Pressable>
</View>
<View style={styles.detailRow}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>IFSC</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{item.ifsc}
</Text>
</View>
{requested ? (
<Text style={[styles.pendingNote, { color: theme.colors.textMuted }]}>
Requested on this device. Your branch will confirm once it has been actioned.
</Text>
) : null}
<View style={styles.actionRow}>
<Button
title="Share"
variant="secondary"
fullWidth={false}
style={styles.actionButton}
leftAccessory={
<Ionicons name="share-social-outline" size={18} color={theme.colors.primary} />
}
onPress={() => {
shareDetails(item);
}}
/>
{/* After a cancellation is requested, the useful next step is a
fresh code — so the action flips rather than disappearing. */}
{requested === 'cancel' && canGenerate ? (
<Button
title="Request new"
fullWidth={false}
style={styles.actionButton}
onPress={() => setConfirming({ account: item, action: 'generate' })}
/>
) : !requested && canCancel ? (
<Button
title="Cancel MMID"
variant="danger"
fullWidth={false}
style={styles.actionButton}
onPress={() => setConfirming({ account: item, action: 'cancel' })}
/>
) : null}
</View>
</Card>
);
},
[canCancel, canGenerate, pending, revealed, shareDetails, theme],
);
const header = (
<View style={styles.header}>
<Card style={styles.explainerCard}>
<View style={[styles.explainerIcon, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="swap-horizontal" size={20} color={theme.colors.primary} />
</View>
<Text style={[styles.explainerBody, { color: theme.colors.primary }]}>
A 7-digit code that, with your registered mobile number, lets anyone send you money
over IMPS without knowing your account number.
</Text>
</Card>
{linkedMobile ? (
<Card style={styles.mobileCard}>
<Text style={[styles.mobileLabel, { color: theme.colors.textSecondary }]}>
LINKED MOBILE
</Text>
<Text style={[styles.mobileValue, { color: theme.colors.textPrimary }]}>
{linkedMobile}
</Text>
<Text style={[styles.mobileHint, { color: theme.colors.textMuted }]}>
The same number applies to every MMID below.
</Text>
</Card>
) : null}
</View>
);
return (
<ScreenShell
title="IMPS MMID"
subtitle="For receiving money over IMPS"
scrollable={false}
// Nothing on this screen takes text input, so the keyboard-avoiding
// wrapper only adds a layout pass that fights the list while it scrolls.
keyboardAware={false}
contentContainerStyle={styles.shell}
>
{isLoading ? (
<Loader />
) : error ? (
<EmptyState
title="Could not load your MMIDs"
description="Something went wrong reading your accounts. Pull down to try again."
/>
) : (
<FlatList
data={mmidAccounts}
keyExtractor={(item) => item.accountNumber}
renderItem={renderAccount}
ListHeaderComponent={header}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={Separator}
removeClippedSubviews={false}
initialNumToRender={4}
windowSize={5}
refreshControl={
<RefreshControl
refreshing={isFetching && !isLoading}
onRefresh={refetch}
tintColor={theme.colors.primary}
/>
}
ListEmptyComponent={
<EmptyState
title="No MMIDs on this profile"
description="None of your accounts has an MMID allocated. Deposit accounts never get one; for a savings or current account, ask your branch to allocate one."
/>
}
/>
)}
<AppModal
visible={confirming !== null}
onClose={() => setConfirming(null)}
title={confirming?.action === 'cancel' ? 'Cancel this MMID?' : 'Request a new MMID?'}
>
<View style={styles.modalBody}>
<Text style={[styles.modalText, { color: theme.colors.textSecondary }]}>
{confirming?.action === 'cancel'
? `Once cancelled, nobody can send money to XXXX ${confirming?.account.accountNumber.slice(-4)} using your mobile number and MMID. You can request a new one afterwards.`
: `A new MMID will be allocated to XXXX ${confirming?.account.accountNumber.slice(-4)}. Anyone holding the old one will need the new code.`}
</Text>
<Button
title={confirming?.action === 'cancel' ? 'Yes, cancel it' : 'Yes, request one'}
variant={confirming?.action === 'cancel' ? 'danger' : 'primary'}
onPress={confirmAction}
/>
<Button title="Not now" variant="ghost" onPress={() => setConfirming(null)} />
</View>
</AppModal>
</ScreenShell>
);
}
const Separator = () => <View style={styles.separator} />;
const styles = StyleSheet.create({
shell: {
paddingHorizontal: 0,
},
listContent: {
padding: 16,
paddingBottom: 40,
},
separator: {
height: 14,
},
header: {
gap: 14,
marginBottom: 14,
},
explainerCard: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
padding: 16,
},
explainerIcon: {
width: 42,
height: 42,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
explainerBody: {
flex: 1,
fontSize: 14,
lineHeight: 21,
},
mobileCard: {
padding: 16,
gap: 3,
},
mobileLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
},
mobileValue: {
fontSize: 20,
fontFamily: fontFamily.bold,
},
mobileHint: {
fontSize: 12,
lineHeight: 17,
},
accountCard: {
padding: 16,
gap: 14,
},
accountHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
accountIcon: {
width: 42,
height: 42,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
accountCopy: {
flex: 1,
gap: 2,
},
accountType: {
fontSize: 12,
fontFamily: fontFamily.bold,
letterSpacing: 0.4,
},
accountNumber: {
fontSize: 18,
fontFamily: fontFamily.bold,
},
statusPill: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 5,
},
statusText: {
fontSize: 10,
fontFamily: fontFamily.bold,
letterSpacing: 0.4,
},
mmidWell: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderRadius: 12,
padding: 14,
},
mmidCopy: {
flex: 1,
gap: 2,
},
mmidLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
},
mmidValue: {
fontSize: 22,
fontFamily: fontFamily.bold,
letterSpacing: 3,
},
revealButton: {
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
detailLabel: {
fontSize: 14,
},
detailValue: {
fontSize: 14,
fontFamily: fontFamily.bold,
},
pendingNote: {
fontSize: 12,
lineHeight: 18,
},
actionRow: {
flexDirection: 'row',
gap: 10,
},
actionButton: {
flex: 1,
},
modalBody: {
gap: 12,
},
modalText: {
fontSize: 14,
lineHeight: 20,
},
});
@@ -4,49 +4,157 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useDispatch, useSelector } from 'react-redux';
import { ScreenShell, Button, Card, AppModal, Input } from '../../../components';
import { useSubmitBankTransferMutation } from '../services/paymentsApi';
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import {
useSaveBeneficiaryMutation,
useSubmitBankTransferMutation,
useValidateBeneficiaryMutation,
} from '../services/paymentsApi';
import { isPayeeVerified } from '../../../types/cbs';
import type { BeneficiaryMode, BeneficiaryPayee } from '../../../types/cbs';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate, guardFeature, isFeatureEnabled } from '../../../features';
import { beneficiariesActions, selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useAccounts } from '../../home/hooks/useAccounts';
import { useTheme, fontFamily } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
import { accountActions } from '../../home/store/accountSlice';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentConfirmation'>;
export function PaymentConfirmationScreen({ navigation, route }: Props) {
const theme = useTheme();
const dispatch = useDispatch();
const { beneficiaryId, transferType, amount, remarks } = route.params;
const beneficiary = useSelector(selectBeneficiaryById(beneficiaryId));
const {
beneficiaryId,
transferType,
amount,
remarks,
recipient,
sourceAccountNumber,
saveRecipient,
} = route.params;
// A transfer reaches this screen one of two ways: from a saved payee, or from
// a form where the recipient was typed in. Either can be missing, so the
// displayed details are resolved once here rather than at each row.
const beneficiary = useSelector(selectBeneficiaryById(beneficiaryId ?? ''));
// Re-read rather than trust what the form passed: this screen can sit open
// while the session is refreshed, and the account list is never cached.
const { accounts } = useAccounts();
const sourceAccount = accounts.find(
(account) => account.accountNumber === sourceAccountNumber,
);
const payeeName = beneficiary?.name ?? recipient?.name ?? 'Recipient';
const payeeBank = beneficiary?.bankName ?? recipient?.bankName;
const payeeAccount = beneficiary?.accountMasked ?? recipient?.accountMasked;
const payeeMobile = beneficiary?.mobileNumber ?? recipient?.mobileNumber;
const payeeMmid = beneficiary?.mmid ?? recipient?.mmid;
const payeeIfsc = beneficiary?.ifsc ?? recipient?.ifsc;
const [submitTransfer, { isLoading }] = useSubmitBankTransferMutation();
const [saveBeneficiary] = useSaveBeneficiaryMutation();
const [validateBeneficiary] = useValidateBeneficiaryMutation();
const [otpModalVisible, setOtpModalVisible] = useState(false);
const [otp, setOtp] = useState('');
const handleConfirm = () => {
const handleConfirm = guardFeature(FeatureKey.PAYMENTS_TRANSFER_SUBMIT, () => {
setOtpModalVisible(true);
};
});
const finalSubmit = 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);
try {
const response = await submitTransfer({
beneficiaryId,
beneficiaryId: beneficiaryId ?? '',
amount,
transferType,
remarks,
sourceAccountId: 'ACC123', // Mock source
// The account the customer actually picked on the form, rather than a
// hardcoded one — this is the account that gets debited.
sourceAccountId: sourceAccountNumber ?? '',
}).unwrap();
dispatch(accountActions.deductBalance(amount));
/**
* 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', {
transactionId: response.referenceId,
amount,
beneficiaryName: beneficiary?.name || 'Unknown',
beneficiaryName: payeeName,
isPending: response.status === 'Process_Pending',
});
} catch (e) {
@@ -54,10 +162,10 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
}
};
const DetailRow = ({ label, value, bold = false }: { label: string, value: string, bold?: boolean }) => (
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, fontWeight: bold ? '800' : '600' }]}>{value}</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary, fontFamily: fontFamily.bold ? '800' : '600' }]}>{value}</Text>
</View>
);
@@ -73,28 +181,37 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.detailsSection}>
<DetailRow label="Transfer Mode" value={transferType} bold />
<DetailRow label="To Beneficiary" value={beneficiary?.name || ''} />
<DetailRow label="Bank Name" value={beneficiary?.bankName || ''} />
<DetailRow label="Account Number" value={beneficiary?.accountMasked || ''} />
<DetailRow label="Transfer Mode" value={transferType} />
<DetailRow label="To Beneficiary" value={payeeName} />
{payeeBank ? <DetailRow label="Bank Name" value={payeeBank} /> : null}
{payeeAccount ? <DetailRow label="Account Number" value={payeeAccount} /> : null}
{payeeIfsc ? <DetailRow label="IFSC" value={payeeIfsc} /> : null}
{payeeMobile ? <DetailRow label="Mobile Number" value={`+91 ${payeeMobile}`} /> : null}
{payeeMmid ? <DetailRow label="MMID" value={payeeMmid} /> : null}
{remarks ? <DetailRow label="Remarks" value={remarks} /> : null}
</View>
</Card>
<View style={[styles.noticeCard, { backgroundColor: `${theme.colors.info}10`, borderColor: theme.colors.info }]}>
<Text style={[styles.noticeText, { color: theme.colors.textPrimary }]}>
Funds will be transferred from your Primary Savings Account ending in XXXX4321.
{sourceAccount
? `Funds will be debited from your ${sourceAccount.accountType} account XXXX ${sourceAccount.accountNumber.slice(-4)}.`
: 'Funds will be debited from your selected account.'}
</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
@@ -146,11 +263,11 @@ const styles = StyleSheet.create({
fontSize: 14,
textTransform: 'uppercase',
letterSpacing: 1,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
amountValue: {
fontSize: 32,
fontWeight: '900',
fontFamily: fontFamily.bold,
},
divider: {
height: 1,
@@ -1,196 +0,0 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, ScrollView, Pressable } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useDispatch, useSelector } from 'react-redux';
import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Input, Button, Card } from '../../../components';
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
import type { RootState } from '../../../app/store';
import { accountActions } from '../../home/store/accountSlice';
import { formatCurrency } from '../../../utils/formatters';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentForm'>;
export function PaymentFormScreen({ navigation, route }: Props) {
const theme = useTheme();
const beneficiaryId = route.params?.beneficiaryId;
const transferType = route.params?.transferType;
const beneficiary = useSelector(selectBeneficiaryById(beneficiaryId));
const dispatch = useDispatch();
const [amount, setAmount] = useState('');
const [remarks, setRemarks] = useState('');
const accountState = useSelector((state: RootState) => state.account);
const accounts = accountState.accounts;
const selectedAccount = accounts.find(a => a.id === accountState.selectedAccountId) || accounts[0];
const currentBalance = selectedAccount?.availableBalance || 0;
const numAmount = parseFloat(amount.replace(/,/g, ''));
const isSufficientBalance = !isNaN(numAmount) && numAmount <= currentBalance;
let amountError = '';
if (amount.length > 0) {
if (isNaN(numAmount) || numAmount <= 0) {
amountError = 'Please enter a valid amount';
} else if (!isSufficientBalance) {
amountError = 'Insufficient balance';
} else if (transferType === 'RTGS' && numAmount < 200000) {
amountError = 'RTGS requires a minimum of ₹2,00,000';
}
}
const isValid = !amountError && numAmount > 0;
const handleSubmit = () => {
if (isValid) {
navigation.navigate('PaymentConfirmation', {
beneficiaryId,
transferType,
amount: numAmount,
remarks,
});
}
};
return (
<ScreenShell title="Enter Amount" subtitle={`${transferType} Transfer`} contentContainerStyle={styles.container} scrollable={false}>
<Card style={styles.card}>
<View style={styles.payeeSection}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Paying to</Text>
<Text style={[styles.payeeName, { color: theme.colors.textPrimary }]}>{beneficiary?.name}</Text>
<Text style={[styles.payeeMeta, { color: theme.colors.textSecondary }]}>{beneficiary?.bankName} • {beneficiary?.accountMasked}</Text>
</View>
<View style={styles.divider} />
<View style={styles.formSection}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Debit From</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.accountSelector}>
{accounts.map(acc => {
const isSelected = acc.id === selectedAccount?.id;
return (
<Pressable
key={acc.id}
onPress={() => dispatch(accountActions.setSelectedAccount(acc.id))}
style={[
styles.accountChip,
{
backgroundColor: isSelected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
borderColor: isSelected ? theme.colors.primary : 'transparent'
}
]}
>
<Text style={[styles.accountType, { color: isSelected ? theme.colors.primary : theme.colors.textPrimary }]}>{acc.type}</Text>
<Text style={[styles.accountMask, { color: theme.colors.textSecondary }]}>{acc.maskedNumber}</Text>
<Text style={[styles.accountBal, { color: theme.colors.textPrimary }]}>{formatCurrency(acc.availableBalance)}</Text>
</Pressable>
);
})}
</ScrollView>
<View style={styles.divider} />
<Input
label="Amount (₹)"
placeholder="0.00"
keyboardType="numeric"
value={amount}
onChangeText={setAmount}
error={amountError}
style={styles.amountInput}
/>
<Text style={[styles.balanceText, { color: theme.colors.textSecondary, textAlign: 'right' }]}>
Avail. Bal: ₹{currentBalance.toLocaleString('en-IN')}
</Text>
<Input
label="Remarks (Optional)"
placeholder="What's this for?"
value={remarks}
onChangeText={setRemarks}
/>
</View>
</Card>
<Button
title="Proceed to Pay"
onPress={handleSubmit}
disabled={!isValid}
style={styles.btn}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
flex: 1,
},
card: {
padding: 20,
gap: 16,
},
payeeSection: {
gap: 4,
},
label: {
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
payeeName: {
fontSize: 18,
fontWeight: '800',
},
payeeMeta: {
fontSize: 14,
},
divider: {
height: 1,
backgroundColor: 'rgba(0,0,0,0.1)',
marginVertical: 4,
},
formSection: {
gap: 16,
},
accountSelector: {
gap: 12,
paddingRight: 10,
},
accountChip: {
padding: 12,
borderRadius: 12,
borderWidth: 1.5,
minWidth: 130,
gap: 2,
},
accountType: {
fontSize: 11,
fontWeight: '800',
textTransform: 'uppercase',
},
accountMask: {
fontSize: 12,
fontWeight: '500',
},
accountBal: {
fontSize: 13,
fontWeight: '700',
marginTop: 4,
},
amountInput: {
fontSize: 24,
fontWeight: 'bold',
},
balanceText: {
fontSize: 12,
marginTop: -8,
},
btn: {
marginTop: 'auto',
}
});
@@ -1,23 +1,40 @@
import React from 'react';
import React, { useEffect } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View, ScrollView } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Card, ScreenShell } from '../../../components';
import { selectBeneficiaries } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import { beneficiariesActions, selectBeneficiaries } from '../store/beneficiariesSlice';
import { useGetBeneficiariesQuery } from '../services/paymentsApi';
import { useTheme, fontFamily } from '../../../theme';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { PaymentsStackParamList } from '../../../navigation/types';
import { formatDate } from '../../../utils/formatters';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate, useEnabledItems } from '../../../features';
import { transferRouteForBeneficiary } from '../utils/transferRouting';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentsHub'>;
export default function PaymentsHomeScreen({ navigation }: Props) {
const theme = useTheme();
const dispatch = useAppDispatch();
const transactions = useAppSelector(state => state.account.transactions);
const beneficiaries = useAppSelector(selectBeneficiaries);
// Quick Pay reads the beneficiaries slice, so this hub has to fetch the live
// list too rather than wait for the payee list screen to be opened first.
// RTK Query dedupes, so the two screens share one request.
const { data: livePayees } = useGetBeneficiariesQuery();
useEffect(() => {
if (livePayees) {
dispatch(beneficiariesActions.setBeneficiaries(livePayees));
}
}, [dispatch, livePayees]);
// Simulated stats
const dailyLimit = 500000;
const utilized = 125000;
@@ -28,6 +45,48 @@ export default function PaymentsHomeScreen({ navigation }: Props) {
t.direction === 'debit' && ['Transfer', 'Payment', 'Bill Pay'].includes(t.category)
).slice(0, 4);
/**
* Where money can be sent from here. Each row is a destination in its own
* right rather than a rail — the rail is decided by the form, or fixed by
* the addressing scheme in the case of mobile + MMID.
*/
const transferDestinations = [
{
id: 'to_mobile',
feature: FeatureKey.PAYMENTS_TRANSFER_MOBILE,
title: 'Fund Transfer To Mobile Number',
subtitle: 'Instant IMPS using mobile number and MMID',
icon: 'phone-portrait-outline',
onPress: () => navigation.navigate('TransferToMobile'),
},
{
id: 'to_account',
feature: FeatureKey.PAYMENTS_TRANSFER_ACCOUNT,
title: 'Fund Transfer To Account Number',
subtitle: 'Send to any bank account using account number and IFSC',
icon: 'business-outline',
onPress: () => navigation.navigate('TransferToAccount'),
},
{
id: 'to_own',
feature: FeatureKey.PAYMENTS_TRANSFER_OWN,
title: 'Fund Transfer To OWN Account',
subtitle: 'Move money between your own accounts, free',
icon: 'repeat-outline',
onPress: () => navigation.navigate('TransferToOwnAccount'),
},
{
id: 'imps_mmid',
feature: FeatureKey.PAYMENTS_MMID,
title: 'IMPS MMID',
subtitle: 'View or cancel your Mobile Money Identifier',
icon: 'swap-horizontal-outline',
onPress: () => navigation.navigate('ImpsMmid'),
},
];
const visibleDestinations = useEnabledItems(transferDestinations);
const ActionCard = ({ title, icon, color, onPress }: { title: string, icon: string, color: string, onPress: () => void }) => (
<Pressable onPress={onPress} style={styles.actionCardWrapper}>
<Card style={styles.actionCard}>
@@ -65,32 +124,65 @@ export default function PaymentsHomeScreen({ navigation }: Props) {
{/* Primary Actions Grid */}
<View style={styles.actionGrid}>
<ActionCard
title="Send Money"
icon="send"
color={theme.colors.primary}
onPress={() => navigation.navigate('BeneficiariesList')}
/>
<ActionCard
title="Add Payee"
icon="person-add"
color="#10B981"
onPress={() => navigation.navigate('AddBeneficiary')}
/>
<ActionCard
title="Manage"
icon="settings-outline"
color="#6366F1"
onPress={() => navigation.navigate('BeneficiariesList')}
/>
<FeatureGate feature={FeatureKey.PAYMENTS_TRANSFER}>
<ActionCard
title="Send Money"
icon="send"
color={theme.colors.primary}
onPress={() => navigation.navigate('BeneficiariesList')}
/>
</FeatureGate>
<FeatureGate feature={FeatureKey.PAYMENTS_BENEFICIARIES_ADD}>
<ActionCard
title="Add Payee"
icon="person-add"
color="#10B981"
onPress={() => navigation.navigate('AddBeneficiary')}
/>
</FeatureGate>
<FeatureGate feature={FeatureKey.PAYMENTS_BENEFICIARIES_LIST}>
<ActionCard
title="Manage"
icon="settings-outline"
color="#6366F1"
onPress={() => navigation.navigate('BeneficiariesList')}
/>
</FeatureGate>
</View>
{/* Fund Transfer */}
{visibleDestinations.length > 0 ? (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary, marginBottom: 12 }]}>
Fund Transfer
</Text>
{visibleDestinations.map((item) => (
<Pressable key={item.id} onPress={item.onPress}>
<Card style={styles.destinationCard}>
<View style={[styles.destinationIcon, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name={item.icon as never} size={22} color={theme.colors.primary} />
</View>
<View style={styles.destinationCopy}>
<Text style={[styles.destinationTitle, { color: theme.colors.textPrimary }]}>
{item.title}
</Text>
<Text style={[styles.destinationSubtitle, { color: theme.colors.primary }]}>
{item.subtitle}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
</Card>
</Pressable>
))}
</View>
) : null}
{/* Quick Pay Section */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Quick Pay</Text>
<Pressable onPress={() => navigation.navigate('BeneficiariesList')}>
<Text style={{ color: theme.colors.primary, fontWeight: '700', fontSize: 13 }}>See All</Text>
<Text style={{ color: theme.colors.primary, fontFamily: fontFamily.bold, fontSize: 13 }}>See All</Text>
</Pressable>
</View>
<FlatList
@@ -101,7 +193,7 @@ export default function PaymentsHomeScreen({ navigation }: Props) {
contentContainerStyle={styles.quickPayList}
renderItem={({ item }) => (
<Pressable
onPress={() => navigation.navigate('TransferTypeSelection', { beneficiaryId: item.id })}
onPress={() => navigation.navigate(...transferRouteForBeneficiary(item))}
style={styles.quickPayItem}
>
<View style={[styles.quickPayAvatar, { backgroundColor: `${theme.colors.primary}10`, borderColor: theme.colors.primary }]}>
@@ -116,7 +208,7 @@ export default function PaymentsHomeScreen({ navigation }: Props) {
{/* Recent Activity */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary, marginBottom: 12 }]}>Recent Activity</Text>
{paymentTransactions.map((item, index) => (
{paymentTransactions.map((item) => (
<Card key={item.id} style={[styles.transactionCard, { marginBottom: 8 }]}>
<View style={[styles.transIcon, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="arrow-up-circle" size={20} color={theme.colors.error} />
@@ -144,6 +236,32 @@ const styles = StyleSheet.create({
gap: 24,
paddingBottom: 40,
},
destinationCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
padding: 16,
marginBottom: 12,
},
destinationIcon: {
width: 46,
height: 46,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
destinationCopy: {
flex: 1,
gap: 3,
},
destinationTitle: {
fontSize: 16,
fontFamily: fontFamily.bold,
},
destinationSubtitle: {
fontSize: 13,
lineHeight: 18,
},
limitCard: {
padding: 16,
gap: 12,
@@ -155,13 +273,13 @@ const styles = StyleSheet.create({
},
limitLabel: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
limitValue: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginTop: 2,
},
progressBarBg: {
@@ -176,7 +294,7 @@ const styles = StyleSheet.create({
},
limitFooter: {
fontSize: 11,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
actionGrid: {
flexDirection: 'row',
@@ -200,7 +318,7 @@ const styles = StyleSheet.create({
},
actionTitle: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textAlign: 'center',
},
section: {
@@ -214,7 +332,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
quickPayList: {
gap: 16,
@@ -236,11 +354,11 @@ const styles = StyleSheet.create({
},
avatarText: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
quickPayName: {
fontSize: 11,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textAlign: 'center',
},
transactionCard: {
@@ -262,13 +380,13 @@ const styles = StyleSheet.create({
},
transTarget: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
transMeta: {
fontSize: 11,
},
transAmount: {
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
}
});
@@ -12,7 +12,7 @@ import Animated, {
} from 'react-native-reanimated';
import { ScreenShell, Button, Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransactionSuccess'>;
@@ -68,7 +68,7 @@ export function TransactionSuccessScreen({ navigation, route }: Props) {
</View>
<View style={styles.receiptRow}>
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>Amount</Text>
<Text style={[styles.receiptValue, { color: theme.colors.primary, fontWeight: '800' }]}>₹{amount.toLocaleString('en-IN')}</Text>
<Text style={[styles.receiptValue, { color: theme.colors.primary, fontFamily: fontFamily.bold }]}>₹{amount.toLocaleString('en-IN')}</Text>
</View>
<View style={styles.receiptRow}>
<Text style={[styles.receiptLabel, { color: theme.colors.textSecondary }]}>Timestamp</Text>
@@ -124,7 +124,7 @@ const styles = StyleSheet.create({
},
statusTitle: {
fontSize: 24,
fontWeight: '900',
fontFamily: fontFamily.bold,
textAlign: 'center',
},
statusSubtitle: {
@@ -149,7 +149,7 @@ const styles = StyleSheet.create({
},
receiptValue: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
footer: {
gap: 8,
@@ -0,0 +1,377 @@
import React, { useEffect, useMemo, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { AccountSelector, Button, Card, Input, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTransferForm } from '../hooks/useTransferForm';
import { TransferAmountFields } from '../components/TransferAmountFields';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
import { isValidAccountNumber, isValidIfsc } from '../../../utils/validation';
import { useTheme, fontFamily } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransferToAccount'>;
/** Rails this screen can offer, narrowest description first. */
const RAILS = [
{ id: 'IMPS' as const, feature: FeatureKey.PAYMENTS_TRANSFER_IMPS, label: 'IMPS', hint: 'Instant, 24×7' },
{ id: 'NEFT' as const, feature: FeatureKey.PAYMENTS_TRANSFER_NEFT, label: 'NEFT', hint: 'Half-hourly batches' },
{ id: 'RTGS' as const, feature: FeatureKey.PAYMENTS_TRANSFER_RTGS, label: 'RTGS', hint: 'High value, same day' },
];
/** Below this an RTGS transfer is refused by the network, not by us. */
const RTGS_MINIMUM = 200000;
/**
* Send to an account number and IFSC.
*
* The account number is asked for twice. It is the one field on this screen
* with no check digit and no lookup behind it — an IFSC is structured and a
* name comes back from the bank, but a mistyped account number is a valid
* account number belonging to someone else.
*/
export default function TransferToAccountScreen({ navigation, route }: Props) {
const theme = useTheme();
const prefillId = route.params?.beneficiaryId;
const beneficiary = useAppSelector(selectBeneficiaryById(prefillId ?? ''));
const impsEnabled = useFeature(FeatureKey.PAYMENTS_TRANSFER_IMPS);
const neftEnabled = useFeature(FeatureKey.PAYMENTS_TRANSFER_NEFT);
const rtgsEnabled = useFeature(FeatureKey.PAYMENTS_TRANSFER_RTGS);
const availableRails = useMemo(
() =>
RAILS.filter((rail) =>
rail.id === 'IMPS' ? impsEnabled : rail.id === 'NEFT' ? neftEnabled : rtgsEnabled,
),
[impsEnabled, neftEnabled, rtgsEnabled],
);
const {
accounts,
sourceAccountNumber,
setSourceAccountNumber,
amount,
handleAmountChange,
parsedAmount,
amountError,
amountValid,
remarks,
setRemarks,
} = useTransferForm();
const [name, setName] = useState('');
const [accountNumber, setAccountNumber] = useState('');
const [confirmAccountNumber, setConfirmAccountNumber] = useState('');
const [ifsc, setIfsc] = useState('');
const [rail, setRail] = useState<'IMPS' | 'NEFT' | 'RTGS'>(availableRails[0]?.id ?? 'IMPS');
const [saveAsBeneficiary, setSaveAsBeneficiary] = useState(false);
// Keep the choice on a rail that is actually offered — a switch flipped in
// remote config must not leave a disabled rail selected underneath.
useEffect(() => {
const fallback = availableRails[0];
if (fallback && !availableRails.some((entry) => entry.id === rail)) {
setRail(fallback.id);
}
}, [availableRails, rail]);
useEffect(() => {
if (!beneficiary) {
return;
}
setName(beneficiary.name);
setIfsc(beneficiary.ifsc ?? '');
// A saved payee is stored masked, so there is no number to prefill. The
// customer confirms it once and it is theirs to keep.
setSaveAsBeneficiary(false);
}, [beneficiary]);
const accountError =
accountNumber.length > 0 && !isValidAccountNumber(accountNumber)
? 'Enter a 9-18 digit account number'
: undefined;
const confirmError =
confirmAccountNumber.length > 0 && confirmAccountNumber !== accountNumber
? 'Both entries must match'
: undefined;
const ifscError =
ifsc.length > 0 && !isValidIfsc(ifsc) ? 'Enter a valid IFSC code' : undefined;
/** The only rail on offer, when there is exactly one. */
const soleRail = availableRails.length === 1 ? availableRails[0] : undefined;
const railError =
rail === 'RTGS' && amountValid && parsedAmount < RTGS_MINIMUM
? `RTGS requires at least ₹${RTGS_MINIMUM.toLocaleString('en-IN')}`
: undefined;
const canContinue =
Boolean(sourceAccountNumber) &&
name.trim().length > 0 &&
isValidAccountNumber(accountNumber) &&
confirmAccountNumber === accountNumber &&
isValidIfsc(ifsc) &&
amountValid &&
!railError;
const handleContinue = () => {
if (!canContinue || !sourceAccountNumber) {
return;
}
navigation.navigate('PaymentConfirmation', {
transferType: rail,
amount: parsedAmount,
remarks: remarks || undefined,
sourceAccountNumber,
beneficiaryId: prefillId,
saveRecipient: saveAsBeneficiary,
recipient: {
name: name.trim(),
// Only the last four ever reach the receipt or the payee list.
accountMasked: `XXXX ${accountNumber.slice(-4)}`,
accountNumber,
ifsc: ifsc.toUpperCase(),
},
});
};
return (
<ScreenShell
title="To account number"
subtitle="Any bank, by account and IFSC"
contentContainerStyle={styles.container}
fixedFooter={
<View style={[styles.footer, { borderTopColor: theme.colors.cardBorder }]}>
<Button title="Continue" onPress={handleContinue} disabled={!canContinue} />
</View>
}
>
<Card style={styles.card}>
<AccountSelector
label="Debit from"
accounts={accounts}
selectedAccountNumber={sourceAccountNumber}
onSelect={setSourceAccountNumber}
/>
</Card>
<Card style={styles.card}>
<Text style={[styles.sectionLabel, { color: theme.colors.primary }]}>RECIPIENT</Text>
<Input
label="Beneficiary name"
placeholder="As per bank records"
value={name}
onChangeText={setName}
maxLength={60}
/>
<Input
label="Account number"
placeholder="Enter 9-18 digit account number"
keyboardType="number-pad"
maxLength={18}
value={accountNumber}
onChangeText={(value) => setAccountNumber(value.replace(/\D/g, ''))}
error={accountError}
/>
<Input
label="Confirm account number"
placeholder="Re-enter account number"
keyboardType="number-pad"
maxLength={18}
// Pasting the first field into the second defeats the point of
// asking twice, so the OS paste affordance is withheld here.
contextMenuHidden
value={confirmAccountNumber}
onChangeText={(value) => setConfirmAccountNumber(value.replace(/\D/g, ''))}
error={confirmError}
/>
<Input
label="IFSC code"
placeholder="e.g. SBIN0001234"
autoCapitalize="characters"
autoCorrect={false}
maxLength={11}
value={ifsc}
onChangeText={(value) => setIfsc(value.replace(/[^A-Za-z0-9]/g, '').toUpperCase())}
error={ifscError}
/>
{availableRails.length > 1 ? (
<View style={styles.railBlock}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
TRANSFER RAIL
</Text>
<View style={styles.railRow}>
{availableRails.map((entry) => {
const selected = entry.id === rail;
return (
<View key={entry.id} style={styles.railItem}>
<Button
title={entry.label}
variant={selected ? 'secondary' : 'ghost'}
onPress={() => setRail(entry.id)}
accessibilityLabel={`${entry.label} — ${entry.hint}`}
style={[
styles.railButton,
{
borderColor: selected ? theme.colors.primary : theme.colors.cardBorder,
backgroundColor: selected
? theme.colors.primarySurface
: theme.colors.surface,
},
]}
/>
<Text style={[styles.railHint, { color: theme.colors.textSecondary }]}>
{entry.hint}
</Text>
</View>
);
})}
</View>
</View>
) : soleRail ? (
// One rail is not a choice. It is still stated, because the customer
// needs to know how the money travels and when it will land.
<View style={styles.railBlock}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
TRANSFER RAIL
</Text>
<View
style={[
styles.soleRail,
{
borderColor: theme.colors.primary,
backgroundColor: theme.colors.primarySurface,
},
]}
>
<Text style={[styles.soleRailLabel, { color: theme.colors.primary }]}>
{soleRail.label}
</Text>
<Text style={[styles.soleRailHint, { color: theme.colors.primary }]}>
{soleRail.hint}
</Text>
</View>
</View>
) : null}
{railError ? (
<Text style={[styles.railError, { color: theme.colors.danger }]}>{railError}</Text>
) : null}
<View style={styles.saveRow}>
<View style={styles.saveCopy}>
<Text style={[styles.saveTitle, { color: theme.colors.textPrimary }]}>
Save as beneficiary
</Text>
<Text style={[styles.saveHint, { color: theme.colors.primary }]}>
Keep this payee for future transfers
</Text>
</View>
<Switch
value={saveAsBeneficiary}
onValueChange={setSaveAsBeneficiary}
accessibilityLabel="Save as beneficiary"
/>
</View>
</Card>
<TransferAmountFields
amount={amount}
onAmountChange={handleAmountChange}
remarks={remarks}
onRemarksChange={setRemarks}
error={amountError}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
paddingBottom: 24,
},
card: {
padding: 16,
gap: 16,
},
sectionLabel: {
fontSize: 13,
fontFamily: fontFamily.bold,
letterSpacing: 0.4,
},
fieldLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
},
railBlock: {
gap: 8,
},
railRow: {
flexDirection: 'row',
gap: 10,
},
railItem: {
flex: 1,
gap: 4,
},
railButton: {
borderWidth: 1.5,
},
railHint: {
fontSize: 11,
textAlign: 'center',
},
soleRail: {
borderWidth: 1.5,
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
gap: 2,
},
soleRailLabel: {
fontSize: 16,
fontFamily: fontFamily.bold,
},
soleRailHint: {
fontSize: 13,
},
railError: {
fontSize: 13,
},
saveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
saveCopy: {
flex: 1,
gap: 2,
},
saveTitle: {
fontSize: 16,
fontFamily: fontFamily.bold,
},
saveHint: {
fontSize: 14,
},
footer: {
padding: 16,
borderTopWidth: 1,
},
});
@@ -0,0 +1,243 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { AccountSelector, Button, Card, Input, ScreenShell } from '../../../components';
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';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransferToMobile'>;
/**
* Send to a mobile number and MMID.
*
* Mobile-number transfers are IMPS by definition — the MMID *is* the IMPS
* addressing scheme — so there is no rail to choose here, and the screen says
* so rather than offering a control with one option in it.
*/
export default function TransferToMobileScreen({ navigation, route }: Props) {
const theme = useTheme();
const prefillId = route.params?.beneficiaryId;
const beneficiary = useAppSelector(selectBeneficiaryById(prefillId ?? ''));
const {
accounts,
sourceAccountNumber,
setSourceAccountNumber,
amount,
handleAmountChange,
parsedAmount,
amountError,
amountValid,
remarks,
setRemarks,
} = useTransferForm();
const [mobileNumber, setMobileNumber] = useState('');
const [mmid, setMmid] = useState('');
const [recipientName, setRecipientName] = useState('');
const [saveAsBeneficiary, setSaveAsBeneficiary] = useState(false);
// Arriving from Quick Pay or the payee list means the recipient is already
// known. The fields stay editable — a saved payee is a starting point, not a
// lock — but they are filled so the customer only enters the amount.
useEffect(() => {
if (!beneficiary) {
return;
}
setMobileNumber(beneficiary.mobileNumber ?? '');
setMmid(beneficiary.mmid ?? '');
setRecipientName(beneficiary.name);
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;
const handleContinue = () => {
if (!canContinue || !sourceAccountNumber) {
return;
}
navigation.navigate('PaymentConfirmation', {
transferType: 'IMPS',
amount: parsedAmount,
remarks: remarks || undefined,
sourceAccountNumber,
beneficiaryId: prefillId,
saveRecipient: saveAsBeneficiary,
recipient: {
// The receipt needs *something* to name. Falling back to the number
// beats printing "Unknown" against money the customer just sent.
name: recipientName.trim() || `+91 ${mobileNumber}`,
mobileNumber,
mmid,
},
});
};
return (
<ScreenShell
title="To mobile number"
subtitle="Instant IMPS using mobile and MMID"
contentContainerStyle={styles.container}
fixedFooter={
<View style={[styles.footer, { borderTopColor: theme.colors.cardBorder }]}>
<Button title="Continue" onPress={handleContinue} disabled={!canContinue} />
</View>
}
>
<Card style={styles.card}>
<AccountSelector
label="Debit from"
accounts={accounts}
selectedAccountNumber={sourceAccountNumber}
onSelect={setSourceAccountNumber}
/>
</Card>
<Card style={styles.card}>
<Text style={[styles.sectionLabel, { color: theme.colors.primary }]}>RECIPIENT</Text>
<Input
label="Mobile number"
prefix="+91"
placeholder="10-digit registered mobile"
keyboardType="number-pad"
maxLength={10}
value={mobileNumber}
onChangeText={(value) => setMobileNumber(value.replace(/\D/g, ''))}
error={mobileError}
/>
<Input
label="MMID"
placeholder="7-digit Mobile Money Identifier"
keyboardType="number-pad"
maxLength={7}
value={mmid}
onChangeText={(value) => setMmid(value.replace(/\D/g, ''))}
error={mmidError}
/>
<Input
label="Recipient name (optional)"
placeholder="Shown on your receipt"
value={recipientName}
onChangeText={setRecipientName}
maxLength={60}
/>
<View style={[styles.note, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons
name="information-circle-outline"
size={18}
color={theme.colors.primary}
style={styles.noteIcon}
/>
<Text style={[styles.noteText, { color: theme.colors.primary }]}>
The recipient can find their MMID in their bank app under IMPS. Mobile-number
transfers always settle over IMPS.
</Text>
</View>
{/* Offered only for a payee not already on file. Ticking it verifies
the details with IMPS and saves them once the transfer succeeds. */}
{!prefillId ? (
<View style={styles.saveRow}>
<View style={styles.saveCopy}>
<Text style={[styles.saveTitle, { color: theme.colors.textPrimary }]}>
Save as beneficiary
</Text>
<Text style={[styles.saveHint, { color: theme.colors.primary }]}>
Keep this payee for future transfers
</Text>
</View>
<Switch
value={saveAsBeneficiary}
onValueChange={setSaveAsBeneficiary}
accessibilityLabel="Save as beneficiary"
/>
</View>
) : null}
</Card>
<TransferAmountFields
amount={amount}
onAmountChange={handleAmountChange}
remarks={remarks}
onRemarksChange={setRemarks}
error={amountError}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
paddingBottom: 24,
},
card: {
padding: 16,
gap: 16,
},
sectionLabel: {
fontSize: 13,
fontFamily: fontFamily.bold,
letterSpacing: 0.4,
},
note: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
borderRadius: 12,
padding: 14,
},
noteIcon: {
marginTop: 1,
},
noteText: {
flex: 1,
fontSize: 14,
lineHeight: 20,
},
saveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
saveCopy: {
flex: 1,
gap: 2,
},
saveTitle: {
fontSize: 16,
fontFamily: fontFamily.bold,
},
saveHint: {
fontSize: 14,
},
footer: {
padding: 16,
borderTopWidth: 1,
},
});
@@ -0,0 +1,195 @@
import React, { useEffect, useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { AccountSelector, Button, Card, EmptyState, ScreenShell } from '../../../components';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
import { useTransferForm } from '../hooks/useTransferForm';
import { TransferAmountFields } from '../components/TransferAmountFields';
import { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransferToOwnAccount'>;
/**
* Move money between the customer's own accounts.
*
* The credit side is drawn from the same account list as the debit side, minus
* whichever account is currently selected to pay: an account cannot be both
* ends of a transfer, and the cleanest way to say so is to not offer it rather
* than to accept it and then complain.
*/
export default function TransferToOwnAccountScreen({ navigation }: Props) {
const theme = useTheme();
// Own-account movements settle on the intra-bank rail. That rail is
// switchable in its own right — a bank can suspend internal settlement for a
// maintenance window — so the destination existing is not the same thing as
// the transfer being possible.
const internalRailEnabled = useFeature(FeatureKey.PAYMENTS_TRANSFER_INTERNAL);
const {
accounts,
sourceAccountNumber,
setSourceAccountNumber,
amount,
handleAmountChange,
parsedAmount,
amountError,
amountValid,
remarks,
setRemarks,
} = useTransferForm();
const destinations = useMemo(
() => accounts.filter((account) => account.accountNumber !== sourceAccountNumber),
[accounts, sourceAccountNumber],
);
const [destinationNumber, setDestinationNumber] = useState<string | undefined>();
// Changing the debit account can invalidate the credit account — the obvious
// case being picking the account that was already the destination.
useEffect(() => {
if (!destinations.length) {
setDestinationNumber(undefined);
return;
}
if (
!destinationNumber ||
!destinations.some((account) => account.accountNumber === destinationNumber)
) {
setDestinationNumber(destinations[0]?.accountNumber);
}
}, [destinationNumber, destinations]);
const destination = destinations.find(
(account) => account.accountNumber === destinationNumber,
);
const canContinue =
internalRailEnabled && Boolean(sourceAccountNumber) && Boolean(destinationNumber) && amountValid;
const handleContinue = () => {
if (!canContinue || !sourceAccountNumber || !destination) {
return;
}
navigation.navigate('PaymentConfirmation', {
transferType: 'Internal',
amount: parsedAmount,
remarks: remarks || undefined,
sourceAccountNumber,
recipient: {
name: `${destination.accountType} account`,
accountMasked: `XXXX ${destination.accountNumber.slice(-4)}`,
accountNumber: destination.accountNumber,
ifsc: destination.ifsc,
bankName: 'Within BankCBS',
},
});
};
if (!internalRailEnabled) {
return (
<ScreenShell title="To own account" subtitle="Temporarily unavailable">
<EmptyState
title="Own-account transfers are paused"
description="Moving money between your own accounts is unavailable right now. Please try again later."
/>
</ScreenShell>
);
}
// One account means there is nowhere to move money to. Saying that outright
// beats a form whose second picker is permanently empty.
if (accounts.length < 2) {
return (
<ScreenShell title="To own account" subtitle="Between your accounts">
<EmptyState
title="You need a second account"
description="Own-account transfers move money between two accounts on this profile. Only one is open at the moment."
/>
</ScreenShell>
);
}
return (
<ScreenShell
title="To own account"
subtitle="Between your accounts, free"
contentContainerStyle={styles.container}
fixedFooter={
<View style={[styles.footer, { borderTopColor: theme.colors.cardBorder }]}>
<Button title="Continue" onPress={handleContinue} disabled={!canContinue} />
</View>
}
>
<Card style={styles.card}>
<AccountSelector
label="Debit from"
accounts={accounts}
selectedAccountNumber={sourceAccountNumber}
onSelect={setSourceAccountNumber}
/>
</Card>
<Card style={styles.card}>
<AccountSelector
label="Credit to"
accounts={destinations}
selectedAccountNumber={destinationNumber}
onSelect={setDestinationNumber}
/>
<View style={[styles.note, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="flash" size={18} color={theme.colors.primary} style={styles.noteIcon} />
<Text style={[styles.noteText, { color: theme.colors.primary }]}>
Own-account transfers are booked instantly within the bank, with no charges.
</Text>
</View>
</Card>
<TransferAmountFields
amount={amount}
onAmountChange={handleAmountChange}
remarks={remarks}
onRemarksChange={setRemarks}
error={amountError}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
paddingBottom: 24,
},
card: {
padding: 16,
gap: 16,
},
note: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
borderRadius: 12,
padding: 14,
},
noteIcon: {
marginTop: 1,
},
noteText: {
flex: 1,
fontSize: 14,
lineHeight: 20,
},
footer: {
padding: 16,
borderTopWidth: 1,
},
});
@@ -1,123 +0,0 @@
import React from 'react';
import { StyleSheet, Text, View, Pressable } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { ScreenShell, Card } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useTheme } from '../../../theme';
import type { PaymentsStackParamList } from '../../../navigation/types';
type Props = NativeStackScreenProps<PaymentsStackParamList, 'TransferTypeSelection'>;
export default function TransferTypeScreen({ navigation, route }: Props) {
const theme = useTheme();
const { beneficiaryId } = route.params;
const beneficiary = useAppSelector(selectBeneficiaryById(beneficiaryId));
const handleSelect = (type: 'IMPS' | 'NEFT' | 'RTGS') => {
navigation.navigate('PaymentForm', { beneficiaryId, transferType: type });
};
return (
<ScreenShell title="Transfer Type" subtitle={`Sending to ${beneficiary?.name || 'Beneficiary'}`} contentContainerStyle={styles.container}>
<Pressable onPress={() => handleSelect('IMPS')}>
<Card style={styles.typeCard}>
<View style={[styles.iconBox, { backgroundColor: `${theme.colors.success}15` }]}>
<Ionicons name="flash" size={24} color={theme.colors.success} />
</View>
<View style={styles.content}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>IMPS Transfer</Text>
<View style={[styles.badge, { backgroundColor: theme.colors.primary }]}>
<Text style={styles.badgeText}>RECOMMENDED</Text>
</View>
</View>
<Text style={[styles.desc, { color: theme.colors.textSecondary }]}>Immediate Payment Service. Instant transfer, 24x7 availability.</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
</Card>
</Pressable>
<Pressable onPress={() => handleSelect('NEFT')}>
<Card style={styles.typeCard}>
<View style={[styles.iconBox, { backgroundColor: `${theme.colors.info}15` }]}>
<Ionicons name="time" size={24} color={theme.colors.info} />
</View>
<View style={styles.content}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>NEFT Transfer</Text>
<Text style={[styles.desc, { color: theme.colors.textSecondary }]}>National Electronic Fund Transfer. Processed in half-hourly batches.</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
</Card>
</Pressable>
<Pressable onPress={() => handleSelect('RTGS')}>
<Card style={styles.typeCard}>
<View style={[styles.iconBox, { backgroundColor: `${theme.colors.warning}15` }]}>
<Ionicons name="briefcase" size={24} color={theme.colors.warning} />
</View>
<View style={styles.content}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>RTGS Transfer</Text>
<Text style={[styles.desc, { color: theme.colors.textSecondary }]}>Real-Time Gross Settlement. Recommended for high-value transfers.</Text>
<Text style={[styles.condition, { color: theme.colors.error }]}>Min amount: ₹2,00,000</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
</Card>
</Pressable>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
},
typeCard: {
padding: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
iconBox: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
},
content: {
flex: 1,
gap: 4,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
title: {
fontSize: 16,
fontWeight: '700',
},
badge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
badgeText: {
color: '#FFF',
fontSize: 10,
fontWeight: '800',
},
desc: {
fontSize: 13,
lineHeight: 18,
},
condition: {
fontSize: 12,
fontWeight: '600',
marginTop: 4,
}
});
+87 -35
View File
@@ -1,14 +1,34 @@
import type { Beneficiary } from '../../../types/app';
import { demoBeneficiaries } from '../../../constants/mockData';
import { endpoints } from '../../../services/api/endpoints';
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { resolveWithMock } from '../../../services/api/serviceHelpers';
import { endpoints } from '../../../services/api/endpoints';
import { resolveLive, resolveWithMock } from '../../../services/api/serviceHelpers';
import type { Beneficiary } from '../../../types/app';
import type {
BeneficiarySaveRequest,
BeneficiaryValidateRequest,
BeneficiaryValidateResult,
CbsBeneficiary,
} from '../../../types/cbs';
import { favoriteStore, toBeneficiary } from '../utils/beneficiaryMapper';
/**
* TEMPORARY — fixed payer account for payee validation.
*
* Every validate 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.
*
* Delete this and pass `request` through untouched once the backend accepts
* the profile's accounts. It must not ship: in production it would validate
* every payee against an account that is not the customer's.
*/
const HARDCODED_PAYER_ACCOUNT = '002002000100019';
export interface BankTransferRequest {
beneficiaryId: string;
amount: number;
transferType: 'IMPS' | 'NEFT' | 'RTGS';
transferType: 'IMPS' | 'NEFT' | 'RTGS' | 'Internal';
remarks?: string;
sourceAccountId: string;
}
@@ -20,37 +40,66 @@ export interface BankTransferResponse {
export const paymentsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
/** The customer's saved payees, newest first. */
getBeneficiaries: builder.query<Beneficiary[], void>({
queryFn: () =>
resolveWithMock({
mockData: demoBeneficiaries,
request: async () => {
// Real API implementation
// const response = await apiClient.get<Beneficiary[]>('/beneficiaries');
// return response.data;
return [] as Beneficiary[];
},
resolveLive(async () => {
const [response, favorites] = await Promise.all([
apiClient.get<CbsBeneficiary[]>(endpoints.beneficiaries.list),
favoriteStore.read(),
]);
const favoriteSet = new Set(favorites);
return (response.data ?? []).map((payee) => toBeneficiary(payee, favoriteSet));
}),
providesTags: ['Payments'],
providesTags: ['Beneficiaries'],
}),
addBeneficiary: builder.mutation<Beneficiary, Partial<Beneficiary>>({
queryFn: (newBen) =>
resolveWithMock({
mockData: {
id: `ben_${Date.now()}`,
name: newBen.name || 'Unknown',
bankName: newBen.bankName || 'Unknown Bank',
accountMasked: newBen.accountMasked || 'xxxx0000',
favorite: false,
} as Beneficiary,
request: async () => {
// const response = await apiClient.post('/beneficiaries/add', newBen);
// return response.data;
return {} as Beneficiary;
}
}),
invalidatesTags: ['Payments'],
/**
* Asks IMPS whether this payee exists, and who they are. Saves nothing.
*
* This is what the confirmation step shows, because a mistyped account
* number is the most common way money reaches the wrong person. Read
* `result` rather than the status code: the switch answers 200 whether or
* not it recognised the payee.
*/
validateBeneficiary: builder.mutation<BeneficiaryValidateResult, BeneficiaryValidateRequest>({
queryFn: (request) =>
resolveLive(async () => {
const response = await apiClient.post<BeneficiaryValidateResult>(
endpoints.beneficiaries.validate,
// See HARDCODED_PAYER_ACCOUNT — the caller's payer is discarded.
{ ...request, payer: { accountNumber: HARDCODED_PAYER_ACCOUNT } },
);
return response.data;
}),
}),
/**
* Saves the payee, resolved through the bank's CBS lookup.
*
* A different body and a different lookup from validate, which goes to
* IMPS — so the two can disagree, and this one's answer is what gets
* stored. Validate first regardless: the server does not enforce the
* order, which means nothing but the app stops a customer saving a payee
* nobody has confirmed.
*/
saveBeneficiary: builder.mutation<Beneficiary, BeneficiarySaveRequest>({
queryFn: (request) =>
resolveLive(async () => {
const response = await apiClient.post<CbsBeneficiary>(
endpoints.beneficiaries.save,
request,
);
return toBeneficiary(response.data);
}),
invalidatesTags: ['Beneficiaries'],
}),
/** No transfer endpoint exists yet, so this stays on fixtures. */
submitBankTransfer: builder.mutation<BankTransferResponse, BankTransferRequest>({
queryFn: (req) =>
resolveWithMock({
@@ -59,9 +108,7 @@ export const paymentsApi = baseApi.injectEndpoints({
status: req.transferType === 'NEFT' ? 'Process_Pending' : 'Process_Success',
}),
request: async () => {
// const response = await apiClient.post<BankTransferResponse>('/transactions/transfer', req);
// return response.data;
return { referenceId: '', status: 'Process_Success' };
throw new Error('Transfer submission is not implemented by the CBS API yet');
},
}),
invalidatesTags: ['Payments', 'Transactions'],
@@ -69,4 +116,9 @@ export const paymentsApi = baseApi.injectEndpoints({
}),
});
export const { useGetBeneficiariesQuery, useAddBeneficiaryMutation, useSubmitBankTransferMutation } = paymentsApi;
export const {
useGetBeneficiariesQuery,
useValidateBeneficiaryMutation,
useSaveBeneficiaryMutation,
useSubmitBankTransferMutation,
} = paymentsApi;
@@ -1,43 +1,80 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { Beneficiary } from '../../../types/app';
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '../../../app/store';
import { demoBeneficiaries } from '../../../constants/mockData';
import type { Beneficiary } from '../../../types/app';
import { favoriteStore } from '../utils/beneficiaryMapper';
interface BeneficiariesState {
list: Beneficiary[];
/** False until the live list has been read once, so screens can tell empty
* from not-yet-loaded. */
hydrated: boolean;
}
const initialState: BeneficiariesState = {
list: demoBeneficiaries,
list: [],
hydrated: false,
};
/**
* Starring a payee is a local preference — the API has no favourite flag — so
* the toggle has to persist it on this device rather than send it anywhere.
*/
export const toggleBeneficiaryFavorite = createAsyncThunk<
string[],
string,
{ state: { beneficiaries: BeneficiariesState } }
>('beneficiaries/toggleFavorite', async (beneficiaryId, { getState }) => {
const current = getState().beneficiaries.list;
const next = current
.filter((payee) =>
payee.id === beneficiaryId ? !payee.favorite : payee.favorite,
)
.map((payee) => payee.id);
await favoriteStore.write(next);
return next;
});
const beneficiariesSlice = createSlice({
name: 'beneficiaries',
initialState,
reducers: {
setBeneficiaries(state, action: PayloadAction<Beneficiary[]>) {
state.list = action.payload;
state.hydrated = true;
},
addBeneficiary(state, action: PayloadAction<Beneficiary>) {
state.list.push(action.payload);
// The list is newest-first, and a save that raced a refresh must not
// produce two rows for the same payee.
state.list = [
action.payload,
...state.list.filter((payee) => payee.id !== action.payload.id),
];
},
removeBeneficiary(state, action: PayloadAction<string>) {
state.list = state.list.filter(b => b.id !== action.payload);
state.list = state.list.filter((payee) => payee.id !== action.payload);
},
markAsFavorite(state, action: PayloadAction<string>) {
const b = state.list.find(item => item.id === action.payload);
if (b) {
b.favorite = !b.favorite;
}
}
},
extraReducers: (builder) => {
builder.addCase(toggleBeneficiaryFavorite.fulfilled, (state, action) => {
const favorites = new Set(action.payload);
state.list = state.list.map((payee) => ({
...payee,
favorite: favorites.has(payee.id),
}));
});
},
});
export const beneficiariesActions = beneficiariesSlice.actions;
export const beneficiariesReducer = beneficiariesSlice.reducer;
// Selectors — the slice is the single source of truth for saved payees, so a
// newly added beneficiary is immediately visible everywhere it is read.
// The slice is the single source of truth for saved payees, so a newly added
// beneficiary is immediately visible everywhere it is read.
export const selectBeneficiaries = (state: RootState) => state.beneficiaries.list;
export const selectBeneficiariesHydrated = (state: RootState) => state.beneficiaries.hydrated;
export const selectBeneficiaryById = (id: string) => (state: RootState) =>
state.beneficiaries.list.find((beneficiary) => beneficiary.id === id);
@@ -0,0 +1,61 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Beneficiary } from '../../../types/app';
import type { CbsBeneficiary } from '../../../types/cbs';
const FAVORITES_KEY = 'bankcbs.beneficiaryFavorites';
/**
* The API has no favourite flag — starring a payee is a local preference. Kept
* as a set of beneficiary ids in AsyncStorage: losing it costs the customer a
* tap, so it does not warrant the Keystore.
*/
export const favoriteStore = {
async read(): Promise<string[]> {
try {
const raw = await AsyncStorage.getItem(FAVORITES_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
},
async write(ids: string[]) {
try {
await AsyncStorage.setItem(FAVORITES_KEY, JSON.stringify(ids));
} catch {
// A failed preference write is not worth surfacing to the customer.
}
},
};
/**
* Maps a payee as the bank returned it onto the shape the payments screens
* already read. `validate` leaves `beneficiaryId` null because nothing has been
* saved yet, so an unsaved payee gets a stable synthetic key from its routing
* details rather than a fresh one on every keystroke.
*/
export const toBeneficiary = (
source: CbsBeneficiary,
favorites: ReadonlySet<string> = new Set(),
): Beneficiary => {
const id =
source.beneficiaryId ??
`draft:${source.mode}:${source.accountNumberMasked}:${source.mmid ?? source.ifsc ?? ''}`;
return {
id,
name: source.name,
bankName: source.bankName,
accountMasked: source.accountNumberMasked,
nickname: source.nickname ?? undefined,
favorite: favorites.has(id),
mode: source.mode,
ifsc: source.ifsc,
mobileNumber: source.mobileNumber,
mmid: source.mmid,
status: source.status,
};
};
@@ -0,0 +1,22 @@
import type { Beneficiary } from '../../../types/app';
import type { PaymentsStackParamList } from '../../../navigation/types';
/** The two payee-driven transfer entry points, as a navigate() argument pair. */
type PayeeTransferRoute =
| ['TransferToMobile', PaymentsStackParamList['TransferToMobile']]
| ['TransferToAccount', PaymentsStackParamList['TransferToAccount']];
/**
* Which transfer screen a saved payee opens.
*
* There is no longer a rail-picker between the payee list and the form, so the
* choice the customer used to make by hand is made from what the payee record
* actually holds: a P2P payee is addressed by mobile and MMID, everything else
* by account number and IFSC. Getting this wrong strands the customer on a
* form they cannot complete, so `mode` is checked rather than guessed from
* whichever fields happen to be populated.
*/
export const transferRouteForBeneficiary = (payee: Beneficiary): PayeeTransferRoute =>
payee.mode === 'P2P'
? ['TransferToMobile', { beneficiaryId: payee.id }]
: ['TransferToAccount', { beneficiaryId: payee.id }];