mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-26 04:08:01 +03:00
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:
parent
2fbaa7b9bc
commit
054833992e
@ -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 () {
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user