Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1279,7 +1279,8 @@ module.exports = class MemberRepository {
memberId: memberModel.id,
subscriptionId: updatedStripeCustomerSubscriptionModel.get('id'),
offerId: offerId,
batchId: options.batch_id
batchId: options.batch_id,
previousStatus: memberModel.get('status')
});
this.dispatchEvent(subscriptionActivatedEvent, options);
}
Expand Down Expand Up @@ -1367,7 +1368,8 @@ module.exports = class MemberRepository {
subscriptionId: newStripeCustomerSubscriptionModel.get('id'),
offerId: offerId,
attribution: attribution,
batchId: options.batch_id
batchId: options.batch_id,
previousStatus: memberModel.get('status')
});
this.dispatchEvent(subscriptionActivatedEvent, options);
}
Expand Down
5 changes: 5 additions & 0 deletions ghost/core/core/server/services/staff/staff-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ class StaffService {
attribution
});
} else if (type === SubscriptionActivatedEvent) {
// Suppress for gift→paid upgrades — staff was already notified
// when the member redeemed the gift (notifyGiftSubscriptionStarted).
if (event.data.previousStatus === 'gift') {
return;
}
let attribution;
if (event.data?.attribution) {
attribution = await this.memberAttributionService.fetchResource(event.data.attribution);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* @prop {string} subscriptionId
* @prop {string} attribution
* @prop {string} offerId
* @prop {string} [previousStatus] The member's status immediately before activation (e.g. 'free', 'paid', 'gift'). Used by subscribers to suppress duplicate notifications — e.g. staff was already notified at gift redemption.
*/

module.exports = class SubscriptionActivatedEvent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const sinon = require('sinon');
const errors = require('@tryghost/errors');
const DomainEvents = require('@tryghost/domain-events');
const MemberRepository = require('../../../../../../../core/server/services/members/members-api/repositories/member-repository');
const {SubscriptionCreatedEvent, OfferRedemptionEvent} = require('../../../../../../../core/shared/events');
const {SubscriptionCreatedEvent, SubscriptionActivatedEvent, OfferRedemptionEvent} = require('../../../../../../../core/shared/events');

const mockOfferRedemption = {
add: sinon.stub(),
Expand Down Expand Up @@ -2112,6 +2112,102 @@ describe('MemberRepository', function () {

sinon.assert.notCalled(WelcomeEmailAutomationRun.add);
});

it('dispatches SubscriptionActivatedEvent with previousStatus="gift" when member status was "gift"', async function () {
Member.findOne.resolves({
id: 'member_id_123',
get: sinon.stub().callsFake((key) => {
const data = {
email: 'test@example.com',
name: 'Test Member',
status: 'gift'
};
return data[key];
}),
related: (relation) => {
return {
query: sinon.stub().returns({
fetchOne: sinon.stub().resolves({})
}),
toJSON: sinon.stub().returns(relation === 'products' ? [] : {}),
fetch: sinon.stub().resolves({
toJSON: sinon.stub().returns(relation === 'products' ? [] : {}),
models: []
})
};
},
toJSON: sinon.stub().returns({})
});

const subscriptionActivatedSpy = sinon.spy();
DomainEvents.subscribe(SubscriptionActivatedEvent, subscriptionActivatedSpy);

const repo = new MemberRepository({
Member,
Outbox,
WelcomeEmailAutomationRun,
MemberPaidSubscriptionEvent,
StripeCustomerSubscription,
MemberProductEvent,
MemberStatusEvent,
stripeAPIService,
productRepository,
WelcomeEmailAutomation,
OfferRedemption: mockOfferRedemption
});

sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null);

await repo.linkSubscription({
id: 'member_id_123',
subscription: subscriptionData
}, {
transacting: {
executionPromise: Promise.resolve()
},
context: {}
});

sinon.assert.calledOnce(subscriptionActivatedSpy);
const event = subscriptionActivatedSpy.firstCall.args[0];
assert.equal(event.data.previousStatus, 'gift');
});

it('dispatches SubscriptionActivatedEvent with previousStatus not equal to "gift" when member status was not "gift"', async function () {
// Default Member.findOne stub returns no status, simulating a non-gift member
const subscriptionActivatedSpy = sinon.spy();
DomainEvents.subscribe(SubscriptionActivatedEvent, subscriptionActivatedSpy);

const repo = new MemberRepository({
Member,
Outbox,
WelcomeEmailAutomationRun,
MemberPaidSubscriptionEvent,
StripeCustomerSubscription,
MemberProductEvent,
MemberStatusEvent,
stripeAPIService,
productRepository,
WelcomeEmailAutomation,
OfferRedemption: mockOfferRedemption
});

sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null);

await repo.linkSubscription({
id: 'member_id_123',
subscription: subscriptionData
}, {
transacting: {
executionPromise: Promise.resolve()
},
context: {}
});

sinon.assert.calledOnce(subscriptionActivatedSpy);
const event = subscriptionActivatedSpy.firstCall.args[0];
assert.notEqual(event.data.previousStatus, 'gift');
});
});

describe('create - member status', function () {
Expand Down
17 changes: 17 additions & 0 deletions ghost/core/test/unit/server/services/staff/staff-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,23 @@ describe('StaffService', function () {
sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('Direct')));
});

it('skips paid subscription started email when previousStatus is "gift"', async function () {
await service.handleEvent(SubscriptionActivatedEvent, {
data: {
source: 'member',
memberId: 'member-1',
subscriptionId: 'sub-1',
offerId: 'offer-1',
tierId: 'tier-1',
previousStatus: 'gift'
}
});

sinon.assert.notCalled(service.memberAttributionService.getSubscriptionCreatedAttribution);
sinon.assert.notCalled(service.memberAttributionService.fetchResource);
sinon.assert.neverCalledWith(mailStub, sinon.match({subject: '💸 Paid subscription started: Jamie'}));
});

it('handles paid member cancellation event', async function () {
await service.handleEvent(SubscriptionCancelledEvent, {
data: {
Expand Down
Loading