Skip to content
Merged
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
9 changes: 3 additions & 6 deletions ghost/admin/app/helpers/parse-member-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export default class ParseMemberEventHelper extends Helper {
}

if (event.type === 'gift_ended_event') {
icon = 'subscriptions';
icon = 'expired-gift';
}

if (event.type === 'email_change_event') {
Expand Down Expand Up @@ -187,9 +187,6 @@ export default class ParseMemberEventHelper extends Helper {

if (event.type === 'subscription_event') {
if (event.data.type === 'created') {
if (event.data.previous_status === 'gift') {
return 'continued paid subscription after gift';
}
return 'started paid subscription';
}
if (event.data.type === 'updated') {
Expand Down Expand Up @@ -281,11 +278,11 @@ export default class ParseMemberEventHelper extends Helper {
}

if (event.type === 'gift_redemption_event') {
return 'started paid subscription via gift';
return 'started gift subscription';
}

if (event.type === 'gift_ended_event') {
return 'ended paid subscription';
return 'gift subscription expired';
}
}

Expand Down
4 changes: 4 additions & 0 deletions ghost/admin/public/assets/icons/event-expired-gift.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 19 additions & 32 deletions ghost/admin/tests/unit/helpers/parse-member-event-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,34 +30,7 @@ describe('Unit: Helper: parse-member-event', function () {
});

describe('subscription_event action', function () {
it('returns "continued paid subscription after gift" when previous_status is "gift"', function () {
const event = buildEvent({
type: 'subscription_event',
data: {type: 'created', previous_status: 'gift'}
});
const result = helper.compute([event]);
expect(result.action).to.equal('continued paid subscription after gift');
});

it('returns "started paid subscription" when previous_status is null', function () {
const event = buildEvent({
type: 'subscription_event',
data: {type: 'created', previous_status: null}
});
const result = helper.compute([event]);
expect(result.action).to.equal('started paid subscription');
});

it('returns "started paid subscription" when previous_status is "free"', function () {
const event = buildEvent({
type: 'subscription_event',
data: {type: 'created', previous_status: 'free'}
});
const result = helper.compute([event]);
expect(result.action).to.equal('started paid subscription');
});

it('returns "started paid subscription" when previous_status is missing', function () {
it('returns "started paid subscription" for a created subscription_event', function () {
const event = buildEvent({
type: 'subscription_event',
data: {type: 'created'}
Expand Down Expand Up @@ -114,17 +87,31 @@ describe('Unit: Helper: parse-member-event', function () {
});
});

describe('gift_redemption_event', function () {
it('returns "started gift subscription" action', function () {
const event = buildEvent({type: 'gift_redemption_event'});
const result = helper.compute([event]);
expect(result.action).to.equal('started gift subscription');
});

it('returns "event-gift" icon', function () {
const event = buildEvent({type: 'gift_redemption_event'});
const result = helper.compute([event]);
expect(result.icon).to.equal('event-gift');
});
});

describe('gift_ended_event', function () {
it('returns "ended paid subscription" action', function () {
it('returns "gift subscription expired" action', function () {
const event = buildEvent({type: 'gift_ended_event'});
const result = helper.compute([event]);
expect(result.action).to.equal('ended paid subscription');
expect(result.action).to.equal('gift subscription expired');
});

it('returns "event-subscriptions" icon', function () {
it('returns "event-expired-gift" icon', function () {
const event = buildEvent({type: 'gift_ended_event'});
const result = helper.compute([event]);
expect(result.icon).to.equal('event-subscriptions');
expect(result.icon).to.equal('event-expired-gift');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,6 @@ module.exports = class EventRepository {
'subscriptionCreatedEvent.userAttribution',
'subscriptionCreatedEvent.tagAttribution',
'subscriptionCreatedEvent.memberCreatedEvent',
'subscriptionCreatedEvent.paidStatusEvent',

// This is rediculous, but we need the tier name (we'll be able to shorten this later when we switch to the subscriptions table)
'stripeSubscription.stripePrice.stripeProduct.product'
Expand Down Expand Up @@ -248,22 +247,16 @@ module.exports = class EventRepository {
const tierName = model.related('stripeSubscription') && model.related('stripeSubscription').related('stripePrice') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product') ? model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product').get('name') : null;

const subscriptionCreatedEvent = model.related('subscriptionCreatedEvent');
const paidStatusEvent = subscriptionCreatedEvent && subscriptionCreatedEvent.id
? subscriptionCreatedEvent.related('paidStatusEvent')
: null;
const previousStatus = paidStatusEvent && paidStatusEvent.id ? paidStatusEvent.get('from_status') : null;

// Prevent toJSON on stripeSubscription (we don't have everything loaded)
delete model.relations.stripeSubscription;
const d = {
...model.toJSON(options),
attribution: model.get('type') === 'created' && subscriptionCreatedEvent && subscriptionCreatedEvent.id ? this._memberAttributionService.getEventAttribution(subscriptionCreatedEvent) : null,
signup: model.get('type') === 'created' && subscriptionCreatedEvent && subscriptionCreatedEvent.id && subscriptionCreatedEvent.related('memberCreatedEvent') && subscriptionCreatedEvent.related('memberCreatedEvent').id ? true : false,
previous_status: previousStatus,
tierName
};
delete d.stripeSubscription;
delete d.subscriptionCreatedEvent?.paidStatusEvent;
return {
type: 'subscription_event',
data: d
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22687,7 +22687,7 @@ exports[`Activity Feed API Can filter events by post id 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "20329",
"content-length": StringMatching /\\\\d\\+/,
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
Expand All @@ -22699,7 +22699,7 @@ exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "18676",
"content-length": StringMatching /\\\\d\\+/,
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
Expand Down
3 changes: 2 additions & 1 deletion ghost/core/test/e2e-api/admin/activity-feed.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,8 @@ describe('Activity Feed API', function () {
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag,
'content-version': anyContentVersion
'content-version': anyContentVersion,
'content-length': anyContentLength // Depending on random conditions (ID generation) the order of events can change
})
.matchBodySnapshot({
events: new Array(15).fill({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ exports[`Members API Member attribution Returns subscription created attribution
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "7287",
"content-length": "7037",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -756,118 +756,4 @@ describe('EventRepository', function () {
assert.equal(event.data.member_id, 'member-xyz');
});
});

describe('getSubscriptionEvents', function () {
// Builds a Bookshelf-shaped mock that mirrors how the model is used inside
// getSubscriptionEvents, including the eager-load behaviour where multiple
// MemberPaidSubscriptionEvent rows that share a subscription_id receive the
// SAME SubscriptionCreatedEvent instance via .related().
function buildModels({sharedSubscriptionCreatedEvent}) {
function makeRelated(map) {
return name => map[name] ?? {id: undefined, related: () => ({id: undefined})};
}

const sharedRelated = makeRelated({
subscriptionCreatedEvent: sharedSubscriptionCreatedEvent,
stripeSubscription: {related: () => ({related: () => ({related: () => null})})}
});

const buildModel = (attrs) => {
const relations = {
subscriptionCreatedEvent: sharedSubscriptionCreatedEvent,
stripeSubscription: {related: () => ({related: () => ({related: () => null})})}
};
return {
id: attrs.id,
relations,
related: name => relations[name] ?? sharedRelated(name),
get: key => attrs[key],
toJSON: () => {
const paidStatusEvent = sharedSubscriptionCreatedEvent.related('paidStatusEvent');

return {
...attrs,
subscriptionCreatedEvent: {
id: sharedSubscriptionCreatedEvent.id,
paidStatusEvent: paidStatusEvent && paidStatusEvent.id ? {
id: paidStatusEvent.id,
from_status: paidStatusEvent.get('from_status'),
to_status: paidStatusEvent.get('to_status')
} : undefined
}
};
}
};
};

return [
// Order matches findPage(order: 'created_at desc, id desc'):
// the newer "updated" row comes first, the original "created" row second.
buildModel({
id: 'mpse-updated',
type: 'updated',
member_id: 'member1',
subscription_id: 'sub1',
created_at: '2026-05-05T18:21:31.000Z'
}),
buildModel({
id: 'mpse-created',
type: 'created',
member_id: 'member1',
subscription_id: 'sub1',
created_at: '2026-05-05T15:49:44.000Z'
})
];
}

it('preserves previous_status on every row when multiple events share a subscription_id', async function () {
// One SubscriptionCreatedEvent shared across both paid-subscription rows
// (this is what Bookshelf's belongsTo eager-load gives us when the foreign
// key is duplicated). The paidStatusEvent on it represents the gift-to-paid
// transition.
const paidStatusEvent = {
id: 'mse-gift-to-paid',
get: key => ({from_status: 'gift', to_status: 'paid'}[key])
};
const sharedSubscriptionCreatedEvent = {
id: 'sce1',
relations: {paidStatusEvent, memberCreatedEvent: {id: undefined}},
related(name) {
return this.relations[name] ?? {id: undefined};
}
};

const models = buildModels({sharedSubscriptionCreatedEvent});
const findPage = sinon.fake.resolves({
data: models,
meta: {pagination: {total: models.length}}
});

const eventRepository = new EventRepository({
EmailRecipient: null,
MemberSubscribeEvent: null,
MemberPaymentEvent: null,
MemberStatusEvent: null,
MemberLoginEvent: null,
MemberPaidSubscriptionEvent: {findPage},
memberAttributionService: {getEventAttribution: () => null},
labsService: null
});

const result = await eventRepository.getSubscriptionEvents({}, '');

assert.equal(result.data.length, 2);
const created = result.data.find(e => e.data.type === 'created');
const updated = result.data.find(e => e.data.type === 'updated');

// The original `created` row should still report previous_status='gift'
// even when iterated AFTER another row that shares its SubscriptionCreatedEvent.
assert.equal(created.data.previous_status, 'gift');

// The helper relation should be removed from the serialized payload
assert.equal(created.data.subscriptionCreatedEvent.paidStatusEvent, undefined);
assert.equal(updated.data.subscriptionCreatedEvent.paidStatusEvent, undefined);
assert.equal(sharedSubscriptionCreatedEvent.related('paidStatusEvent').get('from_status'), 'gift');
});
});
});
Loading