@arathron/n8n-nodes-zoho-books
Version:
n8n community nodes for Zoho Books and Zoho Inventory API integration - Complete CRUD operations for Sales Orders, Invoices, Items, Vendors, Credit Notes, Payments, Purchase Orders, Bills, Composite Items, and Assemblies
371 lines (365 loc) ⢠15.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.makeApiRequestAllItems = exports.makeApiRequest = void 0;
exports.checkRateLimit = checkRateLimit;
exports.zohoApiRequest = zohoApiRequest;
exports.zohoApiRequestWithFormData = zohoApiRequestWithFormData;
exports.zohoApiRequestAllItems = zohoApiRequestAllItems;
exports.getItemBySku = getItemBySku;
exports.handleExecutionError = handleExecutionError;
const n8n_workflow_1 = require("n8n-workflow");
const DEFAULT_RATE_LIMIT_PER_MINUTE = 100;
const DEFAULT_DAILY_LIMIT = 1000;
const RETRY_LIMIT = 3;
const BACKOFF_BASE = 1000;
const rateLimiter = {
requests: [],
dailyRequests: 0,
lastReset: Date.now(),
lastRequest: 0,
};
async function checkRateLimit(rateLimitPerMinute = DEFAULT_RATE_LIMIT_PER_MINUTE, dailyLimit = DEFAULT_DAILY_LIMIT) {
const now = Date.now();
const oneMinuteAgo = now - 60000;
if (now - rateLimiter.lastReset > 86400000) {
rateLimiter.dailyRequests = 0;
rateLimiter.lastReset = now;
}
rateLimiter.requests = rateLimiter.requests.filter(timestamp => timestamp > oneMinuteAgo);
if (rateLimiter.dailyRequests >= dailyLimit) {
throw new Error('Daily API limit exceeded');
}
if (rateLimiter.requests.length >= rateLimitPerMinute) {
const waitTime = 60000 - (now - rateLimiter.requests[0]);
if (waitTime > 0) {
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
rateLimiter.requests.push(now);
rateLimiter.dailyRequests++;
rateLimiter.lastRequest = now;
}
function createZohoError(errorResponse) {
const code = errorResponse.code || 0;
const message = errorResponse.message || 'Unknown error';
const errorMessages = {
1001: {
message: 'Resource not found',
description: 'The requested resource does not exist or has been deleted.',
},
1002: {
message: 'Invalid data provided',
description: 'Please check the required fields and data formats.',
},
1003: {
message: 'Duplicate entry',
description: 'A record with the same information already exists.',
},
4000: {
message: 'Rate limit exceeded',
description: 'Too many requests. Please wait and try again.',
},
1006: {
message: 'Invalid organization',
description: 'The organization ID is invalid or inactive.',
},
};
const errorInfo = errorMessages[code] || {
message: 'Unknown error occurred',
description: message || 'Unknown error occurred',
};
return {
message: errorInfo.message,
description: errorInfo.description,
httpCode: code >= 4000 ? '429' : '400',
};
}
function isRateLimitError(error) {
return (error?.response?.status === 429 ||
error?.code === 4000 ||
(error?.message && error.message.toLowerCase().includes('rate limit')));
}
function isAuthError(error) {
return error?.response?.status === 401 || error?.httpCode === 401;
}
function isNetworkError(error) {
return (error?.code === 'ECONNREFUSED' ||
error?.code === 'ENOTFOUND' ||
error?.code === 'ETIMEDOUT' ||
error?.message?.includes('Network error'));
}
function isValidationError(error) {
return error?.response?.status === 400 || error?.httpCode === 400;
}
async function zohoApiRequest(method, endpoint, body = {}, qs = {}) {
const credentials = await this.getCredentials('zohoBooksOAuth2');
const dataCenter = credentials.dataCenter;
const organizationId = credentials.organizationId;
if (!organizationId || organizationId.trim() === '') {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Organization ID is missing',
description: 'Please configure Organization ID in your Zoho Books OAuth2 credentials. You can find it in Zoho Books ā Settings ā Organization Profile.',
httpCode: '400',
});
}
const baseUrlMap = {
'COM': 'https://www.zohoapis.com/books/v3',
'IN': 'https://www.zohoapis.in/books/v3',
'EU': 'https://www.zohoapis.eu/books/v3',
'AU': 'https://www.zohoapis.com.au/books/v3',
'CN': 'https://www.zohoapis.com.cn/books/v3',
};
const baseUrl = baseUrlMap[dataCenter] || baseUrlMap['COM'];
const options = {
method,
uri: `${baseUrl}${endpoint}`,
headers: {},
qs: {
...qs,
organization_id: organizationId,
},
json: true,
};
if (Object.keys(body).length) {
const isJSONStringOnly = Object.keys(body).length === 1 && body.JSONString !== undefined;
if (isJSONStringOnly) {
options.form = body;
delete options.json;
}
else {
options.body = body;
}
}
let attempt = 0;
while (attempt < RETRY_LIMIT) {
try {
await checkRateLimit();
const response = await this.helpers.requestOAuth2.call(this, 'zohoBooksOAuth2', options);
if (response && typeof response === 'object' && 'code' in response) {
if (response.code !== 0) {
throw new n8n_workflow_1.NodeApiError(this.getNode(), createZohoError(response));
}
}
return response;
}
catch (error) {
if (isRateLimitError(error)) {
attempt++;
if (attempt < RETRY_LIMIT) {
const backoffTime = BACKOFF_BASE * Math.pow(2, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, backoffTime));
continue;
}
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Rate limit exceeded',
description: 'Too many requests. Please wait and try again.',
httpCode: '429',
});
}
if (isAuthError(error)) {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Authorization failed - please check your credentials',
description: `Authentication failed. This is most commonly due to data center mismatch.
š CURRENT CONFIGURATION:
⢠Data Center: ${dataCenter} (${baseUrl.replace('/books/v3', '')})
⢠Organization ID: ${organizationId} (${organizationId.match(/^\d+$/) ? 'ā
Valid format' : 'ā Invalid format'})
ā ļø COMMON CAUSES:
⢠Data center mismatch (#1 cause of 401 errors)
⢠Invalid Organization ID format
⢠OAuth2 app configured in wrong data center
⢠Insufficient API permissions
ā
IMMEDIATE FIXES:
1. Verify your Zoho Books URL matches your data center setting:
If your Zoho Books URL is https://books.zoho.com, keep COM selected
2. Get exact Organization ID from Zoho Books Settings > Organization Profile
3. Ensure OAuth2 app exists in https://api-console.zoho.com
4. Restart n8n if you recently updated the node
š DEBUG INFORMATION:
⢠Request Method: ${method}
⢠Full URL: ${options.uri}
⢠Data Center: ${dataCenter}
⢠Organization ID Header: X-com-zoho-books-organizationid: ${organizationId}
⢠Expected OAuth2 Bearer Token: Authorization: Bearer [hidden]
š§Ŗ MANUAL TEST:
curl -X ${method} "${options.uri}" \\
-H "Authorization: Bearer [YOUR_ACCESS_TOKEN]" \\
-H "X-com-zoho-books-organizationid: ${organizationId}"
š For detailed troubleshooting: See troubleshoot-auth.md in the node package`,
httpCode: '401',
});
}
if (isNetworkError(error)) {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Network error occurred while connecting to Zoho Books API.',
description: 'Please check your internet connection and try again.',
httpCode: '0',
});
}
if (isValidationError(error)) {
const errorData = error;
const zohoErrorMessage = errorData.response?.data?.message || errorData.message || 'Unknown validation error';
const zohoErrorCode = errorData.response?.data?.code;
if (zohoErrorCode === 110701) {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Reason required for sent invoice update',
description: 'Please provide a reason when updating a sent invoice. Add the reason in the Update Fields section.',
httpCode: '400',
});
}
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Bad request - please check your parameters',
description: zohoErrorMessage,
httpCode: '400',
});
}
if (error instanceof n8n_workflow_1.NodeApiError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('rate limit') || errorMessage.includes('Rate limit')) {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'The service is receiving too many requests from you',
description: 'Please wait and try again.',
httpCode: '429',
});
}
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'The service was not able to process your request',
description: errorMessage,
});
}
}
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Zoho Books API Error: Rate limited',
description: 'Failed to complete the request after multiple attempts. Please try again later.',
});
}
async function zohoApiRequestWithFormData(method, endpoint, formData = {}, qs = {}) {
return zohoApiRequest.call(this, method, endpoint, formData, qs);
}
async function zohoApiRequestAllItems(method, endpoint, body = {}, query = {}) {
const returnData = [];
let page = 1;
const perPage = 200;
do {
const qs = { ...query, page, per_page: perPage };
const responseData = await zohoApiRequest.call(this, method, endpoint, body, qs);
if (!responseData || typeof responseData !== 'object') {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Invalid response format from Zoho API',
description: 'Expected object response but received invalid data',
});
}
let items = [];
if (responseData.salesorders) {
items = responseData.salesorders;
}
else if (responseData.invoices) {
items = responseData.invoices;
}
else if (responseData.items) {
items = responseData.items;
}
else if (responseData.contacts) {
items = responseData.contacts;
}
else if (responseData.vendors) {
items = responseData.vendors;
}
else if (responseData.creditnotes) {
items = responseData.creditnotes;
}
else if (responseData.bills) {
items = responseData.bills;
}
else if (responseData.purchaseorders) {
items = responseData.purchaseorders;
}
else if (responseData.composite_items) {
items = responseData.composite_items;
}
else if (Array.isArray(responseData)) {
items = responseData;
}
else {
break;
}
returnData.push(...items);
if (items.length < perPage) {
break;
}
page++;
} while (true);
return returnData;
}
async function getItemBySku(sku) {
if (!sku || sku.trim() === '') {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'SKU code is required',
description: 'Please provide a valid SKU code to search for items.',
httpCode: '400',
});
}
try {
const response = await zohoApiRequest.call(this, 'GET', '/items', {}, {
search_text: sku.trim(),
per_page: 200
});
if (!response || !response.items || !Array.isArray(response.items)) {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Invalid response from Zoho Books API',
description: 'Expected items array in response but received invalid data.',
httpCode: '500',
});
}
const exactMatches = response.items.filter((item) => item.sku && item.sku.toLowerCase() === sku.toLowerCase());
if (exactMatches.length === 0) {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: `Item with SKU '${sku}' not found`,
description: 'No item found with the specified SKU code. Please verify the SKU is correct.',
httpCode: '404',
});
}
if (exactMatches.length > 1) {
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: `Multiple items found with SKU '${sku}'`,
description: `Found ${exactMatches.length} items with the same SKU. Please ensure SKU codes are unique.`,
httpCode: '409',
});
}
return exactMatches[0];
}
catch (error) {
if (error instanceof n8n_workflow_1.NodeApiError) {
throw error;
}
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
message: 'Failed to retrieve item by SKU',
description: `Error occurred while searching for item with SKU '${sku}': ${error instanceof Error ? error.message : String(error)}`,
httpCode: '500',
});
}
}
function handleExecutionError(node, error, itemIndex, continueOnFail) {
const errorMessage = error?.message || error?.toString() || 'Unknown error occurred';
const statusCode = error?.statusCode || error?.response?.status || 500;
if (continueOnFail) {
return [{
json: {
error: errorMessage,
statusCode,
itemIndex,
},
pairedItem: { item: itemIndex },
}];
}
else {
const { NodeOperationError } = require('n8n-workflow');
const nodeError = new NodeOperationError(node, errorMessage, { cause: error });
nodeError.itemIndex = itemIndex;
throw nodeError;
}
}
exports.makeApiRequest = zohoApiRequest;
exports.makeApiRequestAllItems = zohoApiRequestAllItems;
//# sourceMappingURL=GenericFunctions.js.map