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:
parent
94d97d1168
commit
74225779a2
6 changed files with 299 additions and 490 deletions
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
272
ghost/stripe/lib/WebhookController.js
Normal file
272
ghost/stripe/lib/WebhookController.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue