Add TPIN creation, TPIN-authorised transfers, MPIN reset and transfer history

- Create TPIN: current MPIN -> OTP -> encrypted TPIN via /tpin/create/*,
  from Services and from the transfer screen on TPIN_NOT_SET
- IMPS initiate now sends encryptedTpin instead of encryptedMpin; handles
  INVALID_TPIN and TPIN_NOT_SET
- Share one RSA-OAEP encryption and key-retry path for MPIN and TPIN
- Forgot MPIN on sign-in and Reset MPIN in the Security Center
- Fund transfer history list and detail, reachable from Home
- Brand logo on sign-in and palette updates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-09-29 17:54:43 +05:30
co-authored by Claude Opus 5.5
parent abb14d5768
commit f1def81eb7
50 changed files with 3039 additions and 385 deletions
+20 -3
View File
@@ -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', () => {
+20
View File
@@ -11,6 +11,7 @@ export const CBS_ERROR_CODES = [
'OTP_EXPIRED',
'BENEFICIARY_DETAILS_INCOMPLETE',
'INVALID_CREDENTIALS',
'INVALID_TPIN',
'SESSION_INVALID',
'DEVICE_NOT_BOUND',
'RESOURCE_NOT_FOUND',
@@ -30,6 +31,8 @@ export const CBS_ERROR_CODES = [
'OTP_NOT_VERIFIED',
'MPIN_ALREADY_SET',
'MPIN_NOT_SET',
'TPIN_ALREADY_SET',
'TPIN_NOT_SET',
'BENEFICIARY_ALREADY_ADDED',
'POSITIVE_PAY_NOT_AVAILABLE',
'CHEQUE_ALREADY_REGISTERED',
@@ -40,6 +43,7 @@ export const CBS_ERROR_CODES = [
'CHEQUE_BOOK_REQUEST_DECLINED',
'MPIN_LOCKED',
'OTP_ATTEMPTS_EXCEEDED',
'TRANSACTION_NOT_FOUND',
'INTERNAL_ERROR',
'CBS_CONNECTOR_NOT_SUPPORTED',
'MMID_ALLOCATION_FAILED',
@@ -121,6 +125,10 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Sign-in failed',
description: 'Check your mobile number and MPIN, then try again.',
},
INVALID_TPIN: {
title: 'Incorrect TPIN',
description: 'That TPIN is not right. Check it and try again.',
},
SESSION_INVALID: {
title: 'Session ended',
description: 'Please sign in again to continue securely.',
@@ -209,6 +217,14 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Registration unfinished',
description: 'Finish setting your MPIN to start banking.',
},
TPIN_ALREADY_SET: {
title: 'You already have a TPIN',
description: 'Your TPIN is active. Use it to authorise fund transfers.',
},
TPIN_NOT_SET: {
title: 'Create your TPIN first',
description: 'Transfers are authorised with a TPIN. Create one, then try the transfer again.',
},
BENEFICIARY_ALREADY_ADDED: {
title: 'Payee already saved',
description: 'This payee is already in your list.',
@@ -260,6 +276,10 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Too many wrong codes',
description: 'Start again to get a new code.',
},
TRANSACTION_NOT_FOUND: {
title: 'Transaction not found',
description: 'This transfer is not on your profile any more. Pull to refresh your history.',
},
INTERNAL_ERROR: {
title: 'Something went wrong at the bank',
description: 'Please try again in a moment.',
+36
View File
@@ -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',
},
+41 -8
View File
@@ -3,7 +3,7 @@ import forge from 'node-forge';
import { v4 as uuidv4 } from 'uuid';
import { apiClient } from '../api/apiClient';
import { CbsApiError } from '../api/cbsErrors';
import { CbsApiError, isCbsApiError } from '../api/cbsErrors';
import { endpoints } from '../api/endpoints';
import type { PublicKeyResult } from '../../types/cbs';
@@ -15,6 +15,28 @@ const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
};
/**
* `MALFORMED_REQUEST` on an MPIN or TPIN call is what a stale or wrong public
* key looks like from the client — the server will not say more, because
* saying more would leak whether the profile exists. The key is fetched fresh
* on every encryption, so a retry picks up a rotated key; try exactly once
* more. A second failure is a real problem worth surfacing.
*
* `call` must encrypt inside itself, so the retry sends a fresh ciphertext
* rather than the same one twice.
*/
export const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
try {
return await call();
} catch (error) {
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
return call();
}
throw error;
}
};
export const mpinCrypto = {
/**
* The server's *current* key, fetched fresh on every call — the backend
@@ -35,7 +57,7 @@ export const mpinCrypto = {
throw new CbsApiError({
status: 0,
code: 'UNKNOWN_ERROR',
message: 'Could not fetch MPIN encryption key',
message: 'Could not fetch the PIN encryption key',
});
}
@@ -43,8 +65,9 @@ export const mpinCrypto = {
},
/**
* The MPIN never leaves the device in the clear. RSA-OAEP with the server's
* public key, and the ciphertext travels as `encryptedMpin`.
* Neither PIN ever leaves the device in the clear. Both are wrapped as
* `{ [field]: pin, nonce, ts }` and RSA-OAEP encrypted to the server's
* public key — one path for both, so the rules below cannot drift apart.
*
* Three things break integrations here, all of them silently:
*
@@ -58,14 +81,14 @@ export const mpinCrypto = {
* - `nonce` must be fresh on every single call, or it is refused as a replay.
*
* The result is single-use and must be sent within 120 seconds: call this
* immediately before each request, and never reuse one ciphertext for a
* login and a transfer.
* immediately before each request, and never reuse one ciphertext for two
* requests — even when the digits are the same.
*/
async encryptMpin(mpin: string): Promise<string> {
async encryptPin(field: 'mpin' | 'tpin', pin: string): Promise<string> {
const publicKey = await this.getPublicKey();
const payload = JSON.stringify({
mpin,
[field]: pin,
nonce: uuidv4(),
ts: Math.floor(Date.now() / 1000),
});
@@ -77,4 +100,14 @@ export const mpinCrypto = {
}),
);
},
/** Travels as `encryptedMpin`: sign-in, MPIN setup and reset, TPIN creation. */
encryptMpin(mpin: string): Promise<string> {
return this.encryptPin('mpin', mpin);
},
/** Travels as `encryptedTpin`: TPIN creation and every fund transfer. */
encryptTpin(tpin: string): Promise<string> {
return this.encryptPin('tpin', tpin);
},
};