Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on

- Positive Pay on the 12 Oct /pps contract (account number, not account ID):
  OTP enrollment, cheque submit, saved cheques, detail and stop payment.
  Full flow passed on the phone against mobile-banking-backend.lincpay.
- Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on
  Home and the Statement screen; dated PDF export with the password rule.
- Onboarding slides and splash screen, Inter font, shared Icon and
  TypewriterText components, nearby branches via OpenStreetMap.
- Every feature switched on; the 14 Oct switch set is kept in
  featureConfig.stage-2026-10-14.ts. Loans run on demo data until the
  backend has /loans routes.
- npm run android wraps run-android for shells spawned from VS Code.
- OpenProject work packages updated to 14-10-2026 status, with screenshots
  of Positive Pay, statement, loans and cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-10-14 18:18:40 +05:30
co-authored by Claude Opus 5.5
parent 5fbd13ba12
commit f960a119e8
510 changed files with 20268 additions and 6483 deletions
+5
View File
@@ -18,3 +18,8 @@ export const apiClient = axios.create({
});
attachInterceptors(apiClient);
if (__DEV__ && env.devMockBackend) {
// Required lazily so a release bundle never evaluates the fixtures.
apiClient.defaults.adapter = require('./devMockAdapter').devMockAdapter;
}
+1 -1
View File
@@ -304,7 +304,7 @@ export const apiLogger = {
`\n┌── API → #${nextId()} ${method} ${url}` +
`${note ? `\n${note}` : ''}\n` +
`curl -X ${method} '${url}' -H 'Authorization: Bearer <session token>' ` +
"-H 'Accept: application/pdf' --output out.pdf\n└──",
"-H 'Accept: application/pdf, application/json' --output out.pdf\n└──",
);
},
+39 -3
View File
@@ -9,9 +9,9 @@ import {
} from './cbsErrors';
describe('error code catalogue', () => {
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('covers all 66 documented codes, with no duplicates', () => {
expect(CBS_ERROR_CODES).toHaveLength(66);
expect(new Set(CBS_ERROR_CODES).size).toBe(66);
});
it('knows the TPIN codes, so none of them collapses into UNKNOWN_ERROR', () => {
@@ -54,6 +54,7 @@ describe('error code catalogue', () => {
it('knows the statement codes', () => {
expect(isCbsErrorCode('INVALID_DATE_RANGE')).toBe(true);
expect(isCbsErrorCode('STATEMENT_VIEW_NOT_AVAILABLE')).toBe(true);
expect(isCbsErrorCode('STATEMENT_PROFILE_INCOMPLETE')).toBe(true);
});
it('does not invite a retry on either statement refusal', () => {
@@ -63,6 +64,8 @@ describe('error code catalogue', () => {
// nothing — rather than a retry.
expect(isRetryableError('INVALID_DATE_RANGE')).toBe(false);
expect(isRetryableError('STATEMENT_VIEW_NOT_AVAILABLE')).toBe(false);
// The profile has to be corrected at the bank; retrying cannot help.
expect(isRetryableError('STATEMENT_PROFILE_INCOMPLETE')).toBe(false);
});
it('knows the payee-validate codes, which come from IMPS and not from CBS', () => {
@@ -110,6 +113,39 @@ describe('error code catalogue', () => {
expect(isRetryableError('POSITIVE_PAY_NOT_AVAILABLE')).toBe(false);
});
const PPS_CODES = [
'PPS_NOT_ENABLED',
'PPS_ALREADY_ENABLED',
'PPS_OTP_IN_PROGRESS',
'PPS_OTP_ALREADY_VERIFIED',
'PPS_OTP_RESEND_COOLDOWN',
'PPS_OTP_RESEND_LIMIT',
'PPS_TEMPORARILY_UNAVAILABLE',
'PPS_REQUEST_CONFLICT',
'PPS_CHEQUE_NOT_SUBMITTED',
'PPS_OPERATION_IN_PROGRESS',
'PPS_OPERATION_UNRESOLVED',
'PPS_SUBMIT_FAILED',
'PPS_STOP_FAILED',
];
it('knows every PPS code, so none of them collapses into UNKNOWN_ERROR', () => {
// The interceptor maps an unrecognised code to UNKNOWN_ERROR, and the PPS
// screens branch on each of these — a missing one would silently take the
// unknown-outcome path instead of its own.
for (const code of PPS_CODES) {
expect(isCbsErrorCode(code)).toBe(true);
}
});
it('does not invite a retry on any PPS code', () => {
// The cheque operations must never be resent automatically, and the
// enrollment refusals describe a state an identical call cannot change.
for (const code of PPS_CODES) {
expect(isRetryableError(code)).toBe(false);
}
});
it('has copy for every code, so no failure renders as a blank toast', () => {
for (const code of CBS_ERROR_CODES) {
const copy = describeError(code);
+102 -15
View File
@@ -23,6 +23,7 @@ export const CBS_ERROR_CODES = [
'STOP_PAYMENT_NOT_AVAILABLE',
'INVALID_DATE_RANGE',
'STATEMENT_VIEW_NOT_AVAILABLE',
'STATEMENT_PROFILE_INCOMPLETE',
'ORG_NOT_CONFIGURED',
'BENEFICIARY_NOT_FOUND',
'METHOD_NOT_ALLOWED',
@@ -57,6 +58,21 @@ export const CBS_ERROR_CODES = [
'CONNECTOR_UNAVAILABLE',
'IMPS_REQUEST_REJECTED',
'TRANSFER_NOT_AWAITING_OTP',
// Positive Pay enrollment
'PPS_NOT_ENABLED',
'PPS_ALREADY_ENABLED',
'PPS_OTP_IN_PROGRESS',
'PPS_OTP_ALREADY_VERIFIED',
'PPS_OTP_RESEND_COOLDOWN',
'PPS_OTP_RESEND_LIMIT',
'PPS_TEMPORARILY_UNAVAILABLE',
// Positive Pay cheque operations
'PPS_REQUEST_CONFLICT',
'PPS_CHEQUE_NOT_SUBMITTED',
'PPS_OPERATION_IN_PROGRESS',
'PPS_OPERATION_UNRESOLVED',
'PPS_SUBMIT_FAILED',
'PPS_STOP_FAILED',
] as const;
export type CbsErrorCode = (typeof CBS_ERROR_CODES)[number];
@@ -163,15 +179,13 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
description: 'Your bank does not provide a balance for this account.',
},
/**
* Nothing on file for this account and cheque number. The bank keeps no
* separate record of issued cheques, so this also covers the common case of
* a perfectly real cheque that was simply never declared under Positive Pay
* — which is why the copy says what to do rather than just "not found".
* No saved Positive Pay cheque by that id on this profile — deleted, or not
* the customer's. Only a cheque submitted through Positive Pay is ever
* looked up, so the copy sends them back to that list.
*/
CHEQUE_NOT_FOUND: {
title: 'We could not find that cheque',
description:
'Check the account and the six-digit number. Only a cheque registered under Positive Pay can be found here.',
description: 'It is not among your Positive Pay cheques. Refresh your cheque history and pick it again.',
},
STOP_PAYMENT_NOT_AVAILABLE: {
title: 'Stop payment is not available',
@@ -185,6 +199,14 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Statements are not available here',
description: 'Your bank does not expose a ledger for this account.',
},
// 422, statement PDF only: the PDF password is built from the name and date
// of birth on file, so the bank cannot make one without them. Not retryable
// — nothing the customer does in the app fixes it.
STATEMENT_PROFILE_INCOMPLETE: {
title: 'Your bank needs to update your details',
description:
'Your name or date of birth on record is incomplete, so a statement PDF cannot be prepared. Please contact your branch.',
},
ORG_NOT_CONFIGURED: {
title: 'Organisation unavailable',
description: 'This organisation is unknown or has been disabled.',
@@ -233,23 +255,29 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Positive Pay is not available',
description: 'Your bank does not offer it on this account.',
},
/**
* The account already has a submission for this cheque number. Blocked even
* when that earlier attempt failed — the backend will not let a second id
* re-send a cheque whose first send may have reached the bank — so the copy
* must not say "nothing more to do".
*/
CHEQUE_ALREADY_REGISTERED: {
title: 'This cheque is already registered',
title: 'This cheque number is already on file',
description:
'Nothing more to do — the bank already holds these details. Check the number if you meant a different cheque.',
'It was submitted before on this account, even if that attempt failed. Find it in your cheque history instead of submitting it again.',
},
/**
* A retried stop, where the first call already took effect. Nothing went
* wrong and nothing changed — so the copy reassures rather than alarms.
* A stop is already on record. Deliberately not "it is stopped": the cheque
* reads Canceled only once the bank confirmed the stop, and the saved status
* is the only thing that says whether it did.
*/
CHEQUE_ALREADY_STOPPED: {
title: 'This cheque is already stopped',
description:
'Your earlier instruction went through. The bank will dishonour this cheque if it is presented.',
title: 'A stop is already on record',
description: 'Check the cheque\'s saved status. It shows Canceled only once your bank confirmed the stop.',
},
POSITIVE_PAY_DECLINED: {
title: 'Your bank declined the registration',
description: 'Contact your branch to register this cheque.',
title: 'Your bank declined the cheque',
description: 'It was not registered for Positive Pay. Contact your branch.',
},
STOP_PAYMENT_DECLINED: {
title: 'Your bank declined the stop instruction',
@@ -313,6 +341,65 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'This transfer is already finished',
description: 'It is no longer waiting for a code, so nothing was sent again. Check your transactions for its outcome.',
},
/*
* Positive Pay. None is retryable: enrollment refusals describe a state an
* identical call cannot change, and the cheque operations are writes whose
* whole contract is "never resend automatically". The screens decide what,
* if anything, the customer may do next — see `ppsErrors.ts`.
*/
PPS_NOT_ENABLED: {
title: 'Positive Pay is off for this account',
description: 'Turn it on for this account first, then submit the cheque.',
},
PPS_ALREADY_ENABLED: {
title: 'Positive Pay is already on',
description: 'This account is already enabled. You can register cheques on it now.',
},
PPS_OTP_IN_PROGRESS: {
title: 'A code is already on its way',
description: 'Use the code we already sent, or wait for it to expire before asking again.',
},
PPS_OTP_ALREADY_VERIFIED: {
title: 'Code already accepted',
description: 'We are still turning on Positive Pay for this account. Check again in a moment.',
},
PPS_OTP_RESEND_COOLDOWN: {
title: 'Please wait before resending',
description: 'You can ask for another code once the timer runs out.',
},
PPS_OTP_RESEND_LIMIT: {
title: 'No more codes for this attempt',
description: 'You have used every resend. Let this code expire, then start again.',
},
PPS_TEMPORARILY_UNAVAILABLE: {
title: 'Positive Pay is briefly unavailable',
description: 'Nothing was lost. Please try again in a moment.',
},
PPS_REQUEST_CONFLICT: {
title: 'This request was already sent',
description: 'It went with different details. Check your cheque history before submitting again.',
},
PPS_CHEQUE_NOT_SUBMITTED: {
title: 'This cheque cannot be stopped',
description: 'Only a cheque your bank confirmed as submitted can be stopped.',
},
PPS_OPERATION_IN_PROGRESS: {
title: 'Still processing',
description: 'Your bank has not answered this request yet. Do not send it again — check back shortly.',
},
PPS_OPERATION_UNRESOLVED: {
title: 'This cheque needs our support team',
description:
'An earlier request for it never got a clear answer from your bank. Contact support before doing anything else with this cheque.',
},
PPS_SUBMIT_FAILED: {
title: 'Your bank did not confirm the cheque',
description: 'It is saved as Failed. Do not submit it again — contact support if you need it on file.',
},
PPS_STOP_FAILED: {
title: 'The stop was not confirmed',
description: 'The cheque is still active and will be paid if presented.',
},
MMID_ALLOCATION_FAILED: {
title: 'We could not finish setting up your accounts',
description: 'Please try again shortly.',
+306
View File
@@ -0,0 +1,306 @@
import axios, {
type AxiosAdapter,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
import type {
CbsAccount,
CbsBeneficiary,
CbsMiniStatement,
FundTransferHistoryPage,
MmidAccountsResult,
} from '../../types/cbs';
/**
* Dev-only stand-in for the CBS backend, for running the app where the bank
* host does not resolve (off the office network / VPN). Installed by
* `apiClient` only when `__DEV__ && env.devMockBackend`, so a release build
* never carries it into a request path.
*
* Any mobile number, OTP and MPIN are accepted. Routes not listed here fall
* through to the real network and fail the way they would without a backend.
*/
const DEMO_MOBILE = '9876543210';
const DEMO_NAME = 'Demo Customer';
/**
* A throwaway RSA-2048 public key (Base64 SPKI DER). The private half was
* discarded on generation — nothing here decrypts the MPIN, the app only needs
* a valid key to encrypt against.
*/
const DEMO_PUBLIC_KEY =
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwT/mwHUGvfITLhCoPuw5Y5JsTf6zwuXDbsajsDBJLlRa15puL5uF80y2T3eTgnEfrfJWAkLk22zxsU2yuWl8zzpz0K3ZwpWP5xdH8WLmFFfxenOChc9Ghzsk4JcM6QuwoYxh/R/mKNzzHSOc2aXnxIt4xnkDgdAuS69Rq1TXfU9Cba5bz4x6JXZIrl/aYxMwJo9nuJQJySzUtb6M/iwcFoLS/RIOieuRt5kIV7jhDsy7fybCHnnx/sz9NOPh1/WzIwKcI96gPc7ECu2G+fBb0jYI8q6I5Bu07lPkodZg86Wb35H/gMrQuaFuiStXUl0YyibiZVxxjcPvdBi4k4wupQIDAQAB';
const accounts: CbsAccount[] = [
{
accountNumber: '50100012345678',
accountType: 'SAVINGS',
status: 'ACTIVE',
ifsc: 'CBSB0000107',
mmid: '9107001',
},
{
accountNumber: '50200087654321',
accountType: 'CURRENT',
status: 'ACTIVE',
ifsc: 'CBSB0000107',
mmid: '9107002',
},
];
const balances: Record<string, number> = {
'50100012345678': 248_560.75,
'50200087654321': 91_240.0,
};
const mask = (accountNumber: string) => `XXXXXXXXXX${accountNumber.slice(-4)}`;
const isoAt = (daysAgo: number, hour = 10) => {
const date = new Date(Date.now() - daysAgo * 86_400_000);
date.setHours(hour, 15, 0, 0);
return date.toISOString().slice(0, 19);
};
/**
* `GET /accounts/mini-statement` in the guide's shape (14 Oct 2026): newest
* first, at most ten, DEBIT/CREDIT with positive amounts. The last row leaves
* out its optional fields to exercise the null-or-omitted handling.
*/
const miniStatement = (accountNumber: string): CbsMiniStatement => {
const rows: Array<[string, 'DEBIT' | 'CREDIT', number]> = [
['NEFT/SALARY OCT/ACME TECHNOLOGIES PVT LTD', 'CREDIT', 85_000],
['UPI/412233/DR/SWIGGY', 'DEBIT', 642.5],
['IMPS/RENT/RAKESH SHARMA', 'DEBIT', 18_000],
['ATM WDL MG ROAD', 'DEBIT', 5_000],
['UPI/412890/DR/AMAZON PAY', 'DEBIT', 2_199],
['INTEREST CREDIT', 'CREDIT', 412.3],
['POS/BIGBASKET', 'DEBIT', 3_874.2],
];
let balance = balances[accountNumber] ?? 50_000;
const transactions = rows.map(([remarks, transactionType, amount], i) => {
const entry = {
transactionDate: isoAt(i * 2, 9 + i),
referenceNumber: `DEV${accountNumber.slice(-4)}${String(i).padStart(4, '0')}`,
remarks,
transactionType,
amount,
balanceAfter: Math.round(balance * 100) / 100,
};
// Walk backwards in time: undo this entry to get the balance before it.
balance += transactionType === 'DEBIT' ? amount : -amount;
return entry;
});
transactions.push({
transactionDate: isoAt(16, 11),
transactionType: 'CREDIT',
amount: 10_000,
} as (typeof transactions)[number]);
return {
accountNumberMasked: `***********${accountNumber.slice(-4)}`,
transactionCount: transactions.length,
transactions,
};
};
const beneficiaries: CbsBeneficiary[] = [
{
beneficiaryId: 'dev-ben-1',
mode: 'P2A',
name: 'RAKESH SHARMA',
bankName: 'HDFC Bank',
nickname: 'Landlord',
accountNumberMasked: 'XXXXXX4421',
ifsc: 'HDFC0001234',
mobileNumber: null,
mmid: null,
status: 'ACTIVE',
},
{
beneficiaryId: 'dev-ben-2',
mode: 'P2P',
name: 'PRIYA NAIR',
bankName: 'State Bank of India',
nickname: null,
accountNumberMasked: 'XXXXXX9087',
ifsc: null,
mobileNumber: '98XXXXXX12',
mmid: '9001234',
status: 'ACTIVE',
},
];
const transferHistory = (): FundTransferHistoryPage => {
const content = [
{ amount: 18_000, payeeName: 'RAKESH SHARMA', payeeDisplay: 'XXXXXX4421', status: 'SUCCESS' },
{ amount: 2_500, payeeName: 'PRIYA NAIR', payeeDisplay: '98XXXXXX12', status: 'SUCCESS' },
{ amount: 7_200, payeeName: 'RAKESH SHARMA', payeeDisplay: 'XXXXXX4421', status: 'FAILED' },
].map((row, i) => ({
transferId: `dev-trf-${i + 1}`,
referenceId: `IMPS${Date.now().toString().slice(-8)}${i}`,
mode: (i === 1 ? 'P2P' : 'P2A') as 'P2A' | 'P2P',
amount: row.amount,
status: row.status as FundTransferHistoryPage['content'][number]['status'],
reconciliationStatus: null,
payeeName: row.payeeName,
payeeDisplay: row.payeeDisplay,
failureReason: row.status === 'FAILED' ? 'Beneficiary bank timed out' : null,
createdAt: new Date(Date.now() - (i + 1) * 86_400_000).toISOString(),
}));
return {
content,
number: 0,
size: content.length,
totalElements: content.length,
totalPages: 1,
first: true,
last: true,
empty: false,
};
};
const inAnHour = () => new Date(Date.now() + 3_600_000).toISOString();
type Handler = (config: InternalAxiosRequestConfig) => unknown;
const routes: Record<string, Handler> = {
'POST /registration': () => ({
otpStatus: 'SENT',
customerExists: true,
accountsLinked: accounts.length,
}),
'POST /registration/verify-otp': () => ({
verified: true,
mpinSetupRequired: true,
mmidsAssigned: accounts.length,
setupToken: 'dev-setup-token',
}),
'GET /crypto/public-key': () => ({
algorithm: 'RSA',
transformation: 'RSA/ECB/OAEPWithSHA-256AndMGF1Padding',
publicKey: DEMO_PUBLIC_KEY,
}),
'POST /registration/mpin': () => ({ mpinStatus: 'SET', registrationComplete: true }),
'POST /auth/login': () => ({
sessionToken: 'dev-session-token',
expiresAt: inAnHour(),
idleTimeoutSeconds: 3600,
fullName: DEMO_NAME,
}),
'GET /auth/me': () => ({
orgId: '107295',
mobileNumber: DEMO_MOBILE,
fullName: DEMO_NAME,
deviceId: 'dev-device',
expiresAt: inAnHour(),
}),
'POST /auth/logout': () => null,
'GET /accounts': () => accounts,
'GET /accounts/mmid': (): MmidAccountsResult => ({
mobileNumber: DEMO_MOBILE,
accounts: accounts.map(({ accountNumber, ...rest }) => ({
...rest,
accountNumberMasked: mask(accountNumber),
})),
}),
'GET /accounts/balance': (config) => ({
balance: balances[String(config.params?.accountNumber)] ?? 0,
}),
'GET /accounts/mini-statement': (config) =>
miniStatement(String(config.params?.accountNumber)),
'GET /accounts/deposits/detail': (config) => ({
accountNumber: String(config.params?.accountNumber ?? '50300011112222'),
productName: 'Fixed Deposit — 1 Year',
depositType: 'FIXED_DEPOSIT',
status: 'ACTIVE',
balance: 100_000,
interestRate: 7.1,
tenureMonths: 12,
installmentAmount: null,
installmentsPaid: null,
maturityDate: '2027-04-15',
openedDate: '2026-04-15',
}),
'GET /beneficiaries': () => beneficiaries,
'GET /fund-transfer-history': () => transferHistory(),
'POST /cheque-book/charges': () => ({
accountNumberMasked: mask('50100012345678'),
leafCount: 25,
charge: 0,
}),
'GET /cheque-book': () => [
{
referenceId: 'CHQ-2026-000481',
leafCount: 25,
charge: 0,
startingChequeNumber: '000101',
endingChequeNumber: '000125',
issueDate: '2026-06-02',
},
],
};
/** `/fund-transfer-history/{transferId}` — the one parameterised path. */
const transferDetail = (transferId: string) => {
const rows = transferHistory().content;
const item = rows.find((row) => row.transferId === transferId) ?? rows[0];
if (!item) {
return null;
}
return {
...item,
requestId: `REQ-${item.transferId}`,
remark: 'Rent share',
beneficiaryIfsc: item.mode === 'P2A' ? 'HDFC0001234' : null,
beneficiaryMmid: item.mode === 'P2P' ? '9001234' : null,
impsResponseCode: item.status === 'SUCCESS' ? '00' : 'M0',
checkTxnAttempts: 0,
updatedAt: item.createdAt,
reqPayDispatchedAt: item.createdAt,
lastCheckTxnAt: null,
};
};
const fallthrough = axios.getAdapter(axios.defaults.adapter);
export const devMockAdapter: AxiosAdapter = async (config) => {
const path = (config.url ?? '').split('?')[0] ?? '';
const method = (config.method ?? 'get').toUpperCase();
const detailMatch = method === 'GET' && /^\/fund-transfer-history\/([^/]+)$/.exec(path);
const handler: Handler | undefined = detailMatch
? () => transferDetail(detailMatch[1] ?? '')
: routes[`${method} ${path}`];
if (!handler) {
return fallthrough(config);
}
// Enough latency for loaders to show, as they would against a real server.
await new Promise<void>((resolve) => setTimeout(resolve, 250));
const response: AxiosResponse = {
data: {
success: true,
message: 'OK (dev mock)',
data: handler(config),
timestamp: new Date().toISOString(),
},
status: 200,
statusText: 'OK',
headers: { date: new Date().toUTCString() },
config,
};
return response;
};
+34 -42
View File
@@ -74,22 +74,20 @@ export const endpoints = {
* apart rather than answering both from one endpoint.
*/
balance: '/accounts/balance',
/**
* A page of ledger entries for one account. Takes the full account number
* from `list` above, and an optional `fromDate`/`toDate` pair that the
* bank rejects unless both are given or neither is.
/*
* There is no `/accounts/statement` any more: the paged JSON ledger was
* removed from the backend (Statement guide, 14 Oct 2026).
*/
statement: '/accounts/statement',
/**
* The same range `statement` pages through, as one PDF. Takes the identical
* `accountNumber` and optional `fromDate`/`toDate` pair and no
* `page`/`size` — the whole range becomes one document. Answers
* `application/pdf`, not the JSON envelope every other endpoint returns.
* Every entry in an inclusive date range, as one password-protected PDF.
* `accountNumber`, `fromDate` and `toDate` (`yyyy-MM-dd`) are all
* required. Answers `application/pdf` on success and the JSON envelope on
* any refusal — see `services/files/pdfDownload.ts`.
*/
statementExport: '/accounts/statement/export',
/**
* The last 10 entries as a short PDF, led by the current available balance.
* `accountNumber` only — no date range, and no envelope.
* The latest ten entries, newest first, as the JSON envelope.
* `accountNumber` only; no date filter is accepted.
*/
miniStatement: '/accounts/mini-statement',
/**
@@ -153,40 +151,33 @@ export const endpoints = {
chat: '/services/chat',
},
/**
* Declares a cheque before it is presented. Write-only: the bank is the
* system of record and keeps no client-side copy, so there is nothing to
* list or fetch back — `referenceId` in the response is the only handle.
* Positive Pay. An account is first enabled with an OTP (`initiate` →
* `verifyOtp`, with `resendOtp` reusing the same `requestId`), which never
* reaches the bank. Only then can cheques be submitted, and a submitted one
* stopped — those two do reach the bank, and the backend saves a record of
* each, which `cheques` (paged) and `cheque` read back.
*
* The reads return the backend's saved state and never ask the bank for a
* fresh status, so refreshing cannot settle an uncertain outcome. Ids in a
* path are encoded: `chequeId` is the backend's, but it is still data.
*
* The account is always its `accountNumber` — a query parameter on
* `enrollment`, a body field elsewhere. There is no account id in this API.
*/
positivePay: {
register: '/positive-pay',
/**
* Checks the bank does *not* already hold this cheque, before the customer
* fills in the full registration form. "Not found" is the happy path here
* and answers 200 with a null `data`; a cheque already on file is refused
* with `CHEQUE_ALREADY_REGISTERED`.
*/
verify: '/positive-pay/verify',
pps: {
enrollment: '/pps/enrollment/accounts',
initiate: '/pps/enrollment/initiate',
verifyOtp: '/pps/enrollment/verify-otp',
resendOtp: '/pps/enrollment/resend-otp',
cheques: '/pps/cheques',
cheque: (chequeId: string) => `/pps/cheques/${encodeURIComponent(chequeId)}`,
stopPayment: (chequeId: string) =>
`/pps/cheques/${encodeURIComponent(chequeId)}/stop-payment`,
},
/**
* Stopping a cheque already declared under Positive Pay.
* Cheque book issuance.
*
* `verify` resolves to the same core-banking lookup as
* `positivePay.verify` — what differs is which answer is good news. There,
* finding the cheque is a rejection; here it is the whole point, and the
* cheque's own detail comes back so the customer can confirm what they are
* about to stop. A cheque that never went through `positivePay.register`
* does not exist to either of these.
*/
cheque: {
verify: '/check/verify',
stopPayment: '/check/stop-payment',
},
/**
* Cheque book issuance — the one cheque journey the bank keeps a history
* for, unlike Positive Pay and stop payment above.
*
* `charges` quotes the fee before the customer commits to `issue`, the same
* preview-then-act convention as `positivePay.verify`. `issue` is
* `charges` quotes the fee before the customer commits to `issue`. `issue` is
* synchronous: the serial range comes back in its own response, so there is
* no pending state and nothing to poll. `issue` and `list` are the same
* path under different methods, named apart because the screens reach for
@@ -246,7 +237,8 @@ export const endpoints = {
settings: '/cards/settings',
},
profile: {
me: '/profile/me',
/** The signed-in customer, resolved from the session — no parameters. */
me: '/profile',
},
branches: {
list: '/branches',
+1 -1
View File
@@ -64,7 +64,7 @@ const envelopeToError = (status: number, envelope: CbsEnvelope<unknown>) =>
* sign-in form — bouncing the user out of a screen they are not in yet would
* be nonsense, so only the former trips the global handler.
*/
const handleSessionLoss = async (error: CbsApiError) => {
export const handleSessionLoss = async (error: CbsApiError) => {
if (error.code !== 'SESSION_INVALID') {
return;
}
+12 -6
View File
@@ -4,12 +4,15 @@ import ReactNativeBlobUtil from 'react-native-blob-util';
import { env } from '../../config/env';
import { apiLogger } from '../api/apiLogger';
import { CbsApiError, isCbsErrorCode } from '../api/cbsErrors';
import { handleSessionLoss } from '../api/interceptors';
import { sessionManager } from '../security/sessionManager';
import { secureStorage } from '../storage/secureStorage';
import type { CbsPdfDocument } from '../../types/cbs';
/**
* The two statement endpoints that answer a PDF rather than the JSON envelope.
* The statement export, the one endpoint that answers a PDF rather than the
* JSON envelope (the mini statement used to as well, and is JSON since the
* 14 Oct 2026 guide).
*
* Everything else in the app goes through `apiClient`, whose response
* interceptor unwraps that envelope — which is exactly why these cannot: axios
@@ -132,7 +135,10 @@ export const downloadPdf = async ({
appendExt: 'pdf',
}).fetch('GET', url, {
Authorization: `Bearer ${token}`,
Accept: 'application/pdf',
// Both, as the statement guide specifies: a refusal on this endpoint is
// still the JSON envelope, and asking for PDF alone invites a 406 in its
// place.
Accept: 'application/pdf, application/json',
});
const status = response.info().status;
@@ -154,10 +160,10 @@ export const downloadPdf = async ({
// rather than leaving it to be "opened" as a PDF later.
await response.flush();
if (failure.code === 'SESSION_INVALID') {
await secureStorage.clearSession();
sessionManager.clear();
}
// Same path as the interceptor's: clears the session *and* sends the
// customer to sign-in, rather than leaving them on a screen whose token is
// dead.
await handleSessionLoss(failure);
throw failure;
}
+7 -3
View File
@@ -13,7 +13,7 @@ import { secureStorage } from './secureStorage';
* which is the whole point of the guarantee, and the failure mode a
* clear-this-list approach has every time someone forgets to update it.
*
* None of the three exemptions is a property of a person:
* None of the four exemptions is a property of a person:
*
* - the theme is a display choice made on this device, and flipping someone
* back to light mode because they signed out is a bug, not privacy;
@@ -24,12 +24,16 @@ import { secureStorage } from './secureStorage';
* that starts offline still honours a feature the bank killed during an
* incident, and deleting it would make signing out a way to turn those
* features back on. Its one customer-scoped half, `roleOverrides`, is
* stripped out below instead — see `forgetRoleScope`.
* stripped out below instead — see `forgetRoleScope`;
* - the onboarding flag records that this handset has shown its introduction.
* Sign-out also forgets the profile, so without this every sign-out would
* replay the welcome slides to someone who has used the app for years.
*/
const KEPT_KEYS: readonly string[] = [
'@bankcbs/theme-mode',
'bankcbs.featureOverrides',
'bankcbs.featureConfig.lastKnown',
'bankcbs.onboarding.seen',
];
/**
@@ -41,7 +45,7 @@ const KEPT_KEYS: readonly string[] = [
* name) and the **MPIN itself**, which biometric sign-in stores behind the
* device prompt. Only the session was being cleared before, so the other
* two survived every sign-out;
* - every AsyncStorage key but the three above — the biometric flag, payee
* - every AsyncStorage key but the four above — the biometric flag, payee
* favourites, and awareness and scheme bookmarks;
* - the customer's entitlements, in memory *and* in the config cache, which
* would otherwise be re-applied over whoever signs in next;