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
This commit is contained in:
Fabien 'egg' O'Carroll 2020-07-09 16:40:48 +02:00 committed by GitHub
parent 68dbfb707d
commit ac923af0f7
5 changed files with 210 additions and 145 deletions

View File

@ -27,9 +27,12 @@ module.exports = function MembersApi({
getHTML, getHTML,
getSubject getSubject
}, },
memberStripeCustomerModel, models: {
stripeCustomerSubscriptionModel, StripeWebhook,
memberModel, StripeCustomer,
StripeCustomerSubscription,
Member
},
logger logger
}) { }) {
if (logger) { if (logger) {
@ -37,10 +40,14 @@ module.exports = function MembersApi({
} }
const {encodeIdentityToken, decodeToken} = Tokens({privateKey, publicKey, issuer}); const {encodeIdentityToken, decodeToken} = Tokens({privateKey, publicKey, issuer});
const metadata = Metadata({memberStripeCustomerModel, stripeCustomerSubscriptionModel}); const metadata = Metadata({
StripeWebhook,
StripeCustomer,
StripeCustomerSubscription
});
async function hasActiveStripeSubscriptions() { async function hasActiveStripeSubscriptions() {
const firstActiveSubscription = await stripeCustomerSubscriptionModel.findOne({ const firstActiveSubscription = await StripeCustomerSubscription.findOne({
status: 'active' status: 'active'
}); });
@ -48,7 +55,7 @@ module.exports = function MembersApi({
return true; return true;
} }
const firstTrialingSubscription = await stripeCustomerSubscriptionModel.findOne({ const firstTrialingSubscription = await StripeCustomerSubscription.findOne({
status: 'trialing' status: 'trialing'
}); });
@ -92,7 +99,7 @@ module.exports = function MembersApi({
getSubject getSubject
}); });
async function sendEmailWithMagicLink({email, requestedType, payload, options = {forceEmailType: false}}){ async function sendEmailWithMagicLink({email, requestedType, payload, options = {forceEmailType: false}}) {
if (options.forceEmailType) { if (options.forceEmailType) {
return magicLinkService.sendMagicLink({email, payload, subject: email, type: requestedType}); return magicLinkService.sendMagicLink({email, payload, subject: email, type: requestedType});
} }
@ -111,7 +118,7 @@ module.exports = function MembersApi({
const users = Users({ const users = Users({
stripe, stripe,
memberModel Member
}); });
async function getMemberDataFromMagicLinkToken(token) { async function getMemberDataFromMagicLinkToken(token) {
@ -139,11 +146,11 @@ module.exports = function MembersApi({
return getMemberIdentityData(email); return getMemberIdentityData(email);
} }
async function getMemberIdentityData(email){ async function getMemberIdentityData(email) {
return users.get({email}); return users.get({email});
} }
async function getMemberIdentityToken(email){ async function getMemberIdentityToken(email) {
const member = await getMemberIdentityData(email); const member = await getMemberIdentityData(email);
if (!member) { if (!member) {
return null; return null;

View File

@ -1,13 +1,15 @@
let MemberStripeCustomer; module.exports = function ({
let StripeCustomerSubscription; StripeWebhook,
StripeCustomer,
async function setMetadata(module, metadata) { StripeCustomerSubscription
}) {
async function setMetadata(module, metadata) {
if (module !== 'stripe') { if (module !== 'stripe') {
return; return;
} }
if (metadata.customer) { if (metadata.customer) {
await MemberStripeCustomer.upsert(metadata.customer, { await StripeCustomer.upsert(metadata.customer, {
customer_id: metadata.customer.customer_id customer_id: metadata.customer.customer_id
}); });
} }
@ -18,15 +20,21 @@ async function setMetadata(module, metadata) {
}); });
} }
return; if (metadata.webhook) {
} await StripeWebhook.upsert(metadata.webhook, {
webhook_id: metadata.webhook.webhook_id
});
}
async function getMetadata(module, member) { return;
}
async function getMetadata(module, member) {
if (module !== 'stripe') { if (module !== 'stripe') {
return; return;
} }
const customers = (await MemberStripeCustomer.findAll({ const customers = (await StripeCustomer.findAll({
filter: `member_id:${member.id}` filter: `member_id:${member.id}`
})).toJSON(); })).toJSON();
@ -41,14 +49,7 @@ async function getMetadata(module, member) {
customers: customers, customers: customers,
subscriptions: subscriptions subscriptions: subscriptions
}; };
} }
module.exports = function ({
memberStripeCustomerModel,
stripeCustomerSubscriptionModel
}) {
MemberStripeCustomer = memberStripeCustomerModel;
StripeCustomerSubscription = stripeCustomerSubscriptionModel;
return { return {
setMetadata, setMetadata,

View File

@ -1,6 +1,6 @@
const debug = require('ghost-ignition').debug('stripe'); const debug = require('ghost-ignition').debug('stripe');
const _ = require('lodash'); 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 api = require('./api');
const STRIPE_API_VERSION = '2019-09-09'; const STRIPE_API_VERSION = '2019-09-09';
@ -39,33 +39,41 @@ module.exports = class StripePaymentProcessor {
this._checkoutCancelUrl = config.checkoutCancelUrl; this._checkoutCancelUrl = config.checkoutCancelUrl;
this._billingSuccessUrl = config.billingSuccessUrl; this._billingSuccessUrl = config.billingSuccessUrl;
this._billingCancelUrl = config.billingCancelUrl; this._billingCancelUrl = config.billingCancelUrl;
this._webhookHandlerUrl = config.webhookHandlerUrl;
try { try {
this._product = await api.products.ensure(this._stripe, config.product); 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);
}
/**
* @type Array<import('stripe').plans.IPlan>
*/
this._plans = []; this._plans = [];
for (const planSpec of config.plans) { for (const planSpec of config.plans) {
try {
const plan = await api.plans.ensure(this._stripe, planSpec, this._product); const plan = await api.plans.ensure(this._stripe, planSpec, this._product);
this._plans.push(plan); this._plans.push(plan);
} catch (err) {
this.logging.error('There was an error creating the Stripe Plan');
this.logging.error(err);
return this._rejectReady(err);
}
} }
const webhooks = await list(this._stripe, 'webhookEndpoints', { if (process.env.WEBHOOK_SECRET) {
limit: 100 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 webhookToDelete = webhooks.data.find((webhook) => {
return webhook.url === this._webhookHandlerUrl;
});
if (webhookToDelete) {
await del(this._stripe, 'webhookEndpoints', webhookToDelete.id);
} }
try { const webhookConfig = {
const webhook = await create(this._stripe, 'webhookEndpoints', { url: config.webhookHandlerUrl,
url: this._webhookHandlerUrl,
api_version: STRIPE_API_VERSION,
enabled_events: [ enabled_events: [
'checkout.session.completed', 'checkout.session.completed',
'customer.subscription.deleted', 'customer.subscription.deleted',
@ -73,15 +81,67 @@ module.exports = class StripePaymentProcessor {
'invoice.payment_succeeded', 'invoice.payment_succeeded',
'invoice.payment_failed' 'invoice.payment_failed'
] ]
}); };
this._webhookSecret = process.env.WEBHOOK_SECRET || webhook.secret;
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) { } catch (err) {
this._webhookSecret = process.env.WEBHOOK_SECRET; this.logging.error(`Unable to delete Stripe webhook with id: ${id}`);
this.logging.warn(err); this.logging.error(err);
} }
debug(`Webhook secret set to ${this._webhookSecret}`); }
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) { } catch (err) {
debug(`Error configuring ${err.message}`);
return this._rejectReady(err); return this._rejectReady(err);
} }

View File

@ -2,9 +2,11 @@ const _ = require('lodash');
const debug = require('ghost-ignition').debug('users'); const debug = require('ghost-ignition').debug('users');
const common = require('./common'); const common = require('./common');
let Member; module.exports = function ({
stripe,
async function createMember({email, name, note, labels, geolocation}) { Member
}) {
async function createMember({email, name, note, labels, geolocation}) {
const model = await Member.add({ const model = await Member.add({
email, email,
name, name,
@ -14,9 +16,9 @@ async function createMember({email, name, note, labels, geolocation}) {
}); });
const member = model.toJSON(); const member = model.toJSON();
return member; return member;
} }
async function getMember(data, options = {}) { async function getMember(data, options = {}) {
if (!data.email && !data.id && !data.uuid) { if (!data.email && !data.id && !data.uuid) {
return null; return null;
} }
@ -26,36 +28,30 @@ async function getMember(data, options = {}) {
} }
const member = model.toJSON(options); const member = model.toJSON(options);
return member; return member;
} }
async function updateMember(data, options = {}) { async function updateMember(data, options = {}) {
const attrs = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation']); const attrs = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation']);
const model = await Member.edit(attrs, options); const model = await Member.edit(attrs, options);
const member = model.toJSON(options); const member = model.toJSON(options);
return member; return member;
} }
function deleteMember(options) { function deleteMember(options) {
options = options || {}; options = options || {};
return Member.destroy(options); return Member.destroy(options);
} }
function listMembers(options) { function listMembers(options) {
return Member.findPage(options).then((models) => { return Member.findPage(options).then((models) => {
return { return {
members: models.data.map(model => model.toJSON(options)), members: models.data.map(model => model.toJSON(options)),
meta: models.meta meta: models.meta
}; };
}); });
} }
module.exports = function ({
stripe,
memberModel
}) {
Member = memberModel;
async function getStripeSubscriptions(member) { async function getStripeSubscriptions(member) {
if (!stripe) { if (!stripe) {

View File

@ -17,6 +17,7 @@
"gateway" "gateway"
], ],
"devDependencies": { "devDependencies": {
"@types/stripe": "^7.13.24",
"jsdom": "15.2.1", "jsdom": "15.2.1",
"mocha": "6.2.2", "mocha": "6.2.2",
"nock": "12.0.3", "nock": "12.0.3",