378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
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,
|
||
},
|
||
});
|