diff --git a/src/assets/images/logo.jpg b/src/assets/images/logo.jpg
new file mode 100644
index 0000000..c437407
Binary files /dev/null and b/src/assets/images/logo.jpg differ
diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx
index 94a4748..bf9e2e1 100644
--- a/src/components/Input/index.tsx
+++ b/src/components/Input/index.tsx
@@ -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 (
{label ? (
-
+
{label}
{required ? * : null}
diff --git a/src/config/env.ts b/src/config/env.ts
index 2f77f5e..48c5196 100644
--- a/src/config/env.ts
+++ b/src/config/env.ts
@@ -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,
diff --git a/src/constants/featureKeys.ts b/src/constants/featureKeys.ts
index dbb6fcc..5e3e9e7 100644
--- a/src/constants/featureKeys.ts
+++ b/src/constants/featureKeys.ts
@@ -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',
diff --git a/src/features/featureConfig.ts b/src/features/featureConfig.ts
index 4eb1085..d536c01 100644
--- a/src/features/featureConfig.ts
+++ b/src/features/featureConfig.ts
@@ -239,6 +239,7 @@ export const DEFAULT_FEATURE_CONFIG: Record = {
[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 },
diff --git a/src/modules/auth/components/BrandLogo/index.tsx b/src/modules/auth/components/BrandLogo/index.tsx
new file mode 100644
index 0000000..d50af5c
--- /dev/null
+++ b/src/modules/auth/components/BrandLogo/index.tsx
@@ -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 (
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ plate: {
+ alignSelf: 'center',
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ },
+});
+
+export const BrandLogo = memo(BrandLogoComponent);
diff --git a/src/modules/auth/components/CodeInput.tsx b/src/modules/auth/components/CodeInput.tsx
index 37958e6..25eb10b 100644
--- a/src/modules/auth/components/CodeInput.tsx
+++ b/src/modules/auth/components/CodeInput.tsx
@@ -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(null);
@@ -58,7 +65,14 @@ export const CodeInput = ({
return (
{label ? (
- {label}
+
+ {label}
+
) : 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 ? (
-
-
+
+
{error}
diff --git a/src/modules/auth/components/LoginHero/index.tsx b/src/modules/auth/components/LoginHero/index.tsx
index d4ca7b5..0a50039 100644
--- a/src/modules/auth/components/LoginHero/index.tsx
+++ b/src/modules/auth/components/LoginHero/index.tsx
@@ -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 (
-
-
-
- BankCBS
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- },
-});
+const LoginHeroComponent = () => ;
export const LoginHero = memo(LoginHeroComponent);
diff --git a/src/modules/auth/components/MpinResetFlow.tsx b/src/modules/auth/components/MpinResetFlow.tsx
new file mode 100644
index 0000000..8d1d00e
--- /dev/null
+++ b/src/modules/auth/components/MpinResetFlow.tsx
@@ -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('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(null);
+
+ // Secrets of the flow. Screen state only — see the note above.
+ const [resetId, setResetId] = useState(null);
+ const [otpExpiresAt, setOtpExpiresAt] = useState(null);
+ const [attemptsRemaining, setAttemptsRemaining] = useState(null);
+ const [resetToken, setResetToken] = useState(null);
+ const [resetTokenExpiresAt, setResetTokenExpiresAt] = useState(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 (
+
+ item.label)} current={stepIndex} />
+
+ {error ? : null}
+
+ {step === 'mobile' ? (
+ <>
+ {notice ? : null}
+
+
+ {
+ setMobile(value.replace(/\D/g, ''));
+ setError(null);
+ }}
+ placeholder="98765 43210"
+ editable={!busy && !lockMobile}
+ />
+
+ Your bank checks this number, then texts a one-time code to it.
+
+
+
+
+ >
+ ) : null}
+
+ {step === 'otp' ? (
+ <>
+
+ {
+ setOtp(value);
+ setError(null);
+ }}
+ length={6}
+ autoFocus
+ editable={!busy && !otpExpired}
+ onSubmit={handleVerifyOtp}
+ />
+
+
+
+ {otpSecondsLeft === null
+ ? `Sent to +91 ${mobile}`
+ : otpExpired
+ ? 'This code has expired'
+ : `Expires in ${formatCountdown(otpSecondsLeft)}`}
+
+ {attemptsRemaining !== null ? (
+
+ {`${attemptsRemaining} ${attemptsRemaining === 1 ? 'attempt' : 'attempts'} left`}
+
+ ) : null}
+
+
+
+
+
+ {/* No resend endpoint exists; a fresh initiate is the resend. */}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ gap: 16,
+ flexGrow: 1,
+ },
+ card: {
+ padding: 16,
+ gap: 14,
+ },
+ metaRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ submit: {
+ marginTop: 'auto',
+ },
+});
diff --git a/src/modules/auth/screens/CreateMpinScreen.tsx b/src/modules/auth/screens/CreateMpinScreen.tsx
index c1f8fab..120cf37 100644
--- a/src/modules/auth/screens/CreateMpinScreen.tsx
+++ b/src/modules/auth/screens/CreateMpinScreen.tsx
@@ -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) {
diff --git a/src/modules/auth/screens/ForgotMpinScreen.tsx b/src/modules/auth/screens/ForgotMpinScreen.tsx
new file mode 100644
index 0000000..10efbf7
--- /dev/null
+++ b/src/modules/auth/screens/ForgotMpinScreen.tsx
@@ -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>;
+
+/** 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 (
+
+ navigation.navigate('Login')}
+ onMpinNotSet={registrationEnabled ? () => navigation.navigate('Register') : undefined}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ flexGrow: 1,
+ },
+});
diff --git a/src/modules/auth/screens/LoginScreen.tsx b/src/modules/auth/screens/LoginScreen.tsx
index 8f4d76e..bd53530 100644
--- a/src/modules/auth/screens/LoginScreen.tsx
+++ b/src/modules/auth/screens/LoginScreen.tsx
@@ -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 ? (
}
prefix="+91"
keyboardType="number-pad"
@@ -434,6 +436,7 @@ export default function LoginScreen({ navigation }: Props) {
- {/* 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. */}
-
- Forgot your MPIN? Visit your branch to have it reset.
-
+ {/* Customers only — an agent's credentials are the bank's to
+ reset. Switching the feature off falls back to the branch. */}
+ {activeRole === 'User' ? (
+ forgotMpinEnabled ? (
+ navigation.navigate('ForgotMpin')}
+ style={[theme.text.bodySmall, styles.centreText, { color: theme.colors.onBrandPrimary }]}
+ >
+ Forgot MPIN?
+
+ ) : (
+
+ Forgot your MPIN? Visit your branch to have it reset.
+
+ )
+ ) : null}
diff --git a/src/modules/auth/screens/MpinLockedScreen.tsx b/src/modules/auth/screens/MpinLockedScreen.tsx
index 7a7ffc9..e8759a0 100644
--- a/src/modules/auth/screens/MpinLockedScreen.tsx
+++ b/src/modules/auth/screens/MpinLockedScreen.tsx
@@ -13,9 +13,10 @@ import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly>;
/**
- * 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();
diff --git a/src/modules/auth/screens/RegisterScreen.tsx b/src/modules/auth/screens/RegisterScreen.tsx
index 2c28e49..197038f 100644
--- a/src/modules/auth/screens/RegisterScreen.tsx
+++ b/src/modules/auth/screens/RegisterScreen.tsx
@@ -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 ──────────────────────────────────────────── */}
-
-
-
-
- BankCBS
-
+
(call: () => Promise): Promise => {
- 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(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(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(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(endpoints.auth.me);
diff --git a/src/modules/auth/utils/authValidation.ts b/src/modules/auth/utils/authValidation.ts
index 8815d5b..0c1b006 100644
--- a/src/modules/auth/utils/authValidation.ts
+++ b/src/modules/auth/utils/authValidation.ts
@@ -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;
};
diff --git a/src/modules/cards/components/CardCarousel.tsx b/src/modules/cards/components/CardCarousel.tsx
index d897a5c..d98906a 100644
--- a/src/modules/cards/components/CardCarousel.tsx
+++ b/src/modules/cards/components/CardCarousel.tsx
@@ -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)',
},
});
diff --git a/src/modules/history/hooks/useFundTransferHistory.ts b/src/modules/history/hooks/useFundTransferHistory.ts
new file mode 100644
index 0000000..b1fc2a8
--- /dev/null
+++ b/src/modules/history/hooks/useFundTransferHistory.ts
@@ -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>({});
+ 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();
+ 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,
+ };
+};
diff --git a/src/modules/history/screens/TransferDetailScreen.tsx b/src/modules/history/screens/TransferDetailScreen.tsx
new file mode 100644
index 0000000..80609fb
--- /dev/null
+++ b/src/modules/history/screens/TransferDetailScreen.tsx
@@ -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,
+ BottomTabScreenProps
+>;
+
+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 (
+
+
+
+ );
+ }
+
+ if (!data) {
+ const copy = describeError(apiError?.code);
+
+ return (
+
+
+
+ );
+ }
+
+ 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 (
+
+
+ }
+ >
+ {/* ── the outcome ───────────────────────────────────────────────── */}
+
+
+
+
+
+ {formatCurrency(data.amount)}
+
+
+ {/* `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. */}
+
+ {status.label}
+
+
+ {showReason ? (
+
+ {humaniseReason(data.failureReason as string)}
+
+ ) : null}
+
+
+ {data.status === 'PENDING_RECONCILIATION' || data.status === 'PROCESSING' ? (
+
+ ) : null}
+
+ {data.status === 'PENDING_OTP' ? (
+
+ ) : null}
+
+ {data.status === 'MANUAL_REVIEW' ? (
+
+ ) : null}
+
+ {/* ── the reference the customer quotes ─────────────────────────── */}
+
+ Reference
+
+ {data.referenceId}
+
+
+ Press and hold to copy
+
+
+
+
+ {rows
+ .filter(([, value]) => Boolean(value))
+ .map(([label, value]) => (
+
+ {label}
+
+ {value}
+
+
+ ))}
+
+
+ {status.refreshable ? (
+
+ ) : null}
+
+ {data.status === 'MANUAL_REVIEW' || data.status === 'FAILED' ? (
+ navigation.navigate('Profile', { screen: 'HelpSupport' })}
+ />
+ ) : null}
+
+
+ );
+}
+
+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',
+ },
+});
diff --git a/src/modules/history/screens/TransferHistoryScreen.tsx b/src/modules/history/screens/TransferHistoryScreen.tsx
new file mode 100644
index 0000000..2189d09
--- /dev/null
+++ b/src/modules/history/screens/TransferHistoryScreen.tsx
@@ -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;
+
+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 (
+ onPress(item)}
+ >
+
+
+
+
+
+
+
+
+ {item.payeeName || 'Payee'}
+
+ {item.payeeDisplay ? (
+
+ {item.payeeDisplay}
+
+ ) : null}
+
+ {`${formatDate(item.createdAt)} · ${formatTime(item.createdAt)}`}
+
+ {showReason ? (
+
+ {humaniseReason(item.failureReason as string)}
+
+ ) : null}
+
+
+
+
+ {formatCurrency(item.amount)}
+
+
+ {/* See TransferDetailScreen: `simple` stops Android clipping the
+ last word of a label drawn in a custom font. */}
+
+ {copy.label}
+
+
+
+
+
+
+ );
+}
+
+export default function TransferHistoryScreen({ navigation }: Props) {
+ const theme = useTheme();
+ const [filters, setFilters] = useState({ 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(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 = (
+
+
+ {STATUS_FILTERS.map((option) => {
+ const active = filters.status === option.value;
+
+ return (
+ 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,
+ },
+ ]}
+ >
+
+ {option.label}
+
+
+ );
+ })}
+
+
+ setFilters((current) => ({ ...current, mode }))}
+ accessibilityLabel="Transfer type"
+ />
+
+ setRangeOpen(true)}
+ style={[
+ styles.rangeButton,
+ {
+ backgroundColor: theme.colors.surface,
+ borderColor: hasRange ? theme.colors.primary : theme.colors.cardBorder,
+ borderRadius: theme.radius.md,
+ },
+ ]}
+ >
+
+
+ {hasRange
+ ? `${formatDate(filters.fromDate as string)} – ${formatDate(filters.toDate as string)}`
+ : 'Any date'}
+
+
+
+
+ );
+
+ const renderEmpty = () => {
+ if (isLoading) {
+ return ;
+ }
+
+ if (apiError) {
+ const copy = describeError(apiError.code);
+
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ };
+
+ return (
+
+ item.transferId}
+ renderItem={({ item }) => }
+ ListHeaderComponent={listHeader}
+ ListEmptyComponent={renderEmpty}
+ ListFooterComponent={
+ isLoadingMore ? (
+
+ ) : null
+ }
+ ItemSeparatorComponent={Separator}
+ onEndReached={hasMore ? loadMore : undefined}
+ onEndReachedThreshold={0.4}
+ refreshControl={
+
+ }
+ showsVerticalScrollIndicator={false}
+ contentContainerStyle={styles.listContent}
+ />
+
+ setRangeOpen(false)} title="Filter by date">
+
+ {(['from', 'to'] as const).map((which) => (
+
+
+ {which === 'from' ? 'From' : 'To'}
+
+ setPickerOpen(which)}
+ style={[
+ styles.rangeButton,
+ {
+ backgroundColor: theme.colors.surfaceMuted,
+ borderColor: theme.colors.cardBorder,
+ borderRadius: theme.radius.md,
+ },
+ ]}
+ >
+
+
+ {formatDate(toStatementDate(which === 'from' ? tempFrom : tempTo))}
+
+
+
+ ))}
+
+ {rangeError ? (
+ {rangeError}
+ ) : null}
+
+
+
+ setRangeOpen(false)}
+ style={styles.modalButton}
+ />
+
+
+
+ {
+ if (pickerOpen === 'to') setTempTo(date);
+ else setTempFrom(date);
+ setPickerOpen(null);
+ setRangeError(null);
+ }}
+ onCancel={() => setPickerOpen(null)}
+ />
+
+
+ );
+}
+
+const 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,
+ },
+});
diff --git a/src/modules/history/utils/transferStatus.ts b/src/modules/history/utils/transferStatus.ts
new file mode 100644
index 0000000..d8b06fc
--- /dev/null
+++ b/src/modules/history/utils/transferStatus.ts
@@ -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['name'];
+type Colors = ReturnType['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 = {
+ 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 = {
+ P2A: 'To account',
+ P2P: 'To mobile',
+};
diff --git a/src/modules/home/hooks/useStatement.ts b/src/modules/home/hooks/useStatement.ts
index 7d2f69c..b71205e 100644
--- a/src/modules/home/hooks/useStatement.ts
+++ b/src/modules/home/hooks/useStatement.ts
@@ -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,
diff --git a/src/modules/home/screens/HomeScreen.tsx b/src/modules/home/screens/HomeScreen.tsx
index 62e801a..6ab2142 100644
--- a/src/modules/home/screens/HomeScreen.tsx
+++ b/src/modules/home/screens/HomeScreen.tsx
@@ -282,6 +282,22 @@ export default function HomeScreen({ navigation }: Props) {
+ navigation.navigate('TransferHistory')}
+ >
+
+
+
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('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(null);
+ /** A failure about the field on screen, shown under it. */
+ const [fieldError, setFieldError] = useState(null);
+
+ // Secrets of the flow. Screen state only — see the note above.
+ const [tpinCreateId, setTpinCreateId] = useState(null);
+ const [otpExpiresAt, setOtpExpiresAt] = useState(null);
+ const [attemptsRemaining, setAttemptsRemaining] = useState(null);
+ const [tpinCreateToken, setTpinCreateToken] = useState(null);
+ const [tokenExpiresAt, setTokenExpiresAt] = useState(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 (
+
+
+
+
+ );
+ }
+
+ 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 (
+
+ item.label)} current={stepIndex} />
+
+ {error ? : null}
+
+ {step === 'mpin' ? (
+ <>
+ {notice ? : null}
+
+
+ {
+ setMpin(value);
+ setError(null);
+ setFieldError(null);
+ }}
+ length={MPIN_LENGTH}
+ secure
+ autoFocus
+ editable={!busy}
+ error={fieldError ?? undefined}
+ onSubmit={handleInitiate}
+ />
+
+ {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.'}
+
+
+
+
+ >
+ ) : null}
+
+ {step === 'otp' ? (
+ <>
+
+ {
+ setOtp(value);
+ setError(null);
+ setFieldError(null);
+ }}
+ length={OTP_LENGTH}
+ autoFocus
+ editable={!busy && !otpExpired}
+ error={fieldError ?? undefined}
+ onSubmit={handleVerifyOtp}
+ />
+
+
+
+ {otpSecondsLeft === null
+ ? `Sent to ${sentTo ?? 'your registered mobile'}`
+ : otpExpired
+ ? 'This code has expired'
+ : `Expires in ${formatCountdown(otpSecondsLeft)}`}
+
+ {attemptsRemaining !== null ? (
+
+ {`${attemptsRemaining} ${attemptsRemaining === 1 ? 'attempt' : 'attempts'} left`}
+
+ ) : null}
+
+
+
+
+
+ 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' ? (
+ <>
+
+ {
+ setTpin(value);
+ setError(null);
+ setFieldError(null);
+ }}
+ length={TPIN_LENGTH}
+ secure
+ autoFocus
+ editable={!busy}
+ error={fieldError ?? tpinProblem}
+ />
+ {
+ setConfirmTpin(value);
+ setError(null);
+ }}
+ length={TPIN_LENGTH}
+ secure
+ editable={!busy}
+ error={confirmProblem}
+ onSubmit={handleConfirm}
+ />
+
+
+
+
+
+ >
+ ) : null}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ gap: 16,
+ flexGrow: 1,
+ },
+ card: {
+ padding: 16,
+ gap: 14,
+ },
+ metaRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ submit: {
+ marginTop: 'auto',
+ },
+});
diff --git a/src/modules/payments/screens/CreateTpinScreen.tsx b/src/modules/payments/screens/CreateTpinScreen.tsx
new file mode 100644
index 0000000..ce4a2ea
--- /dev/null
+++ b/src/modules/payments/screens/CreateTpinScreen.tsx
@@ -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>;
+
+/**
+ * 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 (
+
+ navigation.goBack()}
+ onExit={() => navigation.goBack()}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ flexGrow: 1,
+ },
+});
diff --git a/src/modules/payments/screens/PaymentConfirmationScreen.tsx b/src/modules/payments/screens/PaymentConfirmationScreen.tsx
index 788931b..59a6e9c 100644
--- a/src/modules/payments/screens/PaymentConfirmationScreen.tsx
+++ b/src/modules/payments/screens/PaymentConfirmationScreen.tsx
@@ -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('review');
- const [mpin, setMpin] = useState('');
+ /** Screen state only, and cleared the moment initiate answers either way. */
+ const [tpin, setTpin] = useState('');
+ const [tpinError, setTpinError] = useState();
const [otp, setOtp] = useState('');
const [otpError, setOtpError] = useState();
/** 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) {
- {/* ── MPIN (IMPS only) ─────────────────────────────────────────────── */}
-
+ {/* ── TPIN (IMPS only) ─────────────────────────────────────────────── */}
+
- {`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.`}
{
+ setTpinError(undefined);
+ setTpin(value);
+ }}
+ length={TPIN_LENGTH}
secure
autoFocus
+ error={tpinError}
editable={!isInitiating}
onSubmit={handleInitiate}
/>
+
diff --git a/src/modules/payments/services/paymentsApi.ts b/src/modules/payments/services/paymentsApi.ts
index 9c3ff46..65d2dba 100644
--- a/src/modules/payments/services/paymentsApi.ts
+++ b/src/modules/payments/services/paymentsApi.ts
@@ -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 & { mpin: string }
+ Omit & { 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(
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({
+ queryFn: ({ page, size, status, mode, fromDate, toDate }) =>
+ resolveLive(async () => {
+ const response = await apiClient.get(
+ 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({
+ queryFn: (transferId) =>
+ resolveLive(async () => {
+ const response = await apiClient.get(
+ 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({
queryFn: (req) =>
@@ -187,4 +225,6 @@ export const {
useSubmitBankTransferMutation,
useInitiateFundTransferMutation,
useVerifyFundTransferOtpMutation,
+ useGetFundTransferHistoryQuery,
+ useGetFundTransferDetailQuery,
} = paymentsApi;
diff --git a/src/modules/payments/services/tpinService.ts b/src/modules/payments/services/tpinService.ts
new file mode 100644
index 0000000..f507c50
--- /dev/null
+++ b/src/modules/payments/services/tpinService.ts
@@ -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(
+ 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(
+ 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(
+ endpoints.tpin.createConfirm,
+ { tpinCreateToken, encryptedTpin },
+ );
+
+ return response.data;
+ });
+ },
+};
diff --git a/src/modules/payments/utils/tpinValidation.test.ts b/src/modules/payments/utils/tpinValidation.test.ts
new file mode 100644
index 0000000..44b1c99
--- /dev/null
+++ b/src/modules/payments/utils/tpinValidation.test.ts
@@ -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);
+ });
+});
diff --git a/src/modules/payments/utils/tpinValidation.ts b/src/modules/payments/utils/tpinValidation.ts
new file mode 100644
index 0000000..5f2fbcb
--- /dev/null
+++ b/src/modules/payments/utils/tpinValidation.ts
@@ -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;
diff --git a/src/modules/profile/navigation/ProfileNavigator.tsx b/src/modules/profile/navigation/ProfileNavigator.tsx
index fba406b..4b4a0b1 100644
--- a/src/modules/profile/navigation/ProfileNavigator.tsx
+++ b/src/modules/profile/navigation/ProfileNavigator.tsx
@@ -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') && }
{canRoute('AboutApp') && }
{canRoute('ChangePin') && }
+ {canRoute('ResetMpin') && }
{canRoute('ChangePassword') && }
{canRoute('FeatureFlags') && }
diff --git a/src/modules/profile/screens/ProfileMainScreen.tsx b/src/modules/profile/screens/ProfileMainScreen.tsx
index 437635f..3dfbaa2 100644
--- a/src/modules/profile/screens/ProfileMainScreen.tsx
+++ b/src/modules/profile/screens/ProfileMainScreen.tsx
@@ -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'),
},
{
diff --git a/src/modules/profile/screens/ResetMpinScreen.tsx b/src/modules/profile/screens/ResetMpinScreen.tsx
new file mode 100644
index 0000000..5820c1d
--- /dev/null
+++ b/src/modules/profile/screens/ResetMpinScreen.tsx
@@ -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;
+
+/**
+ * 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 (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ flexGrow: 1,
+ },
+});
diff --git a/src/modules/profile/screens/SecurityScreen.tsx b/src/modules/profile/screens/SecurityScreen.tsx
index bc6190f..44f732e 100644
--- a/src/modules/profile/screens/SecurityScreen.tsx
+++ b/src/modules/profile/screens/SecurityScreen.tsx
@@ -32,6 +32,18 @@ export default function SecurityScreen({ navigation }: Props) {
/>
+ {/* 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. */}
+
+ navigation.navigate('ResetMpin')}
+ />
+
+
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,
diff --git a/src/modules/services/screens/ServicesHubScreen.tsx b/src/modules/services/screens/ServicesHubScreen.tsx
index 7ddf06c..b819e43 100644
--- a/src/modules/services/screens/ServicesHubScreen.tsx
+++ b/src/modules/services/screens/ServicesHubScreen.tsx
@@ -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'),
diff --git a/src/modules/services/screens/SetTpinScreen.tsx b/src/modules/services/screens/SetTpinScreen.tsx
index 25d766b..8f063d0 100644
--- a/src/modules/services/screens/SetTpinScreen.tsx
+++ b/src/modules/services/screens/SetTpinScreen.tsx
@@ -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>;
-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 (
-
-
-
-
-
-
-
-
-
-
- setTpin(digitsOnly(value))}
- error={tpinProblem}
- editable={!saving}
- />
- setConfirmTpin(digitsOnly(value))}
- error={confirmProblem}
- editable={!saving}
- />
-
-
-
-
-
- Your TPIN authorises payments. Keep it different from your MPIN and never share it —
- bank staff will never ask for it.
-
-
-
+
+ navigation.goBack()}
+ onExit={() => navigation.goBack()}
+ />
);
}
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,
},
});
diff --git a/src/navigation/AuthNavigator.tsx b/src/navigation/AuthNavigator.tsx
index 663a116..57872ed 100644
--- a/src/navigation/AuthNavigator.tsx
+++ b/src/navigation/AuthNavigator.tsx
@@ -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();
@@ -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 && }
{
{canRoute('HomeMain') && }
{canRoute('GlobalSearch') && }
{canRoute('Notifications') && }
+ {canRoute('TransferHistory') && }
+ {canRoute('TransferDetail') && }
{canRoute('MiniStatement') && }
{canRoute('AccountDetails') && }
{canRoute('ReKyc') && }
diff --git a/src/navigation/PaymentsNavigator.tsx b/src/navigation/PaymentsNavigator.tsx
index deb5954..7ea2680 100644
--- a/src/navigation/PaymentsNavigator.tsx
+++ b/src/navigation/PaymentsNavigator.tsx
@@ -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();
@@ -52,6 +53,7 @@ export const PaymentsNavigator = () => {
{canRoute('PaymentConfirmation') && (
)}
+ {canRoute('CreateTpin') && }
{canRoute('TransactionSuccess') && (
=
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 = {
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,
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
index 0dc1b08..fd8ddfd 100644
--- a/src/navigation/types.ts
+++ b/src/navigation/types.ts
@@ -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;
diff --git a/src/services/api/cbsErrors.test.ts b/src/services/api/cbsErrors.test.ts
index 09a26c9..47f23f4 100644
--- a/src/services/api/cbsErrors.test.ts
+++ b/src/services/api/cbsErrors.test.ts
@@ -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', () => {
diff --git a/src/services/api/cbsErrors.ts b/src/services/api/cbsErrors.ts
index 4ef6ece..4169af4 100644
--- a/src/services/api/cbsErrors.ts
+++ b/src/services/api/cbsErrors.ts
@@ -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 = {
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 = {
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 = {
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.',
diff --git a/src/services/api/endpoints.ts b/src/services/api/endpoints.ts
index 26a3375..e82da70 100644
--- a/src/services/api/endpoints.ts
+++ b/src/services/api/endpoints.ts
@@ -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',
},
diff --git a/src/services/security/mpinCrypto.ts b/src/services/security/mpinCrypto.ts
index 6c3a260..1deddc4 100644
--- a/src/services/security/mpinCrypto.ts
+++ b/src/services/security/mpinCrypto.ts
@@ -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 (call: () => Promise): Promise => {
+ 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 {
+ async encryptPin(field: 'mpin' | 'tpin', pin: string): Promise {
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 {
+ return this.encryptPin('mpin', mpin);
+ },
+
+ /** Travels as `encryptedTpin`: TPIN creation and every fund transfer. */
+ encryptTpin(tpin: string): Promise {
+ return this.encryptPin('tpin', tpin);
+ },
};
diff --git a/src/theme/palette.ts b/src/theme/palette.ts
index c9ce63f..695ad30 100644
--- a/src/theme/palette.ts
+++ b/src/theme/palette.ts
@@ -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)',
diff --git a/src/types/cbs.ts b/src/types/cbs.ts
index 8cd4fac..44581ea 100644
--- a/src/types/cbs.ts
+++ b/src/types/cbs.ts
@@ -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;
}
+
+/* ── 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;
+}
diff --git a/src/utils/validation.test.ts b/src/utils/validation.test.ts
index 83f1e2c..d321a06 100644
--- a/src/utils/validation.test.ts
+++ b/src/utils/validation.test.ts
@@ -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);
+ });
+});
diff --git a/src/utils/validation.ts b/src/utils/validation.ts
index 790e9a4..cf6c5f8 100644
--- a/src/utils/validation.ts
+++ b/src/utils/validation.ts
@@ -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);