Wired events for triggering email alerts for subscription creation/cancellation

refs https://github.com/TryGhost/Team/issues/1865

- refactors subscription creation/cancellation to dispatch proper events which are used for email alerts
- cleanup
This commit is contained in:
Rishabh 2022-09-09 20:00:05 +05:30 committed by Rishabh Garg
parent 2fbaa7b9bc
commit 054833992e
4 changed files with 77 additions and 136 deletions

View File

@ -43,6 +43,12 @@ async function assertSubscription(subscriptionId, asserts) {
models.Base.Model.prototype.serialize.call(subscription).should.match(asserts);
}
async function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
describe('Members API', function () {
// @todo: Test what happens when a complimentary subscription ends (should create comped -> free event)
// @todo: Test what happens when a complimentary subscription starts a paid subscription
@ -657,11 +663,6 @@ describe('Members API', function () {
assert.equal(member.status, 'paid', 'The member should be "paid"');
assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription');
mockManager.assert.sentEmail({
subject: '💸 Paid subscription started: checkout-webhook-test@email.com',
to: 'jbloggs@example.com'
});
mockManager.assert.sentEmail({
subject: '🙌 Thank you for signing up to Ghost!',
to: 'checkout-webhook-test@email.com'
@ -706,6 +707,14 @@ describe('Members API', function () {
}
]
});
// Wait for the dispatched events (because this happens async)
await sleep(250);
mockManager.assert.sentEmail({
subject: '💸 Paid subscription started: checkout-webhook-test@email.com',
to: 'jbloggs@example.com'
});
});
it('Will create a member with default newsletter subscriptions', async function () {

View File

@ -778,6 +778,7 @@ module.exports = class MemberRepository {
* @param {String} data.id - member ID
* @param {Object} data.subscription
* @param {String} data.offerId
* @param {import('@tryghost/member-attribution/lib/history').Attribution} data.attribution
* @param {*} options
* @returns
*/
@ -994,14 +995,23 @@ module.exports = class MemberRepository {
const context = options?.context || {};
const source = this._resolveContextSource(context);
// Notify paid member subscription start
if (this._labsService.isSet('emailAlerts') && ['member', 'api'].includes(source)) {
await this.staffService.notifyPaidSubscriptionStart({
member: member.toJSON(),
offer: offer ? this._offerRepository.toJSON(offer) : null,
tier: ghostProduct?.toJSON(),
subscription: subscriptionData
}, {transacting: options.transacting, forUpdate: true});
const event = SubscriptionCreatedEvent.create({
source,
tierId: ghostProduct?.get('id'),
memberId: member.id,
subscriptionId: model.get('id'),
offerId: data.offerId,
attribution: data.attribution
});
if (options?.transacting) {
// Only dispatch the event after the transaction has finished
// Because else the offer won't be committed to the database yet
options.transacting.executionPromise.then(() => {
DomainEvents.dispatch(event);
});
} else {
DomainEvents.dispatch(event);
}
}
@ -1258,22 +1268,6 @@ module.exports = class MemberRepository {
member_id: member.id,
from_plan: subscriptionModel.get('plan_id')
}, sharedOptions);
if (this._labsService.isSet('emailAlerts')) {
const subscriptionPriceData = _.get(updatedSubscription, 'items.data[0].price');
let ghostProduct;
try {
ghostProduct = await this._productRepository.get({stripe_product_id: subscriptionPriceData.product}, {...sharedOptions, forUpdate: true});
} catch (e) {
ghostProduct = null;
}
await this.staffService.notifyPaidSubscriptionCancel({
member: member.toJSON(),
subscription: updatedSubscription,
cancellationReason: data.subscription.cancellationReason,
tier: ghostProduct?.toJSON()
});
}
} else {
updatedSubscription = await this._stripeAPIService.continueSubscriptionAtPeriodEnd(
data.subscription.subscription_id
@ -1286,6 +1280,42 @@ module.exports = class MemberRepository {
id: member.id,
subscription: updatedSubscription
}, options);
// Dispatch cancellation event
if (data.subscription.cancel_at_period_end) {
const stripeProductId = _.get(updatedSubscription, 'items.data[0].price.product');
let ghostProduct;
try {
ghostProduct = await this._productRepository.get(
{stripe_product_id: stripeProductId},
{...sharedOptions, forUpdate: true}
);
} catch (e) {
ghostProduct = null;
}
const context = options?.context || {};
const source = this._resolveContextSource(context);
const cancellationTimestamp = updatedSubscription.canceled_at
? new Date(updatedSubscription.canceled_at * 1000)
: new Date();
const cancelEventData = {
source,
memberId: member.id,
subscriptionId: subscriptionModel.get('id'),
tierId: ghostProduct?.get('id')
};
if (options?.transacting) {
// Only dispatch the event after the transaction has finished
// Because else the offer won't be committed to the database yet
options.transacting.executionPromise.then(() => {
DomainEvents.dispatch(SubscriptionCancelledEvent.create(cancelEventData, cancellationTimestamp));
});
} else {
DomainEvents.dispatch(SubscriptionCancelledEvent.create(cancelEventData, cancellationTimestamp));
}
}
}
}

View File

@ -1,6 +1,8 @@
const assert = require('assert');
const sinon = require('sinon');
const DomainEvents = require('@tryghost/domain-events');
const MemberRepository = require('../../../../lib/repositories/member');
const {SubscriptionCreatedEvent} = require('@tryghost/member-events');
describe('MemberRepository', function () {
describe('#isComplimentarySubscription', function () {
@ -53,7 +55,6 @@ describe('MemberRepository', function () {
describe('linkSubscription', function (){
let Member;
let staffService;
let notifySpy;
let MemberPaidSubscriptionEvent;
let StripeCustomerSubscription;
@ -112,9 +113,6 @@ describe('MemberRepository', function () {
_previousAttributes: {}
})
};
staffService = {
notifyPaidSubscriptionStart: notifySpy
};
MemberPaidSubscriptionEvent = {
add: sinon.stub().resolves()
};
@ -145,13 +143,12 @@ describe('MemberRepository', function () {
};
});
it('triggers email alert for member context', async function (){
it('dispatches paid subscription event', async function (){
const repo = new MemberRepository({
stripeAPIService,
StripeCustomerSubscription,
MemberPaidSubscriptionEvent,
MemberProductEvent,
staffService,
productRepository,
labsService,
Member
@ -159,107 +156,20 @@ describe('MemberRepository', function () {
sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null);
DomainEvents.subscribe(SubscriptionCreatedEvent, notifySpy);
await repo.linkSubscription({
subscription: subscriptionData
}, {
transacting: true,
transacting: {
executionPromise: Promise.resolve()
},
context: {}
});
notifySpy.calledOnce.should.be.true();
});
it('triggers email alert for api context', async function (){
const repo = new MemberRepository({
stripeAPIService,
StripeCustomerSubscription,
MemberPaidSubscriptionEvent,
MemberProductEvent,
staffService,
productRepository,
labsService,
Member
});
sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null);
await repo.linkSubscription({
subscription: subscriptionData
}, {
transacting: true,
context: {api_key: 'abc'}
});
notifySpy.calledOnce.should.be.true();
});
it('does not trigger email alert for importer context', async function (){
const repo = new MemberRepository({
stripeAPIService,
StripeCustomerSubscription,
MemberPaidSubscriptionEvent,
MemberProductEvent,
staffService,
productRepository,
labsService,
Member
});
sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null);
await repo.linkSubscription({
subscription: subscriptionData
}, {
transacting: true,
context: {importer: true}
});
notifySpy.calledOnce.should.be.false();
});
it('does not trigger email alert for admin context', async function (){
const repo = new MemberRepository({
stripeAPIService,
StripeCustomerSubscription,
MemberPaidSubscriptionEvent,
MemberProductEvent,
staffService,
productRepository,
labsService,
Member
});
sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null);
await repo.linkSubscription({
subscription: subscriptionData
}, {
transacting: true,
context: {user: {}}
});
notifySpy.calledOnce.should.be.false();
});
it('does not trigger email alert for internal context', async function (){
const repo = new MemberRepository({
stripeAPIService,
StripeCustomerSubscription,
MemberPaidSubscriptionEvent,
MemberProductEvent,
staffService,
productRepository,
labsService,
Member
});
sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null);
await repo.linkSubscription({
subscription: subscriptionData
}, {
transacting: true,
context: {internal: true}
});
notifySpy.calledOnce.should.be.false();
});
afterEach(function () {
sinon.restore();
});

View File

@ -4,7 +4,6 @@ const DomainEvents = require('@tryghost/domain-events');
const OfferCodeChangeEvent = require('../domain/events/OfferCodeChange');
const OfferCreatedEvent = require('../domain/events/OfferCreated');
const Offer = require('../domain/models/Offer');
const OfferMapper = require('./OfferMapper');
const OfferStatus = require('../domain/models/OfferStatus');
const statusTransformer = mapKeyValues({
@ -123,13 +122,6 @@ class OfferRepository {
}
}, null);
}
/**
* @param {Offer} offer
* @returns {OfferMapper.OfferDTO}
*/
toJSON(offer) {
return OfferMapper.toDTO(offer);
}
/**
* @param {string} id