import { PayMeApiError, PayMeErrorKind } from './payme-error'; import { PayMeEnvelope } from './payme-types'; /** * The transport layer for every PayMe call. Framework-free: it needs only * `fetch` (Node 18+). Port it to your stack as one class or module; keep * everything PayMe-specific about HOW a request is made in this one place: * * - base URL per environment (sandbox vs live) * - `Content-Type: application/json` — without it PayMe answers with an * invalid-JSON error that looks like a validation failure * - the success test: `status_code` 0 in a 2xx response. HTTP 200 alone is * not success (it can carry status_code 1), and a non-2xx is never success * even if its body says status_code 0. The string "0" also counts. * - one error type, which says whether PayMe may have acted anyway. The HTTP * status doesn't decide that: PayMe sends ordinary refusals as HTTP 500 with * a full error body. See isDefiniteRefusal * - logs that never contain a credential, a token, personal data or an id * from the path — including when PayMe echoes one back in an error message */ export type PayMeEnvironment = 'sandbox' | 'production'; export const BASE_URLS: Record = { // Trailing slash matters — endpoints are appended directly. sandbox: 'https://sandbox.payme.io/api/', production: 'https://live.payme.io/api/', }; /** * Keys whose values must never be logged: credentials (PayMe calls the MPL * "your private key in PayMe system"), tokens, ids that act as capabilities * (paying a subscription needs only its id), card fields, and personal data. */ export const REDACTED_KEYS = new Set([ 'payme_client_key', 'payme_client_secret', 'seller_payme_id', 'seller_payme_secret', 'buyer_key', 'sub_payme_id', 'sub_url', 'meta_data_jwt', 'credit_card_number', 'credit_card_cvv', 'credit_card_exp', 'buyer_card_exp', 'buyer_card_expiry', 'password', 'seller_social_id', 'seller_birthdate', 'seller_social_id_issued', 'seller_bank_account_number', 'buyer_social_id', 'buyer_name', 'buyer_email', 'buyer_phone', ]); /** Headers that carry credentials. */ const SECRET_HEADERS = ['PayMe-Partner-Key', 'PayMe-Merchant-Key']; /** Path segments that carry an id: /sellers/{mpl}/..., /subscriptions/{id}/... */ const SECRET_PATH = /\b(sellers|subscriptions)\/([^/?#]+)/g; /** Connection failures where nothing can have reached PayMe. */ const UNSENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']); /** * Codes that arrive like a refusal but don't say nothing happened. 991 and 20002 * mean PayMe lost contact with the card company or its own systems part-way, and * the sale can show `failed` afterwards. 999 means no information was given. */ export const OUTCOME_UNKNOWN_CODES = new Set([991, 20002, 999]); export interface PayMeRequestOptions { /** PayMe is POST for almost everything; a few REST-shaped endpoints are GET/PATCH. */ method?: 'GET' | 'POST' | 'PATCH'; /** e.g. `PayMe-Merchant-Key: ` for subscription resume. */ headers?: Record; /** * Judge success by the HTTP status only and return the body as-is. For * endpoints that answer with a resource (e.g. POST /sellers/{mpl}/tokens), * not with PayMe's envelope. */ rawEnvelope?: boolean; /** * pay-sale with 3-D Secure may answer `status_code: 5` with a * `redirect_url` for the buyer's challenge. With this flag that answer is * returned to the caller instead of thrown. The final result arrives later * by callback. */ allowChallenge?: boolean; } export interface PayMeLogger { debug(message: string): void; warn(message: string): void; } export interface PayMeClientOptions { environment: PayMeEnvironment; /** Injected for tests; defaults to the global fetch. */ fetch?: typeof fetch; logger?: PayMeLogger; /** Network timeout per request. */ timeoutMs?: number; } export class PayMeClient { private readonly fetchImpl: typeof fetch; private readonly logger?: PayMeLogger; constructor(private readonly options: PayMeClientOptions) { this.fetchImpl = options.fetch ?? fetch; this.logger = options.logger; } /** * @param endpoint path relative to the API root, no leading slash — * "generate-sale", "subscriptions/SUB.../resume" */ async request( endpoint: string, body?: unknown, options: PayMeRequestOptions = {}, ): Promise { const { method = 'POST', headers = {}, rawEnvelope = false, allowChallenge = false, } = options; const url = `${BASE_URLS[this.options.environment]}${endpoint}`; const label = maskPath(endpoint); const secrets = collectSecrets(endpoint, body, headers); const clean = (value: unknown) => scrub(redact(value), secrets); this.logger?.debug( `-> ${method} ${label} ${body === undefined ? '' : JSON.stringify(clean(body))}`, ); const fail = ( kind: PayMeErrorKind, envelope: PayMeEnvelope | undefined, details: string, outcomeUnknown: boolean, raw: unknown, ) => { const error = new PayMeApiError( label, kind, envelope?.status_error_code ?? null, String(scrub(details, secrets)), clean(envelope?.status_additional_info ?? null), envelope?.session ?? null, clean(raw), outcomeUnknown, ); this.logger?.warn( `PayMe ${label} failed: kind=${kind} code=${error.paymeErrorCode ?? 'n/a'} ` + `details="${error.paymeErrorDetails}" info=${JSON.stringify(error.additionalInfo)} ` + `session=${error.session ?? 'n/a'} outcomeUnknown=${outcomeUnknown}`, ); return error; }; let response: Response; try { response = await this.fetchImpl(url, { method, headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(this.options.timeoutMs ?? 30_000), }); } catch (cause) { const name = (cause as Error)?.name; if (name === 'TimeoutError' || name === 'AbortError') { throw fail('timeout', undefined, `No answer from PayMe within the timeout (${label})`, true, null); } const code = (cause as { cause?: { code?: string } })?.cause?.code; throw fail( 'network', undefined, `Could not reach PayMe (${label}): ${(cause as Error)?.message ?? 'network error'}`, !(code && UNSENT_CODES.has(code)), null, ); } const text = await response.text(); let payload: unknown; try { payload = text ? JSON.parse(text) : {}; } catch { throw fail( response.ok ? 'invalid-response' : 'http', undefined, `PayMe returned a non-JSON body (HTTP ${response.status}): ${text.slice(0, 500)}`, true, text.slice(0, 500), ); } this.logger?.debug( `<- ${response.status} ${label} ${JSON.stringify(clean(payload))}`, ); const envelope = payload as PayMeEnvelope; if (!response.ok) { const details = envelope?.status_error_details ?? `PayMe answered HTTP ${response.status}`; if (response.status === 429) { // Turned away, but a matching call may still be running (error 998): // wait, re-read the state, and repeat only if it didn't happen. throw fail('rate-limited', envelope, details, true, payload); } if (hasErrorCode(envelope)) { throw fail('rejected', envelope, details, !isDefiniteRefusal(envelope), payload); } throw fail('http', envelope, details, response.status >= 500, payload); } if (rawEnvelope) return payload as TResponse; const code = statusCode(envelope?.status_code); if (code === 0) return payload as TResponse; if (allowChallenge && code === 5) return payload as TResponse; throw fail( 'rejected', envelope, envelope?.status_error_details ?? `PayMe rejected the ${label} request without a message`, !isDefiniteRefusal(envelope), payload, ); } } /** status_code as a number, whether it arrives as a number or a string. */ export function statusCode(value: unknown): number | null { if (typeof value === 'number') return value; if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value); return null; } /** An answer with `status_code` 1 and a non-zero `status_error_code`. */ function hasErrorCode(envelope: PayMeEnvelope | undefined): boolean { return statusCode(envelope?.status_code) === 1 && Boolean(statusCode(envelope?.status_error_code)); } /** * Whether this answer proves PayMe refused, at any HTTP status. Anything else that * isn't a success leaves the outcome unknown: check the sale before repeating. */ export function isDefiniteRefusal(envelope: PayMeEnvelope | undefined): boolean { return ( hasErrorCode(envelope) && !OUTCOME_UNKNOWN_CODES.has(statusCode(envelope?.status_error_code) as number) ); } /** "subscriptions/SUB-123/resume" -> "subscriptions/[redacted]/resume" */ export function maskPath(endpoint: string): string { return endpoint.replace(SECRET_PATH, '$1/[redacted]'); } /** Recursively replace the values of sensitive keys. */ export function redact(value: unknown): unknown { if (Array.isArray(value)) return value.map(redact); if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value as Record).map(([key, val]) => [ key, REDACTED_KEYS.has(key) ? '[redacted]' : redact(val), ]), ); } return value; } /** * Every sensitive value this request carried: values of REDACTED_KEYS in the * body, credential headers, ids in the path. PayMe sometimes echoes a value it * received inside an error message, where key-based redaction cannot see it. */ export function collectSecrets( endpoint: string, body: unknown, headers: Record, ): string[] { const found = new Set(); const walk = (value: unknown) => { if (Array.isArray(value)) return value.forEach(walk); if (value && typeof value === 'object') { for (const [key, val] of Object.entries(value as Record)) { if (REDACTED_KEYS.has(key) && (typeof val === 'string' || typeof val === 'number')) { found.add(String(val)); } else { walk(val); } } } }; walk(body); for (const name of SECRET_HEADERS) { const value = Object.entries(headers).find( ([key]) => key.toLowerCase() === name.toLowerCase(), )?.[1]; if (value) found.add(value); } for (const match of endpoint.matchAll(SECRET_PATH)) found.add(match[2]); // Very short values would mask unrelated text; ids and keys are long. return [...found].filter((value) => value.length >= 6); } /** Replace every occurrence of a collected secret, deep. */ export function scrub(value: unknown, secrets: string[]): unknown { if (!secrets.length) return value; if (typeof value === 'string') { return secrets.reduce((text, secret) => text.split(secret).join('[redacted]'), value); } if (Array.isArray(value)) return value.map((item) => scrub(item, secrets)); if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value as Record).map(([key, val]) => [ key, scrub(val, secrets), ]), ); } return value; }