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 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,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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)}`}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user