diff --git a/ghost/core/core/server/models/subscription-created-event.js b/ghost/core/core/server/models/subscription-created-event.js index 7dd1dc26ad3..347bf7d25d7 100644 --- a/ghost/core/core/server/models/subscription-created-event.js +++ b/ghost/core/core/server/models/subscription-created-event.js @@ -3,6 +3,7 @@ const ghostBookshelf = require('./base'); const SubscriptionCreatedEvent = ghostBookshelf.Model.extend({ tableName: 'members_subscription_created_events', + hasTimestamps: false, // if true (default), requires an updated_at column and schema changes member() { return this.belongsTo('Member', 'member_id', 'id'); diff --git a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap index e9548f7d9a4..0287148a58a 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap @@ -48,54 +48,6 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with author attribution 3: [body] 1`] = ` -Object { - "members": Array [ - Object { - "attribution": Any, - "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, - "email_suppression": Object { - "info": null, - "suppressed": false, - }, - "geolocation": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "labels": Any, - "last_seen_at": null, - "name": null, - "newsletters": Any, - "note": null, - "status": "paid", - "subscribed": false, - "subscriptions": Any, - "tiers": Any, - "unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme", - "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 4: [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": "2384", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, 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 [ @@ -144,54 +96,6 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with deleted post attribution 3: [body] 1`] = ` -Object { - "members": Array [ - Object { - "attribution": Any, - "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, - "email_suppression": Object { - "info": null, - "suppressed": false, - }, - "geolocation": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "labels": Any, - "last_seen_at": null, - "name": null, - "newsletters": Any, - "note": null, - "status": "paid", - "subscribed": false, - "subscriptions": Any, - "tiers": Any, - "unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme", - "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 4: [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": "2391", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, 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 [ @@ -240,54 +144,6 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with empty attribution object 3: [body] 1`] = ` -Object { - "members": Array [ - Object { - "attribution": Any, - "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, - "email_suppression": Object { - "info": null, - "suppressed": false, - }, - "geolocation": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "labels": Any, - "last_seen_at": null, - "name": null, - "newsletters": Any, - "note": null, - "status": "paid", - "subscribed": false, - "subscriptions": Any, - "tiers": Any, - "unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme", - "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 4: [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": "2335", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with page attribution 1: [body] 1`] = ` Object { "members": Array [ @@ -336,54 +192,6 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with page attribution 3: [body] 1`] = ` -Object { - "members": Array [ - Object { - "attribution": Any, - "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, - "email_suppression": Object { - "info": null, - "suppressed": false, - }, - "geolocation": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "labels": Any, - "last_seen_at": null, - "name": null, - "newsletters": Any, - "note": null, - "status": "paid", - "subscribed": false, - "subscriptions": Any, - "tiers": Any, - "unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme", - "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 4: [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": "2415", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with post attribution 1: [body] 1`] = ` Object { "members": Array [ @@ -432,54 +240,6 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with post attribution 3: [body] 1`] = ` -Object { - "members": Array [ - Object { - "attribution": Any, - "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, - "email_suppression": Object { - "info": null, - "suppressed": false, - }, - "geolocation": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "labels": Any, - "last_seen_at": null, - "name": null, - "newsletters": Any, - "note": null, - "status": "paid", - "subscribed": false, - "subscriptions": Any, - "tiers": Any, - "unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme", - "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 4: [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": "2398", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 1: [body] 1`] = ` Object { "members": Array [ @@ -528,7 +288,7 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 3: [body] 1`] = ` +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 1: [body] 1`] = ` Object { "members": Array [ Object { @@ -552,7 +312,7 @@ Object { "newsletters": Any, "note": null, "status": "paid", - "subscribed": false, + "subscribed": true, "subscriptions": Any, "tiers": Any, "unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme", @@ -563,11 +323,11 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 4: [headers] 1`] = ` +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": "2405", + "content-length": "2595", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -576,7 +336,7 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 1: [body] 1`] = ` +exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 1: [body] 1`] = ` Object { "members": Array [ Object { @@ -611,11 +371,11 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 2: [headers] 1`] = ` +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": "2595", + "content-length": "2541", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -624,46 +384,60 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 3: [body] 1`] = ` +exports[`Members API Member attribution Returns subscription created attributions in activity feed 1: [body] 1`] = ` Object { - "members": Array [ + "events": Array [ Object { - "attribution": Any, - "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, - "email_suppression": Object { - "info": null, - "suppressed": false, - }, - "geolocation": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "labels": Any, - "last_seen_at": null, - "name": null, - "newsletters": Any, - "note": null, - "status": "paid", - "subscribed": false, - "subscriptions": Any, - "tiers": Any, - "unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme", - "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\\}/, + "data": Any, + "type": "subscription_event", + }, + Object { + "data": Any, + "type": "subscription_event", + }, + Object { + "data": Any, + "type": "subscription_event", + }, + Object { + "data": Any, + "type": "subscription_event", + }, + Object { + "data": Any, + "type": "subscription_event", + }, + Object { + "data": Any, + "type": "subscription_event", + }, + Object { + "data": Any, + "type": "subscription_event", + }, + Object { + "data": Any, + "type": "subscription_event", }, ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 8, + }, + }, } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 4: [headers] 1`] = ` +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": "2362", + "content-length": "5664", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -672,7 +446,7 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 1: [body] 1`] = ` +exports[`Members API Member attribution Updates attribution when a member already exists 1: [body] 1`] = ` Object { "members": Array [ Object { @@ -707,7 +481,7 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 2: [headers] 1`] = ` +exports[`Members API Member attribution Updates attribution when a member already exists 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", @@ -720,7 +494,7 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 3: [body] 1`] = ` +exports[`Members API Member attribution Updates attribution when a member already exists 3: [body] 1`] = ` Object { "members": Array [ Object { @@ -744,7 +518,7 @@ Object { "newsletters": Any, "note": null, "status": "paid", - "subscribed": false, + "subscribed": true, "subscriptions": Any, "tiers": Any, "unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme", @@ -755,118 +529,11 @@ Object { } `; -exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 4: [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": "2335", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, 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": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - Object { - "data": Any, - "type": "subscription_event", - }, - ], - "meta": Object { - "pagination": Object { - "limit": "100", - "next": null, - "page": null, - "pages": 1, - "prev": null, - "total": 16, - }, - }, -} -`; - -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": "7027", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Members API Member attribution Returns subscription created attributions in activity feed 2: [headers] 1`] = ` +exports[`Members API Member attribution Updates attribution when a member already exists 4: [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": "11237", + "content-length": "2568", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/members/webhooks.test.js b/ghost/core/test/e2e-api/members/webhooks.test.js index 4f59a80aeb9..56ef2396ac3 100644 --- a/ghost/core/test/e2e-api/members/webhooks.test.js +++ b/ghost/core/test/e2e-api/members/webhooks.test.js @@ -58,6 +58,27 @@ describe('Members API', function () { const coupon = {}; beforeEach(function () { + nock.restore(); + nock.activate(); + + nock('https://api.stripe.com') + .persist() + .get(/v1\/customers\/cust_[a-z0-9]+\?.*/) + .reply(function (uri) { + const customerId = uri.split('/')[3].split('?')[0]; + if (customer.id !== customerId) { + return [404]; + } + return [200, { + ...customer, + subscriptions: { + type: 'list', + data: [subscription] + } + }]; + }); + + // Then handle all other requests nock('https://api.stripe.com') .persist() .get(/v1\/.*/) @@ -120,6 +141,21 @@ describe('Members API', function () { return [200, coupon]; } + if (resource === 'prices') { + return [200, { + id: 'price_123', + product: 'product_123', + active: true, + nickname: 'month', + currency: 'usd', + recurring: { + interval: 'month' + }, + unit_amount: 150, + type: 'recurring' + }]; + } + return [500]; }); @@ -127,6 +163,7 @@ describe('Members API', function () { }); afterEach(function () { + nock.cleanAll(); mockManager.restore(); }); @@ -707,10 +744,6 @@ describe('Members API', function () { asserts: [ { from_status: null, - to_status: 'free' - }, - { - from_status: 'free', to_status: 'comped' }, { @@ -1855,9 +1888,9 @@ describe('Members API', function () { }); }); - async function testAttributionOnSignup(attribution, attributionResource) { - const customer_id = createStripeID('cust'); - const subscription_id = createStripeID('sub'); + async function testWithAttribution(attribution, attributionResource, customerId, subscriptionId, options = {}) { + const customer_id = customerId || createStripeID('cust'); + const subscription_id = subscriptionId || createStripeID('sub'); const interval = 'month'; const unit_amount = 150; @@ -1900,31 +1933,7 @@ describe('Members API', function () { } }); - // Stripe first sends a customer.subscription.created webhook - const subscriptionWebhookPayload = JSON.stringify({ - type: 'customer.subscription.created', - data: { - object: subscription - } - }); - - const subscriptionWebhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: subscriptionWebhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(subscriptionWebhookPayload) - .header('content-type', 'application/json') - .header('stripe-signature', subscriptionWebhookSignature) - .expectStatus(200); - - // This should not create a member in the database yet - const {body: bodyAfterSubscriptionWebhook} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); - assert.equal(bodyAfterSubscriptionWebhook.members.length, 0, 'A member was created before the checkout.session.completed webhook was sent'); - - // Then it sends a checkout.session.completed webhook - const checkoutWebhookPayload = JSON.stringify({ + let webhookPayload = JSON.stringify({ type: 'checkout.session.completed', data: { object: { @@ -1940,15 +1949,15 @@ describe('Members API', function () { } }); - const checkoutWebhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: checkoutWebhookPayload, + let webhookSignature = stripe.webhooks.generateTestHeaderString({ + payload: webhookPayload, secret: process.env.WEBHOOK_SECRET }); await membersAgent.post('/webhooks/stripe/') - .body(checkoutWebhookPayload) + .body(webhookPayload) .header('content-type', 'application/json') - .header('stripe-signature', checkoutWebhookSignature) + .header('stripe-signature', webhookSignature) .expectStatus(200); const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); @@ -1979,24 +1988,25 @@ describe('Members API', function () { 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' - } - ] - }); + // Only check MemberCreatedEvent if not skipped + if (!options.existingMember) { + 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}/`) @@ -2009,7 +2019,11 @@ describe('Members API', function () { etag: anyEtag }) .expect(({body: body3}) => { - should(body3.members[0].attribution).eql(attributionResource); + if (!options.existingMember) { + // For new members, the attribution is set on the member object and should match the subscription (SubscriptionCreatedEvent) + should(body3.members[0].attribution).eql(attributionResource); + } + // For existing members, the attribution is set only on the subscription object and may not match the member.attribution (from MemberCreatedEvent) should(body3.members[0].subscriptions[0].attribution).eql(attributionResource); subscriptionAttributions.push(body3.members[0].subscriptions[0].attribution); }); @@ -2017,161 +2031,6 @@ describe('Members API', function () { return memberModel; } - async function testAttributionOnUpgrade(attribution, attributionResource) { - const customer_id = createStripeID('cust'); - const subscription_id = createStripeID('sub'); - - const interval = 'month'; - const unit_amount = 150; - - // Create initial free member - const initialFreeMember = await models.Member.add({ - email: `${customer_id}@email.com`, - status: 'free', - email_disabled: false - }); - - // Create a Stripe Customer too for the free member, as this is created during Stripe Checkout, i.e. before receiving Stripe webhooks - await models.MemberStripeCustomer.add({ - member_id: initialFreeMember.id, - customer_id: customer_id, - email: initialFreeMember.get('email') - }); - - 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] - } - }); - - // Stripe first sends a customer.subscription.created webhook - const subscriptionWebhookPayload = JSON.stringify({ - type: 'customer.subscription.created', - data: { - object: subscription - } - }); - - const subscriptionWebhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: subscriptionWebhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(subscriptionWebhookPayload) - .header('content-type', 'application/json') - .header('stripe-signature', subscriptionWebhookSignature) - .expectStatus(200); - - // This should not create a member subscription in the database yet - const {body: bodyAfterSubscriptionWebhook} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); - const memberAfterSubscriptionWebhook = bodyAfterSubscriptionWebhook.members[0]; - - assert.equal(memberAfterSubscriptionWebhook.subscriptions.length, 0, 'The member should not have a subscription after customer.subscription.updated'); - assert.equal(memberAfterSubscriptionWebhook.status, 'free', 'The member status should still be "free" after customer.subscription.updated'); - - // Then it sends a checkout.session.completed webhook - const checkoutWebhookPayload = 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 - } : {} - } - } - }); - - const checkoutWebhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: checkoutWebhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(checkoutWebhookPayload) - .header('content-type', 'application/json') - .header('stripe-signature', checkoutWebhookSignature) - .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" after checkout.session.completed'); - assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription after checkout.session.completed'); - - // 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 - } - ] - }); - - await adminAgent - .get(`/members/${member.id}/`) - .expectStatus(200) - .matchBodySnapshot({ - members: new Array(1).fill(memberMatcherShallowIncludes) - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .expect(({body: body3}) => { - should(body3.members[0].subscriptions[0].attribution).eql(attributionResource); - subscriptionAttributions.push(body3.members[0].subscriptions[0].attribution); - }); - } - const subscriptionAttributions = []; it('Creates a SubscriptionCreatedEvent with url attribution', async function () { @@ -2184,7 +2043,7 @@ describe('Members API', function () { const absoluteUrl = urlUtils.createUrl('/', true); - const attributionResource = { + await testWithAttribution(attribution, { id: null, url: absoluteUrl, type: 'url', @@ -2192,10 +2051,7 @@ describe('Members API', function () { referrer_source: null, referrer_medium: null, referrer_url: null - }; - - await testAttributionOnSignup(attribution, attributionResource); - await testAttributionOnUpgrade(attribution, attributionResource); + }); }); it('Creates a SubscriptionCreatedEvent with post attribution', async function () { @@ -2210,7 +2066,7 @@ describe('Members API', function () { const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true}); - const attributionResource = { + await testWithAttribution(attribution, { id: post.id, url: absoluteUrl, type: 'post', @@ -2218,10 +2074,7 @@ describe('Members API', function () { referrer_source: null, referrer_medium: null, referrer_url: null - }; - - await testAttributionOnSignup(attribution, attributionResource); - await testAttributionOnUpgrade(attribution, attributionResource); + }); }); it('Creates a SubscriptionCreatedEvent with deleted post attribution', async function () { @@ -2233,7 +2086,7 @@ describe('Members API', function () { const absoluteUrl = urlUtils.createUrl('/removed-blog-post/', true); - const attributionResource = { + await testWithAttribution(attribution, { id: null, url: absoluteUrl, type: 'url', @@ -2241,10 +2094,7 @@ describe('Members API', function () { referrer_source: null, referrer_medium: null, referrer_url: null - }; - - await testAttributionOnSignup(attribution, attributionResource); - await testAttributionOnUpgrade(attribution, attributionResource); + }); }); it('Creates a SubscriptionCreatedEvent with page attribution', async function () { @@ -2259,7 +2109,7 @@ describe('Members API', function () { const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true}); - const attributionResource = { + await testWithAttribution(attribution, { id: post.id, url: absoluteUrl, type: 'page', @@ -2267,10 +2117,7 @@ describe('Members API', function () { referrer_source: null, referrer_medium: null, referrer_url: null - }; - - await testAttributionOnSignup(attribution, attributionResource); - await testAttributionOnUpgrade(attribution, attributionResource); + }); }); it('Creates a SubscriptionCreatedEvent with tag attribution', async function () { @@ -2285,7 +2132,7 @@ describe('Members API', function () { const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true}); - const attributionResource = { + await testWithAttribution(attribution, { id: tag.id, url: absoluteUrl, type: 'tag', @@ -2293,10 +2140,7 @@ describe('Members API', function () { referrer_source: null, referrer_medium: null, referrer_url: null - }; - - await testAttributionOnSignup(attribution, attributionResource); - await testAttributionOnUpgrade(attribution, attributionResource); + }); }); it('Creates a SubscriptionCreatedEvent with author attribution', async function () { @@ -2311,7 +2155,7 @@ describe('Members API', function () { const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true}); - const attributionResource = { + await testWithAttribution(attribution, { id: author.id, url: absoluteUrl, type: 'author', @@ -2319,16 +2163,28 @@ describe('Members API', function () { referrer_source: null, referrer_medium: null, referrer_url: null - }; - - await testAttributionOnSignup(attribution, attributionResource); - await testAttributionOnUpgrade(attribution, attributionResource); + }); }); it('Creates a SubscriptionCreatedEvent without attribution', async function () { const attribution = undefined; + await testWithAttribution(attribution, { + id: null, + url: null, + type: null, + title: null, + referrer_source: null, + referrer_medium: null, + referrer_url: null + }); + }); - const attributionResource = { + it('Updates attribution when a member already exists', async function () { + const customerId = createStripeID('cust'); + const subscriptionId = createStripeID('sub'); + + // First call - creates member with no attribution + await testWithAttribution({}, { id: null, url: null, type: null, @@ -2336,17 +2192,31 @@ describe('Members API', function () { referrer_source: null, referrer_medium: null, referrer_url: null + }, customerId, subscriptionId); + + const attribution = { + id: null, + url: '/', + type: 'url' }; + const absoluteUrl = urlUtils.createUrl('/', true); - await testAttributionOnSignup(attribution, attributionResource); - await testAttributionOnUpgrade(attribution, attributionResource); + // Second call - updates subscription attribution + await testWithAttribution(attribution, { + id: null, + url: absoluteUrl, + type: 'url', + title: 'homepage', + referrer_source: null, + referrer_medium: null, + referrer_url: null + }, customerId, subscriptionId, {existingMember: true}); }); it('Creates a SubscriptionCreatedEvent with empty attribution object', async function () { // Shouldn't happen, but to make sure we handle it const attribution = {}; - - const attributionResource = { + await testWithAttribution(attribution, { id: null, url: null, type: null, @@ -2354,10 +2224,7 @@ describe('Members API', function () { referrer_source: null, referrer_medium: null, referrer_url: null - }; - - await testAttributionOnSignup(attribution, attributionResource); - await testAttributionOnUpgrade(attribution, attributionResource); + }); }); // Activity feed @@ -2365,7 +2232,7 @@ describe('Members API', function () { it('Returns subscription created attributions in activity feed', async function () { // Check activity feed await adminAgent - .get(`/members/events/?filter=type:subscription_event&limit=100`) + .get(`/members/events/?filter=type:subscription_event`) .expectStatus(200) .matchHeaderSnapshot({ 'content-version': anyContentVersion, @@ -2373,8 +2240,7 @@ describe('Members API', function () { }) .matchBodySnapshot({ events: new Array(subscriptionAttributions.length).fill({ - data: anyObject, - type: 'subscription_event' + data: anyObject }) }) .expect(({body}) => { diff --git a/ghost/member-events/index.js b/ghost/member-events/index.js index 233b401db1a..e7644022c68 100644 --- a/ghost/member-events/index.js +++ b/ghost/member-events/index.js @@ -9,6 +9,7 @@ module.exports = { MemberPageViewEvent: require('./lib/MemberPageViewEvent'), MemberCommentEvent: require('./lib/MemberCommentEvent'), SubscriptionCreatedEvent: require('./lib/SubscriptionCreatedEvent'), + SubscriptionAttributionEvent: require('./lib/SubscriptionAttributionEvent'), SubscriptionActivatedEvent: require('./lib/SubscriptionActivatedEvent'), SubscriptionCancelledEvent: require('./lib/SubscriptionCancelledEvent'), OfferRedemptionEvent: require('./lib/OfferRedemptionEvent'), diff --git a/ghost/member-events/lib/SubscriptionAttributionEvent.js b/ghost/member-events/lib/SubscriptionAttributionEvent.js new file mode 100644 index 00000000000..b6154391a90 --- /dev/null +++ b/ghost/member-events/lib/SubscriptionAttributionEvent.js @@ -0,0 +1,26 @@ +/** + * Fired when we receive attribution data for a subscription + * + * @typedef {object} SubscriptionAttributionEventData + * @prop {string} subscriptionId + * @prop {import('@tryghost/member-attribution/lib/Attribution').Attribution} attribution + */ + +module.exports = class SubscriptionAttributionEvent { + /** + * @param {SubscriptionAttributionEventData} data + * @param {Date} timestamp + */ + constructor(data, timestamp) { + this.data = data; + this.timestamp = timestamp; + } + + /** + * @param {SubscriptionAttributionEventData} data + * @param {Date} [timestamp] + */ + static create(data, timestamp) { + return new SubscriptionAttributionEvent(data, timestamp ?? new Date); + } +}; diff --git a/ghost/members-api/lib/repositories/MemberRepository.js b/ghost/members-api/lib/repositories/MemberRepository.js index ada94f69cf7..d84a38aab38 100644 --- a/ghost/members-api/lib/repositories/MemberRepository.js +++ b/ghost/members-api/lib/repositories/MemberRepository.js @@ -3,7 +3,7 @@ const errors = require('@tryghost/errors'); const logging = require('@tryghost/logging'); const tpl = require('@tryghost/tpl'); const DomainEvents = require('@tryghost/domain-events'); -const {SubscriptionActivatedEvent, MemberCreatedEvent, SubscriptionCreatedEvent, MemberSubscribeEvent, SubscriptionCancelledEvent, OfferRedemptionEvent} = require('@tryghost/member-events'); +const {SubscriptionActivatedEvent, MemberCreatedEvent, SubscriptionCreatedEvent, SubscriptionAttributionEvent, MemberSubscribeEvent, SubscriptionCancelledEvent, OfferRedemptionEvent} = require('@tryghost/member-events'); const ObjectId = require('bson-objectid').default; const {NotFoundError} = require('@tryghost/errors'); const validator = require('@tryghost/validator'); @@ -885,6 +885,18 @@ module.exports = class MemberRepository { return subscription; } + /** + * @param {string} subscriptionId - The stripe subscription id + * @param {import('@tryghost/member-attribution/lib/Attribution').AttributionResource} attribution + * @returns {Promise} + */ + async updateSubscriptionAttribution(subscriptionId, attribution) { + const event = SubscriptionAttributionEvent.create({ + subscriptionId, attribution + }); + DomainEvents.dispatch(event); + } + /** * * @param {Object} data diff --git a/ghost/members-events-service/lib/EventStorage.js b/ghost/members-events-service/lib/EventStorage.js index 7f987947573..5ca01ba1bdd 100644 --- a/ghost/members-events-service/lib/EventStorage.js +++ b/ghost/members-events-service/lib/EventStorage.js @@ -1,4 +1,4 @@ -const {MemberCreatedEvent, SubscriptionCreatedEvent} = require('@tryghost/member-events'); +const {MemberCreatedEvent, SubscriptionCreatedEvent, SubscriptionAttributionEvent} = require('@tryghost/member-events'); /** * Store events in the database @@ -55,6 +55,27 @@ class EventStorage { batch_id: event.data.batchId ?? null }); }); + + domainEvents.subscribe(SubscriptionAttributionEvent, async (event) => { + let attribution = event.data.attribution; + + const subscriptionCreatedEvent = await this.models.SubscriptionCreatedEvent + .findOne({subscription_id: event.data.subscriptionId}, {require: false, withRelated: []}); + + if (!subscriptionCreatedEvent) { + return; + } + + const original = subscriptionCreatedEvent.toJSON(); + await subscriptionCreatedEvent.save({ + attribution_id: attribution?.id ?? original.attribution_id, + attribution_url: attribution?.url ?? original.attribution_url, + attribution_type: attribution?.type ?? original.attribution_type, + referrer_source: attribution?.referrerSource ?? original.referrer_source, + referrer_medium: attribution?.referrerMedium ?? original.referrer_medium, + referrer_url: attribution?.referrerUrl ?? original.referrer_url + }, {patch: true}); + }); } } diff --git a/ghost/members-events-service/test/event-storage.test.js b/ghost/members-events-service/test/event-storage.test.js index 7a7f93dfd36..e88440c1006 100644 --- a/ghost/members-events-service/test/event-storage.test.js +++ b/ghost/members-events-service/test/event-storage.test.js @@ -1,7 +1,7 @@ // Switch these lines once there are useful utils // const testUtils = require('./utils'); require('./utils'); -const {MemberCreatedEvent, SubscriptionCreatedEvent} = require('@tryghost/member-events'); +const {MemberCreatedEvent, SubscriptionCreatedEvent, SubscriptionAttributionEvent} = require('@tryghost/member-events'); const EventStorage = require('../lib/EventStorage'); describe('EventStorage', function () { @@ -37,7 +37,7 @@ describe('EventStorage', function () { created_at: new Date(0), source: 'test' }); - sinon.assert.calledTwice(subscribeSpy); + sinon.assert.calledThrice(subscribeSpy); }); it('passes custom attributions', function () { @@ -73,7 +73,7 @@ describe('EventStorage', function () { attribution_url: 'url', source: 'test' }); - sinon.assert.calledTwice(subscribeSpy); + sinon.assert.calledThrice(subscribeSpy); }); it('filters if disabled', function () { @@ -112,7 +112,7 @@ describe('EventStorage', function () { referrer_medium: null, referrer_url: null }); - sinon.assert.calledTwice(subscribeSpy); + sinon.assert.calledThrice(subscribeSpy); }); }); @@ -142,7 +142,7 @@ describe('EventStorage', function () { subscription_id: '456', created_at: new Date(0) }); - sinon.assert.calledTwice(subscribeSpy); + sinon.assert.calledThrice(subscribeSpy); }); it('passes custom attributions', function () { @@ -178,7 +178,7 @@ describe('EventStorage', function () { attribution_type: 'post', attribution_url: 'url' }); - sinon.assert.calledTwice(subscribeSpy); + sinon.assert.calledThrice(subscribeSpy); }); it('works with flag disabled', function () { @@ -217,7 +217,70 @@ describe('EventStorage', function () { referrer_medium: null, referrer_url: null }); - sinon.assert.calledTwice(subscribeSpy); + sinon.assert.calledThrice(subscribeSpy); + }); + }); + + describe('SubscriptionAttributionEvent handling', function () { + let DomainEvents, saveStub, SubscriptionCreatedEventModel, eventHandler, subscribeSpy, handlerCallback; + + beforeEach(function () { + saveStub = sinon.stub().resolves(); + DomainEvents = { + subscribe: (type, handler) => { + if (type === SubscriptionAttributionEvent) { + handlerCallback = handler; + } + } + }; + subscribeSpy = sinon.spy(DomainEvents, 'subscribe'); + SubscriptionCreatedEventModel = { + findOne: sinon.stub() + }; + eventHandler = new EventStorage({ + models: {SubscriptionCreatedEvent: SubscriptionCreatedEventModel} + }); + }); + + it('updates the attribution', async function () { + const toJSONStub = sinon.stub().returns({ + attribution_id: null, + attribution_type: null, + attribution_url: null, + referrer_source: null, + referrer_medium: null, + referrer_url: null + }); + SubscriptionCreatedEventModel.findOne.resolves({id: '123', toJSON: toJSONStub, save: saveStub}); + + eventHandler.subscribe(DomainEvents); + await handlerCallback(SubscriptionAttributionEvent.create({ + memberId: '123', + subscriptionId: '456', + attribution: {id: '123', type: 'post', url: 'url'} + }, new Date(0))); + + sinon.assert.calledOnceWithMatch(SubscriptionCreatedEventModel.findOne, {subscription_id: '456'}); + sinon.assert.calledOnceWithMatch(saveStub, { + attribution_id: '123', + attribution_type: 'post', + attribution_url: 'url', + referrer_source: 'source', + referrer_medium: 'referral', + referrer_url: 'https://ghost.org' + }, {patch: true}); + sinon.assert.calledThrice(subscribeSpy); + }); + + it('does not update if the subscription is not found', async function () { + SubscriptionCreatedEventModel.findOne.resolves(null); + eventHandler.subscribe(DomainEvents); + await handlerCallback(SubscriptionAttributionEvent.create({ + memberId: '123', + subscriptionId: '456', + attribution: {id: '123', type: 'post', url: 'url'} + }, new Date(0))); + sinon.assert.notCalled(saveStub); }); }); }); diff --git a/ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js b/ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js index 257f25f19e8..72bf28b2657 100644 --- a/ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js +++ b/ghost/stripe/lib/services/webhook/CheckoutSessionEventService.js @@ -192,8 +192,7 @@ module.exports = class CheckoutSessionEventService { await memberRepository.linkSubscription({ id: member.id, subscription, - offerId, - attribution + offerId }); } catch (err) { if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') { @@ -204,6 +203,9 @@ module.exports = class CheckoutSessionEventService { }); } } + + const subscriptionModel = await memberRepository.getSubscriptionByStripeID(session.subscription); + await memberRepository.updateSubscriptionAttribution(subscriptionModel.id, attribution); } if (checkoutType !== 'upgrade') { diff --git a/ghost/stripe/lib/services/webhook/SubscriptionEventService.js b/ghost/stripe/lib/services/webhook/SubscriptionEventService.js index 26e531a5c07..8432e44b8c7 100644 --- a/ghost/stripe/lib/services/webhook/SubscriptionEventService.js +++ b/ghost/stripe/lib/services/webhook/SubscriptionEventService.js @@ -1,6 +1,5 @@ const errors = require('@tryghost/errors'); const _ = require('lodash'); -const logging = require('@tryghost/logging'); module.exports = class SubscriptionEventService { constructor(deps) { this.deps = deps; @@ -19,35 +18,18 @@ module.exports = class SubscriptionEventService { customer_id: subscription.customer }); - // After checkout, Stripe sends `customer.subscription.created`, `customer.subscription.updated` and `checkout.session.completed` events - // We want to create a member and its related subscription in the database based on the `checkout.session.completed` event as it contains additional information on the subscription (e.g. attribution data) - // Therefore, if the member or the subscription does not exist in the database yet, we ignore `customer.subscription.*` events, to avoid creating subscriptions with missing data - if (!member) { - logging.info(`Ignoring customer.subscription.* event as member does not exist`); - return; - } - - const memberSubscription = await member.related('stripeSubscriptions').query({ - where: { - subscription_id: subscription.id - } - }).fetchOne(); - - if (!memberSubscription) { - logging.info(`Ignoring customer.subscription.* event as member subscription does not exist`); - return; - } - - try { - await memberRepository.linkSubscription({ - id: member.id, - subscription - }); - } catch (err) { - if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') { - throw err; + if (member) { + try { + await memberRepository.linkSubscription({ + id: member.id, + subscription + }); + } catch (err) { + if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') { + throw err; + } + throw new errors.ConflictError({err}); } - throw new errors.ConflictError({err}); } } }; diff --git a/ghost/stripe/test/hello.test.js b/ghost/stripe/test/hello.test.js deleted file mode 100644 index 85d69d1e08c..00000000000 --- a/ghost/stripe/test/hello.test.js +++ /dev/null @@ -1,10 +0,0 @@ -// Switch these lines once there are useful utils -// const testUtils = require('./utils'); -require('./utils'); - -describe('Hello world', function () { - it('Runs a test', function () { - // TODO: Write me! - 'hello'.should.eql('hello'); - }); -}); diff --git a/ghost/stripe/test/unit/lib/services/webhooks/CheckoutSessionEventService.test.js b/ghost/stripe/test/unit/lib/services/webhooks/CheckoutSessionEventService.test.js index 5819f61266f..933174d68de 100644 --- a/ghost/stripe/test/unit/lib/services/webhooks/CheckoutSessionEventService.test.js +++ b/ghost/stripe/test/unit/lib/services/webhooks/CheckoutSessionEventService.test.js @@ -1,5 +1,6 @@ const assert = require('assert/strict'); const errors = require('@tryghost/errors'); +const sinon = require('sinon'); const CheckoutSessionEventService = require('../../../../../lib/services/webhook/CheckoutSessionEventService'); describe('CheckoutSessionEventService', function () { @@ -19,7 +20,11 @@ describe('CheckoutSessionEventService', function () { create: sinon.stub(), update: sinon.stub(), linkSubscription: sinon.stub(), - upsertCustomer: sinon.stub() + upsertCustomer: sinon.stub(), + updateSubscriptionAttribution: sinon.stub(), + getSubscriptionByStripeID: sinon.stub().callsFake((id) => { + return {id}; + }) }; donationRepository = { @@ -636,6 +641,15 @@ describe('CheckoutSessionEventService', function () { assert.equal(memberData.newsletters, undefined); }); + it('should update subscription attribution', async function () { + api.getCustomer.resolves(customer); + memberRepository.get.resolves(member); + + await service.handleSubscriptionEvent(session); + + assert(memberRepository.updateSubscriptionAttribution.calledOnce); + }); + it('should update member if found', async function () { api.getCustomer.resolves(customer); memberRepository.get.resolves(member); diff --git a/ghost/stripe/test/unit/lib/services/webhooks/SubscriptionEventService.test.js b/ghost/stripe/test/unit/lib/services/webhooks/SubscriptionEventService.test.js index 39a7fe044f2..42beff74a8b 100644 --- a/ghost/stripe/test/unit/lib/services/webhooks/SubscriptionEventService.test.js +++ b/ghost/stripe/test/unit/lib/services/webhooks/SubscriptionEventService.test.js @@ -6,55 +6,15 @@ const SubscriptionEventService = require('../../../../../lib/services/webhook/Su describe('SubscriptionEventService', function () { let service; let memberRepository; - let member; - let subscription; beforeEach(function () { - member = { - id: 'member_123', - related: sinon.stub().returns({ - query: sinon.stub().returns({ - fetchOne: sinon.stub().resolves({subscription_id: 'sub_123'}) - }) - }) - }; - - memberRepository = { - get: sinon.stub().resolves(member), - linkSubscription: sinon.stub() - }; - - subscription = { - id: 'sub_123', - items: { - data: [{price: {id: 'price_123'}}] - }, - customer: 'cust_123' - }; + memberRepository = {get: sinon.stub(), linkSubscription: sinon.stub()}; service = new SubscriptionEventService({memberRepository}); }); - it('should not call linkSubscription if member does not exist', async function () { - memberRepository.get.resolves(null); - - await service.handleSubscriptionEvent(subscription); - assert(memberRepository.linkSubscription.notCalled); - }); - - it('should not call linkSubscription if member subscription does not exist', async function () { - member.related.returns({ - query: sinon.stub().returns({ - fetchOne: sinon.stub().resolves(null) - }) - }); - - await service.handleSubscriptionEvent(subscription); - assert(memberRepository.linkSubscription.notCalled); - }); - it('should throw BadRequestError if subscription has no price item', async function () { - subscription = { + const subscription = { items: { data: [] } @@ -69,6 +29,14 @@ describe('SubscriptionEventService', function () { }); it('should throw ConflictError if linkSubscription fails with ER_DUP_ENTRY', async function () { + const subscription = { + items: { + data: [{price: {id: 'price_123'}}] + }, + customer: 'cust_123' + }; + + memberRepository.get.resolves({id: 'member_123'}); memberRepository.linkSubscription.rejects({code: 'ER_DUP_ENTRY'}); try { @@ -80,6 +48,14 @@ describe('SubscriptionEventService', function () { }); it('should throw ConflictError if linkSubscription fails with SQLITE_CONSTRAINT', async function () { + const subscription = { + items: { + data: [{price: {id: 'price_123'}}] + }, + customer: 'cust_123' + }; + + memberRepository.get.resolves({id: 'member_123'}); memberRepository.linkSubscription.rejects({code: 'SQLITE_CONSTRAINT'}); try { @@ -91,6 +67,14 @@ describe('SubscriptionEventService', function () { }); it('should throw if linkSubscription fails with unexpected error', async function () { + const subscription = { + items: { + data: [{price: {id: 'price_123'}}] + }, + customer: 'cust_123' + }; + + memberRepository.get.resolves({id: 'member_123'}); memberRepository.linkSubscription.rejects(new Error('Unexpected error')); try { @@ -113,6 +97,15 @@ describe('SubscriptionEventService', function () { }); it('should call linkSubscription with correct arguments', async function () { + const subscription = { + items: { + data: [{price: {id: 'price_123'}}] + }, + customer: 'cust_123' + }; + + memberRepository.get.resolves({id: 'member_123'}); + await service.handleSubscriptionEvent(subscription); assert(memberRepository.linkSubscription.calledWith({id: 'member_123', subscription}));