- 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>
228 lines
9.5 KiB
TypeScript
228 lines
9.5 KiB
TypeScript
import {
|
|
CBS_ERROR_CODES,
|
|
CbsApiError,
|
|
describeError,
|
|
isCbsApiError,
|
|
isCbsErrorCode,
|
|
isClientErrorCode,
|
|
isRetryableError,
|
|
} from './cbsErrors';
|
|
|
|
describe('error code catalogue', () => {
|
|
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', () => {
|
|
// 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', () => {
|
|
// The account exists and the bank simply will not price it. Repeating the
|
|
// call gets the same refusal, so the card must not offer a retry.
|
|
expect(isCbsErrorCode('ACCOUNT_BALANCE_NOT_AVAILABLE')).toBe(true);
|
|
expect(isRetryableError('ACCOUNT_BALANCE_NOT_AVAILABLE')).toBe(false);
|
|
});
|
|
|
|
it('knows the cheque book codes', () => {
|
|
expect(isCbsErrorCode('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(true);
|
|
expect(isCbsErrorCode('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(true);
|
|
});
|
|
|
|
it('does not invite a retry on either cheque book refusal', () => {
|
|
// Neither is transient. The account cannot take a cheque book at all, or
|
|
// the bank understood the request and refused it — an identical retry gets
|
|
// an identical answer, and on a paid product that is worse than useless.
|
|
expect(isRetryableError('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(false);
|
|
expect(isRetryableError('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(false);
|
|
});
|
|
|
|
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', () => {
|
|
// Both are the request's fault, not the bank's: one range is unusable and
|
|
// the other view does not exist. Repeating the identical call gets the
|
|
// identical answer, so the screen must offer a different range — or
|
|
// 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', () => {
|
|
expect(isCbsErrorCode('IMPS_UNAVAILABLE')).toBe(true);
|
|
expect(isCbsErrorCode('IMPS_INVALID_RESPONSE')).toBe(true);
|
|
expect(isCbsErrorCode('IMPS_CONFIGURATION_ERROR')).toBe(true);
|
|
});
|
|
|
|
it('invites a retry when the switch was unreachable, but not when it is misconfigured', () => {
|
|
// Validate saves nothing, so a failed call is safe to repeat — unlike the
|
|
// Positive Pay write below. A configuration fault is not the user's to fix.
|
|
expect(isRetryableError('IMPS_UNAVAILABLE')).toBe(true);
|
|
expect(isRetryableError('IMPS_INVALID_RESPONSE')).toBe(true);
|
|
expect(isRetryableError('IMPS_CONFIGURATION_ERROR')).toBe(false);
|
|
});
|
|
|
|
it('knows the Positive Pay codes', () => {
|
|
expect(isCbsErrorCode('POSITIVE_PAY_NOT_AVAILABLE')).toBe(true);
|
|
expect(isCbsErrorCode('CHEQUE_ALREADY_REGISTERED')).toBe(true);
|
|
expect(isCbsErrorCode('POSITIVE_PAY_DECLINED')).toBe(true);
|
|
});
|
|
|
|
it('knows the stop-payment codes', () => {
|
|
expect(isCbsErrorCode('CHEQUE_NOT_FOUND')).toBe(true);
|
|
expect(isCbsErrorCode('STOP_PAYMENT_NOT_AVAILABLE')).toBe(true);
|
|
expect(isCbsErrorCode('CHEQUE_ALREADY_STOPPED')).toBe(true);
|
|
expect(isCbsErrorCode('STOP_PAYMENT_DECLINED')).toBe(true);
|
|
});
|
|
|
|
it('does not invite a retry on a stop-payment refusal either', () => {
|
|
// Same reasoning as Positive Pay: stopping a cheque is a write. A second
|
|
// attempt after a refusal changes nothing, and after a success it returns
|
|
// CHEQUE_ALREADY_STOPPED — which reads like the customer's mistake.
|
|
expect(isRetryableError('CHEQUE_NOT_FOUND')).toBe(false);
|
|
expect(isRetryableError('CHEQUE_ALREADY_STOPPED')).toBe(false);
|
|
expect(isRetryableError('STOP_PAYMENT_DECLINED')).toBe(false);
|
|
expect(isRetryableError('STOP_PAYMENT_NOT_AVAILABLE')).toBe(false);
|
|
});
|
|
|
|
it('does not invite a retry on a Positive Pay refusal', () => {
|
|
// Registering a cheque is a write. A refusal is final, and offering "try
|
|
// again" would only produce CHEQUE_ALREADY_REGISTERED or a duplicate.
|
|
expect(isRetryableError('CHEQUE_ALREADY_REGISTERED')).toBe(false);
|
|
expect(isRetryableError('POSITIVE_PAY_DECLINED')).toBe(false);
|
|
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);
|
|
|
|
expect(copy.title.length).toBeGreaterThan(0);
|
|
expect(copy.description.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('recognises documented codes and rejects invented ones', () => {
|
|
expect(isCbsErrorCode('MPIN_LOCKED')).toBe(true);
|
|
expect(isCbsErrorCode('MPIN_UNLOCKED')).toBe(false);
|
|
expect(isCbsErrorCode(undefined)).toBe(false);
|
|
});
|
|
|
|
it('falls back to generic copy for an unknown or missing code', () => {
|
|
expect(describeError('SOMETHING_NEW').title).toBe(describeError(undefined).title);
|
|
});
|
|
});
|
|
|
|
describe('isRetryableError', () => {
|
|
// Retrying these has a real chance of working: the bank was unreachable or a
|
|
// breaker was open.
|
|
it('marks upstream and transport failures retryable', () => {
|
|
expect(isRetryableError('CBS_UNAVAILABLE')).toBe(true);
|
|
expect(isRetryableError('CONNECTOR_UNAVAILABLE')).toBe(true);
|
|
expect(isRetryableError('NETWORK_ERROR')).toBe(true);
|
|
});
|
|
|
|
// Retrying these can only waste an attempt, and against MPIN_LOCKED there is
|
|
// nothing to retry at all — the customer must visit a branch.
|
|
it('does not mark user-input or policy failures retryable', () => {
|
|
expect(isRetryableError('OTP_INVALID')).toBe(false);
|
|
expect(isRetryableError('INVALID_CREDENTIALS')).toBe(false);
|
|
expect(isRetryableError('MPIN_LOCKED')).toBe(false);
|
|
expect(isRetryableError('VALIDATION_ERROR')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('CbsApiError', () => {
|
|
it('carries the code and the validation field map', () => {
|
|
const error = new CbsApiError({
|
|
status: 400,
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Request validation failed',
|
|
fieldErrors: { mobileNumber: 'must be exactly 10 digits' },
|
|
});
|
|
|
|
expect(isCbsApiError(error)).toBe(true);
|
|
expect(error.code).toBe('VALIDATION_ERROR');
|
|
expect(error.fieldErrors?.mobileNumber).toBe('must be exactly 10 digits');
|
|
expect(error.describe().title).toBe(describeError('VALIDATION_ERROR').title);
|
|
});
|
|
|
|
it('is a real Error, so it survives being thrown and caught', () => {
|
|
expect(() => {
|
|
throw new CbsApiError({ status: 0, code: 'NETWORK_ERROR', message: 'offline' });
|
|
}).toThrow('offline');
|
|
});
|
|
|
|
it('does not mistake a plain object for one', () => {
|
|
expect(isCbsApiError({ status: 400, code: 'OTP_INVALID', message: 'nope' })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isClientErrorCode', () => {
|
|
// These carry a transport detail, not a message anyone wrote for a customer.
|
|
it('is true for the codes the client synthesises', () => {
|
|
expect(isClientErrorCode('NETWORK_ERROR')).toBe(true);
|
|
expect(isClientErrorCode('REQUEST_TIMEOUT')).toBe(true);
|
|
expect(isClientErrorCode('UNKNOWN_ERROR')).toBe(true);
|
|
});
|
|
|
|
it('is false for codes the backend names, whose message is worth showing', () => {
|
|
expect(isClientErrorCode('OTP_INVALID')).toBe(false);
|
|
expect(isClientErrorCode('VALIDATION_ERROR')).toBe(false);
|
|
expect(isClientErrorCode(undefined)).toBe(false);
|
|
});
|
|
});
|