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 0000000000..78b9c0afc0 Binary files /dev/null and b/packages/nodes-base/nodes/ProfitWell/profitwell.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9583d447f4..090f15f47f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -158,6 +158,7 @@ "dist/credentials/PhilipsHueOAuth2Api.credentials.js", "dist/credentials/Postgres.credentials.js", "dist/credentials/PostmarkApi.credentials.js", + "dist/credentials/ProfitWellApi.credentials.js", "dist/credentials/PushbulletOAuth2Api.credentials.js", "dist/credentials/PushoverApi.credentials.js", "dist/credentials/QuestDb.credentials.js", @@ -376,6 +377,7 @@ "dist/nodes/PhilipsHue/PhilipsHue.node.js", "dist/nodes/Postgres/Postgres.node.js", "dist/nodes/Postmark/PostmarkTrigger.node.js", + "dist/nodes/ProfitWell/ProfitWell.node.js", "dist/nodes/Pushbullet/Pushbullet.node.js", "dist/nodes/Pushover/Pushover.node.js", "dist/nodes/QuestDb/QuestDb.node.js",