359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
/**
|
|
* The error codes the CBS backend can return, plus the copy the app shows
|
|
* for each. `errorCode` is the stable contract — `message` is free to be
|
|
* reworded or localised server-side — so every branch in the app switches on
|
|
* the code and never on the message or the HTTP status.
|
|
*/
|
|
export const CBS_ERROR_CODES = [
|
|
'VALIDATION_ERROR',
|
|
'MALFORMED_REQUEST',
|
|
'OTP_INVALID',
|
|
'OTP_EXPIRED',
|
|
'BENEFICIARY_DETAILS_INCOMPLETE',
|
|
'INVALID_CREDENTIALS',
|
|
'SESSION_INVALID',
|
|
'DEVICE_NOT_BOUND',
|
|
'RESOURCE_NOT_FOUND',
|
|
'CUSTOMER_NOT_FOUND',
|
|
'NO_ACCOUNTS_FOUND',
|
|
'ACCOUNT_NOT_FOUND',
|
|
'CHEQUE_NOT_FOUND',
|
|
'STOP_PAYMENT_NOT_AVAILABLE',
|
|
'INVALID_DATE_RANGE',
|
|
'STATEMENT_VIEW_NOT_AVAILABLE',
|
|
'ORG_NOT_CONFIGURED',
|
|
'BENEFICIARY_NOT_FOUND',
|
|
'METHOD_NOT_ALLOWED',
|
|
'DUPLICATE_RECORD',
|
|
'ALREADY_REGISTERED',
|
|
'OTP_NOT_VERIFIED',
|
|
'MPIN_ALREADY_SET',
|
|
'MPIN_NOT_SET',
|
|
'BENEFICIARY_ALREADY_ADDED',
|
|
'POSITIVE_PAY_NOT_AVAILABLE',
|
|
'CHEQUE_ALREADY_REGISTERED',
|
|
'CHEQUE_ALREADY_STOPPED',
|
|
'POSITIVE_PAY_DECLINED',
|
|
'STOP_PAYMENT_DECLINED',
|
|
'CHEQUE_BOOK_NOT_AVAILABLE',
|
|
'CHEQUE_BOOK_REQUEST_DECLINED',
|
|
'MPIN_LOCKED',
|
|
'OTP_ATTEMPTS_EXCEEDED',
|
|
'INTERNAL_ERROR',
|
|
'CBS_CONNECTOR_NOT_SUPPORTED',
|
|
'MMID_ALLOCATION_FAILED',
|
|
'CBS_UNAVAILABLE',
|
|
'CBS_ERROR',
|
|
'IMPS_UNAVAILABLE',
|
|
'IMPS_INVALID_RESPONSE',
|
|
'IMPS_CONFIGURATION_ERROR',
|
|
'SMS_DELIVERY_FAILED',
|
|
'OTP_PROVIDER_UNAVAILABLE',
|
|
'CONNECTOR_UNAVAILABLE',
|
|
] as const;
|
|
|
|
export type CbsErrorCode = (typeof CBS_ERROR_CODES)[number];
|
|
|
|
/** Codes the client synthesises when the request never reached the backend. */
|
|
export type ClientErrorCode = 'NETWORK_ERROR' | 'REQUEST_TIMEOUT' | 'UNKNOWN_ERROR';
|
|
|
|
export type AnyErrorCode = CbsErrorCode | ClientErrorCode;
|
|
|
|
const CBS_ERROR_CODE_SET = new Set<string>(CBS_ERROR_CODES);
|
|
|
|
export const isCbsErrorCode = (value: unknown): value is CbsErrorCode =>
|
|
typeof value === 'string' && CBS_ERROR_CODE_SET.has(value);
|
|
|
|
const CLIENT_ERROR_CODES = new Set<string>([
|
|
'NETWORK_ERROR',
|
|
'REQUEST_TIMEOUT',
|
|
'UNKNOWN_ERROR',
|
|
]);
|
|
|
|
/**
|
|
* True for a failure the client invented because nothing answered. The
|
|
* attached message is then a transport detail — axios says "Network Error" —
|
|
* which tells a customer nothing, so the UI should use our own copy instead of
|
|
* preferring it the way it does for a message the backend actually wrote.
|
|
*/
|
|
export const isClientErrorCode = (code: string | undefined) =>
|
|
code !== undefined && CLIENT_ERROR_CODES.has(code);
|
|
|
|
/** Field name to message, as `VALIDATION_ERROR` returns it. Paints onto a form. */
|
|
export type FieldErrors = Record<string, string>;
|
|
|
|
interface ErrorCopy {
|
|
title: string;
|
|
description: string;
|
|
/**
|
|
* True when retrying the identical request has a real chance of succeeding —
|
|
* the bank being unreachable, a circuit breaker open, a transient 500.
|
|
*/
|
|
retryable?: boolean;
|
|
}
|
|
|
|
const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
|
VALIDATION_ERROR: {
|
|
title: 'Check the highlighted fields',
|
|
description: 'Some details need correcting before we can continue.',
|
|
},
|
|
MALFORMED_REQUEST: {
|
|
title: 'We could not read that request',
|
|
description: 'Please try once more.',
|
|
retryable: true,
|
|
},
|
|
OTP_INVALID: {
|
|
title: 'That code is incorrect',
|
|
description: 'Re-enter the code we sent to your mobile.',
|
|
},
|
|
OTP_EXPIRED: {
|
|
title: 'That code has expired',
|
|
description: 'Request a fresh code and try again.',
|
|
},
|
|
BENEFICIARY_DETAILS_INCOMPLETE: {
|
|
title: 'Payee details are incomplete',
|
|
description: 'Fill in the fields required for the payee type you picked.',
|
|
},
|
|
INVALID_CREDENTIALS: {
|
|
title: 'Sign-in failed',
|
|
description: 'Check your mobile number and MPIN, then try again.',
|
|
},
|
|
SESSION_INVALID: {
|
|
title: 'Session ended',
|
|
description: 'Please sign in again to continue securely.',
|
|
},
|
|
DEVICE_NOT_BOUND: {
|
|
title: 'This device is not registered',
|
|
description: 'Register before signing in.',
|
|
},
|
|
RESOURCE_NOT_FOUND: {
|
|
title: 'Not found',
|
|
description: 'We could not find what you asked for.',
|
|
},
|
|
CUSTOMER_NOT_FOUND: {
|
|
title: 'We could not find that customer',
|
|
description: 'Check the mobile number registered with your bank.',
|
|
},
|
|
NO_ACCOUNTS_FOUND: {
|
|
title: 'No accounts to link',
|
|
description: 'Your bank has no accounts we can link to this number.',
|
|
},
|
|
ACCOUNT_NOT_FOUND: {
|
|
title: 'Account unavailable',
|
|
description: 'That account is no longer part of your profile.',
|
|
},
|
|
/**
|
|
* 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".
|
|
*/
|
|
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.',
|
|
},
|
|
STOP_PAYMENT_NOT_AVAILABLE: {
|
|
title: 'Stop payment is not available',
|
|
description: 'Your bank does not offer it on this account. Contact your branch instead.',
|
|
},
|
|
INVALID_DATE_RANGE: {
|
|
title: 'That date range will not work',
|
|
description: 'Pick a start and an end date, with the start on or before the end.',
|
|
},
|
|
STATEMENT_VIEW_NOT_AVAILABLE: {
|
|
title: 'Statements are not available here',
|
|
description: 'Your bank does not expose a ledger for this account.',
|
|
},
|
|
ORG_NOT_CONFIGURED: {
|
|
title: 'Organisation unavailable',
|
|
description: 'This organisation is unknown or has been disabled.',
|
|
},
|
|
BENEFICIARY_NOT_FOUND: {
|
|
title: 'Payee not found',
|
|
description: 'Check the account number and IFSC, or the mobile number and MMID.',
|
|
},
|
|
METHOD_NOT_ALLOWED: {
|
|
title: 'Something went wrong',
|
|
description: 'The app made a request the server does not accept.',
|
|
},
|
|
DUPLICATE_RECORD: {
|
|
title: 'Already saved',
|
|
description: 'That record was just created. Refresh to see it.',
|
|
},
|
|
ALREADY_REGISTERED: {
|
|
title: 'You are already registered',
|
|
description: 'Sign in with your MPIN instead.',
|
|
},
|
|
OTP_NOT_VERIFIED: {
|
|
title: 'Verification expired',
|
|
description: 'Your setup window has closed. Start registration again.',
|
|
},
|
|
MPIN_ALREADY_SET: {
|
|
title: 'MPIN already set',
|
|
description: 'Sign in with the MPIN you created.',
|
|
},
|
|
MPIN_NOT_SET: {
|
|
title: 'Registration unfinished',
|
|
description: 'Finish setting your MPIN to start banking.',
|
|
},
|
|
BENEFICIARY_ALREADY_ADDED: {
|
|
title: 'Payee already saved',
|
|
description: 'This payee is already in your list.',
|
|
},
|
|
POSITIVE_PAY_NOT_AVAILABLE: {
|
|
title: 'Positive Pay is not available',
|
|
description: 'Your bank does not offer it on this account.',
|
|
},
|
|
CHEQUE_ALREADY_REGISTERED: {
|
|
title: 'This cheque is already registered',
|
|
description:
|
|
'Nothing more to do — the bank already holds these details. Check the number if you meant a different cheque.',
|
|
},
|
|
/**
|
|
* A retried stop, where the first call already took effect. Nothing went
|
|
* wrong and nothing changed — so the copy reassures rather than alarms.
|
|
*/
|
|
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.',
|
|
},
|
|
POSITIVE_PAY_DECLINED: {
|
|
title: 'Your bank declined the registration',
|
|
description: 'Contact your branch to register this cheque.',
|
|
},
|
|
STOP_PAYMENT_DECLINED: {
|
|
title: 'Your bank declined the stop instruction',
|
|
description: 'The cheque has not been stopped. Contact your branch to take this further.',
|
|
},
|
|
/**
|
|
* Answers the charges quote as well as the issue itself, so this copy has to
|
|
* make sense before the customer has committed to anything — hence no
|
|
* "nothing was charged" reassurance, which would be confusing on a quote.
|
|
*/
|
|
CHEQUE_BOOK_NOT_AVAILABLE: {
|
|
title: 'Cheque books are not available on this account',
|
|
description: 'Your bank does not issue them on this account type. Try another account.',
|
|
},
|
|
CHEQUE_BOOK_REQUEST_DECLINED: {
|
|
title: 'Your bank declined the request',
|
|
description: 'No cheque book was issued and nothing was charged. Contact your branch.',
|
|
},
|
|
MPIN_LOCKED: {
|
|
title: 'MPIN locked',
|
|
description: 'Too many wrong attempts. Visit your branch to unlock it.',
|
|
},
|
|
OTP_ATTEMPTS_EXCEEDED: {
|
|
title: 'Too many wrong codes',
|
|
description: 'Start registration again to get a new code.',
|
|
},
|
|
INTERNAL_ERROR: {
|
|
title: 'Something went wrong at the bank',
|
|
description: 'Please try again in a moment.',
|
|
retryable: true,
|
|
},
|
|
CBS_CONNECTOR_NOT_SUPPORTED: {
|
|
title: 'Service unavailable',
|
|
description: 'This is a server configuration issue, not a problem with your details.',
|
|
},
|
|
IMPS_UNAVAILABLE: {
|
|
title: 'Could not reach the payments network',
|
|
description: 'IMPS did not answer. Nothing was saved — please try again in a moment.',
|
|
retryable: true,
|
|
},
|
|
IMPS_INVALID_RESPONSE: {
|
|
title: 'Could not read the payment network reply',
|
|
description:
|
|
'The response failed its security check, so it cannot be trusted. Try again, and tell your branch if it keeps happening.',
|
|
retryable: true,
|
|
},
|
|
IMPS_CONFIGURATION_ERROR: {
|
|
title: 'Payee checks are unavailable',
|
|
description: 'This is a server configuration issue, not a problem with the details you entered.',
|
|
},
|
|
MMID_ALLOCATION_FAILED: {
|
|
title: 'We could not finish setting up your accounts',
|
|
description: 'Please try again shortly.',
|
|
retryable: true,
|
|
},
|
|
CBS_UNAVAILABLE: {
|
|
title: 'Your bank is unreachable',
|
|
description: 'Please try again in a moment.',
|
|
retryable: true,
|
|
},
|
|
CBS_ERROR: {
|
|
title: 'Your bank returned an error',
|
|
description: 'Please try again in a moment.',
|
|
retryable: true,
|
|
},
|
|
SMS_DELIVERY_FAILED: {
|
|
title: 'We could not send the code',
|
|
description: 'Please request it again.',
|
|
retryable: true,
|
|
},
|
|
OTP_PROVIDER_UNAVAILABLE: {
|
|
title: 'Verification service is down',
|
|
description: 'Please try again in a few minutes.',
|
|
retryable: true,
|
|
},
|
|
CONNECTOR_UNAVAILABLE: {
|
|
title: 'Service temporarily unavailable',
|
|
description: 'We are backing off from your bank. Try again shortly.',
|
|
retryable: true,
|
|
},
|
|
NETWORK_ERROR: {
|
|
title: 'No connection',
|
|
description: 'Check your network and try again.',
|
|
retryable: true,
|
|
},
|
|
REQUEST_TIMEOUT: {
|
|
title: 'The request timed out',
|
|
description: 'Your bank took too long to answer. Try again.',
|
|
retryable: true,
|
|
},
|
|
UNKNOWN_ERROR: {
|
|
title: 'Something went wrong',
|
|
description: 'Please try again.',
|
|
retryable: true,
|
|
},
|
|
};
|
|
|
|
export const describeError = (code: string | undefined): ErrorCopy =>
|
|
(code ? ERROR_COPY[code as AnyErrorCode] : undefined) ?? ERROR_COPY.UNKNOWN_ERROR;
|
|
|
|
export const isRetryableError = (code: string | undefined) =>
|
|
Boolean(describeError(code).retryable);
|
|
|
|
/**
|
|
* An error the CBS backend named, or one the client synthesised when the call
|
|
* never got an answer. Carries the code the UI switches on and, for
|
|
* `VALIDATION_ERROR`, the field map that paints straight onto the form.
|
|
*/
|
|
export class CbsApiError extends Error {
|
|
readonly status: number;
|
|
readonly code: AnyErrorCode;
|
|
readonly fieldErrors?: FieldErrors;
|
|
|
|
constructor(params: {
|
|
status: number;
|
|
code: AnyErrorCode;
|
|
message: string;
|
|
fieldErrors?: FieldErrors;
|
|
}) {
|
|
super(params.message);
|
|
this.name = 'CbsApiError';
|
|
this.status = params.status;
|
|
this.code = params.code;
|
|
this.fieldErrors = params.fieldErrors;
|
|
}
|
|
|
|
/** Copy suitable for a toast or an inline banner. */
|
|
describe() {
|
|
return describeError(this.code);
|
|
}
|
|
}
|
|
|
|
export const isCbsApiError = (value: unknown): value is CbsApiError =>
|
|
value instanceof CbsApiError;
|