diff --git a/src/components/AccountCard/index.tsx b/src/components/AccountCard/index.tsx
index d58390d..b71c66a 100644
--- a/src/components/AccountCard/index.tsx
+++ b/src/components/AccountCard/index.tsx
@@ -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
- {`${account.accountType.toUpperCase()} ACCOUNT`}
+ {accountTypeLabel(account.accountType).toUpperCase()}
@@ -112,6 +143,74 @@ const AccountCardComponent = ({
+ {onCheckBalance ? (
+
+ {balanceLoading ? (
+
+
+
+ Checking with your bank…
+
+
+ ) : balanceError ? (
+
+
+
+
+ {balanceError}
+
+ {balanceRetryable ? (
+
+ Tap to try again
+
+ ) : null}
+
+
+ ) : balance !== undefined ? (
+
+
+
+ AVAILABLE BALANCE
+
+
+ {formatCurrency(balance)}
+
+
+ {/* 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. */}
+
+
+ ) : (
+
+
+
+ Check balance
+
+
+
+ )}
+
+ ) : null}
+
@@ -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',
diff --git a/src/components/AccountSelector/index.tsx b/src/components/AccountSelector/index.tsx
index 417a591..cd32689 100644
--- a/src/components/AccountSelector/index.tsx
+++ b/src/components/AccountSelector/index.tsx
@@ -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 = ({
]}
>
- {account.accountType.toUpperCase()}
+ {accountTypeLabel(account.accountType).toUpperCase()}
{`XXXX ${account.accountNumber.slice(-4)}`}
diff --git a/src/modules/home/screens/AccountDetailsScreen.tsx b/src/modules/home/screens/AccountDetailsScreen.tsx
index e2ea27a..5933be3 100644
--- a/src/modules/home/screens/AccountDetailsScreen.tsx
+++ b/src/modules/home/screens/AccountDetailsScreen.tsx
@@ -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 { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
@@ -13,6 +13,10 @@ import { useTheme, fontFamily } from '../../../theme';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
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 SPACING = 16;
@@ -28,6 +32,20 @@ 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);
@@ -37,6 +55,49 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
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>({});
+
+ 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 +144,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 +209,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
return (
@@ -174,6 +235,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 +253,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}
/>
);
diff --git a/src/modules/home/screens/AccountsMmidScreen.tsx b/src/modules/home/screens/AccountsMmidScreen.tsx
index fde65af..5fc44fd 100644
--- a/src/modules/home/screens/AccountsMmidScreen.tsx
+++ b/src/modules/home/screens/AccountsMmidScreen.tsx
@@ -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.
-
@@ -104,7 +104,7 @@ export default function AccountsMmidScreen(_props: Props) {
{account.ifsc}
-
+ :null)
);
const apiError = error as ApiError | undefined;
diff --git a/src/modules/home/screens/MiniStatementScreen.tsx b/src/modules/home/screens/MiniStatementScreen.tsx
index 6080b3e..9738c57 100644
--- a/src/modules/home/screens/MiniStatementScreen.tsx
+++ b/src/modules/home/screens/MiniStatementScreen.tsx
@@ -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) {
]}
>
- {`${account.accountType} · XXXX ${account.accountNumber.slice(-4)}`}
+ {`${accountTypeLabel(account.accountType)} · XXXX ${account.accountNumber.slice(-4)}`}
diff --git a/src/modules/home/services/accountsApi.ts b/src/modules/home/services/accountsApi.ts
index 9c9530c..caddc14 100644
--- a/src/modules/home/services/accountsApi.ts
+++ b/src/modules/home/services/accountsApi.ts
@@ -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({
+ queryFn: (accountNumber) =>
+ resolveLive(async () => {
+ const response = await apiClient.get(
+ 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,
diff --git a/src/modules/home/utils/accountRules.test.ts b/src/modules/home/utils/accountRules.test.ts
index 48fb771..5aaa93e 100644
--- a/src/modules/home/utils/accountRules.test.ts
+++ b/src/modules/home/utils/accountRules.test.ts
@@ -83,10 +83,11 @@ describe('isOperativeAccount', () => {
});
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');
});
});
diff --git a/src/modules/home/utils/accountRules.ts b/src/modules/home/utils/accountRules.ts
index 1585e8f..06bc19b 100644
--- a/src/modules/home/utils/accountRules.ts
+++ b/src/modules/home/utils/accountRules.ts
@@ -22,6 +22,19 @@ 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 `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.
*
@@ -35,11 +48,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(' ');
diff --git a/src/modules/services/screens/BalanceEnquiryScreen.tsx b/src/modules/services/screens/BalanceEnquiryScreen.tsx
index 159ec90..f31cb39 100644
--- a/src/modules/services/screens/BalanceEnquiryScreen.tsx
+++ b/src/modules/services/screens/BalanceEnquiryScreen.tsx
@@ -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) {
- {`${account.accountType.toUpperCase()} ACCOUNT`}
+ {accountTypeLabel(account.accountType).toUpperCase()}
{`XXXX ${account.accountNumber.slice(-4)}`}
diff --git a/src/services/api/cbsErrors.test.ts b/src/services/api/cbsErrors.test.ts
index 1e969ca..bbd98f7 100644
--- a/src/services/api/cbsErrors.test.ts
+++ b/src/services/api/cbsErrors.test.ts
@@ -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', () => {
diff --git a/src/services/api/cbsErrors.ts b/src/services/api/cbsErrors.ts
index 0309d25..9355abe 100644
--- a/src/services/api/cbsErrors.ts
+++ b/src/services/api/cbsErrors.ts
@@ -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 = {
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
diff --git a/src/services/api/endpoints.ts b/src/services/api/endpoints.ts
index 281f467..033043e 100644
--- a/src/services/api/endpoints.ts
+++ b/src/services/api/endpoints.ts
@@ -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
diff --git a/src/types/cbs.ts b/src/types/cbs.ts
index da75299..f258e52 100644
--- a/src/types/cbs.ts
+++ b/src/types/cbs.ts
@@ -251,6 +251,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.
*