From f124d142c9a2f1ac5f4925eb6cdcdd94c34fc769 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Wed, 24 Aug 2022 16:11:25 +0200 Subject: [PATCH] Added member attributions to activity feed (#15283) refs https://github.com/TryGhost/Team/issues/1833 refs https://github.com/TryGhost/Team/issues/1834 We've added the attribution property to subscription and signup events when the flag is enabled. The attributions resource is fetched by creating multiple relations on the model, rather than polymorphic as we ran into issues with that as they can't be nullable/optional. The parse-member-event structure has been updated to make it easier to work with, specifically `getObject` is only used when the event is clickable, and there is now a join property which makes it easier to join the action and the object. --- .../components/dashboard/charts/recents.hbs | 3 +- .../app/components/member/activity-feed.hbs | 3 +- .../components/members-activity/table-row.hbs | 9 +- ghost/admin/app/helpers/parse-member-event.js | 118 +- .../server/models/member-created-event.js | 12 +- .../models/member-paid-subscription-event.js | 4 + .../models/subscription-created-event.js | 12 +- .../services/member-attribution/index.js | 4 +- .../core/core/server/services/members/api.js | 2 + .../admin/__snapshots__/members.test.js.snap | 551 +++ ghost/core/test/e2e-api/admin/members.test.js | 227 ++ .../__snapshots__/webhooks.test.js.snap | 448 +++ .../test/e2e-api/members/webhooks.test.js | 3384 +++++++++-------- .../services/member-attribution.test.js | 24 +- ghost/core/test/utils/fixture-utils.js | 24 + ghost/member-attribution/lib/attribution.js | 41 +- ghost/member-attribution/lib/service.js | 42 +- .../member-attribution/lib/url-translator.js | 30 +- .../test/attribution.test.js | 17 +- ghost/member-attribution/test/service.test.js | 93 + .../test/url-translator.test.js | 28 +- ghost/members-api/lib/MembersAPI.js | 7 +- ghost/members-api/lib/repositories/event.js | 84 +- 23 files changed, 3380 insertions(+), 1787 deletions(-) create mode 100644 ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap diff --git a/ghost/admin/app/components/dashboard/charts/recents.hbs b/ghost/admin/app/components/dashboard/charts/recents.hbs index f6cb68e435..a9382bc0eb 100644 --- a/ghost/admin/app/components/dashboard/charts/recents.hbs +++ b/ghost/admin/app/components/dashboard/charts/recents.hbs @@ -102,9 +102,8 @@ {{capitalize-first-letter parsedEvent.action}} {{#if parsedEvent.url}} + {{parsedEvent.join}} {{parsedEvent.object}} - {{else}} - {{parsedEvent.object}} {{/if}} {{#if parsedEvent.info}} {{parsedEvent.info}} diff --git a/ghost/admin/app/components/member/activity-feed.hbs b/ghost/admin/app/components/member/activity-feed.hbs index 8b910e15fd..f3fe5ea05e 100644 --- a/ghost/admin/app/components/member/activity-feed.hbs +++ b/ghost/admin/app/components/member/activity-feed.hbs @@ -27,9 +27,8 @@ {{capitalize-first-letter event.action}} {{#if event.url}} + {{event.join}} {{event.object}} - {{else}} - {{event.object}} {{/if}} {{#if event.email}} diff --git a/ghost/admin/app/components/members-activity/table-row.hbs b/ghost/admin/app/components/members-activity/table-row.hbs index 82fa0a439c..3e79e15a05 100644 --- a/ghost/admin/app/components/members-activity/table-row.hbs +++ b/ghost/admin/app/components/members-activity/table-row.hbs @@ -6,7 +6,7 @@
-

{{or event.member.name event.member.email}}

+

{{event.subject}}

{{#if event.member.name}}

{{event.member.email}}

{{/if}} @@ -21,10 +21,9 @@
{{capitalize-first-letter event.action}} - {{#if event.url}} - {{event.object}} - {{else}} - {{event.object}} + {{#if (and event.url (not (feature "memberAttribution")))}} + {{event.join}} + {{event.object}} {{/if}} {{#if event.email}} diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index 7bdec31ff1..a50c15db8e 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -2,13 +2,15 @@ import moment from 'moment'; import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency'; export default function parseMemberEvent(event, hasMultipleNewsletters) { - let subject = event.data.member.name || event.data.member.email; - let icon = getIcon(event); - let action = getAction(event); - let object = getObject(event, hasMultipleNewsletters); - let info = getInfo(event); + const subject = event.data.member.name || event.data.member.email; + const icon = getIcon(event); + const action = getAction(event, hasMultipleNewsletters); + const info = getInfo(event); + + const join = getJoin(event); + const object = getObject(event); const url = getURL(event); - let timestamp = moment(event.data.created_at); + const timestamp = moment(event.data.created_at); return { memberId: event.data.member_id ?? event.data.member?.id, @@ -18,6 +20,7 @@ export default function parseMemberEvent(event, hasMultipleNewsletters) { icon, subject, action, + join, object, info, url, @@ -77,7 +80,7 @@ function getIcon(event) { return 'event-' + icon; } -function getAction(event) { +function getAction(event, hasMultipleNewsletters) { if (event.type === 'signup_event') { return 'signed up'; } @@ -91,78 +94,98 @@ function getAction(event) { } if (event.type === 'newsletter_event') { + let newsletter = 'newsletter'; + if (hasMultipleNewsletters && event.data.newsletter && event.data.newsletter.name) { + newsletter = 'newsletter – ' + event.data.newsletter.name; + } + if (event.data.subscribed) { - return 'subscribed to'; + return 'subscribed to ' + newsletter; } else { - return 'unsubscribed from'; + return 'unsubscribed from ' + newsletter; } } if (event.type === 'subscription_event') { if (event.data.type === 'created') { - return 'started'; + return 'started their subscription'; } if (event.data.type === 'updated') { - return 'changed'; + return 'changed their subscription'; } if (event.data.type === 'canceled') { - return 'canceled'; + return 'canceled their subscription'; } if (event.data.type === 'reactivated') { - return 'reactivated'; + return 'reactivated their subscription'; } if (event.data.type === 'expired') { - return 'ended'; + return 'ended their subscription'; } - return 'changed'; + return 'changed their subscription'; } if (event.type === 'email_opened_event') { - return 'opened'; + return 'opened an email'; } if (event.type === 'email_delivered_event') { - return 'received'; + return 'received an email'; } if (event.type === 'email_failed_event') { - return 'failed to receive'; + return 'failed to receive an email'; } if (event.type === 'comment_event') { if (event.data.parent) { - return 'replied to a comment on'; + return 'replied to a comment'; } - return 'commented on'; + return 'commented'; } } -function getObject(event, hasMultipleNewsletters) { - if (event.type === 'newsletter_event') { - if (hasMultipleNewsletters && event.data.newsletter && event.data.newsletter.name) { - return 'newsletter – ' + event.data.newsletter.name; +/** + * When we need to append the action and object in one sentence, you can add extra words here. + * E.g., + * action: 'Signed up'. + * object: 'My blog post' + * When both words need to get appended, we'll add 'on' + * -> do this by returning 'on' in getJoin() + * This string is not added when action and object are in a separete table column, or when the getObject/getURL is empty + */ +function getJoin(event) { + if (event.type === 'signup_event' || event.type === 'subscription_event') { + if (event.data.attribution?.title) { + // Add 'Attributed to ' for now, until this is incorporated in the design + return 'on'; } - return 'newsletter'; - } - - if (event.type === 'subscription_event') { - return 'their subscription'; - } - - if (event.type.match?.(/^email_/)) { - return 'an email'; - } - - if (event.type === 'subscription_event') { - return 'their subscription'; } if (event.type === 'comment_event') { - if (event.type === 'comment_event') { - if (event.data.post) { - return event.data.post.title; - } + if (event.data.post) { + return 'on'; + } + } + + return ''; +} + +/** + * Clickable object, shown between action and info, or in a separate column in some views + */ +function getObject(event) { + if (event.type === 'signup_event' || event.type === 'subscription_event') { + if (event.data.attribution?.title) { + // Add 'Attributed to ' for now, until this is incorporated in the design + return event.data.attribution.title; + } + } + + if (event.type === 'comment_event') { + if (event.data.post) { + return event.data.post.title; } } @@ -179,13 +202,6 @@ function getInfo(event) { let symbol = getSymbol(event.data.currency); return `(MRR ${sign}${symbol}${Math.abs(mrrDelta)})`; } - - // TODO: we can include the post title - /*if (event.type === 'comment_event') { - if (event.data.post) { - return event.data.post.title; - } - }*/ return; } @@ -198,5 +214,11 @@ function getURL(event) { return event.data.post.url; } } + + if (event.type === 'signup_event' || event.type === 'subscription_event') { + if (event.data.attribution && event.data.attribution.url) { + return event.data.attribution.url; + } + } return; } diff --git a/ghost/core/core/server/models/member-created-event.js b/ghost/core/core/server/models/member-created-event.js index 85976c0e6e..3871f8763f 100644 --- a/ghost/core/core/server/models/member-created-event.js +++ b/ghost/core/core/server/models/member-created-event.js @@ -8,8 +8,16 @@ const MemberCreatedEvent = ghostBookshelf.Model.extend({ return this.belongsTo('Member', 'member_id', 'id'); }, - attribution() { - return this.belongsTo('Post', 'attribution_id', 'id'); + postAttribution() { + return this.belongsTo('Post', 'attribution_id', 'id'); + }, + + userAttribution() { + return this.belongsTo('User', 'attribution_id', 'id'); + }, + + tagAttribution() { + return this.belongsTo('Tag', 'attribution_id', 'id'); } }, { async edit() { diff --git a/ghost/core/core/server/models/member-paid-subscription-event.js b/ghost/core/core/server/models/member-paid-subscription-event.js index 9bd0de8a8a..7769d6823d 100644 --- a/ghost/core/core/server/models/member-paid-subscription-event.js +++ b/ghost/core/core/server/models/member-paid-subscription-event.js @@ -8,6 +8,10 @@ const MemberPaidSubscriptionEvent = ghostBookshelf.Model.extend({ return this.belongsTo('Member', 'member_id', 'id'); }, + subscriptionCreatedEvent() { + return this.belongsTo('SubscriptionCreatedEvent', 'subscription_id', 'subscription_id'); + }, + customQuery(qb, options) { if (options.aggregateMRRDeltas) { if (options.limit || options.filter) { diff --git a/ghost/core/core/server/models/subscription-created-event.js b/ghost/core/core/server/models/subscription-created-event.js index a2f904d090..0079cce424 100644 --- a/ghost/core/core/server/models/subscription-created-event.js +++ b/ghost/core/core/server/models/subscription-created-event.js @@ -12,8 +12,16 @@ const SubscriptionCreatedEvent = ghostBookshelf.Model.extend({ return this.belongsTo('StripeCustomerSubscription', 'subscription_id', 'id'); }, - attribution() { - return this.belongsTo('Post', 'attribution_id', 'id'); + postAttribution() { + return this.belongsTo('Post', 'attribution_id', 'id'); + }, + + userAttribution() { + return this.belongsTo('User', 'attribution_id', 'id'); + }, + + tagAttribution() { + return this.belongsTo('Tag', 'attribution_id', 'id'); } }, { async edit() { diff --git a/ghost/core/core/server/services/member-attribution/index.js b/ghost/core/core/server/services/member-attribution/index.js index 2d37a070b9..2f502cdff5 100644 --- a/ghost/core/core/server/services/member-attribution/index.js +++ b/ghost/core/core/server/services/member-attribution/index.js @@ -24,7 +24,7 @@ class MemberAttributionServiceWrapper { } }); - const attributionBuilder = new AttributionBuilder({urlTranslator}); + this.attributionBuilder = new AttributionBuilder({urlTranslator}); // Expose the service this.service = new MemberAttributionService({ @@ -32,7 +32,7 @@ class MemberAttributionServiceWrapper { MemberCreatedEvent: models.MemberCreatedEvent, SubscriptionCreatedEvent: models.SubscriptionCreatedEvent }, - attributionBuilder, + attributionBuilder: this.attributionBuilder, labsService }); diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 00d7c0ca25..93f71ec9dd 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -185,6 +185,8 @@ function createApiInstance(config) { MemberStatusEvent: models.MemberStatusEvent, MemberProductEvent: models.MemberProductEvent, MemberAnalyticEvent: models.MemberAnalyticEvent, + MemberCreatedEvent: models.MemberCreatedEvent, + SubscriptionCreatedEvent: models.SubscriptionCreatedEvent, OfferRedemption: models.OfferRedemption, Offer: models.Offer, StripeProduct: models.StripeProduct, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index 6a8659e0b7..60a01ddafc 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -1,5 +1,279 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Members API - member attribution Can read member attributed to a page 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6e9", + "title": "This is a static page", + "type": "page", + "url": "http://127.0.0.1:2369/static-page-test/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-page@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API - member attribution Can read member attributed to a page 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": "1955", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API - member attribution Can read member attributed to a post 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6df", + "title": "HTML Ipsum", + "type": "post", + "url": "http://127.0.0.1:2369/html-ipsum/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-post@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API - member attribution Can read member attributed to a post 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": "1938", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API - member attribution Can read member attributed to a tag 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1febe2896088840a6db", + "title": "kitchen sink", + "type": "tag", + "url": "http://127.0.0.1:2369/tag/kitchen-sink/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-tag@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API - member attribution Can read member attributed to a tag 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": "1944", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API - member attribution Can read member attributed to an author 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "1", + "title": "Joe Bloggs", + "type": "author", + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-author@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API - member attribution Can read member attributed to an author 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": "1926", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API - member attribution Can read member attributed to an url 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "title": "/a-static-page/", + "type": "url", + "url": "http://127.0.0.1:2369/a-static-page/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-url@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API - member attribution Can read member attributed to an url 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": "1922", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API - member attribution Returns sign up attributions in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], +} +`; + +exports[`Members API - member attribution Returns sign up attributions in activity feed 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": "8514", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Members API Add should fail when passing incorrect email_type query parameter 1: [body] 1`] = ` Object { "errors": Array [ @@ -3748,3 +4022,280 @@ Object { "x-powered-by": "Express", } `; + +exports[`Members APi - member attribution Can read 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": null, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member1@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": "Mr Egg", + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members APi - member attribution Can read 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": "1321", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members APi - member attribution Can read member attributed to a page 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6e9", + "title": "This is a static page", + "type": "page", + "url": "http://127.0.0.1:2369/static-page-test/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-page@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members APi - member attribution Can read member attributed to a page 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": "1955", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members APi - member attribution Can read member attributed to a post 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6df", + "title": "HTML Ipsum", + "type": "post", + "url": "http://127.0.0.1:2369/html-ipsum/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-post@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members APi - member attribution Can read member attributed to a post 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": "1938", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members APi - member attribution Can read member attributed to a tag 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1febe2896088840a6db", + "title": "kitchen sink", + "type": "tag", + "url": "http://127.0.0.1:2369/tag/kitchen-sink/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-tag@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members APi - member attribution Can read member attributed to a tag 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": "1944", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members APi - member attribution Can read member attributed to an author 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "1", + "title": "Joe Bloggs", + "type": "author", + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-author@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members APi - member attribution Can read member attributed to an author 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": "1926", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members APi - member attribution Can read member attributed to an url 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "title": "/a-static-page/", + "type": "url", + "url": "http://127.0.0.1:2369/a-static-page/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "member-attributed-to-url@test.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "free", + "subscribed": true, + "subscriptions": Any, + "tiers": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members APi - member attribution Can read member attributed to an url 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": "1922", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index 1b62cee0e2..da79a1db50 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -10,6 +10,10 @@ const testUtils = require('../../utils'); const Papa = require('papaparse'); const models = require('../../../core/server/models'); +const membersService = require('../../../core/server/services/members'); +const memberAttributionService = require('../../../core/server/services/member-attribution'); +const urlService = require('../../../core/server/services/url'); +const urlUtils = require('../../../core/shared/url-utils'); async function assertMemberEvents({eventType, memberId, asserts}) { const events = await models[eventType].where('member_id', memberId).fetchAll(); @@ -153,6 +157,229 @@ describe('Members API without Stripe', function () { }); }); +// Tests specific for member attribution +describe('Members API - member attribution', function () { + const signupAttributions = []; + + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('posts', 'newsletters', 'members:newsletters', 'comments'); + await agent.loginAsOwner(); + // This is required so that the only members in this test are created by this test, and not from fixtures. + await models.Member.query().del(); + }); + + beforeEach(function () { + mockManager.mockStripe(); + mockManager.mockMail(); + + // For some reason it is enabled by default? + mockManager.mockLabsEnabled('memberAttribution'); + }); + + afterEach(function () { + mockManager.restore(); + }); + + it('Can read member attributed to a post', async function () { + const id = fixtureManager.get('posts', 0).id; + const post = await models.Post.where('id', id).fetch({require: true}); + + // Set the attribution for this member manually + const member = await membersService.api.members.create({ + email: 'member-attributed-to-post@test.com', + attribution: memberAttributionService.attributionBuilder.build({ + id, + url: '/out-of-date/', + type: 'post' + }) + }); + + const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true}); + + await agent + .get(`/members/${member.id}/`) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill(memberMatcherShallowIncludes) + }) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .expect(({body}) => { + should(body.members[0].attribution).eql({ + id: post.id, + url: absoluteUrl, + type: 'post', + title: post.get('title') + }); + signupAttributions.push(body.members[0].attribution); + }); + }); + + it('Can read member attributed to a page', async function () { + const id = fixtureManager.get('posts', 5).id; + const post = await models.Post.where('id', id).fetch({require: true}); + + // Set the attribution for this member manually + const member = await membersService.api.members.create({ + email: 'member-attributed-to-page@test.com', + attribution: memberAttributionService.attributionBuilder.build({ + id, + url: '/out-of-date/', + type: 'page' + }) + }); + + const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true}); + + await agent + .get(`/members/${member.id}/`) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill(memberMatcherShallowIncludes) + }) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .expect(({body}) => { + should(body.members[0].attribution).eql({ + id: post.id, + url: absoluteUrl, + type: 'page', + title: post.get('title') + }); + signupAttributions.push(body.members[0].attribution); + }); + }); + + it('Can read member attributed to a tag', async function () { + const id = fixtureManager.get('tags', 0).id; + const tag = await models.Tag.where('id', id).fetch({require: true}); + + // Set the attribution for this member manually + const member = await membersService.api.members.create({ + email: 'member-attributed-to-tag@test.com', + attribution: memberAttributionService.attributionBuilder.build({ + id, + url: '/out-of-date/', + type: 'tag' + }) + }); + + const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true}); + + await agent + .get(`/members/${member.id}/`) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill(memberMatcherShallowIncludes) + }) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .expect(({body}) => { + should(body.members[0].attribution).eql({ + id: tag.id, + url: absoluteUrl, + type: 'tag', + title: tag.get('name') + }); + signupAttributions.push(body.members[0].attribution); + }); + }); + + it('Can read member attributed to an author', async function () { + const id = fixtureManager.get('users', 0).id; + const author = await models.User.where('id', id).fetch({require: true}); + + // Set the attribution for this member manually + const member = await membersService.api.members.create({ + email: 'member-attributed-to-author@test.com', + attribution: memberAttributionService.attributionBuilder.build({ + id, + url: '/out-of-date/', + type: 'author' + }) + }); + + const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true}); + + await agent + .get(`/members/${member.id}/`) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill(memberMatcherShallowIncludes) + }) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .expect(({body}) => { + should(body.members[0].attribution).eql({ + id: author.id, + url: absoluteUrl, + type: 'author', + title: author.get('name') + }); + signupAttributions.push(body.members[0].attribution); + }); + }); + + it('Can read member attributed to an url', async function () { + // Set the attribution for this member manually + const member = await membersService.api.members.create({ + email: 'member-attributed-to-url@test.com', + attribution: memberAttributionService.attributionBuilder.build({ + id: null, + url: '/a-static-page/', + type: 'url' + }) + }); + + const absoluteUrl = urlUtils.createUrl('/a-static-page/', true); + + await agent + .get(`/members/${member.id}/`) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill(memberMatcherShallowIncludes) + }) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .expect(({body}) => { + should(body.members[0].attribution).eql({ + id: null, + url: absoluteUrl, + type: 'url', + title: '/a-static-page/' + }); + signupAttributions.push(body.members[0].attribution); + }); + }); + + // Activity feed + it('Returns sign up attributions in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events/?filter=type:signup_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(signupAttributions.length).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + should(body.events.find(e => e.type !== 'signup_event')).be.undefined(); + should(body.events.map(e => e.data.attribution)).containDeep(signupAttributions); + }); + }); +}); + describe('Members API', function () { let newsletters; 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 new file mode 100644 index 0000000000..9ec615e833 --- /dev/null +++ b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap @@ -0,0 +1,448 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with author attribution 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "1", + "title": "Joe Bloggs", + "type": "author", + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": Any, + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "tiers": Any, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with author attribution 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": "2795", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with deleted post attribution 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "title": "/removed-blog-post/", + "type": "url", + "url": "http://127.0.0.1:2369/removed-blog-post/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": Any, + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "tiers": Any, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with deleted post attribution 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": "2809", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with empty attribution object 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": null, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": Any, + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "tiers": Any, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with empty attribution object 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": "2611", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with page attribution 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6e9", + "title": "This is a static page", + "type": "page", + "url": "http://127.0.0.1:2369/static-page-test/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": Any, + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "tiers": Any, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with page attribution 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": "2857", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with post attribution 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6df", + "title": "HTML Ipsum", + "type": "post", + "url": "http://127.0.0.1:2369/html-ipsum/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": Any, + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "tiers": Any, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with post attribution 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": "2823", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1febe2896088840a6db", + "title": "kitchen sink", + "type": "tag", + "url": "http://127.0.0.1:2369/tag/kitchen-sink/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": Any, + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "tiers": Any, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 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": "2837", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "title": "/", + "type": "url", + "url": "http://127.0.0.1:2369/", + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": Any, + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "tiers": Any, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 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": "2737", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": null, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": Any, + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Any, + "last_seen_at": null, + "name": null, + "newsletters": Any, + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "tiers": Any, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + ], +} +`; + +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 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": "2611", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution Returns subscription created attributions in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], +} +`; + +exports[`Members API Member attribution Returns subscription created attributions in activity feed 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": "7784", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution Returns subscription created attributions in activity feed 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": "13523", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Member attribution empty initial activity feed 1: [body] 1`] = ` +Object { + "events": Array [], +} +`; + +exports[`Members API Member attribution empty initial activity feed 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": "13", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/members/webhooks.test.js b/ghost/core/test/e2e-api/members/webhooks.test.js index 3ec47e074a..66f1980c6f 100644 --- a/ghost/core/test/e2e-api/members/webhooks.test.js +++ b/ghost/core/test/e2e-api/members/webhooks.test.js @@ -4,9 +4,11 @@ const nock = require('nock'); const should = require('should'); const stripe = require('stripe'); const {Product} = require('../../../core/server/models/product'); -const {agentProvider, mockManager, fixtureManager} = require('../../utils/e2e-framework'); +const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework'); const models = require('../../../core/server/models'); -const offers = require('../../../core/server/services/offers'); +const urlService = require('../../../core/server/services/url'); +const urlUtils = require('../../../core/shared/url-utils'); +const {anyEtag, anyObjectId, anyUuid, anyISODateTime, anyISODate, anyString, anyArray, anyLocationFor, anyErrorId, anyObject} = matchers; let membersAgent; let adminAgent; @@ -42,134 +44,114 @@ async function assertSubscription(subscriptionId, asserts) { } describe('Members API', function () { - before(async function () { - const agents = await agentProvider.getAgentsForMembers(); - membersAgent = agents.membersAgent; - adminAgent = agents.adminAgent; - - await fixtureManager.init('members'); - await adminAgent.loginAsOwner(); - }); - - beforeEach(function () { - mockManager.mockMail(); - mockManager.mockStripe(); - }); - - afterEach(function () { - mockManager.restore(); - }); - // @todo: Test what happens when a complementary subscription ends (should create comped -> free event) // @todo: Test what happens when a complementary subscription starts a paid subscription - describe('/webhooks/stripe/', function () { - // We create some shared stripe resources, so we don't have to have nocks in every test case - const subscription = {}; - const customer = {}; - const paymentMethod = {}; - const setupIntent = {}; - const coupon = {}; + // We create some shared stripe resources, so we don't have to have nocks in every test case + const subscription = {}; + const customer = {}; + const paymentMethod = {}; + const setupIntent = {}; + const coupon = {}; - beforeEach(function () { - nock('https://api.stripe.com') - .persist() - .get(/v1\/.*/) - .reply((uri, body) => { - const [match, resource, id] = uri.match(/\/?v1\/(\w+)\/?(\w+)/) || [null]; - - if (!match) { - return [500]; - } - - if (resource === 'setup_intents') { - return [200, setupIntent]; - } - - if (resource === 'customers') { - if (customer.id !== id) { - return [404]; - } - return [200, customer]; - } - - if (resource === 'subscriptions') { - if (subscription.id !== id) { - return [404]; - } - return [200, subscription]; - } - - if (resource === 'coupons') { - if (coupon.id !== id) { - return [404]; - } - return [200, coupon]; - } - }); - - nock('https://api.stripe.com') - .persist() - .post(/v1\/.*/) - .reply((uri, body) => { - const [match, resource, id, action] = uri.match(/\/?v1\/(\w+)(?:\/?(\w+)){0,2}/) || [null]; - - if (!match) { - return [500]; - } - - if (resource === 'payment_methods') { - return [200, paymentMethod]; - } - - if (resource === 'subscriptions') { - return [200, subscription]; - } - - if (resource === 'customers') { - return [200, customer]; - } - - if (resource === 'coupons') { - return [200, coupon]; - } + beforeEach(function () { + nock('https://api.stripe.com') + .persist() + .get(/v1\/.*/) + .reply((uri, body) => { + const [match, resource, id] = uri.match(/\/?v1\/(\w+)\/?(\w+)/) || [null]; + if (!match) { return [500]; - }); - }); + } + if (resource === 'setup_intents') { + return [200, setupIntent]; + } + + if (resource === 'customers') { + if (customer.id !== id) { + return [404]; + } + return [200, customer]; + } + + if (resource === 'subscriptions') { + if (subscription.id !== id) { + return [404]; + } + return [200, subscription]; + } + + if (resource === 'coupons') { + if (coupon.id !== id) { + return [404]; + } + return [200, coupon]; + } + }); + + nock('https://api.stripe.com') + .persist() + .post(/v1\/.*/) + .reply((uri, body) => { + const [match, resource, id, action] = uri.match(/\/?v1\/(\w+)(?:\/?(\w+)){0,2}/) || [null]; + + if (!match) { + return [500]; + } + + if (resource === 'payment_methods') { + return [200, paymentMethod]; + } + + if (resource === 'subscriptions') { + return [200, subscription]; + } + + if (resource === 'customers') { + return [200, customer]; + } + + if (resource === 'coupons') { + return [200, coupon]; + } + + return [500]; + }); + }); + + afterEach(function () { + nock.cleanAll(); + }); + + // Helper methods to update the customer and subscription + function set(object, newValues) { + for (const key of Object.keys(object)) { + delete object[key]; + } + Object.assign(object, newValues); + } + + describe('/webhooks/stripe/', function () { + before(async function () { + const agents = await agentProvider.getAgentsForMembers(); + membersAgent = agents.membersAgent; + adminAgent = agents.adminAgent; + + await fixtureManager.init('members'); + await adminAgent.loginAsOwner(); + }); + + beforeEach(function () { + mockManager.mockMail(); + mockManager.mockStripe(); + }); + afterEach(function () { - nock.cleanAll(); + mockManager.restore(); }); - // Helper methods to update the customer and subscription - function set(object, newValues) { - for (const key of Object.keys(object)) { - delete object[key]; - } - Object.assign(object, newValues); - } - - /** - * Helper method to create an existing member based on a customer in stripe (= current customer) - */ - async function createMemberFromStripe() { - const initialMember = { - name: customer.name, - email: customer.email, - subscribed: true, - stripe_customer_id: customer.id - }; - - const {body} = await adminAgent - .post(`/members/`) - .body({members: [initialMember]}) - .expectStatus(201); - assert.equal(body.members.length, 1, 'The member was not created'); - const member = body.members[0]; - return member; - } - it('Responds with a 401 when the signature is invalid', async function () { await membersAgent.post('/webhooks/stripe/') .body({ @@ -196,6 +178,48 @@ describe('Members API', function () { .header('stripe-signature', webhookSignature) .expectStatus(200); }); + }); + + describe('Handling the end of subscriptions', function () { + before(async function () { + const agents = await agentProvider.getAgentsForMembers(); + membersAgent = agents.membersAgent; + adminAgent = agents.adminAgent; + + await fixtureManager.init('members'); + await adminAgent.loginAsOwner(); + }); + + beforeEach(function () { + mockManager.mockMail(); + mockManager.mockStripe(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + /** + * Helper method to create an existing member based on a customer in stripe (= current customer) + */ + async function createMemberFromStripe() { + const initialMember = { + name: customer.name, + email: customer.email, + subscribed: true, + stripe_customer_id: customer.id + }; + + const {body} = await adminAgent + .post(`/members/`) + .body({members: [initialMember]}) + .expectStatus(201); + assert.equal(body.members.length, 1, 'The member was not created'); + const member = body.members[0]; + return member; + } + + let canceledPaidMember; it('Handles cancellation of paid subscriptions correctly', async function () { const customer_id = createStripeID('cust'); @@ -215,7 +239,7 @@ describe('Members API', function () { product: 'product_123', active: true, nickname: 'Monthly', - currency: 'USD', + currency: 'usd', recurring: { interval: 'month' }, @@ -233,7 +257,7 @@ describe('Members API', function () { set(customer, { id: customer_id, name: 'Test Member', - email: 'expired-paid-test@email.com', + email: 'cancel-paid-test@email.com', subscriptions: { type: 'list', data: [subscription] @@ -251,10 +275,21 @@ describe('Members API', function () { } ]); + // Check whether MRR and status has been set + await assertSubscription(initialMember.subscriptions[0].id, { + subscription_id: subscription.id, + status: 'active', + cancel_at_period_end: false, + plan_amount: 500, + plan_interval: 'month', + plan_currency: 'usd', + mrr: 500 + }); + // Cancel the previously created subscription in Stripe set(subscription, { ...subscription, - cancel_at_period_end: true + status: 'canceled' }); // Send the webhook call to anounce the cancelation @@ -278,11 +313,220 @@ describe('Members API', function () { const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/'); assert.equal(body2.members.length, 1, 'The member does not exist'); const updatedMember = body2.members[0]; - assert.equal(updatedMember.status, 'paid'); - assert.equal(updatedMember.tiers.length, 1, 'The member should have tiers'); + assert.equal(updatedMember.status, 'free'); + assert.equal(updatedMember.tiers.length, 0, 'The member should have no products'); should(updatedMember.subscriptions).match([ { - cancel_at_period_end: true + status: 'canceled' + } + ]); + + // Check whether MRR and status has been set + await assertSubscription(initialMember.subscriptions[0].id, { + subscription_id: subscription.id, + status: 'canceled', + cancel_at_period_end: false, + plan_amount: 500, + plan_interval: 'month', + plan_currency: 'usd', + mrr: 0 + }); + + // Check the status events for this newly created member (should be NULL -> paid only) + await assertMemberEvents({ + eventType: 'MemberStatusEvent', + memberId: updatedMember.id, + asserts: [ + { + from_status: null, + to_status: 'free' + }, + { + from_status: 'free', + to_status: 'paid' + }, + { + from_status: 'paid', + to_status: 'free' + } + ] + }); + + await assertMemberEvents({ + eventType: 'MemberPaidSubscriptionEvent', + memberId: updatedMember.id, + asserts: [ + { + type: 'created', + mrr_delta: 500 + }, + { + type: 'expired', + mrr_delta: -500 + } + ] + }); + + canceledPaidMember = updatedMember; + }); + + it('Can create a comlimentary subscription after canceling a paid subscription', async function () { + const product = await getPaidProduct(); + + const compedPayload = { + id: canceledPaidMember.id, + tiers: [ + { + id: product.id + } + ] + }; + + const {body} = await adminAgent + .put(`/members/${canceledPaidMember.id}/`) + .body({members: [compedPayload]}) + .expectStatus(200); + + const updatedMember = body.members[0]; + assert.equal(updatedMember.status, 'comped', 'A comped member should have the comped status'); + assert.equal(updatedMember.tiers.length, 1, 'The member should have one tier'); + should(updatedMember.subscriptions).match([ + { + status: 'canceled' + }, + { + status: 'active' + } + ]); + assert.equal(updatedMember.subscriptions.length, 2, 'The member should have two subscriptions'); + + // Check the status events for this newly created member (should be NULL -> paid only) + await assertMemberEvents({ + eventType: 'MemberStatusEvent', + memberId: updatedMember.id, + asserts: [ + { + from_status: null, + to_status: 'free' + }, + { + from_status: 'free', + to_status: 'paid' + }, + { + from_status: 'paid', + to_status: 'free' + }, + { + from_status: 'free', + to_status: 'comped' + } + ] + }); + + await assertMemberEvents({ + eventType: 'MemberPaidSubscriptionEvent', + memberId: updatedMember.id, + asserts: [ + { + mrr_delta: 500 + }, + { + mrr_delta: -500 + } + ] + }); + }); + + it('Handles cancellation of old fashioned comped subscriptions correctly', async function () { + const customer_id = createStripeID('cust'); + const subscription_id = createStripeID('sub'); + + const price = { + id: 'price_123', + product: 'product_123', + active: true, + nickname: 'Complimentary', + currency: 'usd', + recurring: { + interval: 'month' + }, + unit_amount: 0, + type: 'recurring' + }; + + // Create a new subscription in Stripe + set(subscription, { + id: subscription_id, + customer: customer_id, + status: 'active', + items: { + type: 'list', + data: [{ + id: 'item_123', + price + }] + }, + plan: price, // Old stripe thing + start_date: Date.now() / 1000, + current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31), + cancel_at_period_end: false + }); + + // Create a new customer in Stripe + set(customer, { + id: customer_id, + name: 'Test Member', + email: 'cancel-complementary-test@email.com', + subscriptions: { + type: 'list', + data: [subscription] + } + }); + + // Make sure this customer has a corresponding member in the database + // And all the subscriptions are setup correctly + const initialMember = await createMemberFromStripe(); + assert.equal(initialMember.status, 'comped', 'The member initial status should be comped'); + assert.equal(initialMember.tiers.length, 1, 'The member should have one tier'); + should(initialMember.subscriptions).match([ + { + status: 'active' + } + ]); + + // Cancel the previously created subscription in Stripe + set(subscription, { + ...subscription, + status: 'canceled' + }); + + // Send the webhook call to anounce the cancelation + const webhookPayload = JSON.stringify({ + type: 'customer.subscription.updated', + data: { + object: subscription + } + }); + const webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature) + .expectStatus(200); + + // Check status has been updated to 'free' after cancelling + const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/'); + assert.equal(body2.members.length, 1, 'The member does not exist'); + const updatedMember = body2.members[0]; + assert.equal(updatedMember.status, 'free'); + assert.equal(updatedMember.tiers.length, 0, 'The member should have no products'); + should(updatedMember.subscriptions).match([ + { + status: 'canceled' } ]); @@ -297,1551 +541,1419 @@ describe('Members API', function () { }, { from_status: 'free', - to_status: 'paid' + to_status: 'comped' + }, + { + from_status: 'comped', + to_status: 'free' } ] }); + await assertMemberEvents({ + eventType: 'MemberPaidSubscriptionEvent', + memberId: updatedMember.id, + asserts: [{ + type: 'created', + mrr_delta: 0 + }, { + type: 'expired', + mrr_delta: 0 + }] + }); + }); + }); + + describe('checkout.session.completed', function () { + // The subscription that we got from Stripe was created 2 seconds earlier (used for testing events) + const beforeNow = Math.floor((Date.now() - 2000) / 1000) * 1000; + + before(async function () { + const agents = await agentProvider.getAgentsForMembers(); + membersAgent = agents.membersAgent; + adminAgent = agents.adminAgent; + + await fixtureManager.init('members'); + await adminAgent.loginAsOwner(); + + set(subscription, { + 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: beforeNow / 1000, + current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), + cancel_at_period_end: false + }); + }); + + beforeEach(function () { + mockManager.mockMail(); + mockManager.mockStripe(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + it('Will create a member if one does not exist', async function () { + set(customer, { + id: 'cus_123', + name: 'Test Member', + email: 'checkout-webhook-test@email.com', + subscriptions: { + type: 'list', + data: [subscription] + } + }); + + { // ensure member didn't already exist + const {body} = await adminAgent.get('/members/?search=checkout-webhook-test@email.com'); + assert.equal(body.members.length, 0, 'A member already existed'); + } + + const webhookPayload = JSON.stringify({ + type: 'checkout.session.completed', + data: { + object: { + mode: 'subscription', + customer: customer.id, + subscription: subscription.id, + metadata: {} + } + } + }); + + const webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature); + + const {body} = await adminAgent.get('/members/?search=checkout-webhook-test@email.com'); + assert.equal(body.members.length, 1, 'The member was not created'); + const member = body.members[0]; + + assert.equal(member.status, 'paid', 'The member should be "paid"'); + assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); + + mockManager.assert.sentEmail({ + subject: '🙌 Thank you for signing up to Ghost!', + to: 'checkout-webhook-test@email.com' + }); + + // Check whether MRR and status has been set + await assertSubscription(member.subscriptions[0].id, { + subscription_id: subscription.id, + status: 'active', + cancel_at_period_end: false, + plan_amount: 500, + plan_interval: 'month', + plan_currency: 'usd', + current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)), + mrr: 500 + }); + + // Check the status events for this newly created member (should be NULL -> paid only) + await assertMemberEvents({ + eventType: 'MemberStatusEvent', + memberId: member.id, + asserts: [ + { + from_status: null, + to_status: 'free', + created_at: new Date(member.created_at) + }, + { + from_status: 'free', + to_status: 'paid', + created_at: new Date(beforeNow) + } + ] + }); + + await assertMemberEvents({ + eventType: 'MemberPaidSubscriptionEvent', + memberId: member.id, + asserts: [ + { + mrr_delta: 500 + } + ] + }); + }); + + it('Will create a member with default newsletter subscriptions', async function () { + set(customer, { + id: 'cus_123', + name: 'Test Member', + email: 'checkout-newsletter-default-test@email.com', + subscriptions: { + type: 'list', + data: [subscription] + } + }); + + { // ensure member didn't already exist + const {body} = await adminAgent.get('/members/?search=checkout-newsletter-default-test@email.com'); + assert.equal(body.members.length, 0, 'A member already existed'); + } + + const webhookPayload = JSON.stringify({ + type: 'checkout.session.completed', + data: { + object: { + mode: 'subscription', + customer: customer.id, + subscription: subscription.id, + metadata: {} + } + } + }); + + const webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature); + + const {body} = await adminAgent.get('/members/?search=checkout-newsletter-default-test@email.com'); + assert.equal(body.members.length, 1, 'The member was not created'); + const member = body.members[0]; + + assert.equal(member.status, 'paid', 'The member should be "paid"'); + assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); + assert.equal(member.newsletters.length, 1, 'The member should have a single newsletter'); + }); + + it('Will create a member with signup newsletter preference', async function () { + set(customer, { + id: 'cus_123', + name: 'Test Member', + email: 'checkout-newsletter-test@email.com', + subscriptions: { + type: 'list', + data: [subscription] + } + }); + + { // ensure member didn't already exist + const {body} = await adminAgent.get('/members/?search=checkout-newsletter-test@email.com'); + assert.equal(body.members.length, 0, 'A member already existed'); + } + + const webhookPayload = JSON.stringify({ + type: 'checkout.session.completed', + data: { + object: { + mode: 'subscription', + customer: customer.id, + subscription: subscription.id, + metadata: { + newsletters: JSON.stringify([]) + } + } + } + }); + + const webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature); + + const {body} = await adminAgent.get('/members/?search=checkout-newsletter-test@email.com'); + assert.equal(body.members.length, 1, 'The member was not created'); + const member = body.members[0]; + + assert.equal(member.status, 'paid', 'The member should be "paid"'); + assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); + assert.equal(member.newsletters.length, 0, 'The member should not have any newsletter subscription'); + }); + + it('Does not 500 if the member is unknown', async function () { + set(paymentMethod, { + id: 'card_456' + }); + + set(subscription, { + id: 'sub_456', + customer: 'cus_456', + status: 'active', + items: { + type: 'list', + data: [{ + id: 'item_456', + price: { + id: 'price_456', + product: 'product_456', + 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 + }); + + set(setupIntent, { + id: 'setup_intent_456', + payment_method: paymentMethod.id, + metadata: { + customer_id: 'cus_456', // invalid customer id + subscription_id: subscription.id + } + }); + + const webhookPayload = JSON.stringify({ + type: 'checkout.session.completed', + data: { + object: { + mode: 'setup', + customer: 'cus_456', + setup_intent: setupIntent.id + } + } + }); + + const webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature) + .expectStatus(200); + }); + }); + + describe('Discounts', function () { + const beforeNow = Math.floor((Date.now() - 2000) / 1000) * 1000; + let offer; + let couponId = 'testCoupon123'; + + before(async function () { + const agents = await agentProvider.getAgentsForMembers(); + membersAgent = agents.membersAgent; + adminAgent = agents.adminAgent; + + await fixtureManager.init('members'); + await adminAgent.loginAsOwner(); + + // Create a random offer_id that we'll use + // The actual amounts don't matter as we'll only take the ones from Stripe ATM + const newOffer = { + name: 'Black Friday', + code: 'black-friday', + display_title: 'Black Friday Sale!', + display_description: '10% off on yearly plan', + type: 'percent', + cadence: 'year', + amount: 12, + duration: 'once', + duration_in_months: null, + currency_restriction: false, + currency: null, + status: 'active', + redemption_count: 0, + tier: { + id: (await getPaidProduct()).id + } + }; + + // Make sure we link this to the right coupon in Stripe + // This will store the offer with stripe_coupon_id = couponId + set(coupon, { + id: couponId + }); + + const {body} = await adminAgent + .post(`offers/`) + .body({offers: [newOffer]}) + .expectStatus(200); + offer = body.offers[0]; + }); + + beforeEach(function () { + mockManager.mockMail(); + mockManager.mockStripe(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + /** + * Helper for repetitive tests. It tests the MRR and MRR delta given a discount + a price + */ + async function testDiscount({discount, interval, unit_amount, assert_mrr, offer_id}) { + const customer_id = createStripeID('cust'); + const subscription_id = createStripeID('sub'); + + discount.customer = customer_id; + + set(subscription, { + id: subscription_id, + customer: customer_id, + status: 'active', + discount, + items: { + type: 'list', + data: [{ + id: 'item_123', + price: { + id: 'price_123', + product: 'product_123', + active: true, + nickname: interval, + currency: 'usd', + recurring: { + interval + }, + unit_amount, + type: 'recurring' + } + }] + }, + start_date: beforeNow / 1000, + current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), + cancel_at_period_end: false, + metadata: {} + }); + + set(customer, { + id: customer_id, + name: 'Test Member', + email: `${customer_id}@email.com`, + subscriptions: { + type: 'list', + data: [subscription] + } + }); + + let webhookPayload = JSON.stringify({ + type: 'checkout.session.completed', + data: { + object: { + mode: 'subscription', + customer: customer.id, + subscription: subscription.id, + metadata: {} + } + } + }); + + let webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature) + .expectStatus(200); + + const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); + assert.equal(body.members.length, 1, 'The member was not created'); + const member = body.members[0]; + + assert.equal(member.status, 'paid', 'The member should be "paid"'); + assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); + + // Check whether MRR and status has been set + await assertSubscription(member.subscriptions[0].id, { + subscription_id: subscription.id, + status: 'active', + cancel_at_period_end: false, + plan_amount: unit_amount, + plan_interval: interval, + plan_currency: 'usd', + current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)), + mrr: assert_mrr, + offer_id: offer_id + }); + + // Check whether the offer attribute is passed correctly in the response when fetching a single member + member.subscriptions[0].should.match({ + offer: { + id: offer_id + } + }); + + await assertMemberEvents({ + eventType: 'MemberPaidSubscriptionEvent', + memberId: member.id, + asserts: [ + { + mrr_delta: assert_mrr + } + ] + }); + + // Now cancel, and check if the discount is also applied for the cancellation + set(subscription, { + ...subscription, + status: 'canceled' + }); + + // Send the webhook call to anounce the cancelation + webhookPayload = JSON.stringify({ + type: 'customer.subscription.updated', + data: { + object: subscription + } + }); + + webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature) + .expectStatus(200); + + // Check status has been updated to 'free' after cancelling + const {body: body2} = await adminAgent.get('/members/' + member.id + '/'); + assert.equal(body2.members.length, 1, 'The member does not exist'); + const updatedMember = body2.members[0]; + assert.equal(updatedMember.status, 'free'); + assert.equal(updatedMember.tiers.length, 0, 'The member should have no products'); + should(updatedMember.subscriptions).match([ + { + status: 'canceled', + offer: { + id: offer_id + } + } + ]); + + // Check whether MRR and status has been set + await assertSubscription(member.subscriptions[0].id, { + subscription_id: subscription.id, + status: 'canceled', + cancel_at_period_end: false, + plan_amount: unit_amount, + plan_interval: interval, + plan_currency: 'usd', + mrr: 0, + offer_id: offer_id + }); + await assertMemberEvents({ eventType: 'MemberPaidSubscriptionEvent', memberId: updatedMember.id, asserts: [ { type: 'created', - mrr_delta: 500 + mrr_delta: assert_mrr }, { - type: 'canceled', - mrr_delta: -500 + type: 'expired', + mrr_delta: -assert_mrr + } + ] + }); + } + + it('Correctly includes monthly forever percentage discounts in MRR', async function () { + // Do you get a offer_id is null failed test here + // -> check if members-api and members-offers package versions are in sync in yarn.lock or if both are linked in dev + const discount = { + id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', + object: 'discount', + checkout_session: null, + coupon: { + id: couponId, // This coupon id maps to the created offer above + object: 'coupon', + amount_off: null, + created: 1649774041, + currency: 'eur', + duration: 'forever', + duration_in_months: null, + livemode: false, + max_redemptions: null, + metadata: {}, + name: '50% off', + percent_off: 50, + redeem_by: null, + times_redeemed: 0, + valid: true + }, + end: null, + invoice: null, + invoice_item: null, + promotion_code: null, + start: beforeNow / 1000, + subscription: null + }; + await testDiscount({ + discount, + unit_amount: 500, + interval: 'month', + assert_mrr: 250, + offer_id: offer.id + }); + }); + + it('Correctly includes yearly forever percentage discounts in MRR', async function () { + const discount = { + id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', + object: 'discount', + checkout_session: null, + coupon: { + id: couponId, + object: 'coupon', + amount_off: null, + created: 1649774041, + currency: 'eur', + duration: 'forever', + duration_in_months: null, + livemode: false, + max_redemptions: null, + metadata: {}, + name: '50% off', + percent_off: 50, + redeem_by: null, + times_redeemed: 0, + valid: true + }, + end: null, + invoice: null, + invoice_item: null, + promotion_code: null, + start: beforeNow / 1000, + subscription: null + }; + await testDiscount({ + discount, + unit_amount: 1200, + interval: 'year', + assert_mrr: 50, + offer_id: offer.id + }); + }); + + it('Correctly includes monthly forever amount off discounts in MRR', async function () { + const discount = { + id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', + object: 'discount', + checkout_session: null, + coupon: { + id: couponId, + object: 'coupon', + amount_off: 1, + created: 1649774041, + currency: 'eur', + duration: 'forever', + duration_in_months: null, + livemode: false, + max_redemptions: null, + metadata: {}, + name: '1 cent off', + percent_off: null, + redeem_by: null, + times_redeemed: 0, + valid: true + }, + end: null, + invoice: null, + invoice_item: null, + promotion_code: null, + start: beforeNow / 1000, + subscription: null + }; + await testDiscount({ + discount, + unit_amount: 500, + interval: 'month', + assert_mrr: 499, + offer_id: offer.id + }); + }); + + it('Correctly includes yearly forever amount off discounts in MRR', async function () { + const discount = { + id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', + object: 'discount', + checkout_session: null, + coupon: { + id: couponId, + object: 'coupon', + amount_off: 60, + created: 1649774041, + currency: 'eur', + duration: 'forever', + duration_in_months: null, + livemode: false, + max_redemptions: null, + metadata: {}, + name: '60 cent off, yearly', + percent_off: null, + redeem_by: null, + times_redeemed: 0, + valid: true + }, + end: null, + invoice: null, + invoice_item: null, + promotion_code: null, + start: beforeNow / 1000, + subscription: null + }; + await testDiscount({ + discount, + unit_amount: 1200, + interval: 'year', + assert_mrr: 95, + offer_id: offer.id + }); + }); + + it('Does not include repeating discounts in MRR', async function () { + const discount = { + id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', + object: 'discount', + checkout_session: null, + coupon: { + id: couponId, + object: 'coupon', + amount_off: null, + created: 1649774041, + currency: 'eur', + duration: 'repeating', + duration_in_months: 3, + livemode: false, + max_redemptions: null, + metadata: {}, + name: '50% off', + percent_off: 50, + redeem_by: null, + times_redeemed: 0, + valid: true + }, + end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31 * 3), + invoice: null, + invoice_item: null, + promotion_code: null, + start: beforeNow / 1000, + subscription: null + }; + await testDiscount({ + discount, + unit_amount: 500, + interval: 'month', + assert_mrr: 500, + offer_id: offer.id + }); + }); + + it('Also supports adding a discount to an existing subscription', async function () { + const interval = 'month'; + const unit_amount = 500; + const mrr_without = 500; + const mrr_with = 400; + const mrr_difference = 100; + + const discount = { + id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', + object: 'discount', + checkout_session: null, + coupon: { + id: couponId, + object: 'coupon', + amount_off: null, + created: 1649774041, + currency: 'eur', + duration: 'forever', + duration_in_months: null, + livemode: false, + max_redemptions: null, + metadata: {}, + name: '20% off', + percent_off: 20, + redeem_by: null, + times_redeemed: 0, + valid: true + }, + end: null, + invoice: null, + invoice_item: null, + promotion_code: null, + start: beforeNow / 1000, + subscription: null + }; + + const customer_id = createStripeID('cust'); + const subscription_id = createStripeID('sub'); + + discount.customer = customer_id; + + set(subscription, { + id: subscription_id, + customer: customer_id, + status: 'active', + discount: null, + items: { + type: 'list', + data: [{ + id: 'item_123', + price: { + id: 'price_123', + product: 'product_123', + active: true, + nickname: interval, + currency: 'usd', + recurring: { + interval + }, + unit_amount, + type: 'recurring' + } + }] + }, + start_date: beforeNow / 1000, + current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), + cancel_at_period_end: false + }); + + set(customer, { + id: customer_id, + name: 'Test Member', + email: `${customer_id}@email.com`, + subscriptions: { + type: 'list', + data: [subscription] + } + }); + + let webhookPayload = JSON.stringify({ + type: 'checkout.session.completed', + data: { + object: { + mode: 'subscription', + customer: customer.id, + subscription: subscription.id, + metadata: {} + } + } + }); + + let webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature); + + const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); + assert.equal(body.members.length, 1, 'The member was not created'); + const member = body.members[0]; + + assert.equal(member.status, 'paid', 'The member should be "paid"'); + assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); + + // Check whether MRR and status has been set + await assertSubscription(member.subscriptions[0].id, { + subscription_id: subscription.id, + status: 'active', + cancel_at_period_end: false, + plan_amount: unit_amount, + plan_interval: interval, + plan_currency: 'usd', + current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)), + mrr: mrr_without, + offer_id: null + }); + + // Check whether the offer attribute is passed correctly in the response when fetching a single member + member.subscriptions[0].should.match({ + offer: null + }); + + await assertMemberEvents({ + eventType: 'MemberPaidSubscriptionEvent', + memberId: member.id, + asserts: [ + { + mrr_delta: mrr_without + } + ] + }); + + // Now add the discount + set(subscription, { + ...subscription, + discount + }); + + // Send the webhook call to anounce the cancelation + webhookPayload = JSON.stringify({ + type: 'customer.subscription.updated', + data: { + object: subscription + } + }); + + webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature) + .expectStatus(200); + + // Check status has been updated to 'free' after cancelling + const {body: body2} = await adminAgent.get('/members/' + member.id + '/'); + const updatedMember = body2.members[0]; + + // Check whether MRR and status has been set + await assertSubscription(updatedMember.subscriptions[0].id, { + subscription_id: subscription.id, + status: 'active', + cancel_at_period_end: false, + plan_amount: unit_amount, + plan_interval: interval, + plan_currency: 'usd', + mrr: mrr_with, + offer_id: offer.id + }); + + // Check whether the offer attribute is passed correctly in the response when fetching a single member + updatedMember.subscriptions[0].should.match({ + offer: { + id: offer.id + } + }); + + await assertMemberEvents({ + eventType: 'MemberPaidSubscriptionEvent', + memberId: updatedMember.id, + asserts: [ + { + type: 'created', + mrr_delta: mrr_without + }, + { + type: 'updated', + mrr_delta: -mrr_difference } ] }); }); - describe('Handling the end of subscriptions', function () { - let canceledPaidMember; + it('Silently ignores an invalid offer id in metadata', async function () { + const interval = 'month'; + const unit_amount = 500; + const mrr_with = 400; - it('Handles cancellation of paid subscriptions correctly', async function () { - const customer_id = createStripeID('cust'); - const subscription_id = createStripeID('sub'); - - // Create a new subscription in Stripe - set(subscription, { - id: subscription_id, - customer: customer_id, - 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 - }); - - // Create a new customer in Stripe - set(customer, { - id: customer_id, - name: 'Test Member', - email: 'cancel-paid-test@email.com', - subscriptions: { - type: 'list', - data: [subscription] - } - }); - - // Make sure this customer has a corresponding member in the database - // And all the subscriptions are setup correctly - const initialMember = await createMemberFromStripe(); - assert.equal(initialMember.status, 'paid', 'The member initial status should be paid'); - assert.equal(initialMember.tiers.length, 1, 'The member should have one tier'); - should(initialMember.subscriptions).match([ - { - status: 'active' - } - ]); - - // Check whether MRR and status has been set - await assertSubscription(initialMember.subscriptions[0].id, { - subscription_id: subscription.id, - status: 'active', - cancel_at_period_end: false, - plan_amount: 500, - plan_interval: 'month', - plan_currency: 'usd', - mrr: 500 - }); - - // Cancel the previously created subscription in Stripe - set(subscription, { - ...subscription, - status: 'canceled' - }); - - // Send the webhook call to anounce the cancelation - const webhookPayload = JSON.stringify({ - type: 'customer.subscription.updated', - data: { - object: subscription - } - }); - const webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature) - .expectStatus(200); - - // Check status has been updated to 'free' after cancelling - const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/'); - assert.equal(body2.members.length, 1, 'The member does not exist'); - const updatedMember = body2.members[0]; - assert.equal(updatedMember.status, 'free'); - assert.equal(updatedMember.tiers.length, 0, 'The member should have no products'); - should(updatedMember.subscriptions).match([ - { - status: 'canceled' - } - ]); - - // Check whether MRR and status has been set - await assertSubscription(initialMember.subscriptions[0].id, { - subscription_id: subscription.id, - status: 'canceled', - cancel_at_period_end: false, - plan_amount: 500, - plan_interval: 'month', - plan_currency: 'usd', - mrr: 0 - }); - - // Check the status events for this newly created member (should be NULL -> paid only) - await assertMemberEvents({ - eventType: 'MemberStatusEvent', - memberId: updatedMember.id, - asserts: [ - { - from_status: null, - to_status: 'free' - }, - { - from_status: 'free', - to_status: 'paid' - }, - { - from_status: 'paid', - to_status: 'free' - } - ] - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: updatedMember.id, - asserts: [ - { - type: 'created', - mrr_delta: 500 - }, - { - type: 'expired', - mrr_delta: -500 - } - ] - }); - - canceledPaidMember = updatedMember; - }); - - it('Can create a comlimentary subscription after canceling a paid subscription', async function () { - const product = await getPaidProduct(); - - const compedPayload = { - id: canceledPaidMember.id, - tiers: [ - { - id: product.id - } - ] - }; - - const {body} = await adminAgent - .put(`/members/${canceledPaidMember.id}/`) - .body({members: [compedPayload]}) - .expectStatus(200); - - const updatedMember = body.members[0]; - assert.equal(updatedMember.status, 'comped', 'A comped member should have the comped status'); - assert.equal(updatedMember.tiers.length, 1, 'The member should have one tier'); - should(updatedMember.subscriptions).match([ - { - status: 'canceled' - }, - { - status: 'active' - } - ]); - assert.equal(updatedMember.subscriptions.length, 2, 'The member should have two subscriptions'); - - // Check the status events for this newly created member (should be NULL -> paid only) - await assertMemberEvents({ - eventType: 'MemberStatusEvent', - memberId: updatedMember.id, - asserts: [ - { - from_status: null, - to_status: 'free' - }, - { - from_status: 'free', - to_status: 'paid' - }, - { - from_status: 'paid', - to_status: 'free' - }, - { - from_status: 'free', - to_status: 'comped' - } - ] - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: updatedMember.id, - asserts: [ - { - mrr_delta: 500 - }, - { - mrr_delta: -500 - } - ] - }); - }); - - it('Handles cancellation of old fashioned comped subscriptions correctly', async function () { - const customer_id = createStripeID('cust'); - const subscription_id = createStripeID('sub'); - - const price = { - id: 'price_123', - product: 'product_123', - active: true, - nickname: 'Complimentary', - currency: 'usd', - recurring: { - interval: 'month' - }, - unit_amount: 0, - type: 'recurring' - }; - - // Create a new subscription in Stripe - set(subscription, { - id: subscription_id, - customer: customer_id, - status: 'active', - items: { - type: 'list', - data: [{ - id: 'item_123', - price - }] - }, - plan: price, // Old stripe thing - start_date: Date.now() / 1000, - current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31), - cancel_at_period_end: false - }); - - // Create a new customer in Stripe - set(customer, { - id: customer_id, - name: 'Test Member', - email: 'cancel-complementary-test@email.com', - subscriptions: { - type: 'list', - data: [subscription] - } - }); - - // Make sure this customer has a corresponding member in the database - // And all the subscriptions are setup correctly - const initialMember = await createMemberFromStripe(); - assert.equal(initialMember.status, 'comped', 'The member initial status should be comped'); - assert.equal(initialMember.tiers.length, 1, 'The member should have one tier'); - should(initialMember.subscriptions).match([ - { - status: 'active' - } - ]); - - // Cancel the previously created subscription in Stripe - set(subscription, { - ...subscription, - status: 'canceled' - }); - - // Send the webhook call to anounce the cancelation - const webhookPayload = JSON.stringify({ - type: 'customer.subscription.updated', - data: { - object: subscription - } - }); - const webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature) - .expectStatus(200); - - // Check status has been updated to 'free' after cancelling - const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/'); - assert.equal(body2.members.length, 1, 'The member does not exist'); - const updatedMember = body2.members[0]; - assert.equal(updatedMember.status, 'free'); - assert.equal(updatedMember.tiers.length, 0, 'The member should have no products'); - should(updatedMember.subscriptions).match([ - { - status: 'canceled' - } - ]); - - // Check the status events for this newly created member (should be NULL -> paid only) - await assertMemberEvents({ - eventType: 'MemberStatusEvent', - memberId: updatedMember.id, - asserts: [ - { - from_status: null, - to_status: 'free' - }, - { - from_status: 'free', - to_status: 'comped' - }, - { - from_status: 'comped', - to_status: 'free' - } - ] - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: updatedMember.id, - asserts: [{ - type: 'created', - mrr_delta: 0 - }, { - type: 'expired', - mrr_delta: 0 - }] - }); - }); - }); - - describe('checkout.session.completed', function () { - // The subscription that we got from Stripe was created 2 seconds earlier (used for testing events) - const beforeNow = Math.floor((Date.now() - 2000) / 1000) * 1000; - before(function () { - set(subscription, { - 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: beforeNow / 1000, - current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), - cancel_at_period_end: false - }); - }); - - it('Will create a member if one does not exist', async function () { - set(customer, { - id: 'cus_123', - name: 'Test Member', - email: 'checkout-webhook-test@email.com', - subscriptions: { - type: 'list', - data: [subscription] - } - }); - - { // ensure member didn't already exist - const {body} = await adminAgent.get('/members/?search=checkout-webhook-test@email.com'); - assert.equal(body.members.length, 0, 'A member already existed'); - } - - const webhookPayload = JSON.stringify({ - type: 'checkout.session.completed', - data: { - object: { - mode: 'subscription', - customer: customer.id, - subscription: subscription.id, - metadata: {} - } - } - }); - - const webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature); - - const {body} = await adminAgent.get('/members/?search=checkout-webhook-test@email.com'); - assert.equal(body.members.length, 1, 'The member was not created'); - const member = body.members[0]; - - assert.equal(member.status, 'paid', 'The member should be "paid"'); - assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); - - mockManager.assert.sentEmail({ - subject: '🙌 Thank you for signing up to Ghost!', - to: 'checkout-webhook-test@email.com' - }); - - // Check whether MRR and status has been set - await assertSubscription(member.subscriptions[0].id, { - subscription_id: subscription.id, - status: 'active', - cancel_at_period_end: false, - plan_amount: 500, - plan_interval: 'month', - plan_currency: 'usd', - current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)), - mrr: 500 - }); - - // Check the status events for this newly created member (should be NULL -> paid only) - await assertMemberEvents({ - eventType: 'MemberStatusEvent', - memberId: member.id, - asserts: [ - { - from_status: null, - to_status: 'free', - created_at: new Date(member.created_at) - }, - { - from_status: 'free', - to_status: 'paid', - created_at: new Date(beforeNow) - } - ] - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: member.id, - asserts: [ - { - mrr_delta: 500 - } - ] - }); - }); - - it('Will create a member with default newsletter subscriptions', async function () { - set(customer, { - id: 'cus_123', - name: 'Test Member', - email: 'checkout-newsletter-default-test@email.com', - subscriptions: { - type: 'list', - data: [subscription] - } - }); - - { // ensure member didn't already exist - const {body} = await adminAgent.get('/members/?search=checkout-newsletter-default-test@email.com'); - assert.equal(body.members.length, 0, 'A member already existed'); - } - - const webhookPayload = JSON.stringify({ - type: 'checkout.session.completed', - data: { - object: { - mode: 'subscription', - customer: customer.id, - subscription: subscription.id, - metadata: {} - } - } - }); - - const webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature); - - const {body} = await adminAgent.get('/members/?search=checkout-newsletter-default-test@email.com'); - assert.equal(body.members.length, 1, 'The member was not created'); - const member = body.members[0]; - - assert.equal(member.status, 'paid', 'The member should be "paid"'); - assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); - assert.equal(member.newsletters.length, 1, 'The member should have a single newsletter'); - }); - - it('Will create a member with signup newsletter preference', async function () { - set(customer, { - id: 'cus_123', - name: 'Test Member', - email: 'checkout-newsletter-test@email.com', - subscriptions: { - type: 'list', - data: [subscription] - } - }); - - { // ensure member didn't already exist - const {body} = await adminAgent.get('/members/?search=checkout-newsletter-test@email.com'); - assert.equal(body.members.length, 0, 'A member already existed'); - } - - const webhookPayload = JSON.stringify({ - type: 'checkout.session.completed', - data: { - object: { - mode: 'subscription', - customer: customer.id, - subscription: subscription.id, - metadata: { - newsletters: JSON.stringify([]) - } - } - } - }); - - const webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature); - - const {body} = await adminAgent.get('/members/?search=checkout-newsletter-test@email.com'); - assert.equal(body.members.length, 1, 'The member was not created'); - const member = body.members[0]; - - assert.equal(member.status, 'paid', 'The member should be "paid"'); - assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); - assert.equal(member.newsletters.length, 0, 'The member should not have any newsletter subscription'); - }); - - it('Does not 500 if the member is unknown', async function () { - set(paymentMethod, { - id: 'card_456' - }); - - set(subscription, { - id: 'sub_456', - customer: 'cus_456', - status: 'active', - items: { - type: 'list', - data: [{ - id: 'item_456', - price: { - id: 'price_456', - product: 'product_456', - 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 - }); - - set(setupIntent, { - id: 'setup_intent_456', - payment_method: paymentMethod.id, - metadata: { - customer_id: 'cus_456', // invalid customer id - subscription_id: subscription.id - } - }); - - const webhookPayload = JSON.stringify({ - type: 'checkout.session.completed', - data: { - object: { - mode: 'setup', - customer: 'cus_456', - setup_intent: setupIntent.id - } - } - }); - - const webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature) - .expectStatus(200); - }); - }); - - describe('Discounts', function () { - const beforeNow = Math.floor((Date.now() - 2000) / 1000) * 1000; - let offer; - let couponId = 'testCoupon123'; - - before(async function () { - // Create a random offer_id that we'll use - // The actual amounts don't matter as we'll only take the ones from Stripe ATM - const newOffer = { - name: 'Black Friday', - code: 'black-friday', - display_title: 'Black Friday Sale!', - display_description: '10% off on yearly plan', - type: 'percent', - cadence: 'year', - amount: 12, - duration: 'once', + const discount = { + id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', + object: 'discount', + checkout_session: null, + coupon: { + id: 'unknownCoupon', // this one is unknown in Ghost + object: 'coupon', + amount_off: null, + created: 1649774041, + currency: 'eur', + duration: 'forever', duration_in_months: null, - currency_restriction: false, - currency: null, - status: 'active', - redemption_count: 0, - tier: { - id: (await getPaidProduct()).id - } - }; + livemode: false, + max_redemptions: null, + metadata: {}, + name: '20% off', + percent_off: 20, + redeem_by: null, + times_redeemed: 0, + valid: true + }, + end: null, + invoice: null, + invoice_item: null, + promotion_code: null, + start: beforeNow / 1000, + subscription: null + }; - // Make sure we link this to the right coupon in Stripe - // This will store the offer with stripe_coupon_id = couponId - set(coupon, { - id: couponId - }); + const customer_id = createStripeID('cust'); + const subscription_id = createStripeID('sub'); - const {body} = await adminAgent - .post(`offers/`) - .body({offers: [newOffer]}) - .expectStatus(200); - offer = body.offers[0]; + discount.customer = customer_id; + + set(subscription, { + id: subscription_id, + customer: customer_id, + status: 'active', + discount, + items: { + type: 'list', + data: [{ + id: 'item_123', + price: { + id: 'price_123', + product: 'product_123', + active: true, + nickname: interval, + currency: 'usd', + recurring: { + interval + }, + unit_amount, + type: 'recurring' + } + }] + }, + start_date: beforeNow / 1000, + current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), + cancel_at_period_end: false }); - /** - * Helper for repetitive tests. It tests the MRR and MRR delta given a discount + a price - */ - async function testDiscount({discount, interval, unit_amount, assert_mrr, offer_id}) { - const customer_id = createStripeID('cust'); - const subscription_id = createStripeID('sub'); + set(customer, { + id: customer_id, + name: 'Test Member', + email: `${customer_id}@email.com`, + subscriptions: { + type: 'list', + data: [subscription] + } + }); - discount.customer = customer_id; - - set(subscription, { - id: subscription_id, - customer: customer_id, - status: 'active', - discount, - items: { - type: 'list', - data: [{ - id: 'item_123', - price: { - id: 'price_123', - product: 'product_123', - active: true, - nickname: interval, - currency: 'usd', - recurring: { - interval - }, - unit_amount, - type: 'recurring' - } - }] - }, - start_date: beforeNow / 1000, - current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), - cancel_at_period_end: false, - metadata: {} - }); - - set(customer, { - id: customer_id, - name: 'Test Member', - email: `${customer_id}@email.com`, - subscriptions: { - type: 'list', - data: [subscription] + let webhookPayload = JSON.stringify({ + type: 'checkout.session.completed', + data: { + object: { + mode: 'subscription', + customer: customer.id, + subscription: subscription.id, + metadata: {} } - }); + } + }); - let webhookPayload = JSON.stringify({ - type: 'checkout.session.completed', - data: { - object: { - mode: 'subscription', - customer: customer.id, - subscription: subscription.id, - metadata: {} - } - } - }); + let webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); - let webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature); - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature) - .expectStatus(200); + const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); + assert.equal(body.members.length, 1, 'The member was not created'); + const member = body.members[0]; - const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); - assert.equal(body.members.length, 1, 'The member was not created'); - const member = body.members[0]; + assert.equal(member.status, 'paid', 'The member should be "paid"'); + assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); - assert.equal(member.status, 'paid', 'The member should be "paid"'); - assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); + // Check whether MRR and status has been set + await assertSubscription(member.subscriptions[0].id, { + subscription_id: subscription.id, + status: 'active', + cancel_at_period_end: false, + plan_amount: unit_amount, + plan_interval: interval, + plan_currency: 'usd', + current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)), + mrr: mrr_with, + offer_id: null + }); - // Check whether MRR and status has been set - await assertSubscription(member.subscriptions[0].id, { - subscription_id: subscription.id, - status: 'active', - cancel_at_period_end: false, - plan_amount: unit_amount, - plan_interval: interval, - plan_currency: 'usd', - current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)), - mrr: assert_mrr, - offer_id: offer_id - }); + // Check whether the offer attribute is passed correctly in the response when fetching a single member + member.subscriptions[0].should.match({ + offer: null + }); - // Check whether the offer attribute is passed correctly in the response when fetching a single member - member.subscriptions[0].should.match({ - offer: { - id: offer_id - } - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: member.id, - asserts: [ - { - mrr_delta: assert_mrr - } - ] - }); - - // Now cancel, and check if the discount is also applied for the cancellation - set(subscription, { - ...subscription, - status: 'canceled' - }); - - // Send the webhook call to anounce the cancelation - webhookPayload = JSON.stringify({ - type: 'customer.subscription.updated', - data: { - object: subscription - } - }); - - webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature) - .expectStatus(200); - - // Check status has been updated to 'free' after cancelling - const {body: body2} = await adminAgent.get('/members/' + member.id + '/'); - assert.equal(body2.members.length, 1, 'The member does not exist'); - const updatedMember = body2.members[0]; - assert.equal(updatedMember.status, 'free'); - assert.equal(updatedMember.tiers.length, 0, 'The member should have no products'); - should(updatedMember.subscriptions).match([ + await assertMemberEvents({ + eventType: 'MemberPaidSubscriptionEvent', + memberId: member.id, + asserts: [ { - status: 'canceled', - offer: { - id: offer_id - } + mrr_delta: mrr_with } - ]); - - // Check whether MRR and status has been set - await assertSubscription(member.subscriptions[0].id, { - subscription_id: subscription.id, - status: 'canceled', - cancel_at_period_end: false, - plan_amount: unit_amount, - plan_interval: interval, - plan_currency: 'usd', - mrr: 0, - offer_id: offer_id - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: updatedMember.id, - asserts: [ - { - type: 'created', - mrr_delta: assert_mrr - }, - { - type: 'expired', - mrr_delta: -assert_mrr - } - ] - }); - } - - it('Correctly includes monthly forever percentage discounts in MRR', async function () { - // Do you get a offer_id is null failed test here - // -> check if members-api and members-offers package versions are in sync in yarn.lock or if both are linked in dev - const discount = { - id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', - object: 'discount', - checkout_session: null, - coupon: { - id: couponId, // This coupon id maps to the created offer above - object: 'coupon', - amount_off: null, - created: 1649774041, - currency: 'eur', - duration: 'forever', - duration_in_months: null, - livemode: false, - max_redemptions: null, - metadata: {}, - name: '50% off', - percent_off: 50, - redeem_by: null, - times_redeemed: 0, - valid: true - }, - end: null, - invoice: null, - invoice_item: null, - promotion_code: null, - start: beforeNow / 1000, - subscription: null - }; - await testDiscount({ - discount, - unit_amount: 500, - interval: 'month', - assert_mrr: 250, - offer_id: offer.id - }); - }); - - it('Correctly includes yearly forever percentage discounts in MRR', async function () { - const discount = { - id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', - object: 'discount', - checkout_session: null, - coupon: { - id: couponId, - object: 'coupon', - amount_off: null, - created: 1649774041, - currency: 'eur', - duration: 'forever', - duration_in_months: null, - livemode: false, - max_redemptions: null, - metadata: {}, - name: '50% off', - percent_off: 50, - redeem_by: null, - times_redeemed: 0, - valid: true - }, - end: null, - invoice: null, - invoice_item: null, - promotion_code: null, - start: beforeNow / 1000, - subscription: null - }; - await testDiscount({ - discount, - unit_amount: 1200, - interval: 'year', - assert_mrr: 50, - offer_id: offer.id - }); - }); - - it('Correctly includes monthly forever amount off discounts in MRR', async function () { - const discount = { - id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', - object: 'discount', - checkout_session: null, - coupon: { - id: couponId, - object: 'coupon', - amount_off: 1, - created: 1649774041, - currency: 'eur', - duration: 'forever', - duration_in_months: null, - livemode: false, - max_redemptions: null, - metadata: {}, - name: '1 cent off', - percent_off: null, - redeem_by: null, - times_redeemed: 0, - valid: true - }, - end: null, - invoice: null, - invoice_item: null, - promotion_code: null, - start: beforeNow / 1000, - subscription: null - }; - await testDiscount({ - discount, - unit_amount: 500, - interval: 'month', - assert_mrr: 499, - offer_id: offer.id - }); - }); - - it('Correctly includes yearly forever amount off discounts in MRR', async function () { - const discount = { - id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', - object: 'discount', - checkout_session: null, - coupon: { - id: couponId, - object: 'coupon', - amount_off: 60, - created: 1649774041, - currency: 'eur', - duration: 'forever', - duration_in_months: null, - livemode: false, - max_redemptions: null, - metadata: {}, - name: '60 cent off, yearly', - percent_off: null, - redeem_by: null, - times_redeemed: 0, - valid: true - }, - end: null, - invoice: null, - invoice_item: null, - promotion_code: null, - start: beforeNow / 1000, - subscription: null - }; - await testDiscount({ - discount, - unit_amount: 1200, - interval: 'year', - assert_mrr: 95, - offer_id: offer.id - }); - }); - - it('Does not include repeating discounts in MRR', async function () { - const discount = { - id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', - object: 'discount', - checkout_session: null, - coupon: { - id: couponId, - object: 'coupon', - amount_off: null, - created: 1649774041, - currency: 'eur', - duration: 'repeating', - duration_in_months: 3, - livemode: false, - max_redemptions: null, - metadata: {}, - name: '50% off', - percent_off: 50, - redeem_by: null, - times_redeemed: 0, - valid: true - }, - end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31 * 3), - invoice: null, - invoice_item: null, - promotion_code: null, - start: beforeNow / 1000, - subscription: null - }; - await testDiscount({ - discount, - unit_amount: 500, - interval: 'month', - assert_mrr: 500, - offer_id: offer.id - }); - }); - - it('Also supports adding a discount to an existing subscription', async function () { - const interval = 'month'; - const unit_amount = 500; - const mrr_without = 500; - const mrr_with = 400; - const mrr_difference = 100; - - const discount = { - id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', - object: 'discount', - checkout_session: null, - coupon: { - id: couponId, - object: 'coupon', - amount_off: null, - created: 1649774041, - currency: 'eur', - duration: 'forever', - duration_in_months: null, - livemode: false, - max_redemptions: null, - metadata: {}, - name: '20% off', - percent_off: 20, - redeem_by: null, - times_redeemed: 0, - valid: true - }, - end: null, - invoice: null, - invoice_item: null, - promotion_code: null, - start: beforeNow / 1000, - subscription: null - }; - - const customer_id = createStripeID('cust'); - const subscription_id = createStripeID('sub'); - - discount.customer = customer_id; - - set(subscription, { - id: subscription_id, - customer: customer_id, - status: 'active', - discount: null, - items: { - type: 'list', - data: [{ - id: 'item_123', - price: { - id: 'price_123', - product: 'product_123', - active: true, - nickname: interval, - currency: 'usd', - recurring: { - interval - }, - unit_amount, - type: 'recurring' - } - }] - }, - start_date: beforeNow / 1000, - current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), - cancel_at_period_end: false - }); - - set(customer, { - id: customer_id, - name: 'Test Member', - email: `${customer_id}@email.com`, - subscriptions: { - type: 'list', - data: [subscription] - } - }); - - let webhookPayload = JSON.stringify({ - type: 'checkout.session.completed', - data: { - object: { - mode: 'subscription', - customer: customer.id, - subscription: subscription.id, - metadata: {} - } - } - }); - - let webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature); - - const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); - assert.equal(body.members.length, 1, 'The member was not created'); - const member = body.members[0]; - - assert.equal(member.status, 'paid', 'The member should be "paid"'); - assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); - - // Check whether MRR and status has been set - await assertSubscription(member.subscriptions[0].id, { - subscription_id: subscription.id, - status: 'active', - cancel_at_period_end: false, - plan_amount: unit_amount, - plan_interval: interval, - plan_currency: 'usd', - current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)), - mrr: mrr_without, - offer_id: null - }); - - // Check whether the offer attribute is passed correctly in the response when fetching a single member - member.subscriptions[0].should.match({ - offer: null - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: member.id, - asserts: [ - { - mrr_delta: mrr_without - } - ] - }); - - // Now add the discount - set(subscription, { - ...subscription, - discount - }); - - // Send the webhook call to anounce the cancelation - webhookPayload = JSON.stringify({ - type: 'customer.subscription.updated', - data: { - object: subscription - } - }); - - webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature) - .expectStatus(200); - - // Check status has been updated to 'free' after cancelling - const {body: body2} = await adminAgent.get('/members/' + member.id + '/'); - const updatedMember = body2.members[0]; - - // Check whether MRR and status has been set - await assertSubscription(updatedMember.subscriptions[0].id, { - subscription_id: subscription.id, - status: 'active', - cancel_at_period_end: false, - plan_amount: unit_amount, - plan_interval: interval, - plan_currency: 'usd', - mrr: mrr_with, - offer_id: offer.id - }); - - // Check whether the offer attribute is passed correctly in the response when fetching a single member - updatedMember.subscriptions[0].should.match({ - offer: { - id: offer.id - } - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: updatedMember.id, - asserts: [ - { - type: 'created', - mrr_delta: mrr_without - }, - { - type: 'updated', - mrr_delta: -mrr_difference - } - ] - }); - }); - - it('Silently ignores an invalid offer id in metadata', async function () { - const interval = 'month'; - const unit_amount = 500; - const mrr_with = 400; - - const discount = { - id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', - object: 'discount', - checkout_session: null, - coupon: { - id: 'unknownCoupon', // this one is unknown in Ghost - object: 'coupon', - amount_off: null, - created: 1649774041, - currency: 'eur', - duration: 'forever', - duration_in_months: null, - livemode: false, - max_redemptions: null, - metadata: {}, - name: '20% off', - percent_off: 20, - redeem_by: null, - times_redeemed: 0, - valid: true - }, - end: null, - invoice: null, - invoice_item: null, - promotion_code: null, - start: beforeNow / 1000, - subscription: null - }; - - const customer_id = createStripeID('cust'); - const subscription_id = createStripeID('sub'); - - discount.customer = customer_id; - - set(subscription, { - id: subscription_id, - customer: customer_id, - status: 'active', - discount, - items: { - type: 'list', - data: [{ - id: 'item_123', - price: { - id: 'price_123', - product: 'product_123', - active: true, - nickname: interval, - currency: 'usd', - recurring: { - interval - }, - unit_amount, - type: 'recurring' - } - }] - }, - start_date: beforeNow / 1000, - current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), - cancel_at_period_end: false - }); - - set(customer, { - id: customer_id, - name: 'Test Member', - email: `${customer_id}@email.com`, - subscriptions: { - type: 'list', - data: [subscription] - } - }); - - let webhookPayload = JSON.stringify({ - type: 'checkout.session.completed', - data: { - object: { - mode: 'subscription', - customer: customer.id, - subscription: subscription.id, - metadata: {} - } - } - }); - - let webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature); - - const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); - assert.equal(body.members.length, 1, 'The member was not created'); - const member = body.members[0]; - - assert.equal(member.status, 'paid', 'The member should be "paid"'); - assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); - - // Check whether MRR and status has been set - await assertSubscription(member.subscriptions[0].id, { - subscription_id: subscription.id, - status: 'active', - cancel_at_period_end: false, - plan_amount: unit_amount, - plan_interval: interval, - plan_currency: 'usd', - current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)), - mrr: mrr_with, - offer_id: null - }); - - // Check whether the offer attribute is passed correctly in the response when fetching a single member - member.subscriptions[0].should.match({ - offer: null - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: member.id, - asserts: [ - { - mrr_delta: mrr_with - } - ] - }); - }); - }); - - // Test if the session metadata is processed correctly - describe('Member attribution', function () { - beforeEach(function () { - mockManager.mockLabsEnabled('memberAttribution'); - }); - - // The subscription that we got from Stripe was created 2 seconds earlier (used for testing events) - const beforeNow = Math.floor((Date.now() - 2000) / 1000) * 1000; - - async function testWithAttribution(attribution) { - const customer_id = createStripeID('cust'); - const subscription_id = createStripeID('sub'); - - const interval = 'month'; - const unit_amount = 150; - - set(subscription, { - id: subscription_id, - customer: customer_id, - status: 'active', - items: { - type: 'list', - data: [{ - id: 'item_123', - price: { - id: 'price_123', - product: 'product_123', - active: true, - nickname: interval, - currency: 'usd', - recurring: { - interval - }, - unit_amount, - type: 'recurring' - } - }] - }, - start_date: beforeNow / 1000, - current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), - cancel_at_period_end: false, - metadata: {} - }); - - set(customer, { - id: customer_id, - name: 'Test Member', - email: `${customer_id}@email.com`, - subscriptions: { - type: 'list', - data: [subscription] - } - }); - - let webhookPayload = JSON.stringify({ - type: 'checkout.session.completed', - data: { - object: { - mode: 'subscription', - customer: customer.id, - subscription: subscription.id, - metadata: attribution ? { - attribution_id: attribution.id, - attribution_url: attribution.url, - attribution_type: attribution.type - } : {} - } - } - }); - - let webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('stripe-signature', webhookSignature) - .expectStatus(200); - - const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); - assert.equal(body.members.length, 1, 'The member was not created'); - const member = body.members[0]; - - assert.equal(member.status, 'paid', 'The member should be "paid"'); - assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); - - // Convert Stripe ID to internal model ID - const subscriptionModel = await getSubscription(member.subscriptions[0].id); - - await assertMemberEvents({ - eventType: 'SubscriptionCreatedEvent', - memberId: member.id, - asserts: [ - { - member_id: member.id, - subscription_id: subscriptionModel.id, - - // Defaults if attribution is not set - attribution_id: attribution?.id ?? null, - attribution_url: attribution?.url ?? null, - attribution_type: attribution?.type ?? null - } - ] - }); - - const memberModel = await getMember(member.id); - - // It also should have created a new member, and a MemberCreatedEvent - // With the same attributions - await assertMemberEvents({ - eventType: 'MemberCreatedEvent', - memberId: member.id, - asserts: [ - { - member_id: member.id, - created_at: memberModel.get('created_at'), - - // Defaults if attribution is not set - attribution_id: attribution?.id ?? null, - attribution_url: attribution?.url ?? null, - attribution_type: attribution?.type ?? null, - source: 'member' - } - ] - }); - } - - it('Creates a SubscriptionCreatedEvent with url attribution', async function () { - // This mainly tests for nullable fields being set to null and handled correctly - const attribution = { - id: null, - url: '/', - type: 'url' - }; - - await testWithAttribution(attribution); - }); - - it('Creates a SubscriptionCreatedEvent with post attribution', async function () { - const attribution = { - id: 'my-post-id', - url: '/my-post-slug', - type: 'post' - }; - - await testWithAttribution(attribution); - }); - - it('Creates a SubscriptionCreatedEvent without attribution', async function () { - const attribution = undefined; - await testWithAttribution(attribution); - }); - - it('Creates a SubscriptionCreatedEvent with empty attribution object', async function () { - // Shouldn't happen, but to make sure we handle it - const attribution = {}; - await testWithAttribution(attribution); + ] }); }); }); + + // Test if the session metadata is processed correctly + describe('Member attribution', function () { + before(async function () { + const agents = await agentProvider.getAgentsForMembers(); + membersAgent = agents.membersAgent; + adminAgent = agents.adminAgent; + + await fixtureManager.init('posts', 'products'); + await adminAgent.loginAsOwner(); + }); + + beforeEach(function () { + mockManager.mockLabsEnabled('memberAttribution'); + mockManager.mockMail(); + mockManager.mockStripe(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + // The subscription that we got from Stripe was created 2 seconds earlier (used for testing events) + const beforeNow = Math.floor((Date.now() - 2000) / 1000) * 1000; + + const memberMatcherShallowIncludes = { + id: anyObjectId, + uuid: anyUuid, + email: anyString, + created_at: anyISODateTime, + updated_at: anyISODateTime, + subscriptions: anyArray, + labels: anyArray, + tiers: anyArray, + newsletters: anyArray + }; + + // Activity feed + // This test is here because creating subscriptions is a PITA now, and we would need to essentially duplicate all above tests elsewhere + it('empty initial activity feed', async function () { + // Check activity feed + await adminAgent + .get(`/members/events/?filter=type:subscription_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(0).fill({ + type: anyString, + data: anyObject + }) + }); + }); + + async function testWithAttribution(attribution, attributionResource) { + const customer_id = createStripeID('cust'); + const subscription_id = createStripeID('sub'); + + const interval = 'month'; + const unit_amount = 150; + + set(subscription, { + id: subscription_id, + customer: customer_id, + status: 'active', + items: { + type: 'list', + data: [{ + id: 'item_123', + price: { + id: 'price_123', + product: 'product_123', + active: true, + nickname: interval, + currency: 'usd', + recurring: { + interval + }, + unit_amount, + type: 'recurring' + } + }] + }, + start_date: beforeNow / 1000, + current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), + cancel_at_period_end: false, + metadata: {} + }); + + set(customer, { + id: customer_id, + name: 'Test Member', + email: `${customer_id}@email.com`, + subscriptions: { + type: 'list', + data: [subscription] + } + }); + + let webhookPayload = JSON.stringify({ + type: 'checkout.session.completed', + data: { + object: { + mode: 'subscription', + customer: customer.id, + subscription: subscription.id, + metadata: attribution ? { + attribution_id: attribution.id, + attribution_url: attribution.url, + attribution_type: attribution.type + } : {} + } + } + }); + + let webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, + secret: process.env.WEBHOOK_SECRET + }); + + await membersAgent.post('/webhooks/stripe/') + .body(webhookPayload) + .header('stripe-signature', webhookSignature) + .expectStatus(200); + + const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); + assert.equal(body.members.length, 1, 'The member was not created'); + const member = body.members[0]; + + assert.equal(member.status, 'paid', 'The member should be "paid"'); + assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); + + // Convert Stripe ID to internal model ID + const subscriptionModel = await getSubscription(member.subscriptions[0].id); + + await assertMemberEvents({ + eventType: 'SubscriptionCreatedEvent', + memberId: member.id, + asserts: [ + { + member_id: member.id, + subscription_id: subscriptionModel.id, + + // Defaults if attribution is not set + attribution_id: attribution?.id ?? null, + attribution_url: attribution?.url ?? null, + attribution_type: attribution?.type ?? null + } + ] + }); + + const memberModel = await getMember(member.id); + + // It also should have created a new member, and a MemberCreatedEvent + // With the same attributions + await assertMemberEvents({ + eventType: 'MemberCreatedEvent', + memberId: member.id, + asserts: [ + { + member_id: member.id, + created_at: memberModel.get('created_at'), + + // Defaults if attribution is not set + attribution_id: attribution?.id ?? null, + attribution_url: attribution?.url ?? null, + attribution_type: attribution?.type ?? null, + source: 'member' + } + ] + }); + + await adminAgent + .get(`/members/${member.id}/`) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill(memberMatcherShallowIncludes) + }) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .expect(({body: body3}) => { + should(body3.members[0].attribution).eql(attributionResource); + should(body3.members[0].subscriptions[0].attribution).eql(attributionResource); + subscriptionAttributions.push(body3.members[0].subscriptions[0].attribution); + }); + + return memberModel; + } + + const subscriptionAttributions = []; + + it('Creates a SubscriptionCreatedEvent with url attribution', async function () { + // This mainly tests for nullable fields being set to null and handled correctly + const attribution = { + id: null, + url: '/', + type: 'url' + }; + + const absoluteUrl = urlUtils.createUrl('/', true); + + await testWithAttribution(attribution, { + id: null, + url: absoluteUrl, + type: 'url', + title: '/' + }); + }); + + it('Creates a SubscriptionCreatedEvent with post attribution', async function () { + const id = fixtureManager.get('posts', 0).id; + const post = await models.Post.where('id', id).fetch({require: true}); + + const attribution = { + id: post.id, + url: '/out-of-date-url/', + type: 'post' + }; + + const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true}); + + await testWithAttribution(attribution, { + id: post.id, + url: absoluteUrl, + type: 'post', + title: post.get('title') + }); + }); + + it('Creates a SubscriptionCreatedEvent with deleted post attribution', async function () { + const attribution = { + id: 'doesnt-exist-anylonger', + url: '/removed-blog-post/', + type: 'post' + }; + + const absoluteUrl = urlUtils.createUrl('/removed-blog-post/', true); + + await testWithAttribution(attribution, { + id: null, + url: absoluteUrl, + type: 'url', + title: '/removed-blog-post/' + }); + }); + + it('Creates a SubscriptionCreatedEvent with page attribution', async function () { + const id = fixtureManager.get('posts', 5).id; + const post = await models.Post.where('id', id).fetch({require: true}); + + const attribution = { + id: post.id, + url: '/out-of-date-url/', + type: 'page' + }; + + const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true}); + + await testWithAttribution(attribution, { + id: post.id, + url: absoluteUrl, + type: 'page', + title: post.get('title') + }); + }); + + it('Creates a SubscriptionCreatedEvent with tag attribution', async function () { + const id = fixtureManager.get('tags', 0).id; + const tag = await models.Tag.where('id', id).fetch({require: true}); + + const attribution = { + id: tag.id, + url: '/out-of-date-url/', + type: 'tag' + }; + + const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true}); + + await testWithAttribution(attribution, { + id: tag.id, + url: absoluteUrl, + type: 'tag', + title: tag.get('name') + }); + }); + + it('Creates a SubscriptionCreatedEvent with author attribution', async function () { + const id = fixtureManager.get('users', 0).id; + const author = await models.User.where('id', id).fetch({require: true}); + + const attribution = { + id: author.id, + url: '/out-of-date-url/', + type: 'author' + }; + + const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true}); + + await testWithAttribution(attribution, { + id: author.id, + url: absoluteUrl, + type: 'author', + title: author.get('name') + }); + }); + + it('Creates a SubscriptionCreatedEvent without attribution', async function () { + const attribution = undefined; + await testWithAttribution(attribution, null); + }); + + it('Creates a SubscriptionCreatedEvent with empty attribution object', async function () { + // Shouldn't happen, but to make sure we handle it + const attribution = {}; + await testWithAttribution(attribution, null); + }); + + // Activity feed + // This test is here because creating subscriptions is a PITA now, and we would need to essentially duplicate all above tests elsewhere + it('Returns subscription created attributions in activity feed', async function () { + // Check activity feed + await adminAgent + .get(`/members/events/?filter=type:subscription_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(subscriptionAttributions.length).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + should(body.events.find(e => e.type !== 'subscription_event')).be.undefined(); + should(body.events.map(e => e.data.attribution)).containDeep(subscriptionAttributions); + }); + }); + }); }); diff --git a/ghost/core/test/e2e-server/services/member-attribution.test.js b/ghost/core/test/e2e-server/services/member-attribution.test.js index 5e92dbfee0..bab38a3b28 100644 --- a/ghost/core/test/e2e-server/services/member-attribution.test.js +++ b/ghost/core/test/e2e-server/services/member-attribution.test.js @@ -33,7 +33,7 @@ describe('Member Attribution Service', function () { type: 'url' })); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: null, url: absoluteUrl, type: 'url', @@ -60,7 +60,7 @@ describe('Member Attribution Service', function () { const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: post.id, url: absoluteUrl, type: 'post', @@ -92,7 +92,7 @@ describe('Member Attribution Service', function () { // Unpublish this post await models.Post.edit({status: 'draft'}, {id}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: null, url: absoluteUrl, type: 'url', @@ -123,7 +123,7 @@ describe('Member Attribution Service', function () { const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: post.id, url: absoluteUrl, type: 'page', @@ -150,7 +150,7 @@ describe('Member Attribution Service', function () { const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true, withSubdirectory: true}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: tag.id, url: absoluteUrl, type: 'tag', @@ -177,7 +177,7 @@ describe('Member Attribution Service', function () { const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true, withSubdirectory: true}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: author.id, url: absoluteUrl, type: 'author', @@ -212,7 +212,7 @@ describe('Member Attribution Service', function () { type: 'url' })); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: null, url: absoluteUrl, type: 'url', @@ -245,7 +245,7 @@ describe('Member Attribution Service', function () { const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: post.id, url: absoluteUrl, type: 'post', @@ -280,7 +280,7 @@ describe('Member Attribution Service', function () { // Unpublish this post await models.Post.edit({status: 'draft'}, {id}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: null, url: absoluteUrl, type: 'url', @@ -310,7 +310,7 @@ describe('Member Attribution Service', function () { const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: post.id, url: absoluteUrl, type: 'page', @@ -338,7 +338,7 @@ describe('Member Attribution Service', function () { const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true, withSubdirectory: true}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: tag.id, url: absoluteUrl, type: 'tag', @@ -366,7 +366,7 @@ describe('Member Attribution Service', function () { const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true, withSubdirectory: true}); - (await attribution.getResource()).should.match(({ + (await attribution.fetchResource()).should.match(({ id: author.id, url: absoluteUrl, type: 'author', diff --git a/ghost/core/test/utils/fixture-utils.js b/ghost/core/test/utils/fixture-utils.js index df6f580cef..a8f49e07cf 100644 --- a/ghost/core/test/utils/fixture-utils.js +++ b/ghost/core/test/utils/fixture-utils.js @@ -478,6 +478,27 @@ const fixtures = { return models.Product.add(archivedProduct, context.internal); }, + insertProducts: async function insertProducts() { + let coreProductFixtures = fixtureManager.findModelFixtures('Product').entries; + await Promise.map(coreProductFixtures, async (product) => { + const found = await models.Product.findOne(product, context.internal); + if (!found) { + await models.Product.add(product, context.internal); + } + }); + + const product = await models.Product.findOne({type: 'paid'}, context.internal); + + await Promise.each(_.cloneDeep(DataGenerator.forKnex.stripe_products), function (stripeProduct) { + stripeProduct.product_id = product.id; + return models.StripeProduct.add(stripeProduct, context.internal); + }); + + await Promise.each(_.cloneDeep(DataGenerator.forKnex.stripe_prices), function (stripePrice) { + return models.StripePrice.add(stripePrice, context.internal); + }); + }, + insertMembersAndLabelsAndProducts: function insertMembersAndLabelsAndProducts(newsletters = false) { return Promise.map(DataGenerator.forKnex.labels, function (label) { return models.Label.add(label, context.internal); @@ -684,6 +705,9 @@ const toDoList = { members: function insertMembersAndLabelsAndProducts() { return fixtures.insertMembersAndLabelsAndProducts(false); }, + products: function insertProducts() { + return fixtures.insertProducts(); + }, newsletters: function insertNewsletters() { return fixtures.insertNewsletters(); }, diff --git a/ghost/member-attribution/lib/attribution.js b/ghost/member-attribution/lib/attribution.js index 077a6a3498..623f6fb55e 100644 --- a/ghost/member-attribution/lib/attribution.js +++ b/ghost/member-attribution/lib/attribution.js @@ -27,15 +27,16 @@ class Attribution { } /** - * Convert the instance to a parsed instance with more information about the resource included. + * Converts the instance to a parsed instance with more information about the resource included. * It does: - * - Fetch the resource and add some information about it to the attribution - * - If the resource exists and have a new url, it updates the url if possible + * - Uses the passed model and adds a title to the attribution + * - If the resource exists and has a new url, it updates the url if possible * - Returns an absolute URL instead of a relative one - * @returns {Promise} + * @param {Object|null} [model] The Post/User/Tag model of the resource associated with this attribution + * @returns {AttributionResource} */ - async getResource() { - if (!this.id || this.type === 'url' || !this.type) { + getResource(model) { + if (!this.id || this.type === 'url' || !this.type || !model) { return { id: null, type: 'url', @@ -44,19 +45,29 @@ class Attribution { }; } - const resource = await this.#urlTranslator.getResourceById(this.id, this.type, {absolute: true}); - - if (resource) { - return resource; - } + const updatedUrl = this.#urlTranslator.getUrlByResourceId(this.id, {absolute: true}); return { - id: null, - type: 'url', - url: this.#urlTranslator.relativeToAbsolute(this.url), - title: this.url + id: model.id, + type: this.type, + url: updatedUrl, + title: model.get('title') ?? model.get('name') ?? this.url }; } + + /** + * Same as getResource, but fetches the model by ID instead of passing it as a parameter + */ + async fetchResource() { + if (!this.id || this.type === 'url' || !this.type) { + // No fetch required + return this.getResource(); + } + + // Fetch model + const model = await this.#urlTranslator.getResourceById(this.id, this.type, {absolute: true}); + return this.getResource(model); + } } /** diff --git a/ghost/member-attribution/lib/service.js b/ghost/member-attribution/lib/service.js index c8b6124be9..2001ab32bd 100644 --- a/ghost/member-attribution/lib/service.js +++ b/ghost/member-attribution/lib/service.js @@ -24,13 +24,47 @@ class MemberAttributionService { return this.attributionBuilder.getAttribution(history); } + /** + * Returns the attribution resource for a given event model (MemberCreatedEvent / SubscriptionCreatedEvent), where the model has the required relations already loaded + * You need to already load the 'postAttribution', 'userAttribution', and 'tagAttribution' relations + * @param {Object} eventModel MemberCreatedEvent or SubscriptionCreatedEvent + * @returns {import('./attribution').AttributionResource|null} + */ + getEventAttribution(eventModel) { + if (eventModel.get('attribution_type') === null) { + return null; + } + + const _attribution = this.attributionBuilder.build({ + id: eventModel.get('attribution_id'), + url: eventModel.get('attribution_url'), + type: eventModel.get('attribution_type') + }); + + if (_attribution.type !== 'url') { + // Find the right relation to use to fetch the resource + const tryRelations = [ + eventModel.related('postAttribution'), + eventModel.related('userAttribution'), + eventModel.related('tagAttribution') + ]; + for (const relation of tryRelations) { + if (relation && relation.id) { + // We need to check the ID, because .related() always returs a model when eager loaded, even when the relation didn't exist + return _attribution.getResource(relation); + } + } + } + return _attribution.getResource(null); + } + /** * Returns the parsed attribution for a member creation event * @param {string} memberId * @returns {Promise} */ async getMemberCreatedAttribution(memberId) { - const memberCreatedEvent = await this.models.MemberCreatedEvent.findOne({member_id: memberId}, {require: false}); + const memberCreatedEvent = await this.models.MemberCreatedEvent.findOne({member_id: memberId}, {require: false, withRelated: []}); if (!memberCreatedEvent || !memberCreatedEvent.get('attribution_type')) { return null; } @@ -39,7 +73,7 @@ class MemberAttributionService { url: memberCreatedEvent.get('attribution_url'), type: memberCreatedEvent.get('attribution_type') }); - return await attribution.getResource(); + return await attribution.fetchResource(); } /** @@ -48,7 +82,7 @@ class MemberAttributionService { * @returns {Promise} */ async getSubscriptionCreatedAttribution(subscriptionId) { - const subscriptionCreatedEvent = await this.models.SubscriptionCreatedEvent.findOne({subscription_id: subscriptionId}, {require: false}); + const subscriptionCreatedEvent = await this.models.SubscriptionCreatedEvent.findOne({subscription_id: subscriptionId}, {require: false, withRelated: []}); if (!subscriptionCreatedEvent || !subscriptionCreatedEvent.get('attribution_type')) { return null; } @@ -57,7 +91,7 @@ class MemberAttributionService { url: subscriptionCreatedEvent.get('attribution_url'), type: subscriptionCreatedEvent.get('attribution_type') }); - return await attribution.getResource(); + return await attribution.fetchResource(); } } diff --git a/ghost/member-attribution/lib/url-translator.js b/ghost/member-attribution/lib/url-translator.js index 29be267b43..f5a021127b 100644 --- a/ghost/member-attribution/lib/url-translator.js +++ b/ghost/member-attribution/lib/url-translator.js @@ -6,8 +6,7 @@ */ /** - * Translate a url into a type and id - * And also in reverse + * Translate a url into, (id+type), or a resource, and vice versa */ class UrlTranslator { /** @@ -81,9 +80,11 @@ class UrlTranslator { } } - async getResourceById(id, type, options = {absolute: true}) { - const url = this.urlService.getUrlByResourceId(id, options); + getUrlByResourceId(id, options = {absolute: true}) { + return this.urlService.getUrlByResourceId(id, options); + } + async getResourceById(id, type) { switch (type) { case 'post': case 'page': { @@ -92,12 +93,7 @@ class UrlTranslator { return null; } - return { - id: post.id, - type, - url, - title: post.get('title') - }; + return post; } case 'author': { const user = await this.models.User.findOne({id}, {require: false}); @@ -105,12 +101,7 @@ class UrlTranslator { return null; } - return { - id: user.id, - type, - url, - title: user.get('name') - }; + return user; } case 'tag': { const tag = await this.models.Tag.findOne({id}, {require: false}); @@ -118,12 +109,7 @@ class UrlTranslator { return null; } - return { - id: tag.id, - type, - url, - title: tag.get('name') - }; + return tag; } } return null; diff --git a/ghost/member-attribution/test/attribution.test.js b/ghost/member-attribution/test/attribution.test.js index 78369c59cd..26cb282638 100644 --- a/ghost/member-attribution/test/attribution.test.js +++ b/ghost/member-attribution/test/attribution.test.js @@ -25,17 +25,20 @@ describe('AttributionBuilder', function () { } return; }, - getResourceById(id, type) { + getResourceById(id) { if (id === 'invalid') { return null; } return { id, - type, - url: 'https://absolute/dir/path', - title: 'Title' + get() { + return 'Title'; + } }; }, + getUrlByResourceId() { + return 'https://absolute/dir/path'; + }, relativeToAbsolute(path) { return 'https://absolute/dir' + path; }, @@ -105,7 +108,7 @@ describe('AttributionBuilder', function () { }); it('Returns post resource', async function () { - should(await attributionBuilder.build({type: 'post', id: '123', url: '/post'}).getResource()).match({ + should(await attributionBuilder.build({type: 'post', id: '123', url: '/post'}).fetchResource()).match({ type: 'post', id: '123', url: 'https://absolute/dir/path', @@ -114,7 +117,7 @@ describe('AttributionBuilder', function () { }); it('Returns url resource', async function () { - should(await attributionBuilder.build({type: 'url', id: null, url: '/url'}).getResource()).match({ + should(await attributionBuilder.build({type: 'url', id: null, url: '/url'}).fetchResource()).match({ type: 'url', id: null, url: 'https://absolute/dir/url', @@ -123,7 +126,7 @@ describe('AttributionBuilder', function () { }); it('Returns url resource if not found', async function () { - should(await attributionBuilder.build({type: 'post', id: 'invalid', url: '/post'}).getResource()).match({ + should(await attributionBuilder.build({type: 'post', id: 'invalid', url: '/post'}).fetchResource()).match({ type: 'url', id: null, url: 'https://absolute/dir/post', diff --git a/ghost/member-attribution/test/service.test.js b/ghost/member-attribution/test/service.test.js index 3abea1eb0b..30346df7c9 100644 --- a/ghost/member-attribution/test/service.test.js +++ b/ghost/member-attribution/test/service.test.js @@ -9,4 +9,97 @@ describe('MemberAttributionService', function () { new MemberAttributionService({}); }); }); + + describe('getEventAttribution', function () { + it('returns null if attribution_type is null', function () { + const service = new MemberAttributionService({}); + const model = { + id: 'event_id', + get() { + return null; + } + }; + should(service.getEventAttribution(model)).eql(null); + }); + + it('returns url attribution types', function () { + const service = new MemberAttributionService({ + attributionBuilder: { + build(attribution) { + return { + ...attribution, + getResource() { + return { + ...attribution, + title: 'added' + }; + } + }; + } + } + }); + const model = { + id: 'event_id', + get(name) { + if (name === 'attribution_type') { + return 'url'; + } + if (name === 'attribution_url') { + return '/my/url/'; + } + return null; + } + }; + should(service.getEventAttribution(model)).eql({ + id: null, + type: 'url', + url: '/my/url/', + title: 'added' + }); + }); + + it('returns first loaded relation', function () { + const service = new MemberAttributionService({ + attributionBuilder: { + build(attribution) { + return { + ...attribution, + getResource() { + return { + ...attribution, + title: 'added' + }; + } + }; + } + } + }); + const model = { + id: 'event_id', + get(name) { + if (name === 'attribution_type') { + return 'user'; + } + if (name === 'attribution_url') { + return '/my/url/'; + } + return 'test_user_id'; + }, + related(name) { + if (name === 'userAttribution') { + return { + id: 'test_user_id' + }; + } + return {}; + } + }; + should(service.getEventAttribution(model)).eql({ + id: 'test_user_id', + type: 'user', + url: '/my/url/', + title: 'added' + }); + }); + }); }); diff --git a/ghost/member-attribution/test/url-translator.test.js b/ghost/member-attribution/test/url-translator.test.js index 223f64d1bd..fe462eff45 100644 --- a/ghost/member-attribution/test/url-translator.test.js +++ b/ghost/member-attribution/test/url-translator.test.js @@ -111,38 +111,26 @@ describe('UrlTranslator', function () { }); it('returns for post', async function () { - should(await translator.getResourceById('id', 'post')).eql({ - type: 'post', - id: 'post_id', - title: 'Title', - url: '/path' + should(await translator.getResourceById('id', 'post')).match({ + id: 'post_id' }); }); it('returns for page', async function () { - should(await translator.getResourceById('id', 'page')).eql({ - type: 'page', - id: 'post_id', - title: 'Title', - url: '/path' + should(await translator.getResourceById('id', 'page')).match({ + id: 'post_id' }); }); it('returns for tag', async function () { - should(await translator.getResourceById('id', 'tag')).eql({ - type: 'tag', - id: 'tag_id', - title: 'Title', - url: '/path' + should(await translator.getResourceById('id', 'tag')).match({ + id: 'tag_id' }); }); it('returns for user', async function () { - should(await translator.getResourceById('id', 'author')).eql({ - type: 'author', - id: 'user_id', - title: 'Title', - url: '/path' + should(await translator.getResourceById('id', 'author')).match({ + id: 'user_id' }); }); diff --git a/ghost/members-api/lib/MembersAPI.js b/ghost/members-api/lib/MembersAPI.js index b6d4c93fbe..61e779734d 100644 --- a/ghost/members-api/lib/MembersAPI.js +++ b/ghost/members-api/lib/MembersAPI.js @@ -48,6 +48,8 @@ module.exports = function MembersAPI({ MemberProductEvent, MemberEmailChangeEvent, MemberAnalyticEvent, + MemberCreatedEvent, + SubscriptionCreatedEvent, Offer, OfferRedemption, StripeProduct, @@ -105,8 +107,11 @@ module.exports = function MembersAPI({ MemberPaymentEvent, MemberStatusEvent, MemberLoginEvent, + MemberCreatedEvent, + SubscriptionCreatedEvent, Comment, - labsService + labsService, + memberAttributionService }); const memberBREADService = new MemberBREADService({ diff --git a/ghost/members-api/lib/repositories/event.js b/ghost/members-api/lib/repositories/event.js index 8bd669839e..45203c4af5 100644 --- a/ghost/members-api/lib/repositories/event.js +++ b/ghost/members-api/lib/repositories/event.js @@ -8,9 +8,12 @@ module.exports = class EventRepository { MemberPaymentEvent, MemberStatusEvent, MemberLoginEvent, + MemberCreatedEvent, + SubscriptionCreatedEvent, MemberPaidSubscriptionEvent, Comment, - labsService + labsService, + memberAttributionService }) { this._MemberSubscribeEvent = MemberSubscribeEvent; this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent; @@ -20,6 +23,9 @@ module.exports = class EventRepository { this._EmailRecipient = EmailRecipient; this._Comment = Comment; this._labsService = labsService; + this._MemberCreatedEvent = MemberCreatedEvent; + this._SubscriptionCreatedEvent = SubscriptionCreatedEvent; + this._memberAttributionService = memberAttributionService; } async registerPayment(data) { @@ -62,9 +68,38 @@ module.exports = class EventRepository { } async getSubscriptionEvents(options = {}, filters = {}) { + if (!this._labsService.isSet('memberAttribution')){ + options = { + ...options, + withRelated: ['member'], + filter: [] + }; + if (filters['data.created_at']) { + options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'created_at:')); + } + if (filters['data.member_id']) { + options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:')); + } + options.filter = options.filter.join('+'); + + const {data: models, meta} = await this._MemberPaidSubscriptionEvent.findPage(options); + + const data = models.map((model) => { + return { + type: 'subscription_event', + data: model.toJSON(options) + }; + }); + + return { + data, + meta + }; + } + options = { ...options, - withRelated: ['member'], + withRelated: ['member', 'subscriptionCreatedEvent.postAttribution', 'subscriptionCreatedEvent.userAttribution', 'subscriptionCreatedEvent.tagAttribution'], filter: [] }; if (filters['data.created_at']) { @@ -80,7 +115,10 @@ module.exports = class EventRepository { const data = models.map((model) => { return { type: 'subscription_event', - data: model.toJSON(options) + data: { + ...model.toJSON(options), + attribution: model.get('type') === 'created' && model.related('subscriptionCreatedEvent') && model.related('subscriptionCreatedEvent').id ? this._memberAttributionService.getEventAttribution(model.related('subscriptionCreatedEvent')) : null + } }; }); @@ -149,10 +187,39 @@ module.exports = class EventRepository { } async getSignupEvents(options = {}, filters = {}) { + if (!this._labsService.isSet('memberAttribution')){ + options = { + ...options, + withRelated: ['member'], + filter: ['from_status:null'] + }; + if (filters['data.created_at']) { + options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'created_at:')); + } + if (filters['data.member_id']) { + options.filter.push(filters['data.member_id'].replace(/data.member_id:/g, 'member_id:')); + } + options.filter = options.filter.join('+'); + + const {data: models, meta} = await this._MemberStatusEvent.findPage(options); + + const data = models.map((model) => { + return { + type: 'signup_event', + data: model.toJSON(options) + }; + }); + + return { + data, + meta + }; + } + options = { ...options, - withRelated: ['member'], - filter: ['from_status:null'] + withRelated: ['member', 'postAttribution', 'userAttribution', 'tagAttribution'], + filter: [] }; if (filters['data.created_at']) { options.filter.push(filters['data.created_at'].replace(/data.created_at:/g, 'created_at:')); @@ -162,12 +229,15 @@ module.exports = class EventRepository { } options.filter = options.filter.join('+'); - const {data: models, meta} = await this._MemberStatusEvent.findPage(options); + const {data: models, meta} = await this._MemberCreatedEvent.findPage(options); const data = models.map((model) => { return { type: 'signup_event', - data: model.toJSON(options) + data: { + ...model.toJSON(options), + attribution: this._memberAttributionService.getEventAttribution(model) + } }; });