Compare commits
2
Commits
2dcb10775c
...
4eeababec8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eeababec8 | ||
|
|
86f9647437 |
@@ -1,10 +1,12 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { CbsAccount } from '../../types/cbs';
|
||||
import { useTheme, textStyles } from '../../theme';
|
||||
import { accountTypeLabel } from '../../modules/home/utils/accountRules';
|
||||
import { formatCurrency } from '../../utils/formatters';
|
||||
|
||||
interface AccountCardProps {
|
||||
account: CbsAccount;
|
||||
@@ -19,23 +21,52 @@ interface AccountCardProps {
|
||||
* the number stays masked with no way to reveal it.
|
||||
*/
|
||||
canRevealNumber?: boolean;
|
||||
/**
|
||||
* Asks the owning screen to fetch this account's balance.
|
||||
*
|
||||
* Omit it and no balance control is drawn at all — which is what a deposit
|
||||
* gets, since `GET /accounts/balance` serves SAVINGS and CURRENT only. The
|
||||
* fetch itself lives in the screen: this is a shared presentational
|
||||
* primitive and none of them reach into app state.
|
||||
*/
|
||||
onCheckBalance?: () => void;
|
||||
/** The figure, once fetched. Undefined until the customer asks. */
|
||||
balance?: number;
|
||||
balanceLoading?: boolean;
|
||||
/** Already-resolved failure copy. The card renders it, it does not derive it. */
|
||||
balanceError?: string;
|
||||
/**
|
||||
* False for a refusal the bank will repeat — a deposit, or a product it will
|
||||
* not price. The strip then goes inert rather than inviting a tap that gets
|
||||
* the identical answer.
|
||||
*/
|
||||
balanceRetryable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero card for one account.
|
||||
*
|
||||
* This was a balance card until the app moved to `GET /accounts`, which
|
||||
* returns no balance — and no other CBS endpoint returns one either. Rather
|
||||
* than print a figure the bank never sent, the card now leads with what it can
|
||||
* actually vouch for: which account this is, and whether it is usable.
|
||||
* It leads with what `GET /accounts` can vouch for — which account this is and
|
||||
* whether it is usable — because that endpoint returns no balance of its own.
|
||||
* A balance does exist, on `GET /accounts/balance`, but it is a separate live
|
||||
* call: it appears here only once the customer asks for it, and only when the
|
||||
* screen supplies `onCheckBalance`.
|
||||
*
|
||||
* The account number is masked by default. `/accounts` is the only endpoint
|
||||
* that returns an unmasked one, so revealing it is a deliberate act.
|
||||
* Both reveals are deliberate acts. The number is masked by default because
|
||||
* `/accounts` is the only endpoint that hands back an unmasked one, and the
|
||||
* balance is not fetched on render because every call reaches the core banking
|
||||
* system — and because a balance should not appear over someone's shoulder
|
||||
* just for opening a screen.
|
||||
*/
|
||||
const AccountCardComponent = ({
|
||||
account,
|
||||
accountHolder,
|
||||
canRevealNumber = true,
|
||||
onCheckBalance,
|
||||
balance,
|
||||
balanceLoading = false,
|
||||
balanceError,
|
||||
balanceRetryable = true,
|
||||
}: AccountCardProps) => {
|
||||
const theme = useTheme();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
@@ -66,7 +97,7 @@ const AccountCardComponent = ({
|
||||
CBS Bank
|
||||
</Text>
|
||||
<Text style={[styles.cardType, { color: theme.colors.onBrandSecondary }]}>
|
||||
{`${account.accountType.toUpperCase()} ACCOUNT`}
|
||||
{accountTypeLabel(account.accountType).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -112,6 +143,74 @@ const AccountCardComponent = ({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{onCheckBalance ? (
|
||||
<Pressable
|
||||
onPress={onCheckBalance}
|
||||
disabled={balanceLoading || (Boolean(balanceError) && !balanceRetryable)}
|
||||
style={[styles.balanceStrip, { backgroundColor: theme.colors.onBrandSurface }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ busy: balanceLoading }}
|
||||
accessibilityLabel={
|
||||
balance !== undefined
|
||||
? `Balance ${formatCurrency(balance)}. Tap to refresh.`
|
||||
: 'Check balance'
|
||||
}
|
||||
>
|
||||
{balanceLoading ? (
|
||||
<View style={styles.balanceRow}>
|
||||
<ActivityIndicator size="small" color={theme.colors.onBrandPrimary} />
|
||||
<Text style={[styles.balanceAction, { color: theme.colors.onBrandPrimary }]}>
|
||||
Checking with your bank…
|
||||
</Text>
|
||||
</View>
|
||||
) : balanceError ? (
|
||||
<View style={styles.balanceRow}>
|
||||
<Ionicons
|
||||
name="alert-circle-outline"
|
||||
size={20}
|
||||
color={theme.colors.onBrandPrimary}
|
||||
/>
|
||||
<View style={styles.balanceCopy}>
|
||||
<Text style={[styles.balanceAction, { color: theme.colors.onBrandPrimary }]}>
|
||||
{balanceError}
|
||||
</Text>
|
||||
{balanceRetryable ? (
|
||||
<Text style={[styles.balanceHint, { color: theme.colors.onBrandMuted }]}>
|
||||
Tap to try again
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : balance !== undefined ? (
|
||||
<View style={styles.balanceRow}>
|
||||
<View style={styles.balanceCopy}>
|
||||
<Text style={[styles.balanceLabel, { color: theme.colors.onBrandMuted }]}>
|
||||
AVAILABLE BALANCE
|
||||
</Text>
|
||||
<Text style={[styles.balanceValue, { color: theme.colors.onBrandPrimary }]}>
|
||||
{formatCurrency(balance)}
|
||||
</Text>
|
||||
</View>
|
||||
{/* A balance is only true at the instant it was fetched, so the
|
||||
way to get a fresh one stays on screen next to the figure. */}
|
||||
<Ionicons name="refresh-outline" size={18} color={theme.colors.onBrandSecondary} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.balanceRow}>
|
||||
<Ionicons name="eye-outline" size={20} color={theme.colors.onBrandPrimary} />
|
||||
<Text style={[styles.balanceAction, { color: theme.colors.onBrandPrimary }]}>
|
||||
Check balance
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={18}
|
||||
color={theme.colors.onBrandSecondary}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<View style={styles.footerRow}>
|
||||
<View style={styles.footerItem}>
|
||||
<Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}>
|
||||
@@ -121,14 +220,14 @@ const AccountCardComponent = ({
|
||||
{accountHolder ?? account.ifsc}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.footerItem, styles.footerItemEnd]}>
|
||||
{/* <View style={[styles.footerItem, styles.footerItemEnd]}>
|
||||
<Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}>
|
||||
{account.mmid ? 'MMID' : 'IFSC'}
|
||||
</Text>
|
||||
<Text style={[styles.footerValue, { color: theme.colors.onBrandPrimary }]}>
|
||||
{account.mmid ?? account.ifsc}
|
||||
</Text>
|
||||
</View>
|
||||
</View> */}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
);
|
||||
@@ -213,6 +312,42 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
balanceStrip: {
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
justifyContent: 'center',
|
||||
minHeight: 56,
|
||||
},
|
||||
balanceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
balanceCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
balanceLabel: {
|
||||
...textStyles.eyebrow,
|
||||
fontSize: 9,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
// Mono, like every other figure in the app — the theme's own reason is that
|
||||
// proportional digits reflow as a number changes.
|
||||
balanceValue: {
|
||||
...textStyles.moneyLarge,
|
||||
fontSize: 22,
|
||||
},
|
||||
balanceAction: {
|
||||
...textStyles.bodyStrong,
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
},
|
||||
balanceHint: {
|
||||
...textStyles.bodySmall,
|
||||
fontSize: 12,
|
||||
},
|
||||
footerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { useTheme, fontFamily } from '../../theme';
|
||||
import type { CbsAccount } from '../../types/cbs';
|
||||
import { accountTypeLabel } from '../../modules/home/utils/accountRules';
|
||||
|
||||
interface AccountSelectorProps {
|
||||
/** The accounts to offer, as `GET /accounts` returned them. */
|
||||
@@ -64,7 +65,7 @@ const AccountSelectorComponent = ({
|
||||
key={account.accountNumber}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected, disabled }}
|
||||
accessibilityLabel={`${account.accountType} account ending ${account.accountNumber.slice(-4)}`}
|
||||
accessibilityLabel={`${accountTypeLabel(account.accountType)} ending ${account.accountNumber.slice(-4)}`}
|
||||
disabled={disabled}
|
||||
onPress={() => onSelect(account.accountNumber)}
|
||||
style={[
|
||||
@@ -80,7 +81,7 @@ const AccountSelectorComponent = ({
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.type, { color: theme.colors.primary }]}>
|
||||
{account.accountType.toUpperCase()}
|
||||
{accountTypeLabel(account.accountType).toUpperCase()}
|
||||
</Text>
|
||||
<Text style={[styles.number, { color: theme.colors.primary }]}>
|
||||
{`XXXX ${account.accountNumber.slice(-4)}`}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useCallback, useMemo, useState, useRef } from 'react';
|
||||
import { StyleSheet, Text, View, Dimensions, Animated } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
@@ -13,6 +13,14 @@ import { useTheme, fontFamily } from '../../../theme';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useFeature } from '../../../features';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import {
|
||||
accountTypeLabel,
|
||||
isEverydayAccount,
|
||||
supportsBalanceEnquiry,
|
||||
} from '../utils/accountRules';
|
||||
import { useLazyGetAccountBalanceQuery } from '../services/accountsApi';
|
||||
import { describeError, isRetryableError } from '../../../services/api/cbsErrors';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
const SPACING = 16;
|
||||
@@ -28,15 +36,85 @@ type DetailItem = {
|
||||
icon: IconName;
|
||||
};
|
||||
|
||||
/**
|
||||
* What one card knows about its account's balance. Keyed per account rather
|
||||
* than held as a single value, because the carousel has every account mounted
|
||||
* at once and a shared slot would paint one account's figure onto another's
|
||||
* card mid-swipe.
|
||||
*/
|
||||
type BalanceState = {
|
||||
value?: number;
|
||||
loading?: boolean;
|
||||
/** Resolved copy, not a code — the card renders it verbatim. */
|
||||
error?: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
|
||||
export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const canToggleBalance = useFeature(FeatureKey.HOME_ACCOUNTS_BALANCE_TOGGLE);
|
||||
// Accounts come from the bank on every mount and are never cached.
|
||||
const { accounts } = useAccounts();
|
||||
const { accounts: allAccounts } = useAccounts();
|
||||
|
||||
/**
|
||||
* Savings and current accounts only, matching the home screen's carousel.
|
||||
*
|
||||
* A fixed or recurring deposit has no place here: every action this screen
|
||||
* offers — mini statement, e-passbook, re-KYC, balance — is one the bank
|
||||
* refuses on a deposit, and the "Account information" grid would show an
|
||||
* MMID that is permanently "Not allocated" because deposits cannot take part
|
||||
* in IMPS. Their terms live on the deposit screens instead.
|
||||
*
|
||||
* Filtered on type alone, so a dormant savings account still appears.
|
||||
*/
|
||||
const accounts = useMemo(() => allAccounts.filter(isEverydayAccount), [allAccounts]);
|
||||
const customerName = useAppSelector(selectCustomerName);
|
||||
const profile = useAppSelector((state) => state.user.profile);
|
||||
const [selectedNumber, setSelectedNumber] = useState(route.params?.accountNumber);
|
||||
const scrollX = useRef(new Animated.Value(0)).current;
|
||||
|
||||
/**
|
||||
* Balances are fetched on demand, one account at a time, and kept only in
|
||||
* this screen's state — never in the account list, which would let a figure
|
||||
* outlive the moment it was true for.
|
||||
*/
|
||||
const [fetchBalance] = useLazyGetAccountBalanceQuery();
|
||||
const [balances, setBalances] = useState<Record<string, BalanceState>>({});
|
||||
|
||||
const handleCheckBalance = useCallback(
|
||||
async (accountNumber: string) => {
|
||||
setBalances((current) => ({
|
||||
...current,
|
||||
// The previous error is cleared on the way in, so a retry does not sit
|
||||
// under the spinner that is already answering it.
|
||||
[accountNumber]: { ...current[accountNumber], loading: true, error: undefined },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await fetchBalance(accountNumber).unwrap();
|
||||
|
||||
setBalances((current) => ({
|
||||
...current,
|
||||
[accountNumber]: { value: result.balance, loading: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError | undefined;
|
||||
const copy = describeError(apiError?.code);
|
||||
|
||||
setBalances((current) => ({
|
||||
...current,
|
||||
[accountNumber]: {
|
||||
loading: false,
|
||||
error: copy.title,
|
||||
// A refusal the bank will repeat must not offer a tap that gets
|
||||
// the identical answer.
|
||||
retryable: isRetryableError(apiError?.code),
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[fetchBalance],
|
||||
);
|
||||
const account =
|
||||
accounts.find((item) => item.accountNumber === selectedNumber) ?? accounts[0];
|
||||
const activeIndex = accounts.findIndex(
|
||||
@@ -83,7 +161,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
{
|
||||
id: 'type',
|
||||
label: 'Account type',
|
||||
value: account.accountType,
|
||||
value: accountTypeLabel(account.accountType),
|
||||
icon: 'layers-outline',
|
||||
},
|
||||
{
|
||||
@@ -148,7 +226,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Account details"
|
||||
subtitle={`${account.accountType} account overview`}
|
||||
subtitle={`${accountTypeLabel(account.accountType)} overview`}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<View style={styles.carouselSection}>
|
||||
@@ -174,6 +252,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
scrollEventThrottle={16}
|
||||
keyExtractor={(item) => item.accountNumber}
|
||||
renderItem={({ item, index }) => {
|
||||
const balanceState = balances[item.accountNumber];
|
||||
const inputRange = [
|
||||
(index - 1) * (CARD_WIDTH + SPACING),
|
||||
index * (CARD_WIDTH + SPACING),
|
||||
@@ -191,6 +270,18 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
account={item}
|
||||
accountHolder={customerName || undefined}
|
||||
canRevealNumber={canToggleBalance}
|
||||
onCheckBalance={
|
||||
// Undefined for a deposit, which the balance endpoint does
|
||||
// not serve — the card then draws no control at all rather
|
||||
// than one that always fails.
|
||||
supportsBalanceEnquiry(item)
|
||||
? () => void handleCheckBalance(item.accountNumber)
|
||||
: undefined
|
||||
}
|
||||
balance={balanceState?.value}
|
||||
balanceLoading={balanceState?.loading}
|
||||
balanceError={balanceState?.error}
|
||||
balanceRetryable={balanceState?.retryable ?? true}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function AccountsMmidScreen(_props: Props) {
|
||||
// Masked numbers repeat across accounts on this profile, so they cannot key
|
||||
// the list. The MMID is unique per account where the bank has allocated
|
||||
// one; the type disambiguates the deposits that have none.
|
||||
<Card
|
||||
( account.mmid ? <Card
|
||||
key={account.mmid ?? `${account.accountType}-${account.accountNumberMasked}`}
|
||||
style={styles.accountCard}
|
||||
>
|
||||
@@ -104,7 +104,7 @@ export default function AccountsMmidScreen(_props: Props) {
|
||||
{account.ifsc}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
</Card>:null)
|
||||
);
|
||||
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { CbsStatementTransaction } from '../../../types/cbs';
|
||||
import { describeError, isRetryableError } from '../../../services/api/cbsErrors';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { useAccounts } from '../hooks/useAccounts';
|
||||
import { accountTypeLabel } from '../utils/accountRules';
|
||||
import { toStatementDate, useStatement } from '../hooks/useStatement';
|
||||
import {
|
||||
counterpartyDirection,
|
||||
@@ -369,7 +370,7 @@ export default function MiniStatementScreen({ route }: Props) {
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.accountChipText, { color: theme.colors.onBrandPrimary }]}>
|
||||
{`${account.accountType} · XXXX ${account.accountNumber.slice(-4)}`}
|
||||
{`${accountTypeLabel(account.accountType)} · XXXX ${account.accountNumber.slice(-4)}`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveLive } from '../../../services/api/serviceHelpers';
|
||||
import { downloadPdf } from '../../../services/files/pdfDownload';
|
||||
import type {
|
||||
CbsAccount,
|
||||
CbsAccountBalance,
|
||||
CbsPdfDocument,
|
||||
CbsStatementExportQuery,
|
||||
CbsStatementPage,
|
||||
@@ -107,6 +108,37 @@ export const accountsApi = baseApi.injectEndpoints({
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
|
||||
/**
|
||||
* One account's balance, read live from the bank.
|
||||
*
|
||||
* `keepUnusedDataFor: 0` for the same reason as `getAccounts`, only more
|
||||
* so: a balance is the most perishable figure in the app. The moment the
|
||||
* card showing it unmounts the entry is dropped, so nothing can come back
|
||||
* to a screen and find yesterday's number waiting for it.
|
||||
*
|
||||
* Fetched lazily — the screens use `useLazyGetAccountBalanceQuery` — so
|
||||
* the call happens when the customer asks for it, not on render. That is
|
||||
* partly a cost decision, since every call reaches the core banking
|
||||
* system, and partly a privacy one: a balance should not appear over
|
||||
* someone's shoulder because they opened a details screen.
|
||||
*
|
||||
* SAVINGS and CURRENT only; see `supportsBalanceEnquiry`. A deposit sent
|
||||
* here answers `ACCOUNT_BALANCE_NOT_AVAILABLE`, which is a refusal rather
|
||||
* than a fault, and the card says so instead of offering a retry.
|
||||
*/
|
||||
getAccountBalance: builder.query<CbsAccountBalance, string>({
|
||||
queryFn: (accountNumber) =>
|
||||
resolveLive(async () => {
|
||||
const response = await apiClient.get<CbsAccountBalance>(
|
||||
endpoints.accounts.balance,
|
||||
{ params: { accountNumber } },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}),
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
|
||||
/**
|
||||
* The statement for a range, as a PDF on disk.
|
||||
*
|
||||
@@ -155,6 +187,7 @@ export const accountsApi = baseApi.injectEndpoints({
|
||||
export const {
|
||||
useGetAccountsQuery,
|
||||
useGetMmidAccountsQuery,
|
||||
useLazyGetAccountBalanceQuery,
|
||||
useGetStatementQuery,
|
||||
useExportStatementMutation,
|
||||
useDownloadMiniStatementMutation,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
accountTypeLabel,
|
||||
isEverydayAccount,
|
||||
isOperativeAccount,
|
||||
maskAccountNumber,
|
||||
supportsBalanceEnquiry,
|
||||
} from './accountRules';
|
||||
import type { CbsAccount } from '../../../types/cbs';
|
||||
|
||||
@@ -82,11 +84,47 @@ describe('isOperativeAccount', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('isEverydayAccount', () => {
|
||||
it('keeps the two everyday accounts and drops both deposits', () => {
|
||||
expect(LIVE_ACCOUNTS.filter(isEverydayAccount).map((a) => a.accountType)).toEqual([
|
||||
'CURRENT',
|
||||
'SAVINGS',
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The difference from `isOperativeAccount`, and the reason both exist. A
|
||||
* dormant savings account cannot be debited, but it is still the customer's
|
||||
* money and still belongs on a screen that lists what they hold.
|
||||
*/
|
||||
it.each(['DORMANT', 'BLOCKED', 'CLOSED'] as const)('still keeps a %s savings account', (status) => {
|
||||
const account: CbsAccount = {
|
||||
accountNumber: '50108103544967',
|
||||
accountType: 'SAVINGS',
|
||||
status,
|
||||
ifsc: 'SIM0000001',
|
||||
mmid: '1072220',
|
||||
};
|
||||
|
||||
expect(isEverydayAccount(account)).toBe(true);
|
||||
expect(isOperativeAccount(account)).toBe(false);
|
||||
});
|
||||
|
||||
// The balance endpoint serves exactly the everyday set — a deposit's balance
|
||||
// lives on GET /accounts/deposits/detail instead.
|
||||
it('matches which accounts the balance endpoint will answer for', () => {
|
||||
for (const account of LIVE_ACCOUNTS) {
|
||||
expect(supportsBalanceEnquiry(account)).toBe(isEverydayAccount(account));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountTypeLabel', () => {
|
||||
it('turns the API_SHAPE into something readable', () => {
|
||||
expect(accountTypeLabel('FIXED_DEPOSIT')).toBe('Fixed deposit');
|
||||
expect(accountTypeLabel('RECURRING_DEPOSIT')).toBe('Recurring deposit');
|
||||
expect(accountTypeLabel('SAVINGS')).toBe('Savings');
|
||||
it('turns the API shape into something readable for customers', () => {
|
||||
expect(accountTypeLabel('FIXED_DEPOSIT')).toBe('Deposit account');
|
||||
expect(accountTypeLabel('RECURRING_DEPOSIT')).toBe('Deposit account');
|
||||
expect(accountTypeLabel('SAVINGS')).toBe('Savings account');
|
||||
expect(accountTypeLabel('CURRENT')).toBe('Current account');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,34 @@ export const OPERATIVE_ACCOUNT_TYPES: readonly CbsAccountType[] = ['SAVINGS', 'C
|
||||
export const isOperativeAccount = (account: CbsAccount) =>
|
||||
account.status === 'ACTIVE' && OPERATIVE_ACCOUNT_TYPES.includes(account.accountType);
|
||||
|
||||
/**
|
||||
* True when this is an everyday account rather than a deposit.
|
||||
*
|
||||
* Type only, with no status check — unlike `isOperativeAccount` above. A
|
||||
* dormant savings account cannot be the debit side of a transfer, but it is
|
||||
* still the customer's money and still belongs anywhere their accounts are
|
||||
* shown. Excluding it would be the app inventing a restriction the bank does
|
||||
* not have.
|
||||
*
|
||||
* This is the rule for "which accounts does a day-to-day screen show": a
|
||||
* deposit sitting beside them would offer a row of actions — mini statement,
|
||||
* MMID, cheque book — that the bank refuses on a deposit. Deposits have their
|
||||
* own screens, which show their terms instead.
|
||||
*/
|
||||
export const isEverydayAccount = (account: CbsAccount) =>
|
||||
OPERATIVE_ACCOUNT_TYPES.includes(account.accountType);
|
||||
|
||||
/**
|
||||
* True when `GET /accounts/balance` will answer for this account.
|
||||
*
|
||||
* The same set as `isEverydayAccount` today, and kept under its own name
|
||||
* because it states a fact about the endpoint rather than about the account: a
|
||||
* deposit's balance is not missing, it lives on
|
||||
* `GET /accounts/deposits/detail`. If the bank ever prices deposits here, this
|
||||
* moves and `isEverydayAccount` does not.
|
||||
*/
|
||||
export const supportsBalanceEnquiry = isEverydayAccount;
|
||||
|
||||
/**
|
||||
* Masked form of a full account number, in the shape the rest of the app uses.
|
||||
*
|
||||
@@ -35,11 +63,26 @@ export const maskAccountNumber = (accountNumber: string) =>
|
||||
/**
|
||||
* Readable label for an account type.
|
||||
*
|
||||
* The API returns SCREAMING_SNAKE, so `FIXED_DEPOSIT` has to become "Fixed
|
||||
* deposit" rather than "Fixed_deposit" — the underscore is what a naive
|
||||
* title-case leaves behind.
|
||||
* The API returns SCREAMING_SNAKE, but we show the customer a friendly label
|
||||
* rather than the raw backend nomenclature. Deposit accounts are intentionally
|
||||
* genericised because "RECURRING_DEPOSIT" is a bank-internal code, not a user
|
||||
* facing account title.
|
||||
*/
|
||||
export const accountTypeLabel = (accountType: string) => {
|
||||
const upper = accountType.trim().toUpperCase();
|
||||
|
||||
if (upper === 'FIXED_DEPOSIT' || upper === 'RECURRING_DEPOSIT') {
|
||||
return 'Deposit account';
|
||||
}
|
||||
|
||||
if (upper === 'SAVINGS') {
|
||||
return 'Savings account';
|
||||
}
|
||||
|
||||
if (upper === 'CURRENT') {
|
||||
return 'Current account';
|
||||
}
|
||||
|
||||
const [first = '', ...rest] = accountType.toLowerCase().split('_');
|
||||
|
||||
return [first.charAt(0).toUpperCase() + first.slice(1), ...rest].join(' ');
|
||||
|
||||
@@ -5,6 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
|
||||
import { useAccounts } from '../../home/hooks/useAccounts';
|
||||
import { accountTypeLabel } from '../../home/utils/accountRules';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { CbsAccount } from '../../../types/cbs';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
@@ -38,7 +39,7 @@ export default function BalanceEnquiryScreen(_props: Props) {
|
||||
</View>
|
||||
<View style={styles.accountCopy}>
|
||||
<Text style={[styles.accountType, { color: theme.colors.primary }]}>
|
||||
{`${account.accountType.toUpperCase()} ACCOUNT`}
|
||||
{accountTypeLabel(account.accountType).toUpperCase()}
|
||||
</Text>
|
||||
<Text style={[styles.accountNumber, { color: theme.colors.textPrimary }]}>
|
||||
{`XXXX ${account.accountNumber.slice(-4)}`}
|
||||
|
||||
@@ -9,9 +9,16 @@ import {
|
||||
} from './cbsErrors';
|
||||
|
||||
describe('error code catalogue', () => {
|
||||
it('covers all 45 documented codes, with no duplicates', () => {
|
||||
expect(CBS_ERROR_CODES).toHaveLength(45);
|
||||
expect(new Set(CBS_ERROR_CODES).size).toBe(45);
|
||||
it('covers all 46 documented codes, with no duplicates', () => {
|
||||
expect(CBS_ERROR_CODES).toHaveLength(46);
|
||||
expect(new Set(CBS_ERROR_CODES).size).toBe(46);
|
||||
});
|
||||
|
||||
it('does not invite a retry when the bank withholds a balance', () => {
|
||||
// The account exists and the bank simply will not price it. Repeating the
|
||||
// call gets the same refusal, so the card must not offer a retry.
|
||||
expect(isCbsErrorCode('ACCOUNT_BALANCE_NOT_AVAILABLE')).toBe(true);
|
||||
expect(isRetryableError('ACCOUNT_BALANCE_NOT_AVAILABLE')).toBe(false);
|
||||
});
|
||||
|
||||
it('knows the cheque book codes', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ export const CBS_ERROR_CODES = [
|
||||
'CUSTOMER_NOT_FOUND',
|
||||
'NO_ACCOUNTS_FOUND',
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
'ACCOUNT_BALANCE_NOT_AVAILABLE',
|
||||
'CHEQUE_NOT_FOUND',
|
||||
'STOP_PAYMENT_NOT_AVAILABLE',
|
||||
'INVALID_DATE_RANGE',
|
||||
@@ -142,6 +143,15 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
||||
title: 'Account unavailable',
|
||||
description: 'That account is no longer part of your profile.',
|
||||
},
|
||||
/**
|
||||
* The account exists but the bank will not give a balance for it — a deposit
|
||||
* asked of the wrong endpoint, or a product the connector cannot read. Not
|
||||
* retryable: the identical call gets the identical answer.
|
||||
*/
|
||||
ACCOUNT_BALANCE_NOT_AVAILABLE: {
|
||||
title: 'Balance unavailable',
|
||||
description: 'Your bank does not provide a balance for this account.',
|
||||
},
|
||||
/**
|
||||
* Nothing on file for this account and cheque number. The bank keeps no
|
||||
* separate record of issued cheques, so this also covers the common case of
|
||||
|
||||
@@ -29,6 +29,16 @@ export const endpoints = {
|
||||
*/
|
||||
list: '/accounts',
|
||||
mmid: '/accounts/mmid',
|
||||
/**
|
||||
* One transactable account's current balance, by `accountNumber` query
|
||||
* parameter. Read live from the bank on every call — nothing is cached
|
||||
* here, and nothing about a balance may be.
|
||||
*
|
||||
* SAVINGS and CURRENT only. A deposit's balance is a term-deposit concept
|
||||
* and lives on `depositDetail` below, which is why the bank keeps the two
|
||||
* apart rather than answering both from one endpoint.
|
||||
*/
|
||||
balance: '/accounts/balance',
|
||||
/**
|
||||
* A page of ledger entries for one account. Takes the full account number
|
||||
* from `list` above, and an optional `fromDate`/`toDate` pair that the
|
||||
|
||||
+16
-2
@@ -235,8 +235,9 @@ export type CbsAccountStatus = 'ACTIVE' | 'DORMANT' | 'BLOCKED' | 'CLOSED';
|
||||
/**
|
||||
* An account as `GET /accounts` returns it.
|
||||
*
|
||||
* Note what is *not* here: there is no balance field, because no endpoint in
|
||||
* the CBS API returns one. Anything showing a balance is not sourced from here.
|
||||
* Note what is *not* here: there is no balance field. `GET /accounts/balance`
|
||||
* answers that separately, as `CbsAccountBalance` below — so anything showing
|
||||
* a balance is not sourced from this type.
|
||||
*
|
||||
* The account number is unmasked — this is the only endpoint that returns one —
|
||||
* so it must never be written to disk, logged, or rendered in full where a
|
||||
@@ -251,6 +252,19 @@ export interface CbsAccount {
|
||||
mmid: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's balance, as `GET /accounts/balance` returns it.
|
||||
*
|
||||
* Deliberately not folded into `CbsAccount`: `GET /accounts` does not carry a
|
||||
* balance, and the two are fetched at different moments for different reasons.
|
||||
* Keeping them apart is what stops a stale figure riding along on an account
|
||||
* list that was cached for some other purpose.
|
||||
*/
|
||||
export interface CbsAccountBalance {
|
||||
/** In rupees. The bank's live figure at the moment of the call. */
|
||||
balance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One deposit as `GET /accounts/deposits/detail` returns it.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user