diff --git a/components/quickbooks/actions/download-pdf/download-pdf.js b/components/quickbooks/actions/download-pdf/download-pdf.js new file mode 100644 index 0000000000000..0c9a826f652e0 --- /dev/null +++ b/components/quickbooks/actions/download-pdf/download-pdf.js @@ -0,0 +1,64 @@ +const quickbooks = require("../../quickbooks.app"); +const fs = require("fs"); +const { promisify } = require("util"); +const stream = require("stream"); + +module.exports = { + name: "Download PDF", + description: "Download an invoice, bill, purchase order or other QuickBooks entity as a PDF and save it in [Pipedream's temporary file system](https://pipedream.com/docs/workflows/steps/code/nodejs/working-with-files/#the-tmp-directory) for use in a later step.", + key: "quickbooks-download-pdf", + version: "0.0.1", + type: "action", + props: { + quickbooks, + entity: { + type: "string", + label: "Document Type", + description: null, + options: [ + "CreditMemo", + "Estimate", + "Invoice", + "Payment", + "PurchaseOrder", + "RefundReceipt", + "SalesReceipt", + ], + }, + id: { + type: "string", + label: "Record ID", + description: "Use the 'Id' property of the record's JSON object or open the record for editing in Quickbooks Online to find its ID in the URL after 'txnId=', e.g. 'https://app.qbo.intuit.com/app/invoice?txnId=22743'", + }, + fileName: { + type: "string", + label: "File Name (Optional)", + description: "The name to give the PDF when it is stored in the /tmp directory, e.g. `myFile.pdf`. If no file name is provided, the PDF will be named using the record ID, e.g. '22743.pdf'", + optional: true, + }, + }, + methods: { + async downloadPDF($, entity, id, fileName) { + const file = await this.quickbooks.getPDF($, entity, id); + + const filePath = "/tmp/" + fileName; + const pipeline = promisify(stream.pipeline); + await pipeline( + file, + fs.createWriteStream(filePath), + ); + return filePath; + }, + }, + async run({ $ }) { + const fileName = this.fileName || this.id; + const fileNameWithExtension = fileName.endsWith(".pdf") + ? fileName + : fileName + ".pdf"; + + const filePath = await this.downloadPDF($, this.entity, this.id, fileNameWithExtension); + $.export("file_path", filePath); + $.export("file_name", fileNameWithExtension); + $.export("$summary", `Successfully downloaded file: ${fileNameWithExtension}`); + }, +}; diff --git a/components/quickbooks/constants.js b/components/quickbooks/constants.js new file mode 100644 index 0000000000000..301a6d7e1af15 --- /dev/null +++ b/components/quickbooks/constants.js @@ -0,0 +1,210 @@ +const WEBHOOK_ENTITIES = [ + "Account", + "Bill", + "BillPayment", + "Budget", + "Class", + "CreditMemo", + "Currency", + "Customer", + "Department", + "Deposit", + "Employee", + "Estimate", + "Invoice", + "Item", + "JournalCode", + "JournalEntry", + "Payment", + "PaymentMethod", + "Preferences", + "Purchase", + "PurchaseOrder", + "RefundReceipt", + "SalesReceipt", + "TaxAgency", + "Term", + "TimeActivity", + "Transfer", + "Vendor", + "VendorCredit", +]; + +const WEBHOOK_OPERATIONS = [ + "Create", + "Update", + "Merge", + "Delete", + "Void", + "Emailed", +]; + +// The below list is based on the table shown in Step 3 of the Intuit webhooks documentation +// https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks#set-up-oauth +const SUPPORTED_WEBHOOK_OPERATIONS = { + Account: [ + "Create", + "Update", + "Merge", + "Delete", + ], + Bill: [ + "Create", + "Update", + "Delete", + ], + BillPayment: [ + "Create", + "Update", + "Delete", + "Void", + ], + Budget: [ + "Create", + "Update", + ], + Class: [ + "Create", + "Update", + "Merge", + "Delete", + ], + CreditMemo: [ + "Create", + "Update", + "Delete", + "Void", + "Emailed", + ], + Currency: [ + "Create", + "Update", + ], + Customer: [ + "Create", + "Update", + "Merge", + "Delete", + ], + Department: [ + "Create", + "Update", + "Merge", + ], + Deposit: [ + "Create", + "Update", + "Delete", + ], + Employee: [ + "Create", + "Update", + "Merge", + "Delete", + ], + Estimate: [ + "Create", + "Update", + "Delete", + "Emailed", + ], + Invoice: [ + "Create", + "Update", + "Delete", + "Void", + "Emailed", + ], + Item: [ + "Create", + "Update", + "Merge", + "Delete", + ], + JournalCode: [ + "Create", + "Update", + ], + JournalEntry: [ + "Create", + "Update", + "Delete", + ], + Payment: [ + "Create", + "Update", + "Delete", + "Void", + "Emailed", + ], + PaymentMethod: [ + "Create", + "Update", + "Merge", + ], + Preferences: [ + "Update", + ], + Purchase: [ + "Create", + "Update", + "Delete", + "Void", + ], + PurchaseOrder: [ + "Create", + "Update", + "Delete", + "Emailed", + ], + RefundReceipt: [ + "Create", + "Update", + "Delete", + "Void", + "Emailed", + ], + SalesReceipt: [ + "Create", + "Update", + "Delete", + "Void", + "Emailed", + ], + TaxAgency: [ + "Create", + "Update", + ], + Term: [ + "Create", + "Update", + ], + TimeActivity: [ + "Create", + "Update", + "Delete", + ], + Transfer: [ + "Create", + "Update", + "Delete", + "Void", + ], + Vendor: [ + "Create", + "Update", + "Merge", + "Delete", + ], + VendorCredit: [ + "Create", + "Update", + "Delete", + ], +}; + +module.exports = { + WEBHOOK_ENTITIES, + WEBHOOK_OPERATIONS, + SUPPORTED_WEBHOOK_OPERATIONS, +}; diff --git a/components/quickbooks/quickbooks.app.js b/components/quickbooks/quickbooks.app.js new file mode 100644 index 0000000000000..de984d7de231f --- /dev/null +++ b/components/quickbooks/quickbooks.app.js @@ -0,0 +1,97 @@ +const { axios } = require("@pipedream/platform"); +const { + WEBHOOK_ENTITIES, + WEBHOOK_OPERATIONS, +} = require("./constants"); + +module.exports = { + type: "app", + app: "quickbooks", + propDefinitions: { + webhookNames: { + type: "string[]", + label: "Entities", + description: "Select which QuickBooks entities to emit or just leave it blank to emit them all.", + options: WEBHOOK_ENTITIES, + optional: true, + }, + webhookOperations: { + type: "string[]", + label: "Operations", + description: "Select which operations to emit or just leave it blank to emit them all.", + options: WEBHOOK_OPERATIONS, + default: WEBHOOK_OPERATIONS, + optional: true, + }, + webhookVerifierToken: { + type: "string", + label: "Verifier Token", + description: "[Create an app](https://developer.intuit.com/app/developer/qbo/docs/build-your-first-app) " + + "on the Intuit Developer Dashboard and [set up a webhook](https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks). " + + "Once you have a [verifier token](https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks/managing-webhooks-notifications), " + + "fill it in below. Note that if you want to send webhooks to more than one Pipedream source, you will have to create a new Dashboard app for each different source.", + secret: true, + }, + }, + methods: { + _apiUrl() { + return "https://quickbooks.api.intuit.com/v3"; + }, + _authToken() { + return this.$auth.oauth_access_token; + }, + companyId() { + return this.$auth.company_id; + }, + _makeRequestConfig(config = {}) { + const { + headers, + path = "", + ...extraConfig + } = config; + const authToken = this._authToken(); + const baseUrl = this._apiUrl(); + const url = `${baseUrl}${path[0] === "/" + ? "" + : "/"}${path}`; + return { + headers: { + "Authorization": `Bearer ${authToken}`, + "Accept": "application/json", + "Content-Type": "application/json", + ...headers, + }, + url, + ...extraConfig, + }; + }, + async _makeRequest($ = this, config) { + const requestConfig = this._makeRequestConfig(config); + return await axios($, requestConfig); + }, + async getPDF($, entity, id) { + try { + const companyId = this.companyId(); + return await this._makeRequest($, { + path: `company/${companyId}/${entity.toLowerCase()}/${id}/pdf`, + headers: { + "Accept": "application/pdf", + }, + responseType: "stream", + }); + } catch (ex) { + if (ex.response.data.statusCode === 400) { + throw new Error(`Request failed with status code 400. Double-check that '${id}' is a valid ${entity} record ID.`); + } else { + throw ex; + } + } + }, + async getRecordDetails(entityName, id) { + const companyId = this.companyId(); + return await this._makeRequest(this, { + path: `company/${companyId}/${entityName.toLowerCase()}/${id}`, + }); + }, + }, +}; diff --git a/components/quickbooks/sources/common.js b/components/quickbooks/sources/common.js new file mode 100644 index 0000000000000..234b1f83e486a --- /dev/null +++ b/components/quickbooks/sources/common.js @@ -0,0 +1,170 @@ +const quickbooks = require("../quickbooks.app"); +const { createHmac } = require("crypto"); +const { + WEBHOOK_ENTITIES, + WEBHOOK_OPERATIONS, + SUPPORTED_WEBHOOK_OPERATIONS, +} = require("../constants"); + +module.exports = { + props: { + quickbooks, + http: { + type: "$.interface.http", + customResponse: true, + label: "HTTP", + description: "", + }, + webhookVerifierToken: { + propDefinition: [ + quickbooks, + "webhookVerifierToken", + ], + }, + }, + methods: { + companyId(event) { + return event.body.eventNotifications[0].realmId; + }, + getSupportedOperations(entityName) { + return SUPPORTED_WEBHOOK_OPERATIONS[entityName]; + }, + toPastTense(operations) { + const pastTenseVersion = { + Create: "Created", + Update: "Updated", + Merge: "Merged", + Delete: "Deleted", + Void: "Voided", + Emailed: "Emailed", + }; + if (Array.isArray(operations)) { + return operations.map((operation) => pastTenseVersion[operation]); + } else { + return pastTenseVersion[operations]; + } + }, + verifyWebhookRequest(event) { + const token = this.webhookVerifierToken; + const payload = event.bodyRaw; + const header = event.headers["intuit-signature"]; + const hash = createHmac("sha256", token).update(payload) + .digest("hex"); + const convertedHeader = Buffer.from(header, "base64").toString("hex"); + return hash === convertedHeader; + }, + isValidSource(event) { + const isWebhookValid = this.verifyWebhookRequest(event); + if (!isWebhookValid) { + console.log(`Error: Webhook did not pass verification. Try reentering the verifier token, + making sure it's from the correct section on the Intuit Developer Dashboard.`); + return false; + } + + const webhookCompanyId = this.companyId(event); + const connectedCompanyId = this.quickbooks.companyId(); + if (webhookCompanyId !== connectedCompanyId) { + console.log(`Error: Cannot retrieve record details for incoming webhook. The QuickBooks company id + of the incoming event (${webhookCompanyId}) does not match the company id of the account + currently connected to this source in Pipedream (${connectedCompanyId}).`); + return false; + } + return true; + }, + getEntities() { + return WEBHOOK_ENTITIES; + }, + getOperations() { + return WEBHOOK_OPERATIONS; + }, + isEntityRelevant(entity) { + const { + name, + operation, + } = entity; + const relevantEntities = this.getEntities(); + const relevantOperations = this.getOperations(); + + // only emit events that match the entity names and operations indicated by the user + // but if the props are left empty, emit all events rather than filtering them all out + // (it would a hassle for the user to select every option if they wanted to emit everything) + if (relevantEntities?.length > 0 && !relevantEntities.includes(name)) { + console.log(`Skipping '${operation} ${name}' event. (Accepted entities: ${relevantEntities.join(", ")})`); + return false; + } + if (!relevantOperations?.length > 0 && !relevantOperations.includes(operation)) { + console.log(`Skipping '${operation} ${name}' event. (Accepted operations: ${relevantOperations.join(", ")})`); + return false; + } + return true; + }, + async generateEvent(entity, event) { + const eventDetails = { + ...entity, + companyId: this.companyId(event), + }; + + // Unless the record has been deleted, use the id received in the webhook + // to get the full record data from QuickBooks + const recordDetails = entity.operation === "Delete" + ? {} + : await this.quickbooks.getRecordDetails(entity.name, entity.id); + + return { + eventDetails, + recordDetails, + }; + }, + generateMeta(event) { + const { + name, + id, + operation, + lastUpdated, + } = event.eventDetails; + const summary = `${name} ${id} ${this.toPastTense(operation)}`; + const ts = lastUpdated + ? Date.parse(lastUpdated) + : Date.now(); + const eventId = [ + name, + id, + operation, + ts, + ].join("-"); + return { + id: eventId, + summary, + ts, + }; + }, + async processEvent(event) { + const { entities } = event.body.eventNotifications[0].dataChangeEvent; + + const events = await Promise.all(entities + .filter(this.isEntityRelevant) + .map(async (entity) => { + // Generate events asynchronously to fetch multiple records from the API at the same time + return await this.generateEvent(entity, event); + })); + + events.forEach((event) => { + const meta = this.generateMeta(event); + this.$emit(event, meta); + }); + }, + + }, + async run(event) { + if (!this.isValidSource(event)) { + console.log("Skipping event from unrecognized source."); + return; + } + + this.http.respond({ + status: 200, + }); + + return this.processEvent(event); + }, +}; diff --git a/components/quickbooks/sources/custom-webhook-events/custom-webhook-events.js b/components/quickbooks/sources/custom-webhook-events/custom-webhook-events.js new file mode 100644 index 0000000000000..af19f56e1c1bb --- /dev/null +++ b/components/quickbooks/sources/custom-webhook-events/custom-webhook-events.js @@ -0,0 +1,35 @@ +const quickbooks = require("../../quickbooks.app"); +const common = require("../common"); + +module.exports = { + ...common, + key: "quickbooks-custom-webhook-events", + name: "Custom Webhook Events: Created, Updated, Merged, Deleted, Voided or Emailed (Instant)", // eslint-disable-line + description: "Emit new events for more than one type of entity (e.g. \"Emailed Invoices and Purchase Orders\" or \"New and Modified Customers and Vendors\"). Visit the documentation page to learn how to configure webhooks for your QuickBooks company: https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks", + version: "0.0.1", + type: "source", + props: { + ...common.props, + entitiesToEmit: { + propDefinition: [ + quickbooks, + "webhookNames", + ], + }, + operationsToEmit: { + propDefinition: [ + quickbooks, + "webhookOperations", + ], + }, + }, + methods: { + ...common.methods, + getEntities() { + return this.entitiesToEmit; + }, + getOperations() { + return this.operationsToEmit; + }, + }, +}; diff --git a/components/quickbooks/sources/new-or-modified-customer/new-or-modified-customer.js b/components/quickbooks/sources/new-or-modified-customer/new-or-modified-customer.js new file mode 100644 index 0000000000000..ad9ff6ef4f8fe --- /dev/null +++ b/components/quickbooks/sources/new-or-modified-customer/new-or-modified-customer.js @@ -0,0 +1,39 @@ +const quickbooks = require("../../quickbooks.app"); +const common = require("../common"); + +const sourceEntity = "Customer"; + +const supportedOperations = common.methods.getSupportedOperations(sourceEntity); + +module.exports = { + ...common, + key: "quickbooks-new-or-modified-customer", + name: "New or Modified Customer: Created, Updated, Merged or Deleted (Instant)", + description: "Emit new or modified customers. Visit the documentation page to learn how to configure webhooks for your QuickBooks company: https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks", + version: "0.0.1", + type: "source", + props: { + ...common.props, + operationsToEmit: { + propDefinition: [ + quickbooks, + "webhookOperations", + ], + // overwrite the default options from the propDefinition to list only the options supported + // by this source's entity + options: supportedOperations, + default: supportedOperations, + }, + }, + methods: { + ...common.methods, + getEntities() { + return [ + sourceEntity, + ]; + }, + getOperations() { + return this.operationsToEmit; + }, + }, +};