Add TPIN creation, TPIN-authorised transfers, MPIN reset and transfer history
- Create TPIN: current MPIN -> OTP -> encrypted TPIN via /tpin/create/*, from Services and from the transfer screen on TPIN_NOT_SET - IMPS initiate now sends encryptedTpin instead of encryptedMpin; handles INVALID_TPIN and TPIN_NOT_SET - Share one RSA-OAEP encryption and key-retry path for MPIN and TPIN - Forgot MPIN on sign-in and Reset MPIN in the Security Center - Fund transfer history list and detail, reachable from Home - Brand logo on sign-in and palette updates Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
abb14d5768
commit
f1def81eb7
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -29,6 +29,8 @@ interface InputProps extends TextInputProps {
|
||||
// on the TextInput overwrote it — which is why SupportScreen's description
|
||||
// box had been rendering as a single line despite asking for four.
|
||||
style?: TextInputProps['style'];
|
||||
/** Set when the field sits directly on the brand wash, so the label reads as white. */
|
||||
onBrand?: boolean;
|
||||
}
|
||||
|
||||
export const Input = ({
|
||||
@@ -44,6 +46,7 @@ export const Input = ({
|
||||
editable = true,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onBrand = false,
|
||||
...props
|
||||
}: InputProps) => {
|
||||
const theme = useTheme();
|
||||
@@ -90,7 +93,12 @@ export const Input = ({
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label ? (
|
||||
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
|
||||
<Text
|
||||
style={[
|
||||
theme.text.eyebrow,
|
||||
{ color: onBrand ? theme.colors.onBrandPrimary : theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
{required ? <Text style={{ color: theme.colors.danger }}> *</Text> : null}
|
||||
</Text>
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export const env = {
|
||||
/** Every documented endpoint lives under this prefix. */
|
||||
apiBaseUrl: `${API_ORIGIN}/api/v1`,
|
||||
/** Organisation the app is branded for; sent at registration and at sign-in. */
|
||||
orgId: '107295',
|
||||
orgId: '107295',
|
||||
/**
|
||||
* Modules with no backend behind them yet — cards, loans, insurance, deposits,
|
||||
* the dashboard and the agent app — still resolve from fixtures. Registration,
|
||||
|
||||
@@ -229,6 +229,7 @@ export enum FeatureKey {
|
||||
|
||||
PROFILE_SECURITY = 'profile.security',
|
||||
PROFILE_SECURITY_CHANGE_PIN = 'profile.security.changePin',
|
||||
PROFILE_SECURITY_RESET_MPIN = 'profile.security.resetMpin',
|
||||
PROFILE_SECURITY_CHANGE_PASSWORD = 'profile.security.changePassword',
|
||||
PROFILE_SECURITY_BIOMETRIC = 'profile.security.biometric',
|
||||
PROFILE_SECURITY_TWO_FACTOR = 'profile.security.twoFactor',
|
||||
|
||||
@@ -239,6 +239,7 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
|
||||
[FeatureKey.PROFILE_SECURITY]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_CHANGE_PIN]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_RESET_MPIN]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_BIOMETRIC]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_TWO_FACTOR]: { enabled: true },
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Image, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
|
||||
const LOGO = require('../../../../assets/images/logo.jpg');
|
||||
|
||||
/** The source file is 354x99; the lock-up keeps that ratio at any width. */
|
||||
const ASPECT_RATIO = 354 / 99;
|
||||
|
||||
interface BrandLogoProps {
|
||||
/** Rendered width of the logo itself, excluding the plate's padding. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bank's logo, set on a white plate.
|
||||
*
|
||||
* The logo is a JPG with a white ground and no transparency, so it cannot sit
|
||||
* directly on the red brand wash — it would show as a hard-edged white box.
|
||||
* The plate makes that white deliberate: a rounded card the logo belongs to.
|
||||
* It uses `white`, not `surface`, because the logo's own ground is literal
|
||||
* white in both themes and a dark-mode surface would frame it in charcoal.
|
||||
*/
|
||||
const BrandLogoComponent = ({ width = 200 }: BrandLogoProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
accessible
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel="Apna Sahakari Bank Ltd."
|
||||
style={[styles.plate, { backgroundColor: theme.colors.white, borderRadius: theme.radius.sm }]}
|
||||
>
|
||||
<Image
|
||||
source={LOGO}
|
||||
resizeMode="contain"
|
||||
style={{ width, height: width / ASPECT_RATIO }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
plate: {
|
||||
alignSelf: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export const BrandLogo = memo(BrandLogoComponent);
|
||||
@@ -16,6 +16,12 @@ interface CodeInputProps {
|
||||
autoFocus?: boolean;
|
||||
onSubmit?: () => void;
|
||||
editable?: boolean;
|
||||
/**
|
||||
* Set when the field sits directly on the red brand wash (sign-in). The
|
||||
* label and error text switch to on-brand white, and the focus ring to
|
||||
* brass — a `primary` red ring would vanish against the red around it.
|
||||
*/
|
||||
onBrand?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +39,7 @@ export const CodeInput = ({
|
||||
autoFocus = false,
|
||||
onSubmit,
|
||||
editable = true,
|
||||
onBrand = false,
|
||||
}: CodeInputProps) => {
|
||||
const theme = useTheme();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
@@ -58,7 +65,14 @@ export const CodeInput = ({
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label ? (
|
||||
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text
|
||||
style={[
|
||||
theme.text.eyebrow,
|
||||
{ color: onBrand ? theme.colors.onBrandPrimary : theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* The hidden input is scoped to this wrapper rather than the whole
|
||||
@@ -86,11 +100,13 @@ export const CodeInput = ({
|
||||
borderColor: error
|
||||
? theme.colors.danger
|
||||
: isActive
|
||||
? theme.colors.primary
|
||||
? onBrand
|
||||
? theme.colors.accentFill
|
||||
: theme.colors.primary
|
||||
: filled
|
||||
? theme.colors.cardBorderStrong
|
||||
: theme.colors.cardBorder,
|
||||
borderWidth: isActive ? 1.5 : 1,
|
||||
borderWidth: isActive ? (onBrand ? 2 : 1.5) : 1,
|
||||
backgroundColor: error
|
||||
? theme.colors.dangerSurface
|
||||
: editable
|
||||
@@ -136,8 +152,18 @@ export const CodeInput = ({
|
||||
|
||||
{error ? (
|
||||
<View style={styles.message}>
|
||||
<Ionicons name="alert-circle" size={14} color={theme.colors.danger} />
|
||||
<Text style={[theme.text.caption, styles.messageText, { color: theme.colors.danger }]}>
|
||||
<Ionicons
|
||||
name="alert-circle"
|
||||
size={14}
|
||||
color={onBrand ? theme.colors.onBrandPrimary : theme.colors.danger}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
theme.text.caption,
|
||||
styles.messageText,
|
||||
{ color: onBrand ? theme.colors.onBrandPrimary : theme.colors.danger },
|
||||
]}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
import { BrandLogo } from '../BrandLogo';
|
||||
|
||||
/**
|
||||
* The brand lock-up at the top of the sign-in screen.
|
||||
*
|
||||
* There is no logo file in the repo, so the mark is drawn from the Ionicons
|
||||
* `shield-checkmark` glyph — a shield with a tick, which is the mark the design
|
||||
* system specifies.
|
||||
*
|
||||
* It sits on the navy wash, so it is drawn in `onBrand` white (7.12:1 at the
|
||||
* lightest point of the gradient) rather than in `primary`, which would be
|
||||
* navy on navy.
|
||||
* The brand lock-up at the top of the sign-in screen: the bank's logo on its
|
||||
* white plate.
|
||||
*
|
||||
* Deliberately a single quiet row rather than a stacked plate-and-headline.
|
||||
* The sign-in screen's large type belongs to the customer's own name, and a
|
||||
@@ -22,28 +13,6 @@ import { useTheme } from '../../../../theme';
|
||||
* this screen is a lock, and marketing copy on a lock erodes the trust it
|
||||
* needs.
|
||||
*/
|
||||
const LoginHeroComponent = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Ionicons name="shield-checkmark" size={26} color={theme.colors.onBrandPrimary} />
|
||||
<Text
|
||||
accessibilityRole="header"
|
||||
style={[theme.text.h2, { color: theme.colors.onBrandPrimary }]}
|
||||
>
|
||||
BankCBS
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
const LoginHeroComponent = () => <BrandLogo width={230} />;
|
||||
|
||||
export const LoginHero = memo(LoginHeroComponent);
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { Button, Card, InfoBanner, Input, Stepper } from '../../../components';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import { toApiError } from '../../../services/api/serviceHelpers';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import { authService } from '../services/authApi';
|
||||
import { disableBiometricSignIn } from '../store/authThunk';
|
||||
import { CodeInput } from './CodeInput';
|
||||
import {
|
||||
MPIN_LENGTH,
|
||||
describeMpinProblem,
|
||||
isAcceptableNewMpin,
|
||||
isValidOtp,
|
||||
isValidPhone,
|
||||
} from '../utils/authValidation';
|
||||
|
||||
interface MpinResetFlowProps {
|
||||
orgId: string;
|
||||
/** Prefills the mobile step. */
|
||||
initialMobile?: string;
|
||||
/**
|
||||
* Fixes the mobile number to `initialMobile`. Set when a signed-in customer
|
||||
* resets from Profile — they may reset their own MPIN, not anyone's.
|
||||
*/
|
||||
lockMobile?: boolean;
|
||||
/** Shown above the mobile step, e.g. that the reset will sign them out. */
|
||||
notice?: string;
|
||||
/** Runs once the new MPIN is saved and every secret here is cleared. */
|
||||
onSuccess: () => void;
|
||||
/** The profile never set an MPIN, so this is a registration, not a reset. */
|
||||
onMpinNotSet?: () => void;
|
||||
}
|
||||
|
||||
type Step = 'mobile' | 'otp' | 'newMpin';
|
||||
|
||||
/** How long before "send a new code" is offered. There is no resend endpoint. */
|
||||
const RESTART_COOLDOWN_SECONDS = 30;
|
||||
|
||||
const STEPS: ReadonlyArray<{ key: Step; label: string }> = [
|
||||
{ key: 'mobile', label: 'Verify' },
|
||||
{ key: 'otp', label: 'OTP' },
|
||||
{ key: 'newMpin', label: 'New MPIN' },
|
||||
];
|
||||
|
||||
const secondsUntil = (iso: string | null | undefined) =>
|
||||
iso ? Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 1000)) : null;
|
||||
|
||||
const formatCountdown = (seconds: number) =>
|
||||
`${Math.floor(seconds / 60)}:${`${seconds % 60}`.padStart(2, '0')}`;
|
||||
|
||||
/**
|
||||
* The MPIN reset, end to end: customer check → OTP → new MPIN.
|
||||
*
|
||||
* Shared by sign-in's Forgot MPIN and Profile's Reset MPIN, which differ only
|
||||
* in what happens around it — so the screens own the navigation, and this owns
|
||||
* the flow.
|
||||
*
|
||||
* ── WHY LOCAL STATE, AND WHY NO REDUX ───────────────────────────────────────
|
||||
* The `resetId` and the one-time `resetToken` must live in memory only and be
|
||||
* gone on back-navigation, restart and success. Holding them in this
|
||||
* component's state gives exactly that for free — leaving the screen unmounts
|
||||
* it and they are gone — and keeps them out of the Redux store, where dev
|
||||
* tooling would record them. Nothing here is persisted or logged.
|
||||
*/
|
||||
export const MpinResetFlow = ({
|
||||
orgId,
|
||||
initialMobile = '',
|
||||
lockMobile = false,
|
||||
notice,
|
||||
onSuccess,
|
||||
onMpinNotSet,
|
||||
}: MpinResetFlowProps) => {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
const [step, setStep] = useState<Step>('mobile');
|
||||
const [mobile, setMobile] = useState(initialMobile);
|
||||
const [otp, setOtp] = useState('');
|
||||
const [mpin, setMpin] = useState('');
|
||||
const [confirmMpin, setConfirmMpin] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Secrets of the flow. Screen state only — see the note above.
|
||||
const [resetId, setResetId] = useState<string | null>(null);
|
||||
const [otpExpiresAt, setOtpExpiresAt] = useState<string | null>(null);
|
||||
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
|
||||
const [resetToken, setResetToken] = useState<string | null>(null);
|
||||
const [resetTokenExpiresAt, setResetTokenExpiresAt] = useState<string | null>(null);
|
||||
|
||||
// Only its setter is used: each tick re-renders, and the countdown below
|
||||
// reads the clock afresh.
|
||||
const [, setNow] = useState(() => Date.now());
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
mounted.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// One tick drives both the OTP expiry countdown and the restart cooldown.
|
||||
useEffect(() => {
|
||||
if (step !== 'otp') {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
setCooldown((value) => (value > 0 ? value - 1 : 0));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [step]);
|
||||
|
||||
/** Throws the whole reset away and returns to the first step. */
|
||||
const restart = useCallback((message?: string) => {
|
||||
setResetId(null);
|
||||
setOtpExpiresAt(null);
|
||||
setAttemptsRemaining(null);
|
||||
setResetToken(null);
|
||||
setResetTokenExpiresAt(null);
|
||||
setOtp('');
|
||||
setMpin('');
|
||||
setConfirmMpin('');
|
||||
setStep('mobile');
|
||||
setError(message ?? null);
|
||||
}, []);
|
||||
|
||||
const messageFor = (apiError: ApiError) =>
|
||||
apiError.message || describeError(apiError.code).description;
|
||||
|
||||
/* ── Step 1: initiate ─────────────────────────────────────────────────── */
|
||||
|
||||
const handleInitiate = useCallback(async () => {
|
||||
const mobileNumber = mobile.replace(/\D/g, '');
|
||||
|
||||
if (!isValidPhone(mobileNumber) || busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await authService.initiateMpinReset(mobileNumber, orgId);
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
setResetId(result.resetId);
|
||||
setOtpExpiresAt(result.otpExpiresAt);
|
||||
setAttemptsRemaining(result.attemptsRemaining);
|
||||
setOtp('');
|
||||
setCooldown(RESTART_COOLDOWN_SECONDS);
|
||||
setNow(Date.now());
|
||||
setStep('otp');
|
||||
toast('Code sent', 'success', 'Enter the 6-digit code we sent to your mobile.');
|
||||
} catch (caught) {
|
||||
if (!mounted.current) return;
|
||||
|
||||
const apiError = toApiError(caught);
|
||||
|
||||
// Never registered an MPIN: this is a registration, not a reset.
|
||||
if (apiError.code === 'MPIN_NOT_SET' && onMpinNotSet) {
|
||||
toast(describeError(apiError.code).title, 'info', messageFor(apiError));
|
||||
onMpinNotSet();
|
||||
return;
|
||||
}
|
||||
|
||||
// CUSTOMER_NOT_FOUND, NO_ACCOUNTS_FOUND, CBS_UNAVAILABLE, CBS_ERROR and
|
||||
// the rest all stay here, with the server's message and a retry.
|
||||
setError(messageFor(apiError));
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [busy, mobile, onMpinNotSet, orgId, toast]);
|
||||
|
||||
/* ── Step 2: verify OTP ───────────────────────────────────────────────── */
|
||||
|
||||
const handleVerifyOtp = useCallback(async () => {
|
||||
if (!resetId || !isValidOtp(otp) || busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await authService.verifyMpinResetOtp({ resetId, otp });
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
// Only the token goes forward; the OTP and the reset id are done with.
|
||||
setOtp('');
|
||||
setResetId(null);
|
||||
setResetToken(result.resetToken);
|
||||
setResetTokenExpiresAt(result.resetTokenExpiresAt);
|
||||
setStep('newMpin');
|
||||
} catch (caught) {
|
||||
if (!mounted.current) return;
|
||||
|
||||
const apiError = toApiError(caught);
|
||||
setOtp('');
|
||||
|
||||
// An expired or exhausted OTP cannot be retried — a fresh request can.
|
||||
if (apiError.code === 'OTP_EXPIRED' || apiError.code === 'OTP_ATTEMPTS_EXCEEDED') {
|
||||
restart(messageFor(apiError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (attemptsRemaining !== null) {
|
||||
setAttemptsRemaining(Math.max(0, attemptsRemaining - 1));
|
||||
}
|
||||
|
||||
setError(messageFor(apiError));
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [attemptsRemaining, busy, otp, resetId, restart]);
|
||||
|
||||
/* ── Steps 3 + 4: encrypt and confirm ─────────────────────────────────── */
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!resetToken || !isAcceptableNewMpin(mpin) || confirmMpin !== mpin || busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
// An expired token is refused anyway; say so without the round trip.
|
||||
if (secondsUntil(resetTokenExpiresAt) === 0) {
|
||||
restart('Your reset window has closed. Start again to get a new code.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetches the current public key and encrypts {mpin, nonce, ts} fresh.
|
||||
const result = await authService.confirmMpinReset({ resetToken, mpin });
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
setResetToken(null);
|
||||
setResetTokenExpiresAt(null);
|
||||
setMpin('');
|
||||
setConfirmMpin('');
|
||||
|
||||
// A biometric sign-in holds the OLD MPIN behind the fingerprint, and it
|
||||
// would now fail every time. Drop it; it can be re-enabled at sign-in.
|
||||
await dispatch(disableBiometricSignIn());
|
||||
|
||||
toast(
|
||||
'MPIN reset',
|
||||
'success',
|
||||
result.status === 'ACTIVE'
|
||||
? 'Please sign in with your new MPIN.'
|
||||
: 'Sign in with your new MPIN.',
|
||||
);
|
||||
onSuccess();
|
||||
} catch (caught) {
|
||||
if (!mounted.current) return;
|
||||
|
||||
const apiError = toApiError(caught);
|
||||
|
||||
// A spent or expired token, or a different device: the reset is over.
|
||||
if (apiError.code === 'OTP_NOT_VERIFIED' || apiError.code === 'DEVICE_NOT_BOUND') {
|
||||
restart(messageFor(apiError));
|
||||
return;
|
||||
}
|
||||
|
||||
// VALIDATION_ERROR carries the rule that was broken — history, DOB,
|
||||
// mobile digits. Stay here so a different MPIN can be tried.
|
||||
setMpin('');
|
||||
setConfirmMpin('');
|
||||
setError(messageFor(apiError));
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [busy, confirmMpin, dispatch, mpin, onSuccess, resetToken, resetTokenExpiresAt, restart, toast]);
|
||||
|
||||
/* ── render ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const otpSecondsLeft = step === 'otp' ? secondsUntil(otpExpiresAt) : null;
|
||||
const otpExpired = otpSecondsLeft === 0;
|
||||
|
||||
const mpinProblem = describeMpinProblem(mpin, mobile.replace(/\D/g, ''));
|
||||
const confirmProblem =
|
||||
confirmMpin.length === MPIN_LENGTH && confirmMpin !== mpin ? 'Both entries must match' : undefined;
|
||||
|
||||
const stepIndex = STEPS.findIndex((item) => item.key === step);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Stepper steps={STEPS.map((item) => item.label)} current={stepIndex} />
|
||||
|
||||
{error ? <InfoBanner tone="danger" message={error} /> : null}
|
||||
|
||||
{step === 'mobile' ? (
|
||||
<>
|
||||
{notice ? <InfoBanner tone="info" message={notice} /> : null}
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Input
|
||||
label="Registered mobile number"
|
||||
prefix="+91"
|
||||
keyboardType="number-pad"
|
||||
maxLength={10}
|
||||
value={mobile}
|
||||
onChangeText={(value) => {
|
||||
setMobile(value.replace(/\D/g, ''));
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="98765 43210"
|
||||
editable={!busy && !lockMobile}
|
||||
/>
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
|
||||
Your bank checks this number, then texts a one-time code to it.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Continue"
|
||||
onPress={handleInitiate}
|
||||
disabled={!isValidPhone(mobile) || busy}
|
||||
loading={busy}
|
||||
style={styles.submit}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{step === 'otp' ? (
|
||||
<>
|
||||
<Card style={styles.card}>
|
||||
<CodeInput
|
||||
label="6-digit code"
|
||||
value={otp}
|
||||
onChangeText={(value) => {
|
||||
setOtp(value);
|
||||
setError(null);
|
||||
}}
|
||||
length={6}
|
||||
autoFocus
|
||||
editable={!busy && !otpExpired}
|
||||
onSubmit={handleVerifyOtp}
|
||||
/>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||
{otpSecondsLeft === null
|
||||
? `Sent to +91 ${mobile}`
|
||||
: otpExpired
|
||||
? 'This code has expired'
|
||||
: `Expires in ${formatCountdown(otpSecondsLeft)}`}
|
||||
</Text>
|
||||
{attemptsRemaining !== null ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||
{`${attemptsRemaining} ${attemptsRemaining === 1 ? 'attempt' : 'attempts'} left`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Verify code"
|
||||
onPress={handleVerifyOtp}
|
||||
disabled={!isValidOtp(otp) || busy || otpExpired}
|
||||
loading={busy}
|
||||
/>
|
||||
|
||||
{/* No resend endpoint exists; a fresh initiate is the resend. */}
|
||||
<Button
|
||||
title={cooldown > 0 && !otpExpired ? `Send a new code in ${cooldown}s` : 'Send a new code'}
|
||||
variant="ghost"
|
||||
onPress={handleInitiate}
|
||||
disabled={(cooldown > 0 && !otpExpired) || busy}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{step === 'newMpin' ? (
|
||||
<>
|
||||
<Card style={styles.card}>
|
||||
<CodeInput
|
||||
label={`New ${MPIN_LENGTH}-digit MPIN`}
|
||||
value={mpin}
|
||||
onChangeText={(value) => {
|
||||
setMpin(value);
|
||||
setError(null);
|
||||
}}
|
||||
length={MPIN_LENGTH}
|
||||
secure
|
||||
autoFocus
|
||||
editable={!busy}
|
||||
error={mpinProblem}
|
||||
/>
|
||||
<CodeInput
|
||||
label="Confirm new MPIN"
|
||||
value={confirmMpin}
|
||||
onChangeText={(value) => {
|
||||
setConfirmMpin(value);
|
||||
setError(null);
|
||||
}}
|
||||
length={MPIN_LENGTH}
|
||||
secure
|
||||
editable={!busy}
|
||||
error={confirmProblem}
|
||||
onSubmit={handleConfirm}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="Avoid repeated or sequential digits, the last four digits of your mobile, and your last three MPINs. You'll be signed out everywhere after the reset."
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Reset MPIN"
|
||||
onPress={handleConfirm}
|
||||
disabled={!isAcceptableNewMpin(mpin) || confirmMpin !== mpin || busy}
|
||||
loading={busy}
|
||||
style={styles.submit}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
card: {
|
||||
padding: 16,
|
||||
gap: 14,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
submit: {
|
||||
marginTop: 'auto',
|
||||
},
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { biometrics } from '../../../services/security/biometrics';
|
||||
import { authActions } from '../store/authSlice';
|
||||
import { selectRegistration } from '../store/authSelectors';
|
||||
import { completeMpinSetup, rememberRegisteredProfile } from '../store/authThunk';
|
||||
import { MPIN_LENGTH, describeMpinProblem, isValidMpin } from '../utils/authValidation';
|
||||
import { MPIN_LENGTH, describeMpinProblem, isAcceptableNewMpin } from '../utils/authValidation';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
import type { AuthStackParamList } from '../../../navigation/types';
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function CreateMpinScreen({ navigation }: Props) {
|
||||
: undefined;
|
||||
|
||||
const canSubmit =
|
||||
isValidMpin(mpin) && confirmMpin === mpin && !isLoading;
|
||||
isAcceptableNewMpin(mpin) && confirmMpin === mpin && !isLoading;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { env } from '../../../config/env';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useFeature } from '../../../features';
|
||||
import type { AuthStackParamList } from '../../../navigation/types';
|
||||
import { selectLastProfile } from '../store/authSelectors';
|
||||
import { MpinResetFlow } from '../components/MpinResetFlow';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'ForgotMpin'>>;
|
||||
|
||||
/** Forgot MPIN from sign-in: signed out, so success simply returns there. */
|
||||
export default function ForgotMpinScreen({ navigation }: Props) {
|
||||
const lastProfile = useAppSelector(selectLastProfile);
|
||||
const registrationEnabled = useFeature(FeatureKey.AUTH_REGISTER);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Forgot MPIN"
|
||||
subtitle="Reset it with a code sent to your registered mobile"
|
||||
withTabBar={false}
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<MpinResetFlow
|
||||
orgId={lastProfile?.orgId ?? env.orgId}
|
||||
initialMobile={lastProfile?.mobileNumber}
|
||||
onSuccess={() => navigation.navigate('Login')}
|
||||
onMpinNotSet={registrationEnabled ? () => navigation.navigate('Register') : undefined}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
@@ -86,6 +86,7 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
|
||||
const userRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_USER_ROLE);
|
||||
const agentRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_AGENT_ROLE);
|
||||
const forgotMpinEnabled = useFeature(FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD);
|
||||
|
||||
const isLoading = signInStatus === 'loading';
|
||||
|
||||
@@ -420,6 +421,7 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
{!isReturningCustomer ? (
|
||||
<Input
|
||||
label="Registered mobile number"
|
||||
onBrand
|
||||
leftAccessory={<SimIcon size={18} color={theme.colors.textSecondary} />}
|
||||
prefix="+91"
|
||||
keyboardType="number-pad"
|
||||
@@ -434,6 +436,7 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
|
||||
<CodeInput
|
||||
label="Enter MPIN"
|
||||
onBrand
|
||||
value={mpin}
|
||||
onChangeText={handleMpinChange}
|
||||
length={MPIN_LENGTH}
|
||||
@@ -540,14 +543,25 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
</Text>
|
||||
</FeatureGate>
|
||||
|
||||
{/* There is no forgot-MPIN or reset flow in the API — by policy,
|
||||
not by omission — so the app says so instead of offering a
|
||||
dead link. */}
|
||||
<Text
|
||||
style={[theme.text.caption, styles.centreText, { color: theme.colors.onBrandMuted }]}
|
||||
>
|
||||
Forgot your MPIN? Visit your branch to have it reset.
|
||||
</Text>
|
||||
{/* Customers only — an agent's credentials are the bank's to
|
||||
reset. Switching the feature off falls back to the branch. */}
|
||||
{activeRole === 'User' ? (
|
||||
forgotMpinEnabled ? (
|
||||
<Text
|
||||
accessibilityRole="link"
|
||||
onPress={() => navigation.navigate('ForgotMpin')}
|
||||
style={[theme.text.bodySmall, styles.centreText, { color: theme.colors.onBrandPrimary }]}
|
||||
>
|
||||
Forgot MPIN?
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[theme.text.caption, styles.centreText, { color: theme.colors.onBrandMuted }]}
|
||||
>
|
||||
Forgot your MPIN? Visit your branch to have it reset.
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
@@ -13,9 +13,10 @@ import type { AuthStackParamList } from '../../../navigation/types';
|
||||
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'MpinLocked'>>;
|
||||
|
||||
/**
|
||||
* A locked profile is a dead end by policy: there is no forgot-MPIN flow and no
|
||||
* in-app reset anywhere in the API. Saying so plainly beats a retry button that
|
||||
* can only ever fail.
|
||||
* A locked profile is a dead end by policy. The forgot-MPIN flow is for a
|
||||
* customer who does not know their MPIN; whether a reset also lifts a lock is
|
||||
* the bank's rule, not the app's, so it is not offered here. Saying so plainly
|
||||
* beats a retry button that can only ever fail.
|
||||
*/
|
||||
export default function MpinLockedScreen({ navigation }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useTheme } from '../../../theme';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { isValidPhone } from '../../../utils/validation';
|
||||
import { simCard, type SimInfo, type SimSubscription } from '../../../services/device/simCard';
|
||||
import { BrandLogo } from '../components/BrandLogo';
|
||||
import { SimChooser } from '../components/SimChooser';
|
||||
import { authActions } from '../store/authSlice';
|
||||
import { selectLastProfile, selectRegistration } from '../store/authSelectors';
|
||||
@@ -422,20 +423,7 @@ export default function RegisterScreen({ navigation }: Props) {
|
||||
|
||||
{/* ── Brand and purpose ──────────────────────────────────────────── */}
|
||||
<Animated.View entering={FadeInDown.duration(420)} style={styles.heroBody}>
|
||||
<View style={styles.brandRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.mark,
|
||||
{
|
||||
backgroundColor: theme.colors.onBrandSurfaceStrong,
|
||||
borderRadius: theme.radius.sm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="shield-checkmark" size={26} color={theme.colors.onBrandPrimary} />
|
||||
</View>
|
||||
<Text style={[theme.text.h1, { color: theme.colors.onBrandPrimary }]}>BankCBS</Text>
|
||||
</View>
|
||||
<BrandLogo width={270} />
|
||||
|
||||
<Text
|
||||
accessibilityRole="header"
|
||||
@@ -648,17 +636,6 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroBody: { gap: 10 },
|
||||
brandRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 9,
|
||||
},
|
||||
mark: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
import { env } from '../../../config/env';
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { isCbsApiError } from '../../../services/api/cbsErrors';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { deviceIdentity } from '../../../services/security/deviceIdentity';
|
||||
import { mpinCrypto } from '../../../services/security/mpinCrypto';
|
||||
import { mpinCrypto, withKeyRetry } from '../../../services/security/mpinCrypto';
|
||||
import type {
|
||||
CreateMpinResult,
|
||||
LoginResult,
|
||||
MpinResetConfirmResult,
|
||||
MpinResetInitiateResult,
|
||||
MpinResetVerifyResult,
|
||||
SessionIdentity,
|
||||
StartRegistrationResult,
|
||||
VerifyOtpResult,
|
||||
} from '../../../types/cbs';
|
||||
|
||||
/**
|
||||
* `MALFORMED_REQUEST` on an MPIN call is what a stale or wrong public key looks
|
||||
* like from the client — the server will not say more, because saying more
|
||||
* would leak whether the profile exists. The key is fetched fresh on every
|
||||
* encryption, so a retry picks up a rotated key; try exactly once more.
|
||||
* A second failure is a real problem worth surfacing.
|
||||
*/
|
||||
const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await call();
|
||||
} catch (error) {
|
||||
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
||||
return call();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Looks the customer up in their bank's core system, links every account it
|
||||
@@ -127,6 +110,54 @@ export const authService = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgot MPIN, step 1: checks the customer with their bank and sends an OTP.
|
||||
* The device id sent here is the one `confirmMpinReset` must repeat —
|
||||
* `deviceIdentity` is stable per install, so both read the same value.
|
||||
*/
|
||||
async initiateMpinReset(mobileNumber: string, orgId: string = env.orgId) {
|
||||
const deviceId = await deviceIdentity.get();
|
||||
|
||||
const response = await apiClient.post<MpinResetInitiateResult>(endpoints.mpinReset.initiate, {
|
||||
orgId,
|
||||
mobileNumber,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Step 2: swaps the OTP for a short-lived, one-time reset token. */
|
||||
async verifyMpinResetOtp({ resetId, otp }: { resetId: string; otp: string }) {
|
||||
const response = await apiClient.post<MpinResetVerifyResult>(endpoints.mpinReset.verifyOtp, {
|
||||
resetId,
|
||||
otp,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Steps 3 and 4: fetches the current key, encrypts the new MPIN and spends
|
||||
* the reset token on it. Encrypted inside the retry so a MALFORMED_REQUEST
|
||||
* (rotated key, stale `ts`, reused nonce) is retried once with a fresh key
|
||||
* and a fresh payload — never the same ciphertext twice.
|
||||
*/
|
||||
async confirmMpinReset({ resetToken, mpin }: { resetToken: string; mpin: string }) {
|
||||
return withKeyRetry(async () => {
|
||||
const deviceId = await deviceIdentity.get();
|
||||
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
|
||||
|
||||
const response = await apiClient.post<MpinResetConfirmResult>(endpoints.mpinReset.confirm, {
|
||||
resetToken,
|
||||
deviceId,
|
||||
encryptedMpin,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
|
||||
/** Who the current session belongs to. Doubles as a keep-alive. */
|
||||
async getIdentity() {
|
||||
const response = await apiClient.get<SessionIdentity>(endpoints.auth.me);
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { MPIN_LENGTH } from '../../../utils/validation';
|
||||
import { MPIN_LENGTH, isSequentialDigits } from '../../../utils/validation';
|
||||
|
||||
export {
|
||||
MPIN_LENGTH,
|
||||
isAcceptableNewMpin,
|
||||
isRepeatedDigits,
|
||||
isSequentialDigits,
|
||||
isValidMpin,
|
||||
isValidOtp,
|
||||
isValidPhone,
|
||||
} from '../../../utils/validation';
|
||||
|
||||
/** Inline copy for why an MPIN was rejected before it ever left the device. */
|
||||
export const describeMpinProblem = (value: string): string | undefined => {
|
||||
/**
|
||||
* Inline copy for why a NEW MPIN was rejected before it ever left the device —
|
||||
* for choosing one (creation, reset), not for entering one at sign-in.
|
||||
*
|
||||
* `mobileNumber`, when known, adds the backend's "not the last four digits of
|
||||
* your mobile" rule, so the customer is told before a round trip rather than
|
||||
* after one.
|
||||
*/
|
||||
export const describeMpinProblem = (value: string, mobileNumber?: string): string | undefined => {
|
||||
if (value.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -26,5 +35,13 @@ export const describeMpinProblem = (value: string): string | undefined => {
|
||||
return 'Pick an MPIN that is not the same digit repeated';
|
||||
}
|
||||
|
||||
if (isSequentialDigits(value)) {
|
||||
return 'Pick an MPIN that is not a run of digits like 1234 or 4321';
|
||||
}
|
||||
|
||||
if (mobileNumber && MPIN_LENGTH === 4 && mobileNumber.slice(-4) === value) {
|
||||
return 'Pick an MPIN that is not the last four digits of your mobile number';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { BankCard } from '../../../types/app';
|
||||
import { CardItem } from './CardItem';
|
||||
|
||||
@@ -32,6 +33,7 @@ const CardCarouselComponent = ({
|
||||
selectedIndex,
|
||||
onSnapToCard,
|
||||
}: CardCarouselProps) => {
|
||||
const theme = useTheme();
|
||||
const scrollX = useSharedValue(selectedIndex * ITEM_SIZE);
|
||||
|
||||
const initialScrollIndex = useMemo(
|
||||
@@ -97,7 +99,9 @@ const CardCarouselComponent = ({
|
||||
key={card.id}
|
||||
style={[
|
||||
styles.dot,
|
||||
index === selectedIndex ? styles.dotActive : styles.dotInactive,
|
||||
index === selectedIndex
|
||||
? [styles.dotActive, { backgroundColor: theme.colors.primary }]
|
||||
: { backgroundColor: theme.colors.cardBorderStrong },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
@@ -130,10 +134,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
dotActive: {
|
||||
width: 24,
|
||||
backgroundColor: '#124577',
|
||||
},
|
||||
dotInactive: {
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.14)',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useGetFundTransferHistoryQuery } from '../../payments/services/paymentsApi';
|
||||
import type {
|
||||
BeneficiaryMode,
|
||||
FundTransferHistoryItem,
|
||||
FundTransferStatus,
|
||||
} from '../../../types/cbs';
|
||||
|
||||
export const HISTORY_PAGE_SIZE = 10;
|
||||
|
||||
export interface HistoryFilters {
|
||||
status: FundTransferStatus | 'ALL';
|
||||
mode: BeneficiaryMode | 'ALL';
|
||||
/** Inclusive, `yyyy-MM-dd`, sent only as a pair. */
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transfer history, accumulated page by page — the same shape as
|
||||
* `useStatement`, for the same reason: RTK Query caches one entry per page, and
|
||||
* the list needs every page walked so far.
|
||||
*
|
||||
* Any change of filter starts again from page 0, as the guide requires; page 2
|
||||
* of the old filter has no meaning under the new one. The next page is asked
|
||||
* for only while the server says `last` is false.
|
||||
*/
|
||||
export const useFundTransferHistory = (filters: HistoryFilters) => {
|
||||
const queryKey = `${filters.status}|${filters.mode}|${filters.fromDate ?? ''}|${filters.toDate ?? ''}`;
|
||||
const [page, setPage] = useState(0);
|
||||
const [pages, setPages] = useState<Record<number, FundTransferHistoryItem[]>>({});
|
||||
const loadedKey = useRef(queryKey);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetFundTransferHistoryQuery(
|
||||
{ ...filters, page, size: HISTORY_PAGE_SIZE },
|
||||
{ refetchOnMountOrArgChange: true },
|
||||
);
|
||||
|
||||
// Reset before the new filter's first response lands, so the list is never
|
||||
// briefly the old filter's rows under the new filter's chips.
|
||||
if (loadedKey.current !== queryKey) {
|
||||
loadedKey.current = queryKey;
|
||||
|
||||
if (page !== 0) {
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
if (Object.keys(pages).length > 0) {
|
||||
setPages({});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPages((current) =>
|
||||
current[data.number] === data.content ? current : { ...current, [data.number]: data.content },
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
/**
|
||||
* Flattened in page order with repeats dropped. The server caches a page for
|
||||
* about 30 seconds, so a row can shift across a page boundary mid-browse;
|
||||
* `transferId` is the stable key that stops it appearing twice.
|
||||
*/
|
||||
const transfers = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const ordered: FundTransferHistoryItem[] = [];
|
||||
|
||||
Object.keys(pages)
|
||||
.map(Number)
|
||||
.sort((left, right) => left - right)
|
||||
.forEach((index) => {
|
||||
(pages[index] ?? []).forEach((item) => {
|
||||
if (!seen.has(item.transferId)) {
|
||||
seen.add(item.transferId);
|
||||
ordered.push(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return ordered;
|
||||
}, [pages]);
|
||||
|
||||
const hasMore = data ? !data.last : false;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasMore || isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPage((current) => current + 1);
|
||||
}, [hasMore, isFetching]);
|
||||
|
||||
/**
|
||||
* Pull-to-refresh: start again from page 0.
|
||||
*
|
||||
* The fresh page is written straight from the refetch's own result, not left
|
||||
* to the `[data]` effect above. The server caches a page for ~30 seconds, so a
|
||||
* refresh usually returns exactly what it returned before — and RTK Query's
|
||||
* structural sharing then hands back the SAME `data` object, the effect never
|
||||
* fires, and a list cleared before the request stayed empty. The current rows
|
||||
* also stay on screen until the fresh page lands, rather than blanking.
|
||||
*/
|
||||
const reload = useCallback(() => {
|
||||
if (page !== 0) {
|
||||
// A different cache entry, so `data` genuinely changes and the effect
|
||||
// refills the list.
|
||||
setPages({});
|
||||
setPage(0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
refetch()
|
||||
.unwrap()
|
||||
.then((fresh) => setPages({ [fresh.number]: fresh.content }))
|
||||
.catch(() => {
|
||||
// Surfaced through `error`; the rows already on screen are kept.
|
||||
});
|
||||
}, [page, refetch]);
|
||||
|
||||
return {
|
||||
transfers,
|
||||
totalElements: data?.totalElements ?? 0,
|
||||
hasMore,
|
||||
isLoading: isLoading || (isFetching && transfers.length === 0),
|
||||
isLoadingMore: isFetching && page > 0,
|
||||
isRefreshing: isFetching && page === 0 && transfers.length > 0,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
|
||||
import type { HomeStackParamList, MainTabParamList } from '../../../navigation/types';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { useGetFundTransferDetailQuery } from '../../payments/services/paymentsApi';
|
||||
import { MODE_LABEL, humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<HomeStackParamList, 'TransferDetail'>,
|
||||
BottomTabScreenProps<MainTabParamList>
|
||||
>;
|
||||
|
||||
const when = (iso: string | null) => (iso ? `${formatDate(iso)}, ${formatTime(iso)}` : null);
|
||||
|
||||
export default function TransferDetailScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const showError = useApiErrorToast();
|
||||
const { transferId } = route.params;
|
||||
|
||||
// Always fetched fresh on open: reopening the screen is one of the two ways
|
||||
// the guide says a pending transfer should re-check its state.
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetFundTransferDetailQuery(transferId, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
// A transfer that is not on this profile: close the detail and say why.
|
||||
useEffect(() => {
|
||||
if (apiError?.code === 'TRANSACTION_NOT_FOUND') {
|
||||
showError(apiError);
|
||||
navigation.goBack();
|
||||
}
|
||||
}, [apiError, navigation, showError]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ScreenShell title="Transfer details">
|
||||
<Loader />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
const copy = describeError(apiError?.code);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Transfer details">
|
||||
<EmptyState
|
||||
title={copy.title}
|
||||
description={apiError?.message || copy.description}
|
||||
actionLabel="Try again"
|
||||
onAction={refetch}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const status = statusCopy(data.status);
|
||||
const colors = toneColors(status.tone, theme.colors);
|
||||
const showReason =
|
||||
Boolean(data.failureReason) && (data.status === 'FAILED' || data.status === 'MANUAL_REVIEW');
|
||||
|
||||
const rows: Array<[string, string | null | undefined]> = [
|
||||
['Paid to', data.payeeName],
|
||||
[data.mode === 'P2P' ? 'Mobile' : 'Account', data.payeeDisplay],
|
||||
['IFSC', data.beneficiaryIfsc],
|
||||
['MMID', data.beneficiaryMmid],
|
||||
['Type', `IMPS · ${MODE_LABEL[data.mode] ?? data.mode}`],
|
||||
['Remark', data.remark],
|
||||
['Initiated', when(data.createdAt)],
|
||||
['Last updated', when(data.updatedAt)],
|
||||
];
|
||||
|
||||
return (
|
||||
<ScreenShell title="Transfer details" scrollable={false}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching}
|
||||
onRefresh={refetch}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* ── the outcome ───────────────────────────────────────────────── */}
|
||||
<Card style={styles.hero}>
|
||||
<View style={[styles.statusIcon, { backgroundColor: colors.surface }]}>
|
||||
<Ionicons name={status.icon} size={30} color={colors.tone} />
|
||||
</View>
|
||||
<Text style={[theme.text.balance, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(data.amount)}
|
||||
</Text>
|
||||
<View style={[styles.badge, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
{/* `simple` break strategy: Android's default `highQuality` measures
|
||||
a custom font as fitting on one line, then wraps it on draw, so
|
||||
the last word ("pending") was clipped outside the pill. */}
|
||||
<Text
|
||||
textBreakStrategy="simple"
|
||||
style={[theme.text.bodyStrong, styles.badgeText, { color: colors.tone }]}
|
||||
>
|
||||
{status.label}
|
||||
</Text>
|
||||
</View>
|
||||
{showReason ? (
|
||||
<Text style={[theme.text.body, styles.centre, { color: theme.colors.danger }]}>
|
||||
{humaniseReason(data.failureReason as string)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{data.status === 'PENDING_RECONCILIATION' || data.status === 'PROCESSING' ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
message="Your bank is still confirming this transfer. Don't send it again — refresh in a little while to see the final status."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.status === 'PENDING_OTP' ? (
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="This transfer was never confirmed with an OTP, so no money has left your account."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.status === 'MANUAL_REVIEW' ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
message="Your bank is reviewing this transfer. Contact support and quote the reference below if you need help."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* ── the reference the customer quotes ─────────────────────────── */}
|
||||
<Card style={styles.section}>
|
||||
<Text style={[theme.text.eyebrow, { color: theme.colors.textMuted }]}>Reference</Text>
|
||||
<Text selectable style={[theme.text.code, { color: theme.colors.textPrimary }]}>
|
||||
{data.referenceId}
|
||||
</Text>
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
|
||||
Press and hold to copy
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.section}>
|
||||
{rows
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([label, value]) => (
|
||||
<View key={label} style={styles.detailRow}>
|
||||
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text
|
||||
style={[theme.text.bodyStrong, styles.detailValue, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{status.refreshable ? (
|
||||
<Button
|
||||
title={isFetching ? 'Checking…' : 'Refresh status'}
|
||||
variant="secondary"
|
||||
onPress={refetch}
|
||||
disabled={isFetching}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.status === 'MANUAL_REVIEW' || data.status === 'FAILED' ? (
|
||||
<Button
|
||||
title="Get help"
|
||||
variant="ghost"
|
||||
onPress={() => navigation.navigate('Profile', { screen: 'HelpSupport' })}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
gap: 14,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
hero: {
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 24,
|
||||
},
|
||||
statusIcon: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
badge: {
|
||||
maxWidth: '100%',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
},
|
||||
centre: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
section: {
|
||||
gap: 6,
|
||||
padding: 16,
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
detailValue: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import DatePicker from 'react-native-date-picker';
|
||||
|
||||
import {
|
||||
AppModal as Modal,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
Loader,
|
||||
ScreenShell,
|
||||
SegmentedControl,
|
||||
} from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
|
||||
import type { HomeStackParamList } from '../../../navigation/types';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import type { BeneficiaryMode, FundTransferHistoryItem, FundTransferStatus } from '../../../types/cbs';
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import { toStatementDate } from '../../home/hooks/useStatement';
|
||||
import { useFundTransferHistory, type HistoryFilters } from '../hooks/useFundTransferHistory';
|
||||
import { humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
|
||||
|
||||
type Props = NativeStackScreenProps<HomeStackParamList, 'TransferHistory'>;
|
||||
|
||||
const STATUS_FILTERS: ReadonlyArray<{ value: FundTransferStatus | 'ALL'; label: string }> = [
|
||||
{ value: 'ALL', label: 'All' },
|
||||
{ value: 'SUCCESS', label: 'Successful' },
|
||||
{ value: 'FAILED', label: 'Failed' },
|
||||
{ value: 'PROCESSING', label: 'In progress' },
|
||||
{ value: 'PENDING_RECONCILIATION', label: 'Pending' },
|
||||
{ value: 'MANUAL_REVIEW', label: 'Under review' },
|
||||
];
|
||||
|
||||
const MODE_FILTERS: ReadonlyArray<{ value: BeneficiaryMode | 'ALL'; label: string }> = [
|
||||
{ value: 'ALL', label: 'All' },
|
||||
{ value: 'P2A', label: 'Account' },
|
||||
{ value: 'P2P', label: 'Mobile' },
|
||||
];
|
||||
|
||||
function TransferRow({
|
||||
item,
|
||||
onPress,
|
||||
}: {
|
||||
item: FundTransferHistoryItem;
|
||||
onPress: (item: FundTransferHistoryItem) => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const copy = statusCopy(item.status);
|
||||
const colors = toneColors(copy.tone, theme.colors);
|
||||
// Shown only for failed and review states, and only when there is one.
|
||||
const showReason =
|
||||
Boolean(item.failureReason) && (item.status === 'FAILED' || item.status === 'MANUAL_REVIEW');
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${item.payeeName ?? 'Transfer'}, ${formatCurrency(item.amount)}, ${copy.label}`}
|
||||
onPress={() => onPress(item)}
|
||||
>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
|
||||
<Ionicons name={copy.icon} size={20} color={colors.tone} />
|
||||
</View>
|
||||
|
||||
<View style={styles.copy}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{item.payeeName || 'Payee'}
|
||||
</Text>
|
||||
{item.payeeDisplay ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||
{item.payeeDisplay}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
|
||||
{`${formatDate(item.createdAt)} · ${formatTime(item.createdAt)}`}
|
||||
</Text>
|
||||
{showReason ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>
|
||||
{humaniseReason(item.failureReason as string)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.trailing}>
|
||||
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(item.amount)}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{ backgroundColor: colors.surface, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
{/* See TransferDetailScreen: `simple` stops Android clipping the
|
||||
last word of a label drawn in a custom font. */}
|
||||
<Text
|
||||
textBreakStrategy="simple"
|
||||
style={[theme.text.caption, styles.badgeText, { color: colors.tone }]}
|
||||
>
|
||||
{copy.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransferHistoryScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const [filters, setFilters] = useState<HistoryFilters>({ status: 'ALL', mode: 'ALL' });
|
||||
|
||||
const [rangeOpen, setRangeOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState<'from' | 'to' | null>(null);
|
||||
const [tempFrom, setTempFrom] = useState(() => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 30);
|
||||
return date;
|
||||
});
|
||||
const [tempTo, setTempTo] = useState(() => new Date());
|
||||
const [rangeError, setRangeError] = useState<string | null>(null);
|
||||
|
||||
const { transfers, totalElements, hasMore, isLoading, isLoadingMore, isRefreshing, error, loadMore, reload } =
|
||||
useFundTransferHistory(filters);
|
||||
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
const hasRange = Boolean(filters.fromDate && filters.toDate);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(item: FundTransferHistoryItem) => navigation.navigate('TransferDetail', { transferId: item.transferId }),
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const applyRange = useCallback(() => {
|
||||
if (tempFrom.getTime() > tempTo.getTime()) {
|
||||
setRangeError('The start date cannot be after the end date.');
|
||||
return;
|
||||
}
|
||||
|
||||
setRangeError(null);
|
||||
setRangeOpen(false);
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
fromDate: toStatementDate(tempFrom),
|
||||
toDate: toStatementDate(tempTo),
|
||||
}));
|
||||
}, [tempFrom, tempTo]);
|
||||
|
||||
const clearRange = useCallback(() => {
|
||||
setRangeOpen(false);
|
||||
setRangeError(null);
|
||||
setFilters((current) => ({ ...current, fromDate: undefined, toDate: undefined }));
|
||||
}, []);
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (isLoading) return 'Loading your transfers';
|
||||
return `${totalElements} ${totalElements === 1 ? 'transfer' : 'transfers'}`;
|
||||
}, [isLoading, totalElements]);
|
||||
|
||||
const listHeader = (
|
||||
<View style={styles.filters}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
|
||||
{STATUS_FILTERS.map((option) => {
|
||||
const active = filters.status === option.value;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: active }}
|
||||
onPress={() => setFilters((current) => ({ ...current, status: option.value }))}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
borderRadius: theme.radius.pill,
|
||||
backgroundColor: active ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: active ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
theme.text.bodySmall,
|
||||
{ color: active ? theme.colors.textOnBrand : theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
<SegmentedControl
|
||||
options={MODE_FILTERS}
|
||||
value={filters.mode}
|
||||
onChange={(mode) => setFilters((current) => ({ ...current, mode }))}
|
||||
accessibilityLabel="Transfer type"
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setRangeOpen(true)}
|
||||
style={[
|
||||
styles.rangeButton,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: hasRange ? theme.colors.primary : theme.colors.cardBorder,
|
||||
borderRadius: theme.radius.md,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
|
||||
{hasRange
|
||||
? `${formatDate(filters.fromDate as string)} – ${formatDate(filters.toDate as string)}`
|
||||
: 'Any date'}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderEmpty = () => {
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (apiError) {
|
||||
const copy = describeError(apiError.code);
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
title={copy.title}
|
||||
description={apiError.message || copy.description}
|
||||
actionLabel="Try again"
|
||||
onAction={reload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
title="No transfers found"
|
||||
description={
|
||||
filters.status === 'ALL' && filters.mode === 'ALL' && !hasRange
|
||||
? 'Transfers you make from the app will appear here.'
|
||||
: 'Nothing matches these filters. Try widening them.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="History" subtitle={subtitle} scrollable={false}>
|
||||
<FlatList
|
||||
data={transfers}
|
||||
keyExtractor={(item) => item.transferId}
|
||||
renderItem={({ item }) => <TransferRow item={item} onPress={openDetail} />}
|
||||
ListHeaderComponent={listHeader}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={
|
||||
isLoadingMore ? (
|
||||
<ActivityIndicator style={styles.footer} color={theme.colors.primary} />
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={Separator}
|
||||
onEndReached={hasMore ? loadMore : undefined}
|
||||
onEndReachedThreshold={0.4}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={reload}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
|
||||
<Modal visible={rangeOpen} onClose={() => setRangeOpen(false)} title="Filter by date">
|
||||
<View style={styles.modalBody}>
|
||||
{(['from', 'to'] as const).map((which) => (
|
||||
<View key={which} style={styles.dateField}>
|
||||
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
|
||||
{which === 'from' ? 'From' : 'To'}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setPickerOpen(which)}
|
||||
style={[
|
||||
styles.rangeButton,
|
||||
{
|
||||
backgroundColor: theme.colors.surfaceMuted,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
borderRadius: theme.radius.md,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(toStatementDate(which === 'from' ? tempFrom : tempTo))}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{rangeError ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>{rangeError}</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<Button title="Apply" onPress={applyRange} style={styles.modalButton} />
|
||||
<Button
|
||||
title={hasRange ? 'Clear dates' : 'Cancel'}
|
||||
variant="secondary"
|
||||
onPress={hasRange ? clearRange : () => setRangeOpen(false)}
|
||||
style={styles.modalButton}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<DatePicker
|
||||
modal
|
||||
open={pickerOpen !== null}
|
||||
date={pickerOpen === 'to' ? tempTo : tempFrom}
|
||||
maximumDate={new Date()}
|
||||
mode="date"
|
||||
title={pickerOpen === 'to' ? 'Select end date' : 'Select start date'}
|
||||
onConfirm={(date) => {
|
||||
if (pickerOpen === 'to') setTempTo(date);
|
||||
else setTempFrom(date);
|
||||
setPickerOpen(null);
|
||||
setRangeError(null);
|
||||
}}
|
||||
onCancel={() => setPickerOpen(null)}
|
||||
/>
|
||||
</Modal>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const Separator = () => <View style={styles.separator} />;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 24,
|
||||
flexGrow: 1,
|
||||
},
|
||||
filters: {
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
chips: {
|
||||
gap: 8,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
rangeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderWidth: 1,
|
||||
},
|
||||
rangeText: {
|
||||
flex: 1,
|
||||
},
|
||||
separator: {
|
||||
height: 10,
|
||||
},
|
||||
card: {
|
||||
padding: 14,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
trailing: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 6,
|
||||
},
|
||||
badgeText: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
badge: {
|
||||
maxWidth: 150,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
},
|
||||
footer: {
|
||||
marginVertical: 16,
|
||||
},
|
||||
modalBody: {
|
||||
gap: 14,
|
||||
},
|
||||
dateField: {
|
||||
gap: 6,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: 6,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import type React from 'react';
|
||||
|
||||
import type { FundTransferStatus } from '../../../types/cbs';
|
||||
import type { useTheme } from '../../../theme';
|
||||
|
||||
type IconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
type Colors = ReturnType<typeof useTheme>['colors'];
|
||||
|
||||
export type StatusTone = 'success' | 'danger' | 'warning' | 'info';
|
||||
|
||||
interface StatusCopy {
|
||||
label: string;
|
||||
icon: IconName;
|
||||
tone: StatusTone;
|
||||
/**
|
||||
* True for a state the server may still move on its own — the detail screen
|
||||
* offers a refresh for these, and never shows them as final.
|
||||
*/
|
||||
refreshable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The labels are the backend guide's own recommended wording. A pending
|
||||
* reconciliation deliberately reads as neither success nor failure: the money
|
||||
* may or may not have moved, and only the bank's ReqChkTxn can say which.
|
||||
*/
|
||||
export const TRANSFER_STATUS: Record<FundTransferStatus, StatusCopy> = {
|
||||
PENDING_VALIDATION: { label: 'Validating beneficiary', icon: 'hourglass-outline', tone: 'info', refreshable: true },
|
||||
PENDING_OTP: { label: 'Awaiting OTP confirmation', icon: 'key-outline', tone: 'info', refreshable: true },
|
||||
PROCESSING: { label: 'Transfer in progress', icon: 'sync-outline', tone: 'info', refreshable: true },
|
||||
SUCCESS: { label: 'Transfer successful', icon: 'checkmark-circle-outline', tone: 'success', refreshable: false },
|
||||
FAILED: { label: 'Transfer failed', icon: 'close-circle-outline', tone: 'danger', refreshable: false },
|
||||
PENDING_RECONCILIATION: { label: 'Transaction status pending', icon: 'time-outline', tone: 'warning', refreshable: true },
|
||||
MANUAL_REVIEW: { label: 'Under bank review', icon: 'shield-outline', tone: 'warning', refreshable: false },
|
||||
};
|
||||
|
||||
/** An unknown status the server added later still renders, as a neutral pending one. */
|
||||
export const statusCopy = (status: string): StatusCopy =>
|
||||
TRANSFER_STATUS[status as FundTransferStatus] ?? {
|
||||
label: status.replace(/_/g, ' ').toLowerCase(),
|
||||
icon: 'help-circle-outline',
|
||||
tone: 'info',
|
||||
refreshable: true,
|
||||
};
|
||||
|
||||
export const toneColors = (tone: StatusTone, colors: Colors) => {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return { tone: colors.success, surface: colors.successSurface, border: colors.successBorder };
|
||||
case 'danger':
|
||||
return { tone: colors.danger, surface: colors.dangerSurface, border: colors.dangerBorder };
|
||||
case 'warning':
|
||||
return { tone: colors.warning, surface: colors.warningSurface, border: colors.warningBorder };
|
||||
default:
|
||||
return { tone: colors.info, surface: colors.infoSurface, border: colors.infoBorder };
|
||||
}
|
||||
};
|
||||
|
||||
/** `PAYEE_NOT_VERIFIED` → "Payee not verified". */
|
||||
export const humaniseReason = (reason: string) => {
|
||||
const words = reason.replace(/_/g, ' ').toLowerCase();
|
||||
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
};
|
||||
|
||||
export const MODE_LABEL: Record<string, string> = {
|
||||
P2A: 'To account',
|
||||
P2P: 'To mobile',
|
||||
};
|
||||
@@ -129,11 +129,31 @@ export const useStatement = ({ accountNumber, fromDate, toDate }: UseStatementAr
|
||||
setPage((current) => current + 1);
|
||||
}, [hasMore, isFetching]);
|
||||
|
||||
/**
|
||||
* Start again from page 0.
|
||||
*
|
||||
* On page 0 the fresh rows are written from the refetch's own result rather
|
||||
* than left to the `[data]` effect: an identical response comes back as the
|
||||
* SAME `data` object under RTK Query's structural sharing, the effect never
|
||||
* fires, and a list cleared first would stay empty. Off page 0, moving back
|
||||
* to it is a different cache entry, so the effect refills the list — and
|
||||
* refetching here would only re-request the page being left.
|
||||
*/
|
||||
const reload = useCallback(() => {
|
||||
setPages({});
|
||||
setPage(0);
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
if (page !== 0) {
|
||||
setPages({});
|
||||
setPage(0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
refetch()
|
||||
.unwrap()
|
||||
.then((fresh) => setPages({ [fresh.page]: fresh.content }))
|
||||
.catch(() => {
|
||||
// Surfaced through `error`; whatever is on screen is kept.
|
||||
});
|
||||
}, [page, refetch]);
|
||||
|
||||
return {
|
||||
transactions,
|
||||
|
||||
@@ -282,6 +282,22 @@ export default function HomeScreen({ navigation }: Props) {
|
||||
</Pressable>
|
||||
</FeatureGate>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Transfer history"
|
||||
style={[
|
||||
styles.iconButton,
|
||||
theme.elevation.level1,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
onPress={() => navigation.navigate('TransferHistory')}
|
||||
>
|
||||
<Ionicons name="time-outline" size={20} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
|
||||
<FeatureGate feature={FeatureKey.SEARCH_GLOBAL}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { Button, Card, InfoBanner, Stepper } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import { toApiError } from '../../../services/api/serviceHelpers';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import { MPIN_LENGTH, isValidOtp } from '../../../utils/validation';
|
||||
import { CodeInput } from '../../auth/components/CodeInput';
|
||||
import { tpinService } from '../services/tpinService';
|
||||
import { TPIN_LENGTH, describeTpinProblem, isAcceptableNewTpin } from '../utils/tpinValidation';
|
||||
|
||||
interface TpinCreateFlowProps {
|
||||
/**
|
||||
* The session's registered mobile. Feeds the "not the last four digits of
|
||||
* your mobile" rule and the OTP copy; never sent — the server reads it from
|
||||
* the session.
|
||||
*/
|
||||
mobileNumber?: string;
|
||||
/** Shown above the first step, e.g. why a transfer sent the customer here. */
|
||||
notice?: string;
|
||||
/** Runs once the TPIN is active and every secret here is cleared. */
|
||||
onSuccess: () => void;
|
||||
/** Leaves the flow after TPIN_ALREADY_SET, when there is nothing to create. */
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
type Step = 'mpin' | 'otp' | 'tpin' | 'exists';
|
||||
|
||||
/** How long before "send a new code" is offered. There is no resend endpoint. */
|
||||
const RESTART_COOLDOWN_SECONDS = 30;
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
|
||||
const STEPS: ReadonlyArray<{ key: Step; label: string }> = [
|
||||
{ key: 'mpin', label: 'Verify MPIN' },
|
||||
{ key: 'otp', label: 'OTP' },
|
||||
{ key: 'tpin', label: 'New TPIN' },
|
||||
];
|
||||
|
||||
const secondsUntil = (iso: string | null | undefined) =>
|
||||
iso ? Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 1000)) : null;
|
||||
|
||||
const formatCountdown = (seconds: number) =>
|
||||
`${Math.floor(seconds / 60)}:${`${seconds % 60}`.padStart(2, '0')}`;
|
||||
|
||||
const messageFor = (apiError: ApiError) =>
|
||||
apiError.message || describeError(apiError.code).description;
|
||||
|
||||
/**
|
||||
* Creating the TPIN, end to end: current MPIN → OTP → new TPIN.
|
||||
*
|
||||
* Shared by Services' Create TPIN and the transfer screen's TPIN_NOT_SET
|
||||
* detour, which differ only in where the customer goes afterwards — so the
|
||||
* screens own the navigation, and this owns the flow.
|
||||
*
|
||||
* ── WHY LOCAL STATE, AND WHY NO REDUX ───────────────────────────────────────
|
||||
* The MPIN, the OTP, the `tpinCreateId`, the one-time `tpinCreateToken` and
|
||||
* the TPIN itself must live in memory only, and be gone on back-navigation,
|
||||
* restart, failure and success. Component state gives exactly that — leaving
|
||||
* the screen unmounts it — and keeps them out of the Redux store, where dev
|
||||
* tooling would record them. Each is also cleared the moment its step is done
|
||||
* with it. Nothing here is persisted or logged.
|
||||
*/
|
||||
export const TpinCreateFlow = ({ mobileNumber, notice, onSuccess, onExit }: TpinCreateFlowProps) => {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const [step, setStep] = useState<Step>('mpin');
|
||||
const [mpin, setMpin] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [tpin, setTpin] = useState('');
|
||||
const [confirmTpin, setConfirmTpin] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** A failure about the flow as a whole, shown as a banner. */
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** A failure about the field on screen, shown under it. */
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
|
||||
// Secrets of the flow. Screen state only — see the note above.
|
||||
const [tpinCreateId, setTpinCreateId] = useState<string | null>(null);
|
||||
const [otpExpiresAt, setOtpExpiresAt] = useState<string | null>(null);
|
||||
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
|
||||
const [tpinCreateToken, setTpinCreateToken] = useState<string | null>(null);
|
||||
const [tokenExpiresAt, setTokenExpiresAt] = useState<string | null>(null);
|
||||
|
||||
// Only its setter is used: each tick re-renders, and the countdown below
|
||||
// reads the clock afresh.
|
||||
const [, setNow] = useState(() => Date.now());
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
mounted.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// One tick drives both the OTP expiry countdown and the restart cooldown.
|
||||
useEffect(() => {
|
||||
if (step !== 'otp') {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
setCooldown((value) => (value > 0 ? value - 1 : 0));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [step]);
|
||||
|
||||
const clearSecrets = useCallback(() => {
|
||||
setTpinCreateId(null);
|
||||
setOtpExpiresAt(null);
|
||||
setAttemptsRemaining(null);
|
||||
setTpinCreateToken(null);
|
||||
setTokenExpiresAt(null);
|
||||
setMpin('');
|
||||
setOtp('');
|
||||
setTpin('');
|
||||
setConfirmTpin('');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Throws the whole attempt away and returns to the MPIN. An OTP can only be
|
||||
* reissued by a fresh initiate, and initiate needs the MPIN every time.
|
||||
*/
|
||||
const restart = useCallback(
|
||||
(message?: string) => {
|
||||
clearSecrets();
|
||||
setFieldError(null);
|
||||
setStep('mpin');
|
||||
setError(message ?? null);
|
||||
},
|
||||
[clearSecrets],
|
||||
);
|
||||
|
||||
/** TPIN_ALREADY_SET: nothing to create, so the flow stops for good. */
|
||||
const endAsExisting = useCallback(() => {
|
||||
clearSecrets();
|
||||
setFieldError(null);
|
||||
setError(null);
|
||||
setStep('exists');
|
||||
}, [clearSecrets]);
|
||||
|
||||
/**
|
||||
* The default failure path. VALIDATION_ERROR's field map is split: the entry
|
||||
* for the field on screen goes under it, the rest go in the banner. Any
|
||||
* other failure is a banner with the server's message.
|
||||
*/
|
||||
const showFailure = useCallback((apiError: ApiError, field: string) => {
|
||||
const fields = apiError.fieldErrors ?? {};
|
||||
const own = fields[field];
|
||||
const others = Object.entries(fields)
|
||||
.filter(([key]) => key !== field)
|
||||
.map(([, message]) => message);
|
||||
|
||||
setFieldError(own ?? null);
|
||||
setError(others.length > 0 ? others.join('\n') : own ? null : messageFor(apiError));
|
||||
}, []);
|
||||
|
||||
/* ── Step 1: current MPIN → initiate ──────────────────────────────────── */
|
||||
|
||||
const handleInitiate = useCallback(async () => {
|
||||
if (mpin.length !== MPIN_LENGTH || busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
|
||||
try {
|
||||
// Fetches the current public key and encrypts {mpin, nonce, ts} fresh.
|
||||
const result = await tpinService.initiateCreate(mpin);
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
setMpin('');
|
||||
setTpinCreateId(result.tpinCreateId);
|
||||
setOtpExpiresAt(result.otpExpiresAt);
|
||||
setAttemptsRemaining(result.attemptsRemaining);
|
||||
setOtp('');
|
||||
setCooldown(RESTART_COOLDOWN_SECONDS);
|
||||
setNow(Date.now());
|
||||
setStep('otp');
|
||||
toast('Code sent', 'success', 'Enter the 6-digit code we sent to your registered mobile.');
|
||||
} catch (caught) {
|
||||
if (!mounted.current) return;
|
||||
|
||||
const apiError = toApiError(caught);
|
||||
// The MPIN is spent on this attempt whatever the answer.
|
||||
setMpin('');
|
||||
|
||||
switch (apiError.code) {
|
||||
case 'SESSION_INVALID':
|
||||
// The interceptor has already signed the customer out.
|
||||
return;
|
||||
case 'TPIN_ALREADY_SET':
|
||||
endAsExisting();
|
||||
return;
|
||||
case 'INVALID_CREDENTIALS':
|
||||
setFieldError('That MPIN is incorrect. Enter the MPIN you sign in with.');
|
||||
return;
|
||||
default:
|
||||
// MPIN_LOCKED, CUSTOMER_NOT_FOUND, CBS_UNAVAILABLE and the rest stay
|
||||
// here, with the server's message and a retry.
|
||||
showFailure(apiError, 'encryptedMpin');
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [busy, endAsExisting, mpin, showFailure, toast]);
|
||||
|
||||
/* ── Step 2: verify OTP ───────────────────────────────────────────────── */
|
||||
|
||||
const handleVerifyOtp = useCallback(async () => {
|
||||
if (!tpinCreateId || !isValidOtp(otp) || busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
|
||||
try {
|
||||
const result = await tpinService.verifyCreateOtp({ tpinCreateId, otp });
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
// Only the token goes forward; the OTP and the create id are done with.
|
||||
setOtp('');
|
||||
setTpinCreateId(null);
|
||||
setOtpExpiresAt(null);
|
||||
setAttemptsRemaining(null);
|
||||
setTpinCreateToken(result.tpinCreateToken);
|
||||
setTokenExpiresAt(result.tokenExpiresAt);
|
||||
setStep('tpin');
|
||||
} catch (caught) {
|
||||
if (!mounted.current) return;
|
||||
|
||||
const apiError = toApiError(caught);
|
||||
setOtp('');
|
||||
|
||||
switch (apiError.code) {
|
||||
case 'SESSION_INVALID':
|
||||
return;
|
||||
case 'TPIN_ALREADY_SET':
|
||||
endAsExisting();
|
||||
return;
|
||||
// An expired or exhausted OTP cannot be retried — a fresh request can.
|
||||
case 'OTP_EXPIRED':
|
||||
restart('That code expired. Enter your MPIN again to get a new one.');
|
||||
return;
|
||||
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||
restart('Too many wrong codes. Enter your MPIN again to get a new one.');
|
||||
return;
|
||||
case 'OTP_INVALID': {
|
||||
// The OTP screen stays, one attempt lighter.
|
||||
const remaining =
|
||||
attemptsRemaining === null ? null : Math.max(0, attemptsRemaining - 1);
|
||||
setAttemptsRemaining(remaining);
|
||||
setFieldError(
|
||||
remaining
|
||||
? `That code is incorrect. ${remaining} ${remaining === 1 ? 'attempt' : 'attempts'} left.`
|
||||
: 'That code is incorrect.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
showFailure(apiError, 'otp');
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [attemptsRemaining, busy, endAsExisting, otp, restart, showFailure, tpinCreateId]);
|
||||
|
||||
/** No resend endpoint: a new code means a new initiate, MPIN and all. */
|
||||
const handleNewCode = useCallback(() => {
|
||||
restart();
|
||||
toast('Enter your MPIN again', 'info', 'We need it each time we send you a new code.');
|
||||
}, [restart, toast]);
|
||||
|
||||
/* ── Step 3: encrypt the new TPIN and confirm ─────────────────────────── */
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (
|
||||
!tpinCreateToken ||
|
||||
!isAcceptableNewTpin(tpin, mobileNumber) ||
|
||||
confirmTpin !== tpin ||
|
||||
busy
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// An expired token is refused anyway; say so without the round trip.
|
||||
if (secondsUntil(tokenExpiresAt) === 0) {
|
||||
restart('Your time to set a TPIN ran out. Enter your MPIN again to get a new code.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
|
||||
try {
|
||||
// Fetches the current public key and encrypts {tpin, nonce, ts} fresh.
|
||||
await tpinService.confirmCreate({ tpinCreateToken, tpin });
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
clearSecrets();
|
||||
toast('TPIN created', 'success', 'Use it to authorise your fund transfers.');
|
||||
onSuccess();
|
||||
} catch (caught) {
|
||||
if (!mounted.current) return;
|
||||
|
||||
const apiError = toApiError(caught);
|
||||
setTpin('');
|
||||
setConfirmTpin('');
|
||||
|
||||
switch (apiError.code) {
|
||||
case 'SESSION_INVALID':
|
||||
return;
|
||||
case 'TPIN_ALREADY_SET':
|
||||
endAsExisting();
|
||||
return;
|
||||
// A spent or expired token: this attempt is over.
|
||||
case 'OTP_NOT_VERIFIED':
|
||||
case 'OTP_EXPIRED':
|
||||
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||
restart(messageFor(apiError));
|
||||
return;
|
||||
default:
|
||||
// VALIDATION_ERROR carries the rule that was broken — date of birth,
|
||||
// mobile digits, a sequence. Stay here so a different TPIN can be
|
||||
// tried against the same token.
|
||||
showFailure(apiError, 'encryptedTpin');
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [
|
||||
busy,
|
||||
clearSecrets,
|
||||
confirmTpin,
|
||||
endAsExisting,
|
||||
mobileNumber,
|
||||
onSuccess,
|
||||
restart,
|
||||
showFailure,
|
||||
toast,
|
||||
tokenExpiresAt,
|
||||
tpin,
|
||||
tpinCreateToken,
|
||||
]);
|
||||
|
||||
/* ── render ───────────────────────────────────────────────────────────── */
|
||||
|
||||
if (step === 'exists') {
|
||||
const copy = describeError('TPIN_ALREADY_SET');
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<InfoBanner tone="success" title={copy.title} message={copy.description} />
|
||||
<Button title="Done" onPress={onExit} style={styles.submit} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const mobileDigits = mobileNumber?.replace(/\D/g, '') ?? '';
|
||||
const sentTo = mobileDigits.length >= 4 ? `+91 XXXXXX${mobileDigits.slice(-4)}` : null;
|
||||
|
||||
const otpSecondsLeft = step === 'otp' ? secondsUntil(otpExpiresAt) : null;
|
||||
const otpExpired = otpSecondsLeft === 0;
|
||||
|
||||
const tpinProblem = describeTpinProblem(tpin, mobileNumber);
|
||||
const confirmProblem =
|
||||
confirmTpin.length === TPIN_LENGTH && confirmTpin !== tpin ? 'Both entries must match' : undefined;
|
||||
|
||||
const stepIndex = STEPS.findIndex((item) => item.key === step);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Stepper steps={STEPS.map((item) => item.label)} current={stepIndex} />
|
||||
|
||||
{error ? <InfoBanner tone="danger" message={error} /> : null}
|
||||
|
||||
{step === 'mpin' ? (
|
||||
<>
|
||||
{notice ? <InfoBanner tone="info" message={notice} /> : null}
|
||||
|
||||
<Card style={styles.card}>
|
||||
<CodeInput
|
||||
label="Current MPIN"
|
||||
value={mpin}
|
||||
onChangeText={(value) => {
|
||||
setMpin(value);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
}}
|
||||
length={MPIN_LENGTH}
|
||||
secure
|
||||
autoFocus
|
||||
editable={!busy}
|
||||
error={fieldError ?? undefined}
|
||||
onSubmit={handleInitiate}
|
||||
/>
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
|
||||
{sentTo
|
||||
? `We check your MPIN, then text a one-time code to ${sentTo}.`
|
||||
: 'We check your MPIN, then text a one-time code to your registered mobile.'}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Continue"
|
||||
onPress={handleInitiate}
|
||||
disabled={mpin.length !== MPIN_LENGTH || busy}
|
||||
loading={busy}
|
||||
style={styles.submit}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{step === 'otp' ? (
|
||||
<>
|
||||
<Card style={styles.card}>
|
||||
<CodeInput
|
||||
label="6-digit code"
|
||||
value={otp}
|
||||
onChangeText={(value) => {
|
||||
setOtp(value);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
}}
|
||||
length={OTP_LENGTH}
|
||||
autoFocus
|
||||
editable={!busy && !otpExpired}
|
||||
error={fieldError ?? undefined}
|
||||
onSubmit={handleVerifyOtp}
|
||||
/>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||
{otpSecondsLeft === null
|
||||
? `Sent to ${sentTo ?? 'your registered mobile'}`
|
||||
: otpExpired
|
||||
? 'This code has expired'
|
||||
: `Expires in ${formatCountdown(otpSecondsLeft)}`}
|
||||
</Text>
|
||||
{attemptsRemaining !== null ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||
{`${attemptsRemaining} ${attemptsRemaining === 1 ? 'attempt' : 'attempts'} left`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Verify code"
|
||||
onPress={handleVerifyOtp}
|
||||
disabled={!isValidOtp(otp) || busy || otpExpired}
|
||||
loading={busy}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={cooldown > 0 && !otpExpired ? `Send a new code in ${cooldown}s` : 'Send a new code'}
|
||||
variant="ghost"
|
||||
onPress={handleNewCode}
|
||||
disabled={(cooldown > 0 && !otpExpired) || busy}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{step === 'tpin' ? (
|
||||
<>
|
||||
<Card style={styles.card}>
|
||||
<CodeInput
|
||||
label={`New ${TPIN_LENGTH}-digit TPIN`}
|
||||
value={tpin}
|
||||
onChangeText={(value) => {
|
||||
setTpin(value);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
}}
|
||||
length={TPIN_LENGTH}
|
||||
secure
|
||||
autoFocus
|
||||
editable={!busy}
|
||||
error={fieldError ?? tpinProblem}
|
||||
/>
|
||||
<CodeInput
|
||||
label="Confirm TPIN"
|
||||
value={confirmTpin}
|
||||
onChangeText={(value) => {
|
||||
setConfirmTpin(value);
|
||||
setError(null);
|
||||
}}
|
||||
length={TPIN_LENGTH}
|
||||
secure
|
||||
editable={!busy}
|
||||
error={confirmProblem}
|
||||
onSubmit={handleConfirm}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="Your TPIN authorises fund transfers. Avoid sequences like 1234, your date of birth and the last four digits of your mobile, and keep it different from your MPIN."
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Create TPIN"
|
||||
onPress={handleConfirm}
|
||||
disabled={!isAcceptableNewTpin(tpin, mobileNumber) || confirmTpin !== tpin || busy}
|
||||
loading={busy}
|
||||
style={styles.submit}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
card: {
|
||||
padding: 16,
|
||||
gap: 14,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
submit: {
|
||||
marginTop: 'auto',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
import { TpinCreateFlow } from '../components/TpinCreateFlow';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<PaymentsStackParamList, 'CreateTpin'>>;
|
||||
|
||||
/**
|
||||
* Create TPIN, reached from the transfer screen when initiate answers
|
||||
* TPIN_NOT_SET.
|
||||
*
|
||||
* Registered in the payments stack rather than borrowed from Services, so
|
||||
* going back lands on the same transfer review — amount, payee and all — and
|
||||
* the customer can confirm it again with the TPIN they just made.
|
||||
*/
|
||||
export default function CreateTpinScreen({ navigation }: Props) {
|
||||
const session = useAppSelector((state) => state.auth.session);
|
||||
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Create TPIN"
|
||||
subtitle="You need one before you can send money"
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<TpinCreateFlow
|
||||
mobileNumber={session?.mobileNumber ?? lastProfile?.mobileNumber}
|
||||
notice="Fund transfers are authorised with a TPIN. Create yours now, then confirm the transfer again."
|
||||
onSuccess={() => navigation.goBack()}
|
||||
onExit={() => navigation.goBack()}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import { FeatureGate, guardFeature, isFeatureEnabled } from '../../../features';
|
||||
import { beneficiariesActions, selectBeneficiaryById } from '../store/beneficiariesSlice';
|
||||
import { useAccounts } from '../../home/hooks/useAccounts';
|
||||
import { CodeInput } from '../../auth/components/CodeInput';
|
||||
import { MPIN_LENGTH } from '../../../utils/validation';
|
||||
import { TPIN_LENGTH, isValidTpinEntry } from '../utils/tpinValidation';
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
@@ -37,7 +37,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentConfirmation
|
||||
const OTP_LENGTH = 6;
|
||||
|
||||
/** Which modal, if any, is in front of the review. */
|
||||
type Step = 'review' | 'mpin' | 'otp';
|
||||
type Step = 'review' | 'tpin' | 'otp';
|
||||
|
||||
/**
|
||||
* A payee addressed by mobile is P2P, by account number P2A. The mode decides
|
||||
@@ -125,7 +125,9 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
const [validateBeneficiary] = useValidateBeneficiaryMutation();
|
||||
|
||||
const [step, setStep] = useState<Step>('review');
|
||||
const [mpin, setMpin] = useState('');
|
||||
/** Screen state only, and cleared the moment initiate answers either way. */
|
||||
const [tpin, setTpin] = useState('');
|
||||
const [tpinError, setTpinError] = useState<string | undefined>();
|
||||
const [otp, setOtp] = useState('');
|
||||
const [otpError, setOtpError] = useState<string | undefined>();
|
||||
/** A failure that ended this attempt, kept on the review screen. */
|
||||
@@ -163,7 +165,8 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
requestIdRef.current = null;
|
||||
setTransfer(null);
|
||||
setOtp('');
|
||||
setMpin('');
|
||||
setTpin('');
|
||||
setTpinError(undefined);
|
||||
setOtpError(undefined);
|
||||
setStep('review');
|
||||
setNotice(message);
|
||||
@@ -173,10 +176,24 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
setNotice(undefined);
|
||||
setOtp('');
|
||||
setOtpError(undefined);
|
||||
// IMPS needs the MPIN first; the fixture rails go straight to their OTP.
|
||||
setStep(impsTarget ? 'mpin' : 'otp');
|
||||
setTpin('');
|
||||
setTpinError(undefined);
|
||||
// IMPS needs the TPIN first; the fixture rails go straight to their OTP.
|
||||
setStep(impsTarget ? 'tpin' : 'otp');
|
||||
});
|
||||
|
||||
/**
|
||||
* Off to create a TPIN, from the sheet or on TPIN_NOT_SET. This screen stays
|
||||
* underneath, so coming back lands on the same review to confirm again.
|
||||
*/
|
||||
const openCreateTpin = () => {
|
||||
requestIdRef.current = null;
|
||||
setTpin('');
|
||||
setTpinError(undefined);
|
||||
setStep('review');
|
||||
navigation.navigate('CreateTpin');
|
||||
};
|
||||
|
||||
/**
|
||||
* Saving the payee is deliberately *after* the transfer succeeds, and
|
||||
* deliberately not allowed to fail it: a payee the customer asked to keep is
|
||||
@@ -216,30 +233,33 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
/* ── IMPS: step one — MPIN, name inquiry, OTP ──────────────────────────── */
|
||||
/* ── IMPS: step one — TPIN, name inquiry, OTP ──────────────────────────── */
|
||||
|
||||
const handleInitiate = async () => {
|
||||
// No source-account check: the payer is fixed in paymentsApi for now.
|
||||
if (!impsTarget || mpin.length !== MPIN_LENGTH) {
|
||||
if (!impsTarget || !isValidTpinEntry(tpin) || isInitiating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_SUBMIT)) return;
|
||||
|
||||
setTpinError(undefined);
|
||||
|
||||
try {
|
||||
const result = await initiateTransfer({
|
||||
requestId: requestId(),
|
||||
mode: impsTarget.mode,
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
remark: remarks,
|
||||
mpin,
|
||||
// Encrypted fresh inside the mutation; the plain TPIN never leaves.
|
||||
tpin,
|
||||
payer: { accountNumber: sourceAccountNumber ?? '' },
|
||||
// The backend's reference request sends SAVINGS for both modes; the
|
||||
// forms do not ask for the payee's account type.
|
||||
payee: { ...impsTarget.payee, accountType: 'SAVINGS' },
|
||||
}).unwrap();
|
||||
|
||||
setMpin('');
|
||||
setTpin('');
|
||||
|
||||
if (result.status !== 'PENDING_OTP') {
|
||||
// IMPS did not recognise the payee. No OTP was issued and nothing
|
||||
@@ -257,28 +277,34 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
setStep('otp');
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
setMpin('');
|
||||
|
||||
// A wrong MPIN is the customer's to retry, so the MPIN sheet stays up.
|
||||
// Everything else closes it: nothing was debited, but the attempt is over.
|
||||
if (apiError.code === 'INVALID_CREDENTIALS') {
|
||||
showApiError(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiError.code === 'MPIN_LOCKED') {
|
||||
abandonAttempt(describeError('MPIN_LOCKED').description);
|
||||
return;
|
||||
}
|
||||
setTpin('');
|
||||
|
||||
// A network failure keeps the requestId, so trying again returns this
|
||||
// same transfer instead of starting a second one.
|
||||
// same transfer instead of starting a second one. Any definite answer —
|
||||
// a wrong TPIN included — ends it, so the next try is a new transfer
|
||||
// rather than a replay of one the server already refused.
|
||||
if (apiError.code !== 'NETWORK_ERROR' && apiError.code !== 'REQUEST_TIMEOUT') {
|
||||
requestIdRef.current = null;
|
||||
}
|
||||
|
||||
setStep('review');
|
||||
showApiError(apiError);
|
||||
switch (apiError.code) {
|
||||
case 'SESSION_INVALID':
|
||||
// The interceptor has already signed the customer out.
|
||||
return;
|
||||
case 'INVALID_TPIN':
|
||||
// The customer's to retry, so the TPIN sheet stays up.
|
||||
setTpinError(apiError.message || describeError('INVALID_TPIN').description);
|
||||
return;
|
||||
case 'TPIN_NOT_SET':
|
||||
// Nothing was checked or debited. Create one, then confirm again.
|
||||
openCreateTpin();
|
||||
return;
|
||||
default:
|
||||
// Everything else closes the sheet: nothing was debited, but the
|
||||
// attempt is over.
|
||||
setStep('review');
|
||||
showApiError(apiError);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -423,7 +449,8 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
// answer, not cancel it.
|
||||
if (busy) return;
|
||||
|
||||
setMpin('');
|
||||
setTpin('');
|
||||
setTpinError(undefined);
|
||||
setStep('review');
|
||||
};
|
||||
|
||||
@@ -484,28 +511,38 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* ── MPIN (IMPS only) ─────────────────────────────────────────────── */}
|
||||
<AppModal visible={step === 'mpin'} onClose={closeModal} title="Enter your MPIN">
|
||||
{/* ── TPIN (IMPS only) ─────────────────────────────────────────────── */}
|
||||
<AppModal visible={step === 'tpin'} onClose={closeModal} title="Enter your TPIN">
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={[styles.modalCopy, { color: theme.colors.textSecondary }]}>
|
||||
{`We'll verify ${payeeName} with IMPS and send a code to your registered mobile. Nothing is debited yet.`}
|
||||
{`We'll check your TPIN, verify ${payeeName} with IMPS and send a code to your registered mobile. Nothing is debited yet.`}
|
||||
</Text>
|
||||
<CodeInput
|
||||
label="MPIN"
|
||||
value={mpin}
|
||||
onChangeText={setMpin}
|
||||
length={MPIN_LENGTH}
|
||||
label="TPIN"
|
||||
value={tpin}
|
||||
onChangeText={(value) => {
|
||||
setTpinError(undefined);
|
||||
setTpin(value);
|
||||
}}
|
||||
length={TPIN_LENGTH}
|
||||
secure
|
||||
autoFocus
|
||||
error={tpinError}
|
||||
editable={!isInitiating}
|
||||
onSubmit={handleInitiate}
|
||||
/>
|
||||
<Button
|
||||
title="Verify payee & send OTP"
|
||||
onPress={handleInitiate}
|
||||
disabled={mpin.length !== MPIN_LENGTH || isInitiating}
|
||||
disabled={!isValidTpinEntry(tpin) || isInitiating}
|
||||
loading={isInitiating}
|
||||
/>
|
||||
<Button
|
||||
title="Don't have a TPIN? Create one"
|
||||
variant="ghost"
|
||||
onPress={openCreateTpin}
|
||||
disabled={isInitiating}
|
||||
/>
|
||||
</View>
|
||||
</AppModal>
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import type {
|
||||
BeneficiaryValidateRequest,
|
||||
BeneficiaryValidateResult,
|
||||
CbsBeneficiary,
|
||||
FundTransferDetail,
|
||||
FundTransferHistoryPage,
|
||||
FundTransferHistoryQuery,
|
||||
FundTransferInitiateRequest,
|
||||
FundTransferInitiateResult,
|
||||
FundTransferVerifyResult,
|
||||
} from '../../../types/cbs';
|
||||
import { isCbsApiError } from '../../../services/api/cbsErrors';
|
||||
import { mpinCrypto } from '../../../services/security/mpinCrypto';
|
||||
import { mpinCrypto, withKeyRetry } from '../../../services/security/mpinCrypto';
|
||||
import { favoriteStore, toBeneficiary } from '../utils/beneficiaryMapper';
|
||||
|
||||
/**
|
||||
@@ -106,7 +108,11 @@ export const paymentsApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Step one of an IMPS transfer: IMPS name inquiry, then an OTP. No debit.
|
||||
* Step one of an IMPS transfer: TPIN check, IMPS name inquiry, then an
|
||||
* OTP. No debit.
|
||||
*
|
||||
* Authorised by the TPIN — the MPIN only opens the session now. The TPIN
|
||||
* is encrypted fresh on every send, so no ciphertext is ever reused.
|
||||
*
|
||||
* Retried once with a fresh key on MALFORMED_REQUEST, the same as sign-in.
|
||||
* That is safe here and nowhere near ReqPay: the `requestId` makes a
|
||||
@@ -114,32 +120,22 @@ export const paymentsApi = baseApi.injectEndpoints({
|
||||
*/
|
||||
initiateFundTransfer: builder.mutation<
|
||||
FundTransferInitiateResult,
|
||||
Omit<FundTransferInitiateRequest, 'encryptedMpin'> & { mpin: string }
|
||||
Omit<FundTransferInitiateRequest, 'encryptedTpin'> & { tpin: string }
|
||||
>({
|
||||
queryFn: ({ mpin, ...request }) =>
|
||||
resolveLive(async () => {
|
||||
const send = async () => {
|
||||
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
|
||||
queryFn: ({ tpin, ...request }) =>
|
||||
resolveLive(() =>
|
||||
withKeyRetry(async () => {
|
||||
const encryptedTpin = await mpinCrypto.encryptTpin(tpin);
|
||||
const response = await apiClient.post<FundTransferInitiateResult>(
|
||||
endpoints.fundTransfers.initiate,
|
||||
// See HARDCODED_PAYER_ACCOUNT — the caller's payer is discarded,
|
||||
// so this is also the account that gets debited.
|
||||
{ ...request, payer: { accountNumber: HARDCODED_PAYER_ACCOUNT }, encryptedMpin },
|
||||
{ ...request, payer: { accountNumber: HARDCODED_PAYER_ACCOUNT }, encryptedTpin },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
try {
|
||||
return await send();
|
||||
} catch (error) {
|
||||
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
||||
return send();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -163,6 +159,48 @@ export const paymentsApi = baseApi.injectEndpoints({
|
||||
invalidatesTags: ['Payments', 'Transactions'],
|
||||
}),
|
||||
|
||||
/**
|
||||
* One page of the customer's transfer history. Filters equal to `ALL` and an
|
||||
* incomplete date pair are left off rather than sent, so the server applies
|
||||
* its own defaults. Never cached here: the server is the status authority
|
||||
* and a pending transfer can settle between two visits.
|
||||
*/
|
||||
getFundTransferHistory: builder.query<FundTransferHistoryPage, FundTransferHistoryQuery>({
|
||||
queryFn: ({ page, size, status, mode, fromDate, toDate }) =>
|
||||
resolveLive(async () => {
|
||||
const response = await apiClient.get<FundTransferHistoryPage>(
|
||||
endpoints.fundTransferHistory.list,
|
||||
{
|
||||
params: {
|
||||
...(page === undefined ? {} : { page }),
|
||||
...(size === undefined ? {} : { size }),
|
||||
...(status && status !== 'ALL' ? { status } : {}),
|
||||
...(mode && mode !== 'ALL' ? { mode } : {}),
|
||||
...(fromDate && toDate ? { fromDate, toDate } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Payments'],
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
|
||||
/** One transfer, by the `transferId` from the history list. */
|
||||
getFundTransferDetail: builder.query<FundTransferDetail, string>({
|
||||
queryFn: (transferId) =>
|
||||
resolveLive(async () => {
|
||||
const response = await apiClient.get<FundTransferDetail>(
|
||||
endpoints.fundTransferHistory.detail(transferId),
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Payments'],
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
|
||||
/** NEFT, RTGS and own-account have no endpoint yet, so this stays on fixtures. */
|
||||
submitBankTransfer: builder.mutation<BankTransferResponse, BankTransferRequest>({
|
||||
queryFn: (req) =>
|
||||
@@ -187,4 +225,6 @@ export const {
|
||||
useSubmitBankTransferMutation,
|
||||
useInitiateFundTransferMutation,
|
||||
useVerifyFundTransferOtpMutation,
|
||||
useGetFundTransferHistoryQuery,
|
||||
useGetFundTransferDetailQuery,
|
||||
} = paymentsApi;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { mpinCrypto, withKeyRetry } from '../../../services/security/mpinCrypto';
|
||||
import type {
|
||||
TpinCreateConfirmResult,
|
||||
TpinCreateInitiateResult,
|
||||
TpinCreateVerifyResult,
|
||||
} from '../../../types/cbs';
|
||||
|
||||
/**
|
||||
* Creating the TPIN. Every call rides on the signed-in session — the
|
||||
* interceptor attaches the Bearer token, and the server derives org, customer,
|
||||
* mobile, device and profile from it, so none of those are sent here.
|
||||
*
|
||||
* Deliberately not RTK Query: a mutation keeps its arguments and result in the
|
||||
* store, where dev tooling records them, and the create id, the one-time token
|
||||
* and the OTP must live in screen state only. See `TpinCreateFlow`.
|
||||
*/
|
||||
export const tpinService = {
|
||||
/**
|
||||
* Step 1: re-authenticates with the current MPIN, checks the customer with
|
||||
* the bank and sends an OTP. Encrypted inside the retry, so a
|
||||
* MALFORMED_REQUEST (rotated key, stale `ts`) is retried once with a fresh
|
||||
* key and a fresh payload — never the same ciphertext twice.
|
||||
*/
|
||||
async initiateCreate(mpin: string) {
|
||||
return withKeyRetry(async () => {
|
||||
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
|
||||
|
||||
const response = await apiClient.post<TpinCreateInitiateResult>(
|
||||
endpoints.tpin.createInitiate,
|
||||
{ encryptedMpin },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
|
||||
/** Step 2: swaps the OTP for a short-lived, one-time create token. */
|
||||
async verifyCreateOtp({ tpinCreateId, otp }: { tpinCreateId: string; otp: string }) {
|
||||
const response = await apiClient.post<TpinCreateVerifyResult>(
|
||||
endpoints.tpin.createVerifyOtp,
|
||||
{ tpinCreateId, otp },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Step 3: spends the token on the new TPIN, encrypted fresh for this call. */
|
||||
async confirmCreate({ tpinCreateToken, tpin }: { tpinCreateToken: string; tpin: string }) {
|
||||
return withKeyRetry(async () => {
|
||||
const encryptedTpin = await mpinCrypto.encryptTpin(tpin);
|
||||
|
||||
const response = await apiClient.post<TpinCreateConfirmResult>(
|
||||
endpoints.tpin.createConfirm,
|
||||
{ tpinCreateToken, encryptedTpin },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
TPIN_LENGTH,
|
||||
describeTpinProblem,
|
||||
isAcceptableNewTpin,
|
||||
isValidTpinEntry,
|
||||
} from './tpinValidation';
|
||||
|
||||
describe('isValidTpinEntry', () => {
|
||||
it('accepts exactly four digits', () => {
|
||||
expect(TPIN_LENGTH).toBe(4);
|
||||
expect(isValidTpinEntry('2580')).toBe(true);
|
||||
expect(isValidTpinEntry('258')).toBe(false);
|
||||
expect(isValidTpinEntry('25801')).toBe(false);
|
||||
expect(isValidTpinEntry('25a0')).toBe(false);
|
||||
});
|
||||
|
||||
// On a transfer the server decides whether the digits are right, so an
|
||||
// entry the create rules would refuse must still be typeable.
|
||||
it('does not apply the choosing rules to an entry', () => {
|
||||
expect(isValidTpinEntry('1234')).toBe(true);
|
||||
expect(isValidTpinEntry('1111')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTpinProblem', () => {
|
||||
it('says nothing about an empty field', () => {
|
||||
expect(describeTpinProblem('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('asks for four digits until there are four', () => {
|
||||
expect(describeTpinProblem('25')).toBe('A TPIN is 4 digits');
|
||||
expect(describeTpinProblem('2a')).toBe('Digits only');
|
||||
});
|
||||
|
||||
it('refuses ascending and descending runs', () => {
|
||||
expect(describeTpinProblem('1234')).toMatch(/run of digits/);
|
||||
expect(describeTpinProblem('4321')).toMatch(/run of digits/);
|
||||
expect(describeTpinProblem('6789')).toMatch(/run of digits/);
|
||||
});
|
||||
|
||||
it('refuses the same digit repeated', () => {
|
||||
expect(describeTpinProblem('1111')).toMatch(/same digit/);
|
||||
});
|
||||
|
||||
it('refuses the last four digits of the registered mobile', () => {
|
||||
expect(describeTpinProblem('0101', '9300000101')).toMatch(/last four digits/);
|
||||
expect(describeTpinProblem('0101', '+91 93000 00101')).toMatch(/last four digits/);
|
||||
expect(describeTpinProblem('0102', '9300000101')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips the mobile rule when the number is unknown', () => {
|
||||
expect(describeTpinProblem('0101')).toBeUndefined();
|
||||
expect(describeTpinProblem('0101', '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAcceptableNewTpin', () => {
|
||||
it('accepts a TPIN that breaks no rule', () => {
|
||||
expect(isAcceptableNewTpin('2580', '9300000101')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses one that breaks any rule, or is incomplete', () => {
|
||||
expect(isAcceptableNewTpin('258')).toBe(false);
|
||||
expect(isAcceptableNewTpin('1234')).toBe(false);
|
||||
expect(isAcceptableNewTpin('0101', '9300000101')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { isRepeatedDigits, isSequentialDigits } from '../../../utils/validation';
|
||||
|
||||
/** A TPIN is always exactly four digits. */
|
||||
export const TPIN_LENGTH = 4;
|
||||
|
||||
/**
|
||||
* Any four digits — for ENTERING the TPIN on a transfer. Deliberately loose:
|
||||
* whether they are the right four is the server's to say, with INVALID_TPIN.
|
||||
*/
|
||||
export const isValidTpinEntry = (value: string) =>
|
||||
new RegExp(`^[0-9]{${TPIN_LENGTH}}$`).test(value);
|
||||
|
||||
/**
|
||||
* Inline copy for why a NEW TPIN was refused before it ever left the device —
|
||||
* for choosing one, not for entering one on a transfer.
|
||||
*
|
||||
* `mobileNumber`, when known, adds the "not the last four digits of your
|
||||
* registered mobile" rule, so the customer is told before a round trip rather
|
||||
* than after one.
|
||||
*
|
||||
* The backend stays the final authority. It also refuses a TPIN matching the
|
||||
* customer's date of birth, which the app never holds — so that one can only
|
||||
* arrive as a VALIDATION_ERROR on confirm.
|
||||
*/
|
||||
export const describeTpinProblem = (value: string, mobileNumber?: string): string | undefined => {
|
||||
if (value.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!/^[0-9]*$/.test(value)) {
|
||||
return 'Digits only';
|
||||
}
|
||||
|
||||
if (value.length !== TPIN_LENGTH) {
|
||||
return `A TPIN is ${TPIN_LENGTH} digits`;
|
||||
}
|
||||
|
||||
if (isRepeatedDigits(value)) {
|
||||
return 'Pick a TPIN that is not the same digit repeated';
|
||||
}
|
||||
|
||||
if (isSequentialDigits(value)) {
|
||||
return 'Pick a TPIN that is not a run of digits like 1234 or 4321';
|
||||
}
|
||||
|
||||
const mobileDigits = mobileNumber?.replace(/\D/g, '') ?? '';
|
||||
|
||||
if (mobileDigits.length >= TPIN_LENGTH && mobileDigits.slice(-TPIN_LENGTH) === value) {
|
||||
return 'Pick a TPIN that is not the last four digits of your mobile number';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** The stricter check for CHOOSING a TPIN. */
|
||||
export const isAcceptableNewTpin = (value: string, mobileNumber?: string) =>
|
||||
isValidTpinEntry(value) && describeTpinProblem(value, mobileNumber) === undefined;
|
||||
@@ -12,6 +12,7 @@ import NotificationSettingsScreen from '../screens/NotificationSettingsScreen';
|
||||
import PrivacySettingsScreen from '../screens/PrivacySettingsScreen';
|
||||
import AboutAppScreen from '../screens/AboutAppScreen';
|
||||
import ChangePinScreen from '../screens/ChangePinScreen';
|
||||
import ResetMpinScreen from '../screens/ResetMpinScreen';
|
||||
import ChangePasswordScreen from '../screens/ChangePasswordScreen';
|
||||
import FeatureFlagsScreen from '../screens/FeatureFlagsScreen';
|
||||
|
||||
@@ -40,6 +41,7 @@ export const ProfileNavigator = () => {
|
||||
{canRoute('PrivacySettings') && <Stack.Screen name="PrivacySettings" component={PrivacySettingsScreen} />}
|
||||
{canRoute('AboutApp') && <Stack.Screen name="AboutApp" component={AboutAppScreen} />}
|
||||
{canRoute('ChangePin') && <Stack.Screen name="ChangePin" component={ChangePinScreen} />}
|
||||
{canRoute('ResetMpin') && <Stack.Screen name="ResetMpin" component={ResetMpinScreen} />}
|
||||
{canRoute('ChangePassword') && <Stack.Screen name="ChangePassword" component={ChangePasswordScreen} />}
|
||||
{canRoute('FeatureFlags') && <Stack.Screen name="FeatureFlags" component={FeatureFlagsScreen} />}
|
||||
</Stack.Navigator>
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function ProfileMainScreen({ navigation }: Props) {
|
||||
feature: FeatureKey.PROFILE_SECURITY,
|
||||
icon: 'lock-closed-outline',
|
||||
title: 'Security Center',
|
||||
subtitle: 'Password, PIN and 2FA',
|
||||
subtitle: 'MPIN reset, password and 2FA',
|
||||
onPress: () => navigation.navigate('SecuritySettings'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { env } from '../../../config/env';
|
||||
import type { ProfileStackParamList } from '../../../navigation/types';
|
||||
import { MpinResetFlow } from '../../auth/components/MpinResetFlow';
|
||||
import { logoutSession } from '../../auth/store/authThunk';
|
||||
|
||||
type Props = NativeStackScreenProps<ProfileStackParamList, 'ResetMpin'>;
|
||||
|
||||
/**
|
||||
* Reset MPIN from Profile → Security Center, for a signed-in customer.
|
||||
*
|
||||
* The same flow as sign-in's Forgot MPIN, with two differences that follow from
|
||||
* being signed in:
|
||||
*
|
||||
* - the mobile number is prefilled from the session;
|
||||
* - a successful reset revokes every session server-side, including this one,
|
||||
* so the app signs out locally straight after. `expired`, not `user`: the
|
||||
* customer has not left, and wiping the remembered profile would cost them
|
||||
* an OTP and a device re-bind just to sign back in with the new MPIN.
|
||||
*/
|
||||
export default function ResetMpinScreen(_props: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const session = useAppSelector((state) => state.auth.session);
|
||||
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
|
||||
|
||||
const mobile = session?.mobileNumber ?? lastProfile?.mobileNumber;
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
void dispatch(logoutSession('expired'));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Reset MPIN"
|
||||
subtitle="Set a new MPIN with a code sent to your mobile"
|
||||
// The tab bar stays up on Profile's inner screens, so the shell has to
|
||||
// pad for it — without that, the bottom-pinned Continue button sat
|
||||
// underneath the bar.
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<MpinResetFlow
|
||||
orgId={session?.orgId ?? lastProfile?.orgId ?? env.orgId}
|
||||
// Prefilled, but editable: the OTP goes to whatever number is entered
|
||||
// and confirm is bound to this device, so letting it be typed opens
|
||||
// nothing — and a locked field read as a broken one.
|
||||
initialMobile={mobile}
|
||||
notice="Resetting your MPIN signs you out on every device, including this one. You'll sign in again with the new MPIN."
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
@@ -32,6 +32,18 @@ export default function SecurityScreen({ navigation }: Props) {
|
||||
/>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
</FeatureGate>
|
||||
{/* Beside Change Login PIN, where customers look for anything to
|
||||
do with their credentials. Real, API-backed — it signs the
|
||||
customer out when done, because the reset revokes sessions. */}
|
||||
<FeatureGate feature={FeatureKey.PROFILE_SECURITY_RESET_MPIN}>
|
||||
<SettingRow
|
||||
icon="refresh-circle-outline"
|
||||
title="Reset MPIN"
|
||||
subtitle="Set a new MPIN with an OTP to your mobile"
|
||||
onPress={() => navigation.navigate('ResetMpin')}
|
||||
/>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
</FeatureGate>
|
||||
<FeatureGate feature={FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD}>
|
||||
<SettingRow
|
||||
icon="lock-open-outline"
|
||||
|
||||
@@ -261,6 +261,17 @@ export function buildUserSearchCatalog(
|
||||
keywords: ['chequebook', 'new cheque book', 'order cheques'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'ChequeBookRequest' }),
|
||||
},
|
||||
{
|
||||
id: 'services-create-tpin',
|
||||
feature: FeatureKey.SERVICES_TPIN_CHANGE,
|
||||
title: 'Create TPIN',
|
||||
subtitle: 'Set the PIN that authorises transfers',
|
||||
module: 'Services',
|
||||
icon: 'lock-closed-outline',
|
||||
hue: MODULE_HUES.services,
|
||||
keywords: ['tpin', 'transaction pin', 'set tpin', 'transfer pin', 'payment pin'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'SetTpin' }),
|
||||
},
|
||||
{
|
||||
id: 'services-stop-payment',
|
||||
feature: FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT,
|
||||
@@ -586,6 +597,17 @@ export function buildUserSearchCatalog(
|
||||
keywords: ['pin', 'reset pin', 'mpin'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'ChangePin' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-reset-mpin',
|
||||
feature: FeatureKey.PROFILE_SECURITY_RESET_MPIN,
|
||||
title: 'Reset MPIN',
|
||||
subtitle: 'Set a new MPIN with an OTP',
|
||||
module: 'Profile',
|
||||
icon: 'refresh-circle-outline',
|
||||
hue: MODULE_HUES.profile,
|
||||
keywords: ['mpin', 'reset mpin', 'forgot mpin', 'new mpin', 'pin'],
|
||||
navigate: (nav) => nav.navigate('Profile', { screen: 'ResetMpin' }),
|
||||
},
|
||||
{
|
||||
id: 'profile-change-password',
|
||||
feature: FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD,
|
||||
|
||||
@@ -81,7 +81,9 @@ export default function ServicesHubScreen({ navigation }: Props) {
|
||||
{
|
||||
id: 'tpin_change',
|
||||
feature: FeatureKey.SERVICES_TPIN_CHANGE,
|
||||
title: 'TPIN Change',
|
||||
// The backend creates a TPIN but has no change endpoint, so the tile
|
||||
// promises only what the screen can do.
|
||||
title: 'Create TPIN',
|
||||
icon: 'lock-closed-outline',
|
||||
iconColor: theme.colors.category.pink.tone,
|
||||
onPress: () => navigation.navigate('SetTpin'),
|
||||
|
||||
@@ -1,166 +1,44 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { TpinCreateFlow } from '../../payments/components/TpinCreateFlow';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<ServicesStackParamList, 'SetTpin'>>;
|
||||
|
||||
const TPIN_LENGTH = 4;
|
||||
|
||||
/** Rejected outright: a TPIN nobody has to guess is not a second factor. */
|
||||
const TRIVIAL_TPINS = new Set(['0000', '1111', '2222', '3333', '4444', '5555', '6666', '7777', '8888', '9999', '1234', '4321']);
|
||||
|
||||
/**
|
||||
* Sets the transaction PIN — the second factor on a payment, separate from the
|
||||
* MPIN that opens the app.
|
||||
* Creates the transaction PIN — the second factor on a fund transfer,
|
||||
* separate from the MPIN that opens the app.
|
||||
*
|
||||
* The whole point of the TPIN is that it is *not* the MPIN: one unlocks the
|
||||
* app, the other authorises money leaving it, and a customer who sets them the
|
||||
* same has collapsed two factors into one. This screen cannot check that
|
||||
* directly — it never sees the MPIN, which lives behind the Keystore — so it
|
||||
* says so plainly instead and refuses the guessable choices it can see.
|
||||
* app, the other authorises money leaving it. Creating one needs the current
|
||||
* MPIN and an OTP first, so a phone left unlocked is not enough to set it.
|
||||
*/
|
||||
export default function SetTpinScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
|
||||
const [tpin, setTpin] = useState('');
|
||||
const [confirmTpin, setConfirmTpin] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const tpinProblem = useMemo(() => {
|
||||
if (tpin.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (tpin.length < TPIN_LENGTH) {
|
||||
return `A TPIN is ${TPIN_LENGTH} digits`;
|
||||
}
|
||||
|
||||
if (TRIVIAL_TPINS.has(tpin)) {
|
||||
return 'Too easy to guess — avoid repeated or sequential digits';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [tpin]);
|
||||
|
||||
const confirmProblem =
|
||||
confirmTpin.length === TPIN_LENGTH && confirmTpin !== tpin
|
||||
? 'Both entries must match'
|
||||
: undefined;
|
||||
|
||||
const canSubmit =
|
||||
tpin.length === TPIN_LENGTH && !tpinProblem && confirmTpin === tpin && !saving;
|
||||
|
||||
const digitsOnly = (value: string) => value.replace(/\D/g, '').slice(0, TPIN_LENGTH);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// No CBS endpoint for the TPIN yet. When one lands it belongs here, and
|
||||
// the PIN must be encrypted the same way the MPIN is rather than sent in
|
||||
// the clear — see `authApi` for the RSA-OAEP path this should reuse.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
|
||||
toast('TPIN set', 'success', 'Use it to authorise payments from now on.');
|
||||
navigation.goBack();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const session = useAppSelector((state) => state.auth.session);
|
||||
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Set TPIN" subtitle="Your payment authorisation PIN">
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.iconWrap}>
|
||||
<View style={[styles.circle, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="shield-half" size={32} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Input
|
||||
label="TPIN"
|
||||
placeholder="XXXX"
|
||||
keyboardType="number-pad"
|
||||
maxLength={TPIN_LENGTH}
|
||||
secureTextEntry
|
||||
value={tpin}
|
||||
onChangeText={(value) => setTpin(digitsOnly(value))}
|
||||
error={tpinProblem}
|
||||
editable={!saving}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm TPIN"
|
||||
placeholder="XXXX"
|
||||
keyboardType="number-pad"
|
||||
maxLength={TPIN_LENGTH}
|
||||
secureTextEntry
|
||||
value={confirmTpin}
|
||||
onChangeText={(value) => setConfirmTpin(digitsOnly(value))}
|
||||
error={confirmProblem}
|
||||
editable={!saving}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Set TPIN"
|
||||
onPress={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
loading={saving}
|
||||
style={styles.btn}
|
||||
/>
|
||||
|
||||
<Text style={[styles.hint, { color: theme.colors.primary }]}>
|
||||
Your TPIN authorises payments. Keep it different from your MPIN and never share it —
|
||||
bank staff will never ask for it.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<ScreenShell
|
||||
title="Create TPIN"
|
||||
subtitle="Your PIN for authorising fund transfers"
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<TpinCreateFlow
|
||||
mobileNumber={session?.mobileNumber ?? lastProfile?.mobileNumber}
|
||||
onSuccess={() => navigation.goBack()}
|
||||
onExit={() => navigation.goBack()}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
container: {
|
||||
gap: 24,
|
||||
paddingTop: 10,
|
||||
},
|
||||
iconWrap: {
|
||||
alignItems: 'center',
|
||||
marginVertical: 10,
|
||||
},
|
||||
circle: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
card: {
|
||||
gap: 16,
|
||||
},
|
||||
btn: {
|
||||
marginTop: 8,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 16,
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import RegisterScreen from '../modules/auth/screens/RegisterScreen';
|
||||
import VerifyOtpScreen from '../modules/auth/screens/VerifyOtpScreen';
|
||||
import CreateMpinScreen from '../modules/auth/screens/CreateMpinScreen';
|
||||
import MpinLockedScreen from '../modules/auth/screens/MpinLockedScreen';
|
||||
import ForgotMpinScreen from '../modules/auth/screens/ForgotMpinScreen';
|
||||
|
||||
const Stack = createNativeStackNavigator<AuthStackParamList>();
|
||||
|
||||
@@ -45,6 +46,7 @@ export const AuthNavigator = () => {
|
||||
* the app should say nothing rather than offer a flow that will be refused.
|
||||
*/
|
||||
const registrationEnabled = useFeature(FeatureKey.AUTH_REGISTER);
|
||||
const forgotMpinEnabled = useFeature(FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD);
|
||||
|
||||
/**
|
||||
* Read once, on the navigator's first render, and frozen from then on.
|
||||
@@ -98,6 +100,7 @@ export const AuthNavigator = () => {
|
||||
component={CreateMpinScreen}
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
{forgotMpinEnabled && <Stack.Screen name="ForgotMpin" component={ForgotMpinScreen} />}
|
||||
<Stack.Screen
|
||||
name="MpinLocked"
|
||||
component={MpinLockedScreen}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import HomeScreen from '../modules/home/screens/HomeScreen';
|
||||
import GlobalSearchScreen from '../modules/search/screens/GlobalSearchScreen';
|
||||
import NotificationsScreen from '../modules/notifications/screens/NotificationsScreen';
|
||||
import TransferHistoryScreen from '../modules/history/screens/TransferHistoryScreen';
|
||||
import TransferDetailScreen from '../modules/history/screens/TransferDetailScreen';
|
||||
import MiniStatementScreen from '../modules/home/screens/MiniStatementScreen';
|
||||
import AccountDetailsScreen from '../modules/home/screens/AccountDetailsScreen';
|
||||
import ReKycScreen from '../modules/home/screens/ReKycScreen';
|
||||
@@ -24,6 +26,8 @@ export const HomeNavigator = () => {
|
||||
{canRoute('HomeMain') && <Stack.Screen name="HomeMain" component={HomeScreen} />}
|
||||
{canRoute('GlobalSearch') && <Stack.Screen name="GlobalSearch" component={GlobalSearchScreen} />}
|
||||
{canRoute('Notifications') && <Stack.Screen name="Notifications" component={NotificationsScreen} />}
|
||||
{canRoute('TransferHistory') && <Stack.Screen name="TransferHistory" component={TransferHistoryScreen} />}
|
||||
{canRoute('TransferDetail') && <Stack.Screen name="TransferDetail" component={TransferDetailScreen} />}
|
||||
{canRoute('MiniStatement') && <Stack.Screen name="MiniStatement" component={MiniStatementScreen} />}
|
||||
{canRoute('AccountDetails') && <Stack.Screen name="AccountDetails" component={AccountDetailsScreen} />}
|
||||
{canRoute('ReKyc') && <Stack.Screen name="ReKyc" component={ReKycScreen} />}
|
||||
|
||||
@@ -12,6 +12,7 @@ import TransferToAccountScreen from '../modules/payments/screens/TransferToAccou
|
||||
import TransferToOwnAccountScreen from '../modules/payments/screens/TransferToOwnAccountScreen';
|
||||
import ImpsMmidScreen from '../modules/payments/screens/ImpsMmidScreen';
|
||||
import { PaymentConfirmationScreen } from '../modules/payments/screens/PaymentConfirmationScreen';
|
||||
import CreateTpinScreen from '../modules/payments/screens/CreateTpinScreen';
|
||||
import { TransactionSuccessScreen } from '../modules/payments/screens/TransactionSuccessScreen';
|
||||
|
||||
const Stack = createNativeStackNavigator<PaymentsStackParamList>();
|
||||
@@ -52,6 +53,7 @@ export const PaymentsNavigator = () => {
|
||||
{canRoute('PaymentConfirmation') && (
|
||||
<Stack.Screen name="PaymentConfirmation" component={PaymentConfirmationScreen} />
|
||||
)}
|
||||
{canRoute('CreateTpin') && <Stack.Screen name="CreateTpin" component={CreateTpinScreen} />}
|
||||
{canRoute('TransactionSuccess') && (
|
||||
<Stack.Screen
|
||||
name="TransactionSuccess"
|
||||
|
||||
@@ -67,6 +67,9 @@ export const PAYMENTS_ROUTE_FEATURES: RouteFeatureMap<PaymentsStackParamList> =
|
||||
TransferToOwnAccount: FeatureKey.PAYMENTS_TRANSFER_OWN,
|
||||
ImpsMmid: FeatureKey.PAYMENTS_MMID,
|
||||
PaymentConfirmation: FeatureKey.PAYMENTS_TRANSFER,
|
||||
// Part of the transfer, not of Services: a transfer that needs a TPIN must
|
||||
// be able to create one even with the Services entry switched off.
|
||||
CreateTpin: FeatureKey.PAYMENTS_TRANSFER,
|
||||
TransactionSuccess: FeatureKey.PAYMENTS_TRANSFER,
|
||||
};
|
||||
|
||||
@@ -148,6 +151,7 @@ export const PROFILE_ROUTE_FEATURES: RouteFeatureMap<ProfileStackParamList> = {
|
||||
PrivacySettings: FeatureKey.PROFILE_SETTINGS_PRIVACY,
|
||||
AboutApp: FeatureKey.PROFILE_ABOUT,
|
||||
ChangePin: FeatureKey.PROFILE_SECURITY_CHANGE_PIN,
|
||||
ResetMpin: FeatureKey.PROFILE_SECURITY_RESET_MPIN,
|
||||
ChangePassword: FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD,
|
||||
SecuritySettings: FeatureKey.PROFILE_SECURITY,
|
||||
FeatureFlags: FeatureKey.PROFILE_FEATURE_FLAGS,
|
||||
|
||||
@@ -10,6 +10,7 @@ export type AuthStackParamList = {
|
||||
VerifyOtp: undefined;
|
||||
CreateMpin: undefined;
|
||||
MpinLocked: undefined;
|
||||
ForgotMpin: undefined;
|
||||
};
|
||||
|
||||
import type { LoanCategory } from '../types/app';
|
||||
@@ -32,6 +33,8 @@ export type HomeStackParamList = {
|
||||
HomeMain: undefined;
|
||||
GlobalSearch: undefined;
|
||||
Notifications: undefined;
|
||||
TransferHistory: undefined;
|
||||
TransferDetail: { transferId: string };
|
||||
MiniStatement: { accountNumber: string };
|
||||
AccountDetails: { accountNumber?: string } | undefined;
|
||||
ReKyc: { accountNumber: string };
|
||||
@@ -105,6 +108,8 @@ export type PaymentsStackParamList = {
|
||||
/** The "save as beneficiary" tick, actioned once the transfer succeeds. */
|
||||
saveRecipient?: boolean;
|
||||
};
|
||||
/** Reached from PaymentConfirmation on TPIN_NOT_SET; returns there when done. */
|
||||
CreateTpin: undefined;
|
||||
TransactionSuccess: {
|
||||
transactionId: string;
|
||||
amount: number;
|
||||
@@ -169,6 +174,7 @@ export type ProfileStackParamList = {
|
||||
PrivacySettings: undefined;
|
||||
AboutApp: undefined;
|
||||
ChangePin: undefined;
|
||||
ResetMpin: undefined;
|
||||
ChangePassword: undefined;
|
||||
SecuritySettings: undefined;
|
||||
FeatureFlags: undefined;
|
||||
|
||||
@@ -9,9 +9,26 @@ import {
|
||||
} from './cbsErrors';
|
||||
|
||||
describe('error code catalogue', () => {
|
||||
it('covers all 48 documented codes, with no duplicates', () => {
|
||||
expect(CBS_ERROR_CODES).toHaveLength(48);
|
||||
expect(new Set(CBS_ERROR_CODES).size).toBe(48);
|
||||
it('covers all 52 documented codes, with no duplicates', () => {
|
||||
expect(CBS_ERROR_CODES).toHaveLength(52);
|
||||
expect(new Set(CBS_ERROR_CODES).size).toBe(52);
|
||||
});
|
||||
|
||||
it('knows the TPIN codes, so none of them collapses into UNKNOWN_ERROR', () => {
|
||||
// The transfer and create-TPIN screens branch on each of these: a wrong
|
||||
// TPIN keeps the sheet up, a missing one sends the customer to create it,
|
||||
// and an existing one ends the create flow.
|
||||
expect(isCbsErrorCode('INVALID_TPIN')).toBe(true);
|
||||
expect(isCbsErrorCode('TPIN_NOT_SET')).toBe(true);
|
||||
expect(isCbsErrorCode('TPIN_ALREADY_SET')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not invite a retry on a TPIN refusal', () => {
|
||||
// A wrong TPIN is the customer's to re-enter, not the identical request's
|
||||
// to repeat; the other two are states a retry cannot change.
|
||||
expect(isRetryableError('INVALID_TPIN')).toBe(false);
|
||||
expect(isRetryableError('TPIN_NOT_SET')).toBe(false);
|
||||
expect(isRetryableError('TPIN_ALREADY_SET')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not invite a retry when the bank withholds a balance', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const CBS_ERROR_CODES = [
|
||||
'OTP_EXPIRED',
|
||||
'BENEFICIARY_DETAILS_INCOMPLETE',
|
||||
'INVALID_CREDENTIALS',
|
||||
'INVALID_TPIN',
|
||||
'SESSION_INVALID',
|
||||
'DEVICE_NOT_BOUND',
|
||||
'RESOURCE_NOT_FOUND',
|
||||
@@ -30,6 +31,8 @@ export const CBS_ERROR_CODES = [
|
||||
'OTP_NOT_VERIFIED',
|
||||
'MPIN_ALREADY_SET',
|
||||
'MPIN_NOT_SET',
|
||||
'TPIN_ALREADY_SET',
|
||||
'TPIN_NOT_SET',
|
||||
'BENEFICIARY_ALREADY_ADDED',
|
||||
'POSITIVE_PAY_NOT_AVAILABLE',
|
||||
'CHEQUE_ALREADY_REGISTERED',
|
||||
@@ -40,6 +43,7 @@ export const CBS_ERROR_CODES = [
|
||||
'CHEQUE_BOOK_REQUEST_DECLINED',
|
||||
'MPIN_LOCKED',
|
||||
'OTP_ATTEMPTS_EXCEEDED',
|
||||
'TRANSACTION_NOT_FOUND',
|
||||
'INTERNAL_ERROR',
|
||||
'CBS_CONNECTOR_NOT_SUPPORTED',
|
||||
'MMID_ALLOCATION_FAILED',
|
||||
@@ -121,6 +125,10 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
||||
title: 'Sign-in failed',
|
||||
description: 'Check your mobile number and MPIN, then try again.',
|
||||
},
|
||||
INVALID_TPIN: {
|
||||
title: 'Incorrect TPIN',
|
||||
description: 'That TPIN is not right. Check it and try again.',
|
||||
},
|
||||
SESSION_INVALID: {
|
||||
title: 'Session ended',
|
||||
description: 'Please sign in again to continue securely.',
|
||||
@@ -209,6 +217,14 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
||||
title: 'Registration unfinished',
|
||||
description: 'Finish setting your MPIN to start banking.',
|
||||
},
|
||||
TPIN_ALREADY_SET: {
|
||||
title: 'You already have a TPIN',
|
||||
description: 'Your TPIN is active. Use it to authorise fund transfers.',
|
||||
},
|
||||
TPIN_NOT_SET: {
|
||||
title: 'Create your TPIN first',
|
||||
description: 'Transfers are authorised with a TPIN. Create one, then try the transfer again.',
|
||||
},
|
||||
BENEFICIARY_ALREADY_ADDED: {
|
||||
title: 'Payee already saved',
|
||||
description: 'This payee is already in your list.',
|
||||
@@ -260,6 +276,10 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
||||
title: 'Too many wrong codes',
|
||||
description: 'Start again to get a new code.',
|
||||
},
|
||||
TRANSACTION_NOT_FOUND: {
|
||||
title: 'Transaction not found',
|
||||
description: 'This transfer is not on your profile any more. Pull to refresh your history.',
|
||||
},
|
||||
INTERNAL_ERROR: {
|
||||
title: 'Something went wrong at the bank',
|
||||
description: 'Please try again in a moment.',
|
||||
|
||||
@@ -8,6 +8,18 @@ export const endpoints = {
|
||||
crypto: {
|
||||
publicKey: '/crypto/public-key',
|
||||
},
|
||||
/**
|
||||
* Forgot MPIN. Unauthenticated, like registration — the customer has no MPIN
|
||||
* to open a session with. `initiate` checks the customer with the CBS and
|
||||
* sends an OTP; `verifyOtp` swaps the OTP for a one-time reset token;
|
||||
* `confirm` spends that token on the new, encrypted MPIN and revokes every
|
||||
* open session. There is no resend endpoint: a fresh `initiate` is the resend.
|
||||
*/
|
||||
mpinReset: {
|
||||
initiate: '/mpin-reset/initiate',
|
||||
verifyOtp: '/mpin-reset/verify-otp',
|
||||
confirm: '/mpin-reset/confirm',
|
||||
},
|
||||
/**
|
||||
* Server-driven feature switches. Deliberately callable without a session —
|
||||
* the sign-in screen's own controls are gated, so this has to resolve before
|
||||
@@ -21,6 +33,20 @@ export const endpoints = {
|
||||
me: '/auth/me',
|
||||
logout: '/auth/logout',
|
||||
},
|
||||
/**
|
||||
* Creating the Transaction PIN that authorises fund transfers. Signed-in
|
||||
* only: org, customer, mobile, device and profile all come from the session,
|
||||
* so none of them go in a body. `createInitiate` re-checks the current MPIN
|
||||
* and sends an OTP; `createVerifyOtp` swaps the OTP for a one-time create
|
||||
* token; `createConfirm` spends that token on the new, encrypted TPIN. There
|
||||
* is no resend endpoint: a fresh `createInitiate` — MPIN and all — is the
|
||||
* resend.
|
||||
*/
|
||||
tpin: {
|
||||
createInitiate: '/tpin/create/initiate',
|
||||
createVerifyOtp: '/tpin/create/verify-otp',
|
||||
createConfirm: '/tpin/create/confirm',
|
||||
},
|
||||
accounts: {
|
||||
/**
|
||||
* Every account on the signed-in profile. No parameters — the profile comes
|
||||
@@ -78,6 +104,16 @@ export const endpoints = {
|
||||
initiate: '/fund-transfers/initiate',
|
||||
verifyOtp: (transferId: string) => `/fund-transfers/${transferId}/verify-otp`,
|
||||
},
|
||||
/**
|
||||
* The customer's own IMPS transfers, as the server recorded them — the status
|
||||
* authority for how each one ended. The list is paginated and filterable by
|
||||
* status, mode and an inclusive `fromDate`/`toDate`; the detail takes a
|
||||
* `transferId` from the list. Payee identifiers arrive already masked.
|
||||
*/
|
||||
fundTransferHistory: {
|
||||
list: '/fund-transfer-history',
|
||||
detail: (transferId: string) => `/fund-transfer-history/${transferId}`,
|
||||
},
|
||||
dashboard: {
|
||||
summary: '/dashboard/summary',
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import forge from 'node-forge';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { apiClient } from '../api/apiClient';
|
||||
import { CbsApiError } from '../api/cbsErrors';
|
||||
import { CbsApiError, isCbsApiError } from '../api/cbsErrors';
|
||||
import { endpoints } from '../api/endpoints';
|
||||
import type { PublicKeyResult } from '../../types/cbs';
|
||||
|
||||
@@ -15,6 +15,28 @@ const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
|
||||
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* `MALFORMED_REQUEST` on an MPIN or TPIN call is what a stale or wrong public
|
||||
* key looks like from the client — the server will not say more, because
|
||||
* saying more would leak whether the profile exists. The key is fetched fresh
|
||||
* on every encryption, so a retry picks up a rotated key; try exactly once
|
||||
* more. A second failure is a real problem worth surfacing.
|
||||
*
|
||||
* `call` must encrypt inside itself, so the retry sends a fresh ciphertext
|
||||
* rather than the same one twice.
|
||||
*/
|
||||
export const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await call();
|
||||
} catch (error) {
|
||||
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
||||
return call();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const mpinCrypto = {
|
||||
/**
|
||||
* The server's *current* key, fetched fresh on every call — the backend
|
||||
@@ -35,7 +57,7 @@ export const mpinCrypto = {
|
||||
throw new CbsApiError({
|
||||
status: 0,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Could not fetch MPIN encryption key',
|
||||
message: 'Could not fetch the PIN encryption key',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,8 +65,9 @@ export const mpinCrypto = {
|
||||
},
|
||||
|
||||
/**
|
||||
* The MPIN never leaves the device in the clear. RSA-OAEP with the server's
|
||||
* public key, and the ciphertext travels as `encryptedMpin`.
|
||||
* Neither PIN ever leaves the device in the clear. Both are wrapped as
|
||||
* `{ [field]: pin, nonce, ts }` and RSA-OAEP encrypted to the server's
|
||||
* public key — one path for both, so the rules below cannot drift apart.
|
||||
*
|
||||
* Three things break integrations here, all of them silently:
|
||||
*
|
||||
@@ -58,14 +81,14 @@ export const mpinCrypto = {
|
||||
* - `nonce` must be fresh on every single call, or it is refused as a replay.
|
||||
*
|
||||
* The result is single-use and must be sent within 120 seconds: call this
|
||||
* immediately before each request, and never reuse one ciphertext for a
|
||||
* login and a transfer.
|
||||
* immediately before each request, and never reuse one ciphertext for two
|
||||
* requests — even when the digits are the same.
|
||||
*/
|
||||
async encryptMpin(mpin: string): Promise<string> {
|
||||
async encryptPin(field: 'mpin' | 'tpin', pin: string): Promise<string> {
|
||||
const publicKey = await this.getPublicKey();
|
||||
|
||||
const payload = JSON.stringify({
|
||||
mpin,
|
||||
[field]: pin,
|
||||
nonce: uuidv4(),
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
@@ -77,4 +100,14 @@ export const mpinCrypto = {
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/** Travels as `encryptedMpin`: sign-in, MPIN setup and reset, TPIN creation. */
|
||||
encryptMpin(mpin: string): Promise<string> {
|
||||
return this.encryptPin('mpin', mpin);
|
||||
},
|
||||
|
||||
/** Travels as `encryptedTpin`: TPIN creation and every fund transfer. */
|
||||
encryptTpin(tpin: string): Promise<string> {
|
||||
return this.encryptPin('tpin', tpin);
|
||||
},
|
||||
};
|
||||
|
||||
+54
-55
@@ -177,54 +177,53 @@ export type Palette = {
|
||||
* ══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export const light: Palette = {
|
||||
/* Cool neutrals, tinted toward the brand hue (~211°) at 15% saturation so
|
||||
* the greys read as part of the navy family rather than as flat web grey.
|
||||
/* Warm neutrals, tinted toward the brand hue (~2°) at 14% saturation so
|
||||
* the greys read as part of the red family rather than as flat web grey.
|
||||
* Each step keeps the luminance of the navy-tinted ramp it replaced, so
|
||||
* every text-on-surface ratio in this file is unchanged.
|
||||
* Steps are spaced by CIE L*, which is the honest metric between two
|
||||
* near-white fills — a WCAG ratio is not meaningful there. */
|
||||
background: '#EEF2F7', // L* 95.3 app background
|
||||
background: '#F4F0F0', // L* 95.3 app background
|
||||
backgroundElevated: '#FFFFFF',
|
||||
backgroundGradient: ['#FFFFFF', '#F7F9FC', '#EEF2F7'],
|
||||
backgroundGradient: ['#FFFFFF', '#FAF9F8', '#F4F0F0'],
|
||||
surface: '#FFFFFF', // L* 100.0 cards
|
||||
surfaceMuted: '#E2EAF2', // L* 92.3 pressed rows, wells
|
||||
surfaceSunk: '#D5E0EA', // L* 88.6 non-text only
|
||||
border: '#D3DDE7', // 1.32:1 on a card, so it is actually visible
|
||||
borderStrong: '#B4C3D2',
|
||||
surfaceMuted: '#EDE8E7', // L* 92.3 pressed rows, wells
|
||||
surfaceSunk: '#E5DCDC', // L* 88.6 non-text only
|
||||
border: '#E3DAD9', // 1.32:1 on a card, so it is actually visible
|
||||
borderStrong: '#CDBDBD',
|
||||
|
||||
textPrimary: '#0D1B2A', // 17.39 / 15.47 / 14.32
|
||||
textSecondary: '#48596B', // 7.20 / 6.40 / 5.93 — a neutral slate, NOT
|
||||
// the brand navy, which is what it used to be
|
||||
// and why the whole app read as one flat colour
|
||||
textMuted: '#5A697A', // 5.62 / 5.00 / 4.63
|
||||
textOnFill: '#FFFFFF', // white on brand navy is 7.12:1
|
||||
textOnFill: '#FFFFFF', // white on brand red is 5.25:1
|
||||
|
||||
brand: '#124577', // ◀ LOCKED IDENTITY
|
||||
brandLight: '#1B5A96',
|
||||
brandDark: '#0D3459',
|
||||
brandDeep: '#092440',
|
||||
brandSurface: '#E9F0F7',
|
||||
brandBorder: '#B9CDE0',
|
||||
// Stops at brandDark rather than brandDeep: the deepest navy reads as
|
||||
// near-black at the foot of a phone screen, and the wash should still be
|
||||
// recognisably the brand colour where content sits on it.
|
||||
brandGradient: ['#1B5A96', '#124577', '#0D3459'],
|
||||
brand: '#CF2927', // ◀ LOCKED IDENTITY — 5.25 / 4.67 / 4.32. Below
|
||||
// AA on surfaceMuted, so body text on a muted
|
||||
// well should use textPrimary, not brand
|
||||
brandLight: '#E0453F', // decorative only — too light for white text
|
||||
brandDark: '#A81F1D', // pressed fill; white on it 7.29:1
|
||||
brandDeep: '#6E1312',
|
||||
brandSurface: '#FDF0EF', // brand on it 4.72:1; separation from a card 1.11
|
||||
brandBorder: '#F0B9B6', // 1.70:1 against a card
|
||||
// Deliberately DEEPER than `brand`. #CF2927 is too light a red to carry the
|
||||
// translucent on-brand tiers (white @0.75 on it is 3.53:1), so the wash
|
||||
// starts at a darker red, where every tier below clears AA.
|
||||
brandGradient: ['#A81E1C', '#8F1918', '#731413'],
|
||||
|
||||
/* On the navy wash. Each tier is validated against the LIGHTEST stop
|
||||
* (#1B5A96), which is the worst case.
|
||||
/* On the red wash. Each tier is validated against the LIGHTEST stop
|
||||
* (#A81E1C), which is the worst case.
|
||||
*
|
||||
* THE TIERS DO NOT STACK. A translucent fill lightens the wash beneath it,
|
||||
* so a translucent tone on a translucent fill has less to work with:
|
||||
*
|
||||
* white @0.85 on the bare wash 5.64:1 AA
|
||||
* white @0.85 on an @0.12 fill 4.37:1 FAILS
|
||||
* white (solid) on an @0.12 fill 5.36:1 AA
|
||||
*
|
||||
* so a translucent tone on a translucent fill has less to work with.
|
||||
* Text directly on the wash may use any tier. Text on a FILL takes the
|
||||
* solid tone, always. */
|
||||
onBrandPrimary: '#FFFFFF', // 7.12:1 on the lightest stop
|
||||
onBrandPrimary: '#FFFFFF', // 7.32:1 on the lightest stop
|
||||
onBrandSecondary: 'rgba(255, 255, 255, 0.85)', // 5.64:1 — body-safe
|
||||
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 4.80:1 — still AA
|
||||
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 5.36:1
|
||||
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)', // white on it 4.66:1 —
|
||||
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 4.69:1 — still AA
|
||||
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 5.94:1
|
||||
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)', // white on it 5.22:1 —
|
||||
// this is the ceiling
|
||||
onBrandBorder: 'rgba(255, 255, 255, 0.24)',
|
||||
|
||||
@@ -266,9 +265,9 @@ export const light: Palette = {
|
||||
indigo: { tone: '#4F46E5', surface: '#ECEAFE' }, // 5.85 / 5.07 / 1.13
|
||||
},
|
||||
|
||||
overlay: 'rgba(9, 36, 64, 0.55)',
|
||||
overlay: 'rgba(13, 27, 42, 0.55)',
|
||||
white: '#FFFFFF',
|
||||
shadow: '#092440',
|
||||
shadow: '#0D1B2A',
|
||||
};
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -285,43 +284,43 @@ export const light: Palette = {
|
||||
* borders below — which is why each step is measured against its
|
||||
* neighbour and not in isolation.
|
||||
*
|
||||
* The ground is navy-tinted rather than neutral black: it keeps the app in the
|
||||
* The ground is warm red-tinted rather than neutral black: it keeps the app in the
|
||||
* brand family, and pure #000 under light text is the classic recipe for
|
||||
* halation on an OLED panel.
|
||||
* ══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export const dark: Palette = {
|
||||
background: '#0B1420',
|
||||
backgroundElevated: '#131F2C',
|
||||
backgroundGradient: ['#131F2C', '#0F1A26', '#0B1420'],
|
||||
surface: '#131F2C',
|
||||
surfaceMuted: '#1B2937',
|
||||
surfaceSunk: '#243343', // non-text; only textPrimary 11.31, textSecondary
|
||||
background: '#171312',
|
||||
backgroundElevated: '#241C1C',
|
||||
backgroundGradient: ['#241C1C', '#1E1817', '#171312'],
|
||||
surface: '#241C1C',
|
||||
surfaceMuted: '#2F2625',
|
||||
surfaceSunk: '#3B2F2E', // non-text; only textPrimary 11.31, textSecondary
|
||||
// 7.39 and brand 4.93 are legible on it
|
||||
border: '#2E4053', // 1.57:1 against a card
|
||||
borderStrong: '#3F556D', // 2.17:1
|
||||
border: '#4A3A3A', // 1.57:1 against a card
|
||||
borderStrong: '#634F4E', // 2.17:1
|
||||
|
||||
textPrimary: '#EAF1F8', // 14.63 / 16.24 / 12.99
|
||||
textSecondary: '#B6C6D6', // 9.55 / 10.60 / 8.48
|
||||
textMuted: '#93A6B9', // 6.66 / 7.40 / 5.92
|
||||
textOnFill: '#0B1420', // ink on `brand` 7.08:1 — white would be 2.61:1
|
||||
textOnFill: '#0B1420', // ink on `brand` 7.44:1 — white would fail
|
||||
|
||||
brand: '#6BA5DC', // 6.38 / 7.08 / 5.67
|
||||
brandLight: '#8CBEE9',
|
||||
brandDark: '#4A80B5',
|
||||
brandDeep: '#0A1E33',
|
||||
brandSurface: '#17304A', // brand on it 5.16:1; separation from a card 1.24
|
||||
brandBorder: '#2D4B6B', // 1.85:1 against a card
|
||||
brand: '#F28580', // 6.70 / 7.44 / 5.95
|
||||
brandLight: '#F7A9A5',
|
||||
brandDark: '#D9564F',
|
||||
brandDeep: '#2A0E0E',
|
||||
brandSurface: '#3A1E24', // brand on it 6.07:1; separation from a card 1.10
|
||||
brandBorder: '#663038', // 1.62:1 against a card
|
||||
// The wash DARKENS in dark mode rather than lightening. White on the
|
||||
// lightest stop is 10.89:1, so every on-brand tier below clears AA with
|
||||
// lightest stop is 11.24:1, so every on-brand tier below clears AA with
|
||||
// room to spare — they were calibrated against the far lighter light-mode
|
||||
// wash, which is the harder case.
|
||||
brandGradient: ['#173F63', '#102D49', '#0A1E33'],
|
||||
brandGradient: ['#6E1E1F', '#521616', '#380F0F'],
|
||||
|
||||
onBrandPrimary: '#FFFFFF', // 10.89:1 on the lightest stop
|
||||
onBrandSecondary: 'rgba(255, 255, 255, 0.85)', // 8.35:1
|
||||
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 6.72:1
|
||||
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 7.68:1
|
||||
onBrandPrimary: '#FFFFFF', // 11.24:1 on the lightest stop
|
||||
onBrandSecondary: 'rgba(255, 255, 255, 0.85)', // 8.48:1
|
||||
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 6.96:1
|
||||
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 8.24:1
|
||||
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)',
|
||||
onBrandBorder: 'rgba(255, 255, 255, 0.24)',
|
||||
|
||||
|
||||
+118
-3
@@ -422,8 +422,8 @@ export type FundTransferStatus =
|
||||
| 'MANUAL_REVIEW';
|
||||
|
||||
/**
|
||||
* `POST /fund-transfers/initiate`. Runs the IMPS name inquiry and issues an
|
||||
* OTP; it moves no money.
|
||||
* `POST /fund-transfers/initiate`. Verifies the TPIN, runs the IMPS name
|
||||
* inquiry and issues an OTP; it moves no money.
|
||||
*
|
||||
* `requestId` is the idempotency key: orgId + userId + requestId is unique, so
|
||||
* a retry with the same id returns the same transfer rather than a second one.
|
||||
@@ -434,7 +434,11 @@ export interface FundTransferInitiateRequest {
|
||||
mode: BeneficiaryMode;
|
||||
amount: number;
|
||||
remark?: string;
|
||||
encryptedMpin: string;
|
||||
/**
|
||||
* Transfers are authorised by the TPIN, not the MPIN — the MPIN only opens
|
||||
* the session. Answers `TPIN_NOT_SET` for a profile that has not created one.
|
||||
*/
|
||||
encryptedTpin: string;
|
||||
payer: { accountNumber: string };
|
||||
payee: BeneficiaryPayee & { accountType?: string };
|
||||
}
|
||||
@@ -677,3 +681,114 @@ export interface FeatureConfigResult {
|
||||
/** Switches for this session's role and tenant. Applied to the `tenant` layer. */
|
||||
roleOverrides?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/* ── Fund transfer history ─────────────────────────────────────────────────── */
|
||||
|
||||
/** Filters for `GET /fund-transfer-history`. `ALL` is the server's default. */
|
||||
export interface FundTransferHistoryQuery {
|
||||
page?: number;
|
||||
/** 1–100; the server defaults to 20. */
|
||||
size?: number;
|
||||
status?: FundTransferStatus | 'ALL';
|
||||
mode?: BeneficiaryMode | 'ALL';
|
||||
/** Inclusive, `yyyy-MM-dd`. */
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the history list. `payeeDisplay` is already masked by the server
|
||||
* and is shown exactly as received — never rebuilt into a full number.
|
||||
*/
|
||||
export interface FundTransferHistoryItem {
|
||||
transferId: string;
|
||||
referenceId: string;
|
||||
mode: BeneficiaryMode;
|
||||
amount: number;
|
||||
status: FundTransferStatus;
|
||||
/** Extra state for an IMPS outcome the bank has not confirmed yet. */
|
||||
reconciliationStatus: string | null;
|
||||
payeeName: string | null;
|
||||
payeeDisplay: string | null;
|
||||
failureReason: string | null;
|
||||
/** UTC. */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Spring's page envelope, as the list endpoint returns it. */
|
||||
export interface FundTransferHistoryPage {
|
||||
content: FundTransferHistoryItem[];
|
||||
number: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
/** `GET /fund-transfer-history/{transferId}`. Any field after `status` may be null. */
|
||||
export interface FundTransferDetail extends FundTransferHistoryItem {
|
||||
requestId: string | null;
|
||||
remark: string | null;
|
||||
beneficiaryIfsc: string | null;
|
||||
beneficiaryMmid: string | null;
|
||||
impsResponseCode: string | null;
|
||||
checkTxnAttempts: number | null;
|
||||
updatedAt: string | null;
|
||||
reqPayDispatchedAt: string | null;
|
||||
lastCheckTxnAt: string | null;
|
||||
}
|
||||
|
||||
/* ── MPIN reset ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** `POST /mpin-reset/initiate`. */
|
||||
export interface MpinResetInitiateResult {
|
||||
/** Held in screen state only; the OTP step needs it. */
|
||||
resetId: string;
|
||||
status: string;
|
||||
otpExpiresAt: string | null;
|
||||
attemptsRemaining: number | null;
|
||||
}
|
||||
|
||||
/** `POST /mpin-reset/verify-otp`. */
|
||||
export interface MpinResetVerifyResult {
|
||||
/** One-time. Never stored, logged or put in Redux. */
|
||||
resetToken: string;
|
||||
status: string;
|
||||
resetTokenExpiresAt: string | null;
|
||||
}
|
||||
|
||||
/** `POST /mpin-reset/confirm`. */
|
||||
export interface MpinResetConfirmResult {
|
||||
status: string;
|
||||
resetAt: string | null;
|
||||
}
|
||||
|
||||
/* ── TPIN creation ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/** `POST /tpin/create/initiate`. The current MPIN checked out and an OTP is on its way. */
|
||||
export interface TpinCreateInitiateResult {
|
||||
/** Held in screen state only; the OTP step needs it. */
|
||||
tpinCreateId: string;
|
||||
/** `PENDING_OTP`. */
|
||||
status: string;
|
||||
otpExpiresAt: string | null;
|
||||
attemptsRemaining: number | null;
|
||||
}
|
||||
|
||||
/** `POST /tpin/create/verify-otp`. */
|
||||
export interface TpinCreateVerifyResult {
|
||||
/** One-time. Never stored, logged or put in Redux. */
|
||||
tpinCreateToken: string;
|
||||
/** `OTP_VERIFIED`. */
|
||||
status: string;
|
||||
tokenExpiresAt: string | null;
|
||||
}
|
||||
|
||||
/** `POST /tpin/create/confirm`. */
|
||||
export interface TpinCreateConfirmResult {
|
||||
/** `ACTIVE` — the TPIN authorises transfers from now on. */
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
MPIN_LENGTH,
|
||||
isAcceptableNewMpin,
|
||||
isSequentialDigits,
|
||||
isRepeatedDigits,
|
||||
isValidAccountNumber,
|
||||
isValidIfsc,
|
||||
@@ -94,3 +96,33 @@ describe('payee routing fields', () => {
|
||||
expect(isValidMmid('10727301')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSequentialDigits', () => {
|
||||
it('flags straight runs up and down', () => {
|
||||
expect(isSequentialDigits('1234')).toBe(true);
|
||||
expect(isSequentialDigits('6789')).toBe(true);
|
||||
expect(isSequentialDigits('4321')).toBe(true);
|
||||
expect(isSequentialDigits('987654')).toBe(true);
|
||||
});
|
||||
|
||||
it('passes anything that is not one steady step', () => {
|
||||
expect(isSequentialDigits('1243')).toBe(false);
|
||||
expect(isSequentialDigits('1357')).toBe(false);
|
||||
expect(isSequentialDigits('1111')).toBe(false);
|
||||
expect(isSequentialDigits('9012')).toBe(false);
|
||||
expect(isSequentialDigits('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAcceptableNewMpin', () => {
|
||||
it('refuses runs and repeats when choosing an MPIN', () => {
|
||||
expect(isAcceptableNewMpin('1234')).toBe(false);
|
||||
expect(isAcceptableNewMpin('4321')).toBe(false);
|
||||
expect(isAcceptableNewMpin('1111')).toBe(false);
|
||||
expect(isAcceptableNewMpin('4271')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves sign-in able to enter an old sequential MPIN', () => {
|
||||
expect(isValidMpin('1234')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,40 @@ export const isValidMpin = (value: string) =>
|
||||
export const isRepeatedDigits = (value: string) =>
|
||||
value.length > 0 && /^(\d)\1*$/.test(value);
|
||||
|
||||
/**
|
||||
* A straight run up or down — 1234, 6789, 4321. The backend refuses these
|
||||
* when an MPIN is SET, in the same message-only way as repeated digits.
|
||||
*/
|
||||
export const isSequentialDigits = (value: string) => {
|
||||
if (value.length < 2 || !/^[0-9]+$/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const step = Number(value[1]) - Number(value[0]);
|
||||
|
||||
if (step !== 1 && step !== -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 1; index < value.length; index += 1) {
|
||||
if (Number(value[index]) - Number(value[index - 1]) !== step) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* The stricter check for CHOOSING an MPIN — creation and reset.
|
||||
*
|
||||
* Deliberately separate from `isValidMpin`, which sign-in also uses: an MPIN
|
||||
* like 1234 set before the backend began refusing runs still has to be
|
||||
* enterable at sign-in, or its owner is locked out of their own profile.
|
||||
*/
|
||||
export const isAcceptableNewMpin = (value: string) =>
|
||||
isValidMpin(value) && !isSequentialDigits(value);
|
||||
|
||||
/** Constraints as the API documents them, not as any one bank formats them. */
|
||||
export const isValidAccountNumber = (value: string) => /^[0-9]{5,20}$/.test(value);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user