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,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 ({ module.exports = function ({
memberStripeCustomerModel, StripeWebhook,
stripeCustomerSubscriptionModel StripeCustomer,
StripeCustomerSubscription
}) { }) {
MemberStripeCustomer = memberStripeCustomerModel; async function setMetadata(module, metadata) {
StripeCustomerSubscription = stripeCustomerSubscriptionModel; 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 { 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,49 +39,109 @@ 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);
}
this._plans = []; /**
for (const planSpec of config.plans) { * @type Array<import('stripe').plans.IPlan>
*/
this._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);
}
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) { } catch (err) {
this._webhookSecret = process.env.WEBHOOK_SECRET; this.logging.error('There was an error creating the Stripe Plan');
this.logging.warn(err); 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) { } catch (err) {
debug(`Error configuring ${err.message}`);
return this._rejectReady(err); return this._rejectReady(err);
} }

View File

@ -2,60 +2,56 @@ 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;
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 ({ module.exports = function ({
stripe, 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) { 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",