Update CBS Mobile App codebase
This commit is contained in:
@@ -4,7 +4,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { BottomSheet } from '../../../components';
|
||||
import { Button } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { BranchWithDistance } from '../hooks/useBranches';
|
||||
import { formatDistance, openDirections, callBranch } from '../utils/geo';
|
||||
|
||||
@@ -167,7 +167,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
mainBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -187,12 +187,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
infoSubtext: {
|
||||
fontSize: 12,
|
||||
@@ -210,12 +210,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
facilitiesSection: {
|
||||
gap: 8,
|
||||
@@ -223,7 +223,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
facilitiesTitle: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
facilitiesRow: {
|
||||
@@ -241,7 +241,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
facilityChipText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
actions: {
|
||||
gap: 10,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { BranchWithDistance } from '../hooks/useBranches';
|
||||
import { formatDistance } from '../utils/geo';
|
||||
|
||||
@@ -153,7 +153,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
name: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
flexShrink: 1,
|
||||
},
|
||||
mainBadge: {
|
||||
@@ -163,7 +163,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
mainBadgeText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '800',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
address: {
|
||||
@@ -182,7 +182,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
fontFamily: fontFamily.medium,
|
||||
},
|
||||
facilityRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -197,11 +197,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
facilityTagText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
moreText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import Animated, {
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
/* ───────────────────── tiny sparkle dot ───────────────────── */
|
||||
function SparkleParticle({
|
||||
@@ -400,7 +400,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
headerLabelText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '900',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
chequeNumber: {
|
||||
@@ -410,7 +410,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
chequeNumberText: {
|
||||
fontSize: 8,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
dateLine: {
|
||||
@@ -421,7 +421,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
dateLabel: {
|
||||
fontSize: 8,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
dateUnderline: {
|
||||
borderBottomWidth: 1,
|
||||
@@ -431,7 +431,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
dateValue: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
payToRow: {
|
||||
@@ -441,7 +441,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
payToLabel: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
payToLineWrap: {
|
||||
flex: 1,
|
||||
@@ -488,7 +488,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
rupeeSign: {
|
||||
fontSize: 13,
|
||||
fontWeight: '900',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
bottomRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -501,9 +501,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
micrText: {
|
||||
fontSize: 7,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1.2,
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: fontFamily.mono,
|
||||
},
|
||||
signatureWrap: {
|
||||
alignItems: 'center',
|
||||
@@ -518,7 +517,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
signatureLabel: {
|
||||
fontSize: 7,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
stamp: {
|
||||
@@ -544,7 +543,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
stampText: {
|
||||
fontSize: 7,
|
||||
fontWeight: '900',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 1.2,
|
||||
},
|
||||
checkmarkBadge: {
|
||||
@@ -565,6 +564,6 @@ const styles = StyleSheet.create({
|
||||
checkmarkText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '900',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { memo } from 'react';
|
||||
import { ScrollView, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { BranchFacility } from '../../../types/app';
|
||||
|
||||
const FACILITIES: { key: BranchFacility; icon: string }[] = [
|
||||
@@ -83,7 +83,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import Animated, {
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
/* ─────────── floating dot particle ─────────── */
|
||||
function FloatingDot({
|
||||
@@ -445,7 +445,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
checkIcon: {
|
||||
fontSize: 11,
|
||||
fontWeight: '900',
|
||||
fontFamily: fontFamily.bold,
|
||||
marginTop: -1,
|
||||
},
|
||||
pencilIcon: {
|
||||
@@ -490,7 +490,7 @@ const styles = StyleSheet.create({
|
||||
submittedText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import type { ServiceStatus } from '../../../types/app';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { getServiceStatusTone } from '../utils/serviceStatus';
|
||||
|
||||
interface ServiceStatusPillProps {
|
||||
@@ -37,7 +37,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
text: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import { 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 { Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
|
||||
import { useAccounts } from '../../home/hooks/useAccounts';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { CbsAccount } from '../../../types/cbs';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<ServicesStackParamList, 'BalanceEnquiry'>>;
|
||||
|
||||
/**
|
||||
* Every account on the profile, with its routing details.
|
||||
*
|
||||
* This screen is switched off, and the reason is worth recording where the
|
||||
* next person to touch it will read it: **no CBS endpoint returns a balance**.
|
||||
* `GET /accounts` carries the account number, type, status, IFSC and MMID and
|
||||
* nothing else, and there is no balance endpoint anywhere in the API.
|
||||
*
|
||||
* It is kept, rather than deleted, because everything except the figures is
|
||||
* already correct — when a balance endpoint lands, the work is to fetch it
|
||||
* here and put the number back into the wells below, then switch
|
||||
* `services.balanceEnquiry` on. Until then the screen says plainly that the
|
||||
* bank does not give the app balances, which is the one thing a screen called
|
||||
* "balance enquiry" must not be vague about.
|
||||
*/
|
||||
export default function BalanceEnquiryScreen(_props: Props) {
|
||||
const theme = useTheme();
|
||||
const { accounts, isLoading, isFetching, refetch, error } = useAccounts();
|
||||
|
||||
const renderAccount = (account: CbsAccount) => (
|
||||
<Card key={account.accountNumber} style={styles.accountCard}>
|
||||
<View style={styles.accountHeader}>
|
||||
<View style={[styles.accountIcon, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="wallet-outline" size={22} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.accountCopy}>
|
||||
<Text style={[styles.accountType, { color: theme.colors.primary }]}>
|
||||
{`${account.accountType.toUpperCase()} ACCOUNT`}
|
||||
</Text>
|
||||
<Text style={[styles.accountNumber, { color: theme.colors.textPrimary }]}>
|
||||
{`XXXX ${account.accountNumber.slice(-4)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.statusPill,
|
||||
{
|
||||
backgroundColor:
|
||||
account.status === 'ACTIVE'
|
||||
? `${theme.colors.success}1A`
|
||||
: theme.colors.surfaceMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{
|
||||
color:
|
||||
account.status === 'ACTIVE'
|
||||
? theme.colors.success
|
||||
: theme.colors.textSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{account.status}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>IFSC</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{account.ifsc}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>MMID</Text>
|
||||
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
|
||||
{account.mmid ?? 'Not allocated'}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Your accounts"
|
||||
subtitle="Account and routing details"
|
||||
scrollable={false}
|
||||
contentContainerStyle={styles.shell}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
title="Could not load your accounts"
|
||||
description="Something went wrong reading your accounts. Pull down to try again."
|
||||
/>
|
||||
) : accounts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No accounts to show"
|
||||
description="We could not read any accounts for this profile."
|
||||
/>
|
||||
) : (
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scroll}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching && !isLoading}
|
||||
onRefresh={refetch}
|
||||
tintColor={theme.colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
title="Balances are not available in the app"
|
||||
message="Your bank does not provide account balances to this app. Use your passbook, an ATM, or your branch to check a balance."
|
||||
/>
|
||||
|
||||
{accounts.map(renderAccount)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
shell: {
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
scroll: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
accountCard: {
|
||||
padding: 16,
|
||||
gap: 14,
|
||||
},
|
||||
accountHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
},
|
||||
accountIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
accountCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
accountType: {
|
||||
fontSize: 14,
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
accountNumber: {
|
||||
fontSize: 20,
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
statusPill: {
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: 15,
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: 15,
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { Loader, EmptyState } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import type { BranchFacility } from '../../../types/app';
|
||||
import { useUserLocation } from '../hooks/useUserLocation';
|
||||
@@ -138,7 +138,7 @@ export default function BranchLocatorScreen({ navigation }: Props) {
|
||||
if (isLoading || locationLoading) {
|
||||
return (
|
||||
<SafeAreaView edges={['top']} style={[styles.flex, { backgroundColor: theme.colors.background }]}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||
<StatusBar barStyle={theme.statusBarStyle} backgroundColor="transparent" translucent />
|
||||
<Header theme={theme} navigation={navigation} />
|
||||
<Loader />
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function BranchLocatorScreen({ navigation }: Props) {
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={['top']} style={[styles.flex, { backgroundColor: theme.colors.background }]}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||
<StatusBar barStyle={theme.statusBarStyle} backgroundColor="transparent" translucent />
|
||||
|
||||
{/* Header */}
|
||||
<Header theme={theme} navigation={navigation} />
|
||||
@@ -201,7 +201,7 @@ export default function BranchLocatorScreen({ navigation }: Props) {
|
||||
</View>
|
||||
|
||||
{/* Segmented Control for Locator Type */}
|
||||
<View style={styles.segmentedContainer}>
|
||||
<View style={[styles.segmentedContainer, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
{(['all', 'branch', 'atm'] as const).map((type) => {
|
||||
const isActive = filterType === type;
|
||||
const label = type === 'all' ? 'All' : type === 'branch' ? 'Branches' : 'ATMs';
|
||||
@@ -503,7 +503,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
lineHeight: 20,
|
||||
@@ -526,7 +526,7 @@ const styles = StyleSheet.create({
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
fontFamily: fontFamily.medium,
|
||||
height: '100%',
|
||||
},
|
||||
toggleBtn: {
|
||||
@@ -544,7 +544,6 @@ const styles = StyleSheet.create({
|
||||
marginHorizontal: 10,
|
||||
marginBottom: 8,
|
||||
padding: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
segmentSegment: {
|
||||
@@ -555,7 +554,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
segmentLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
countRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -566,11 +565,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
countText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
clearText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
mapContainer: {
|
||||
flex: 1,
|
||||
@@ -656,7 +655,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
cityChipText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
|
||||
sectionCopy: {
|
||||
@@ -665,12 +664,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
nearestLabel: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
nearestName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
nearestDistance: {
|
||||
fontSize: 12,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell, Card, Button } from '../../../components';
|
||||
import { AppModal } from '../../../components/Modal';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'CertificateList'>;
|
||||
|
||||
@@ -28,7 +28,10 @@ export default function CertificateListScreen({ route }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title={title} subtitle={`${mockDocs.length} documents available`}>
|
||||
<ScreenShell
|
||||
// The body is a FlatList, so it owns the scrolling.
|
||||
scrollable={false}
|
||||
title={title} subtitle={`${mockDocs.length} documents available`}>
|
||||
<FlatList
|
||||
data={mockDocs}
|
||||
keyExtractor={item => item.id}
|
||||
@@ -85,9 +88,9 @@ export default function CertificateListScreen({ route }: Props) {
|
||||
<Text style={[styles.certTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
<View style={{ gap: 8, marginTop: 12 }}>
|
||||
<Text style={[styles.certText, { color: theme.colors.textSecondary }]}>
|
||||
This officially certifies that the document <Text style={{fontWeight: '700', color: theme.colors.textPrimary}}>{viewingDoc}</Text> has been cryptographically generated and is valid.
|
||||
This officially certifies that the document <Text style={{fontFamily: fontFamily.bold, color: theme.colors.textPrimary}}>{viewingDoc}</Text> has been cryptographically generated and is valid.
|
||||
</Text>
|
||||
<Text style={[styles.certText, { color: theme.colors.textSecondary }]}>Reference Category: <Text style={{fontWeight: '700'}}>{category.toUpperCase()}</Text></Text>
|
||||
<Text style={[styles.certText, { color: theme.colors.textSecondary }]}>Reference Category: <Text style={{fontFamily: fontFamily.bold}}>{category.toUpperCase()}</Text></Text>
|
||||
<Text style={[styles.certText, { color: theme.colors.textSecondary }]}>Account Bearer: Lincpay User</Text>
|
||||
<Text style={[styles.certText, { color: theme.colors.textSecondary }]}>Account #: XXXX-XXXX-1234</Text>
|
||||
</View>
|
||||
@@ -146,13 +149,13 @@ const styles = StyleSheet.create({
|
||||
gap: 6,
|
||||
},
|
||||
docTitle: {
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
fontSize: 16,
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
docMeta: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
fontFamily: fontFamily.medium,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
@@ -182,12 +185,12 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 12,
|
||||
},
|
||||
certBankName: {
|
||||
fontWeight: '900',
|
||||
fontFamily: fontFamily.bold,
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
certTitle: {
|
||||
fontWeight: '800',
|
||||
fontFamily: fontFamily.bold,
|
||||
fontSize: 18,
|
||||
textAlign: 'center',
|
||||
},
|
||||
@@ -213,7 +216,7 @@ const styles = StyleSheet.create({
|
||||
signature: {
|
||||
fontStyle: 'italic',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell, Card } from '../../../components';
|
||||
import { ServiceGridItem } from '../../home/components/ServiceGridItem';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useEnabledItems } from '../../../features';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'CertificatesHub'>;
|
||||
|
||||
@@ -16,50 +18,67 @@ export default function CertificatesHubScreen({ navigation }: Props) {
|
||||
() => [
|
||||
{
|
||||
id: 'balance',
|
||||
feature: FeatureKey.SERVICES_CERTIFICATES_BALANCE,
|
||||
title: 'Balance Cert',
|
||||
icon: 'wallet-outline',
|
||||
iconColor: '#3B82F6',
|
||||
iconColor: theme.colors.info,
|
||||
onPress: () => navigation.navigate('CertificateList', { title: 'Balance Certificates', category: 'balance' }),
|
||||
},
|
||||
{
|
||||
id: 'interest',
|
||||
feature: FeatureKey.SERVICES_CERTIFICATES_INTEREST,
|
||||
title: 'Interest Cert',
|
||||
icon: 'trending-up-outline',
|
||||
iconColor: '#10B981',
|
||||
iconColor: theme.colors.success,
|
||||
onPress: () => navigation.navigate('CertificateList', { title: 'Interest Certificates', category: 'interest' }),
|
||||
},
|
||||
{
|
||||
id: 'share',
|
||||
feature: FeatureKey.SERVICES_CERTIFICATES_SHARE,
|
||||
title: 'Share Cert',
|
||||
icon: 'ribbon-outline',
|
||||
iconColor: theme.colors.category.sky.tone,
|
||||
onPress: () => navigation.navigate('CertificateList', { title: 'Share Certificates', category: 'share' }),
|
||||
},
|
||||
{
|
||||
id: 'hypothecation',
|
||||
feature: FeatureKey.SERVICES_CERTIFICATES_HYPOTHECATION,
|
||||
title: 'Hypothecation',
|
||||
icon: 'document-text-outline',
|
||||
iconColor: '#F59E0B',
|
||||
iconColor: theme.colors.warning,
|
||||
onPress: () => navigation.navigate('CertificateList', { title: 'Hypothecation', category: 'hypothecation' }),
|
||||
},
|
||||
{
|
||||
id: 'insurance',
|
||||
feature: FeatureKey.SERVICES_CERTIFICATES_INSURANCE,
|
||||
title: 'Insurance',
|
||||
icon: 'shield-checkmark-outline',
|
||||
iconColor: '#8B5CF6',
|
||||
iconColor: theme.colors.category.violet.tone,
|
||||
onPress: () => navigation.navigate('CertificateList', { title: 'Insurance Copies', category: 'insurance' }),
|
||||
},
|
||||
{
|
||||
id: 'tax',
|
||||
feature: FeatureKey.SERVICES_CERTIFICATES_TAX,
|
||||
title: 'Tax Forms',
|
||||
icon: 'receipt-outline',
|
||||
iconColor: '#EC4899',
|
||||
iconColor: theme.colors.category.pink.tone,
|
||||
onPress: () => navigation.navigate('CertificateList', { title: 'Tax Statements', category: 'tax' }),
|
||||
},
|
||||
{
|
||||
id: 'loan',
|
||||
feature: FeatureKey.SERVICES_CERTIFICATES_LOAN,
|
||||
title: 'Loan Docs',
|
||||
icon: 'home-outline',
|
||||
iconColor: '#6366F1',
|
||||
iconColor: theme.colors.category.indigo.tone,
|
||||
onPress: () => navigation.navigate('CertificateList', { title: 'Loan Documents', category: 'loan' }),
|
||||
},
|
||||
],
|
||||
[navigation]
|
||||
);
|
||||
|
||||
const visibleOptions = useEnabledItems(options);
|
||||
|
||||
|
||||
return (
|
||||
<ScreenShell title="Certificates" subtitle="Secure document vault">
|
||||
|
||||
@@ -91,7 +110,7 @@ export default function CertificatesHubScreen({ navigation }: Props) {
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Select Category</Text>
|
||||
|
||||
<View style={styles.grid}>
|
||||
{options.map((opt) => (
|
||||
{visibleOptions.map((opt) => (
|
||||
<ServiceGridItem key={opt.id} {...opt} />
|
||||
))}
|
||||
</View>
|
||||
@@ -123,7 +142,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
heroSubtitle: {
|
||||
@@ -144,17 +163,17 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
heroStatLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
heroStatValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
marginTop: 8,
|
||||
marginLeft: 4,
|
||||
},
|
||||
|
||||
@@ -1,62 +1,286 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell, Card, Input, Button } from '../../../components';
|
||||
|
||||
import {
|
||||
AccountSelector,
|
||||
Button,
|
||||
Card,
|
||||
InfoBanner,
|
||||
ScreenShell,
|
||||
SegmentedControl,
|
||||
} from '../../../components';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAccountSelection } from '../../home/hooks/useAccounts';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { fontFamily, useTheme } from '../../../theme';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import {
|
||||
CHEQUE_BOOK_LEAF_COUNTS,
|
||||
type ChequeBookIssued,
|
||||
type ChequeBookLeafCount,
|
||||
} from '../../../types/cbs';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import {
|
||||
useGetChequeBookChargesQuery,
|
||||
useRequestChequeBookMutation,
|
||||
} from '../services/chequeBookApi';
|
||||
import { describeChequeBookError, isIssueOutcomeUnknown } from '../services/chequeBookErrors';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'ChequeBookRequest'>;
|
||||
|
||||
const LEAF_OPTIONS = CHEQUE_BOOK_LEAF_COUNTS.map((value) => ({
|
||||
value,
|
||||
label: `${value} leaves`,
|
||||
}));
|
||||
|
||||
/** Zero is a real price here — a 25-leaf book is free — so it gets its own word. */
|
||||
const formatCharge = (charge: number) => (charge === 0 ? 'No charge' : formatCurrency(charge));
|
||||
|
||||
export default function ChequeBookRequestScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const [account, setAccount] = useState('0000XXXX1234');
|
||||
const [leaves, setLeaves] = useState('25');
|
||||
const [address, setAddress] = useState('Registered Address');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const showApiError = useApiErrorToast();
|
||||
|
||||
const handleSubmit = () => {
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Request submitted', 'success', 'Your cheque book will arrive in 3 to 5 days.');
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
/**
|
||||
* The customer picks the account rather than typing its number, for the same
|
||||
* reason as Positive Pay: `GET /accounts` returns the full number, so the
|
||||
* typo class a free-text field invited cannot happen — and neither can the
|
||||
* ambiguous ACCOUNT_NOT_FOUND it used to produce.
|
||||
*
|
||||
* Operable accounts only, the hook's default. A deposit or a frozen account
|
||||
* belongs to the customer and is worth listing on an enquiry screen, but no
|
||||
* cheque book is drawn on one.
|
||||
*/
|
||||
const {
|
||||
accounts,
|
||||
selectedAccountNumber,
|
||||
isLoading: accountsLoading,
|
||||
setSelectedAccountNumber,
|
||||
} = useAccountSelection();
|
||||
|
||||
const [leafCount, setLeafCount] = useState<ChequeBookLeafCount>('25');
|
||||
const [issued, setIssued] = useState<ChequeBookIssued | null>(null);
|
||||
const [unconfirmed, setUnconfirmed] = useState<string | null>(null);
|
||||
|
||||
const accountNumber = selectedAccountNumber ?? '';
|
||||
|
||||
/**
|
||||
* The fee follows the selection rather than waiting for submit, so the
|
||||
* customer never commits to a number they have not seen. Skipped until an
|
||||
* account exists — the first render legitimately has none.
|
||||
*/
|
||||
const {
|
||||
data: charges,
|
||||
isFetching: chargesLoading,
|
||||
error: chargesError,
|
||||
} = useGetChequeBookChargesQuery(
|
||||
{ accountNumber, leafCount },
|
||||
{ skip: !accountNumber, refetchOnMountOrArgChange: true },
|
||||
);
|
||||
|
||||
const [requestChequeBook, { isLoading: isRequesting }] = useRequestChequeBookMutation();
|
||||
|
||||
const chargesApiError = chargesError as ApiError | undefined;
|
||||
|
||||
/**
|
||||
* Nothing is submitted against a fee we could not quote. The quote and the
|
||||
* issue read the same account and leaf count, so a failed quote means the
|
||||
* issue would fail too — and letting it through would put the customer on
|
||||
* the hook for a price nobody showed them.
|
||||
*/
|
||||
const canSubmit = Boolean(accountNumber) && !chargesLoading && !chargesApiError && !isRequesting;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setUnconfirmed(null);
|
||||
|
||||
if (!accountNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestChequeBook({ accountNumber, leafCount }).unwrap();
|
||||
|
||||
setIssued(result);
|
||||
toast('Cheque book issued', 'success', `Reference ${result.referenceId}`);
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
/**
|
||||
* A write whose outcome is unknown cannot be reported as a plain failure
|
||||
* and must not offer a retry — there is no server-side duplicate check
|
||||
* here, so a second attempt is a second book at a second fee. It stays
|
||||
* on screen as a banner rather than a toast the customer can miss.
|
||||
*/
|
||||
if (isIssueOutcomeUnknown(apiError?.code)) {
|
||||
const copy = describeChequeBookError(apiError?.code);
|
||||
setUnconfirmed(apiError?.message || copy.description);
|
||||
return;
|
||||
}
|
||||
|
||||
showApiError(apiError);
|
||||
}
|
||||
};
|
||||
|
||||
if (issued) {
|
||||
return (
|
||||
<ScreenShell title="Cheque book issued" subtitle="Keep the reference for your records">
|
||||
<Card variant="default" style={styles.card}>
|
||||
<View style={styles.referenceBlock}>
|
||||
<Text style={[styles.referenceLabel, { color: theme.colors.textSecondary }]}>
|
||||
Reference ID
|
||||
</Text>
|
||||
<Text style={[styles.referenceValue, { color: theme.colors.textPrimary }]} selectable>
|
||||
{issued.referenceId}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="These cheque numbers are yours alone and are never reissued. Check the range against the book when it arrives."
|
||||
/>
|
||||
|
||||
<Row label="Status" value={issued.status} />
|
||||
<Row label="Account" value={issued.accountNumberMasked} />
|
||||
<Row label="Leaves" value={String(issued.leafCount)} />
|
||||
<Row label="Charge" value={formatCharge(issued.charge)} />
|
||||
<Row
|
||||
label="Cheque numbers"
|
||||
value={`${issued.startingChequeNumber} – ${issued.endingChequeNumber}`}
|
||||
/>
|
||||
<Row label="Issued on" value={issued.issueDate} />
|
||||
|
||||
<Button title="Done" onPress={() => navigation.goBack()} style={styles.submitBtn} />
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell title="Cheque Book" subtitle="Request a new cheque book">
|
||||
<Card variant="default" style={styles.card}>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Select Account</Text>
|
||||
<Input value={account} onChangeText={setAccount} placeholder="Account Number" />
|
||||
{unconfirmed ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
title="We could not confirm the request"
|
||||
message={unconfirmed}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AccountSelector
|
||||
label="Cheque book for"
|
||||
accounts={accounts}
|
||||
selectedAccountNumber={selectedAccountNumber}
|
||||
onSelect={setSelectedAccountNumber}
|
||||
disabled={isRequesting || accountsLoading}
|
||||
/>
|
||||
|
||||
{/* Nothing below can be answered without an account, and an empty
|
||||
selector renders as nothing at all — so say why the form is bare
|
||||
rather than leaving the customer looking at a dead button. */}
|
||||
{!accountsLoading && !accounts.length ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
title="No eligible account"
|
||||
message="Cheque books are issued on current and savings accounts. None on this profile can take one."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
|
||||
Number of leaves
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
options={LEAF_OPTIONS}
|
||||
value={leafCount}
|
||||
onChange={setLeafCount}
|
||||
disabled={isRequesting}
|
||||
accessibilityLabel="Number of cheque leaves"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Number of Leaves</Text>
|
||||
<Input value={leaves} onChangeText={setLeaves} placeholder="e.g. 25, 50, 100" keyboardType="number-pad" />
|
||||
<View
|
||||
style={[
|
||||
styles.chargeRow,
|
||||
{ backgroundColor: theme.colors.surfaceMuted, borderRadius: theme.radius.md },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.chargeLabel, { color: theme.colors.textSecondary }]}>
|
||||
Issuance charge
|
||||
</Text>
|
||||
|
||||
{chargesLoading ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
) : chargesApiError ? (
|
||||
<Text style={[styles.chargeValue, { color: theme.colors.danger }]}>Unavailable</Text>
|
||||
) : (
|
||||
<Text style={[styles.chargeValue, { color: theme.colors.textPrimary }]}>
|
||||
{charges ? formatCharge(charges.charge) : '—'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Delivery Address</Text>
|
||||
<Input value={address} onChangeText={setAddress} placeholder="Branch or Registered Address" />
|
||||
</View>
|
||||
{chargesApiError ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
title={describeChequeBookError(chargesApiError.code).title}
|
||||
message={describeChequeBookError(chargesApiError.code).description}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
title="Submit Request"
|
||||
title={
|
||||
charges && !chargesLoading && !chargesApiError
|
||||
? `Request book · ${formatCharge(charges.charge)}`
|
||||
: 'Request book'
|
||||
}
|
||||
onPress={handleSubmit}
|
||||
loading={isLoading}
|
||||
loading={isRequesting}
|
||||
disabled={!canSubmit}
|
||||
style={styles.submitBtn}
|
||||
/>
|
||||
|
||||
{/* The fee is debited on issue and the book cannot be cancelled, so the
|
||||
commitment is stated next to the control that makes it. */}
|
||||
<Text style={[styles.footnote, { color: theme.colors.textSecondary }]}>
|
||||
The charge is debited from the selected account when the book is issued.
|
||||
</Text>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.rowLabel, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text style={[styles.rowValue, { color: theme.colors.textPrimary }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { padding: 16, gap: 16 },
|
||||
formGroup: { gap: 8 },
|
||||
label: { fontSize: 13, fontWeight: '600' },
|
||||
submitBtn: { marginTop: 16 },
|
||||
field: { gap: 10 },
|
||||
submitBtn: { marginTop: 8 },
|
||||
referenceBlock: { gap: 4 },
|
||||
referenceLabel: { fontSize: 13, fontFamily: fontFamily.semibold },
|
||||
referenceValue: { fontSize: 20, fontFamily: fontFamily.bold, letterSpacing: 0.5 },
|
||||
chargeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
minHeight: 52,
|
||||
},
|
||||
chargeLabel: { fontSize: 13, fontFamily: fontFamily.regular },
|
||||
chargeValue: { fontSize: 16, fontFamily: fontFamily.bold },
|
||||
footnote: { fontSize: 12, fontFamily: fontFamily.regular, textAlign: 'center' },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
|
||||
rowLabel: { fontSize: 13, fontFamily: fontFamily.regular },
|
||||
rowValue: { fontSize: 13, fontFamily: fontFamily.semibold, flexShrink: 1, textAlign: 'right' },
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { ServiceGridItem } from '../../home/components/ServiceGridItem';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { ChequeAnimation } from '../components/ChequeAnimation';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useEnabledItems } from '../../../features';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'ChequeServicesHub'>;
|
||||
|
||||
@@ -16,29 +18,49 @@ export default function ChequeServicesHubScreen({ navigation }: Props) {
|
||||
() => [
|
||||
{
|
||||
id: 'request',
|
||||
feature: FeatureKey.SERVICES_CHEQUE_BOOK_REQUEST,
|
||||
title: 'Book Request',
|
||||
icon: 'book-outline',
|
||||
iconColor: '#3B82F6',
|
||||
iconColor: theme.colors.info,
|
||||
onPress: () => navigation.navigate('ChequeBookRequest'),
|
||||
},
|
||||
{
|
||||
id: 'stop',
|
||||
feature: FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT,
|
||||
title: 'Stop Payment',
|
||||
icon: 'stop-circle-outline',
|
||||
iconColor: '#EF4444',
|
||||
iconColor: theme.colors.danger,
|
||||
onPress: () => navigation.navigate('StopPayment'),
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Its key has always been `services.cheque.positivePay` — a child of
|
||||
* this hub — but the only tile pointing at it lived on the
|
||||
* service-requests hub. That hub is off, which left the screen
|
||||
* reachable by global search alone. This is where it belongs.
|
||||
*/
|
||||
id: 'positive_pay',
|
||||
feature: FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY,
|
||||
title: 'Positive Pay',
|
||||
icon: 'shield-checkmark-outline',
|
||||
iconColor: theme.colors.primary,
|
||||
onPress: () => navigation.navigate('PositivePay'),
|
||||
},
|
||||
{
|
||||
id: 'track',
|
||||
feature: FeatureKey.SERVICES_CHEQUE_TRACKING,
|
||||
title: 'Track Cheques',
|
||||
icon: 'git-compare-outline',
|
||||
iconColor: '#10B981',
|
||||
iconColor: theme.colors.success,
|
||||
onPress: () => navigation.navigate('ChequeTracking'),
|
||||
},
|
||||
],
|
||||
[navigation]
|
||||
[navigation, theme]
|
||||
);
|
||||
|
||||
const visibleOptions = useEnabledItems(options);
|
||||
|
||||
|
||||
return (
|
||||
<ScreenShell title="Cheque Services" subtitle="Manage all your cheque-related needs">
|
||||
{/* Animated cheque illustration */}
|
||||
@@ -48,13 +70,13 @@ export default function ChequeServicesHubScreen({ navigation }: Props) {
|
||||
<View style={[styles.infoStrip, { backgroundColor: `${theme.colors.primary}08`, borderColor: `${theme.colors.primary}12` }]}>
|
||||
<View style={[styles.infoDot, { backgroundColor: theme.colors.accent }]} />
|
||||
<Text style={[styles.infoText, { color: theme.colors.textSecondary }]}>
|
||||
Request, track, or manage cheques — all in one place
|
||||
Request, track, register, or stop cheques — all in one place
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Service grid */}
|
||||
<View style={styles.grid}>
|
||||
{options.map((opt) => (
|
||||
{visibleOptions.map((opt) => (
|
||||
<ServiceGridItem key={opt.id} {...opt} />
|
||||
))}
|
||||
</View>
|
||||
@@ -86,7 +108,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
flex: 1,
|
||||
lineHeight: 17,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, StyleSheet, Text, FlatList } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell, Card } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'ChequeTracking'>;
|
||||
|
||||
@@ -16,7 +16,10 @@ export default function ChequeTrackingScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<ScreenShell title="Track Cheques" subtitle="Cheque books and individual cheques">
|
||||
<ScreenShell
|
||||
// The body is a FlatList, so it owns the scrolling.
|
||||
scrollable={false}
|
||||
title="Track Cheques" subtitle="Cheque books and individual cheques">
|
||||
<FlatList
|
||||
data={MOCK_TRACKING}
|
||||
keyExtractor={item => item.id}
|
||||
@@ -24,7 +27,7 @@ export default function ChequeTrackingScreen({ navigation }: Props) {
|
||||
renderItem={({ item }) => (
|
||||
<Card style={{ padding: 16, gap: 12 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text style={{ fontWeight: '700', fontSize: 16, color: theme.colors.textPrimary }}>{item.title}</Text>
|
||||
<Text style={{ fontFamily: fontFamily.bold, fontSize: 16, color: theme.colors.textPrimary }}>{item.title}</Text>
|
||||
<Text style={{ fontSize: 13, color: theme.colors.textSecondary }}>{item.date}</Text>
|
||||
</View>
|
||||
<View style={{ height: 1, backgroundColor: `${theme.colors.cardBorder}40`, marginVertical: 4 }} />
|
||||
@@ -32,7 +35,7 @@ export default function ChequeTrackingScreen({ navigation }: Props) {
|
||||
<View key={idx} style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
||||
<View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: stage.active ? theme.colors.primary : theme.colors.cardBorder }} />
|
||||
<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: stage.active ? theme.colors.primary : theme.colors.textSecondary, fontWeight: stage.active ? '700' : '500' }}>{stage.stage}</Text>
|
||||
<Text style={{ color: stage.active ? theme.colors.primary : theme.colors.textSecondary, fontFamily: stage.active ? fontFamily.bold : fontFamily.medium }}>{stage.stage}</Text>
|
||||
<Text style={{ fontSize: 12, color: theme.colors.textMuted }}>{stage.time}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import React, { 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, Input, ScreenShell } from '../../../components';
|
||||
import { useAccountSelection } from '../../home/hooks/useAccounts';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import { lookupChequeFate, type ChequeFate } from '../utils/chequeFate';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<ServicesStackParamList, 'FateOfCheque'>>;
|
||||
|
||||
const CHEQUE_NUMBER_LENGTH = 6;
|
||||
|
||||
/**
|
||||
* "What happened to my cheque?" — the single most common branch enquiry.
|
||||
*
|
||||
* A cheque number is only unique within an account, so the account is part of
|
||||
* the question rather than something to infer: two of the customer's accounts
|
||||
* can each hold a leaf numbered 123456, and answering for the wrong one is
|
||||
* worse than not answering.
|
||||
*/
|
||||
export default function FateOfChequeScreen(_props: Props) {
|
||||
const theme = useTheme();
|
||||
// Read from the bank on mount; nothing about the account list is cached.
|
||||
const { accounts, selectedAccountNumber, setSelectedAccountNumber } = useAccountSelection();
|
||||
|
||||
const [chequeNumber, setChequeNumber] = useState('');
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [result, setResult] = useState<ChequeFate | null>(null);
|
||||
|
||||
const numberError = useMemo(() => {
|
||||
if (chequeNumber.length === 0 || chequeNumber.length === CHEQUE_NUMBER_LENGTH) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `A cheque number is ${CHEQUE_NUMBER_LENGTH} digits`;
|
||||
}, [chequeNumber]);
|
||||
|
||||
const canSubmit =
|
||||
Boolean(selectedAccountNumber) && chequeNumber.length === CHEQUE_NUMBER_LENGTH && !checking;
|
||||
|
||||
const handleNumberChange = (value: string) => {
|
||||
// Retyping is the customer asking a new question, so the previous answer
|
||||
// comes down rather than sitting under a number it no longer describes.
|
||||
setResult(null);
|
||||
setChequeNumber(value.replace(/\D/g, '').slice(0, CHEQUE_NUMBER_LENGTH));
|
||||
};
|
||||
|
||||
const handleAccountChange = (nextAccountNumber: string) => {
|
||||
setResult(null);
|
||||
setSelectedAccountNumber(nextAccountNumber);
|
||||
};
|
||||
|
||||
const handleCheck = async () => {
|
||||
if (!canSubmit || !selectedAccountNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
|
||||
try {
|
||||
setResult(
|
||||
await lookupChequeFate({ accountNumber: selectedAccountNumber, chequeNumber }),
|
||||
);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tone = ((): { color: string; surface: string; icon: string } => {
|
||||
switch (result?.status) {
|
||||
case 'paid':
|
||||
return {
|
||||
color: theme.colors.success,
|
||||
surface: `${theme.colors.success}14`,
|
||||
icon: 'checkmark-circle',
|
||||
};
|
||||
case 'returned':
|
||||
case 'stopped':
|
||||
return {
|
||||
color: theme.colors.danger,
|
||||
surface: `${theme.colors.danger}14`,
|
||||
icon: 'close-circle',
|
||||
};
|
||||
case 'in_clearing':
|
||||
return {
|
||||
color: theme.colors.info,
|
||||
surface: `${theme.colors.info}14`,
|
||||
icon: 'time',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: theme.colors.textSecondary,
|
||||
surface: theme.colors.surfaceMuted,
|
||||
icon: 'help-circle',
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Fate of cheque"
|
||||
subtitle="Check where a cheque got to"
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<Card style={styles.formCard}>
|
||||
<AccountSelector
|
||||
label="Drawn on account"
|
||||
accounts={accounts}
|
||||
selectedAccountNumber={selectedAccountNumber}
|
||||
onSelect={handleAccountChange}
|
||||
disabled={checking}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Cheque number"
|
||||
placeholder={`${CHEQUE_NUMBER_LENGTH}-digit number on the cheque leaf`}
|
||||
keyboardType="number-pad"
|
||||
maxLength={CHEQUE_NUMBER_LENGTH}
|
||||
value={chequeNumber}
|
||||
onChangeText={handleNumberChange}
|
||||
error={numberError}
|
||||
editable={!checking}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Check status"
|
||||
onPress={handleCheck}
|
||||
disabled={!canSubmit}
|
||||
loading={checking}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{result ? (
|
||||
<Card style={styles.resultCard}>
|
||||
<View style={[styles.resultHeader, { backgroundColor: tone.surface }]}>
|
||||
<Ionicons name={tone.icon as never} size={26} color={tone.color} />
|
||||
<View style={styles.resultHeaderCopy}>
|
||||
<Text style={[styles.resultStatus, { color: tone.color }]}>{result.label}</Text>
|
||||
<Text style={[styles.resultNumber, { color: theme.colors.textSecondary }]}>
|
||||
{`Cheque ${result.chequeNumber}`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.resultRows}>
|
||||
{result.amount !== undefined ? (
|
||||
<ResultRow label="Amount" value={formatCurrency(result.amount)} />
|
||||
) : null}
|
||||
{result.presentedOn ? (
|
||||
<ResultRow label="Presented on" value={result.presentedOn} />
|
||||
) : null}
|
||||
{result.clearedOn ? <ResultRow label="Cleared on" value={result.clearedOn} /> : null}
|
||||
{result.payee ? <ResultRow label="Payee" value={result.payee} /> : null}
|
||||
{result.reason ? <ResultRow label="Reason" value={result.reason} /> : null}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.resultNote, { color: theme.colors.textMuted }]}>
|
||||
{result.note}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<View style={styles.hintRow}>
|
||||
<Ionicons
|
||||
name="information-circle-outline"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
style={styles.hintIcon}
|
||||
/>
|
||||
<Text style={[styles.hintText, { color: theme.colors.primary }]}>
|
||||
The cheque number is the six-digit number printed at the bottom-left of the cheque
|
||||
leaf. Status is shown as recorded by the clearing house.
|
||||
</Text>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const ResultRow = ({ label, value }: { label: string; value: string }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.resultRow}>
|
||||
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text style={[styles.resultValue, { color: theme.colors.textPrimary }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
formCard: {
|
||||
padding: 16,
|
||||
gap: 18,
|
||||
},
|
||||
resultCard: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
resultHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
},
|
||||
resultHeaderCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
resultStatus: {
|
||||
fontSize: 17,
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
resultNumber: {
|
||||
fontSize: 13,
|
||||
},
|
||||
resultRows: {
|
||||
gap: 12,
|
||||
},
|
||||
resultRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
},
|
||||
resultLabel: {
|
||||
fontSize: 14,
|
||||
},
|
||||
resultValue: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontFamily: fontFamily.bold,
|
||||
textAlign: 'right',
|
||||
},
|
||||
resultNote: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
hintRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
hintIcon: {
|
||||
marginTop: 1,
|
||||
},
|
||||
hintText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell, Card, Input, Button } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'Form15G15H'>;
|
||||
|
||||
@@ -72,10 +72,10 @@ export default function Form15G15HScreen({ navigation }: Props) {
|
||||
</Card>
|
||||
|
||||
<View style={{ marginTop: 24, gap: 16 }}>
|
||||
<Text style={{ fontWeight: '700', fontSize: 16, color: theme.colors.textPrimary, marginLeft: 4 }}>History</Text>
|
||||
<Text style={{ fontFamily: fontFamily.bold, fontSize: 16, color: theme.colors.textPrimary, marginLeft: 4 }}>History</Text>
|
||||
<Card style={{ padding: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<View>
|
||||
<Text style={{ fontWeight: '700', color: theme.colors.textPrimary }}>Form 15G - FY 24-25</Text>
|
||||
<Text style={{ fontFamily: fontFamily.bold, color: theme.colors.textPrimary }}>Form 15G - FY 24-25</Text>
|
||||
<Text style={{ fontSize: 12, color: theme.colors.textSecondary }}>Submitted April 2025</Text>
|
||||
</View>
|
||||
<Button title="Download" variant="ghost" onPress={() => toast('Download started', 'info', 'Your form is being prepared for download.')} />
|
||||
@@ -88,6 +88,6 @@ export default function Form15G15HScreen({ navigation }: Props) {
|
||||
const styles = StyleSheet.create({
|
||||
card: { padding: 16, gap: 16 },
|
||||
formGroup: { gap: 8 },
|
||||
label: { fontSize: 13, fontWeight: '600' },
|
||||
label: { fontSize: 13, fontFamily: fontFamily.semibold },
|
||||
submitBtn: { marginTop: 8 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, View, Pressable, ScrollView } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { ScreenShell, Card, Input, Button } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { useAccounts } from "../../home/hooks/useAccounts";
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'Nomination'>;
|
||||
|
||||
const RELATIONSHIPS = ['Spouse', 'Son', 'Daughter', 'Father', 'Mother', 'Other'];
|
||||
|
||||
export default function NominationScreen({ route, navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
// Nomination is filed against an operative account.
|
||||
const { operableAccounts: accounts } = useAccounts();
|
||||
|
||||
const [selectedAccountId, setSelectedAccountId] = useState(
|
||||
route.params?.accountNumber ?? accounts[0]?.accountNumber,
|
||||
);
|
||||
const [nomineeName, setNomineeName] = useState('');
|
||||
const [relationship, setRelationship] = useState('Spouse');
|
||||
const [dob, setDob] = useState('');
|
||||
const [share, setShare] = useState('100');
|
||||
const [address, setAddress] = useState('');
|
||||
const [isMinor, setIsMinor] = useState(false);
|
||||
const [guardianName, setGuardianName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!nomineeName.trim() || !dob.trim() || !address.trim()) {
|
||||
toast('Some details are missing', 'error', 'Please enter the nominee name, date of birth and address.');
|
||||
return;
|
||||
}
|
||||
if (isMinor && !guardianName.trim()) {
|
||||
toast('Guardian required', 'error', 'Please enter the guardian name for a minor nominee.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Nomination submitted', 'success', `${nomineeName} has been registered as your nominee.`);
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Online Nomination" subtitle="Register or update your account nominee" contentContainerStyle={styles.content}>
|
||||
{/* Account selector */}
|
||||
<Card style={styles.card}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Nominate for account</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.selector}>
|
||||
{accounts.map((acc) => {
|
||||
const isSelected = acc.accountNumber === selectedAccountId;
|
||||
return (
|
||||
<Pressable
|
||||
key={acc.accountNumber}
|
||||
onPress={() => setSelectedAccountId(acc.accountNumber)}
|
||||
style={[
|
||||
styles.accountChip,
|
||||
{
|
||||
backgroundColor: isSelected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: isSelected ? theme.colors.primary : 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.chipType, { color: isSelected ? theme.colors.primary : theme.colors.textPrimary }]}>{acc.accountType}</Text>
|
||||
<Text style={[styles.chipMask, { color: theme.colors.textSecondary }]}>{('XXXX ' + acc.accountNumber.slice(-4))}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Card>
|
||||
|
||||
{/* Nominee details */}
|
||||
<Card style={styles.card}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Nominee details</Text>
|
||||
|
||||
<Input label="Nominee full name" value={nomineeName} onChangeText={setNomineeName} placeholder="As per ID proof" autoCapitalize="words" />
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Relationship</Text>
|
||||
<View style={styles.chipWrap}>
|
||||
{RELATIONSHIPS.map((rel) => {
|
||||
const isSelected = rel === relationship;
|
||||
return (
|
||||
<Pressable
|
||||
key={rel}
|
||||
onPress={() => setRelationship(rel)}
|
||||
style={[
|
||||
styles.relChip,
|
||||
{
|
||||
backgroundColor: isSelected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: isSelected ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.relChipText, { color: isSelected ? theme.colors.primary : theme.colors.textPrimary }]}>{rel}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Input label="Date of birth" value={dob} onChangeText={setDob} placeholder="DD/MM/YYYY" keyboardType="number-pad" />
|
||||
<Input label="Share of entitlement (%)" value={share} onChangeText={setShare} placeholder="100" keyboardType="number-pad" />
|
||||
<Input label="Nominee address" value={address} onChangeText={setAddress} placeholder="Flat, street, area, city" />
|
||||
</Card>
|
||||
|
||||
{/* Minor toggle */}
|
||||
<Card style={styles.card}>
|
||||
<Pressable onPress={() => setIsMinor((value) => !value)} style={styles.minorRow}>
|
||||
<Ionicons
|
||||
name={isMinor ? 'checkmark-circle' : 'ellipse-outline'}
|
||||
size={22}
|
||||
color={isMinor ? theme.colors.success : theme.colors.textMuted}
|
||||
/>
|
||||
<Text style={[styles.minorText, { color: theme.colors.textPrimary }]}>The nominee is a minor (below 18 years)</Text>
|
||||
</Pressable>
|
||||
{isMinor && (
|
||||
<Input label="Guardian name" value={guardianName} onChangeText={setGuardianName} placeholder="Guardian full name" autoCapitalize="words" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<View style={[styles.notice, { backgroundColor: `${theme.colors.info}12` }]}>
|
||||
<Text style={{ color: theme.colors.info, fontSize: 13, lineHeight: 18 }}>
|
||||
Only one nominee can be registered per deposit account. Submitting this form replaces any existing nomination.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button title="Submit nomination" onPress={handleSubmit} loading={isLoading} leftAccessory={<Ionicons name="person-add-outline" size={18} color={theme.colors.white} />} />
|
||||
<View style={{ height: 40 }} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { gap: 16 },
|
||||
card: { padding: 18, gap: 14 },
|
||||
sectionTitle: { fontSize: 16, fontFamily: fontFamily.bold },
|
||||
label: { fontSize: 13, fontFamily: fontFamily.semibold },
|
||||
formGroup: { gap: 8 },
|
||||
selector: { gap: 10, paddingRight: 12 },
|
||||
accountChip: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1.5, gap: 2, minWidth: 120 },
|
||||
chipType: { fontSize: 12, fontFamily: fontFamily.bold, textTransform: 'uppercase' },
|
||||
chipMask: { fontSize: 12, fontFamily: fontFamily.medium },
|
||||
chipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
relChip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, borderWidth: 1 },
|
||||
relChipText: { fontSize: 13, fontFamily: fontFamily.bold },
|
||||
minorRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||
minorText: { flex: 1, fontSize: 13, fontFamily: fontFamily.semibold },
|
||||
notice: { padding: 12, borderRadius: 10 },
|
||||
});
|
||||
@@ -1,68 +1,376 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell, Card, Input, Button } from '../../../components';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
InfoBanner,
|
||||
Input,
|
||||
ScreenShell,
|
||||
} from '../../../components';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { AccountSelector } from '../../../components';
|
||||
import { useAccountSelection } from '../../home/hooks/useAccounts';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { fontFamily, useTheme } from '../../../theme';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import type { PositivePayResult } from '../../../types/cbs';
|
||||
import { formatCurrency } from '../../../utils/formatters';
|
||||
import {
|
||||
useRegisterPositivePayMutation,
|
||||
useVerifyChequeAvailableMutation,
|
||||
} from '../services/positivePayApi';
|
||||
import {
|
||||
describePositivePayError,
|
||||
isRegistrationOutcomeUnknown,
|
||||
} from '../services/positivePayErrors';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'PositivePay'>;
|
||||
|
||||
/** A cheque stays valid for three months, and post-dating one is ordinary. */
|
||||
const LATEST_CHEQUE_DATE = () => {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
return d;
|
||||
};
|
||||
|
||||
/** `DD/MM/YYYY` from the picker to the `yyyy-MM-dd` the API takes. */
|
||||
const toIsoDate = (ddmmyyyy: string): string | null => {
|
||||
const [dd, mm, yyyy] = ddmmyyyy.split('/');
|
||||
if (!dd || !mm || !yyyy) {
|
||||
return null;
|
||||
}
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
};
|
||||
|
||||
const digitsOnly = (value: string) => value.replace(/\D/g, '');
|
||||
|
||||
type FormErrors = Record<string, string>;
|
||||
|
||||
export default function PositivePayScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const [account, setAccount] = useState('0000XXXX1234');
|
||||
const showApiError = useApiErrorToast();
|
||||
|
||||
/**
|
||||
* The customer picks the account rather than typing its number.
|
||||
*
|
||||
* This used to be a free-text field, because at the time no endpoint returned
|
||||
* an unmasked account number and the screen could only check the last four
|
||||
* digits of what was typed. `GET /accounts` returns the full number, so the
|
||||
* typo class this screen worked hardest to catch cannot happen now — and
|
||||
* neither can the ambiguous ACCOUNT_NOT_FOUND a typo used to produce, since
|
||||
* every number on offer is one the bank just handed us.
|
||||
*/
|
||||
const {
|
||||
accounts,
|
||||
selectedAccountNumber,
|
||||
setSelectedAccountNumber,
|
||||
isLoading: accountsLoading,
|
||||
} = useAccountSelection();
|
||||
const [registerCheque, { isLoading: isRegistering }] = useRegisterPositivePayMutation();
|
||||
const [checkAvailable, { isLoading: isChecking }] = useVerifyChequeAvailableMutation();
|
||||
|
||||
// One flag for the form, because both calls belong to the same submit and
|
||||
// neither should leave a second one startable underneath it.
|
||||
const isLoading = isRegistering || isChecking;
|
||||
|
||||
const accountNumber = selectedAccountNumber ?? '';
|
||||
const [chequeNumber, setChequeNumber] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [payee, setPayee] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [payeeName, setPayeeName] = useState('');
|
||||
const [chequeDate, setChequeDate] = useState('');
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [registered, setRegistered] = useState<PositivePayResult | null>(null);
|
||||
const [unconfirmed, setUnconfirmed] = useState<string | null>(null);
|
||||
/**
|
||||
* The `account|cheque` pair the bank has already told us is free, so the
|
||||
* check is not repeated against values nothing has touched since. Cleared
|
||||
* whenever either half changes, because a pair that was clear is no evidence
|
||||
* about a different one.
|
||||
*/
|
||||
const [clearedPair, setClearedPair] = useState<string | null>(null);
|
||||
const [alreadyRegistered, setAlreadyRegistered] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!chequeNumber || !amount || !payee) {
|
||||
toast('Some details are missing', 'error', 'Please fill in all the required details to continue.');
|
||||
const pairKey = `${accountNumber}|${chequeNumber}`;
|
||||
|
||||
const validate = (): FormErrors => {
|
||||
const next: FormErrors = {};
|
||||
|
||||
// Still checked, because the value reaches the API either way — but this
|
||||
// now guards against no account being selected, not against a typo.
|
||||
if (!/^\d{5,20}$/.test(accountNumber)) {
|
||||
next.accountNumber = 'Choose the account the cheque is drawn on.';
|
||||
}
|
||||
|
||||
if (!/^\d{6}$/.test(chequeNumber)) {
|
||||
next.chequeNumber = 'The cheque number is exactly 6 digits, from the MICR band.';
|
||||
}
|
||||
|
||||
if (!chequeDate) {
|
||||
next.chequeDate = 'Enter the date written on the cheque.';
|
||||
} else if (!toIsoDate(chequeDate)) {
|
||||
next.chequeDate = 'Pick the date again.';
|
||||
}
|
||||
|
||||
const parsedAmount = Number(amount);
|
||||
if (!amount || Number.isNaN(parsedAmount)) {
|
||||
next.amount = 'Enter the amount written on the cheque.';
|
||||
} else if (parsedAmount < 1) {
|
||||
next.amount = 'The amount must be at least 1.00.';
|
||||
} else if (!/^\d+(\.\d{1,2})?$/.test(amount)) {
|
||||
next.amount = 'Up to two decimal places.';
|
||||
}
|
||||
|
||||
const trimmedPayee = payeeName.trim();
|
||||
if (!trimmedPayee) {
|
||||
next.payeeName = 'Enter the payee exactly as written on the cheque.';
|
||||
} else if (trimmedPayee.length > 100) {
|
||||
next.payeeName = 'Up to 100 characters.';
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Asks the bank whether this cheque is already on file.
|
||||
*
|
||||
* Returns true when it is free to register. `CHEQUE_ALREADY_REGISTERED` is
|
||||
* the answer this exists to catch early — the registration would eventually
|
||||
* give the same code, but only after the customer had typed an amount, a
|
||||
* date and a payee name for nothing. Every other failure is left for the
|
||||
* caller, which is the only place that knows whether it is worth a toast.
|
||||
*/
|
||||
const confirmAvailable = async (): Promise<boolean> => {
|
||||
if (clearedPair === pairKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await checkAvailable({ accountNumber, chequeNumber }).unwrap();
|
||||
|
||||
setClearedPair(pairKey);
|
||||
setAlreadyRegistered(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
if (apiError?.code === 'CHEQUE_ALREADY_REGISTERED') {
|
||||
setAlreadyRegistered(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the availability check once the cheque number is complete, rather
|
||||
* than waiting for the whole form — which is what this endpoint is for.
|
||||
* Anything other than "already registered" stays quiet here: the customer is
|
||||
* mid-form, the registration will surface it anyway, and a toast about an
|
||||
* unreachable bank while they are still typing is noise.
|
||||
*/
|
||||
const handleChequeNumberBlur = async () => {
|
||||
if (!/^\d{6}$/.test(chequeNumber) || !/^\d{5,20}$/.test(accountNumber)) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Cheque details submitted', 'success', `Your details for cheque #${chequeNumber} have been saved securely.`);
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
|
||||
try {
|
||||
await confirmAvailable();
|
||||
} catch {
|
||||
// Deliberately swallowed. See above.
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setUnconfirmed(null);
|
||||
|
||||
const found = validate();
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isoDate = toIsoDate(chequeDate);
|
||||
if (!isoDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Checked against the exact pair being submitted, not against whatever
|
||||
// was in the fields when they were last blurred.
|
||||
if (!(await confirmAvailable())) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// The check itself is a read, so an unanswered one is unambiguous and
|
||||
// safe to report plainly — unlike the registration below.
|
||||
showApiError(error as ApiError | undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await registerCheque({
|
||||
accountNumber,
|
||||
chequeNumber,
|
||||
chequeDate: isoDate,
|
||||
amount: Number(amount),
|
||||
payeeName: payeeName.trim(),
|
||||
}).unwrap();
|
||||
|
||||
setRegistered(result);
|
||||
toast('Cheque registered', 'success', `Reference ${result.referenceId}`);
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
if (apiError?.fieldErrors) {
|
||||
setErrors(apiError.fieldErrors);
|
||||
}
|
||||
|
||||
// A write whose outcome is unknown cannot be reported as a plain failure
|
||||
// and must not offer a retry — the cheque may already be on file. It
|
||||
// stays on screen as a banner rather than a toast the customer can miss.
|
||||
if (isRegistrationOutcomeUnknown(apiError?.code)) {
|
||||
const copy = describePositivePayError(apiError?.code);
|
||||
setUnconfirmed(apiError?.message || copy.description);
|
||||
return;
|
||||
}
|
||||
|
||||
showApiError(apiError);
|
||||
}
|
||||
};
|
||||
|
||||
if (registered) {
|
||||
return (
|
||||
<ScreenShell title="Cheque registered" subtitle="Keep the reference for your records">
|
||||
<Card variant="default" style={styles.card}>
|
||||
<View style={styles.referenceBlock}>
|
||||
<Text style={[styles.referenceLabel, { color: theme.colors.textSecondary }]}>
|
||||
Reference ID
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.referenceValue, { color: theme.colors.textPrimary }]}
|
||||
selectable
|
||||
>
|
||||
{registered.referenceId}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="Your bank holds this record. The app does not keep a copy, so save the reference if you may need to quote it."
|
||||
/>
|
||||
|
||||
<Row label="Status" value={registered.status} />
|
||||
<Row label="Account" value={registered.accountNumberMasked} />
|
||||
<Row label="Cheque number" value={registered.chequeNumber} />
|
||||
<Row label="Cheque date" value={registered.chequeDate} />
|
||||
<Row label="Amount" value={formatCurrency(registered.amount)} />
|
||||
<Row label="Payee" value={registered.payeeName} />
|
||||
|
||||
<Button title="Done" onPress={() => navigation.goBack()} style={styles.submitBtn} />
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell title="Positive Pay System" subtitle="Secure your high-value cheques">
|
||||
<Card variant="default" style={styles.card}>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Select Account</Text>
|
||||
<Input value={account} onChangeText={setAccount} placeholder="Account Number" />
|
||||
</View>
|
||||
{unconfirmed ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
title="We could not confirm the registration"
|
||||
message={unconfirmed}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Cheque Number</Text>
|
||||
<Input value={chequeNumber} onChangeText={setChequeNumber} placeholder="6-digit unique number" keyboardType="number-pad" />
|
||||
</View>
|
||||
{alreadyRegistered ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
title="This cheque is already registered"
|
||||
message="Your bank already holds these details, so there is nothing more to do. Check the number if you meant a different cheque."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Amount</Text>
|
||||
<Input value={amount} onChangeText={setAmount} placeholder="e.g. 50000" keyboardType="numeric" />
|
||||
</View>
|
||||
<AccountSelector
|
||||
label="Drawn on account"
|
||||
accounts={accounts}
|
||||
selectedAccountNumber={selectedAccountNumber}
|
||||
onSelect={(next) => {
|
||||
setSelectedAccountNumber(next);
|
||||
// A clearance is about one account and one cheque number. Changing
|
||||
// either makes it evidence about a pair nobody asked about.
|
||||
setClearedPair(null);
|
||||
setAlreadyRegistered(false);
|
||||
}}
|
||||
disabled={isLoading || accountsLoading}
|
||||
/>
|
||||
{errors.accountNumber ? (
|
||||
<Text style={[styles.fieldError, { color: theme.colors.danger }]}>
|
||||
{errors.accountNumber}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Payee Name</Text>
|
||||
<Input value={payee} onChangeText={setPayee} placeholder="Exact name on cheque" />
|
||||
</View>
|
||||
<Input
|
||||
label="Cheque number"
|
||||
required
|
||||
value={chequeNumber}
|
||||
onChangeText={(text) => {
|
||||
setChequeNumber(digitsOnly(text));
|
||||
setClearedPair(null);
|
||||
setAlreadyRegistered(false);
|
||||
}}
|
||||
// Checked as soon as it is complete, so a cheque the bank already
|
||||
// holds is caught before the rest of the form is filled in.
|
||||
onBlur={() => void handleChequeNumberBlur()}
|
||||
placeholder="6 digits"
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
error={errors.chequeNumber}
|
||||
hint="The six digits printed in the MICR band."
|
||||
/>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Cheque Date</Text>
|
||||
<Input value={date} onChangeText={setDate} placeholder="DD/MM/YYYY" />
|
||||
</View>
|
||||
<Input
|
||||
label="Amount"
|
||||
required
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
placeholder="e.g. 42500.00"
|
||||
keyboardType="decimal-pad"
|
||||
error={errors.amount}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Payee name"
|
||||
required
|
||||
value={payeeName}
|
||||
onChangeText={setPayeeName}
|
||||
placeholder="Exactly as written on the cheque"
|
||||
maxLength={100}
|
||||
error={errors.payeeName}
|
||||
/>
|
||||
|
||||
{/* The picker's own default caps selection at today, which would rule
|
||||
out every post-dated cheque — the ordinary case here. */}
|
||||
<DatePicker
|
||||
label="Cheque date"
|
||||
value={chequeDate}
|
||||
onDateChange={setChequeDate}
|
||||
maxDate={LATEST_CHEQUE_DATE()}
|
||||
error={errors.chequeDate}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Submit Details"
|
||||
title="Register cheque"
|
||||
onPress={handleSubmit}
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
style={styles.submitBtn}
|
||||
/>
|
||||
</Card>
|
||||
@@ -70,9 +378,25 @@ export default function PositivePayScreen({ navigation }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.rowLabel, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text style={[styles.rowValue, { color: theme.colors.textPrimary }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { padding: 16, gap: 16 },
|
||||
formGroup: { gap: 8 },
|
||||
label: { fontSize: 13, fontWeight: '600' },
|
||||
submitBtn: { marginTop: 16 },
|
||||
submitBtn: { marginTop: 8 },
|
||||
referenceBlock: { gap: 4 },
|
||||
referenceLabel: { fontSize: 13, fontFamily: fontFamily.semibold },
|
||||
referenceValue: { fontSize: 20, fontFamily: fontFamily.bold, letterSpacing: 0.5 },
|
||||
fieldError: { fontSize: 13, fontFamily: fontFamily.regular },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
|
||||
rowLabel: { fontSize: 13, fontFamily: fontFamily.regular },
|
||||
rowValue: { fontSize: 13, fontFamily: fontFamily.semibold, flexShrink: 1, textAlign: 'right' },
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { ServiceGridItem } from '../../home/components/ServiceGridItem';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { ServiceRequestAnimation } from '../components/ServiceRequestAnimation';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useEnabledItems } from '../../../features';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'ServiceRequestsHub'>;
|
||||
|
||||
@@ -16,6 +18,7 @@ export default function ServiceRequestsHubScreen({ navigation }: Props) {
|
||||
() => [
|
||||
{
|
||||
id: 'positive_pay',
|
||||
feature: FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY,
|
||||
title: 'Positive Pay',
|
||||
icon: 'lock-closed-outline',
|
||||
iconColor: '#F43F5E',
|
||||
@@ -23,22 +26,35 @@ export default function ServiceRequestsHubScreen({ navigation }: Props) {
|
||||
},
|
||||
{
|
||||
id: 'form15g',
|
||||
feature: FeatureKey.SERVICES_FORM_15G15H,
|
||||
title: 'Form 15G/15H',
|
||||
icon: 'document-attach-outline',
|
||||
iconColor: '#10B981',
|
||||
iconColor: theme.colors.success,
|
||||
onPress: () => navigation.navigate('Form15G15H'),
|
||||
},
|
||||
{
|
||||
id: 'special',
|
||||
title: 'Special Instr.',
|
||||
icon: 'create-outline',
|
||||
iconColor: '#6366F1',
|
||||
onPress: () => navigation.navigate('SpecialInstructions'),
|
||||
id: 'standing',
|
||||
feature: FeatureKey.SERVICES_STANDING_INSTRUCTIONS,
|
||||
title: 'Standing Instr.',
|
||||
icon: 'repeat-outline',
|
||||
iconColor: theme.colors.category.indigo.tone,
|
||||
onPress: () => navigation.navigate('StandingInstructions'),
|
||||
},
|
||||
{
|
||||
id: 'nomination',
|
||||
feature: FeatureKey.SERVICES_NOMINATION,
|
||||
title: 'Nomination',
|
||||
icon: 'person-add-outline',
|
||||
iconColor: theme.colors.category.sky.tone,
|
||||
onPress: () => navigation.navigate('Nomination'),
|
||||
},
|
||||
],
|
||||
[navigation]
|
||||
);
|
||||
|
||||
const visibleOptions = useEnabledItems(options);
|
||||
|
||||
|
||||
return (
|
||||
<ScreenShell title="Service Requests" subtitle="Submit and track requests">
|
||||
{/* Animated illustration */}
|
||||
@@ -54,7 +70,7 @@ export default function ServiceRequestsHubScreen({ navigation }: Props) {
|
||||
|
||||
{/* Service grid */}
|
||||
<View style={styles.grid}>
|
||||
{options.map((opt) => (
|
||||
{visibleOptions.map((opt) => (
|
||||
<ServiceGridItem key={opt.id} {...opt} />
|
||||
))}
|
||||
</View>
|
||||
@@ -86,7 +102,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
flex: 1,
|
||||
lineHeight: 17,
|
||||
},
|
||||
|
||||
@@ -7,12 +7,15 @@ import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { Card, ScreenShell } from '../../../components';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useEnabledItems } from '../../../features';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { ServiceGridItem } from '../../home/components/ServiceGridItem';
|
||||
import { ServiceStatusPill } from '../components/ServiceStatusPill';
|
||||
import { selectServiceRequests } from '../store/serviceSelectors';
|
||||
import { useAccounts } from '../../home/hooks/useAccounts';
|
||||
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<ServicesStackParamList, 'ServicesHub'>,
|
||||
@@ -23,61 +26,104 @@ export default function ServicesHubScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const serviceRequests = useAppSelector(selectServiceRequests);
|
||||
// The mini-statement screen is account-scoped, so the tile has to name one.
|
||||
// Read from the bank rather than from a cached list.
|
||||
// The statement shortcut opens an operative account.
|
||||
const { operableAccounts: accounts } = useAccounts();
|
||||
const primaryAccount = accounts[0];
|
||||
|
||||
const latestRequest = serviceRequests[0] ?? null;
|
||||
const openRequestsCount = serviceRequests.filter(
|
||||
(request) => request.status !== 'resolved',
|
||||
).length;
|
||||
|
||||
// Every tile declares the feature that owns it; `useEnabledItems` drops the
|
||||
// ones whose feature (or any ancestor) is off, so a disabled service leaves no
|
||||
// gap, no greyed tile and no reachable destination.
|
||||
const serviceOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'balance_enquiry',
|
||||
feature: FeatureKey.SERVICES_BALANCE_ENQUIRY,
|
||||
title: 'Balance Enquiry',
|
||||
icon: 'cash-outline',
|
||||
iconColor: theme.colors.success,
|
||||
onPress: () => navigation.navigate('BalanceEnquiry'),
|
||||
},
|
||||
{
|
||||
id: 'mini_statement',
|
||||
feature: FeatureKey.SERVICES_MINI_STATEMENT,
|
||||
title: 'Mini Statement',
|
||||
icon: 'receipt-outline',
|
||||
iconColor: theme.colors.primary,
|
||||
// Lives in the home stack. Without an account there is nothing to
|
||||
// show, so the tile says so rather than opening an empty screen.
|
||||
onPress: () => {
|
||||
if (!primaryAccount) {
|
||||
toast('No account found', 'info', 'We could not read your accounts. Try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
navigation.navigate('Home', {
|
||||
screen: 'MiniStatement',
|
||||
params: { accountNumber: primaryAccount.accountNumber },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mpin_change',
|
||||
feature: FeatureKey.SERVICES_MPIN_CHANGE,
|
||||
title: 'MPIN Change',
|
||||
icon: 'keypad-outline',
|
||||
iconColor: theme.colors.category.indigo.tone,
|
||||
onPress: () => navigation.navigate('Profile', { screen: 'ChangePin' }),
|
||||
},
|
||||
{
|
||||
id: 'tpin_change',
|
||||
feature: FeatureKey.SERVICES_TPIN_CHANGE,
|
||||
title: 'TPIN Change',
|
||||
icon: 'lock-closed-outline',
|
||||
iconColor: theme.colors.category.pink.tone,
|
||||
onPress: () => navigation.navigate('SetTpin'),
|
||||
},
|
||||
{
|
||||
id: 'fate_of_cheque',
|
||||
feature: FeatureKey.SERVICES_FATE_OF_CHEQUE,
|
||||
title: 'Fate Of Cheque',
|
||||
icon: 'checkmark-circle-outline',
|
||||
iconColor: theme.colors.info,
|
||||
onPress: () => navigation.navigate('FateOfCheque'),
|
||||
},
|
||||
{
|
||||
id: 'branch_locator',
|
||||
feature: FeatureKey.SERVICES_BRANCH_LOCATOR,
|
||||
title: 'Find Branch & ATMs',
|
||||
icon: 'map-outline',
|
||||
iconColor: '#10B981',
|
||||
iconColor: theme.colors.success,
|
||||
onPress: () => navigation.navigate('BranchLocator'),
|
||||
},
|
||||
{
|
||||
id: 'all_deposits',
|
||||
id: 'deposits',
|
||||
feature: FeatureKey.SERVICES_DEPOSITS_OVERVIEW,
|
||||
title: 'Deposits',
|
||||
icon: 'wallet-outline',
|
||||
iconColor: '#124577',
|
||||
iconColor: theme.colors.primary,
|
||||
onPress: () => navigation.navigate('DepositsOverview'),
|
||||
},
|
||||
{
|
||||
id: 'cheque_services',
|
||||
feature: FeatureKey.SERVICES_CHEQUE_HUB,
|
||||
title: 'Cheque Services',
|
||||
icon: 'book-outline',
|
||||
iconColor: '#3B82F6',
|
||||
iconColor: theme.colors.info,
|
||||
onPress: () => navigation.navigate('ChequeServicesHub'),
|
||||
},
|
||||
{
|
||||
id: 'certificates',
|
||||
title: 'Certificates',
|
||||
icon: 'document-text-outline',
|
||||
iconColor: '#F59E0B',
|
||||
onPress: () => navigation.navigate('CertificatesHub'),
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
title: 'Service Requests',
|
||||
icon: 'git-pull-request-outline',
|
||||
iconColor: '#EF4444',
|
||||
onPress: () => navigation.navigate('ServiceRequestsHub'),
|
||||
},
|
||||
{
|
||||
id: 'calculator',
|
||||
title: 'FD/RD Calc',
|
||||
icon: 'calculator-outline',
|
||||
iconColor: '#6366F1',
|
||||
onPress: () => navigation.navigate('DepositCalculator'),
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
feature: FeatureKey.SERVICES_SUPPORT_CHAT,
|
||||
title: 'Support Chat',
|
||||
icon: 'chatbubble-ellipses-outline',
|
||||
iconColor: '#0EA5E9',
|
||||
iconColor: theme.colors.category.sky.tone,
|
||||
onPress: () =>
|
||||
toast(
|
||||
'Support chat coming soon',
|
||||
@@ -87,30 +133,35 @@ export default function ServicesHubScreen({ navigation }: Props) {
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
feature: FeatureKey.SERVICES_HELP_CENTER,
|
||||
title: 'Help Center',
|
||||
icon: 'help-circle-outline',
|
||||
iconColor: '#8B5CF6',
|
||||
iconColor: theme.colors.category.violet.tone,
|
||||
onPress: () =>
|
||||
toast('Help center coming soon', 'info', 'This feature will be available shortly.'),
|
||||
},
|
||||
{
|
||||
id: 'cyber_security',
|
||||
feature: FeatureKey.SERVICES_AWARENESS_LIST,
|
||||
title: 'Cyber Security',
|
||||
icon: 'shield-half-outline',
|
||||
iconColor: '#F59E0B',
|
||||
iconColor: theme.colors.warning,
|
||||
onPress: () => navigation.navigate('AwarenessList'),
|
||||
},
|
||||
{
|
||||
id: 'protection_schemes',
|
||||
feature: FeatureKey.SERVICES_PROTECTION_HUB,
|
||||
title: 'Protection & Schemes',
|
||||
icon: 'umbrella-outline',
|
||||
iconColor: '#EC4899',
|
||||
iconColor: theme.colors.category.pink.tone,
|
||||
onPress: () => navigation.navigate('FinancialProtectionMain'),
|
||||
},
|
||||
],
|
||||
[navigation, toast]
|
||||
[navigation, primaryAccount, theme, toast]
|
||||
);
|
||||
|
||||
const visibleServiceOptions = useEnabledItems(serviceOptions);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Service desk" subtitle="All service options in one hub">
|
||||
{/* <View style={styles.sectionHeader}>
|
||||
@@ -123,7 +174,7 @@ export default function ServicesHubScreen({ navigation }: Props) {
|
||||
</View> */}
|
||||
|
||||
<View style={styles.grid}>
|
||||
{serviceOptions.map((option) => (
|
||||
{visibleServiceOptions.map((option) => (
|
||||
<ServiceGridItem
|
||||
key={option.id}
|
||||
id={option.id}
|
||||
@@ -207,7 +258,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 13,
|
||||
@@ -225,25 +276,25 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
heroStatLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
heroStatValue: {
|
||||
fontSize: 22,
|
||||
fontWeight: '900',
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
heroStatEmpty: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
sectionHeader: {
|
||||
gap: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 13,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<ServicesStackParamList, 'SetTpin'>>;
|
||||
|
||||
const TPIN_LENGTH = 4;
|
||||
|
||||
/** Rejected outright: a TPIN nobody has to guess is not a second factor. */
|
||||
const TRIVIAL_TPINS = new Set(['0000', '1111', '2222', '3333', '4444', '5555', '6666', '7777', '8888', '9999', '1234', '4321']);
|
||||
|
||||
/**
|
||||
* Sets the transaction PIN — the second factor on a payment, separate from the
|
||||
* MPIN that opens the app.
|
||||
*
|
||||
* The whole point of the TPIN is that it is *not* the MPIN: one unlocks the
|
||||
* app, the other authorises money leaving it, and a customer who sets them the
|
||||
* same has collapsed two factors into one. This screen cannot check that
|
||||
* directly — it never sees the MPIN, which lives behind the Keystore — so it
|
||||
* says so plainly instead and refuses the guessable choices it can see.
|
||||
*/
|
||||
export default function SetTpinScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
|
||||
const [tpin, setTpin] = useState('');
|
||||
const [confirmTpin, setConfirmTpin] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const tpinProblem = useMemo(() => {
|
||||
if (tpin.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (tpin.length < TPIN_LENGTH) {
|
||||
return `A TPIN is ${TPIN_LENGTH} digits`;
|
||||
}
|
||||
|
||||
if (TRIVIAL_TPINS.has(tpin)) {
|
||||
return 'Too easy to guess — avoid repeated or sequential digits';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [tpin]);
|
||||
|
||||
const confirmProblem =
|
||||
confirmTpin.length === TPIN_LENGTH && confirmTpin !== tpin
|
||||
? 'Both entries must match'
|
||||
: undefined;
|
||||
|
||||
const canSubmit =
|
||||
tpin.length === TPIN_LENGTH && !tpinProblem && confirmTpin === tpin && !saving;
|
||||
|
||||
const digitsOnly = (value: string) => value.replace(/\D/g, '').slice(0, TPIN_LENGTH);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// No CBS endpoint for the TPIN yet. When one lands it belongs here, and
|
||||
// the PIN must be encrypted the same way the MPIN is rather than sent in
|
||||
// the clear — see `authApi` for the RSA-OAEP path this should reuse.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
|
||||
toast('TPIN set', 'success', 'Use it to authorise payments from now on.');
|
||||
navigation.goBack();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Set TPIN" subtitle="Your payment authorisation PIN">
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.iconWrap}>
|
||||
<View style={[styles.circle, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="shield-half" size={32} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Input
|
||||
label="TPIN"
|
||||
placeholder="XXXX"
|
||||
keyboardType="number-pad"
|
||||
maxLength={TPIN_LENGTH}
|
||||
secureTextEntry
|
||||
value={tpin}
|
||||
onChangeText={(value) => setTpin(digitsOnly(value))}
|
||||
error={tpinProblem}
|
||||
editable={!saving}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm TPIN"
|
||||
placeholder="XXXX"
|
||||
keyboardType="number-pad"
|
||||
maxLength={TPIN_LENGTH}
|
||||
secureTextEntry
|
||||
value={confirmTpin}
|
||||
onChangeText={(value) => setConfirmTpin(digitsOnly(value))}
|
||||
error={confirmProblem}
|
||||
editable={!saving}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Set TPIN"
|
||||
onPress={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
loading={saving}
|
||||
style={styles.btn}
|
||||
/>
|
||||
|
||||
<Text style={[styles.hint, { color: theme.colors.primary }]}>
|
||||
Your TPIN authorises payments. Keep it different from your MPIN and never share it —
|
||||
bank staff will never ask for it.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
container: {
|
||||
gap: 24,
|
||||
paddingTop: 10,
|
||||
},
|
||||
iconWrap: {
|
||||
alignItems: 'center',
|
||||
marginVertical: 10,
|
||||
},
|
||||
circle: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
card: {
|
||||
gap: 16,
|
||||
},
|
||||
btn: {
|
||||
marginTop: 8,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell, Card, Input, Button } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'SpecialInstructions'>;
|
||||
|
||||
export default function SpecialInstructionsScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!title || !description) {
|
||||
toast('Some details are missing', 'error', 'Please add a title and description before you continue.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Request submitted', 'success', 'Your special instruction has been sent to your branch.');
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Special Instructions" subtitle="Submit custom service requests">
|
||||
<Card variant="default" style={styles.card}>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Request Title</Text>
|
||||
<Input value={title} onChangeText={setTitle} placeholder="Short objective summary" />
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Description</Text>
|
||||
<Input
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Detailed instructions..."
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Submit Instruction"
|
||||
onPress={handleSubmit}
|
||||
loading={isLoading}
|
||||
style={styles.submitBtn}
|
||||
/>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { padding: 16, gap: 16 },
|
||||
formGroup: { gap: 8 },
|
||||
label: { fontSize: 13, fontWeight: '600' },
|
||||
submitBtn: { marginTop: 16 },
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Text, Pressable, ScrollView } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell, Card, Input, Button } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { useAccounts } from "../../home/hooks/useAccounts";
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'StandingInstructions'>;
|
||||
|
||||
const FREQUENCIES = ['Monthly', 'Quarterly', 'Half-yearly', 'Yearly'];
|
||||
|
||||
export default function StandingInstructionsScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
// A standing instruction debits an operative account.
|
||||
const { operableAccounts: accounts } = useAccounts();
|
||||
|
||||
const [debitAccountId, setDebitAccountId] = useState(accounts[0]?.accountNumber);
|
||||
const [payeeName, setPayeeName] = useState('');
|
||||
const [payeeAccount, setPayeeAccount] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [frequency, setFrequency] = useState('Monthly');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!payeeName || !payeeAccount || !amount || !startDate) {
|
||||
toast('Some details are missing', 'error', 'Please enter the payee, amount and start date to continue.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Standing instruction set', 'success', `₹${amount} will be auto-debited ${frequency.toLowerCase()} to ${payeeName}.`);
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Standing Instructions" subtitle="Set up an automatic recurring payment" contentContainerStyle={styles.content}>
|
||||
{/* Debit account */}
|
||||
<Card style={styles.card}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Debit from account</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.selector}>
|
||||
{accounts.map((acc) => {
|
||||
const isSelected = acc.accountNumber === debitAccountId;
|
||||
return (
|
||||
<Pressable
|
||||
key={acc.accountNumber}
|
||||
onPress={() => setDebitAccountId(acc.accountNumber)}
|
||||
style={[
|
||||
styles.accountChip,
|
||||
{
|
||||
backgroundColor: isSelected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: isSelected ? theme.colors.primary : 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.chipType, { color: isSelected ? theme.colors.primary : theme.colors.textPrimary }]}>{acc.accountType}</Text>
|
||||
<Text style={[styles.chipMask, { color: theme.colors.textSecondary }]}>{('XXXX ' + acc.accountNumber.slice(-4))}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Card>
|
||||
|
||||
{/* Payee + amount */}
|
||||
<Card style={styles.card}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Payment details</Text>
|
||||
<Input label="Payee name" value={payeeName} onChangeText={setPayeeName} placeholder="Beneficiary / biller name" autoCapitalize="words" />
|
||||
<Input label="Payee account / reference" value={payeeAccount} onChangeText={setPayeeAccount} placeholder="Account number or consumer ID" />
|
||||
<Input label="Amount (₹)" value={amount} onChangeText={setAmount} placeholder="e.g. 5000" keyboardType="number-pad" />
|
||||
</Card>
|
||||
|
||||
{/* Schedule */}
|
||||
<Card style={styles.card}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Schedule</Text>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Frequency</Text>
|
||||
<View style={styles.chipWrap}>
|
||||
{FREQUENCIES.map((freq) => {
|
||||
const isSelected = freq === frequency;
|
||||
return (
|
||||
<Pressable
|
||||
key={freq}
|
||||
onPress={() => setFrequency(freq)}
|
||||
style={[
|
||||
styles.freqChip,
|
||||
{
|
||||
backgroundColor: isSelected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: isSelected ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.freqChipText, { color: isSelected ? theme.colors.primary : theme.colors.textPrimary }]}>{freq}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
<Input label="Start date" value={startDate} onChangeText={setStartDate} placeholder="DD/MM/YYYY" keyboardType="number-pad" />
|
||||
<Input label="Remarks (optional)" value={remarks} onChangeText={setRemarks} placeholder="e.g. Monthly rent" multiline />
|
||||
</Card>
|
||||
|
||||
<View style={[styles.notice, { backgroundColor: `${theme.colors.info}12` }]}>
|
||||
<Text style={{ color: theme.colors.info, fontSize: 13, lineHeight: 18 }}>
|
||||
The amount will be auto-debited on the scheduled date each cycle until you cancel this standing instruction.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Set standing instruction"
|
||||
onPress={handleSubmit}
|
||||
loading={isLoading}
|
||||
leftAccessory={<Ionicons name="repeat-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
<View style={{ height: 40 }} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { gap: 16 },
|
||||
card: { padding: 18, gap: 14 },
|
||||
sectionTitle: { fontSize: 16, fontFamily: fontFamily.bold },
|
||||
label: { fontSize: 13, fontFamily: fontFamily.semibold },
|
||||
formGroup: { gap: 8 },
|
||||
selector: { gap: 10, paddingRight: 12 },
|
||||
accountChip: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1.5, gap: 2, minWidth: 120 },
|
||||
chipType: { fontSize: 12, fontFamily: fontFamily.bold, textTransform: 'uppercase' },
|
||||
chipMask: { fontSize: 12, fontFamily: fontFamily.medium },
|
||||
chipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
freqChip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, borderWidth: 1 },
|
||||
freqChipText: { fontSize: 13, fontFamily: fontFamily.bold },
|
||||
notice: { padding: 12, borderRadius: 10 },
|
||||
});
|
||||
@@ -1,67 +1,428 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { ScreenShell, Card, Input, Button } from '../../../components';
|
||||
|
||||
import {
|
||||
AccountSelector,
|
||||
Button,
|
||||
Card,
|
||||
InfoBanner,
|
||||
Input,
|
||||
ScreenShell,
|
||||
} from '../../../components';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useAccountSelection } from '../../home/hooks/useAccounts';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { fontFamily, useTheme } from '../../../theme';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import {
|
||||
STOP_PAYMENT_REASONS,
|
||||
type ChequeDetail,
|
||||
type StopPaymentReason,
|
||||
type StopPaymentResult,
|
||||
} from '../../../types/cbs';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { useStopPaymentMutation, useVerifyChequeMutation } from '../services/chequeApi';
|
||||
import {
|
||||
describeStopPaymentError,
|
||||
isAlreadyStopped,
|
||||
isStopOutcomeUnknown,
|
||||
} from '../services/stopPaymentErrors';
|
||||
|
||||
type Props = NativeStackScreenProps<ServicesStackParamList, 'StopPayment'>;
|
||||
|
||||
const REASON_LABEL: Record<StopPaymentReason, string> = {
|
||||
LOST: 'Lost',
|
||||
STOLEN: 'Stolen',
|
||||
DISPUTE: 'Dispute',
|
||||
SIGNATURE_MISMATCH: 'Signature mismatch',
|
||||
ACCOUNT_CLOSURE: 'Account closing',
|
||||
DUPLICATE_ISSUED: 'Duplicate issued',
|
||||
OTHER: 'Other',
|
||||
};
|
||||
|
||||
const digitsOnly = (value: string) => value.replace(/\D/g, '');
|
||||
|
||||
/**
|
||||
* Stopping a cheque, in the three steps the API imposes.
|
||||
*
|
||||
* `find` looks the cheque up, `confirm` shows what the bank actually holds so
|
||||
* the customer stops the cheque they meant to, and `stopped` keeps the
|
||||
* reference on screen. The confirmation step is not politeness — the request
|
||||
* carries only an account and six digits, and a mistyped digit is otherwise a
|
||||
* different, perfectly valid cheque that gets dishonoured instead.
|
||||
*/
|
||||
type Step = 'find' | 'confirm' | 'stopped';
|
||||
|
||||
export default function StopPaymentScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const [account, setAccount] = useState('0000XXXX1234');
|
||||
const [chequeNumber, setChequeNumber] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const showApiError = useApiErrorToast();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!chequeNumber) {
|
||||
toast('Cheque number required', 'error', 'Please enter a valid cheque number.');
|
||||
/**
|
||||
* The customer picks the account rather than typing it, for the same reason
|
||||
* as Positive Pay: `GET /accounts` hands back the full number, so the typo
|
||||
* that used to produce an ambiguous `ACCOUNT_NOT_FOUND` cannot happen.
|
||||
*/
|
||||
const {
|
||||
accounts,
|
||||
selectedAccountNumber,
|
||||
setSelectedAccountNumber,
|
||||
isLoading: accountsLoading,
|
||||
} = useAccountSelection();
|
||||
|
||||
const [verifyCheque, { isLoading: isVerifying }] = useVerifyChequeMutation();
|
||||
const [stopPayment, { isLoading: isStopping }] = useStopPaymentMutation();
|
||||
|
||||
const accountNumber = selectedAccountNumber ?? '';
|
||||
const [step, setStep] = useState<Step>('find');
|
||||
const [chequeNumber, setChequeNumber] = useState('');
|
||||
const [cheque, setCheque] = useState<ChequeDetail | null>(null);
|
||||
const [reason, setReason] = useState<StopPaymentReason>('LOST');
|
||||
const [remark, setRemark] = useState('');
|
||||
const [stopped, setStopped] = useState<StopPaymentResult | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [unconfirmed, setUnconfirmed] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const busy = isVerifying || isStopping;
|
||||
|
||||
const validateLookup = () => {
|
||||
const next: Record<string, string> = {};
|
||||
|
||||
if (!/^\d{5,20}$/.test(accountNumber)) {
|
||||
next.accountNumber = 'Choose the account the cheque is drawn on.';
|
||||
}
|
||||
|
||||
if (!/^\d{6}$/.test(chequeNumber)) {
|
||||
next.chequeNumber = 'The cheque number is exactly 6 digits, from the MICR band.';
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const handleFind = async () => {
|
||||
setNotice(null);
|
||||
setUnconfirmed(null);
|
||||
|
||||
const found = validateLookup();
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast('Payment stopped', 'success', `We've stopped payment on cheque #${chequeNumber}.`);
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
|
||||
try {
|
||||
const detail = await verifyCheque({ accountNumber, chequeNumber }).unwrap();
|
||||
|
||||
setCheque(detail);
|
||||
|
||||
// Already stopped, so there is nothing left to instruct. Shown here
|
||||
// rather than letting the customer fill in a reason and be refused.
|
||||
if (detail.status === 'STOPPED') {
|
||||
setNotice('This cheque is already stopped. The bank will dishonour it if presented.');
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('confirm');
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
if (apiError?.fieldErrors) {
|
||||
setErrors(apiError.fieldErrors);
|
||||
}
|
||||
|
||||
showApiError(apiError);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setUnconfirmed(null);
|
||||
|
||||
if (reason === 'OTHER' && !remark.trim()) {
|
||||
setErrors({ remark: 'Tell your bank why, so the instruction can be acted on.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const result = await stopPayment({
|
||||
accountNumber,
|
||||
chequeNumber,
|
||||
reason,
|
||||
remark,
|
||||
}).unwrap();
|
||||
|
||||
setStopped(result);
|
||||
setStep('stopped');
|
||||
toast('Payment stopped', 'success', `Reference ${result.referenceId}`);
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
if (apiError?.fieldErrors) {
|
||||
setErrors(apiError.fieldErrors);
|
||||
}
|
||||
|
||||
// The bank refusing a retry of an instruction that already took effect
|
||||
// is good news wearing an error code. Reported as the outcome it is,
|
||||
// because a red failure here sends someone to ring a branch about a
|
||||
// cheque that is already blocked.
|
||||
if (isAlreadyStopped(apiError?.code)) {
|
||||
setStep('find');
|
||||
setCheque(null);
|
||||
setNotice('This cheque is already stopped. Your earlier instruction went through.');
|
||||
return;
|
||||
}
|
||||
|
||||
// A write whose outcome is unknown cannot be reported as a plain failure
|
||||
// and must not offer a retry — the cheque may already be stopped. It
|
||||
// stays on screen as a banner rather than a toast the customer can miss.
|
||||
if (isStopOutcomeUnknown(apiError?.code)) {
|
||||
const copy = describeStopPaymentError(apiError?.code);
|
||||
setUnconfirmed(apiError?.message || copy.description);
|
||||
return;
|
||||
}
|
||||
|
||||
showApiError(apiError);
|
||||
}
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
setStep('find');
|
||||
setCheque(null);
|
||||
setStopped(null);
|
||||
setChequeNumber('');
|
||||
setRemark('');
|
||||
setReason('LOST');
|
||||
setErrors({});
|
||||
setNotice(null);
|
||||
setUnconfirmed(null);
|
||||
};
|
||||
|
||||
if (step === 'stopped' && stopped) {
|
||||
return (
|
||||
<ScreenShell title="Payment stopped" subtitle="Keep the reference for your records">
|
||||
<Card variant="default" style={styles.card}>
|
||||
<View style={styles.referenceBlock}>
|
||||
<Text style={[styles.referenceLabel, { color: theme.colors.textSecondary }]}>
|
||||
Reference ID
|
||||
</Text>
|
||||
<Text style={[styles.referenceValue, { color: theme.colors.textPrimary }]} selectable>
|
||||
{stopped.referenceId}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="Your bank holds this instruction. The app does not keep a copy, so save the reference if you may need to quote it."
|
||||
/>
|
||||
|
||||
<Row label="Status" value={stopped.status} />
|
||||
<Row label="Account" value={stopped.accountNumberMasked} />
|
||||
<Row label="Cheque number" value={stopped.chequeNumber} />
|
||||
<Row label="Reason" value={REASON_LABEL[reason] ?? stopped.reason} />
|
||||
|
||||
<Button title="Done" onPress={() => navigation.goBack()} style={styles.submitBtn} />
|
||||
<Button
|
||||
title="Stop another cheque"
|
||||
variant="secondary"
|
||||
onPress={restart}
|
||||
style={styles.secondaryBtn}
|
||||
/>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'confirm' && cheque) {
|
||||
return (
|
||||
<ScreenShell title="Confirm the cheque" subtitle="Check this is the one you mean to stop">
|
||||
<Card variant="default" style={styles.card}>
|
||||
{unconfirmed ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
title="We could not confirm the stop"
|
||||
message={unconfirmed}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
message="A stop cannot be undone from the app. The bank will dishonour this cheque if it is presented."
|
||||
/>
|
||||
|
||||
<Row label="Cheque number" value={cheque.chequeNumber} />
|
||||
<Row label="Account" value={cheque.accountNumberMasked} />
|
||||
<Row label="Payee" value={cheque.payeeName} />
|
||||
<Row label="Amount" value={formatCurrency(cheque.amount)} />
|
||||
<Row label="Cheque date" value={formatDate(cheque.chequeDate)} />
|
||||
<Row label="Current status" value={cheque.status} />
|
||||
|
||||
<View style={styles.reasonBlock}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
|
||||
Reason <Text style={{ color: theme.colors.danger }}>*</Text>
|
||||
</Text>
|
||||
{/* Chips rather than a dropdown: seven fixed options, and the bank
|
||||
accepts no other value, so nothing here is worth a modal. */}
|
||||
<View style={styles.reasonRow}>
|
||||
{STOP_PAYMENT_REASONS.map((option) => {
|
||||
const selected = option === reason;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={option}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected, disabled: busy }}
|
||||
accessibilityLabel={REASON_LABEL[option]}
|
||||
disabled={busy}
|
||||
onPress={() => setReason(option)}
|
||||
style={[
|
||||
styles.reasonChip,
|
||||
{
|
||||
borderColor: selected ? theme.colors.primary : theme.colors.cardBorder,
|
||||
backgroundColor: selected
|
||||
? `${theme.colors.primary}14`
|
||||
: theme.colors.surface,
|
||||
borderRadius: theme.radius.sm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.reasonChipText,
|
||||
{
|
||||
color: selected ? theme.colors.primary : theme.colors.textSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{REASON_LABEL[option]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label="Remark"
|
||||
required={reason === 'OTHER'}
|
||||
value={remark}
|
||||
onChangeText={setRemark}
|
||||
placeholder="Anything the bank should know"
|
||||
maxLength={200}
|
||||
multiline
|
||||
error={errors.remark}
|
||||
hint={
|
||||
reason === 'OTHER'
|
||||
? 'Required when the reason is Other.'
|
||||
: 'Optional — up to 200 characters.'
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Confirm stop payment"
|
||||
variant="danger"
|
||||
onPress={handleStop}
|
||||
loading={isStopping}
|
||||
disabled={busy}
|
||||
style={styles.submitBtn}
|
||||
/>
|
||||
<Button
|
||||
title="Back"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
setStep('find');
|
||||
setUnconfirmed(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
style={styles.secondaryBtn}
|
||||
/>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell title="Stop Payment" subtitle="Block a specific cheque">
|
||||
<Card variant="default" style={styles.card}>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Select Account</Text>
|
||||
<Input value={account} onChangeText={setAccount} placeholder="Account Number" />
|
||||
</View>
|
||||
{notice ? <InfoBanner tone="info" message={notice} /> : null}
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Cheque Number</Text>
|
||||
<Input value={chequeNumber} onChangeText={setChequeNumber} placeholder="6-digit unique number" keyboardType="number-pad" />
|
||||
</View>
|
||||
{/* Only a cheque declared under Positive Pay exists to this endpoint,
|
||||
and CHEQUE_NOT_FOUND alone reads as a typo. Said up front, where it
|
||||
is still useful, rather than after a failed lookup. */}
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="Only a cheque you registered under Positive Pay can be stopped here."
|
||||
actionLabel="Register a cheque"
|
||||
onActionPress={() => navigation.navigate('PositivePay')}
|
||||
/>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Reason (Optional)</Text>
|
||||
<Input value={reason} onChangeText={setReason} placeholder="e.g. Lost in transit" />
|
||||
</View>
|
||||
<AccountSelector
|
||||
label="Drawn on account"
|
||||
accounts={accounts}
|
||||
selectedAccountNumber={selectedAccountNumber}
|
||||
onSelect={setSelectedAccountNumber}
|
||||
disabled={busy || accountsLoading}
|
||||
/>
|
||||
{errors.accountNumber ? (
|
||||
<Text style={[styles.fieldError, { color: theme.colors.danger }]}>
|
||||
{errors.accountNumber}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Cheque number"
|
||||
required
|
||||
value={chequeNumber}
|
||||
onChangeText={(text) => {
|
||||
setChequeNumber(digitsOnly(text));
|
||||
setNotice(null);
|
||||
}}
|
||||
placeholder="6 digits"
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
error={errors.chequeNumber}
|
||||
hint="The six digits printed in the MICR band."
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Confirm Stop Payment"
|
||||
onPress={handleSubmit}
|
||||
loading={isLoading}
|
||||
title="Find this cheque"
|
||||
onPress={handleFind}
|
||||
loading={isVerifying}
|
||||
disabled={busy}
|
||||
style={styles.submitBtn}
|
||||
variant="danger"
|
||||
/>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.rowLabel, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text style={[styles.rowValue, { color: theme.colors.textPrimary }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { padding: 16, gap: 16 },
|
||||
formGroup: { gap: 8 },
|
||||
label: { fontSize: 13, fontWeight: '600' },
|
||||
submitBtn: { marginTop: 16 },
|
||||
submitBtn: { marginTop: 8 },
|
||||
secondaryBtn: { marginTop: 4 },
|
||||
label: { fontSize: 13, fontFamily: fontFamily.semibold },
|
||||
fieldError: { fontSize: 13, fontFamily: fontFamily.regular },
|
||||
referenceBlock: { gap: 4 },
|
||||
referenceLabel: { fontSize: 13, fontFamily: fontFamily.semibold },
|
||||
referenceValue: { fontSize: 20, fontFamily: fontFamily.bold, letterSpacing: 0.5 },
|
||||
reasonBlock: { gap: 8 },
|
||||
reasonRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
reasonChip: { paddingHorizontal: 12, paddingVertical: 8, borderWidth: 1 },
|
||||
reasonChipText: { fontSize: 13, fontFamily: fontFamily.semibold },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
|
||||
rowLabel: { fontSize: 13, fontFamily: fontFamily.regular },
|
||||
rowValue: { fontSize: 13, fontFamily: fontFamily.semibold, flexShrink: 1, textAlign: 'right' },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { resolveLive } from '../../../services/api/serviceHelpers';
|
||||
import type {
|
||||
ChequeDetail,
|
||||
ChequeLookupRequest,
|
||||
StopPaymentRequest,
|
||||
StopPaymentResult,
|
||||
} from '../../../types/cbs';
|
||||
|
||||
/**
|
||||
* Stopping a cheque: the lookup that precedes it, and the instruction itself.
|
||||
*
|
||||
* The failure policy lives in `stopPaymentErrors.ts`, apart from this file so
|
||||
* it carries no transport imports. See it for why an unanswered stop is not
|
||||
* the same as an unanswered lookup.
|
||||
*/
|
||||
export const chequeApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
/**
|
||||
* Confirms the bank holds this cheque, and shows what it holds.
|
||||
*
|
||||
* The mirror image of `verifyChequeAvailable` on the Positive Pay side:
|
||||
* one core-banking lookup, read for the opposite reason. Finding the
|
||||
* cheque is the happy path here, and its payee, amount, date and current
|
||||
* status come back so the customer can see exactly what they are about to
|
||||
* stop rather than confirming a number.
|
||||
*
|
||||
* Only a cheque declared under `POST /positive-pay` exists to this call —
|
||||
* the bank keeps no separate issuance ledger — so an ordinary cheque
|
||||
* nobody registered answers `CHEQUE_NOT_FOUND`, and there is no way to
|
||||
* stop it. The screen has to say that plainly; it is not a lookup failure.
|
||||
*
|
||||
* A mutation rather than a query despite being a read: it is fired by a
|
||||
* button against values the customer is still typing, and caching the
|
||||
* answer would let a stale `status` sit behind a confirmation screen —
|
||||
* which is the one thing on this journey that must be current.
|
||||
*/
|
||||
verifyCheque: builder.mutation<ChequeDetail, ChequeLookupRequest>({
|
||||
queryFn: (request) =>
|
||||
resolveLive(async () => {
|
||||
const response = await apiClient.post<ChequeDetail>(
|
||||
endpoints.cheque.verify,
|
||||
request,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Instructs the bank to dishonour the cheque if it is presented.
|
||||
*
|
||||
* No `invalidatesTags`: nothing is stored on this side, exactly as with
|
||||
* Positive Pay, so there is no list to refresh. `referenceId` in the
|
||||
* response is the only handle the customer keeps, which is why the screen
|
||||
* shows it rather than toasting it away.
|
||||
*
|
||||
* A second attempt after a successful one is refused with
|
||||
* `CHEQUE_ALREADY_STOPPED` rather than answered with a fresh 200 — so a
|
||||
* customer retrying after a dropped response is never told twice that
|
||||
* their instruction took effect, when only the first call changed
|
||||
* anything.
|
||||
*/
|
||||
stopPayment: builder.mutation<StopPaymentResult, StopPaymentRequest>({
|
||||
queryFn: ({ remark, ...request }) =>
|
||||
resolveLive(async () => {
|
||||
const trimmed = remark?.trim();
|
||||
|
||||
const response = await apiClient.post<StopPaymentResult>(
|
||||
endpoints.cheque.stopPayment,
|
||||
// `remark` is optional and mainly qualifies `OTHER`. Spread in
|
||||
// rather than set, so a blank one is absent from the body instead
|
||||
// of arriving as an empty string the bank has to interpret.
|
||||
{ ...request, ...(trimmed ? { remark: trimmed } : {}) },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useVerifyChequeMutation, useStopPaymentMutation } = chequeApi;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { resolveLive } from '../../../services/api/serviceHelpers';
|
||||
import type {
|
||||
ChequeBookCharges,
|
||||
ChequeBookHistoryEntry,
|
||||
ChequeBookIssued,
|
||||
ChequeBookRequest,
|
||||
} from '../../../types/cbs';
|
||||
|
||||
/**
|
||||
* Requesting a cheque book: the fee quote that precedes it, the issue itself,
|
||||
* and the account's history of both.
|
||||
*
|
||||
* The failure policy lives in `chequeBookErrors.ts`, apart from this file so it
|
||||
* carries no transport imports. See it for why an unanswered issue is not the
|
||||
* same as an unanswered quote.
|
||||
*/
|
||||
export const chequeBookApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
/**
|
||||
* What the bank will charge for this account and leaf count.
|
||||
*
|
||||
* A query rather than a mutation, which is where this parts company with
|
||||
* `verifyCheque` and `verifyChequeAvailable` — the other two POST previews
|
||||
* in this module. Those are fired by a button against a cheque number the
|
||||
* customer is still typing, and their answer is a status that must never
|
||||
* be served stale. This one is a price list: deterministic for a given
|
||||
* account and leaf count, and wanted the moment the customer touches the
|
||||
* leaf-count control rather than when they press submit. Making it a query
|
||||
* is what lets the fee follow the selection instead of appearing after it.
|
||||
*
|
||||
* Caching per argument pair is the point: toggling 25 → 50 → 25 asks the
|
||||
* bank twice, not three times. The screen still passes
|
||||
* `refetchOnMountOrArgChange` so a revisit re-quotes rather than opening
|
||||
* on a price fetched some screens ago.
|
||||
*/
|
||||
getChequeBookCharges: builder.query<ChequeBookCharges, ChequeBookRequest>({
|
||||
queryFn: (request) =>
|
||||
resolveLive(async () => {
|
||||
const response = await apiClient.post<ChequeBookCharges>(
|
||||
endpoints.chequeBook.charges,
|
||||
request,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Issues the book. Synchronous — the serial range is in the response.
|
||||
*
|
||||
* `invalidatesTags` unlike every other write in the cheque module: this is
|
||||
* the one that leaves a record behind, so the account's history is now
|
||||
* wrong and has to be refetched. Positive Pay and stop payment invalidate
|
||||
* nothing because the bank exposes no list for either.
|
||||
*
|
||||
* A retry after a dropped response is not safe here. There is no
|
||||
* `CHEQUE_BOOK_ALREADY_ISSUED` guarding it the way
|
||||
* `CHEQUE_ALREADY_REGISTERED` guards Positive Pay — a second call is a
|
||||
* second book, with its own serial range and its own fee. That is why the
|
||||
* screen must route an unanswered issue through `isIssueOutcomeUnknown`
|
||||
* rather than offering a retry.
|
||||
*/
|
||||
requestChequeBook: builder.mutation<ChequeBookIssued, ChequeBookRequest>({
|
||||
queryFn: (request) =>
|
||||
resolveLive(async () => {
|
||||
const response = await apiClient.post<ChequeBookIssued>(
|
||||
endpoints.chequeBook.issue,
|
||||
request,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['ChequeBooks'],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Every cheque book issued to one account, newest first.
|
||||
*
|
||||
* An account that never had one answers with an empty list, not an error,
|
||||
* so an empty result is a legitimate state to render rather than a failure
|
||||
* to report. Safe to poll: nothing here identifies a specific book, so a
|
||||
* client that lost the issue response can confirm it landed by looking for
|
||||
* the new serial range.
|
||||
*/
|
||||
getChequeBooks: builder.query<ChequeBookHistoryEntry[], string>({
|
||||
queryFn: (accountNumber) =>
|
||||
resolveLive(async () => {
|
||||
const response = await apiClient.get<ChequeBookHistoryEntry[]>(
|
||||
endpoints.chequeBook.list,
|
||||
{ params: { accountNumber } },
|
||||
);
|
||||
|
||||
return response.data ?? [];
|
||||
}),
|
||||
providesTags: ['ChequeBooks'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetChequeBookChargesQuery,
|
||||
useRequestChequeBookMutation,
|
||||
useGetChequeBooksQuery,
|
||||
} = chequeBookApi;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import {
|
||||
describeChequeBookError,
|
||||
isIssueDeclined,
|
||||
isIssueOutcomeUnknown,
|
||||
} from './chequeBookErrors';
|
||||
|
||||
/**
|
||||
* Issuing a cheque book is the one write in this module with no server-side
|
||||
* duplicate check behind it. Positive Pay refuses a repeat with
|
||||
* `CHEQUE_ALREADY_REGISTERED` and a stop with `CHEQUE_ALREADY_STOPPED`;
|
||||
* nothing refuses a second cheque book. It is simply issued, with its own
|
||||
* serial range and its own fee.
|
||||
*
|
||||
* These tests pin the consequence: an unanswered issue never reaches the
|
||||
* customer as a retryable failure.
|
||||
*/
|
||||
describe('cheque book failure handling', () => {
|
||||
it('treats an unanswered write as an unknown outcome', () => {
|
||||
expect(isIssueOutcomeUnknown('CBS_UNAVAILABLE')).toBe(true);
|
||||
expect(isIssueOutcomeUnknown('CBS_ERROR')).toBe(true);
|
||||
expect(isIssueOutcomeUnknown('CONNECTOR_UNAVAILABLE')).toBe(true);
|
||||
expect(isIssueOutcomeUnknown('REQUEST_TIMEOUT')).toBe(true);
|
||||
expect(isIssueOutcomeUnknown('NETWORK_ERROR')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a definite refusal as known', () => {
|
||||
expect(isIssueOutcomeUnknown('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(false);
|
||||
expect(isIssueOutcomeUnknown('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(false);
|
||||
expect(isIssueOutcomeUnknown('ACCOUNT_NOT_FOUND')).toBe(false);
|
||||
expect(isIssueOutcomeUnknown('VALIDATION_ERROR')).toBe(false);
|
||||
expect(isIssueOutcomeUnknown(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces the app-wide copy when the outcome is unknown', () => {
|
||||
const shared = describeError('CBS_UNAVAILABLE');
|
||||
const here = describeChequeBookError('CBS_UNAVAILABLE');
|
||||
|
||||
// The shared copy invites another attempt, which is right for the reads it
|
||||
// was written for and wrong for a request that may already have issued a
|
||||
// book the customer will be charged for.
|
||||
expect(shared.retryable).toBe(true);
|
||||
expect(here.retryable).toBe(false);
|
||||
expect(here.title).not.toBe(shared.title);
|
||||
expect(here.description).toMatch(/may already have been issued/i);
|
||||
});
|
||||
|
||||
it('defers to the app-wide copy for every certain failure', () => {
|
||||
for (const code of [
|
||||
'CHEQUE_BOOK_REQUEST_DECLINED',
|
||||
'CHEQUE_BOOK_NOT_AVAILABLE',
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
'VALIDATION_ERROR',
|
||||
'SESSION_INVALID',
|
||||
]) {
|
||||
expect(describeChequeBookError(code)).toEqual(describeError(code));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A 422 means the bank understood the request and refused it, so no book
|
||||
* exists and no fee was applied — the one failure the screen can state that
|
||||
* plainly about.
|
||||
*/
|
||||
it('singles out the outright refusal', () => {
|
||||
expect(isIssueDeclined('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(true);
|
||||
expect(isIssueDeclined('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(false);
|
||||
expect(isIssueDeclined('CBS_UNAVAILABLE')).toBe(false);
|
||||
expect(isIssueDeclined(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
|
||||
/**
|
||||
* Failure policy for `POST /cheque-book`, kept apart from the API slice so it
|
||||
* carries no transport imports and can be reasoned about — and tested — on its
|
||||
* own.
|
||||
*
|
||||
* Issuing a cheque book is a write with a sharper retry problem than the other
|
||||
* two writes in this module. A repeated Positive Pay registration is refused
|
||||
* with `CHEQUE_ALREADY_REGISTERED`, and a repeated stop with
|
||||
* `CHEQUE_ALREADY_STOPPED` — the bank itself stops the duplicate. Nothing
|
||||
* guards this one. Each call issues a fresh book with its own serial range and
|
||||
* its own fee, so a customer who retries an unanswered request gets two books
|
||||
* and pays twice, and only finds out when the second arrives.
|
||||
*/
|
||||
|
||||
const WRITE_AMBIGUOUS = {
|
||||
title: 'We could not confirm the request',
|
||||
description:
|
||||
'Do not request another book yet — this one may already have been issued. Check your cheque book history or ask your bank first.',
|
||||
retryable: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Codes that can arrive after the book may already have been issued — either
|
||||
* the bank answered with a failure it had begun processing, or nothing
|
||||
* answered at all.
|
||||
*/
|
||||
const AMBIGUOUS_CODES = new Set([
|
||||
'CBS_UNAVAILABLE',
|
||||
'CBS_ERROR',
|
||||
'CONNECTOR_UNAVAILABLE',
|
||||
'REQUEST_TIMEOUT',
|
||||
'NETWORK_ERROR',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when the issue's outcome is unknown. Callers must not offer a plain
|
||||
* "try again" for these: there is no server-side duplicate check to catch the
|
||||
* second attempt.
|
||||
*/
|
||||
export const isIssueOutcomeUnknown = (code: string | undefined) =>
|
||||
code !== undefined && AMBIGUOUS_CODES.has(code);
|
||||
|
||||
/**
|
||||
* True when the bank declined outright. Unambiguous and safe to report as a
|
||||
* plain failure — `CHEQUE_BOOK_REQUEST_DECLINED` is a 422, which means the
|
||||
* request was understood and refused, so nothing was issued and nothing was
|
||||
* charged.
|
||||
*/
|
||||
export const isIssueDeclined = (code: string | undefined) =>
|
||||
code === 'CHEQUE_BOOK_REQUEST_DECLINED';
|
||||
|
||||
/**
|
||||
* Failure copy for the cheque book screen. Defers to the app-wide copy for
|
||||
* every code whose outcome is certain, and overrides only the ambiguous ones —
|
||||
* whose shared copy invites the retry this endpoint must not offer.
|
||||
*/
|
||||
export const describeChequeBookError = (code: string | undefined) =>
|
||||
isIssueOutcomeUnknown(code) ? WRITE_AMBIGUOUS : describeError(code);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { resolveLive } from '../../../services/api/serviceHelpers';
|
||||
import type {
|
||||
ChequeLookupRequest,
|
||||
PositivePayRequest,
|
||||
PositivePayResult,
|
||||
} from '../../../types/cbs';
|
||||
|
||||
/**
|
||||
* Positive Pay — declaring a cheque to the bank before it is presented, so a
|
||||
* tampered one is refused at clearing.
|
||||
*
|
||||
* The failure policy lives in `positivePayErrors.ts`; this file is only the
|
||||
* call. See that module for why an unanswered request here is not the same as
|
||||
* an unanswered lookup.
|
||||
*/
|
||||
export const positivePayApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
/**
|
||||
* No `invalidatesTags`: the bank is the system of record and this app
|
||||
* caches no list of registrations, so there is nothing to refetch. The
|
||||
* `referenceId` in the response is the only handle the customer keeps,
|
||||
* which is why the screen shows it rather than toasting it away.
|
||||
*/
|
||||
registerPositivePay: builder.mutation<PositivePayResult, PositivePayRequest>({
|
||||
queryFn: (request) =>
|
||||
resolveLive(async () => {
|
||||
const response = await apiClient.post<PositivePayResult>(
|
||||
endpoints.positivePay.register,
|
||||
request,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Checks the bank does not already hold this cheque, before the customer
|
||||
* fills in the rest of the form.
|
||||
*
|
||||
* "Not found" is the happy path and answers 200 with a null `data` — there
|
||||
* is nothing to report about a cheque that does not exist yet, only
|
||||
* clearance to register it. A cheque already on file is refused here with
|
||||
* the same `CHEQUE_ALREADY_REGISTERED` the registration itself would
|
||||
* eventually give, which is the point: the customer learns it before
|
||||
* typing an amount, a date and a payee name for nothing.
|
||||
*
|
||||
* A read, so unlike `registerPositivePay` an unanswered call here is
|
||||
* unambiguous — nothing was written, and retrying is safe.
|
||||
*/
|
||||
verifyChequeAvailable: builder.mutation<null, ChequeLookupRequest>({
|
||||
queryFn: (request) =>
|
||||
resolveLive(async () => {
|
||||
await apiClient.post(endpoints.positivePay.verify, request);
|
||||
|
||||
return null;
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useRegisterPositivePayMutation, useVerifyChequeAvailableMutation } =
|
||||
positivePayApi;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import {
|
||||
describePositivePayError,
|
||||
isRegistrationOutcomeUnknown,
|
||||
} from './positivePayErrors';
|
||||
|
||||
/**
|
||||
* Registering a cheque is the app's one write to an endpoint that keeps no
|
||||
* client-side record, so a failure the client never saw the answer to is
|
||||
* genuinely ambiguous: the cheque may be on file already. These tests pin the
|
||||
* two consequences — the copy stops claiming the registration failed, and
|
||||
* nothing offers a retry.
|
||||
*/
|
||||
describe('Positive Pay failure handling', () => {
|
||||
it('treats an unanswered write as an unknown outcome', () => {
|
||||
expect(isRegistrationOutcomeUnknown('CBS_UNAVAILABLE')).toBe(true);
|
||||
expect(isRegistrationOutcomeUnknown('CBS_ERROR')).toBe(true);
|
||||
expect(isRegistrationOutcomeUnknown('CONNECTOR_UNAVAILABLE')).toBe(true);
|
||||
expect(isRegistrationOutcomeUnknown('REQUEST_TIMEOUT')).toBe(true);
|
||||
expect(isRegistrationOutcomeUnknown('NETWORK_ERROR')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a definite refusal as known', () => {
|
||||
expect(isRegistrationOutcomeUnknown('CHEQUE_ALREADY_REGISTERED')).toBe(false);
|
||||
expect(isRegistrationOutcomeUnknown('POSITIVE_PAY_DECLINED')).toBe(false);
|
||||
expect(isRegistrationOutcomeUnknown('POSITIVE_PAY_NOT_AVAILABLE')).toBe(false);
|
||||
expect(isRegistrationOutcomeUnknown('VALIDATION_ERROR')).toBe(false);
|
||||
expect(isRegistrationOutcomeUnknown(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces the app-wide copy when the outcome is unknown', () => {
|
||||
const shared = describeError('CBS_UNAVAILABLE');
|
||||
const here = describePositivePayError('CBS_UNAVAILABLE');
|
||||
|
||||
// The shared copy invites another attempt, which is right for the lookups
|
||||
// it was written for and wrong for a write that may have landed.
|
||||
expect(shared.retryable).toBe(true);
|
||||
expect(here.retryable).toBe(false);
|
||||
expect(here.title).not.toBe(shared.title);
|
||||
expect(here.description).toMatch(/check with your bank/i);
|
||||
});
|
||||
|
||||
it('leaves every other code to the app-wide copy', () => {
|
||||
for (const code of [
|
||||
'VALIDATION_ERROR',
|
||||
'SESSION_INVALID',
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
'CHEQUE_ALREADY_REGISTERED',
|
||||
'POSITIVE_PAY_DECLINED',
|
||||
'POSITIVE_PAY_NOT_AVAILABLE',
|
||||
]) {
|
||||
expect(describePositivePayError(code)).toEqual(describeError(code));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
|
||||
/**
|
||||
* Failure policy for `POST /positive-pay`, kept apart from the API slice so it
|
||||
* carries no transport imports and can be reasoned about — and tested — on its
|
||||
* own.
|
||||
*
|
||||
* Registering a cheque is this app's one write to an endpoint that keeps no
|
||||
* client-side record. That makes an unanswered call genuinely ambiguous: the
|
||||
* declaration may already be on file at the bank. Everywhere else an
|
||||
* unreachable backend means "nothing happened, try again"; here it means "we
|
||||
* do not know", and the difference has to reach the customer.
|
||||
*/
|
||||
|
||||
const WRITE_AMBIGUOUS = {
|
||||
title: 'We could not confirm the registration',
|
||||
description:
|
||||
'Check with your bank before registering this cheque again. It may already be on file.',
|
||||
retryable: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Codes that can arrive after the request may already have been applied —
|
||||
* either the bank answered with a failure it had begun processing, or nothing
|
||||
* answered at all.
|
||||
*/
|
||||
const AMBIGUOUS_CODES = new Set([
|
||||
'CBS_UNAVAILABLE',
|
||||
'CBS_ERROR',
|
||||
'CONNECTOR_UNAVAILABLE',
|
||||
'REQUEST_TIMEOUT',
|
||||
'NETWORK_ERROR',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when the registration's outcome is unknown. Callers must not offer a
|
||||
* plain "try again" for these: a second attempt either duplicates the
|
||||
* declaration or returns `CHEQUE_ALREADY_REGISTERED`, which then reads like
|
||||
* the customer's mistake rather than the retry's.
|
||||
*/
|
||||
export const isRegistrationOutcomeUnknown = (code: string | undefined) =>
|
||||
code !== undefined && AMBIGUOUS_CODES.has(code);
|
||||
|
||||
/**
|
||||
* Failure copy for the Positive Pay screen. Defers to the app-wide copy for
|
||||
* every code whose outcome is certain, and overrides only the ambiguous ones —
|
||||
* whose shared copy invites the retry this endpoint must not offer.
|
||||
*/
|
||||
export const describePositivePayError = (code: string | undefined) =>
|
||||
isRegistrationOutcomeUnknown(code) ? WRITE_AMBIGUOUS : describeError(code);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import {
|
||||
describeStopPaymentError,
|
||||
isAlreadyStopped,
|
||||
isStopOutcomeUnknown,
|
||||
} from './stopPaymentErrors';
|
||||
|
||||
/**
|
||||
* Stopping a cheque is a write to an endpoint that keeps no client-side
|
||||
* record, so a failure the client never saw the answer to is genuinely
|
||||
* ambiguous — the instruction may have landed. These tests pin the three
|
||||
* consequences: the copy stops claiming the cheque is unstopped, nothing
|
||||
* offers a retry, and the one error code that carries good news is not
|
||||
* mistaken for a fault.
|
||||
*/
|
||||
describe('stop payment failure handling', () => {
|
||||
it('treats an unanswered write as an unknown outcome', () => {
|
||||
expect(isStopOutcomeUnknown('CBS_UNAVAILABLE')).toBe(true);
|
||||
expect(isStopOutcomeUnknown('CBS_ERROR')).toBe(true);
|
||||
expect(isStopOutcomeUnknown('CONNECTOR_UNAVAILABLE')).toBe(true);
|
||||
expect(isStopOutcomeUnknown('REQUEST_TIMEOUT')).toBe(true);
|
||||
expect(isStopOutcomeUnknown('NETWORK_ERROR')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a definite refusal as known', () => {
|
||||
expect(isStopOutcomeUnknown('CHEQUE_NOT_FOUND')).toBe(false);
|
||||
expect(isStopOutcomeUnknown('STOP_PAYMENT_DECLINED')).toBe(false);
|
||||
expect(isStopOutcomeUnknown('STOP_PAYMENT_NOT_AVAILABLE')).toBe(false);
|
||||
expect(isStopOutcomeUnknown('CHEQUE_ALREADY_STOPPED')).toBe(false);
|
||||
expect(isStopOutcomeUnknown(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces the app-wide copy when the outcome is unknown', () => {
|
||||
const shared = describeError('CBS_UNAVAILABLE');
|
||||
const here = describeStopPaymentError('CBS_UNAVAILABLE');
|
||||
|
||||
// The shared copy invites another attempt, which is right for the lookups
|
||||
// it was written for and wrong for an instruction that may have landed.
|
||||
expect(shared.retryable).toBe(true);
|
||||
expect(here.retryable).toBe(false);
|
||||
expect(here.title).not.toBe(shared.title);
|
||||
expect(here.description).toMatch(/check with your bank/i);
|
||||
});
|
||||
|
||||
it('defers to the app-wide copy for every certain failure', () => {
|
||||
for (const code of [
|
||||
'CHEQUE_NOT_FOUND',
|
||||
'STOP_PAYMENT_DECLINED',
|
||||
'STOP_PAYMENT_NOT_AVAILABLE',
|
||||
'VALIDATION_ERROR',
|
||||
'SESSION_INVALID',
|
||||
]) {
|
||||
expect(describeStopPaymentError(code)).toEqual(describeError(code));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* `CHEQUE_ALREADY_STOPPED` arrives as an error and means the customer got
|
||||
* what they asked for. Rendering it red pushes someone to try again, or to
|
||||
* ring a branch about a cheque that is already blocked.
|
||||
*/
|
||||
it('singles out the refusal that means the instruction already took effect', () => {
|
||||
expect(isAlreadyStopped('CHEQUE_ALREADY_STOPPED')).toBe(true);
|
||||
expect(isAlreadyStopped('CHEQUE_NOT_FOUND')).toBe(false);
|
||||
expect(isAlreadyStopped('STOP_PAYMENT_DECLINED')).toBe(false);
|
||||
expect(isAlreadyStopped(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
|
||||
/**
|
||||
* Failure policy for `POST /check/stop-payment`, kept apart from the API slice
|
||||
* so it carries no transport imports and can be reasoned about — and tested —
|
||||
* on its own.
|
||||
*
|
||||
* Stopping a cheque is a write to an endpoint that keeps no client-side
|
||||
* record, the same shape of problem as Positive Pay: an unanswered call is
|
||||
* genuinely ambiguous, because the instruction may already have reached the
|
||||
* bank. Everywhere else an unreachable backend means "nothing happened, try
|
||||
* again"; here it means "we do not know", and the difference has to reach the
|
||||
* customer — this one decides whether a cheque gets honoured.
|
||||
*/
|
||||
|
||||
const WRITE_AMBIGUOUS = {
|
||||
title: 'We could not confirm the stop',
|
||||
description:
|
||||
'Do not assume this cheque is stopped. Check with your bank before instructing it again.',
|
||||
retryable: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Codes that can arrive after the instruction may already have been applied —
|
||||
* either the bank answered with a failure it had begun processing, or nothing
|
||||
* answered at all.
|
||||
*/
|
||||
const AMBIGUOUS_CODES = new Set([
|
||||
'CBS_UNAVAILABLE',
|
||||
'CBS_ERROR',
|
||||
'CONNECTOR_UNAVAILABLE',
|
||||
'REQUEST_TIMEOUT',
|
||||
'NETWORK_ERROR',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when the stop's outcome is unknown. Callers must not offer a plain "try
|
||||
* again" for these: a second attempt either duplicates the instruction or
|
||||
* returns `CHEQUE_ALREADY_STOPPED`, which then reads like the customer's
|
||||
* mistake rather than the retry's.
|
||||
*/
|
||||
export const isStopOutcomeUnknown = (code: string | undefined) =>
|
||||
code !== undefined && AMBIGUOUS_CODES.has(code);
|
||||
|
||||
/**
|
||||
* True when the bank is telling us the customer already got what they asked
|
||||
* for.
|
||||
*
|
||||
* `CHEQUE_ALREADY_STOPPED` is an error code carrying good news: the cheque is
|
||||
* stopped, the first call did it, and this one changed nothing. Showing it as
|
||||
* a red failure would push someone to try a third time, or to ring the branch
|
||||
* about a cheque that is already blocked — so the screen treats it as the
|
||||
* outcome it describes rather than as a fault.
|
||||
*/
|
||||
export const isAlreadyStopped = (code: string | undefined) => code === 'CHEQUE_ALREADY_STOPPED';
|
||||
|
||||
/**
|
||||
* Failure copy for the stop-payment screen. Defers to the app-wide copy for
|
||||
* every code whose outcome is certain, and overrides only the ambiguous ones —
|
||||
* whose shared copy invites the retry this endpoint must not offer.
|
||||
*/
|
||||
export const describeStopPaymentError = (code: string | undefined) =>
|
||||
isStopOutcomeUnknown(code) ? WRITE_AMBIGUOUS : describeError(code);
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Cheque status lookup.
|
||||
*
|
||||
* There is no CBS endpoint for this yet — `endpoints.ts` has cheque book
|
||||
* requests and stop payments but nothing that answers "what happened to leaf
|
||||
* 123456". Until there is, the answer is resolved locally, in the same way the
|
||||
* dashboard, cards and loans resolve from fixtures today.
|
||||
*
|
||||
* The result is derived from the cheque number rather than drawn at random, so
|
||||
* the same number always gives the same answer. A demo that changes its mind
|
||||
* between two taps of the same button reads as a bug, and it makes the screen
|
||||
* impossible to write a test against.
|
||||
*
|
||||
* To go live, replace the body of `lookupChequeFate` with the API call. The
|
||||
* `ChequeFate` shape is what the screen renders, so it is the contract to map
|
||||
* the response onto.
|
||||
*/
|
||||
|
||||
export type ChequeFateStatus = 'paid' | 'in_clearing' | 'returned' | 'stopped' | 'not_found';
|
||||
|
||||
export type ChequeFate = {
|
||||
chequeNumber: string;
|
||||
status: ChequeFateStatus;
|
||||
/** Short human label for the status, shown as the result headline. */
|
||||
label: string;
|
||||
amount?: number;
|
||||
presentedOn?: string;
|
||||
clearedOn?: string;
|
||||
payee?: string;
|
||||
/** Why a cheque was returned or stopped. Absent for a clean outcome. */
|
||||
reason?: string;
|
||||
/** The caveat under the result — what the customer should do next. */
|
||||
note: string;
|
||||
};
|
||||
|
||||
const formatDay = (daysAgo: number) => {
|
||||
const date = new Date();
|
||||
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
|
||||
return new Intl.DateTimeFormat('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export type ChequeFateQuery = {
|
||||
accountNumber: string;
|
||||
chequeNumber: string;
|
||||
};
|
||||
|
||||
export const lookupChequeFate = async ({
|
||||
accountNumber,
|
||||
chequeNumber,
|
||||
}: ChequeFateQuery): Promise<ChequeFate> => {
|
||||
// Stands in for the round trip, so the button's loading state is visible and
|
||||
// the screen is exercised the way it will behave against the real API.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
|
||||
const digits = Number.parseInt(chequeNumber, 10);
|
||||
// Mixing the account in means the same leaf number on two accounts gives two
|
||||
// answers, which is the behaviour the real ledger has.
|
||||
const seed = (Number.isNaN(digits) ? 0 : digits) + Number(accountNumber.slice(-2) || 0);
|
||||
const amount = 1500 + (seed % 220) * 125;
|
||||
|
||||
switch (seed % 5) {
|
||||
case 0:
|
||||
return {
|
||||
chequeNumber,
|
||||
status: 'paid',
|
||||
label: 'Paid',
|
||||
amount,
|
||||
presentedOn: formatDay(6),
|
||||
clearedOn: formatDay(4),
|
||||
payee: 'Meghana Rao',
|
||||
note: 'Funds have left your account. The clearing house record is final.',
|
||||
};
|
||||
case 1:
|
||||
return {
|
||||
chequeNumber,
|
||||
status: 'in_clearing',
|
||||
label: 'In clearing',
|
||||
amount,
|
||||
presentedOn: formatDay(1),
|
||||
payee: 'Sunrise Traders',
|
||||
note: 'Presented and awaiting settlement. Cheques normally clear within two working days.',
|
||||
};
|
||||
case 2:
|
||||
return {
|
||||
chequeNumber,
|
||||
status: 'returned',
|
||||
label: 'Returned unpaid',
|
||||
amount,
|
||||
presentedOn: formatDay(3),
|
||||
payee: 'Vasan Electricals',
|
||||
reason: 'Insufficient funds',
|
||||
note: 'A return charge may apply. Contact your branch before re-presenting the cheque.',
|
||||
};
|
||||
case 3:
|
||||
return {
|
||||
chequeNumber,
|
||||
status: 'stopped',
|
||||
label: 'Stop payment in force',
|
||||
amount,
|
||||
presentedOn: formatDay(2),
|
||||
reason: 'Stop payment requested by you',
|
||||
note: 'This cheque will not be paid while the stop instruction stands.',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
chequeNumber,
|
||||
status: 'not_found',
|
||||
label: 'No record found',
|
||||
note: 'This cheque has not been presented on the selected account. Check the number and the account, then try again.',
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user