Add account balance enquiry via GET /accounts/balance

The CBS API now exposes a balance endpoint, which it previously did not.
Several comments in the codebase asserted that no such endpoint existed
anywhere in the API; those are corrected here rather than left to mislead.

Balance is fetched lazily, per account, from the Account Details card:

- `accounts.balance` endpoint, `CbsAccountBalance` type, and a
  `getAccountBalance` query with `keepUnusedDataFor: 0` — a balance is the
  most perishable figure in the app and must not outlive its screen.
- The call is triggered by the customer, not by render. Every call reaches
  the core banking system, and a balance should not appear over someone's
  shoulder just for opening a screen.
- `supportsBalanceEnquiry` gates the control by account type only, with no
  status check: a dormant savings account cannot be debited but still has a
  balance. Deposits are excluded because the endpoint does not serve them.
- `ACCOUNT_BALANCE_NOT_AVAILABLE` added to the error catalogue as a
  non-retryable refusal, so the card states it instead of inviting a tap
  that gets the identical answer.

Also carries in-progress work already present in the tree: customer-facing
account type labels (`accountTypeLabel`) applied across the account card,
selector, mini statement, balance enquiry and MMID screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-09-21 15:27:50 +05:30
co-authored by Claude Opus 5
parent 2dcb10775c
commit 86f9647437
13 changed files with 341 additions and 27 deletions
+143 -8
View File
@@ -1,10 +1,12 @@
import React, { memo, useState } from 'react'; 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 LinearGradient from 'react-native-linear-gradient';
import Ionicons from '@react-native-vector-icons/ionicons'; import Ionicons from '@react-native-vector-icons/ionicons';
import type { CbsAccount } from '../../types/cbs'; import type { CbsAccount } from '../../types/cbs';
import { useTheme, textStyles } from '../../theme'; import { useTheme, textStyles } from '../../theme';
import { accountTypeLabel } from '../../modules/home/utils/accountRules';
import { formatCurrency } from '../../utils/formatters';
interface AccountCardProps { interface AccountCardProps {
account: CbsAccount; account: CbsAccount;
@@ -19,23 +21,52 @@ interface AccountCardProps {
* the number stays masked with no way to reveal it. * the number stays masked with no way to reveal it.
*/ */
canRevealNumber?: boolean; 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. * The hero card for one account.
* *
* This was a balance card until the app moved to `GET /accounts`, which * It leads with what `GET /accounts` can vouch for — which account this is and
* returns no balance — and no other CBS endpoint returns one either. Rather * whether it is usable — because that endpoint returns no balance of its own.
* than print a figure the bank never sent, the card now leads with what it can * A balance does exist, on `GET /accounts/balance`, but it is a separate live
* actually vouch for: which account this is, and whether it is usable. * 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 * Both reveals are deliberate acts. The number is masked by default because
* that returns an unmasked one, so revealing it is a deliberate act. * `/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 = ({ const AccountCardComponent = ({
account, account,
accountHolder, accountHolder,
canRevealNumber = true, canRevealNumber = true,
onCheckBalance,
balance,
balanceLoading = false,
balanceError,
balanceRetryable = true,
}: AccountCardProps) => { }: AccountCardProps) => {
const theme = useTheme(); const theme = useTheme();
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
@@ -66,7 +97,7 @@ const AccountCardComponent = ({
CBS Bank CBS Bank
</Text> </Text>
<Text style={[styles.cardType, { color: theme.colors.onBrandSecondary }]}> <Text style={[styles.cardType, { color: theme.colors.onBrandSecondary }]}>
{`${account.accountType.toUpperCase()} ACCOUNT`} {accountTypeLabel(account.accountType).toUpperCase()}
</Text> </Text>
</View> </View>
</View> </View>
@@ -112,6 +143,74 @@ const AccountCardComponent = ({
</View> </View>
</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.footerRow}>
<View style={styles.footerItem}> <View style={styles.footerItem}>
<Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}> <Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}>
@@ -213,6 +312,42 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: '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: { footerRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
+3 -2
View File
@@ -3,6 +3,7 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTheme, fontFamily } from '../../theme'; import { useTheme, fontFamily } from '../../theme';
import type { CbsAccount } from '../../types/cbs'; import type { CbsAccount } from '../../types/cbs';
import { accountTypeLabel } from '../../modules/home/utils/accountRules';
interface AccountSelectorProps { interface AccountSelectorProps {
/** The accounts to offer, as `GET /accounts` returned them. */ /** The accounts to offer, as `GET /accounts` returned them. */
@@ -64,7 +65,7 @@ const AccountSelectorComponent = ({
key={account.accountNumber} key={account.accountNumber}
accessibilityRole="radio" accessibilityRole="radio"
accessibilityState={{ selected, disabled }} accessibilityState={{ selected, disabled }}
accessibilityLabel={`${account.accountType} account ending ${account.accountNumber.slice(-4)}`} accessibilityLabel={`${accountTypeLabel(account.accountType)} ending ${account.accountNumber.slice(-4)}`}
disabled={disabled} disabled={disabled}
onPress={() => onSelect(account.accountNumber)} onPress={() => onSelect(account.accountNumber)}
style={[ style={[
@@ -80,7 +81,7 @@ const AccountSelectorComponent = ({
]} ]}
> >
<Text style={[styles.type, { color: theme.colors.primary }]}> <Text style={[styles.type, { color: theme.colors.primary }]}>
{account.accountType.toUpperCase()} {accountTypeLabel(account.accountType).toUpperCase()}
</Text> </Text>
<Text style={[styles.number, { color: theme.colors.primary }]}> <Text style={[styles.number, { color: theme.colors.primary }]}>
{`XXXX ${account.accountNumber.slice(-4)}`} {`XXXX ${account.accountNumber.slice(-4)}`}
@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useCallback, useState, useRef } from 'react';
import { StyleSheet, Text, View, Dimensions, Animated } from 'react-native'; import { StyleSheet, Text, View, Dimensions, Animated } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons'; import Ionicons from '@react-native-vector-icons/ionicons';
@@ -13,6 +13,10 @@ import { useTheme, fontFamily } from '../../../theme';
import { FeatureKey } from '../../../constants/featureKeys'; import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features'; import { useFeature } from '../../../features';
import { formatDate } from '../../../utils/formatters'; import { formatDate } from '../../../utils/formatters';
import { accountTypeLabel, 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 { width: SCREEN_WIDTH } = Dimensions.get('window');
const SPACING = 16; const SPACING = 16;
@@ -28,6 +32,20 @@ type DetailItem = {
icon: IconName; 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) { export default function AccountDetailsScreen({ route, navigation }: Props) {
const theme = useTheme(); const theme = useTheme();
const canToggleBalance = useFeature(FeatureKey.HOME_ACCOUNTS_BALANCE_TOGGLE); const canToggleBalance = useFeature(FeatureKey.HOME_ACCOUNTS_BALANCE_TOGGLE);
@@ -37,6 +55,49 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
const profile = useAppSelector((state) => state.user.profile); const profile = useAppSelector((state) => state.user.profile);
const [selectedNumber, setSelectedNumber] = useState(route.params?.accountNumber); const [selectedNumber, setSelectedNumber] = useState(route.params?.accountNumber);
const scrollX = useRef(new Animated.Value(0)).current; 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 = const account =
accounts.find((item) => item.accountNumber === selectedNumber) ?? accounts[0]; accounts.find((item) => item.accountNumber === selectedNumber) ?? accounts[0];
const activeIndex = accounts.findIndex( const activeIndex = accounts.findIndex(
@@ -83,7 +144,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
{ {
id: 'type', id: 'type',
label: 'Account type', label: 'Account type',
value: account.accountType, value: accountTypeLabel(account.accountType),
icon: 'layers-outline', icon: 'layers-outline',
}, },
{ {
@@ -148,7 +209,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
return ( return (
<ScreenShell <ScreenShell
title="Account details" title="Account details"
subtitle={`${account.accountType} account overview`} subtitle={`${accountTypeLabel(account.accountType)} overview`}
contentContainerStyle={styles.contentContainer} contentContainerStyle={styles.contentContainer}
> >
<View style={styles.carouselSection}> <View style={styles.carouselSection}>
@@ -174,6 +235,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
scrollEventThrottle={16} scrollEventThrottle={16}
keyExtractor={(item) => item.accountNumber} keyExtractor={(item) => item.accountNumber}
renderItem={({ item, index }) => { renderItem={({ item, index }) => {
const balanceState = balances[item.accountNumber];
const inputRange = [ const inputRange = [
(index - 1) * (CARD_WIDTH + SPACING), (index - 1) * (CARD_WIDTH + SPACING),
index * (CARD_WIDTH + SPACING), index * (CARD_WIDTH + SPACING),
@@ -191,6 +253,18 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
account={item} account={item}
accountHolder={customerName || undefined} accountHolder={customerName || undefined}
canRevealNumber={canToggleBalance} 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> </Animated.View>
); );
@@ -64,7 +64,7 @@ export default function AccountsMmidScreen(_props: Props) {
// Masked numbers repeat across accounts on this profile, so they cannot key // 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 // the list. The MMID is unique per account where the bank has allocated
// one; the type disambiguates the deposits that have none. // one; the type disambiguates the deposits that have none.
<Card ( account.mmid ? <Card
key={account.mmid ?? `${account.accountType}-${account.accountNumberMasked}`} key={account.mmid ?? `${account.accountType}-${account.accountNumberMasked}`}
style={styles.accountCard} style={styles.accountCard}
> >
@@ -104,7 +104,7 @@ export default function AccountsMmidScreen(_props: Props) {
{account.ifsc} {account.ifsc}
</Text> </Text>
</View> </View>
</Card> </Card>:null)
); );
const apiError = error as ApiError | undefined; const apiError = error as ApiError | undefined;
@@ -29,6 +29,7 @@ import type { CbsStatementTransaction } from '../../../types/cbs';
import { describeError, isRetryableError } from '../../../services/api/cbsErrors'; import { describeError, isRetryableError } from '../../../services/api/cbsErrors';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast'; import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useAccounts } from '../hooks/useAccounts'; import { useAccounts } from '../hooks/useAccounts';
import { accountTypeLabel } from '../utils/accountRules';
import { toStatementDate, useStatement } from '../hooks/useStatement'; import { toStatementDate, useStatement } from '../hooks/useStatement';
import { import {
counterpartyDirection, counterpartyDirection,
@@ -369,7 +370,7 @@ export default function MiniStatementScreen({ route }: Props) {
]} ]}
> >
<Text style={[styles.accountChipText, { color: theme.colors.onBrandPrimary }]}> <Text style={[styles.accountChipText, { color: theme.colors.onBrandPrimary }]}>
{`${account.accountType} · XXXX ${account.accountNumber.slice(-4)}`} {`${accountTypeLabel(account.accountType)} · XXXX ${account.accountNumber.slice(-4)}`}
</Text> </Text>
</View> </View>
</View> </View>
+33
View File
@@ -5,6 +5,7 @@ import { resolveLive } from '../../../services/api/serviceHelpers';
import { downloadPdf } from '../../../services/files/pdfDownload'; import { downloadPdf } from '../../../services/files/pdfDownload';
import type { import type {
CbsAccount, CbsAccount,
CbsAccountBalance,
CbsPdfDocument, CbsPdfDocument,
CbsStatementExportQuery, CbsStatementExportQuery,
CbsStatementPage, CbsStatementPage,
@@ -107,6 +108,37 @@ export const accountsApi = baseApi.injectEndpoints({
keepUnusedDataFor: 0, 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. * The statement for a range, as a PDF on disk.
* *
@@ -155,6 +187,7 @@ export const accountsApi = baseApi.injectEndpoints({
export const { export const {
useGetAccountsQuery, useGetAccountsQuery,
useGetMmidAccountsQuery, useGetMmidAccountsQuery,
useLazyGetAccountBalanceQuery,
useGetStatementQuery, useGetStatementQuery,
useExportStatementMutation, useExportStatementMutation,
useDownloadMiniStatementMutation, useDownloadMiniStatementMutation,
+5 -4
View File
@@ -83,10 +83,11 @@ describe('isOperativeAccount', () => {
}); });
describe('accountTypeLabel', () => { describe('accountTypeLabel', () => {
it('turns the API_SHAPE into something readable', () => { it('turns the API shape into something readable for customers', () => {
expect(accountTypeLabel('FIXED_DEPOSIT')).toBe('Fixed deposit'); expect(accountTypeLabel('FIXED_DEPOSIT')).toBe('Deposit account');
expect(accountTypeLabel('RECURRING_DEPOSIT')).toBe('Recurring deposit'); expect(accountTypeLabel('RECURRING_DEPOSIT')).toBe('Deposit account');
expect(accountTypeLabel('SAVINGS')).toBe('Savings'); expect(accountTypeLabel('SAVINGS')).toBe('Savings account');
expect(accountTypeLabel('CURRENT')).toBe('Current account');
}); });
}); });
+31 -3
View File
@@ -22,6 +22,19 @@ export const OPERATIVE_ACCOUNT_TYPES: readonly CbsAccountType[] = ['SAVINGS', 'C
export const isOperativeAccount = (account: CbsAccount) => export const isOperativeAccount = (account: CbsAccount) =>
account.status === 'ACTIVE' && OPERATIVE_ACCOUNT_TYPES.includes(account.accountType); account.status === 'ACTIVE' && OPERATIVE_ACCOUNT_TYPES.includes(account.accountType);
/**
* True when `GET /accounts/balance` will answer for this account.
*
* Type only, with no status check — unlike `isOperativeAccount` above. A
* dormant savings account cannot be the debit side of a transfer but it still
* has a balance, and refusing to show it would be the app inventing a
* restriction the bank does not have. A deposit is excluded because the
* endpoint genuinely does not serve one: its balance lives on
* `GET /accounts/deposits/detail`.
*/
export const supportsBalanceEnquiry = (account: CbsAccount) =>
OPERATIVE_ACCOUNT_TYPES.includes(account.accountType);
/** /**
* Masked form of a full account number, in the shape the rest of the app uses. * Masked form of a full account number, in the shape the rest of the app uses.
* *
@@ -35,11 +48,26 @@ export const maskAccountNumber = (accountNumber: string) =>
/** /**
* Readable label for an account type. * Readable label for an account type.
* *
* The API returns SCREAMING_SNAKE, so `FIXED_DEPOSIT` has to become "Fixed * The API returns SCREAMING_SNAKE, but we show the customer a friendly label
* deposit" rather than "Fixed_deposit" — the underscore is what a naive * rather than the raw backend nomenclature. Deposit accounts are intentionally
* title-case leaves behind. * genericised because "RECURRING_DEPOSIT" is a bank-internal code, not a user
* facing account title.
*/ */
export const accountTypeLabel = (accountType: string) => { 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('_'); const [first = '', ...rest] = accountType.toLowerCase().split('_');
return [first.charAt(0).toUpperCase() + first.slice(1), ...rest].join(' '); 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 { Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
import { useAccounts } from '../../home/hooks/useAccounts'; import { useAccounts } from '../../home/hooks/useAccounts';
import { accountTypeLabel } from '../../home/utils/accountRules';
import { useTheme, fontFamily } from '../../../theme'; import { useTheme, fontFamily } from '../../../theme';
import type { CbsAccount } from '../../../types/cbs'; import type { CbsAccount } from '../../../types/cbs';
import type { ServicesStackParamList } from '../../../navigation/types'; import type { ServicesStackParamList } from '../../../navigation/types';
@@ -38,7 +39,7 @@ export default function BalanceEnquiryScreen(_props: Props) {
</View> </View>
<View style={styles.accountCopy}> <View style={styles.accountCopy}>
<Text style={[styles.accountType, { color: theme.colors.primary }]}> <Text style={[styles.accountType, { color: theme.colors.primary }]}>
{`${account.accountType.toUpperCase()} ACCOUNT`} {accountTypeLabel(account.accountType).toUpperCase()}
</Text> </Text>
<Text style={[styles.accountNumber, { color: theme.colors.textPrimary }]}> <Text style={[styles.accountNumber, { color: theme.colors.textPrimary }]}>
{`XXXX ${account.accountNumber.slice(-4)}`} {`XXXX ${account.accountNumber.slice(-4)}`}
+10 -3
View File
@@ -9,9 +9,16 @@ import {
} from './cbsErrors'; } from './cbsErrors';
describe('error code catalogue', () => { describe('error code catalogue', () => {
it('covers all 45 documented codes, with no duplicates', () => { it('covers all 46 documented codes, with no duplicates', () => {
expect(CBS_ERROR_CODES).toHaveLength(45); expect(CBS_ERROR_CODES).toHaveLength(46);
expect(new Set(CBS_ERROR_CODES).size).toBe(45); 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', () => { it('knows the cheque book codes', () => {
+10
View File
@@ -17,6 +17,7 @@ export const CBS_ERROR_CODES = [
'CUSTOMER_NOT_FOUND', 'CUSTOMER_NOT_FOUND',
'NO_ACCOUNTS_FOUND', 'NO_ACCOUNTS_FOUND',
'ACCOUNT_NOT_FOUND', 'ACCOUNT_NOT_FOUND',
'ACCOUNT_BALANCE_NOT_AVAILABLE',
'CHEQUE_NOT_FOUND', 'CHEQUE_NOT_FOUND',
'STOP_PAYMENT_NOT_AVAILABLE', 'STOP_PAYMENT_NOT_AVAILABLE',
'INVALID_DATE_RANGE', 'INVALID_DATE_RANGE',
@@ -142,6 +143,15 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Account unavailable', title: 'Account unavailable',
description: 'That account is no longer part of your profile.', 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 * 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 * separate record of issued cheques, so this also covers the common case of
+10
View File
@@ -29,6 +29,16 @@ export const endpoints = {
*/ */
list: '/accounts', list: '/accounts',
mmid: '/accounts/mmid', 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 * A page of ledger entries for one account. Takes the full account number
* from `list` above, and an optional `fromDate`/`toDate` pair that the * from `list` above, and an optional `fromDate`/`toDate` pair that the
+13
View File
@@ -251,6 +251,19 @@ export interface CbsAccount {
mmid: string | null; 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. * One deposit as `GET /accounts/deposits/detail` returns it.
* *