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:
co-authored by
Claude Opus 5
parent
2dcb10775c
commit
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 }]}>
|
||||
@@ -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)}`}
|
||||
|
||||
Reference in New Issue
Block a user