diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 58c9764535..9d089959d2 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "5.12.2", + "version": "5.12.3", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/package.json b/ghost/core/package.json index d2b2e90158..e89aa63303 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.12.2", + "version": "5.12.3", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", diff --git a/ghost/members-api/lib/repositories/member.js b/ghost/members-api/lib/repositories/member.js index eeeed52406..b9e075ba97 100644 --- a/ghost/members-api/lib/repositories/member.js +++ b/ghost/members-api/lib/repositories/member.js @@ -407,7 +407,7 @@ module.exports = class MemberRepository { productsToAdd = _.differenceWith(incomingProductIds, existingProductIds); productsToRemove = _.differenceWith(existingProductIds, incomingProductIds); const productsToModify = productsToAdd.concat(productsToRemove); - + if (productsToModify.length !== 0) { // Load active subscriptions information await initialMember.load( @@ -417,9 +417,9 @@ module.exports = class MemberRepository { 'stripeSubscriptions.stripePrice.stripeProduct', 'stripeSubscriptions.stripePrice.stripeProduct.product' ], sharedOptions); - + const exisitingSubscriptions = initialMember.related('stripeSubscriptions')?.models ?? []; - + if (productsToRemove.length > 0) { // Only allow to delete comped products without a subscription attached to them // Other products should be removed by canceling them via the related stripe subscription @@ -445,7 +445,7 @@ module.exports = class MemberRepository { const existingActiveSubscriptions = exisitingSubscriptions.filter((subscription) => { return this.isActiveSubscriptionStatus(subscription.get('status')); }); - + if (existingActiveSubscriptions.length) { throw new errors.BadRequestError({message: tpl(messages.addProductWithActiveSubscription)}); } @@ -964,8 +964,11 @@ module.exports = class MemberRepository { ...eventData }, options); + const context = options?.context || {}; + const source = this._resolveContextSource(context); + // Notify paid member subscription start - if (this._labsService.isSet('emailAlerts')) { + if (this._labsService.isSet('emailAlerts') && ['member', 'api'].includes(source)) { await this.staffService.notifyPaidSubscriptionStart({ member: member.toJSON(), offer: offer ? this._offerRepository.toJSON(offer) : null, 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 548d9f0254..0de52cf60d 100644 --- a/ghost/members-api/test/unit/lib/repositories/member.test.js +++ b/ghost/members-api/test/unit/lib/repositories/member.test.js @@ -1,4 +1,5 @@ const assert = require('assert'); +const sinon = require('sinon'); const MemberRepository = require('../../../../lib/repositories/member'); describe('MemberRepository', function () { @@ -33,7 +34,7 @@ describe('MemberRepository', function () { api_key: true }); assert.equal(source, 'api'); - + source = repo._resolveContextSource({ api_key: true }); @@ -49,4 +50,218 @@ describe('MemberRepository', function () { assert.equal(source, 'member'); }); }); + + describe('linkSubscription', function (){ + let Member; + let staffService; + let notifySpy; + let MemberPaidSubscriptionEvent; + let StripeCustomerSubscription; + let MemberProductEvent; + let stripeAPIService; + let productRepository; + let labsService; + let subscriptionData; + + beforeEach(async function () { + notifySpy = sinon.spy(); + subscriptionData = { + id: 'sub_123', + customer: 'cus_123', + status: 'active', + items: { + type: 'list', + data: [{ + id: 'item_123', + price: { + id: 'price_123', + product: 'product_123', + active: true, + nickname: 'Monthly', + currency: 'usd', + recurring: { + interval: 'month' + }, + unit_amount: 500, + type: 'recurring' + } + }] + }, + start_date: Date.now() / 1000, + current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31), + cancel_at_period_end: false + }; + + Member = { + findOne: sinon.stub().resolves({ + related: () => { + return { + query: sinon.stub().returns({ + fetchOne: sinon.stub().resolves({}) + }), + toJSON: sinon.stub().returns([]), + fetch: sinon.stub().resolves({ + toJSON: sinon.stub().returns({}) + }) + }; + }, + toJSON: sinon.stub().returns({}) + }), + edit: sinon.stub().resolves({ + attributes: {}, + _previousAttributes: {} + }) + }; + staffService = { + notifyPaidSubscriptionStart: notifySpy + }; + MemberPaidSubscriptionEvent = { + add: sinon.stub().resolves() + }; + StripeCustomerSubscription = { + add: sinon.stub().resolves({ + get: sinon.stub().returns() + }) + }; + MemberProductEvent = { + add: sinon.stub().resolves({}) + }; + + stripeAPIService = { + configured: true, + getSubscription: sinon.stub().resolves(subscriptionData) + }; + + productRepository = { + get: sinon.stub().resolves({ + get: sinon.stub().returns(), + toJSON: sinon.stub().returns({}) + }), + update: sinon.stub().resolves({}) + }; + + labsService = { + isSet: sinon.stub().returns(true) + }; + }); + + it('triggers email alert for member 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: {} + }); + 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(); + }); + }); });