From 9f4d6f44cb9b7e61a4200cf1c1fde3c375cadbb1 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 25 Nov 2020 11:44:50 +0100 Subject: [PATCH] :sparkles: Add ProfitWell Node (#1204) * :zap: Improvements to ProfitWell Node :zap: Improvements to Pro * :zap: Improvements to ProfitWell-Node * :zap: improvements simplifying data * :zap: Small formatting improvement Co-authored-by: ricardo --- .../credentials/ProfitWellApi.credentials.ts | 19 + .../nodes/ProfitWell/CompanyDescription.ts | 27 ++ .../nodes/ProfitWell/GenericFunctions.ts | 74 +++ .../nodes/ProfitWell/MetricDescription.ts | 439 ++++++++++++++++++ .../nodes/ProfitWell/ProfitWell.node.ts | 155 +++++++ .../nodes/ProfitWell/profitwell.png | Bin 0 -> 1870 bytes packages/nodes-base/package.json | 2 + 7 files changed, 716 insertions(+) create mode 100644 packages/nodes-base/credentials/ProfitWellApi.credentials.ts create mode 100644 packages/nodes-base/nodes/ProfitWell/CompanyDescription.ts create mode 100644 packages/nodes-base/nodes/ProfitWell/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/ProfitWell/MetricDescription.ts create mode 100644 packages/nodes-base/nodes/ProfitWell/ProfitWell.node.ts create mode 100644 packages/nodes-base/nodes/ProfitWell/profitwell.png diff --git a/packages/nodes-base/credentials/ProfitWellApi.credentials.ts b/packages/nodes-base/credentials/ProfitWellApi.credentials.ts new file mode 100644 index 0000000000..8f1fe16307 --- /dev/null +++ b/packages/nodes-base/credentials/ProfitWellApi.credentials.ts @@ -0,0 +1,19 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class ProfitWellApi implements ICredentialType { + name = 'profitWellApi'; + displayName = 'ProfitWell API'; + documentationUrl = 'profitWell'; + properties = [ + { + displayName: 'API Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + description: 'Your Private Token', + }, + ]; +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/ProfitWell/CompanyDescription.ts b/packages/nodes-base/nodes/ProfitWell/CompanyDescription.ts new file mode 100644 index 0000000000..40a1be77dd --- /dev/null +++ b/packages/nodes-base/nodes/ProfitWell/CompanyDescription.ts @@ -0,0 +1,27 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const companyOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'company', + ], + }, + }, + options: [ + { + name: 'Get Settings', + value: 'getSetting', + description: 'Get your companys ProfitWell account settings', + }, + ], + default: 'getSetting', + description: 'The operation to perform.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ProfitWell/GenericFunctions.ts b/packages/nodes-base/nodes/ProfitWell/GenericFunctions.ts new file mode 100644 index 0000000000..4561bbef59 --- /dev/null +++ b/packages/nodes-base/nodes/ProfitWell/GenericFunctions.ts @@ -0,0 +1,74 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function profitWellApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + try { + const credentials = this.getCredentials('profitWellApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + let options: OptionsWithUri = { + headers: { + 'Authorization': credentials.accessToken, + }, + method, + qs, + body, + uri: uri || `https://api.profitwell.com/v2${resource}`, + json: true, + }; + + options = Object.assign({}, options, option); + + return await this.helpers.request!(options); + } catch (error) { + + if (error.response && error.response.body && error.response.body.message) { + // Try to return the error prettier + const errorBody = error.response.body; + throw new Error(`ProfitWell error response [${error.statusCode}]: ${errorBody.message}`); + } + + // Expected error data did not get returned so throw the actual error + throw error; + } +} + +export function simplifyDailyMetrics(responseData: { [key: string]: [{ date: string, value: number | null }] }) { + const data: IDataObject[] = []; + const keys = Object.keys(responseData); + const dates = responseData[keys[0]].map(e => e.date); + for (const [index, date] of dates.entries()) { + const element: IDataObject = { + date, + }; + for (const key of keys) { + element[key] = responseData[key][index].value; + } + data.push(element); + } + return data; +} + +export function simplifyMontlyMetrics(responseData: { [key: string]: [{ date: string, value: number | null }] }) { + const data: IDataObject = {}; + for (const key of Object.keys(responseData)) { + for (const [index] of responseData[key].entries()) { + data[key] = responseData[key][index].value; + data['date'] = responseData[key][index].date; + } + } + return data; +} diff --git a/packages/nodes-base/nodes/ProfitWell/MetricDescription.ts b/packages/nodes-base/nodes/ProfitWell/MetricDescription.ts new file mode 100644 index 0000000000..8602711f32 --- /dev/null +++ b/packages/nodes-base/nodes/ProfitWell/MetricDescription.ts @@ -0,0 +1,439 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const metricOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'metric', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Retrieve financial metric broken down by day for either the current month or the last', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const metricFields = [ + + /* -------------------------------------------------------------------------- */ + /* metric:get */ + /* -------------------------------------------------------------------------- */ + + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Daily', + value: 'daily', + description: 'Retrieve financial metric broken down by day for either the current month or the last', + }, + { + name: 'Monthly', + value: 'monthly', + description: 'Retrieve all monthly financial metric for your company', + }, + ], + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'metric', + ], + operation: [ + 'get', + ], + }, + }, + }, + { + displayName: 'Month', + name: 'month', + type: 'string', + default: '', + placeholder: 'YYYY-MM', + required: true, + displayOptions: { + show: { + resource: [ + 'metric', + ], + operation: [ + 'get', + ], + type: [ + 'daily', + ], + }, + }, + description: 'Can only be the current or previous month. Format should be YYYY-MM', + }, + { + displayName: 'Simple', + name: 'simple', + type: 'boolean', + default: true, + displayOptions: { + show: { + resource: [ + 'metric', + ], + operation: [ + 'get', + ], + }, + }, + description: 'When set to true a simplify version of the response will be used else the raw data.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + displayOptions: { + show: { + resource: [ + 'metric', + ], + operation: [ + 'get', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Plan ID', + name: 'plan_id', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getPlanIds', + }, + default: '', + description: 'Only return the metric for this Plan ID', + }, + { + displayName: 'Metrics', + name: 'dailyMetrics', + type: 'multiOptions', + displayOptions: { + show: { + '/type': [ + 'daily', + ], + }, + }, + options: [ + { + name: 'Active Customers', + value: 'active_customers', + description: 'Number of paying customers', + }, + { + name: 'Churned Customers', + value: 'churned_customers', + description: 'Number of paying customers who churned', + }, + { + name: 'Churned Recurring Revenue', + value: 'churned_recurring_revenue', + description: 'MRR lost to churn (voluntary and delinquent)', + }, + { + name: 'Cumulative Net New MRR', + value: 'cumulative_net_new_mrr', + description: 'New + Upgrades - Downgrades - Churn MRR, cumulative for the month up through the given day', + }, + { + name: 'Cumulative New Trialing Customers', + value: 'cumulative_new_trialing_customers', + description: 'Number of new trialing customers, cumulative for the month up through the given day', + }, + { + name: 'Downgraded Customers', + value: 'downgraded_customers', + description: 'Number of existing customers who net downgraded', + }, + { + name: 'Downgraded Recurring Revenue', + value: 'downgraded_recurring_revenue', + description: 'How much downgrades and plan length decreases affect your MRR', + }, + { + name: 'Future Churn MRR', + value: 'future_churn_mrr', + description: 'MRR that will be lost when users who are currently cancelled actually churn', + }, + { + name: 'New Customers', + value: 'new_customers', + description: 'Number of new, paying customers you have', + }, + { + name: 'New Recurring Revenue', + value: 'new_recurring_revenue', + description: 'MRR from new users', + }, + { + name: 'Reactivated Customers', + value: 'reactivated_customers', + description: 'Number of customers who have reactivated', + }, + { + name: 'Reactivated Recurring Revenue', + value: 'reactivated_recurring_revenue', + description: 'How much MRR comes from reactivated customers', + }, + { + name: 'Recurring Revenue', + value: 'recurring_revenue', + description: `Your company's MRR`, + }, + { + name: 'Upgraded Customers', + value: 'upgraded_customers', + description: `Number of existing customers who net upgraded`, + }, + { + name: 'Upgraded Recurring Revenue', + value: 'upgraded_recurring_revenue', + description: `How much upgrades and plan length increases affect your MRR`, + }, + ], + default: '', + description: 'Comma-separated list of metric trends to return (the default is to return all metric)', + }, + { + displayName: 'Metrics', + name: 'monthlyMetrics', + type: 'multiOptions', + displayOptions: { + show: { + '/type': [ + 'monthly', + ], + }, + }, + options: [ + { + name: 'Active Customers', + value: 'active_customers', + description: 'Number of paying customers', + }, + { + name: 'Active Trialing Customers', + value: 'active_trialing_customers', + description: 'Number of trialing customers', + }, + { + name: 'Average Revenue Per User', + value: 'average_revenue_per_user', + description: 'ARPU', + }, + { + name: 'Churned Customers', + value: 'churned_customers', + description: 'Number of paying customers who churned', + }, + { + name: 'Churned Customers Cancellations', + value: 'churned_customers_cancellations', + description: 'Number of customers who churned by cancelling their subscription(s)', + }, + { + name: 'Churned Customers Delinquent', + value: 'churned_customers_delinquent', + description: 'Number of customers who churned because they failed to pay you', + }, + { + name: 'Churned Recurring Revenue', + value: 'churned_recurring_revenue', + description: 'Revenue lost to churn (voluntary and delinquent)', + }, + { + name: 'Churned Recurring Revenue Cancellations', + value: 'churned_recurring_revenue_cancellations', + description: 'Revenue lost to customers who churned by cancelling their subscription(s)', + }, + { + name: 'Churned Recurring Revenue Delinquent', + value: 'churned_recurring_revenue_delinquent', + description: 'Revenue lost to customers who churned delinquent', + }, + { + name: 'Churned Trialing Customers', + value: 'churned_trialing_customers', + description: 'Number of trialling customers who churned', + }, + { + name: 'Converted Customers', + value: 'converted_customers', + description: 'Number of customers who converted from trialing to active', + }, + { + name: 'Converted Recurring Revenue', + value: 'converted_recurring_revenue', + description: 'How much MRR comes from users who converted from trialing to active', + }, + { + name: 'Customer Churn Cancellations Rate', + value: 'customers_churn_cancellations_rate', + description: `Percentage of paying customers who churned by cancelling their subscription(s)`, + }, + { + name: 'Customer Churn Delinquent Rate', + value: 'customers_churn_delinquent_rate', + description: `Percentage of paying customers who churned because they failed to pay you`, + }, + { + name: 'Customer Churn Rate', + value: 'customers_churn_rate', + description: `Percentage of paying customers who churned`, + }, + { + name: 'Customer Conversion Rate', + value: 'customer_conversion_rate', + description: 'Percent of trialing customers who converted', + }, + { + name: 'Customer Retention Rate', + value: 'customers_retention_rate', + description: 'Percent of customers active last month who are still active this month', + }, + { + name: 'Downgrade Customers', + value: 'downgraded_customers', + description: 'Number of existing customers who net downgraded', + }, + { + name: 'Downgrade Rate', + value: 'downgrade_rate', + description: 'Downgrade revenue as a percent of existing revenue', + }, + { + name: 'Downgrade Recurring Revenue', + value: 'downgraded_recurring_revenue', + description: 'How much downgrades and plan length decreases affect your MRR ', + }, + { + name: 'Existing Customers', + value: 'existing_customers', + description: 'Number of paying customers you had at the start of the given month', + }, + { + name: 'Existing Recurring Revenue', + value: 'existing_recurring_revenue', + description: `Your company's MRR at the start of the given month`, + }, + { + name: 'Existing Trialing Customers', + value: 'existing_trialing_customers', + description: `Number of trialing customers who existed at the start of the month`, + }, + { + name: 'Growth_Rate', + value: 'growth_rate', + description: `Rate at which your company's MRR has grown over the previous month`, + }, + { + name: 'Lifetime Value', + value: 'lifetime_value', + description: `Average LTV, as calculated at the end of the given period`, + }, + { + name: 'New Customers', + value: 'new_customers', + description: `Number of new, paying customers you have`, + }, + { + name: 'New Recurring Revenue', + value: 'new_recurring_revenue', + description: `MRR from new users`, + }, + { + name: 'New Trailing Customers', + value: 'new_trialing_customers', + description: `Number of new trialing customers`, + }, + { + name: 'Reactivated Customers', + value: 'reactivated_customers', + description: `Number of customers who have reactivated`, + }, + { + name: 'Reactivated Recurring Revenue', + value: 'reactivated_recurring_revenue', + description: `How much MRR comes from reactivated customers`, + }, + { + name: 'Recurring Revenue', + value: 'recurring_revenue', + description: `Your company's MRR`, + }, + { + name: 'Revenue Churn Cancellations Rate', + value: 'revenue_churn_cancellations_rate', + description: `Voluntary churn revenue as a percent of the month's starting revenue`, + }, + { + name: 'Revenue Churn Delinquent_ Rate', + value: 'revenue_churn_delinquent_rate', + description: `Delinquent churn revenue as a percent of the month's starting revenue `, + }, + { + name: 'Revenue Churn Rate', + value: 'revenue_churn_rate', + description: `Revenue lost to churn as a percentage of existing revenue`, + }, + { + name: 'Revenue Retention Rate', + value: 'revenue_retention_rate', + description: `Percent of revenue coming from existing customers that was retained by the end of the month`, + }, + { + name: 'Upgrade Rate', + value: 'upgrade_rate', + description: `Upgrade revenue as a percent of existing revenue`, + }, + { + name: 'Upgraded Customers', + value: 'upgraded_customers', + description: `Number of existing customers who net upgraded `, + }, + { + name: 'Upgraded Recurring Revenue', + value: 'upgraded_recurring_revenue', + description: `How much upgrades and plan length increases affect your MRR`, + }, + { + name: 'Plan Changed Rate', + value: 'plan_change_rate', + description: `Net change in revenue as a percentage of existing revenue`, + }, + { + name: 'Plan Changed Recurring Revenue', + value: 'plan_changed_recurring_revenue', + description: `Net change in revenue for this plan`, + }, + ], + default: '', + description: 'Comma-separated list of metric trends to return (the default is to return all metric)', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ProfitWell/ProfitWell.node.ts b/packages/nodes-base/nodes/ProfitWell/ProfitWell.node.ts new file mode 100644 index 0000000000..ebd8e86cb6 --- /dev/null +++ b/packages/nodes-base/nodes/ProfitWell/ProfitWell.node.ts @@ -0,0 +1,155 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + profitWellApiRequest, + simplifyDailyMetrics, + simplifyMontlyMetrics, +} from './GenericFunctions'; + +import { + companyOperations, +} from './CompanyDescription'; + +import { + metricFields, + metricOperations, +} from './MetricDescription'; + +export class ProfitWell implements INodeType { + description: INodeTypeDescription = { + displayName: 'ProfitWell', + name: 'profitWell', + icon: 'file:profitwell.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume ProfitWell API', + defaults: { + name: 'ProfitWell', + color: '#1e333d', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'profitWellApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Company', + value: 'company', + }, + { + name: 'Metric', + value: 'metric', + }, + ], + default: 'metric', + description: 'Resource to consume.', + }, + // COMPANY + ...companyOperations, + // METRICS + ...metricOperations, + ...metricFields, + ], + }; + + methods = { + loadOptions: { + async getPlanIds( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + const planIds = await profitWellApiRequest.call( + this, + 'GET', + '/metrics/plans', + ); + for (const planId of planIds.plan_ids) { + returnData.push({ + name: planId, + value: planId, + }); + } + 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; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + if (resource === 'company') { + if (operation === 'getSetting') { + responseData = await profitWellApiRequest.call(this, 'GET', `/company/settings/`); + } + } + if (resource === 'metric') { + if (operation === 'get') { + const type = this.getNodeParameter('type', i) as string; + + const simple = this.getNodeParameter('simple', 0) as boolean; + + if (type === 'daily') { + qs.month = this.getNodeParameter('month', i) as string; + } + const options = this.getNodeParameter('options', i) as IDataObject; + + Object.assign(qs, options); + + if (qs.dailyMetrics) { + qs.metrics = (qs.dailyMetrics as string[]).join(','); + delete qs.dailyMetrics; + } + + if (qs.monthlyMetrics) { + qs.metrics = (qs.monthlyMetrics as string[]).join(','); + delete qs.monthlyMetrics; + } + + responseData = await profitWellApiRequest.call(this, 'GET', `/metrics/${type}`, {}, qs); + responseData = responseData.data; + + if (simple === true) { + if (type === 'daily') { + responseData = simplifyDailyMetrics(responseData); + } else { + responseData = simplifyMontlyMetrics(responseData); + } + } + } + } + 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/ProfitWell/profitwell.png b/packages/nodes-base/nodes/ProfitWell/profitwell.png new file mode 100644 index 0000000000000000000000000000000000000000..78b9c0afc0749afd93b9b920572bad844938254b GIT binary patch literal 1870 zcmd5*dpJ~S7(e6CFk>c0t-+8;a-R!ACbt=x5>inbcUvi2E+G*j4a!|EEv7OF z3CYrhvgMLp_EGNFhs*m%x3}GaLdYTj06@{K zD2`Ax7A#RvkWo@Ig@Ul3nT;6$+`fe5c!@w~MQDi0fv`=UM249EBM^&>=mC(0e^dw&OJ50E2FO~5 zP)Ehzt8f-Hu{(LFk}F@yBr>9ofnetTMTQ8_{!>P<`GQX+h`L-vEkiOXTC~0Zdo~D1 zwH9OD6m2R-R%C)h&EK|F&@sZ9TcLB4QTdlpd7mMfd=^!hD!1ENV2x<}83bc_12X~i zOrQ?(MB`7!A@2gh$2ka`e!+Kd`47L{$IBo`vr*7Cgl2-H>=caR<7AMv*TtX>|^Yf|!>9oi3a}#IVl@!(d!aC@VkfNJ=m4;Bt6;%MuDOv=5*|#mp~6Rz2vO+`Zx>gy0RR-DQOumI+NWMEyZ4x4 z;V1T2L~TkEGOtFbZcz`+xl1*pwo+m6I3={xfZRaTOuW09y$*Qi-;55PjOO(O|OBv58oOR1r7-JH3?!AJ}|KPY8?Lvx_R?*3w(`1KT7GbQe-dv9%55Ey4#8+T|} z(K`FhO0!MHtyuB8*@0fP-1Jc&^1*`AB7Ltbjp{`S6iT+Fm=RjQ0;k?_N4D_WB1D^{0yK!AZSiAt_lMYBhMZnz51=m8H&s zh=4JVT zx8PZi2QTtQkF?Zj_Z=6mm^3L33~dPMr>2e9_bg45CtMX(0P0@l>5*US5>+rFS+jX= zu1XzE^saKv%euosr?Z^9(T|c{=OwCI`*LMY7kEAI?^22o$03W-Bf>E&yiP5zKSZQ= z;hB@AqxP|5H4n!MeSCZhbugp)BglSnZ`OfGYrid0puAm$q2<2HadWHD!`P4mp6C2! z!V3?F?9^`)*N{s=lr31a%{#U?A9b7Xa+spdTT1sNNw71V#`W0y3N0}B8Ctq_QFe2o zW87T3!Orax4y7K6EqcG0wzMbM!J>3L&eS}qDbKO>d+8TJK$BkF;*^_Y>?k=_3xZv< zKLQ!YpY$;LKHTo`nXh#AW0eK?S`2nFM{^$^h%32hbi>H1K#MJf5OHN&c=N;P@eb--v%N&