Files
CBS-Mobile-App/src/modules/payments/screens/TransferToAccountScreen.tsx
T

378 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
},
});