Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
@@ -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);
+118
View File
@@ -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.',
};
}
};