diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index 91a9ddcc22f..fcc8d563a37 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -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') { @@ -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') { @@ -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'; } } diff --git a/ghost/admin/public/assets/icons/event-expired-gift.svg b/ghost/admin/public/assets/icons/event-expired-gift.svg new file mode 100644 index 00000000000..fe6eb1c5807 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-expired-gift.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ghost/admin/tests/unit/helpers/parse-member-event-test.js b/ghost/admin/tests/unit/helpers/parse-member-event-test.js index cef76c440b3..c95c608fc98 100644 --- a/ghost/admin/tests/unit/helpers/parse-member-event-test.js +++ b/ghost/admin/tests/unit/helpers/parse-member-event-test.js @@ -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'} @@ -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'); }); }); }); diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index 2f8f8ec4b5b..e8fa01cd5d3 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -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' @@ -248,10 +247,6 @@ 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; @@ -259,11 +254,9 @@ module.exports = class EventRepository { ...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 diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap index c2e140b430e..649b9d7ae97 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap @@ -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", @@ -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 \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/activity-feed.test.js b/ghost/core/test/e2e-api/admin/activity-feed.test.js index 70d1214e512..422fc70bee9 100644 --- a/ghost/core/test/e2e-api/admin/activity-feed.test.js +++ b/ghost/core/test/e2e-api/admin/activity-feed.test.js @@ -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({ diff --git a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap index 0270247a019..88326c99a72 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap @@ -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 \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js index 12b53e352c2..00671a001cd 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js @@ -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'); - }); - }); });