UNPKG

n8n-nodes-bizappay

Version:

Unofficial community n8n node for Bizappay API integration - NOT officially endorsed by Bizappay

355 lines (354 loc) 13.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.bizappayApiRequest = exports.clearTokenCache = exports.getCacheStats = void 0; const n8n_workflow_1 = require("n8n-workflow"); const tokenCache = new Map(); const pendingTokenRequests = new Map(); const cacheStats = { hits: 0, misses: 0, totalRequests: 0 }; const TOKEN_EXPIRY_MINUTES = 55; const PREEMPTIVE_REFRESH_MINUTES = 5; const MAX_CACHE_SIZE = 100; const CLEANUP_INTERVAL = 30 * 60 * 1000; const MAX_RETRIES = 3; const BASE_RETRY_DELAY = 1000; function hashApiKey(apiKey) { return Buffer.from(apiKey).toString('base64').slice(0, 16); } function shouldRefreshToken(entry) { const timeUntilExpiry = entry.expires - Date.now(); return timeUntilExpiry < PREEMPTIVE_REFRESH_MINUTES * 60 * 1000; } function cleanupCache() { const now = Date.now(); for (const [key, entry] of tokenCache.entries()) { if (entry.expires < now || now - entry.lastUsed > 2 * 60 * 60 * 1000) { tokenCache.delete(key); } } if (tokenCache.size > MAX_CACHE_SIZE) { const entries = Array.from(tokenCache.entries()).sort((a, b) => a[1].lastUsed - b[1].lastUsed); const toDelete = entries.slice(0, entries.length - MAX_CACHE_SIZE); toDelete.forEach(([key]) => tokenCache.delete(key)); } } setInterval(cleanupCache, CLEANUP_INTERVAL); async function makeTokenRequestWithRetry(context, apiKey, baseUrl, retryCount = 0) { try { const httpOptions = { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'n8n-nodes-bizappay/1.0.0', }, method: 'POST', body: `apiKey=${encodeURIComponent(apiKey)}`, url: `${baseUrl}/api/v3/token`, json: false, timeout: 15000, }; const response = await context.helpers.httpRequest(httpOptions); let result; if (typeof response === 'string') { try { result = JSON.parse(response); } catch (e) { throw new Error(`Invalid JSON response: ${response}`); } } else if (typeof response === 'object' && response !== null) { result = response; } else { throw new Error(`Unexpected response type: ${typeof response}`); } if (!result.token || result.status !== 'ok') { throw new Error(`Invalid token response: ${result.error || 'Unknown error'}`); } return result; } catch (error) { if (retryCount < MAX_RETRIES) { const delay = BASE_RETRY_DELAY * Math.pow(2, retryCount) + Math.random() * 500; await new Promise((resolve) => setTimeout(resolve, delay)); return makeTokenRequestWithRetry(context, apiKey, baseUrl, retryCount + 1); } throw error; } } async function getTokenWithDeduplication(context, apiKey, baseUrl) { const hashedKey = hashApiKey(apiKey); if (pendingTokenRequests.has(hashedKey)) { const response = await pendingTokenRequests.get(hashedKey); return response.token; } const tokenPromise = makeTokenRequestWithRetry(context, apiKey, baseUrl); pendingTokenRequests.set(hashedKey, tokenPromise); try { const response = await tokenPromise; return response.token; } finally { pendingTokenRequests.delete(hashedKey); } } function getCacheStats() { return { ...cacheStats }; } exports.getCacheStats = getCacheStats; function clearTokenCache() { tokenCache.clear(); pendingTokenRequests.clear(); cacheStats.hits = 0; cacheStats.misses = 0; cacheStats.totalRequests = 0; } exports.clearTokenCache = clearTokenCache; async function bizappayApiRequest(method, endpoint, body = {}, query = {}, headers = {}) { try { const credentials = await this.getCredentials('bizappayApi'); if (!credentials) { throw new n8n_workflow_1.NodeApiError(this.getNode(), { message: 'No credentials provided' }); } const apiKey = credentials.apiKey; if (!apiKey) { throw new n8n_workflow_1.NodeApiError(this.getNode(), { message: 'API key is required' }); } const baseUrl = 'https://bizappay.my'; const hashedKey = hashApiKey(apiKey); const now = Date.now(); cacheStats.totalRequests++; let authToken = ''; if (endpoint !== '/token') { const cachedEntry = tokenCache.get(hashedKey); if (cachedEntry && cachedEntry.expires > now) { cachedEntry.lastUsed = now; cachedEntry.requestCount++; cacheStats.hits++; authToken = cachedEntry.token; if (shouldRefreshToken(cachedEntry)) { setImmediate(async () => { try { const newToken = await getTokenWithDeduplication(this, apiKey, baseUrl); const expiresAt = now + TOKEN_EXPIRY_MINUTES * 60 * 1000; tokenCache.set(hashedKey, { token: newToken, expires: expiresAt, createdAt: now, lastUsed: now, requestCount: 1, }); } catch (error) { } }); } } else { cacheStats.misses++; authToken = await getTokenWithDeduplication(this, apiKey, baseUrl); const expiresAt = now + TOKEN_EXPIRY_MINUTES * 60 * 1000; tokenCache.set(hashedKey, { token: authToken, expires: expiresAt, createdAt: now, lastUsed: now, requestCount: 1, }); } } return await makeApiRequestWithRetry(this, { method, endpoint, body, query, headers, authToken, baseUrl, apiKey, credentials, }); } catch (error) { if (error instanceof n8n_workflow_1.NodeApiError) { throw error; } let errorMessage = 'Unknown error occurred'; if (error instanceof Error) { errorMessage = error.message; } else if (typeof error === 'string') { errorMessage = error; } else if (error && typeof error === 'object') { const errorObj = error; errorMessage = errorObj.message || errorObj.error || errorObj.msg || 'Object error occurred'; if (typeof errorMessage !== 'string') { errorMessage = 'Complex error object encountered'; } } else { errorMessage = String(error); } throw new n8n_workflow_1.NodeApiError(this.getNode(), { message: errorMessage, }); } } exports.bizappayApiRequest = bizappayApiRequest; async function makeApiRequestWithRetry(context, params, retryCount = 0) { const formData = { ...params.body }; if (params.endpoint === '/token') { formData.apiKey = params.credentials.apiKey; } else if (params.endpoint === '/bill/create') { formData.apiKey = params.credentials.apiKey; if (formData.payer_phone && typeof formData.payer_phone === 'string') { let phone = formData.payer_phone.trim(); phone = phone.replace(/[^\d+]/g, ''); if (phone.startsWith('+60')) { if (phone.length < 12 || phone.length > 13) { phone = '+60123456789'; } } else if (phone.startsWith('60')) { phone = '+' + phone; if (phone.length < 12 || phone.length > 13) { phone = '+60123456789'; } } else if (phone.startsWith('0')) { phone = '+6' + phone; if (phone.length < 12 || phone.length > 13) { phone = '+60123456789'; } } else { if (phone.length >= 9 && phone.length <= 10) { phone = '+60' + phone; } else { phone = '+60123456789'; } } formData.payer_phone = phone; } } else if (params.endpoint === '/bill/info') { formData.apiKey = params.credentials.apiKey; } else if (params.endpoint === '/category') { formData.apiKey = params.credentials.apiKey; } const httpOptions = { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'n8n-nodes-bizappay/1.0.0', ...params.headers, }, method: params.method, body: Object.keys(formData) .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(String(formData[key]))}`) .join('&'), qs: params.query, url: `${params.baseUrl}/api/v3${params.endpoint}`, json: true, timeout: 15000, }; if (params.authToken) { if (httpOptions.headers) { httpOptions.headers.Authentication = params.authToken; } } try { const response = await context.helpers.httpRequest(httpOptions); if (response && typeof response === 'object') { if (response.status || response.msg || response.token || response.categories || response.billCode) { const cleanResponse = { status: String(response.status || 'ok'), msg: String(response.msg || 'Success'), }; if (response.token) cleanResponse.token = String(response.token); if (response.categories && Array.isArray(response.categories)) { cleanResponse.categories = response.categories.map((cat) => ({ code: String(cat.code || ''), name: String(cat.name || ''), ...cat, })); } if (response.billCode) cleanResponse.billCode = String(response.billCode); if (response.billUrl) cleanResponse.billUrl = String(response.billUrl); if (response.ttl) cleanResponse.ttl = Number(response.ttl); if (response.error) cleanResponse.error = String(response.error); for (const [key, value] of Object.entries(response)) { if (!cleanResponse.hasOwnProperty(key) && value !== undefined && value !== null) { try { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { cleanResponse[key] = value; } else if (Array.isArray(value)) { JSON.stringify(value); cleanResponse[key] = value; } else if (typeof value === 'object') { JSON.stringify(value); cleanResponse[key] = value; } else { cleanResponse[key] = String(value); } } catch (e) { if (typeof value === 'object' && value !== null) { cleanResponse[key] = '[Complex Object]'; } else { cleanResponse[key] = String(value); } } } } return cleanResponse; } } return { status: 'ok', msg: 'Response received', data: response, endpoint: params.endpoint, }; } catch (error) { if (error.httpCode === 401 && retryCount === 0) { const hashedKey = hashApiKey(params.apiKey); tokenCache.delete(hashedKey); const newToken = await getTokenWithDeduplication(context, params.apiKey, params.baseUrl); const now = Date.now(); const expiresAt = now + TOKEN_EXPIRY_MINUTES * 60 * 1000; tokenCache.set(hashedKey, { token: newToken, expires: expiresAt, createdAt: now, lastUsed: now, requestCount: 1, }); return makeApiRequestWithRetry(context, { ...params, authToken: newToken }, retryCount + 1); } if ((error.httpCode === 429 || error.httpCode >= 500) && retryCount < MAX_RETRIES) { const delay = BASE_RETRY_DELAY * Math.pow(2, retryCount) + Math.random() * 1000; await new Promise((resolve) => setTimeout(resolve, delay)); return makeApiRequestWithRetry(context, params, retryCount + 1); } throw error; } }