From 4187f0da54d3098819307c252d8b16204ea21033 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 9 Sep 2022 19:53:43 +0530 Subject: [PATCH] Updated staff service to trigger alerts via events refs https://github.com/TryGhost/Team/issues/1865 - refactors staff service to listen to member and subscription events - triggers email alerts based on events instead of directly calling the service - removes staff service dependency for members api --- .../core/core/server/services/members/api.js | 2 - .../core/core/server/services/staff/index.js | 11 +- .../unit/server/services/staff/index.test.js | 235 ++++++++++++++++++ ghost/members-api/lib/MembersAPI.js | 3 - ghost/members-api/lib/repositories/member.js | 3 - ghost/staff-service/lib/emails.js | 37 +-- ghost/staff-service/lib/staff-service.js | 128 ++++++++-- .../staff-service/test/staff-service.test.js | 189 +++++++++++--- 8 files changed, 527 insertions(+), 81 deletions(-) create mode 100644 ghost/core/test/unit/server/services/staff/index.test.js diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 87416e7a9c..93f71ec9dd 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -13,7 +13,6 @@ const SingleUseTokenProvider = require('./SingleUseTokenProvider'); const urlUtils = require('../../../shared/url-utils'); const labsService = require('../../../shared/labs'); const offersService = require('../offers'); -const staffService = require('../staff'); const newslettersService = require('../newsletters'); const memberAttributionService = require('../member-attribution'); @@ -198,7 +197,6 @@ function createApiInstance(config) { }, stripeAPIService: stripeService.api, offersAPI: offersService.api, - staffService: staffService.api, labsService: labsService, newslettersService: newslettersService, memberAttributionService: memberAttributionService.service diff --git a/ghost/core/core/server/services/staff/index.js b/ghost/core/core/server/services/staff/index.js index 27f489bb0c..a845eecb31 100644 --- a/ghost/core/core/server/services/staff/index.js +++ b/ghost/core/core/server/services/staff/index.js @@ -1,5 +1,11 @@ +const DomainEvents = require('@tryghost/domain-events'); class StaffServiceWrapper { init() { + if (this.api) { + // Prevent creating duplicate DomainEvents subscribers + return; + } + const StaffService = require('@tryghost/staff-service'); const logging = require('@tryghost/logging'); @@ -16,8 +22,11 @@ class StaffServiceWrapper { mailer, settingsHelpers, settingsCache, - urlUtils + urlUtils, + DomainEvents }); + + this.api.subscribeEvents(); } } diff --git a/ghost/core/test/unit/server/services/staff/index.test.js b/ghost/core/test/unit/server/services/staff/index.test.js new file mode 100644 index 0000000000..3bb1178658 --- /dev/null +++ b/ghost/core/test/unit/server/services/staff/index.test.js @@ -0,0 +1,235 @@ +const sinon = require('sinon'); + +const staffService = require('../../../../../core/server/services/staff'); + +const DomainEvents = require('@tryghost/domain-events'); +const {mockManager} = require('../../../../utils/e2e-framework'); +const models = require('../../../../../core/server/models'); + +const {SubscriptionCreatedEvent, SubscriptionCancelledEvent, MemberCreatedEvent} = require('@tryghost/member-events'); + +async function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +describe('Staff Service:', function () { + before(function () { + models.init(); + }); + + beforeEach(function () { + mockManager.mockMail(); + sinon.stub(models.User, 'getEmailAlertUsers').resolves([{ + email: 'owner@ghost.org', + slug: 'ghost' + }]); + + sinon.stub(models.Member, 'findOne').resolves({ + toJSON: sinon.stub().returns({ + id: '1', + email: 'jamie@example.com', + name: 'Jamie', + status: 'free', + geolocation: null, + created_at: '2022-08-01T07:30:39.882Z' + }) + }); + + sinon.stub(models.Product, 'findOne').resolves({ + toJSON: sinon.stub().returns({ + id: 'tier-1', + name: 'Tier 1' + }) + }); + + sinon.stub(models.Offer, 'findOne').resolves({ + toJSON: sinon.stub().returns({ + discount_amount: 1000, + duration: 'forever', + discount_type: 'fixed', + name: 'Test offer', + duration_in_months: null + }) + }); + + sinon.stub(models.StripeCustomerSubscription, 'findOne').resolves({ + toJSON: sinon.stub().returns({ + id: 'sub-1', + plan: { + amount: 5000, + currency: 'USD', + interval: 'month' + }, + start_date: new Date('2022-08-01T07:30:39.882Z'), + current_period_end: '2024-08-01T07:30:39.882Z', + cancellation_reason: 'Changed my mind!' + }) + }); + }); + + afterEach(function () { + sinon.restore(); + mockManager.restore(); + }); + + describe('free member created event:', function () { + let eventData = { + memberId: '1' + }; + + it('sends email for member source', async function () { + await staffService.init(); + DomainEvents.dispatch(MemberCreatedEvent.create({ + source: 'member', + ...eventData + })); + + // Wait for the dispatched events (because this happens async) + await sleep(250); + mockManager.assert.sentEmail({ + to: 'owner@ghost.org', + subject: /🥳 Free member signup: Jamie/ + }); + mockManager.assert.sentEmailCount(1); + }); + + it('sends email for api source', async function () { + await staffService.init(); + DomainEvents.dispatch(MemberCreatedEvent.create({ + source: 'api', + ...eventData + })); + + // Wait for the dispatched events (because this happens async) + await sleep(250); + mockManager.assert.sentEmail({ + to: 'owner@ghost.org', + subject: /🥳 Free member signup: Jamie/ + }); + mockManager.assert.sentEmailCount(1); + }); + + it('does not send email for importer source', async function () { + await staffService.init(); + DomainEvents.dispatch(MemberCreatedEvent.create({ + source: 'import', + ...eventData + })); + + // Wait for the dispatched events (because this happens async) + await sleep(250); + mockManager.assert.sentEmailCount(0); + }); + }); + + describe('paid subscription start event:', function () { + let eventData = { + memberId: '1', + tierId: 'tier-1', + subscriptionId: 'sub-1', + offerId: 'offer-1' + }; + + afterEach(function () { + sinon.restore(); + }); + + it('sends email for member source', async function () { + await staffService.init(); + DomainEvents.dispatch(SubscriptionCreatedEvent.create({ + source: 'member', + ...eventData + })); + + // Wait for the dispatched events (because this happens async) + await sleep(250); + mockManager.assert.sentEmail({ + to: 'owner@ghost.org', + subject: /💸 Paid subscription started: Jamie/ + }); + mockManager.assert.sentEmailCount(1); + }); + + it('sends email for api source', async function () { + await staffService.init(); + DomainEvents.dispatch(SubscriptionCreatedEvent.create({ + source: 'api', + ...eventData + })); + + // Wait for the dispatched events (because this happens async) + await sleep(250); + mockManager.assert.sentEmail({ + to: 'owner@ghost.org', + subject: /💸 Paid subscription started: Jamie/ + }); + mockManager.assert.sentEmailCount(1); + }); + + it('does not send email for importer source', async function () { + await staffService.init(); + DomainEvents.dispatch(SubscriptionCreatedEvent.create({ + source: 'import', + ...eventData + })); + + // Wait for the dispatched events (because this happens async) + await sleep(250); + mockManager.assert.sentEmailCount(0); + }); + }); + + describe('paid subscription cancel event:', function () { + let eventData = { + memberId: '1', + tierId: 'tier-1', + subscriptionId: 'sub-1' + }; + + it('sends email for member source', async function () { + await staffService.init(); + DomainEvents.dispatch(SubscriptionCancelledEvent.create({ + source: 'member', + ...eventData + }, new Date())); + + // Wait for the dispatched events (because this happens async) + await sleep(250); + mockManager.assert.sentEmail({ + to: 'owner@ghost.org', + subject: /⚠️ Cancellation: Jamie/ + }); + mockManager.assert.sentEmailCount(1); + }); + + it('sends email for api source', async function () { + await staffService.init(); + DomainEvents.dispatch(SubscriptionCancelledEvent.create({ + source: 'api', + ...eventData + })); + + // Wait for the dispatched events (because this happens async) + await sleep(250); + mockManager.assert.sentEmail({ + to: 'owner@ghost.org', + subject: /⚠️ Cancellation: Jamie/ + }); + mockManager.assert.sentEmailCount(1); + }); + + it('does not send email for importer source', async function () { + await staffService.init(); + DomainEvents.dispatch(SubscriptionCancelledEvent.create({ + source: 'import', + ...eventData + })); + + // Wait for the dispatched events (because this happens async) + await sleep(250); + mockManager.assert.sentEmailCount(0); + }); + }); +}); diff --git a/ghost/members-api/lib/MembersAPI.js b/ghost/members-api/lib/MembersAPI.js index cd6957e108..f84335dce8 100644 --- a/ghost/members-api/lib/MembersAPI.js +++ b/ghost/members-api/lib/MembersAPI.js @@ -61,7 +61,6 @@ module.exports = function MembersAPI({ }, stripeAPIService, offersAPI, - staffService, labsService, newslettersService, memberAttributionService @@ -87,7 +86,6 @@ module.exports = function MembersAPI({ stripeAPIService, tokenService, newslettersService, - staffService, labsService, productRepository, Member, @@ -152,7 +150,6 @@ module.exports = function MembersAPI({ productRepository, StripePrice, tokenService, - staffService, sendEmailWithMagicLink }); diff --git a/ghost/members-api/lib/repositories/member.js b/ghost/members-api/lib/repositories/member.js index b6f93a3e67..a311737a7c 100644 --- a/ghost/members-api/lib/repositories/member.js +++ b/ghost/members-api/lib/repositories/member.js @@ -39,7 +39,6 @@ module.exports = class MemberRepository { * @param {any} deps.OfferRedemption * @param {import('../../services/stripe-api')} deps.stripeAPIService * @param {any} deps.labsService - * @param {any} deps.staffService * @param {any} deps.productRepository * @param {any} deps.offerRepository * @param {ITokenService} deps.tokenService @@ -61,7 +60,6 @@ module.exports = class MemberRepository { productRepository, offerRepository, tokenService, - staffService, newslettersService }) { this._Member = Member; @@ -77,7 +75,6 @@ module.exports = class MemberRepository { this._productRepository = productRepository; this._offerRepository = offerRepository; this.tokenService = tokenService; - this.staffService = staffService; this._newslettersService = newslettersService; this._labsService = labsService; diff --git a/ghost/staff-service/lib/emails.js b/ghost/staff-service/lib/emails.js index cad60b879c..1382114456 100644 --- a/ghost/staff-service/lib/emails.js +++ b/ghost/staff-service/lib/emails.js @@ -1,6 +1,5 @@ const {promises: fs} = require('fs'); const path = require('path'); -const _ = require('lodash'); const moment = require('moment'); class StaffServiceEmails { @@ -55,16 +54,16 @@ class StaffServiceEmails { const subject = `💸 Paid subscription started: ${memberData.name}`; - const amount = this.getAmount(subscription?.plan_amount); - const formattedAmount = this.getFormattedAmount({currency: subscription?.plan_currency, amount}); - const interval = subscription?.plan_interval || ''; + const amount = this.getAmount(subscription?.amount); + const formattedAmount = this.getFormattedAmount({currency: subscription?.currency, amount}); + const interval = subscription?.interval || ''; const tierData = { name: tier?.name || '', details: `${formattedAmount}/${interval}` }; const subscriptionData = { - startedOn: this.getFormattedDate(subscription.start_date) + startedOn: this.getFormattedDate(subscription.startDate) }; let offerData = this.getOfferData(offer); @@ -94,17 +93,17 @@ class StaffServiceEmails { } } - async notifyPaidSubscriptionCanceled({member, cancellationReason, tier, subscription}, options = {}) { + async notifyPaidSubscriptionCanceled({member, tier, subscription}, options = {}) { const users = await this.models.User.getEmailAlertUsers('paid-canceled', options); - const subscriptionPriceData = _.get(subscription, 'items.data[0].price'); + for (const user of users) { const to = user.email; const memberData = this.getMemberData(member); const subject = `⚠️ Cancellation: ${memberData.name}`; - const amount = this.getAmount(subscriptionPriceData?.unit_amount); - const formattedAmount = this.getFormattedAmount({currency: subscriptionPriceData?.currency, amount}); - const interval = subscriptionPriceData?.recurring?.interval; + const amount = this.getAmount(subscription?.amount); + const formattedAmount = this.getFormattedAmount({currency: subscription?.currency, amount}); + const interval = subscription?.interval; const tierDetail = `${formattedAmount}/${interval}`; const tierData = { name: tier?.name || '', @@ -112,9 +111,9 @@ class StaffServiceEmails { }; const subscriptionData = { - expiryAt: this.getFormattedStripeDate(subscription.cancel_at), - canceledAt: this.getFormattedStripeDate(subscription.canceled_at), - cancellationReason: cancellationReason || '' + expiryAt: this.getFormattedDate(subscription.cancelAt), + canceledAt: this.getFormattedDate(subscription.canceledAt), + cancellationReason: subscription.cancellationReason || '' }; const templateData = { @@ -202,16 +201,6 @@ class StaffServiceEmails { return moment(date).format('D MMM YYYY'); } - /** @private */ - getFormattedStripeDate(stripeDate) { - if (!stripeDate) { - return ''; - } - const date = new Date(stripeDate * 1000); - - return this.getFormattedDate(date); - } - /** @private */ getOfferData(offer) { if (offer) { @@ -221,7 +210,7 @@ class StaffServiceEmails { if (offer.duration === 'once') { offDuration = ', first payment'; } else if (offer.duration === 'repeating') { - offDuration = `, first ${offer.duration_in_months} months`; + offDuration = `, first ${offer.durationInMonths} months`; } else if (offer.duration === 'forever') { offDuration = `, forever`; } else if (offer.duration === 'trial') { diff --git a/ghost/staff-service/lib/staff-service.js b/ghost/staff-service/lib/staff-service.js index 268f18ac32..324ce164bd 100644 --- a/ghost/staff-service/lib/staff-service.js +++ b/ghost/staff-service/lib/staff-service.js @@ -1,10 +1,13 @@ +const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionCreatedEvent} = require('@tryghost/member-events'); + class StaffService { - constructor({logging, models, mailer, settingsCache, settingsHelpers, urlUtils}) { + constructor({logging, models, mailer, settingsCache, settingsHelpers, urlUtils, DomainEvents}) { this.logging = logging; /** @private */ this.settingsCache = settingsCache; this.models = models; + this.DomainEvents = DomainEvents; const Emails = require('./emails'); @@ -19,28 +22,115 @@ class StaffService { }); } - async notifyFreeMemberSignup(member, options) { - try { - await this.emails.notifyFreeMemberSignup(member, options); - } catch (e) { - this.logging.error(`Failed to notify free member signup - ${member?.id}`); + /** @private */ + getSerializedData({member, tier = null, subscription = null, offer = null}) { + return { + offer: offer ? { + name: offer.name, + type: offer.discount_type, + currency: offer.currency, + duration: offer.duration, + durationInMonths: offer.duration_in_months, + amount: offer.discount_amount + } : null, + subscription: subscription ? { + id: subscription.id, + amount: subscription.plan?.amount, + interval: subscription.plan?.interval, + currency: subscription.plan?.currency, + startDate: subscription.start_date, + cancelAt: subscription.current_period_end, + cancellationReason: subscription.cancellation_reason + } : null, + member: member ? { + id: member.id, + name: member.name, + email: member.email, + geolocation: member.geolocation, + status: member.status, + created_at: member.created_at + } : null, + tier: tier ? { + id: tier.id, + name: tier.name + } : null + }; + } + + /** @private */ + async getDataFromIds({memberId, tierId = null, subscriptionId = null, offerId = null}) { + const memberModel = memberId ? await this.models.Member.findOne({id: memberId}) : null; + const tierModel = tierId ? await this.models.Product.findOne({id: tierId}) : null; + const subscriptionModel = subscriptionId ? await this.models.StripeCustomerSubscription.findOne({id: subscriptionId}) : null; + const offerModel = offerId ? await this.models.Offer.findOne({id: offerId}) : null; + + return this.getSerializedData({ + member: memberModel?.toJSON(), + tier: tierModel?.toJSON(), + subscription: subscriptionModel?.toJSON(), + offer: offerModel?.toJSON() + }); + } + + /** @private */ + async handleEvent(type, event) { + if (!['api', 'member'].includes(event.data.source)) { + return; + } + + const {member, tier, subscription, offer} = await this.getDataFromIds({ + memberId: event.data.memberId, + tierId: event.data.tierId, + subscriptionId: event.data.subscriptionId, + offerId: event.data.offerId + }); + + if (type === MemberCreatedEvent && member.status === 'free') { + await this.emails.notifyFreeMemberSignup(member); + } else if (type === SubscriptionCreatedEvent) { + await this.emails.notifyPaidSubscriptionStarted({ + member, + offer, + tier, + subscription + }); + } else if (type === SubscriptionCancelledEvent) { + subscription.canceledAt = event.timestamp; + await this.emails.notifyPaidSubscriptionCanceled({ + member, + tier, + subscription + }); } } - async notifyPaidSubscriptionStart({member, offer, tier, subscription}, options) { - try { - await this.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options); - } catch (e) { - this.logging.error(`Failed to notify paid member subscription start - ${member?.id}`); - } - } + subscribeEvents() { + // Trigger email for free member signup + this.DomainEvents.subscribe(MemberCreatedEvent, async (event) => { + try { + await this.handleEvent(MemberCreatedEvent, event); + } catch (e) { + this.logging.error(`Failed to notify free member signup - ${event?.data?.memberId}`); + } + }); - async notifyPaidSubscriptionCancel({member, cancellationReason, tier, subscription}, options) { - try { - await this.emails.notifyPaidSubscriptionCanceled({member, cancellationReason, tier, subscription}, options); - } catch (e) { - this.logging.error(`Failed to notify paid member subscription cancel - ${member?.id}`); - } + // Trigger email on paid subscription start + this.DomainEvents.subscribe(SubscriptionCreatedEvent, async (event) => { + try { + await this.handleEvent(SubscriptionCreatedEvent, event); + } catch (e) { + this.logging.error(`Failed to notify paid member subscription start - ${event?.data?.memberId}`); + } + }); + + // Trigger email when a member cancels their subscription + this.DomainEvents.subscribe(SubscriptionCancelledEvent, async (event) => { + try { + await this.handleEvent(SubscriptionCancelledEvent, event); + } catch (e) { + this.logging.error(`Failed to notify paid member subscription cancel - ${event?.data?.memberId}`); + } + }); } } diff --git a/ghost/staff-service/test/staff-service.test.js b/ghost/staff-service/test/staff-service.test.js index bb79d3ed19..ae5e5abc29 100644 --- a/ghost/staff-service/test/staff-service.test.js +++ b/ghost/staff-service/test/staff-service.test.js @@ -1,6 +1,7 @@ // Switch these lines once there are useful utils // const testUtils = require('./utils'); const sinon = require('sinon'); +const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionCreatedEvent} = require('@tryghost/member-events'); require('./utils'); const StaffService = require('../lib/staff-service'); @@ -106,6 +107,7 @@ describe('StaffService', function () { describe('email notifications:', function () { let mailStub; + let subscribeStub; let getEmailAlertUsersStub; let service; let options = { @@ -145,6 +147,7 @@ describe('StaffService', function () { beforeEach(function () { mailStub = sinon.stub().resolves(); + subscribeStub = sinon.stub().resolves(); getEmailAlertUsersStub = sinon.stub().resolves([{ email: 'owner@ghost.org', slug: 'ghost' @@ -162,6 +165,9 @@ describe('StaffService', function () { mailer: { send: mailStub }, + DomainEvents: { + subscribe: subscribeStub + }, settingsCache, urlUtils, settingsHelpers @@ -172,6 +178,135 @@ describe('StaffService', function () { sinon.restore(); }); + describe('subscribeEvents', function () { + it('subscribes to events', async function () { + service.subscribeEvents(); + subscribeStub.calledThrice.should.be.true(); + subscribeStub.calledWith(SubscriptionCreatedEvent).should.be.true(); + subscribeStub.calledWith(SubscriptionCancelledEvent).should.be.true(); + subscribeStub.calledWith(MemberCreatedEvent).should.be.true(); + }); + }); + + describe('handleEvent', function () { + beforeEach(function () { + const models = { + User: { + getEmailAlertUsers: sinon.stub().resolves([{ + email: 'owner@ghost.org', + slug: 'ghost' + }]) + }, + Member: { + findOne: sinon.stub().resolves({ + toJSON: sinon.stub().returns({ + id: '1', + email: 'jamie@example.com', + name: 'Jamie', + status: 'free', + geolocation: null, + created_at: '2022-08-01T07:30:39.882Z' + }) + }) + }, + Product: { + findOne: sinon.stub().resolves({ + toJSON: sinon.stub().returns({ + id: 'tier-1', + name: 'Tier 1' + }) + }) + }, + Offer: { + findOne: sinon.stub().resolves({ + toJSON: sinon.stub().returns({ + discount_amount: 1000, + duration: 'forever', + discount_type: 'fixed', + name: 'Test offer', + duration_in_months: null + }) + }) + }, + StripeCustomerSubscription: { + findOne: sinon.stub().resolves({ + toJSON: sinon.stub().returns({ + id: 'sub-1', + plan: { + amount: 5000, + currency: 'USD', + interval: 'month' + }, + start_date: new Date('2022-08-01T07:30:39.882Z'), + current_period_end: '2024-08-01T07:30:39.882Z', + cancellation_reason: 'Changed my mind!' + }) + }) + } + }; + + service = new StaffService({ + logging: { + warn: () => {}, + error: () => {} + }, + models: models, + mailer: { + send: mailStub + }, + DomainEvents: { + subscribe: subscribeStub + }, + settingsCache, + urlUtils, + settingsHelpers + }); + }); + it('handles free member created event', async function () { + await service.handleEvent(MemberCreatedEvent, { + data: { + source: 'member', + memberId: 'member-1' + } + }); + + mailStub.calledWith( + sinon.match({subject: '🥳 Free member signup: Jamie'}) + ).should.be.true(); + }); + + it('handles paid member created event', async function () { + await service.handleEvent(SubscriptionCreatedEvent, { + data: { + source: 'member', + memberId: 'member-1', + subscriptionId: 'sub-1', + offerId: 'offer-1', + tierId: 'tier-1' + } + }); + + mailStub.calledWith( + sinon.match({subject: '💸 Paid subscription started: Jamie'}) + ).should.be.true(); + }); + + it('handles paid member cancellation event', async function () { + await service.handleEvent(SubscriptionCancelledEvent, { + data: { + source: 'member', + memberId: 'member-1', + subscriptionId: 'sub-1', + tierId: 'tier-1' + } + }); + + mailStub.calledWith( + sinon.match({subject: '⚠️ Cancellation: Jamie'}) + ).should.be.true(); + }); + }); + describe('notifyFreeMemberSignup', function () { it('sends free member signup alert', async function () { const member = { @@ -182,7 +317,7 @@ describe('StaffService', function () { created_at: '2022-08-01T07:30:39.882Z' }; - await service.notifyFreeMemberSignup(member, options); + await service.emails.notifyFreeMemberSignup(member, options); mailStub.calledOnce.should.be.true(); testCommonMailData(stubs); @@ -207,7 +342,7 @@ describe('StaffService', function () { created_at: '2022-08-01T07:30:39.882Z' }; - await service.notifyFreeMemberSignup(member, options); + await service.emails.notifyFreeMemberSignup(member, options); mailStub.calledOnce.should.be.true(); testCommonMailData(stubs); @@ -249,15 +384,15 @@ describe('StaffService', function () { }; subscription = { - plan_amount: 5000, - plan_currency: 'USD', - plan_interval: 'month', - start_date: '2022-08-01T07:30:39.882Z' + amount: 5000, + currency: 'USD', + interval: 'month', + startDate: '2022-08-01T07:30:39.882Z' }; }); it('sends paid subscription start alert without offer', async function () { - await service.notifyPaidSubscriptionStart({member, offer: null, tier, subscription}, options); + await service.emails.notifyPaidSubscriptionStarted({member, offer: null, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); @@ -274,7 +409,7 @@ describe('StaffService', function () { geolocation: '{"country": "France"}', created_at: '2022-08-01T07:30:39.882Z' }; - await service.notifyPaidSubscriptionStart({member: memberData, offer: null, tier, subscription}, options); + await service.emails.notifyPaidSubscriptionStarted({member: memberData, offer: null, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member: memberData}); @@ -285,7 +420,7 @@ describe('StaffService', function () { }); it('sends paid subscription start alert with percent offer - first payment', async function () { - await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options); + await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); @@ -305,13 +440,13 @@ describe('StaffService', function () { offer = { name: 'Save ten', duration: 'repeating', - duration_in_months: 3, + durationInMonths: 3, type: 'fixed', currency: 'USD', amount: 1000 }; - await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options); + await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); @@ -336,7 +471,7 @@ describe('StaffService', function () { amount: 2000 }; - await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options); + await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); @@ -360,7 +495,7 @@ describe('StaffService', function () { amount: 7 }; - await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options); + await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubMailData({...stubs, member}); @@ -392,23 +527,19 @@ describe('StaffService', function () { }; subscription = { - items: { - data: [{ - price: { - unit_amount: 5000, - currency: 'USD', - recurring: {interval: 'month'} - } - }] - }, - cancel_at: 1690875039, - canceled_at: 1659684639 + amount: 5000, + currency: 'USD', + interval: 'month', + cancelAt: '2024-08-01T07:30:39.882Z', + canceledAt: '2022-08-05T07:30:39.882Z' }; }); it('sends paid subscription cancel alert', async function () { - let cancellationReason = 'Changed my mind!'; - await service.notifyPaidSubscriptionCancel({member, tier, subscription, cancellationReason}, options); + await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription: { + ...subscription, + cancellationReason: 'Changed my mind!' + }}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubCancelMailData(stubs); @@ -422,7 +553,7 @@ describe('StaffService', function () { ).should.be.true(); mailStub.calledWith( - sinon.match.has('html', sinon.match('1 Aug 2023')) + sinon.match.has('html', sinon.match('1 Aug 2024')) ).should.be.true(); mailStub.calledWith( @@ -438,7 +569,7 @@ describe('StaffService', function () { }); it('sends paid subscription cancel alert without reason', async function () { - await service.notifyPaidSubscriptionCancel({member, tier, subscription}, options); + await service.emails.notifyPaidSubscriptionCanceled({member, tier, subscription}, options); mailStub.calledOnce.should.be.true(); testCommonPaidSubCancelMailData(stubs); @@ -452,7 +583,7 @@ describe('StaffService', function () { ).should.be.true(); mailStub.calledWith( - sinon.match.has('html', sinon.match('1 Aug 2023')) + sinon.match.has('html', sinon.match('1 Aug 2024')) ).should.be.true(); mailStub.calledWith(