diff --git a/ghost/member-events/index.js b/ghost/member-events/index.js index 4f6fdb94ed..6995e32620 100644 --- a/ghost/member-events/index.js +++ b/ghost/member-events/index.js @@ -11,5 +11,6 @@ module.exports = { SubscriptionCreatedEvent: require('./lib/SubscriptionCreatedEvent'), SubscriptionActivatedEvent: require('./lib/SubscriptionActivatedEvent'), SubscriptionCancelledEvent: require('./lib/SubscriptionCancelledEvent'), + OfferRedemptionEvent: require('./lib/OfferRedemptionEvent'), MemberLinkClickEvent: require('./lib/MemberLinkClickEvent') }; diff --git a/ghost/member-events/lib/OfferRedemptionEvent.js b/ghost/member-events/lib/OfferRedemptionEvent.js new file mode 100644 index 0000000000..95ab8a2b86 --- /dev/null +++ b/ghost/member-events/lib/OfferRedemptionEvent.js @@ -0,0 +1,28 @@ +/** + * @typedef {object} OfferRedemptionEventData + * @prop {string} memberId + * @prop {string} offerId + * @prop {string} subscriptionId + */ + +/** + * Server-side event firing on page views (page, post, tags...) + */ +module.exports = class OfferRedemptionEvent { + /** + * @param {OfferRedemptionEventData} data + * @param {Date} timestamp + */ + constructor(data, timestamp) { + this.data = data; + this.timestamp = timestamp; + } + + /** + * @param {OfferRedemptionEventData} data + * @param {Date} [timestamp] + */ + static create(data, timestamp) { + return new OfferRedemptionEvent(data, timestamp || new Date); + } +}; diff --git a/ghost/members-api/lib/repositories/MemberRepository.js b/ghost/members-api/lib/repositories/MemberRepository.js index 63f6e7de1d..4d0595a855 100644 --- a/ghost/members-api/lib/repositories/MemberRepository.js +++ b/ghost/members-api/lib/repositories/MemberRepository.js @@ -3,7 +3,7 @@ const errors = require('@tryghost/errors'); const logging = require('@tryghost/logging'); const tpl = require('@tryghost/tpl'); const DomainEvents = require('@tryghost/domain-events'); -const {SubscriptionActivatedEvent, MemberCreatedEvent, SubscriptionCreatedEvent, MemberSubscribeEvent, SubscriptionCancelledEvent} = require('@tryghost/member-events'); +const {SubscriptionActivatedEvent, MemberCreatedEvent, SubscriptionCreatedEvent, MemberSubscribeEvent, SubscriptionCancelledEvent, OfferRedemptionEvent} = require('@tryghost/member-events'); const ObjectId = require('bson-objectid').default; const {NotFoundError} = require('@tryghost/errors'); const validator = require('@tryghost/validator'); @@ -77,6 +77,7 @@ module.exports = class MemberRepository { this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent; this._MemberStatusEvent = MemberStatusEvent; this._MemberProductEvent = MemberProductEvent; + this._OfferRedemption = OfferRedemption; this._StripeCustomer = StripeCustomer; this._StripeCustomerSubscription = StripeCustomerSubscription; this._stripeAPIService = stripeAPIService; @@ -86,16 +87,26 @@ module.exports = class MemberRepository { this._newslettersService = newslettersService; this._labsService = labsService; - DomainEvents.subscribe(SubscriptionCreatedEvent, async function (event) { + DomainEvents.subscribe(OfferRedemptionEvent, async function (event) { if (!event.data.offerId) { return; } - await OfferRedemption.add({ + // To be extra safe, check if the redemption already exists before adding it + const existingRedemption = await OfferRedemption.findOne({ member_id: event.data.memberId, subscription_id: event.data.subscriptionId, offer_id: event.data.offerId }); + + if (!existingRedemption) { + await OfferRedemption.add({ + member_id: event.data.memberId, + subscription_id: event.data.subscriptionId, + offer_id: event.data.offerId, + created_at: event.timestamp || Date.now() + }); + } }); } @@ -1062,6 +1073,18 @@ module.exports = class MemberRepository { id: model.id }); + // CASE: Existing free member subscribes to a paid tier with an offer + // Stripe doesn't send the discount/offer info in the subscription.created event + // So we need to record the offer redemption event upon updating the subscription here + if (model.get('offer_id') === null && subscriptionData.offer_id) { + const event = OfferRedemptionEvent.create({ + memberId: member.id, + offerId: subscriptionData.offer_id, + subscriptionId: updated.id + }, updated.get('created_at')); + this.dispatchEvent(event, options); + } + if (model.get('mrr') !== updated.get('mrr') || model.get('plan_id') !== updated.get('plan_id') || model.get('status') !== updated.get('status') || model.get('cancel_at_period_end') !== updated.get('cancel_at_period_end')) { const originalMrrDelta = model.get('mrr'); const updatedMrrDelta = updated.get('mrr'); @@ -1129,7 +1152,7 @@ module.exports = class MemberRepository { const context = options?.context || {}; const source = this._resolveContextSource(context); - const event = SubscriptionCreatedEvent.create({ + const subscriptionCreatedEvent = SubscriptionCreatedEvent.create({ source, tierId: ghostProduct?.get('id'), memberId: member.id, @@ -1139,11 +1162,16 @@ module.exports = class MemberRepository { batchId: options.batch_id }); - if (offerId) { - logging.info(`Dispatching ${event.constructor.name} for member ${member.id} with offer ${offerId}`); - } + this.dispatchEvent(subscriptionCreatedEvent, options); - this.dispatchEvent(event, options); + if (offerId) { + const offerRedemptionEvent = OfferRedemptionEvent.create({ + memberId: member.id, + offerId: offerId, + subscriptionId: subscriptionModel.get('id') + }); + this.dispatchEvent(offerRedemptionEvent, options); + } if (getStatus(subscriptionModel) === 'active') { const activatedEvent = SubscriptionActivatedEvent.create({ diff --git a/ghost/members-api/test/unit/lib/repositories/member.test.js b/ghost/members-api/test/unit/lib/repositories/member.test.js index 9aa4fd386a..fc6b795091 100644 --- a/ghost/members-api/test/unit/lib/repositories/member.test.js +++ b/ghost/members-api/test/unit/lib/repositories/member.test.js @@ -2,10 +2,11 @@ const assert = require('assert/strict'); const sinon = require('sinon'); const DomainEvents = require('@tryghost/domain-events'); const MemberRepository = require('../../../../lib/repositories/MemberRepository'); -const {SubscriptionCreatedEvent} = require('@tryghost/member-events'); +const {SubscriptionCreatedEvent, OfferRedemptionEvent} = require('@tryghost/member-events'); const mockOfferRedemption = { - add: sinon.stub() + add: sinon.stub(), + findOne: sinon.stub() }; describe('MemberRepository', function () { @@ -238,14 +239,16 @@ describe('MemberRepository', function () { let offerRepository; let labsService; let subscriptionData; - let notifySpy; + let subscriptionCreatedNotifySpy; + let offerRedemptionNotifySpy; afterEach(function () { sinon.restore(); }); beforeEach(async function () { - notifySpy = sinon.spy(); + subscriptionCreatedNotifySpy = sinon.spy(); + offerRedemptionNotifySpy = sinon.spy(); subscriptionData = { id: 'sub_123', @@ -283,7 +286,8 @@ describe('MemberRepository', function () { }), toJSON: sinon.stub().returns(relation === 'products' ? [] : {}), fetch: sinon.stub().resolves({ - toJSON: sinon.stub().returns(relation === 'products' ? [] : {}) + toJSON: sinon.stub().returns(relation === 'products' ? [] : {}), + models: [] }) }; }, @@ -300,6 +304,9 @@ describe('MemberRepository', function () { StripeCustomerSubscription = { add: sinon.stub().resolves({ get: sinon.stub().returns() + }), + edit: sinon.stub().resolves({ + get: sinon.stub().returns() }) }; MemberProductEvent = { @@ -344,7 +351,8 @@ describe('MemberRepository', function () { sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); - DomainEvents.subscribe(SubscriptionCreatedEvent, notifySpy); + DomainEvents.subscribe(SubscriptionCreatedEvent, subscriptionCreatedNotifySpy); + DomainEvents.subscribe(OfferRedemptionEvent, offerRedemptionNotifySpy); await repo.linkSubscription({ subscription: subscriptionData @@ -355,10 +363,12 @@ describe('MemberRepository', function () { context: {} }); - notifySpy.calledOnce.should.be.true(); + subscriptionCreatedNotifySpy.calledOnce.should.be.true(); + offerRedemptionNotifySpy.called.should.be.false(); }); - it('attaches offer information to subscription event', async function (){ + it('dispatches the offer redemption event for a new member starting a subscription', async function (){ + // When a new member starts a paid subscription, the subscription is created with the offer ID const repo = new MemberRepository({ stripeAPIService, StripeCustomerSubscription, @@ -371,9 +381,11 @@ describe('MemberRepository', function () { OfferRedemption: mockOfferRedemption }); + // No existing subscription sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); - DomainEvents.subscribe(SubscriptionCreatedEvent, notifySpy); + DomainEvents.subscribe(SubscriptionCreatedEvent, subscriptionCreatedNotifySpy); + DomainEvents.subscribe(OfferRedemptionEvent, offerRedemptionNotifySpy); await repo.linkSubscription({ id: 'member_id_123', @@ -386,8 +398,60 @@ describe('MemberRepository', function () { context: {} }); - notifySpy.calledOnce.should.be.true(); - notifySpy.calledWith(sinon.match((event) => { + subscriptionCreatedNotifySpy.calledOnce.should.be.true(); + subscriptionCreatedNotifySpy.calledWith(sinon.match((event) => { + if (event.data.offerId === 'offer_123') { + return true; + } + return false; + })).should.be.true(); + + offerRedemptionNotifySpy.called.should.be.true(); + offerRedemptionNotifySpy.calledWith(sinon.match((event) => { + if (event.data.offerId === 'offer_123') { + return true; + } + return false; + })).should.be.true(); + }); + + it('dispatches the offer redemption event for an existing member upgrading to a paid subscription', async function (){ + // When an existing free member upgrades to a paid subscription, the subscription is first created _without_ the offer id + // Then it is updated with the offer id after the checkout.completed webhook is received + const repo = new MemberRepository({ + stripeAPIService, + StripeCustomerSubscription, + MemberPaidSubscriptionEvent, + MemberProductEvent, + productRepository, + offerRepository, + labsService, + Member, + OfferRedemption: mockOfferRedemption + }); + + sinon.stub(repo, 'getSubscriptionByStripeID').resolves({ + get: sinon.stub().withArgs('offer_id').returns(null) + }); + + DomainEvents.subscribe(SubscriptionCreatedEvent, subscriptionCreatedNotifySpy); + DomainEvents.subscribe(OfferRedemptionEvent, offerRedemptionNotifySpy); + + await repo.linkSubscription({ + id: 'member_id_123', + subscription: subscriptionData, + offerId: 'offer_123' + }, { + transacting: { + executionPromise: Promise.resolve() + }, + context: {} + }); + + subscriptionCreatedNotifySpy.calledOnce.should.be.false(); + + offerRedemptionNotifySpy.called.should.be.true(); + offerRedemptionNotifySpy.calledWith(sinon.match((event) => { if (event.data.offerId === 'offer_123') { return true; }