From bf895e6e99e6fe8e90f0d582fa6e38d5c4e37f5d Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 9 Jul 2024 16:05:26 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20offer=20redemptions=20fo?= =?UTF-8?q?r=20free=20members=20redeeming=20an=20offer=20(#20571)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/tryghost/issue/ENG-1251/support-escalation-re-offers-not-tracking - Offer Redemptions were not being persisted in the database for existing free members who upgrade to a paid plan with an offer, which resulted in inaccurate offer redemption counts. This made it difficult to assess the performance of an offer. - Previously, Ghost recorded an offer redemption in the DB in response to the `SubscriptionCreatedEvent`, under the assumption that the offer details would be included in this event. This assumption was valid for brand new members starting a subscription with an offer, but not for existing free members upgrading to a paid plan with an offer. - For existing free members, the subscription is first stored in Ghost in response to the `customer.subscription.created` Stripe webhook. At this point, the offer/discount is not attached to the subscription, so the `SubscriptionCreatedEvent` triggers without the offer information, and the offer redemption is not recorded. After the `checkout.session.completed` webhook is received (which _does_ include the offer details), the subscription is updated in Ghost, but the Offer Redemption is not stored. - For brand new members, the `customer.subscription.created` webhook no-ops, because the member and Stripe Customer don't exist yet. Therefore, the subscription is first created in Ghost in response to the `checkout.session.completed` webhook, which _does_ include the offer information, so the offer information is included in the `SubscriptionCreatedEvent` and the offer redemption is recorded as expected. - This change adds a new `OfferRedemptionEvent`, which triggers either: (1) when a new subscription is created with an offer (as in the case of a brand new member), or (2) when an existing subscription is first updated to include an offer (as in the case of an existing free member upgrading with an offer). The Offer Redemption is then persisted in the DB in response to the `OfferRedemptionEvent` rather than the `SubscriptionCreatedEvent`. --- ghost/member-events/index.js | 1 + .../member-events/lib/OfferRedemptionEvent.js | 28 ++++++ .../lib/repositories/MemberRepository.js | 44 ++++++++-- .../test/unit/lib/repositories/member.test.js | 86 ++++++++++++++++--- 4 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 ghost/member-events/lib/OfferRedemptionEvent.js 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; }