Update CBS Mobile App codebase
This commit is contained in:
@@ -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,
|
||||
}
|
||||
});
|
||||
@@ -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 }];
|
||||
Reference in New Issue
Block a user