0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

Moved webhook handling into Stripe service

no-issue

Handling Stripe webhooks is a Stripe concern and so we're moving it into
the Stripe module.
This commit is contained in:
Fabien "egg" O'Carroll 2022-01-17 14:17:52 +02:00
parent 94d97d1168
commit 74225779a2
6 changed files with 299 additions and 490 deletions

View file

@ -2,13 +2,11 @@ const {Router} = require('express');
const body = require('body-parser');
const MagicLink = require('@tryghost/magic-link');
const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const MemberAnalyticsService = require('@tryghost/member-analytics-service');
const MembersAnalyticsIngress = require('@tryghost/members-analytics-ingress');
const PaymentsService = require('@tryghost/members-payments');
const StripeWebhookService = require('./services/stripe-webhook');
const TokenService = require('./services/token');
const GeolocationSerice = require('./services/geolocation');
const MemberBREADService = require('./services/member-bread');
@ -38,7 +36,6 @@ module.exports = function MembersAPI({
getSubject
},
models: {
StripeWebhook,
StripeCustomer,
StripeCustomerSubscription,
Member,
@ -119,28 +116,6 @@ module.exports = function MembersAPI({
stripeService: stripeAPIService
});
const stripeWebhookService = new StripeWebhookService({
StripeWebhook,
stripeAPIService,
productRepository,
memberRepository,
eventRepository,
/**
* @param {string} email
*/
async sendSignupEmail(email) {
const requestedType = 'signup-paid';
await sendEmailWithMagicLink({
email,
requestedType,
options: {
forceEmailType: true
},
tokenData: {}
});
}
});
const geolocationService = new GeolocationSerice();
const magicLinkService = new MagicLink({
@ -334,44 +309,12 @@ module.exports = function MembersAPI({
body.json(),
(req, res) => memberController.updateSubscription(req, res)
),
handleStripeWebhook: Router(),
wellKnown: Router()
.get('/jwks.json',
(req, res) => wellKnownController.getPublicKeys(req, res)
)
};
middleware.handleStripeWebhook.use(body.raw({type: 'application/json'}), async function (req, res) {
if (!stripeAPIService.configured) {
logging.error(`Stripe not configured, not handling webhook`);
res.writeHead(400);
return res.end();
}
if (!req.body || !req.headers['stripe-signature']) {
res.writeHead(400);
return res.end();
}
let event;
try {
event = stripeWebhookService.parseWebhook(req.body, req.headers['stripe-signature']);
} catch (err) {
logging.error(err);
res.writeHead(401);
return res.end();
}
logging.info(`Handling webhook ${event.type}`);
try {
await stripeWebhookService.handleWebhook(event);
res.writeHead(200);
res.end();
} catch (err) {
logging.error(`Error handling webhook ${event.type}`, err);
res.writeHead(err.statusCode || 500);
res.end();
}
});
const getPublicConfig = function () {
return Promise.resolve({
publicKey,

View file

@ -1,311 +0,0 @@
const _ = require('lodash');
const errors = require('@tryghost/errors');
const DomainEvents = require('@tryghost/domain-events');
const {SubscriptionCreatedEvent} = require('@tryghost/member-events');
module.exports = class StripeWebhookService {
/**
* @param {object} deps
* @param {any} deps.StripeWebhook
* @param {import('../stripe-api')} deps.stripeAPIService
* @param {import('../../repositories/member')} deps.memberRepository
* @param {import('../../repositories/product')} deps.productRepository
* @param {import('../../repositories/event')} deps.eventRepository
* @param {(email: string) => Promise<void>} deps.sendSignupEmail
*/
constructor({
StripeWebhook,
stripeAPIService,
productRepository,
memberRepository,
eventRepository,
sendSignupEmail
}) {
this._StripeWebhook = StripeWebhook;
this._stripeAPIService = stripeAPIService;
this._productRepository = productRepository;
this._memberRepository = memberRepository;
this._eventRepository = eventRepository;
/** @private */
this.sendSignupEmail = sendSignupEmail;
this.handlers = {};
this.registerHandler('customer.subscription.deleted', this.subscriptionEvent);
this.registerHandler('customer.subscription.updated', this.subscriptionEvent);
this.registerHandler('customer.subscription.created', this.subscriptionEvent);
this.registerHandler('invoice.payment_succeeded', this.invoiceEvent);
this.registerHandler('checkout.session.completed', this.checkoutSessionEvent);
}
registerHandler(event, handler) {
this.handlers[event] = handler.name;
}
async configure(config) {
if (config.webhookSecret) {
this._webhookSecret = config.webhookSecret;
return;
}
/** @type {import('stripe').Stripe.WebhookEndpointCreateParams.EnabledEvent[]} */
const events = [
'checkout.session.completed',
'customer.subscription.deleted',
'customer.subscription.updated',
'customer.subscription.created',
'invoice.payment_succeeded'
];
const setupWebhook = async (id, secret, opts = {}) => {
if (!id || !secret || opts.forceCreate) {
if (id && !opts.skipDelete) {
try {
await this._stripeAPIService.deleteWebhookEndpoint(id);
} catch (err) {
// Continue
}
}
const webhook = await this._stripeAPIService.createWebhookEndpoint(
config.webhookHandlerUrl,
events
);
return {
id: webhook.id,
secret: webhook.secret
};
} else {
try {
await this._stripeAPIService.updateWebhookEndpoint(
id,
config.webhookHandlerUrl,
events
);
return {
id,
secret
};
} catch (err) {
if (err.code === 'resource_missing') {
return setupWebhook(id, secret, {skipDelete: true, forceCreate: true});
}
return setupWebhook(id, secret, {skipDelete: false, forceCreate: true});
}
}
};
const webhook = await setupWebhook(config.webhook.id, config.webhook.secret);
await this._StripeWebhook.upsert({
webhook_id: webhook.id,
secret: webhook.secret
}, {webhook_id: webhook.id});
this._webhookSecret = webhook.secret;
}
/**
* @param {string} id - WebhookEndpoint Stripe ID
*
* @returns {Promise<boolean>}
*/
async removeWebhook(id) {
try {
await this._stripeAPIService.deleteWebhookEndpoint(id);
return true;
} catch (err) {
return false;
}
}
/**
* @param {string} body
* @param {string} signature
* @returns {import('stripe').Stripe.Event}
*/
parseWebhook(body, signature) {
return this._stripeAPIService.parseWebhook(body, signature, this._webhookSecret);
}
/**
* @param {import('stripe').Stripe.Event} event
*
* @returns {Promise<void>}
*/
async handleWebhook(event) {
if (!this.handlers[event.type]) {
return;
}
try {
await this[this.handlers[event.type]](event.data.object);
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
throw err;
}
throw new errors.ConflictError({
err
});
}
}
async subscriptionEvent(subscription) {
const subscriptionPriceData = _.get(subscription, 'items.data');
if (!subscriptionPriceData || subscriptionPriceData.length !== 1) {
throw new errors.BadRequestError({
message: 'Subscription should have exactly 1 price item'
});
}
const member = await this._memberRepository.get({
customer_id: subscription.customer
});
if (member) {
await this._memberRepository.linkSubscription({
id: member.id,
subscription
});
}
}
/**
* @param {import('stripe').Stripe.Invoice} invoice
*
* @returns {Promise<void>}
*/
async invoiceEvent(invoice) {
if (!invoice.subscription) {
return;
}
const subscription = await this._stripeAPIService.getSubscription(invoice.subscription, {
expand: ['default_payment_method']
});
const member = await this._memberRepository.get({
customer_id: subscription.customer
});
if (member) {
if (invoice.paid && invoice.amount_paid !== 0) {
await this._eventRepository.registerPayment({
member_id: member.id,
currency: invoice.currency,
amount: invoice.amount_paid
});
}
} else {
// Subscription has more than one plan - meaning it is not one created by us - ignore.
if (!subscription.plan) {
return;
}
// Subscription is for a different product - ignore.
const product = await this._productRepository.get({
stripe_product_id: subscription.plan.product
});
if (!product) {
return;
}
// Could not find the member, which we need in order to insert an payment event.
throw new errors.NotFoundError({
message: `No member found for customer ${subscription.customer}`
});
}
}
async checkoutSessionEvent(session) {
if (session.mode === 'setup') {
const setupIntent = await this._stripeAPIService.getSetupIntent(session.setup_intent);
const member = await this._memberRepository.get({
customer_id: setupIntent.metadata.customer_id
});
await this._stripeAPIService.attachPaymentMethodToCustomer(
setupIntent.metadata.customer_id,
setupIntent.payment_method
);
if (setupIntent.metadata.subscription_id) {
const updatedSubscription = await this._stripeAPIService.updateSubscriptionDefaultPaymentMethod(
setupIntent.metadata.subscription_id,
setupIntent.payment_method
);
await this._memberRepository.linkSubscription({
id: member.id,
subscription: updatedSubscription
});
return;
}
const subscriptions = await member.related('stripeSubscriptions').fetch();
const activeSubscriptions = subscriptions.models.filter((subscription) => {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.get('status'));
});
for (const subscription of activeSubscriptions) {
if (subscription.get('customer_id') === setupIntent.metadata.customer_id) {
const updatedSubscription = await this._stripeAPIService.updateSubscriptionDefaultPaymentMethod(
subscription.get('subscription_id'),
setupIntent.payment_method
);
await this._memberRepository.linkSubscription({
id: member.id,
subscription: updatedSubscription
});
}
}
}
if (session.mode === 'subscription') {
const customer = await this._stripeAPIService.getCustomer(session.customer, {
expand: ['subscriptions.data.default_payment_method']
});
let member = await this._memberRepository.get({
email: customer.email
});
const checkoutType = _.get(session, 'metadata.checkoutType');
if (!member) {
const metadataName = _.get(session, 'metadata.name');
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
const name = metadataName || payerName || null;
member = await this._memberRepository.create({email: customer.email, name});
} else {
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
if (payerName && !member.get('name')) {
await this._memberRepository.update({name: payerName}, {id: member.get('id')});
}
}
await this._memberRepository.upsertCustomer({
customer_id: customer.id,
member_id: member.id,
name: customer.name,
email: customer.email
});
for (const subscription of customer.subscriptions.data) {
await this._memberRepository.linkSubscription({
id: member.id,
subscription
});
}
const subscription = await this._memberRepository.getSubscriptionByStripeID(session.subscription);
const event = SubscriptionCreatedEvent.create({
memberId: member.id,
subscriptionId: subscription.id,
offerId: session.metadata.offer || null
});
DomainEvents.dispatch(event);
if (checkoutType !== 'upgrade') {
this.sendSignupEmail(customer.email);
}
}
}
};

View file

@ -1,118 +0,0 @@
const {describe, it} = require('mocha');
const should = require('should');
const sinon = require('sinon');
const StripeAPIService = require('@tryghost/members-stripe-service');
const StripeWebhookService = require('../../../../lib/services/stripe-webhook');
const ProductRepository = require('../../../../lib/repositories/product');
const MemberRepository = require('../../../../lib/repositories/member');
function mock(Class) {
return sinon.stub(Object.create(Class.prototype));
}
describe('StripeWebhookService', function () {
describe('invoice.payment_succeeded webhooks', function () {
it('Should throw a 404 error when a member is not found for a valid Ghost Members invoice', async function () {
const stripeWebhookService = new StripeWebhookService({
stripeAPIService: mock(StripeAPIService),
productRepository: mock(ProductRepository),
memberRepository: mock(MemberRepository)
});
stripeWebhookService._stripeAPIService.getSubscription.resolves({
customer: 'customer_id',
plan: {
product: 'product_id'
}
});
stripeWebhookService._memberRepository.get.resolves(null);
stripeWebhookService._productRepository.get.resolves({
id: 'product_id'
});
try {
await stripeWebhookService.invoiceEvent({
subscription: 'sub_id'
});
should.fail();
} catch (err) {
should.equal(err.statusCode, 404);
}
});
});
describe('customer.subscription.updated webhooks', function () {
it('Should throw a 400 error when a subscription has multiple prices', async function () {
const stripeWebhookService = new StripeWebhookService({
stripeAPIService: mock(StripeAPIService),
productRepository: mock(ProductRepository),
memberRepository: mock(MemberRepository)
});
stripeWebhookService._stripeAPIService.getSubscription.resolves({
customer: 'customer_id',
plan: {
product: 'product_id'
}
});
stripeWebhookService._memberRepository.get.resolves(null);
stripeWebhookService._productRepository.get.resolves({
id: 'product_id'
});
try {
await stripeWebhookService.subscriptionEvent({
items: {
data: [
{
id: 'si_1',
price: {}
},
{
id: 'si_2',
price: {}
}
]
}
});
should.fail();
} catch (err) {
should.equal(err.statusCode, 400);
}
});
it('Should throw a 400 error when a subscription has 0 prices', async function () {
const stripeWebhookService = new StripeWebhookService({
stripeAPIService: mock(StripeAPIService),
productRepository: mock(ProductRepository),
memberRepository: mock(MemberRepository)
});
stripeWebhookService._stripeAPIService.getSubscription.resolves({
customer: 'customer_id',
plan: {
product: 'product_id'
}
});
stripeWebhookService._memberRepository.get.resolves(null);
stripeWebhookService._productRepository.get.resolves({
id: 'product_id'
});
try {
await stripeWebhookService.subscriptionEvent({
items: {
data: []
}
});
should.fail();
} catch (err) {
should.equal(err.statusCode, 400);
}
});
});
});

View file

@ -1,9 +1,11 @@
const WebhookManager = require('./WebhookManager');
const StripeAPI = require('./StripeAPI');
const StripeMigrations = require('./Migrations');
const WebhookController = require('./WebhookController');
module.exports = class StripeService {
constructor({
membersService,
StripeWebhook,
models
}) {
@ -16,11 +18,35 @@ module.exports = class StripeService {
models,
api
});
const webhookController = new WebhookController({
webhookManager,
api,
get memberRepository(){
return membersService.api.members;
},
get productRepository() {
return membersService.api.productRepository;
},
get eventRepository() {
return membersService.api.events;
},
sendSignupEmail(email){
return membersService.sendMagicLink({
email,
requestedType: 'paid-signup',
options: {
forceEmailType: true
},
tokenData: {}
});
}
});
this.models = models;
this.api = api;
this.webhookManager = webhookManager;
this.migrations = migrations;
this.webhookController = webhookController;
}
async connect() {
@ -47,13 +73,10 @@ module.exports = class StripeService {
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');
}
};

View file

@ -0,0 +1,272 @@
const _ = require('lodash');
const logging = require('@tryghost/logging');
const errors = require('@tryghost/errors');
const DomainEvents = require('@tryghost/domain-events');
const {SubscriptionCreatedEvent} = require('@tryghost/member-events');
module.exports = class WebhookController {
/**
* @param {object} deps
* @param {import('./WebhookManager')} deps.webhookManager
* @param {any} deps.deps.memberRepository
*/
constructor(deps) {
this.deps = deps;
this.webhookManager = deps.webhookManager;
this.api = deps.api;
this.sendSignupEmail = deps.sendSignupEmail;
this.handlers = {
'customer.subscription.deleted': this.subscriptionEvent,
'customer.subscription.updated': this.subscriptionEvent,
'customer.subscription.created': this.subscriptionEvent,
'invoice.payment_succeeded': this.invoiceEvent,
'checkout.session.completed': this.checkoutSessionEvent
};
}
async handle(req, res) {
// if (!apiService.configured) {
// logging.error(`Stripe not configured, not handling webhook`);
// res.writeHead(400);
// return res.end();
// }
if (!req.body || !req.headers['stripe-signature']) {
res.writeHead(400);
return res.end();
}
let event;
try {
event = this.webhookManager.parseWebhook(req.body, req.headers['stripe-signature']);
} catch (err) {
logging.error(err);
res.writeHead(401);
return res.end();
}
logging.info(`Handling webhook ${event.type}`);
try {
await this.handleEvent(event);
res.writeHead(200);
res.end();
} catch (err) {
logging.error(`Error handling webhook ${event.type}`, err);
res.writeHead(err.statusCode || 500);
res.end();
}
}
/**
* @private
*/
async handleEvent(event) {
if (!this.handlers[event.type]) {
return;
}
await this.handlers[event.type].call(this, event.data.object);
}
/**
* @private
*/
async subscriptionEvent(subscription) {
const subscriptionPriceData = _.get(subscription, 'items.data');
if (!subscriptionPriceData || subscriptionPriceData.length !== 1) {
throw new errors.BadRequestError({
message: 'Subscription should have exactly 1 price item'
});
}
const member = await this.deps.memberRepository.get({
customer_id: subscription.customer
});
if (member) {
try {
await this.deps.memberRepository.linkSubscription({
id: member.id,
subscription
});
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
throw err;
}
throw new errors.ConflictError({
err
});
}
}
}
/**
* @private
*/
async invoiceEvent(invoice) {
if (!invoice.subscription) {
return;
}
const subscription = await this.api.getSubscription(invoice.subscription, {
expand: ['default_payment_method']
});
const member = await this.deps.memberRepository.get({
customer_id: subscription.customer
});
if (member) {
if (invoice.paid && invoice.amount_paid !== 0) {
await this.deps.eventRepository.registerPayment({
member_id: member.id,
currency: invoice.currency,
amount: invoice.amount_paid
});
}
} else {
// Subscription has more than one plan - meaning it is not one created by us - ignore.
if (!subscription.plan) {
return;
}
// Subscription is for a different product - ignore.
const product = await this.deps.productRepository.get({
stripe_product_id: subscription.plan.product
});
if (!product) {
return;
}
// Could not find the member, which we need in order to insert an payment event.
throw new errors.NotFoundError({
message: `No member found for customer ${subscription.customer}`
});
}
}
/**
* @private
*/
async checkoutSessionEvent(session) {
if (session.mode === 'setup') {
const setupIntent = await this.api.getSetupIntent(session.setup_intent);
const member = await this.deps.memberRepository.get({
customer_id: setupIntent.metadata.customer_id
});
await this.api.attachPaymentMethodToCustomer(
setupIntent.metadata.customer_id,
setupIntent.payment_method
);
if (setupIntent.metadata.subscription_id) {
const updatedSubscription = await this.api.updateSubscriptionDefaultPaymentMethod(
setupIntent.metadata.subscription_id,
setupIntent.payment_method
);
try {
await this.deps.memberRepository.linkSubscription({
id: member.id,
subscription: updatedSubscription
});
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
throw err;
}
throw new errors.ConflictError({
err
});
}
return;
}
const subscriptions = await member.related('stripeSubscriptions').fetch();
const activeSubscriptions = subscriptions.models.filter((subscription) => {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.get('status'));
});
for (const subscription of activeSubscriptions) {
if (subscription.get('customer_id') === setupIntent.metadata.customer_id) {
const updatedSubscription = await this.api.updateSubscriptionDefaultPaymentMethod(
subscription.get('subscription_id'),
setupIntent.payment_method
);
try {
await this.deps.memberRepository.linkSubscription({
id: member.id,
subscription: updatedSubscription
});
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
throw err;
}
throw new errors.ConflictError({
err
});
}
}
}
}
if (session.mode === 'subscription') {
const customer = await this.api.getCustomer(session.customer, {
expand: ['subscriptions.data.default_payment_method']
});
let member = await this.deps.memberRepository.get({
email: customer.email
});
const checkoutType = _.get(session, 'metadata.checkoutType');
if (!member) {
const metadataName = _.get(session, 'metadata.name');
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
const name = metadataName || payerName || null;
member = await this.deps.memberRepository.create({email: customer.email, name});
} else {
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
if (payerName && !member.get('name')) {
await this.deps.memberRepository.update({name: payerName}, {id: member.get('id')});
}
}
await this.deps.memberRepository.upsertCustomer({
customer_id: customer.id,
member_id: member.id,
name: customer.name,
email: customer.email
});
for (const subscription of customer.subscriptions.data) {
try {
await this.deps.memberRepository.linkSubscription({
id: member.id,
subscription
});
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
throw err;
}
throw new errors.ConflictError({
err
});
}
}
const subscription = await this.deps.memberRepository.getSubscriptionByStripeID(session.subscription);
const event = SubscriptionCreatedEvent.create({
memberId: member.id,
subscriptionId: subscription.id,
offerId: session.metadata.offer || null
});
DomainEvents.dispatch(event);
if (checkoutType !== 'upgrade') {
this.sendSignupEmail(customer.email);
}
}
}
};

View file

@ -27,7 +27,7 @@
},
"dependencies": {
"@tryghost/debug": "^0.1.4",
"@tryghost/errors": "^0.2.13",
"@tryghost/errors": "1.2.0",
"leaky-bucket": "^2.2.0",
"stripe": "^8.174.0"
}