- 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>
190 lines
6.4 KiB
TypeScript
190 lines
6.4 KiB
TypeScript
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
|
|
|
|
import type { CbsEnvelope } from '../../types/cbs';
|
|
import { sessionManager } from '../security/sessionManager';
|
|
import { secureStorage } from '../storage/secureStorage';
|
|
import { apiLogger } from './apiLogger';
|
|
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
|
|
import { rememberServerTime } from './serverClock';
|
|
|
|
const unauthorizedHandlers = new Set<() => void>();
|
|
|
|
export const registerUnauthorizedHandler = (handler: () => void) => {
|
|
unauthorizedHandlers.add(handler);
|
|
|
|
return () => {
|
|
unauthorizedHandlers.delete(handler);
|
|
};
|
|
};
|
|
|
|
const looksLikeEnvelope = (value: unknown): value is CbsEnvelope<unknown> =>
|
|
typeof value === 'object' && value !== null && 'success' in value;
|
|
|
|
/**
|
|
* `VALIDATION_ERROR` is the one failure that also fills `data`, with a
|
|
* field-to-message map. Anything else in there on a failure is not a field map.
|
|
*/
|
|
const readFieldErrors = (envelope: CbsEnvelope<unknown>): FieldErrors | undefined => {
|
|
if (envelope.errorCode !== 'VALIDATION_ERROR') {
|
|
return undefined;
|
|
}
|
|
|
|
const { data } = envelope;
|
|
|
|
if (typeof data !== 'object' || data === null) {
|
|
return undefined;
|
|
}
|
|
|
|
const entries = Object.entries(data as Record<string, unknown>).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
);
|
|
|
|
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
};
|
|
|
|
/** The absolute URL a failed request was aimed at, for a dev-only diagnostic. */
|
|
const describeTarget = (error: AxiosError) => {
|
|
const base = error.config?.baseURL ?? '';
|
|
const path = error.config?.url ?? '';
|
|
|
|
return `${base}${path}` || 'the server';
|
|
};
|
|
|
|
const envelopeToError = (status: number, envelope: CbsEnvelope<unknown>) =>
|
|
new CbsApiError({
|
|
status,
|
|
code: isCbsErrorCode(envelope.errorCode) ? envelope.errorCode : 'UNKNOWN_ERROR',
|
|
message: envelope.message || 'Unexpected service error',
|
|
fieldErrors: readFieldErrors(envelope),
|
|
});
|
|
|
|
/**
|
|
* `SESSION_INVALID` means the token we hold is dead and every screen must drop
|
|
* it. `INVALID_CREDENTIALS` shares the 401 but means a wrong MPIN on the
|
|
* 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.
|
|
*/
|
|
export const handleSessionLoss = async (error: CbsApiError) => {
|
|
if (error.code !== 'SESSION_INVALID') {
|
|
return;
|
|
}
|
|
|
|
await secureStorage.clearSession();
|
|
sessionManager.clear();
|
|
unauthorizedHandlers.forEach((handler) => handler());
|
|
};
|
|
|
|
export const attachInterceptors = (client: AxiosInstance) => {
|
|
client.interceptors.request.use(async (config) => {
|
|
const token = await secureStorage.getAccessToken();
|
|
|
|
if (token) {
|
|
const nextHeaders = (config.headers ?? {}) as Record<string, string>;
|
|
nextHeaders.Authorization = `Bearer ${token}`;
|
|
config.headers = nextHeaders as never;
|
|
}
|
|
|
|
// Logged here, at the end of the interceptor, so the curl shows the
|
|
// headers the request will actually be sent with — the token itself as a
|
|
// placeholder.
|
|
apiLogger.request(config);
|
|
|
|
return config;
|
|
});
|
|
|
|
client.interceptors.response.use(
|
|
async (response: AxiosResponse) => {
|
|
apiLogger.response(response);
|
|
|
|
if (!looksLikeEnvelope(response.data)) {
|
|
return response;
|
|
}
|
|
|
|
const envelope = response.data as CbsEnvelope<unknown>;
|
|
|
|
// Branch on `success`, not on the HTTP status: a failure can arrive with
|
|
// a 2xx, and treating it as a payload would hand callers `undefined`.
|
|
if (!envelope.success) {
|
|
const error = envelopeToError(response.status, envelope);
|
|
apiLogger.failure(response, `envelope said success: false -> ${error.code}`);
|
|
await handleSessionLoss(error);
|
|
throw error;
|
|
}
|
|
|
|
// Any authenticated call refreshes the server's idle timer, so mirror
|
|
// that locally — otherwise the app would sign the user out while the
|
|
// session is still very much alive.
|
|
if (response.config.headers?.Authorization) {
|
|
sessionManager.touch();
|
|
}
|
|
|
|
// Unwrap so callers read the payload directly rather than the envelope.
|
|
// The server's `timestamp` is kept aside for anything counting down to
|
|
// a server expiry — see `serverClock`.
|
|
const unwrapped = { ...response, data: envelope.data };
|
|
rememberServerTime(unwrapped, envelope.timestamp);
|
|
|
|
return unwrapped;
|
|
},
|
|
async (error: AxiosError) => {
|
|
if (error instanceof CbsApiError) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (error.response) {
|
|
apiLogger.failure(error.response, `HTTP error ${error.code ?? ''}`.trim());
|
|
} else {
|
|
apiLogger.networkFailure(error);
|
|
}
|
|
|
|
const status = Number(error.response?.status ?? 0);
|
|
const body = error.response?.data;
|
|
|
|
if (looksLikeEnvelope(body)) {
|
|
const cbsError = envelopeToError(status, body);
|
|
await handleSessionLoss(cbsError);
|
|
|
|
return Promise.reject(cbsError);
|
|
}
|
|
|
|
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
|
return Promise.reject(
|
|
new CbsApiError({
|
|
status: 0,
|
|
code: 'REQUEST_TIMEOUT',
|
|
message: 'The request timed out',
|
|
}),
|
|
);
|
|
}
|
|
|
|
// No response at all: nothing answered on the other end. The usual causes
|
|
// are the backend not running, it being bound to 127.0.0.1 instead of
|
|
// 0.0.0.0, the handset being on a different network from the host, a
|
|
// firewall on the host port, or cleartext HTTP blocked in a release build.
|
|
//
|
|
// Naming the URL that failed matters more than it looks: without it this
|
|
// is indistinguishable from a server-side fault, and the address the
|
|
// phone needs is often not the one that works in the laptop's browser.
|
|
if (!error.response) {
|
|
return Promise.reject(
|
|
new CbsApiError({
|
|
status: 0,
|
|
code: 'NETWORK_ERROR',
|
|
message: __DEV__
|
|
? `Could not reach ${describeTarget(error)}. Check the backend is running and that this device can reach that address.`
|
|
: error.message || 'Could not reach the server',
|
|
}),
|
|
);
|
|
}
|
|
|
|
return Promise.reject(
|
|
new CbsApiError({
|
|
status,
|
|
code: 'UNKNOWN_ERROR',
|
|
message: error.message || 'Unexpected service error',
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
};
|