Files
CBS-Mobile-App/src/services/api/cbsErrors.ts
T

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;