Add TPIN creation, TPIN-authorised transfers, MPIN reset and transfer history

- Create TPIN: current MPIN -> OTP -> encrypted TPIN via /tpin/create/*,
  from Services and from the transfer screen on TPIN_NOT_SET
- IMPS initiate now sends encryptedTpin instead of encryptedMpin; handles
  INVALID_TPIN and TPIN_NOT_SET
- Share one RSA-OAEP encryption and key-retry path for MPIN and TPIN
- Forgot MPIN on sign-in and Reset MPIN in the Security Center
- Fund transfer history list and detail, reachable from Home
- Brand logo on sign-in and palette updates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-09-29 17:54:43 +05:30
co-authored by Claude Opus 5.5
parent abb14d5768
commit f1def81eb7
50 changed files with 3039 additions and 385 deletions
@@ -0,0 +1,236 @@
import React, { useEffect } from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
import type { HomeStackParamList, MainTabParamList } from '../../../navigation/types';
import type { ApiError } from '../../../types/api';
import { describeError } from '../../../services/api/cbsErrors';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useGetFundTransferDetailQuery } from '../../payments/services/paymentsApi';
import { MODE_LABEL, humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
type Props = CompositeScreenProps<
NativeStackScreenProps<HomeStackParamList, 'TransferDetail'>,
BottomTabScreenProps<MainTabParamList>
>;
const when = (iso: string | null) => (iso ? `${formatDate(iso)}, ${formatTime(iso)}` : null);
export default function TransferDetailScreen({ navigation, route }: Props) {
const theme = useTheme();
const showError = useApiErrorToast();
const { transferId } = route.params;
// Always fetched fresh on open: reopening the screen is one of the two ways
// the guide says a pending transfer should re-check its state.
const { data, isLoading, isFetching, error, refetch } = useGetFundTransferDetailQuery(transferId, {
refetchOnMountOrArgChange: true,
});
const apiError = error as ApiError | undefined;
// A transfer that is not on this profile: close the detail and say why.
useEffect(() => {
if (apiError?.code === 'TRANSACTION_NOT_FOUND') {
showError(apiError);
navigation.goBack();
}
}, [apiError, navigation, showError]);
if (isLoading) {
return (
<ScreenShell title="Transfer details">
<Loader />
</ScreenShell>
);
}
if (!data) {
const copy = describeError(apiError?.code);
return (
<ScreenShell title="Transfer details">
<EmptyState
title={copy.title}
description={apiError?.message || copy.description}
actionLabel="Try again"
onAction={refetch}
/>
</ScreenShell>
);
}
const status = statusCopy(data.status);
const colors = toneColors(status.tone, theme.colors);
const showReason =
Boolean(data.failureReason) && (data.status === 'FAILED' || data.status === 'MANUAL_REVIEW');
const rows: Array<[string, string | null | undefined]> = [
['Paid to', data.payeeName],
[data.mode === 'P2P' ? 'Mobile' : 'Account', data.payeeDisplay],
['IFSC', data.beneficiaryIfsc],
['MMID', data.beneficiaryMmid],
['Type', `IMPS · ${MODE_LABEL[data.mode] ?? data.mode}`],
['Remark', data.remark],
['Initiated', when(data.createdAt)],
['Last updated', when(data.updatedAt)],
];
return (
<ScreenShell title="Transfer details" scrollable={false}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl
refreshing={isFetching}
onRefresh={refetch}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
>
{/* ── the outcome ───────────────────────────────────────────────── */}
<Card style={styles.hero}>
<View style={[styles.statusIcon, { backgroundColor: colors.surface }]}>
<Ionicons name={status.icon} size={30} color={colors.tone} />
</View>
<Text style={[theme.text.balance, { color: theme.colors.textPrimary }]}>
{formatCurrency(data.amount)}
</Text>
<View style={[styles.badge, { backgroundColor: colors.surface, borderColor: colors.border }]}>
{/* `simple` break strategy: Android's default `highQuality` measures
a custom font as fitting on one line, then wraps it on draw, so
the last word ("pending") was clipped outside the pill. */}
<Text
textBreakStrategy="simple"
style={[theme.text.bodyStrong, styles.badgeText, { color: colors.tone }]}
>
{status.label}
</Text>
</View>
{showReason ? (
<Text style={[theme.text.body, styles.centre, { color: theme.colors.danger }]}>
{humaniseReason(data.failureReason as string)}
</Text>
) : null}
</Card>
{data.status === 'PENDING_RECONCILIATION' || data.status === 'PROCESSING' ? (
<InfoBanner
tone="warning"
message="Your bank is still confirming this transfer. Don't send it again — refresh in a little while to see the final status."
/>
) : null}
{data.status === 'PENDING_OTP' ? (
<InfoBanner
tone="info"
message="This transfer was never confirmed with an OTP, so no money has left your account."
/>
) : null}
{data.status === 'MANUAL_REVIEW' ? (
<InfoBanner
tone="warning"
message="Your bank is reviewing this transfer. Contact support and quote the reference below if you need help."
/>
) : null}
{/* ── the reference the customer quotes ─────────────────────────── */}
<Card style={styles.section}>
<Text style={[theme.text.eyebrow, { color: theme.colors.textMuted }]}>Reference</Text>
<Text selectable style={[theme.text.code, { color: theme.colors.textPrimary }]}>
{data.referenceId}
</Text>
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
Press and hold to copy
</Text>
</Card>
<Card style={styles.section}>
{rows
.filter(([, value]) => Boolean(value))
.map(([label, value]) => (
<View key={label} style={styles.detailRow}>
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>{label}</Text>
<Text
style={[theme.text.bodyStrong, styles.detailValue, { color: theme.colors.textPrimary }]}
>
{value}
</Text>
</View>
))}
</Card>
{status.refreshable ? (
<Button
title={isFetching ? 'Checking…' : 'Refresh status'}
variant="secondary"
onPress={refetch}
disabled={isFetching}
/>
) : null}
{data.status === 'MANUAL_REVIEW' || data.status === 'FAILED' ? (
<Button
title="Get help"
variant="ghost"
onPress={() => navigation.navigate('Profile', { screen: 'HelpSupport' })}
/>
) : null}
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
content: {
gap: 14,
paddingBottom: 24,
},
hero: {
alignItems: 'center',
gap: 10,
paddingVertical: 24,
},
statusIcon: {
width: 60,
height: 60,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
},
badgeText: {
textAlign: 'center',
},
badge: {
maxWidth: '100%',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
borderWidth: 1,
},
centre: {
textAlign: 'center',
},
section: {
gap: 6,
padding: 16,
},
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 16,
paddingVertical: 6,
},
detailValue: {
flex: 1,
textAlign: 'right',
},
});
@@ -0,0 +1,445 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import DatePicker from 'react-native-date-picker';
import {
AppModal as Modal,
Button,
Card,
EmptyState,
Loader,
ScreenShell,
SegmentedControl,
} from '../../../components';
import { useTheme } from '../../../theme';
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
import type { HomeStackParamList } from '../../../navigation/types';
import type { ApiError } from '../../../types/api';
import type { BeneficiaryMode, FundTransferHistoryItem, FundTransferStatus } from '../../../types/cbs';
import { describeError } from '../../../services/api/cbsErrors';
import { toStatementDate } from '../../home/hooks/useStatement';
import { useFundTransferHistory, type HistoryFilters } from '../hooks/useFundTransferHistory';
import { humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
type Props = NativeStackScreenProps<HomeStackParamList, 'TransferHistory'>;
const STATUS_FILTERS: ReadonlyArray<{ value: FundTransferStatus | 'ALL'; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'SUCCESS', label: 'Successful' },
{ value: 'FAILED', label: 'Failed' },
{ value: 'PROCESSING', label: 'In progress' },
{ value: 'PENDING_RECONCILIATION', label: 'Pending' },
{ value: 'MANUAL_REVIEW', label: 'Under review' },
];
const MODE_FILTERS: ReadonlyArray<{ value: BeneficiaryMode | 'ALL'; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'P2A', label: 'Account' },
{ value: 'P2P', label: 'Mobile' },
];
function TransferRow({
item,
onPress,
}: {
item: FundTransferHistoryItem;
onPress: (item: FundTransferHistoryItem) => void;
}) {
const theme = useTheme();
const copy = statusCopy(item.status);
const colors = toneColors(copy.tone, theme.colors);
// Shown only for failed and review states, and only when there is one.
const showReason =
Boolean(item.failureReason) && (item.status === 'FAILED' || item.status === 'MANUAL_REVIEW');
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${item.payeeName ?? 'Transfer'}, ${formatCurrency(item.amount)}, ${copy.label}`}
onPress={() => onPress(item)}
>
<Card style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
<Ionicons name={copy.icon} size={20} color={colors.tone} />
</View>
<View style={styles.copy}>
<Text
numberOfLines={1}
style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}
>
{item.payeeName || 'Payee'}
</Text>
{item.payeeDisplay ? (
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
{item.payeeDisplay}
</Text>
) : null}
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
{`${formatDate(item.createdAt)} · ${formatTime(item.createdAt)}`}
</Text>
{showReason ? (
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>
{humaniseReason(item.failureReason as string)}
</Text>
) : null}
</View>
<View style={styles.trailing}>
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>
{formatCurrency(item.amount)}
</Text>
<View
style={[
styles.badge,
{ backgroundColor: colors.surface, borderColor: colors.border },
]}
>
{/* See TransferDetailScreen: `simple` stops Android clipping the
last word of a label drawn in a custom font. */}
<Text
textBreakStrategy="simple"
style={[theme.text.caption, styles.badgeText, { color: colors.tone }]}
>
{copy.label}
</Text>
</View>
</View>
</View>
</Card>
</Pressable>
);
}
export default function TransferHistoryScreen({ navigation }: Props) {
const theme = useTheme();
const [filters, setFilters] = useState<HistoryFilters>({ status: 'ALL', mode: 'ALL' });
const [rangeOpen, setRangeOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState<'from' | 'to' | null>(null);
const [tempFrom, setTempFrom] = useState(() => {
const date = new Date();
date.setDate(date.getDate() - 30);
return date;
});
const [tempTo, setTempTo] = useState(() => new Date());
const [rangeError, setRangeError] = useState<string | null>(null);
const { transfers, totalElements, hasMore, isLoading, isLoadingMore, isRefreshing, error, loadMore, reload } =
useFundTransferHistory(filters);
const apiError = error as ApiError | undefined;
const hasRange = Boolean(filters.fromDate && filters.toDate);
const openDetail = useCallback(
(item: FundTransferHistoryItem) => navigation.navigate('TransferDetail', { transferId: item.transferId }),
[navigation],
);
const applyRange = useCallback(() => {
if (tempFrom.getTime() > tempTo.getTime()) {
setRangeError('The start date cannot be after the end date.');
return;
}
setRangeError(null);
setRangeOpen(false);
setFilters((current) => ({
...current,
fromDate: toStatementDate(tempFrom),
toDate: toStatementDate(tempTo),
}));
}, [tempFrom, tempTo]);
const clearRange = useCallback(() => {
setRangeOpen(false);
setRangeError(null);
setFilters((current) => ({ ...current, fromDate: undefined, toDate: undefined }));
}, []);
const subtitle = useMemo(() => {
if (isLoading) return 'Loading your transfers';
return `${totalElements} ${totalElements === 1 ? 'transfer' : 'transfers'}`;
}, [isLoading, totalElements]);
const listHeader = (
<View style={styles.filters}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
{STATUS_FILTERS.map((option) => {
const active = filters.status === option.value;
return (
<Pressable
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected: active }}
onPress={() => setFilters((current) => ({ ...current, status: option.value }))}
style={[
styles.chip,
{
borderRadius: theme.radius.pill,
backgroundColor: active ? theme.colors.primary : theme.colors.surface,
borderColor: active ? theme.colors.primary : theme.colors.cardBorder,
},
]}
>
<Text
style={[
theme.text.bodySmall,
{ color: active ? theme.colors.textOnBrand : theme.colors.textPrimary },
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</ScrollView>
<SegmentedControl
options={MODE_FILTERS}
value={filters.mode}
onChange={(mode) => setFilters((current) => ({ ...current, mode }))}
accessibilityLabel="Transfer type"
/>
<Pressable
accessibilityRole="button"
onPress={() => setRangeOpen(true)}
style={[
styles.rangeButton,
{
backgroundColor: theme.colors.surface,
borderColor: hasRange ? theme.colors.primary : theme.colors.cardBorder,
borderRadius: theme.radius.md,
},
]}
>
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
{hasRange
? `${formatDate(filters.fromDate as string)} – ${formatDate(filters.toDate as string)}`
: 'Any date'}
</Text>
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
</Pressable>
</View>
);
const renderEmpty = () => {
if (isLoading) {
return <Loader />;
}
if (apiError) {
const copy = describeError(apiError.code);
return (
<EmptyState
title={copy.title}
description={apiError.message || copy.description}
actionLabel="Try again"
onAction={reload}
/>
);
}
return (
<EmptyState
title="No transfers found"
description={
filters.status === 'ALL' && filters.mode === 'ALL' && !hasRange
? 'Transfers you make from the app will appear here.'
: 'Nothing matches these filters. Try widening them.'
}
/>
);
};
return (
<ScreenShell title="History" subtitle={subtitle} scrollable={false}>
<FlatList
data={transfers}
keyExtractor={(item) => item.transferId}
renderItem={({ item }) => <TransferRow item={item} onPress={openDetail} />}
ListHeaderComponent={listHeader}
ListEmptyComponent={renderEmpty}
ListFooterComponent={
isLoadingMore ? (
<ActivityIndicator style={styles.footer} color={theme.colors.primary} />
) : null
}
ItemSeparatorComponent={Separator}
onEndReached={hasMore ? loadMore : undefined}
onEndReachedThreshold={0.4}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={reload}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
/>
<Modal visible={rangeOpen} onClose={() => setRangeOpen(false)} title="Filter by date">
<View style={styles.modalBody}>
{(['from', 'to'] as const).map((which) => (
<View key={which} style={styles.dateField}>
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
{which === 'from' ? 'From' : 'To'}
</Text>
<Pressable
accessibilityRole="button"
onPress={() => setPickerOpen(which)}
style={[
styles.rangeButton,
{
backgroundColor: theme.colors.surfaceMuted,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.md,
},
]}
>
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
{formatDate(toStatementDate(which === 'from' ? tempFrom : tempTo))}
</Text>
</Pressable>
</View>
))}
{rangeError ? (
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>{rangeError}</Text>
) : null}
<View style={styles.modalActions}>
<Button title="Apply" onPress={applyRange} style={styles.modalButton} />
<Button
title={hasRange ? 'Clear dates' : 'Cancel'}
variant="secondary"
onPress={hasRange ? clearRange : () => setRangeOpen(false)}
style={styles.modalButton}
/>
</View>
</View>
<DatePicker
modal
open={pickerOpen !== null}
date={pickerOpen === 'to' ? tempTo : tempFrom}
maximumDate={new Date()}
mode="date"
title={pickerOpen === 'to' ? 'Select end date' : 'Select start date'}
onConfirm={(date) => {
if (pickerOpen === 'to') setTempTo(date);
else setTempFrom(date);
setPickerOpen(null);
setRangeError(null);
}}
onCancel={() => setPickerOpen(null)}
/>
</Modal>
</ScreenShell>
);
}
const Separator = () => <View style={styles.separator} />;
const styles = StyleSheet.create({
listContent: {
paddingBottom: 24,
flexGrow: 1,
},
filters: {
gap: 12,
marginBottom: 16,
},
chips: {
gap: 8,
},
chip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderWidth: 1,
},
rangeButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
borderWidth: 1,
},
rangeText: {
flex: 1,
},
separator: {
height: 10,
},
card: {
padding: 14,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
copy: {
flex: 1,
gap: 2,
},
trailing: {
alignItems: 'flex-end',
gap: 6,
},
badgeText: {
textAlign: 'right',
},
badge: {
maxWidth: 150,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
borderWidth: 1,
},
footer: {
marginVertical: 16,
},
modalBody: {
gap: 14,
},
dateField: {
gap: 6,
},
modalActions: {
flexDirection: 'row',
gap: 10,
marginTop: 6,
},
modalButton: {
flex: 1,
},
});