UNPKG

line-pay-core-v4

Version:

Core library for LINE Pay API V4 SDK - Provides shared utilities, base client, TypeScript types, and error handling for building LINE Pay integrations

238 lines (237 loc) 8.53 kB
import { randomUUID } from 'node:crypto'; import { DEFAULT_TIMEOUT, LINE_PAY_API_BASE_URL } from './config/env'; import { LinePayConfigError, LinePayError, LinePayTimeoutError } from './errors/LinePayError'; import { LinePayUtils } from './LinePayUtils'; /** * LINE Pay Base Client * * Abstract base class for LINE Pay API integration (Online and Offline). * Provides core functionality for: * - API authentication with HMAC-SHA256 signatures * - HTTP request handling with timeout support * - Response parsing and error handling * - Configuration validation * * This class should be extended by specific API clients (e.g., `LinePayOnlineClient`). * * **Features:** * - ✅ Automatic signature generation for each request * - ✅ Timeout protection with AbortController * - ✅ Comprehensive error handling * - ✅ Type-safe response parsing * * @example * ```typescript * import { LinePayBaseClient } from 'line-pay-core-v4' * * class MyLinePayClient extends LinePayBaseClient { * async requestPayment(body: PaymentRequest) { * return this.sendRequest('POST', '/v3/payments/request', body) * } * } * * const client = new MyLinePayClient({ * channelId: process.env.LINE_PAY_CHANNEL_ID!, * channelSecret: process.env.LINE_PAY_CHANNEL_SECRET!, * env: 'sandbox', * timeout: 30000 * }) * ``` * * @see {@link https://pay.line.me/documents/online_v3_en.html} LINE Pay API Documentation */ export class LinePayBaseClient { /** * LINE Pay Channel ID * @protected */ channelId; /** * LINE Pay Channel Secret (encrypted/hashed in memory) * @protected */ channelSecret; /** * BASE URL for LINE Pay API * @protected */ baseUrl; /** * Request timeout in milliseconds * @protected */ timeout; /** * Creates a new LinePayBaseClient instance * * Validates the configuration and sets up the client with the appropriate API base URL. * * @param config - LINE Pay configuration object * @throws {LinePayConfigError} If channelId or channelSecret is empty * @throws {LinePayConfigError} If timeout is not a positive number * * @example * ```typescript * const client = new MyLinePayClient({ * channelId: '1234567890', * channelSecret: 'abc123...', * env: 'sandbox', * timeout: 20000 * }) * ``` * * @example * ```typescript * // Using environment variables (recommended) * const client = new MyLinePayClient({ * channelId: process.env.LINE_PAY_CHANNEL_ID!, * channelSecret: process.env.LINE_PAY_CHANNEL_SECRET!, * env: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox' * }) * ``` */ constructor(config) { const channelId = config.channelId.trim(); const channelSecret = config.channelSecret.trim(); if (channelId === '') { throw new LinePayConfigError('channelId is required and cannot be empty'); } if (channelSecret === '') { throw new LinePayConfigError('channelSecret is required and cannot be empty'); } this.channelId = channelId; this.channelSecret = channelSecret; this.baseUrl = config.env === 'production' ? LINE_PAY_API_BASE_URL.production : LINE_PAY_API_BASE_URL.sandbox; this.timeout = config.timeout ?? DEFAULT_TIMEOUT; if (this.timeout <= 0) { throw new LinePayConfigError('timeout must be a positive number'); } } /** * Sends an HTTP request to LINE Pay API with authentication * * Handles the complete request lifecycle: * 1. Generates HMAC-SHA256 signature * 2. Sets authentication headers * 3. Sends HTTP request with timeout * 4. Parses and validates response * 5. Throws appropriate errors on failure * * **Authentication Flow:** * - Generates unique nonce (UUID) * - Creates signature from: `secret + URI + queryString + body + nonce` * - Sets headers: `X-LINE-ChannelId`, `X-LINE-Authorization`, `X-LINE-Authorization-Nonce` * * **Error Handling:** * - Network errors → Propagated as-is * - Timeout → {@link LinePayTimeoutError} * - JSON parse error → {@link LinePayError} with code `PARSE_ERROR` * - HTTP error → {@link LinePayError} with LINE Pay error code * - Business error (returnCode !== '0000') → {@link LinePayError} * * @template T - Expected response type extending {@link LinePayBaseResponse} * @param method - HTTP method ('GET' or 'POST') * @param path - API endpoint path (e.g., '/v3/payments/request') * @param body - Optional request body (will be JSON stringified) * @param params - Optional query parameters * @param additionalHeaders - Optional additional HTTP headers to include in the request * @returns Promise resolving to typed LINE Pay response * @throws {LinePayTimeoutError} If request exceeds configured timeout * @throws {LinePayError} If API returns an error or response is invalid * @protected * * @example * ```typescript * // In a derived class * async requestPayment(requestBody: PaymentRequest) { * return this.sendRequest<PaymentResponse>( * 'POST', * '/v3/payments/request', * requestBody * ) * } * ``` * * @example * ```typescript * // GET request with query parameters * async getPaymentStatus(transactionId: string) { * return this.sendRequest<StatusResponse>( * 'GET', * `/v3/payments/${transactionId}`, * undefined, * { someParam: 'value' } * ) * } * ``` * * @example * ```typescript * // POST request with additional headers (e.g., for Offline API) * async makeOfflinePayment(body: PaymentRequest, deviceId: string) { * return this.sendRequest<PaymentResponse>( * 'POST', * '/v4/payments/oneTimeKeys/pay', * body, * undefined, * { * 'X-LINE-MerchantDeviceProfileId': deviceId, * 'X-LINE-MerchantDeviceType': 'POS' * } * ) * } * ``` */ async sendRequest(method, path, body, params, additionalHeaders) { const nonce = randomUUID(); const queryString = LinePayUtils.buildQueryString(params); const url = `${this.baseUrl}${path}${queryString}`; const bodyString = body !== undefined ? JSON.stringify(body) : ''; const signature = LinePayUtils.generateSignature(this.channelSecret, path, bodyString, nonce, queryString); const headers = { 'Content-Type': 'application/json', 'X-LINE-ChannelId': this.channelId, 'X-LINE-Authorization-Nonce': nonce, 'X-LINE-Authorization': signature, ...additionalHeaders, }; try { const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, this.timeout); const response = await fetch(url, { method, headers, body: method === 'POST' ? bodyString : undefined, signal: controller.signal, }); clearTimeout(timeoutId); const responseText = await response.text(); let jsonResponse; try { jsonResponse = JSON.parse(responseText); } catch { throw new LinePayError('PARSE_ERROR', 'Failed to parse response as JSON', response.status, responseText); } if (!response.ok) { throw new LinePayError(jsonResponse.returnCode || 'HTTP_ERROR', jsonResponse.returnMessage || response.statusText, response.status, responseText); } if (jsonResponse.returnCode !== '0000') { throw new LinePayError(jsonResponse.returnCode, jsonResponse.returnMessage, response.status, responseText); } return jsonResponse; } catch (error) { if (error instanceof LinePayError) { throw error; } if (error instanceof Error && error.name === 'AbortError') { throw new LinePayTimeoutError(this.timeout, url); } throw error; } } }