/** * 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(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([ '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; 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 = { 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;