From ac923af0f7b4b321b6704d1b011acc58cfdb0ec0 Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Thu, 9 Jul 2020 16:40:48 +0200 Subject: [PATCH] Refactored webhook creation (#175) no-issue * Refactored model dependencies This groups all of the model depenencies into a single models object, and renames the models with more concise identifiers * Fixed spacing * Added webhook support to metadata * Refactored stripe configure to have better logging * Refactored webhook creation to reuse existing webhook * Installed @types/stripe --- ghost/members-api/index.js | 27 ++++-- ghost/members-api/lib/metadata.js | 99 ++++++++++---------- ghost/members-api/lib/stripe/index.js | 130 +++++++++++++++++++------- ghost/members-api/lib/users.js | 98 ++++++++++--------- ghost/members-api/package.json | 1 + 5 files changed, 210 insertions(+), 145 deletions(-) diff --git a/ghost/members-api/index.js b/ghost/members-api/index.js index 28ac48fe11..7586202de9 100644 --- a/ghost/members-api/index.js +++ b/ghost/members-api/index.js @@ -27,9 +27,12 @@ module.exports = function MembersApi({ getHTML, getSubject }, - memberStripeCustomerModel, - stripeCustomerSubscriptionModel, - memberModel, + models: { + StripeWebhook, + StripeCustomer, + StripeCustomerSubscription, + Member + }, logger }) { if (logger) { @@ -37,10 +40,14 @@ module.exports = function MembersApi({ } const {encodeIdentityToken, decodeToken} = Tokens({privateKey, publicKey, issuer}); - const metadata = Metadata({memberStripeCustomerModel, stripeCustomerSubscriptionModel}); + const metadata = Metadata({ + StripeWebhook, + StripeCustomer, + StripeCustomerSubscription + }); async function hasActiveStripeSubscriptions() { - const firstActiveSubscription = await stripeCustomerSubscriptionModel.findOne({ + const firstActiveSubscription = await StripeCustomerSubscription.findOne({ status: 'active' }); @@ -48,7 +55,7 @@ module.exports = function MembersApi({ return true; } - const firstTrialingSubscription = await stripeCustomerSubscriptionModel.findOne({ + const firstTrialingSubscription = await StripeCustomerSubscription.findOne({ status: 'trialing' }); @@ -92,7 +99,7 @@ module.exports = function MembersApi({ getSubject }); - async function sendEmailWithMagicLink({email, requestedType, payload, options = {forceEmailType: false}}){ + async function sendEmailWithMagicLink({email, requestedType, payload, options = {forceEmailType: false}}) { if (options.forceEmailType) { return magicLinkService.sendMagicLink({email, payload, subject: email, type: requestedType}); } @@ -111,7 +118,7 @@ module.exports = function MembersApi({ const users = Users({ stripe, - memberModel + Member }); async function getMemberDataFromMagicLinkToken(token) { @@ -139,11 +146,11 @@ module.exports = function MembersApi({ return getMemberIdentityData(email); } - async function getMemberIdentityData(email){ + async function getMemberIdentityData(email) { return users.get({email}); } - async function getMemberIdentityToken(email){ + async function getMemberIdentityToken(email) { const member = await getMemberIdentityData(email); if (!member) { return null; diff --git a/ghost/members-api/lib/metadata.js b/ghost/members-api/lib/metadata.js index 80ccc4efde..7a843a4ec4 100644 --- a/ghost/members-api/lib/metadata.js +++ b/ghost/members-api/lib/metadata.js @@ -1,54 +1,55 @@ -let MemberStripeCustomer; -let StripeCustomerSubscription; - -async function setMetadata(module, metadata) { - if (module !== 'stripe') { - return; - } - - if (metadata.customer) { - await MemberStripeCustomer.upsert(metadata.customer, { - customer_id: metadata.customer.customer_id - }); - } - - if (metadata.subscription) { - await StripeCustomerSubscription.upsert(metadata.subscription, { - subscription_id: metadata.subscription.subscription_id - }); - } - - return; -} - -async function getMetadata(module, member) { - if (module !== 'stripe') { - return; - } - - const customers = (await MemberStripeCustomer.findAll({ - filter: `member_id:${member.id}` - })).toJSON(); - - const subscriptions = await customers.reduce(async (subscriptionsPromise, customer) => { - const customerSubscriptions = await StripeCustomerSubscription.findAll({ - filter: `customer_id:${customer.customer_id}` - }); - return (await subscriptionsPromise).concat(customerSubscriptions.toJSON()); - }, []); - - return { - customers: customers, - subscriptions: subscriptions - }; -} - module.exports = function ({ - memberStripeCustomerModel, - stripeCustomerSubscriptionModel + StripeWebhook, + StripeCustomer, + StripeCustomerSubscription }) { - MemberStripeCustomer = memberStripeCustomerModel; - StripeCustomerSubscription = stripeCustomerSubscriptionModel; + async function setMetadata(module, metadata) { + if (module !== 'stripe') { + return; + } + + if (metadata.customer) { + await StripeCustomer.upsert(metadata.customer, { + customer_id: metadata.customer.customer_id + }); + } + + if (metadata.subscription) { + await StripeCustomerSubscription.upsert(metadata.subscription, { + subscription_id: metadata.subscription.subscription_id + }); + } + + if (metadata.webhook) { + await StripeWebhook.upsert(metadata.webhook, { + webhook_id: metadata.webhook.webhook_id + }); + } + + return; + } + + async function getMetadata(module, member) { + if (module !== 'stripe') { + return; + } + + const customers = (await StripeCustomer.findAll({ + filter: `member_id:${member.id}` + })).toJSON(); + + const subscriptions = await customers.reduce(async (subscriptionsPromise, customer) => { + const customerSubscriptions = await StripeCustomerSubscription.findAll({ + filter: `customer_id:${customer.customer_id}` + }); + return (await subscriptionsPromise).concat(customerSubscriptions.toJSON()); + }, []); + + return { + customers: customers, + subscriptions: subscriptions + }; + } return { setMetadata, diff --git a/ghost/members-api/lib/stripe/index.js b/ghost/members-api/lib/stripe/index.js index c58cd53fd2..05c2d2e7f7 100644 --- a/ghost/members-api/lib/stripe/index.js +++ b/ghost/members-api/lib/stripe/index.js @@ -1,6 +1,6 @@ const debug = require('ghost-ignition').debug('stripe'); const _ = require('lodash'); -const {retrieve, list, create, update, del} = require('./api/stripeRequests'); +const {retrieve, create, update, del} = require('./api/stripeRequests'); const api = require('./api'); const STRIPE_API_VERSION = '2019-09-09'; @@ -39,49 +39,109 @@ module.exports = class StripePaymentProcessor { this._checkoutCancelUrl = config.checkoutCancelUrl; this._billingSuccessUrl = config.billingSuccessUrl; this._billingCancelUrl = config.billingCancelUrl; - this._webhookHandlerUrl = config.webhookHandlerUrl; try { this._product = await api.products.ensure(this._stripe, config.product); + } catch (err) { + this.logging.error('There was an error creating the Stripe Product'); + this.logging.error(err); + return this._rejectReady(err); + } - this._plans = []; - for (const planSpec of config.plans) { + /** + * @type Array + */ + this._plans = []; + for (const planSpec of config.plans) { + try { const plan = await api.plans.ensure(this._stripe, planSpec, this._product); this._plans.push(plan); - } - - const webhooks = await list(this._stripe, 'webhookEndpoints', { - limit: 100 - }); - - const webhookToDelete = webhooks.data.find((webhook) => { - return webhook.url === this._webhookHandlerUrl; - }); - - if (webhookToDelete) { - await del(this._stripe, 'webhookEndpoints', webhookToDelete.id); - } - - try { - const webhook = await create(this._stripe, 'webhookEndpoints', { - url: this._webhookHandlerUrl, - api_version: STRIPE_API_VERSION, - enabled_events: [ - 'checkout.session.completed', - 'customer.subscription.deleted', - 'customer.subscription.updated', - 'invoice.payment_succeeded', - 'invoice.payment_failed' - ] - }); - this._webhookSecret = process.env.WEBHOOK_SECRET || webhook.secret; } catch (err) { - this._webhookSecret = process.env.WEBHOOK_SECRET; - this.logging.warn(err); + this.logging.error('There was an error creating the Stripe Plan'); + this.logging.error(err); + return this._rejectReady(err); } - debug(`Webhook secret set to ${this._webhookSecret}`); + } + + if (process.env.WEBHOOK_SECRET) { + this.logging.warn(`Skipping Stripe webhook creation and validation, using WEBHOOK_SECRET environment variable`); + this._webhookSecret = process.env.WEBHOOK_SECRET; + return this._resolveReady({ + product: this._product, + plans: this._plans + }); + } + + const webhookConfig = { + url: config.webhookHandlerUrl, + enabled_events: [ + 'checkout.session.completed', + 'customer.subscription.deleted', + 'customer.subscription.updated', + 'invoice.payment_succeeded', + 'invoice.payment_failed' + ] + }; + + const setupWebhook = async (id, secret, opts = {}) => { + if (!id || !secret || opts.forceCreate) { + if (id && !opts.skipDelete) { + try { + this.logging.info(`Deleting Stripe webhook ${id}`); + await del(this._stripe, 'webhookEndpoints', id); + } catch (err) { + this.logging.error(`Unable to delete Stripe webhook with id: ${id}`); + this.logging.error(err); + } + } + try { + this.logging.info(`Creating Stripe webhook with url: ${webhookConfig.url}, version: ${STRIPE_API_VERSION}, events: ${webhookConfig.enabled_events.join(', ')}`); + const webhook = await create(this._stripe, 'webhookEndpoints', Object.assign({}, webhookConfig, { + api_version: STRIPE_API_VERSION + })); + return { + id: webhook.id, + secret: webhook.secret + }; + } catch (err) { + this.logging.error('Failed to create Stripe webhook. For local development please see https://ghost.org/docs/members/webhooks/#stripe-webhooks'); + this.logging.error(err); + throw err; + } + } else { + try { + this.logging.info(`Updating Stripe webhook ${id} with url: ${webhookConfig.url}, events: ${webhookConfig.enabled_events.join(', ')}`); + const updatedWebhook = await update(this._stripe, 'webhookEndpoints', id, webhookConfig); + + if (updatedWebhook.api_version !== STRIPE_API_VERSION) { + throw new Error(`Webhook ${id} has api_version ${updatedWebhook.api_version}, expected ${STRIPE_API_VERSION}`); + } + + return { + id, + secret + }; + } catch (err) { + this.logging.error(`Unable to update Stripe webhook ${id}`); + this.logging.error(err); + if (err.code === 'resource_missing') { + return setupWebhook(id, secret, {skipDelete: true, forceCreate: true}); + } + return setupWebhook(id, secret, {skipDelete: false, forceCreate: true}); + } + } + }; + + try { + const webhook = await setupWebhook(config.webhook.id, config.webhook.secret); + await this.storage.set({ + webhook: { + webhook_id: webhook.id, + secret: webhook.secret + } + }); + this._webhookSecret = webhook.secret; } catch (err) { - debug(`Error configuring ${err.message}`); return this._rejectReady(err); } diff --git a/ghost/members-api/lib/users.js b/ghost/members-api/lib/users.js index 70afd7c8a8..c38f1f1db2 100644 --- a/ghost/members-api/lib/users.js +++ b/ghost/members-api/lib/users.js @@ -2,60 +2,56 @@ const _ = require('lodash'); const debug = require('ghost-ignition').debug('users'); const common = require('./common'); -let Member; - -async function createMember({email, name, note, labels, geolocation}) { - const model = await Member.add({ - email, - name, - note, - labels, - geolocation - }); - const member = model.toJSON(); - return member; -} - -async function getMember(data, options = {}) { - if (!data.email && !data.id && !data.uuid) { - return null; - } - const model = await Member.findOne(data, options); - if (!model) { - return null; - } - const member = model.toJSON(options); - return member; -} - -async function updateMember(data, options = {}) { - const attrs = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation']); - - const model = await Member.edit(attrs, options); - - const member = model.toJSON(options); - return member; -} - -function deleteMember(options) { - options = options || {}; - return Member.destroy(options); -} - -function listMembers(options) { - return Member.findPage(options).then((models) => { - return { - members: models.data.map(model => model.toJSON(options)), - meta: models.meta - }; - }); -} - module.exports = function ({ stripe, - memberModel + Member }) { - Member = memberModel; + async function createMember({email, name, note, labels, geolocation}) { + const model = await Member.add({ + email, + name, + note, + labels, + geolocation + }); + const member = model.toJSON(); + return member; + } + + async function getMember(data, options = {}) { + if (!data.email && !data.id && !data.uuid) { + return null; + } + const model = await Member.findOne(data, options); + if (!model) { + return null; + } + const member = model.toJSON(options); + return member; + } + + async function updateMember(data, options = {}) { + const attrs = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation']); + + const model = await Member.edit(attrs, options); + + const member = model.toJSON(options); + return member; + } + + function deleteMember(options) { + options = options || {}; + return Member.destroy(options); + } + + function listMembers(options) { + return Member.findPage(options).then((models) => { + return { + members: models.data.map(model => model.toJSON(options)), + meta: models.meta + }; + }); + } async function getStripeSubscriptions(member) { if (!stripe) { diff --git a/ghost/members-api/package.json b/ghost/members-api/package.json index a05eab9e64..d5335835d3 100644 --- a/ghost/members-api/package.json +++ b/ghost/members-api/package.json @@ -17,6 +17,7 @@ "gateway" ], "devDependencies": { + "@types/stripe": "^7.13.24", "jsdom": "15.2.1", "mocha": "6.2.2", "nock": "12.0.3",