n8n-nodes-bizappay
Version:
Unofficial community n8n node for Bizappay API integration - NOT officially endorsed by Bizappay
355 lines (354 loc) • 13.8 kB
JavaScript
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;
}
}
;