From b46161aee761c133ab35bfa8e42b7730fd9ab1ae Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sun, 12 Jul 2020 12:12:32 -0400 Subject: [PATCH] :sparkles: Xero Integration (#639) * :sparkles: Xero Integration * :zap: Add contact resource * :bug: Small fix --- .../credentials/XeroOAuth2Api.credentials.ts | 51 + .../nodes/Xero/ContactDescription.ts | 838 +++++++++++++++ .../nodes-base/nodes/Xero/GenericFunctions.ts | 76 ++ .../nodes/Xero/IContactInterface.ts | 44 + .../nodes/Xero/InvoiceDescription.ts | 983 ++++++++++++++++++ .../nodes-base/nodes/Xero/InvoiceInterface.ts | 40 + packages/nodes-base/nodes/Xero/Xero.node.ts | 681 ++++++++++++ packages/nodes-base/nodes/Xero/xero.png | Bin 0 -> 9587 bytes packages/nodes-base/package.json | 2 + 9 files changed, 2715 insertions(+) create mode 100644 packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/Xero/ContactDescription.ts create mode 100644 packages/nodes-base/nodes/Xero/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Xero/IContactInterface.ts create mode 100644 packages/nodes-base/nodes/Xero/InvoiceDescription.ts create mode 100644 packages/nodes-base/nodes/Xero/InvoiceInterface.ts create mode 100644 packages/nodes-base/nodes/Xero/Xero.node.ts create mode 100644 packages/nodes-base/nodes/Xero/xero.png diff --git a/packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts b/packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts new file mode 100644 index 0000000000..2db47c13de --- /dev/null +++ b/packages/nodes-base/credentials/XeroOAuth2Api.credentials.ts @@ -0,0 +1,51 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'offline_access', + 'accounting.transactions', + 'accounting.settings', + 'accounting.contacts', +]; + +export class XeroOAuth2Api implements ICredentialType { + name = 'xeroOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Xero OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://login.xero.com/identity/connect/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://identity.xero.com/connect/token', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'header', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Xero/ContactDescription.ts b/packages/nodes-base/nodes/Xero/ContactDescription.ts new file mode 100644 index 0000000000..418aef44ac --- /dev/null +++ b/packages/nodes-base/nodes/Xero/ContactDescription.ts @@ -0,0 +1,838 @@ +import { + INodeProperties, + } from 'n8n-workflow'; + +export const contactOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'contact', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'create a contact', + }, + { + name: 'Get', + value: 'get', + description: 'Get a contact', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all contacts', + }, + { + name: 'Update', + value: 'update', + description: 'Update a contact', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const contactFields = [ + +/* -------------------------------------------------------------------------- */ +/* contact:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + description: 'Full name of contact/organisation', + required: true, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Account Number', + name: 'accountNumber', + type: 'string', + default: '', + description: 'A user defined account number', + }, + // { + // displayName: 'Addresses', + // name: 'addressesUi', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: '', + // placeholder: 'Add Address', + // options: [ + // { + // name: 'addressesValues', + // displayName: 'Address', + // values: [ + // { + // displayName: 'Type', + // name: 'type', + // type: 'options', + // options: [ + // { + // name: 'PO Box', + // value: 'POBOX', + // }, + // { + // name: 'Street', + // value: 'STREET', + // }, + // ], + // default: '', + // }, + // { + // displayName: 'Line 1', + // name: 'line1', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Line 2', + // name: 'line2', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'City', + // name: 'city', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Region', + // name: 'region', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Postal Code', + // name: 'postalCode', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Country', + // name: 'country', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Attention To', + // name: 'attentionTo', + // type: 'string', + // default: '', + // }, + // ], + // }, + // ], + // }, + { + displayName: 'Bank Account Details', + name: 'bankAccountDetails', + type: 'string', + default: '', + description: 'Bank account number of contact', + }, + { + displayName: 'Contact Number', + name: 'contactNumber', + type: 'string', + default: '', + description: 'This field is read only on the Xero contact screen, used to identify contacts in external systems', + }, + { + displayName: 'Contact Status', + name: 'contactStatus', + type: 'options', + options: [ + { + name: 'Active', + value: 'ACTIVE', + description: 'The Contact is active and can be used in transactions', + }, + { + name: 'Archived', + value: 'ARCHIVED', + description: 'The Contact is archived and can no longer be used in transactions', + }, + { + name: 'GDPR Request', + value: 'GDPRREQUEST', + description: 'The Contact is the subject of a GDPR erasure request', + }, + ], + default: '', + description: 'Current status of a contact - see contact status types', + }, + { + displayName: 'Default Currency', + name: 'defaultCurrency', + type: 'string', + default: '', + description: 'Default currency for raising invoices against contact', + }, + { + displayName: 'Email', + name: 'emailAddress', + type: 'string', + default: '', + description: 'Email address of contact person (umlauts not supported) (max length = 255)', + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: 'First name of contact person (max length = 255)', + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + description: 'Last name of contact person (max length = 255)', + }, + // { + // displayName: 'Phones', + // name: 'phonesUi', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: '', + // placeholder: 'Add Phone', + // options: [ + // { + // name: 'phonesValues', + // displayName: 'Phones', + // values: [ + // { + // displayName: 'Type', + // name: 'type', + // type: 'options', + // options: [ + // { + // name: 'Default', + // value: 'DEFAULT', + // }, + // { + // name: 'DDI', + // value: 'DDI', + // }, + // { + // name: 'Mobile', + // value: 'MOBILE', + // }, + // { + // name: 'Fax', + // value: 'FAX', + // }, + // ], + // default: '', + // }, + // { + // displayName: 'Number', + // name: 'phoneNumber', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Area Code', + // name: 'phoneAreaCode', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Country Code', + // name: 'phoneCountryCode', + // type: 'string', + // default: '', + // }, + // ], + // }, + // ], + // }, + { + displayName: 'Purchase Default Account Code', + name: 'purchasesDefaultAccountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + }, + default: '', + description: 'The default purchases account code for contacts', + }, + { + displayName: 'Sales Default Account Code', + name: 'salesDefaultAccountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + }, + default: '', + description: 'The default sales account code for contacts', + }, + { + displayName: 'Skype', + name: 'skypeUserName', + type: 'string', + default: '', + description: 'Skype user name of contact', + }, + { + displayName: 'Tax Number', + name: 'taxNumber', + type: 'string', + default: '', + description: 'Tax number of contact', + }, + { + displayName: 'Xero Network Key', + name: 'xeroNetworkKey', + type: 'string', + default: '', + description: 'Store XeroNetworkKey for contacts', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* contact:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + }, + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + }, +/* -------------------------------------------------------------------------- */ +/* contact:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Include Archived', + name: 'includeArchived', + type: 'boolean', + default: false, + description: `Contacts with a status of ARCHIVED will be included in the response`, + }, + { + displayName: 'Order By', + name: 'orderBy', + type: 'string', + placeholder: 'contactID', + default: '', + description: 'Order by any element returned', + }, + { + displayName: 'Sort Order', + name: 'sortOrder', + type: 'options', + options: [ + { + name: 'Asc', + value: 'ASC', + }, + { + name: 'Desc', + value: 'DESC', + }, + ], + default: '', + description: 'Sort order', + }, + { + displayName: 'Where', + name: 'where', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + placeholder: 'EmailAddress!=null&&EmailAddress.StartsWith("boom")', + default: '', + description: `The where parameter allows you to filter on endpoints and elements that don't have explicit parameters. Examples Here`, + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* contact:update */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'update', + ], + }, + }, + required: true, + }, + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'update', + ], + }, + }, + required: true, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Account Number', + name: 'accountNumber', + type: 'string', + default: '', + description: 'A user defined account number', + }, + // { + // displayName: 'Addresses', + // name: 'addressesUi', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: '', + // placeholder: 'Add Address', + // options: [ + // { + // name: 'addressesValues', + // displayName: 'Address', + // values: [ + // { + // displayName: 'Type', + // name: 'type', + // type: 'options', + // options: [ + // { + // name: 'PO Box', + // value: 'POBOX', + // }, + // { + // name: 'Street', + // value: 'STREET', + // }, + // ], + // default: '', + // }, + // { + // displayName: 'Line 1', + // name: 'line1', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Line 2', + // name: 'line2', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'City', + // name: 'city', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Region', + // name: 'region', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Postal Code', + // name: 'postalCode', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Country', + // name: 'country', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Attention To', + // name: 'attentionTo', + // type: 'string', + // default: '', + // }, + // ], + // }, + // ], + // }, + { + displayName: 'Bank Account Details', + name: 'bankAccountDetails', + type: 'string', + default: '', + description: 'Bank account number of contact', + }, + { + displayName: 'Contact Number', + name: 'contactNumber', + type: 'string', + default: '', + description: 'This field is read only on the Xero contact screen, used to identify contacts in external systems', + }, + { + displayName: 'Contact Status', + name: 'contactStatus', + type: 'options', + options: [ + { + name: 'Active', + value: 'ACTIVE', + description: 'The Contact is active and can be used in transactions', + }, + { + name: 'Archived', + value: 'ARCHIVED', + description: 'The Contact is archived and can no longer be used in transactions', + }, + { + name: 'GDPR Request', + value: 'GDPRREQUEST', + description: 'The Contact is the subject of a GDPR erasure request', + }, + ], + default: '', + description: 'Current status of a contact - see contact status types', + }, + { + displayName: 'Default Currency', + name: 'defaultCurrency', + type: 'string', + default: '', + description: 'Default currency for raising invoices against contact', + }, + { + displayName: 'Email', + name: 'emailAddress', + type: 'string', + default: '', + description: 'Email address of contact person (umlauts not supported) (max length = 255)', + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: 'First name of contact person (max length = 255)', + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + default: '', + description: 'Last name of contact person (max length = 255)', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Full name of contact/organisation', + }, + // { + // displayName: 'Phones', + // name: 'phonesUi', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: '', + // placeholder: 'Add Phone', + // options: [ + // { + // name: 'phonesValues', + // displayName: 'Phones', + // values: [ + // { + // displayName: 'Type', + // name: 'type', + // type: 'options', + // options: [ + // { + // name: 'Default', + // value: 'DEFAULT', + // }, + // { + // name: 'DDI', + // value: 'DDI', + // }, + // { + // name: 'Mobile', + // value: 'MOBILE', + // }, + // { + // name: 'Fax', + // value: 'FAX', + // }, + // ], + // default: '', + // }, + // { + // displayName: 'Number', + // name: 'phoneNumber', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Area Code', + // name: 'phoneAreaCode', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Country Code', + // name: 'phoneCountryCode', + // type: 'string', + // default: '', + // }, + // ], + // }, + // ], + // }, + { + displayName: 'Purchase Default Account Code', + name: 'purchasesDefaultAccountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + }, + default: '', + description: 'The default purchases account code for contacts', + }, + { + displayName: 'Sales Default Account Code', + name: 'salesDefaultAccountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + }, + default: '', + description: 'The default sales account code for contacts', + }, + { + displayName: 'Skype', + name: 'skypeUserName', + type: 'string', + default: '', + description: 'Skype user name of contact', + }, + { + displayName: 'Tax Number', + name: 'taxNumber', + type: 'string', + default: '', + description: 'Tax number of contact', + }, + { + displayName: 'Xero Network Key', + name: 'xeroNetworkKey', + type: 'string', + default: '', + description: 'Store XeroNetworkKey for contacts', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Xero/GenericFunctions.ts b/packages/nodes-base/nodes/Xero/GenericFunctions.ts new file mode 100644 index 0000000000..840579bf2f --- /dev/null +++ b/packages/nodes-base/nodes/Xero/GenericFunctions.ts @@ -0,0 +1,76 @@ +import { + OptionsWithUri, + } from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function xeroApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, headers: IDataObject = {}): Promise { // tslint:disable-line:no-any + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body, + qs, + uri: uri || `https://api.xero.com/api.xro/2.0${resource}`, + json: true + }; + try { + if (body.organizationId) { + options.headers = { ...options.headers, 'Xero-tenant-id': body.organizationId }; + delete body.organizationId; + } + if (Object.keys(headers).length !== 0) { + options.headers = Object.assign({}, options.headers, headers); + } + if (Object.keys(body).length === 0) { + delete options.body; + } + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'xeroOAuth2Api', options); + } catch (error) { + let errorMessage; + + if (error.response && error.response.body && error.response.body.Message) { + + errorMessage = error.response.body.Message; + + if (error.response.body.Elements) { + const elementErrors = []; + for (const element of error.response.body.Elements) { + elementErrors.push(element.ValidationErrors.map((error: IDataObject) => error.Message).join('|')); + } + errorMessage = elementErrors.join('-'); + } + // Try to return the error prettier + throw new Error(`Xero error response [${error.statusCode}]: ${errorMessage}`); + } + throw error; + } +} + +export async function xeroApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string ,method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + query.page = 1; + + do { + responseData = await xeroApiRequest.call(this, method, endpoint, body, query); + query.page++; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData[propertyName].length !== 0 + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Xero/IContactInterface.ts b/packages/nodes-base/nodes/Xero/IContactInterface.ts new file mode 100644 index 0000000000..1fc5eebe6a --- /dev/null +++ b/packages/nodes-base/nodes/Xero/IContactInterface.ts @@ -0,0 +1,44 @@ + +export interface IAddress { + Type?: string; + AddressLine1?: string; + AddressLine2?: string; + City?: string; + Region?: string; + PostalCode?: string; + Country?: string; + AttentionTo?: string; +} + +export interface IPhone { + Type?: string; + PhoneNumber?: string; + PhoneAreaCode?: string; + PhoneCountryCode?: string; +} + +export interface IContact extends ITenantId { + AccountNumber?: string; + Addresses?: IAddress[]; + BankAccountDetails?: string; + ContactId?: string; + ContactNumber?: string; + ContactStatus?: string; + DefaultCurrency?: string; + EmailAddress?: string; + FirstName?: string; + LastName?: string; + Name?: string; + Phones?: IPhone[]; + PurchaseTrackingCategory?: string; + PurchasesDefaultAccountCode?: string; + SalesDefaultAccountCode?: string; + SalesTrackingCategory?: string; + SkypeUserName?: string; + taxNumber?: string; + xeroNetworkKey?: string; +} + +export interface ITenantId { + organizationId?: string; +} diff --git a/packages/nodes-base/nodes/Xero/InvoiceDescription.ts b/packages/nodes-base/nodes/Xero/InvoiceDescription.ts new file mode 100644 index 0000000000..6591adc24c --- /dev/null +++ b/packages/nodes-base/nodes/Xero/InvoiceDescription.ts @@ -0,0 +1,983 @@ +import { + INodeProperties, + } from 'n8n-workflow'; + +export const invoiceOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a invoice', + }, + { + name: 'Get', + value: 'get', + description: 'Get a invoice', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all invoices', + }, + { + name: 'Update', + value: 'update', + description: 'Update a invoice', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const invoiceFields = [ + +/* -------------------------------------------------------------------------- */ +/* invoice:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Bill', + value: 'ACCPAY', + description: 'Accounts Payable or supplier invoice' + }, + { + name: 'Sales Invoice', + value: 'ACCREC', + description: ' Accounts Receivable or customer invoice' + }, + ], + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'Invoice Type', + }, + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'Contact ID', + }, + { + displayName: 'Line Items', + name: 'lineItemsUi', + placeholder: 'Add Line Item', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ] + }, + }, + description: 'Line item data', + options: [ + { + name: 'lineItemsValues', + displayName: 'Line Item', + values: [ + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'A line item with just a description', + }, + { + displayName: 'Quantity', + name: 'quantity', + type: 'number', + default: 1, + typeOptions: { + minValue: 1, + }, + description: 'LineItem Quantity', + }, + { + displayName: 'Unit Amount', + name: 'unitAmount', + type: 'string', + default: '', + description: 'Lineitem unit amount. By default, unit amount will be rounded to two decimal places.', + }, + { + displayName: 'Item Code', + name: 'itemCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getItemCodes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Account Code', + name: 'accountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Tax Type', + name: 'taxType', + type: 'options', + options: [ + { + name: 'Tax on Purchases', + value: 'INPUT', + }, + { + name: 'Tax Exempt', + value: 'NONE', + }, + { + name: 'Tax on Sales', + value: 'OUTPUT', + }, + { + name: 'Sales Tax on Imports ', + value: 'GSTONIMPORTS', + }, + ], + default: '', + required: true, + description: 'Tax Type', + }, + { + displayName: 'Tax Amount', + name: 'taxAmount', + type: 'string', + default: '', + description: 'The tax amount is auto calculated as a percentage of the line amount based on the tax rate.', + }, + { + displayName: 'Line Amount', + name: 'lineAmount', + type: 'string', + default: '', + description: 'The line amount reflects the discounted price if a DiscountRate has been used', + }, + { + displayName: 'Discount Rate', + name: 'discountRate', + type: 'string', + default: '', + description: 'Percentage discount or discount amount being applied to a line item. Only supported on ACCREC invoices - ACCPAY invoices and credit notes in Xero do not support discounts', + }, + // { + // displayName: 'Tracking', + // name: 'trackingUi', + // placeholder: 'Add Tracking', + // description: 'Any LineItem can have a maximum of 2 TrackingCategory elements.', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: {}, + // options: [ + // { + // name: 'trackingValues', + // displayName: 'Tracking', + // values: [ + // { + // displayName: 'Name', + // name: 'name', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getTrakingCategories', + // loadOptionsDependsOn: [ + // 'organizationId', + // ], + // }, + // default: '', + // description: 'Name of the tracking category', + // }, + // { + // displayName: 'Option', + // name: 'option', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getTrakingOptions', + // loadOptionsDependsOn: [ + // '/name', + // ], + // }, + // default: '', + // description: 'Name of the option', + // }, + // ], + // }, + // ], + // }, + ], + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Branding Theme ID', + name: 'brandingThemeId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getBrandingThemes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Currency', + name: 'currency', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getCurrencies', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Currency Rate', + name: 'currencyRate', + type: 'string', + default: '', + description: 'The currency rate for a multicurrency invoice. If no rate is specified, the XE.com day rate is used.', + }, + { + displayName: 'Date', + name: 'date', + type: 'dateTime', + default: '', + description: 'Date invoice was issued - YYYY-MM-DD. If the Date element is not specified it will default to the current date based on the timezone setting of the organisation', + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'dateTime', + default: '', + description: 'Date invoice is due - YYYY-MM-DD', + }, + { + displayName: 'Expected Payment Date', + name: 'expectedPaymentDate', + type: 'dateTime', + default: '', + description: 'Shown on sales invoices (Accounts Receivable) when this has been set', + }, + { + displayName: 'Invoice Number', + name: 'invoiceNumber', + type: 'string', + default: '', + }, + { + displayName: 'Line Amount Type', + name: 'lineAmountType', + type: 'options', + options: [ + { + name: 'Exclusive', + value: 'Exclusive', + description: 'Line items are exclusive of tax', + }, + { + name: 'Inclusive', + value: 'Inclusive', + description: 'Line items are inclusive tax', + }, + { + name: 'NoTax', + value: 'NoTax', + description: 'Line have no tax', + }, + ], + default: 'Exclusive', + }, + { + displayName: 'Planned Payment Date ', + name: 'plannedPaymentDate', + type: 'dateTime', + default: '', + description: 'Shown on bills (Accounts Payable) when this has been set', + }, + { + displayName: 'Reference', + name: 'reference', + type: 'string', + default: '', + description: 'ACCREC only - additional reference number (max length = 255)', + }, + { + displayName: 'Send To Contact', + name: 'sendToContact', + type: 'boolean', + default: false, + description: 'Whether the invoice in the Xero app should be marked as "sent". This can be set only on invoices that have been approved', + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Draft', + value: 'DRAFT', + }, + { + name: 'Submitted', + value: 'SUBMITTED', + }, + { + name: 'Authorised', + value: 'AUTHORISED', + }, + ], + default: 'DRAFT', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + description: 'URL link to a source document - shown as "Go to [appName]" in the Xero app', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* invoice:update */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'update', + ], + }, + }, + required: true, + }, + { + displayName: 'Invoice ID', + name: 'invoiceId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'update', + ], + }, + }, + description: 'Invoice ID', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Branding Theme ID', + name: 'brandingThemeId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getBrandingThemes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + default: '', + description: 'Contact ID', + }, + { + displayName: 'Currency', + name: 'currency', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getCurrencies', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Currency Rate', + name: 'currencyRate', + type: 'string', + default: '', + description: 'The currency rate for a multicurrency invoice. If no rate is specified, the XE.com day rate is used.', + }, + { + displayName: 'Date', + name: 'date', + type: 'dateTime', + default: '', + description: 'Date invoice was issued - YYYY-MM-DD. If the Date element is not specified it will default to the current date based on the timezone setting of the organisation', + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'dateTime', + default: '', + description: 'Date invoice is due - YYYY-MM-DD', + }, + { + displayName: 'Expected Payment Date', + name: 'expectedPaymentDate', + type: 'dateTime', + default: '', + description: 'Shown on sales invoices (Accounts Receivable) when this has been set', + }, + { + displayName: 'Invoice Number', + name: 'invoiceNumber', + type: 'string', + default: '', + }, + { + displayName: 'Line Amount Type', + name: 'lineAmountType', + type: 'options', + options: [ + { + name: 'Exclusive', + value: 'Exclusive', + description: 'Line items are exclusive of tax', + }, + { + name: 'Inclusive', + value: 'Inclusive', + description: 'Line items are inclusive tax', + }, + { + name: 'NoTax', + value: 'NoTax', + description: 'Line have no tax', + }, + ], + default: 'Exclusive', + }, + { + displayName: 'Line Items', + name: 'lineItemsUi', + placeholder: 'Add Line Item', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + description: 'Line item data', + options: [ + { + name: 'lineItemsValues', + displayName: 'Line Item', + values: [ + { + displayName: 'Line Item ID', + name: 'lineItemId', + type: 'string', + default: '', + description: 'The Xero generated identifier for a LineItem', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'A line item with just a description', + }, + { + displayName: 'Quantity', + name: 'quantity', + type: 'number', + default: 1, + typeOptions: { + minValue: 1, + }, + description: 'LineItem Quantity', + }, + { + displayName: 'Unit Amount', + name: 'unitAmount', + type: 'string', + default: '', + description: 'Lineitem unit amount. By default, unit amount will be rounded to two decimal places.', + }, + { + displayName: 'Item Code', + name: 'itemCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getItemCodes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Account Code', + name: 'accountCode', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAccountCodes', + loadOptionsDependsOn: [ + 'organizationId', + ], + }, + default: '', + }, + { + displayName: 'Tax Type', + name: 'taxType', + type: 'options', + options: [ + { + name: 'Tax on Purchases', + value: 'INPUT', + }, + { + name: 'Tax Exempt', + value: 'NONE', + }, + { + name: 'Tax on Sales', + value: 'OUTPUT', + }, + { + name: 'Sales Tax on Imports ', + value: 'GSTONIMPORTS', + }, + ], + default: '', + required: true, + description: 'Tax Type', + }, + { + displayName: 'Tax Amount', + name: 'taxAmount', + type: 'string', + default: '', + description: 'The tax amount is auto calculated as a percentage of the line amount based on the tax rate.', + }, + { + displayName: 'Line Amount', + name: 'lineAmount', + type: 'string', + default: '', + description: 'The line amount reflects the discounted price if a DiscountRate has been used', + }, + { + displayName: 'Discount Rate', + name: 'discountRate', + type: 'string', + default: '', + description: 'Percentage discount or discount amount being applied to a line item. Only supported on ACCREC invoices - ACCPAY invoices and credit notes in Xero do not support discounts', + }, + // { + // displayName: 'Tracking', + // name: 'trackingUi', + // placeholder: 'Add Tracking', + // description: 'Any LineItem can have a maximum of 2 TrackingCategory elements.', + // type: 'fixedCollection', + // typeOptions: { + // multipleValues: true, + // }, + // default: {}, + // options: [ + // { + // name: 'trackingValues', + // displayName: 'Tracking', + // values: [ + // { + // displayName: 'Name', + // name: 'name', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getTrakingCategories', + // loadOptionsDependsOn: [ + // 'organizationId', + // ], + // }, + // default: '', + // description: 'Name of the tracking category', + // }, + // { + // displayName: 'Option', + // name: 'option', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getTrakingOptions', + // loadOptionsDependsOn: [ + // '/name', + // ], + // }, + // default: '', + // description: 'Name of the option', + // }, + // ], + // }, + // ], + // }, + ], + }, + ], + }, + { + displayName: 'Planned Payment Date ', + name: 'plannedPaymentDate', + type: 'dateTime', + default: '', + description: 'Shown on bills (Accounts Payable) when this has been set', + }, + { + displayName: 'Reference', + name: 'reference', + type: 'string', + default: '', + description: 'ACCREC only - additional reference number (max length = 255)', + }, + { + displayName: 'Send To Contact', + name: 'sendToContact', + type: 'boolean', + default: false, + description: 'Whether the invoice in the Xero app should be marked as "sent". This can be set only on invoices that have been approved', + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Draft', + value: 'DRAFT', + }, + { + name: 'Submitted', + value: 'SUBMITTED', + }, + { + name: 'Authorised', + value: 'AUTHORISED', + }, + ], + default: 'DRAFT', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + description: 'URL link to a source document - shown as "Go to [appName]" in the Xero app', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* invoice:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + }, + { + displayName: 'Invoice ID', + name: 'invoiceId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'get', + ], + }, + }, + description: 'Invoice ID', + }, +/* -------------------------------------------------------------------------- */ +/* invoice:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTenants', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'invoice', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Created By My App', + name: 'createdByMyApp', + type: 'boolean', + default: false, + description: `When set to true you'll only retrieve Invoices created by your app`, + }, + { + displayName: 'Order By', + name: 'orderBy', + type: 'string', + placeholder: 'InvoiceID', + default: '', + description: 'Order by any element returned', + }, + { + displayName: 'Sort Order', + name: 'sortOrder', + type: 'options', + options: [ + { + name: 'Asc', + value: 'ASC', + }, + { + name: 'Desc', + value: 'DESC', + }, + ], + default: '', + description: 'Sort order', + }, + { + displayName: 'Statuses', + name: 'statuses', + type: 'multiOptions', + options: [ + { + name: 'Draft', + value: 'DRAFT', + }, + { + name: 'Submitted', + value: 'SUBMITTED', + }, + { + name: 'Authorised', + value: 'AUTHORISED', + }, + ], + default: [], + }, + { + displayName: 'Where', + name: 'where', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + placeholder: 'EmailAddress!=null&&EmailAddress.StartsWith("boom")', + default: '', + description: `The where parameter allows you to filter on endpoints and elements that don't have explicit parameters. Examples Here`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Xero/InvoiceInterface.ts b/packages/nodes-base/nodes/Xero/InvoiceInterface.ts new file mode 100644 index 0000000000..6d6da63fb9 --- /dev/null +++ b/packages/nodes-base/nodes/Xero/InvoiceInterface.ts @@ -0,0 +1,40 @@ +import { + IDataObject, + } from 'n8n-workflow'; + + export interface ILineItem { + Description?: string; + Quantity?: string; + UnitAmount?: string; + ItemCode?: string; + AccountCode?: string; + LineItemID?: string; + TaxType?: string; + TaxAmount?: string; + LineAmount?: string; + DiscountRate?: string; + Tracking?: IDataObject[]; +} + +export interface IInvoice extends ITenantId { + Type?: string; + LineItems?: ILineItem[]; + Contact?: IDataObject; + Date?: string; + DueDate?: string; + LineAmountType?: string; + InvoiceNumber?: string; + Reference?: string; + BrandingThemeID?: string; + Url?: string; + CurrencyCode?: string; + CurrencyRate?: string; + Status?: string; + SentToContact?: boolean; + ExpectedPaymentDate?: string; + PlannedPaymentDate?: string; +} + +export interface ITenantId { + organizationId?: string; +} diff --git a/packages/nodes-base/nodes/Xero/Xero.node.ts b/packages/nodes-base/nodes/Xero/Xero.node.ts new file mode 100644 index 0000000000..7703bf0c88 --- /dev/null +++ b/packages/nodes-base/nodes/Xero/Xero.node.ts @@ -0,0 +1,681 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; + +import { + xeroApiRequest, + xeroApiRequestAllItems, +} from './GenericFunctions'; + +import { + invoiceFields, + invoiceOperations +} from './InvoiceDescription'; + +import { + contactFields, + contactOperations, +} from './ContactDescription'; + +import { + IInvoice, + ILineItem, +} from './InvoiceInterface'; + +import { + IContact, + IPhone, + IAddress, +} from './IContactInterface'; + +export class Xero implements INodeType { + description: INodeTypeDescription = { + displayName: 'Xero', + name: 'xero', + icon: 'file:xero.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Xero API', + defaults: { + name: 'Xero', + color: '#13b5ea', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'xeroOAuth2Api', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Contact', + value: 'contact', + }, + { + name: 'Invoice', + value: 'invoice', + }, + ], + default: 'invoice', + description: 'Resource to consume.', + }, + // CONTACT + ...contactOperations, + ...contactFields, + // INVOICE + ...invoiceOperations, + ...invoiceFields, + ], + }; + + methods = { + loadOptions: { + // Get all the item codes to display them to user so that he can + // select them easily + async getItemCodes(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { Items: items } = await xeroApiRequest.call(this, 'GET', '/items', { organizationId }); + for (const item of items) { + const itemName = item.Description; + const itemId = item.Code; + returnData.push({ + name: itemName, + value: itemId, + }); + } + return returnData; + }, + // Get all the account codes to display them to user so that he can + // select them easily + async getAccountCodes(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { Accounts: accounts } = await xeroApiRequest.call(this, 'GET', '/Accounts', { organizationId }); + for (const account of accounts) { + const accountName = account.Name; + const accountId = account.Code; + returnData.push({ + name: accountName, + value: accountId, + }); + } + return returnData; + }, + // Get all the tenants to display them to user so that he can + // select them easily + async getTenants(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const tenants = await xeroApiRequest.call(this, 'GET', '', {}, {}, 'https://api.xero.com/connections'); + for (const tenant of tenants) { + const tenantName = tenant.tenantName; + const tenantId = tenant.tenantId; + returnData.push({ + name: tenantName, + value: tenantId, + }); + } + return returnData; + }, + // Get all the brading themes to display them to user so that he can + // select them easily + async getBrandingThemes(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { BrandingThemes: themes } = await xeroApiRequest.call(this, 'GET', '/BrandingThemes', { organizationId }); + for (const theme of themes) { + const themeName = theme.Name; + const themeId = theme.BrandingThemeID; + returnData.push({ + name: themeName, + value: themeId, + }); + } + return returnData; + }, + // Get all the brading themes to display them to user so that he can + // select them easily + async getCurrencies(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { Currencies: currencies } = await xeroApiRequest.call(this, 'GET', '/Currencies', { organizationId }); + for (const currency of currencies) { + const currencyName = currency.Code; + const currencyId = currency.Description; + returnData.push({ + name: currencyName, + value: currencyId, + }); + } + return returnData; + }, + // Get all the tracking categories to display them to user so that he can + // select them easily + async getTrakingCategories(this: ILoadOptionsFunctions): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId'); + const returnData: INodePropertyOptions[] = []; + const { TrackingCategories: categories } = await xeroApiRequest.call(this, 'GET', '/TrackingCategories', { organizationId }); + for (const category of categories) { + const categoryName = category.Name; + const categoryId = category.TrackingCategoryID; + returnData.push({ + name: categoryName, + value: categoryId, + }); + } + return returnData; + }, + // // Get all the tracking categories to display them to user so that he can + // // select them easily + // async getTrakingOptions(this: ILoadOptionsFunctions): Promise { + // const organizationId = this.getCurrentNodeParameter('organizationId'); + // const name = this.getCurrentNodeParameter('name'); + // const returnData: INodePropertyOptions[] = []; + // const { TrackingCategories: categories } = await xeroApiRequest.call(this, 'GET', '/TrackingCategories', { organizationId }); + // const { Options: options } = categories.filter((category: IDataObject) => category.Name === name)[0]; + // for (const option of options) { + // const optionName = option.Name; + // const optionId = option.TrackingOptionID; + // returnData.push({ + // name: optionName, + // value: optionId, + // }); + // } + // return returnData; + // }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length as unknown as number; + const qs: IDataObject = {}; + let responseData; + for (let i = 0; i < length; i++) { + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + //https://developer.xero.com/documentation/api/invoices + if (resource === 'invoice') { + if (operation === 'create') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const type = this.getNodeParameter('type', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const contactId = this.getNodeParameter('contactId', i) as string; + const lineItemsValues = ((this.getNodeParameter('lineItemsUi', i) as IDataObject).lineItemsValues as IDataObject[]); + + const body: IInvoice = { + organizationId, + Type: type, + Contact: { ContactID: contactId }, + }; + + if (lineItemsValues) { + const lineItems: ILineItem[] = []; + for (const lineItemValue of lineItemsValues) { + const lineItem: ILineItem = { + Tracking: [], + }; + lineItem.AccountCode = lineItemValue.accountCode as string; + lineItem.Description = lineItemValue.description as string; + lineItem.DiscountRate = lineItemValue.discountRate as string; + lineItem.ItemCode = lineItemValue.itemCode as string; + lineItem.LineAmount = lineItemValue.lineAmount as string; + lineItem.Quantity = (lineItemValue.quantity as number).toString(); + lineItem.TaxAmount = lineItemValue.taxAmount as string; + lineItem.TaxType = lineItemValue.taxType as string; + lineItem.UnitAmount = lineItemValue.unitAmount as string; + // if (lineItemValue.trackingUi) { + // //@ts-ignore + // const { trackingValues } = lineItemValue.trackingUi as IDataObject[]; + // if (trackingValues) { + // for (const trackingValue of trackingValues) { + // const tracking: IDataObject = {}; + // tracking.Name = trackingValue.name as string; + // tracking.Option = trackingValue.option as string; + // lineItem.Tracking!.push(tracking); + // } + // } + // } + lineItems.push(lineItem); + } + body.LineItems = lineItems; + } + + if (additionalFields.brandingThemeId) { + body.BrandingThemeID = additionalFields.brandingThemeId as string; + } + if (additionalFields.currency) { + body.CurrencyCode = additionalFields.currency as string; + } + if (additionalFields.currencyRate) { + body.CurrencyRate = additionalFields.currencyRate as string; + } + if (additionalFields.date) { + body.Date = additionalFields.date as string; + } + if (additionalFields.dueDate) { + body.DueDate = additionalFields.dueDate as string; + } + if (additionalFields.dueDate) { + body.DueDate = additionalFields.dueDate as string; + } + if (additionalFields.expectedPaymentDate) { + body.ExpectedPaymentDate = additionalFields.expectedPaymentDate as string; + } + if (additionalFields.invoiceNumber) { + body.InvoiceNumber = additionalFields.invoiceNumber as string; + } + if (additionalFields.lineAmountType) { + body.LineAmountType = additionalFields.lineAmountType as string; + } + if (additionalFields.plannedPaymentDate) { + body.PlannedPaymentDate = additionalFields.plannedPaymentDate as string; + } + if (additionalFields.reference) { + body.Reference = additionalFields.reference as string; + } + if (additionalFields.sendToContact) { + body.SentToContact = additionalFields.sendToContact as boolean; + } + if (additionalFields.status) { + body.Status = additionalFields.status as string; + } + if (additionalFields.url) { + body.Url = additionalFields.url as string; + } + + responseData = await xeroApiRequest.call(this, 'POST', '/Invoices', body); + responseData = responseData.Invoices; + } + if (operation === 'update') { + const invoiceId = this.getNodeParameter('invoiceId', i) as string; + const organizationId = this.getNodeParameter('organizationId', i) as string; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + const body: IInvoice = { + organizationId, + }; + + if (updateFields.lineItemsUi) { + const lineItemsValues = (updateFields.lineItemsUi as IDataObject).lineItemsValues as IDataObject[]; + if (lineItemsValues) { + const lineItems: ILineItem[] = []; + for (const lineItemValue of lineItemsValues) { + const lineItem: ILineItem = { + Tracking: [], + }; + lineItem.AccountCode = lineItemValue.accountCode as string; + lineItem.Description = lineItemValue.description as string; + lineItem.DiscountRate = lineItemValue.discountRate as string; + lineItem.ItemCode = lineItemValue.itemCode as string; + lineItem.LineAmount = lineItemValue.lineAmount as string; + lineItem.Quantity = (lineItemValue.quantity as number).toString(); + lineItem.TaxAmount = lineItemValue.taxAmount as string; + lineItem.TaxType = lineItemValue.taxType as string; + lineItem.UnitAmount = lineItemValue.unitAmount as string; + // if (lineItemValue.trackingUi) { + // //@ts-ignore + // const { trackingValues } = lineItemValue.trackingUi as IDataObject[]; + // if (trackingValues) { + // for (const trackingValue of trackingValues) { + // const tracking: IDataObject = {}; + // tracking.Name = trackingValue.name as string; + // tracking.Option = trackingValue.option as string; + // lineItem.Tracking!.push(tracking); + // } + // } + // } + lineItems.push(lineItem); + } + body.LineItems = lineItems; + } + } + + if (updateFields.type) { + body.Type = updateFields.type as string; + } + if (updateFields.Contact) { + body.Contact = { ContactID: updateFields.contactId as string }; + } + if (updateFields.brandingThemeId) { + body.BrandingThemeID = updateFields.brandingThemeId as string; + } + if (updateFields.currency) { + body.CurrencyCode = updateFields.currency as string; + } + if (updateFields.currencyRate) { + body.CurrencyRate = updateFields.currencyRate as string; + } + if (updateFields.date) { + body.Date = updateFields.date as string; + } + if (updateFields.dueDate) { + body.DueDate = updateFields.dueDate as string; + } + if (updateFields.dueDate) { + body.DueDate = updateFields.dueDate as string; + } + if (updateFields.expectedPaymentDate) { + body.ExpectedPaymentDate = updateFields.expectedPaymentDate as string; + } + if (updateFields.invoiceNumber) { + body.InvoiceNumber = updateFields.invoiceNumber as string; + } + if (updateFields.lineAmountType) { + body.LineAmountType = updateFields.lineAmountType as string; + } + if (updateFields.plannedPaymentDate) { + body.PlannedPaymentDate = updateFields.plannedPaymentDate as string; + } + if (updateFields.reference) { + body.Reference = updateFields.reference as string; + } + if (updateFields.sendToContact) { + body.SentToContact = updateFields.sendToContact as boolean; + } + if (updateFields.status) { + body.Status = updateFields.status as string; + } + if (updateFields.url) { + body.Url = updateFields.url as string; + } + + responseData = await xeroApiRequest.call(this, 'POST', `/Invoices/${invoiceId}`, body); + responseData = responseData.Invoices; + } + if (operation === 'get') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const invoiceId = this.getNodeParameter('invoiceId', i) as string; + responseData = await xeroApiRequest.call(this, 'GET', `/Invoices/${invoiceId}`, { organizationId }); + responseData = responseData.Invoices; + } + if (operation === 'getAll') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const options = this.getNodeParameter('options', i) as IDataObject; + if (options.statuses) { + qs.statuses = (options.statuses as string[]).join(','); + } + if (options.orderBy) { + qs.order = `${options.orderBy} ${(options.sortOrder === undefined) ? 'DESC' : options.sortOrder}`; + } + if (options.where) { + qs.where = options.where; + } + if (options.createdByMyApp) { + qs.createdByMyApp = options.createdByMyApp as boolean; + } + if (returnAll) { + responseData = await xeroApiRequestAllItems.call(this, 'Invoices', 'GET', '/Invoices', { organizationId }, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + responseData = await xeroApiRequest.call(this, 'GET', `/Invoices`, { organizationId }, qs); + responseData = responseData.Invoices; + responseData = responseData.splice(0, limit); + } + } + } + if (resource === 'contact') { + } + if (operation === 'create') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const name = this.getNodeParameter('name', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + // const addressesUi = additionalFields.addressesUi as IDataObject; + // const phonesUi = additionalFields.phonesUi as IDataObject; + + const body: IContact = { + Name: name, + }; + + if (additionalFields.accountNumber) { + body.AccountNumber = additionalFields.accountNumber as string; + } + + if (additionalFields.bankAccountDetails) { + body.BankAccountDetails = additionalFields.bankAccountDetails as string; + } + + if (additionalFields.contactNumber) { + body.ContactNumber = additionalFields.contactNumber as string; + } + + if (additionalFields.contactStatus) { + body.ContactStatus = additionalFields.contactStatus as string; + } + + if (additionalFields.defaultCurrency) { + body.DefaultCurrency = additionalFields.defaultCurrency as string; + } + + if (additionalFields.emailAddress) { + body.EmailAddress = additionalFields.emailAddress as string; + } + + if (additionalFields.firstName) { + body.FirstName = additionalFields.firstName as string; + } + + if (additionalFields.lastName) { + body.LastName = additionalFields.lastName as string; + } + + if (additionalFields.purchasesDefaultAccountCode) { + body.PurchasesDefaultAccountCode = additionalFields.purchasesDefaultAccountCode as string; + } + + if (additionalFields.salesDefaultAccountCode) { + body.SalesDefaultAccountCode = additionalFields.salesDefaultAccountCode as string; + } + + if (additionalFields.skypeUserName) { + body.SkypeUserName = additionalFields.skypeUserName as string; + } + + if (additionalFields.taxNumber) { + body.taxNumber = additionalFields.taxNumber as string; + } + + if (additionalFields.xeroNetworkKey) { + body.xeroNetworkKey = additionalFields.xeroNetworkKey as string; + } + + // if (phonesUi) { + // const phoneValues = phonesUi?.phonesValues as IDataObject[]; + // if (phoneValues) { + // const phones: IPhone[] = []; + // for (const phoneValue of phoneValues) { + // const phone: IPhone = {}; + // phone.Type = phoneValue.type as string; + // phone.PhoneNumber = phoneValue.PhoneNumber as string; + // phone.PhoneAreaCode = phoneValue.phoneAreaCode as string; + // phone.PhoneCountryCode = phoneValue.phoneCountryCode as string; + // phones.push(phone); + // } + // body.Phones = phones; + // } + // } + + // if (addressesUi) { + // const addressValues = addressesUi?.addressesValues as IDataObject[]; + // if (addressValues) { + // const addresses: IAddress[] = []; + // for (const addressValue of addressValues) { + // const address: IAddress = {}; + // address.Type = addressValue.type as string; + // address.AddressLine1 = addressValue.line1 as string; + // address.AddressLine2 = addressValue.line2 as string; + // address.City = addressValue.city as string; + // address.Region = addressValue.region as string; + // address.PostalCode = addressValue.postalCode as string; + // address.Country = addressValue.country as string; + // address.AttentionTo = addressValue.attentionTo as string; + // addresses.push(address); + // } + // body.Addresses = addresses; + // } + // } + + responseData = await xeroApiRequest.call(this, 'POST', '/Contacts', { organizationId, Contacts: [body] }); + responseData = responseData.Contacts; + } + if (operation === 'get') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const contactId = this.getNodeParameter('contactId', i) as string; + responseData = await xeroApiRequest.call(this, 'GET', `/Contacts/${contactId}`, { organizationId }); + responseData = responseData.Contacts; + } + if (operation === 'getAll') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const options = this.getNodeParameter('options', i) as IDataObject; + if (options.includeArchived) { + qs.includeArchived = options.includeArchived as boolean; + } + if (options.orderBy) { + qs.order = `${options.orderBy} ${(options.sortOrder === undefined) ? 'DESC' : options.sortOrder}`; + } + if (options.where) { + qs.where = options.where; + } + if (returnAll) { + responseData = await xeroApiRequestAllItems.call(this, 'Contacts', 'GET', '/Contacts', { organizationId }, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + responseData = await xeroApiRequest.call(this, 'GET', `/Contacts`, { organizationId }, qs); + responseData = responseData.Contacts; + responseData = responseData.splice(0, limit); + } + + } + if (operation === 'update') { + const organizationId = this.getNodeParameter('organizationId', i) as string; + const contactId = this.getNodeParameter('contactId', i) as string; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + // const addressesUi = updateFields.addressesUi as IDataObject; + // const phonesUi = updateFields.phonesUi as IDataObject; + + const body: IContact = {}; + + if (updateFields.accountNumber) { + body.AccountNumber = updateFields.accountNumber as string; + } + + if (updateFields.name) { + body.Name = updateFields.name as string; + } + + if (updateFields.bankAccountDetails) { + body.BankAccountDetails = updateFields.bankAccountDetails as string; + } + + if (updateFields.contactNumber) { + body.ContactNumber = updateFields.contactNumber as string; + } + + if (updateFields.contactStatus) { + body.ContactStatus = updateFields.contactStatus as string; + } + + if (updateFields.defaultCurrency) { + body.DefaultCurrency = updateFields.defaultCurrency as string; + } + + if (updateFields.emailAddress) { + body.EmailAddress = updateFields.emailAddress as string; + } + + if (updateFields.firstName) { + body.FirstName = updateFields.firstName as string; + } + + if (updateFields.lastName) { + body.LastName = updateFields.lastName as string; + } + + if (updateFields.purchasesDefaultAccountCode) { + body.PurchasesDefaultAccountCode = updateFields.purchasesDefaultAccountCode as string; + } + + if (updateFields.salesDefaultAccountCode) { + body.SalesDefaultAccountCode = updateFields.salesDefaultAccountCode as string; + } + + if (updateFields.skypeUserName) { + body.SkypeUserName = updateFields.skypeUserName as string; + } + + if (updateFields.taxNumber) { + body.taxNumber = updateFields.taxNumber as string; + } + + if (updateFields.xeroNetworkKey) { + body.xeroNetworkKey = updateFields.xeroNetworkKey as string; + } + + // if (phonesUi) { + // const phoneValues = phonesUi?.phonesValues as IDataObject[]; + // if (phoneValues) { + // const phones: IPhone[] = []; + // for (const phoneValue of phoneValues) { + // const phone: IPhone = {}; + // phone.Type = phoneValue.type as string; + // phone.PhoneNumber = phoneValue.PhoneNumber as string; + // phone.PhoneAreaCode = phoneValue.phoneAreaCode as string; + // phone.PhoneCountryCode = phoneValue.phoneCountryCode as string; + // phones.push(phone); + // } + // body.Phones = phones; + // } + // } + + // if (addressesUi) { + // const addressValues = addressesUi?.addressesValues as IDataObject[]; + // if (addressValues) { + // const addresses: IAddress[] = []; + // for (const addressValue of addressValues) { + // const address: IAddress = {}; + // address.Type = addressValue.type as string; + // address.AddressLine1 = addressValue.line1 as string; + // address.AddressLine2 = addressValue.line2 as string; + // address.City = addressValue.city as string; + // address.Region = addressValue.region as string; + // address.PostalCode = addressValue.postalCode as string; + // address.Country = addressValue.country as string; + // address.AttentionTo = addressValue.attentionTo as string; + // addresses.push(address); + // } + // body.Addresses = addresses; + // } + // } + + responseData = await xeroApiRequest.call(this, 'POST', `/Contacts/${contactId}`, { organizationId, Contacts: [body] }); + responseData = responseData.Contacts; + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Xero/xero.png b/packages/nodes-base/nodes/Xero/xero.png new file mode 100644 index 0000000000000000000000000000000000000000..a9d46c10aacd655047a0039b75024aebcab3cf84 GIT binary patch literal 9587 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