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 { 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 }]}>
@@ -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 -2
View File
@@ -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)}`}