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. *