From 2f4649cdf44e15b41b40879f27ffe39406a53b1b Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Fri, 21 Oct 2022 19:45:54 +0300 Subject: [PATCH] fix(InvoiceNinja Node): added support for v5 --- .../InvoiceNinjaApi.credentials.ts | 48 +++++- .../nodes/InvoiceNinja/GenericFunctions.ts | 43 +++-- .../nodes/InvoiceNinja/InvoiceNinja.node.ts | 153 +++++++++++++++--- .../InvoiceNinja/InvoiceNinjaTrigger.node.ts | 125 ++++++++++++-- .../nodes/InvoiceNinja/PaymentInterface.ts | 1 + 5 files changed, 311 insertions(+), 59 deletions(-) diff --git a/packages/nodes-base/credentials/InvoiceNinjaApi.credentials.ts b/packages/nodes-base/credentials/InvoiceNinjaApi.credentials.ts index 9ad1fe0a53..b85e0b6816 100644 --- a/packages/nodes-base/credentials/InvoiceNinjaApi.credentials.ts +++ b/packages/nodes-base/credentials/InvoiceNinjaApi.credentials.ts @@ -1,4 +1,10 @@ -import { ICredentialType, INodeProperties } from 'n8n-workflow'; +import { + ICredentialDataDecryptedObject, + ICredentialTestRequest, + ICredentialType, + IHttpRequestOptions, + INodeProperties, +} from 'n8n-workflow'; export class InvoiceNinjaApi implements ICredentialType { name = 'invoiceNinjaApi'; @@ -9,7 +15,8 @@ export class InvoiceNinjaApi implements ICredentialType { displayName: 'URL', name: 'url', type: 'string', - default: 'https://app.invoiceninja.com', + default: '', + hint: 'Default URL for v4 is https://app.invoiceninja.com, for v5 it is https://invoicing.co', }, { displayName: 'API Token', @@ -17,5 +24,42 @@ export class InvoiceNinjaApi implements ICredentialType { type: 'string', default: '', }, + { + displayName: 'Secret', + name: 'secret', + type: 'string', + default: '', + hint: 'This is optional, enter only if you did set a secret in your app and only if you are using v5', + }, ]; + test: ICredentialTestRequest = { + request: { + baseURL: '={{$credentials?.url}}', + url: '/api/v1/clients', + method: 'GET', + }, + }; + async authenticate( + credentials: ICredentialDataDecryptedObject, + requestOptions: IHttpRequestOptions, + ): Promise { + const VERSION_5_TOKEN_LENGTH = 64; + const { apiToken, secret } = credentials; + const tokenLength = (apiToken as string).length; + + if (tokenLength < VERSION_5_TOKEN_LENGTH) { + requestOptions.headers = { + Accept: 'application/json', + 'X-Ninja-Token': apiToken, + }; + } else { + requestOptions.headers = { + 'Content-Type': 'application/json', + 'X-API-TOKEN': apiToken, + 'X-Requested-With': 'XMLHttpRequest', + 'X-API-SECRET': secret || '', + }; + } + return requestOptions; + } } diff --git a/packages/nodes-base/nodes/InvoiceNinja/GenericFunctions.ts b/packages/nodes-base/nodes/InvoiceNinja/GenericFunctions.ts index ef2dc4042a..d697232cf7 100644 --- a/packages/nodes-base/nodes/InvoiceNinja/GenericFunctions.ts +++ b/packages/nodes-base/nodes/InvoiceNinja/GenericFunctions.ts @@ -7,51 +7,60 @@ import { ILoadOptionsFunctions, } from 'n8n-core'; -import { IDataObject, NodeApiError, NodeOperationError } from 'n8n-workflow'; +import { IDataObject, JsonObject, NodeApiError, NodeOperationError } from 'n8n-workflow'; import { get } from 'lodash'; +export const eventID: { [key: string]: string } = { + create_client: '1', + create_invoice: '2', + create_quote: '3', + create_payment: '4', + create_vendor: '5', +}; + export async function invoiceNinjaApiRequest( this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, endpoint: string, - // tslint:disable-next-line:no-any - body: any = {}, + body: IDataObject = {}, query?: IDataObject, uri?: string, - // tslint:disable-next-line:no-any -): Promise { +) { const credentials = await this.getCredentials('invoiceNinjaApi'); - const baseUrl = credentials!.url || 'https://app.invoiceninja.com'; + if (credentials === undefined) { + throw new NodeOperationError(this.getNode(), 'No credentials got returned!'); + } + + const version = this.getNodeParameter('apiVersion', 0) as string; + + const defaultUrl = version === 'v4' ? 'https://app.invoiceninja.com' : 'https://invoicing.co'; + const baseUrl = credentials!.url || defaultUrl; + const options: OptionsWithUri = { - headers: { - Accept: 'application/json', - 'X-Ninja-Token': credentials.apiToken, - }, method, qs: query, uri: uri || `${baseUrl}/api/v1${endpoint}`, body, json: true, }; + try { - return await this.helpers.request!(options); + return await this.helpers.requestWithAuthentication.call(this, 'invoiceNinjaApi', options); } catch (error) { - throw new NodeApiError(this.getNode(), error); + throw new NodeApiError(this.getNode(), error as JsonObject); } } export async function invoiceNinjaApiRequestAllItems( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions, propertyName: string, method: string, endpoint: string, - // tslint:disable-next-line:no-any - body: any = {}, + body: IDataObject = {}, query: IDataObject = {}, - // tslint:disable-next-line:no-any -): Promise { +) { const returnData: IDataObject[] = []; let responseData; diff --git a/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinja.node.ts b/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinja.node.ts index 4d0006cdc7..5f1301f3d2 100644 --- a/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinja.node.ts +++ b/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinja.node.ts @@ -43,7 +43,7 @@ export class InvoiceNinja implements INodeType { name: 'invoiceNinja', icon: 'file:invoiceNinja.svg', group: ['output'], - version: 1, + version: [1, 2], subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Consume Invoice Ninja API', defaults: { @@ -58,6 +58,50 @@ export class InvoiceNinja implements INodeType { }, ], properties: [ + { + displayName: 'API Version', + name: 'apiVersion', + type: 'options', + isNodeSetting: true, + displayOptions: { + show: { + '@version': [1], + }, + }, + options: [ + { + name: 'Version 4', + value: 'v4', + }, + { + name: 'Version 5', + value: 'v5', + }, + ], + default: 'v4', + }, + { + displayName: 'API Version', + name: 'apiVersion', + type: 'options', + isNodeSetting: true, + displayOptions: { + show: { + '@version': [2], + }, + }, + options: [ + { + name: 'Version 4', + value: 'v4', + }, + { + name: 'Version 5', + value: 'v5', + }, + ], + default: 'v5', + }, { displayName: 'Resource', name: 'resource', @@ -114,8 +158,8 @@ export class InvoiceNinja implements INodeType { const returnData: INodePropertyOptions[] = []; const clients = await invoiceNinjaApiRequestAllItems.call(this, 'data', 'GET', '/clients'); for (const client of clients) { - const clientName = client.display_name; - const clientId = client.id; + const clientName = client.display_name as string; + const clientId = client.id as string; returnData.push({ name: clientName, value: clientId, @@ -134,8 +178,8 @@ export class InvoiceNinja implements INodeType { '/projects', ); for (const project of projects) { - const projectName = project.name; - const projectId = project.id; + const projectName = project.name as string; + const projectId = project.id as string; returnData.push({ name: projectName, value: projectId, @@ -154,8 +198,8 @@ export class InvoiceNinja implements INodeType { '/invoices', ); for (const invoice of invoices) { - const invoiceName = invoice.invoice_number; - const invoiceId = invoice.id; + const invoiceName = (invoice.invoice_number || invoice.number) as string; + const invoiceId = invoice.id as string; returnData.push({ name: invoiceName, value: invoiceId, @@ -183,8 +227,8 @@ export class InvoiceNinja implements INodeType { const returnData: INodePropertyOptions[] = []; const vendors = await invoiceNinjaApiRequestAllItems.call(this, 'data', 'GET', '/vendors'); for (const vendor of vendors) { - const vendorName = vendor.name; - const vendorId = vendor.id; + const vendorName = vendor.name as string; + const vendorId = vendor.id as string; returnData.push({ name: vendorName, value: vendorId, @@ -203,8 +247,8 @@ export class InvoiceNinja implements INodeType { '/expense_categories', ); for (const category of categories) { - const categoryName = category.name; - const categoryId = category.id; + const categoryName = category.name as string; + const categoryId = category.id as string; returnData.push({ name: categoryName, value: categoryId, @@ -219,10 +263,14 @@ export class InvoiceNinja implements INodeType { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const length = items.length; - let responseData; const qs: IDataObject = {}; + + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; + const apiVersion = this.getNodeParameter('apiVersion', 0) as string; + for (let i = 0; i < length; i++) { //Routes: https://github.com/invoiceninja/invoiceninja/blob/ff455c8ed9fd0c0326956175ecd509efa8bad263/routes/api.php try { @@ -291,7 +339,12 @@ export class InvoiceNinja implements INodeType { body.postal_code = billingAddressValue.postalCode as string; body.country_id = parseInt(billingAddressValue.countryCode as string, 10); } - responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/clients', body); + responseData = await invoiceNinjaApiRequest.call( + this, + 'POST', + '/clients', + body as IDataObject, + ); responseData = responseData.data; } if (operation === 'get') { @@ -429,14 +482,28 @@ export class InvoiceNinja implements INodeType { } body.invoice_items = items; } - responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/invoices', body); + responseData = await invoiceNinjaApiRequest.call( + this, + 'POST', + '/invoices', + body as IDataObject, + ); responseData = responseData.data; } if (operation === 'email') { const invoiceId = this.getNodeParameter('invoiceId', i) as string; - responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/email_invoice', { - id: invoiceId, - }); + if (apiVersion === 'v4') { + responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/email_invoice', { + id: invoiceId, + }); + } + if (apiVersion === 'v5') { + responseData = await invoiceNinjaApiRequest.call( + this, + 'GET', + `/invoices/${invoiceId}/email`, + ); + } } if (operation === 'get') { const invoiceId = this.getNodeParameter('invoiceId', i) as string; @@ -526,7 +593,12 @@ export class InvoiceNinja implements INodeType { } body.time_log = JSON.stringify(logs); } - responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/tasks', body); + responseData = await invoiceNinjaApiRequest.call( + this, + 'POST', + '/tasks', + body as IDataObject, + ); responseData = responseData.data; } if (operation === 'get') { @@ -575,10 +647,14 @@ export class InvoiceNinja implements INodeType { if (operation === 'create') { const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; const invoice = this.getNodeParameter('invoice', i) as number; + const client = ( + await invoiceNinjaApiRequest.call(this, 'GET', `/invoices/${invoice}`, {}, qs) + ).data?.client_id as string; const amount = this.getNodeParameter('amount', i) as number; const body: IPayment = { invoice_id: invoice, amount, + client_id: client, }; if (additionalFields.paymentType) { body.payment_type_id = additionalFields.paymentType as number; @@ -589,7 +665,12 @@ export class InvoiceNinja implements INodeType { if (additionalFields.privateNotes) { body.private_notes = additionalFields.privateNotes as string; } - responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/payments', body); + responseData = await invoiceNinjaApiRequest.call( + this, + 'POST', + '/payments', + body as IDataObject, + ); responseData = responseData.data; } if (operation === 'get') { @@ -693,7 +774,12 @@ export class InvoiceNinja implements INodeType { if (additionalFields.vendor) { body.vendor_id = additionalFields.vendor as number; } - responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/expenses', body); + responseData = await invoiceNinjaApiRequest.call( + this, + 'POST', + '/expenses', + body as IDataObject, + ); responseData = responseData.data; } if (operation === 'get') { @@ -735,6 +821,7 @@ export class InvoiceNinja implements INodeType { } } if (resource === 'quote') { + const resourceEndpoint = apiVersion === 'v4' ? '/invoices' : '/quotes'; if (operation === 'create') { const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; const body: IQuote = { @@ -825,14 +912,28 @@ export class InvoiceNinja implements INodeType { } body.invoice_items = items; } - responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/invoices', body); + responseData = await invoiceNinjaApiRequest.call( + this, + 'POST', + resourceEndpoint, + body as IDataObject, + ); responseData = responseData.data; } if (operation === 'email') { const quoteId = this.getNodeParameter('quoteId', i) as string; - responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/email_invoice', { - id: quoteId, - }); + if (apiVersion === 'v4') { + responseData = await invoiceNinjaApiRequest.call(this, 'POST', '/email_invoice', { + id: quoteId, + }); + } + if (apiVersion === 'v5') { + responseData = await invoiceNinjaApiRequest.call( + this, + 'GET', + `/quotes/${quoteId}/email`, + ); + } } if (operation === 'get') { const quoteId = this.getNodeParameter('quoteId', i) as string; @@ -843,7 +944,7 @@ export class InvoiceNinja implements INodeType { responseData = await invoiceNinjaApiRequest.call( this, 'GET', - `/invoices/${quoteId}`, + `${resourceEndpoint}/${quoteId}`, {}, qs, ); @@ -878,7 +979,7 @@ export class InvoiceNinja implements INodeType { responseData = await invoiceNinjaApiRequest.call( this, 'DELETE', - `/invoices/${quoteId}`, + `${resourceEndpoint}/${quoteId}`, ); responseData = responseData.data; } diff --git a/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinjaTrigger.node.ts b/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinjaTrigger.node.ts index 556bdabe05..9eff070a9b 100644 --- a/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinjaTrigger.node.ts +++ b/packages/nodes-base/nodes/InvoiceNinja/InvoiceNinjaTrigger.node.ts @@ -1,8 +1,12 @@ import { IHookFunctions, IWebhookFunctions } from 'n8n-core'; -import { INodeType, INodeTypeDescription, IWebhookResponseData } from 'n8n-workflow'; +import { IDataObject, INodeType, INodeTypeDescription, IWebhookResponseData } from 'n8n-workflow'; -import { invoiceNinjaApiRequest } from './GenericFunctions'; +import { + eventID, + invoiceNinjaApiRequest, + invoiceNinjaApiRequestAllItems, +} from './GenericFunctions'; export class InvoiceNinjaTrigger implements INodeType { description: INodeTypeDescription = { @@ -10,7 +14,7 @@ export class InvoiceNinjaTrigger implements INodeType { name: 'invoiceNinjaTrigger', icon: 'file:invoiceNinja.svg', group: ['trigger'], - version: 1, + version: [1, 2], description: 'Starts the workflow when Invoice Ninja events occur', defaults: { name: 'Invoice Ninja Trigger', @@ -32,6 +36,50 @@ export class InvoiceNinjaTrigger implements INodeType { }, ], properties: [ + { + displayName: 'API Version', + name: 'apiVersion', + type: 'options', + isNodeSetting: true, + displayOptions: { + show: { + '@version': [1], + }, + }, + options: [ + { + name: 'Version 4', + value: 'v4', + }, + { + name: 'Version 5', + value: 'v5', + }, + ], + default: 'v4', + }, + { + displayName: 'API Version', + name: 'apiVersion', + type: 'options', + isNodeSetting: true, + displayOptions: { + show: { + '@version': [2], + }, + }, + options: [ + { + name: 'Version 4', + value: 'v4', + }, + { + name: 'Version 5', + value: 'v5', + }, + ], + default: 'v5', + }, { displayName: 'Event', name: 'event', @@ -68,35 +116,84 @@ export class InvoiceNinjaTrigger implements INodeType { webhookMethods = { default: { async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default') as string; + const event = this.getNodeParameter('event') as string; + const apiVersion = this.getNodeParameter('apiVersion', 0) as string; + + if (webhookData.webhookId === undefined) { + return false; + } + + if (apiVersion === 'v5') { + const registeredWebhooks = (await invoiceNinjaApiRequestAllItems.call( + this, + 'data', + 'GET', + '/webhooks', + )) as IDataObject[]; + + for (const webhook of registeredWebhooks) { + if ( + webhook.target_url === webhookUrl && + webhook.is_deleted === false && + webhook.event_id === eventID[event] + ) { + webhookData.webhookId = webhook.id; + return true; + } + } + } + return false; }, async create(this: IHookFunctions): Promise { const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); const event = this.getNodeParameter('event') as string; + const apiVersion = this.getNodeParameter('apiVersion', 0) as string; - const endpoint = '/hooks'; + let responseData; - const body = { - target_url: webhookUrl, - event, - }; + if (apiVersion === 'v4') { + const endpoint = '/hooks'; - const responseData = await invoiceNinjaApiRequest.call(this, 'POST', endpoint, body); + const body = { + target_url: webhookUrl, + event, + }; - if (responseData.id === undefined) { + responseData = await invoiceNinjaApiRequest.call(this, 'POST', endpoint, body); + webhookData.webhookId = responseData.id as string; + } + + if (apiVersion === 'v5') { + const endpoint = '/webhooks'; + + const body = { + target_url: webhookUrl, + event_id: eventID[event], + }; + + responseData = await invoiceNinjaApiRequest.call(this, 'POST', endpoint, body); + webhookData.webhookId = responseData.data.id as string; + } + + if (webhookData.webhookId === undefined) { // Required data is missing so was not successful return false; } - const webhookData = this.getWorkflowStaticData('node'); - webhookData.webhookId = responseData.id as string; - return true; }, async delete(this: IHookFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); + + const apiVersion = this.getNodeParameter('apiVersion', 0) as string; + const hooksEndpoint = apiVersion === 'v4' ? '/hooks' : '/webhooks'; + if (webhookData.webhookId !== undefined) { - const endpoint = `/hooks/${webhookData.webhookId}`; + const endpoint = `${hooksEndpoint}/${webhookData.webhookId}`; try { await invoiceNinjaApiRequest.call(this, 'DELETE', endpoint); diff --git a/packages/nodes-base/nodes/InvoiceNinja/PaymentInterface.ts b/packages/nodes-base/nodes/InvoiceNinja/PaymentInterface.ts index 315f3ced5c..6b0d04fad9 100644 --- a/packages/nodes-base/nodes/InvoiceNinja/PaymentInterface.ts +++ b/packages/nodes-base/nodes/InvoiceNinja/PaymentInterface.ts @@ -4,4 +4,5 @@ export interface IPayment { payment_type_id?: number; transaction_reference?: string; private_notes?: string; + client_id?: string; }