Files
Abin ToppoandClaude Opus 5.5 f960a119e8 Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on
- 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>
2026-10-14 18:18:40 +05:30

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',
}),
);
},
);
};