From 635aa8aa3f80486f6c8d41fb24bc4e4b7e57dca9 Mon Sep 17 00:00:00 2001 From: "Fabien \"egg\" O'Carroll" Date: Fri, 14 Jan 2022 11:05:19 +0200 Subject: [PATCH] Added WebhookManager and StripeService modules no-issue --- ghost/stripe/lib/StripeService.js | 59 ++++++++++ ghost/stripe/lib/WebhookManager.js | 168 +++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 ghost/stripe/lib/StripeService.js create mode 100644 ghost/stripe/lib/WebhookManager.js diff --git a/ghost/stripe/lib/StripeService.js b/ghost/stripe/lib/StripeService.js new file mode 100644 index 0000000000..d85ead296f --- /dev/null +++ b/ghost/stripe/lib/StripeService.js @@ -0,0 +1,59 @@ +const WebhookManager = require('./WebhookManager'); +const StripeAPI = require('./StripeAPI'); +const StripeMigrations = require('./Migrations'); + +module.exports = class StripeService { + constructor({ + StripeWebhook, + models + }) { + const api = new StripeAPI(); + const webhookManager = new WebhookManager({ + StripeWebhook, + api + }); + const migrations = new StripeMigrations({ + models, + api + }); + + this.models = models; + this.api = api; + this.webhookManager = webhookManager; + this.migrations = migrations; + } + + async connect() { + } + + async disconnect() { + await this.models.Product.forge().query().update({ + monthly_price_id: null, + yearly_price_id: null + }); + await this.models.StripePrice.forge().query().del(); + await this.models.StripeProduct.forge().query().del(); + await this.models.MemberStripeCustomer.forge().query().del(); + await this.models.Offer.forge().query().update({ + stripe_coupon_id: null + }); + await this.webhookManager.stop(); + } + + async configure(config) { + this.api.configure({ + secretKey: config.secretKey, + publicKey: config.publicKey, + enablePromoCodes: config.enablePromoCodes + }); + + console.log('finna setup webhooks'); + console.log(config.webhookSecret, config.webhookHandlerUrl); + await this.webhookManager.configure({ + webhookSecret: config.webhookSecret, + webhookHandlerUrl: config.webhookHandlerUrl + }); + await this.webhookManager.start(); + console.log('webhooks done'); + } +}; diff --git a/ghost/stripe/lib/WebhookManager.js b/ghost/stripe/lib/WebhookManager.js new file mode 100644 index 0000000000..41ed6ef133 --- /dev/null +++ b/ghost/stripe/lib/WebhookManager.js @@ -0,0 +1,168 @@ +/** + * @typedef {import('stripe').Stripe.WebhookEndpointCreateParams.EnabledEvent} WebhookEvent + */ + +/** + * @typedef {import('stripe').Stripe.WebhookEndpoint} Webhook + */ + +/** + * @typedef {import('./StripeAPI')} StripeAPI + */ + +/** + * @typedef {object} StripeWebhookModel + * @prop {string} webhook_id + * @prop {string} secret + */ + +/** + * @typedef {object} StripeWebhook + * @prop {(data: StripeWebhookModel) => Promise} save + * @prop {() => Promise} get + */ + +module.exports = class WebhookManager { + /** + * @param {object} deps + * @param {StripeWebhook} deps.StripeWebhook + * @param {StripeAPI} deps.api + */ + constructor({ + StripeWebhook, + api + }) { + /** @private */ + this.StripeWebhook = StripeWebhook; + /** @private */ + this.api = api; + /** @private */ + this.config = null; + /** @private */ + this.webhookSecret = null; + /** + * @private + * @type {'network'|'local'} + */ + this.mode = 'network'; + } + + /** @type {WebhookEvent[]} */ + static events = [ + 'checkout.session.completed', + 'customer.subscription.deleted', + 'customer.subscription.updated', + 'customer.subscription.created', + 'invoice.payment_succeeded' + ]; + + /** + * @returns {Promise} + */ + async stop() { + if (this.mode !== 'network') { + return; + } + + try { + const existingWebhook = await this.StripeWebhook.get(); + if (existingWebhook.webhook_id) { + await this.api.deleteWebhookEndpoint(existingWebhook.webhook_id); + } + await this.StripeWebhook.save({ + webhook_id: null, + secret: null + }); + return true; + } catch (err) { + return false; + } + } + + async start() { + if (this.mode !== 'network') { + return; + } + const existingWebhook = await this.StripeWebhook.get(); + + const webhook = await this.setupWebhook(existingWebhook.webhook_id, existingWebhook.secret); + + await this.StripeWebhook.save({ + webhook_id: webhook.id, + secret: webhook.secret + }); + + this.webhookSecret = webhook.secret; + } + + /** + * @param {object} config + * @param {string=} config.webhookSecret An optional webhook secret for use with stripe-cli, passing this will ensure a webhook is not created in Stripe + * @param {string} config.webhookHandlerUrl The URL which the Webhook should hit + * + * @returns {Promise} + */ + async configure(config) { + this.config = config; + if (config.webhookSecret) { + this.webhookSecret = config.webhookSecret; + this.mode = 'local'; + } + } + + /** + * @param {string=} id + * @param {string=} secret + * @param {object=} opts + * @param {boolean} opts.forceCreate + * @param {boolean} opts.skipDelete + * + * @returns {Promise} + */ + async setupWebhook(id, secret, opts) { + if (!id || !secret || opts.forceCreate) { + if (id && !opts.skipDelete) { + try { + await this.api.deleteWebhookEndpoint(id); + } catch (err) { + // Continue + } + } + const webhook = await this.api.createWebhookEndpoint( + this.config.webhookHandlerUrl, + WebhookManager.events + ); + return { + id: webhook.id, + secret: webhook.secret + }; + } else { + try { + await this.api.updateWebhookEndpoint( + id, + this.config.webhookHandlerUrl, + WebhookManager.events + ); + + return { + id, + secret + }; + } catch (err) { + if (err.code === 'resource_missing') { + return this.setupWebhook(id, secret, {skipDelete: true, forceCreate: true}); + } + return this.setupWebhook(id, secret, {skipDelete: false, forceCreate: true}); + } + } + } + + /** + * @param {string} body + * @param {string} signature + * @returns {import('stripe').Stripe.Event} + */ + parseWebhook(body, signature) { + return this.api.parseWebhook(body, signature, this.webhookSecret); + } +};