From 79d86f5d351842884aebc1ffadb517b722cf901e Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Mon, 21 Aug 2023 15:30:28 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20handling=20multiple=20St?= =?UTF-8?q?ripe=20subscriptions=20for=20same=20member?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes https://github.com/TryGhost/Product/issues/3752 --- .../members-edit-subscriptions.test.js.snap | 1080 +++++++++++++++++ .../admin/members-edit-subscriptions.test.js | 818 +++++++++++++ ghost/core/test/e2e-api/admin/members.test.js | 57 - ghost/core/test/utils/fixture-utils.js | 10 + ghost/core/test/utils/stripe-mocker.js | 4 + .../lib/repositories/MemberRepository.js | 54 +- .../test/unit/lib/repositories/member.test.js | 6 +- 7 files changed, 1952 insertions(+), 77 deletions(-) create mode 100644 ghost/core/test/e2e-api/admin/__snapshots__/members-edit-subscriptions.test.js.snap create mode 100644 ghost/core/test/e2e-api/admin/members-edit-subscriptions.test.js diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members-edit-subscriptions.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members-edit-subscriptions.test.js.snap new file mode 100644 index 00000000000..4d85648b0c8 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members-edit-subscriptions.test.js.snap @@ -0,0 +1,1080 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Members API: edit subscriptions Can cancel a subscription 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6e1", + "referrer_medium": null, + "referrer_source": "Direct", + "referrer_url": null, + "title": "Ghostly Kitchen Sink", + "type": "post", + "url": "http://127.0.0.1:2369/ghostly-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": "member2@test.com", + "email_count": 0, + "email_open_rate": 50, + "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": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "cancel_at_period_end": false, + "cancellation_reason": null, + "current_period_end": Any, + "customer": Object { + "email": "member2@test.com", + "id": Any, + "name": null, + }, + "default_payment_card_last4": null, + "id": Any, + "offer": null, + "plan": Object { + "amount": 5000, + "currency": "USD", + "id": Any, + "interval": "year", + "nickname": "Yearly", + }, + "price": Object { + "amount": 5000, + "currency": "USD", + "id": Any, + "interval": "year", + "nickname": "Yearly", + "price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "tier": Object { + "id": Any, + "name": "Default Product", + "tier_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + "type": "recurring", + }, + "start_date": Any, + "status": "active", + "tier": Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "expiry_at": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": Any, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, + "yearly_price_id": Any, + }, + "trial_end_at": null, + "trial_start_at": null, + }, + ], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "expiry_at": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": Any, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, + "yearly_price_id": 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: edit subscriptions Can cancel a subscription 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": "2484", + "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: edit subscriptions Can cancel a subscription 3: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6e1", + "referrer_medium": null, + "referrer_source": "Direct", + "referrer_url": null, + "title": "Ghostly Kitchen Sink", + "type": "post", + "url": "http://127.0.0.1:2369/ghostly-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": "member2@test.com", + "email_count": 0, + "email_open_rate": 50, + "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": "free", + "subscribed": false, + "subscriptions": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "cancel_at_period_end": false, + "cancellation_reason": null, + "current_period_end": Any, + "customer": Object { + "email": "member2@test.com", + "id": Any, + "name": null, + }, + "default_payment_card_last4": null, + "id": Any, + "offer": null, + "plan": Object { + "amount": 5000, + "currency": "USD", + "id": Any, + "interval": "year", + "nickname": "Yearly", + }, + "price": Object { + "amount": 5000, + "currency": "USD", + "id": Any, + "interval": "year", + "nickname": "Yearly", + "price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "tier": Object { + "id": Any, + "name": "Default Product", + "tier_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + "type": "recurring", + }, + "start_date": Any, + "status": "canceled", + "trial_end_at": null, + "trial_start_at": null, + }, + ], + "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: edit subscriptions Can cancel a subscription 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": "1584", + "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: edit subscriptions Can cancel a subscription for a member with both comped and paid subscriptions 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": "comped-paid-combination@example.com", + "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, + "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: edit subscriptions Can cancel a subscription for a member with both comped and paid subscriptions 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": "3657", + "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: edit subscriptions Can cancel a subscription for a member with both comped and paid subscriptions 3: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": null, + "avatar_image": null, + "comped": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "comped-paid-combination@example.com", + "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": "comped", + "subscribed": false, + "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: edit subscriptions Can cancel a subscription for a member with both comped and paid subscriptions 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": "2757", + "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: edit subscriptions Can cancel a subscription for a member with duplicate customers 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "duplicate-customers-test@example.com", + "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": 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: edit subscriptions Can cancel a subscription for a member with duplicate customers 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": "4175", + "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: edit subscriptions Can cancel a subscription for a member with duplicate customers 3: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "duplicate-customers-test@example.com", + "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": "free", + "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: edit subscriptions Can cancel a subscription for a member with duplicate customers 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": "3275", + "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: edit subscriptions Can cancel a subscription for a member with duplicate subscriptions 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "duplicate-subscription-test@example.com", + "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": 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: edit subscriptions Can cancel a subscription for a member with duplicate subscriptions 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": "4184", + "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: edit subscriptions Can cancel a subscription for a member with duplicate subscriptions 3: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "duplicate-subscription-test@example.com", + "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": "free", + "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: edit subscriptions Can cancel a subscription for a member with duplicate subscriptions 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": "3284", + "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: edit subscriptions Can edit a subscription 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6e1", + "referrer_medium": null, + "referrer_source": "Direct", + "referrer_url": null, + "title": "Ghostly Kitchen Sink", + "type": "post", + "url": "http://127.0.0.1:2369/ghostly-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": "member2@test.com", + "email_count": 0, + "email_open_rate": 50, + "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": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "cancel_at_period_end": false, + "cancellation_reason": null, + "current_period_end": Any, + "customer": Object { + "email": "member2@test.com", + "id": Any, + "name": null, + }, + "default_payment_card_last4": null, + "id": Any, + "offer": null, + "plan": Object { + "amount": 5000, + "currency": "USD", + "id": Any, + "interval": "year", + "nickname": "Yearly", + }, + "price": Object { + "amount": 5000, + "currency": "USD", + "id": Any, + "interval": "year", + "nickname": "Yearly", + "price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "tier": Object { + "id": Any, + "name": "Default Product", + "tier_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + "type": "recurring", + }, + "start_date": Any, + "status": "active", + "tier": Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "expiry_at": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": Any, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, + "yearly_price_id": Any, + }, + "trial_end_at": null, + "trial_start_at": null, + }, + ], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "expiry_at": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": Any, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, + "yearly_price_id": 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: edit subscriptions Can edit a subscription 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": "2484", + "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: edit subscriptions Can edit a subscription 3: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": "618ba1ffbe2896088840a6e1", + "referrer_medium": null, + "referrer_source": "Direct", + "referrer_url": null, + "title": "Ghostly Kitchen Sink", + "type": "post", + "url": "http://127.0.0.1:2369/ghostly-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": "member2@test.com", + "email_count": 0, + "email_open_rate": 50, + "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": "free", + "subscribed": false, + "subscriptions": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "cancel_at_period_end": false, + "cancellation_reason": null, + "current_period_end": Any, + "customer": Object { + "email": "member2@test.com", + "id": Any, + "name": null, + }, + "default_payment_card_last4": null, + "id": Any, + "offer": null, + "plan": Object { + "amount": 5000, + "currency": "USD", + "id": Any, + "interval": "year", + "nickname": "Yearly", + }, + "price": Object { + "amount": 5000, + "currency": "USD", + "id": Any, + "interval": "year", + "nickname": "Yearly", + "price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "tier": Object { + "id": Any, + "name": "Default Product", + "tier_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + "type": "recurring", + }, + "start_date": Any, + "status": "canceled", + "trial_end_at": null, + "trial_start_at": null, + }, + ], + "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: edit subscriptions Can edit a subscription 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": "1584", + "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: edit subscriptions Can recover member products when we cancel a subscription 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "duplicate-subscription-wrongfully-test@example.com", + "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": 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: edit subscriptions Can recover member products when we cancel a subscription 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": "4217", + "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: edit subscriptions Can recover member products when we cancel a subscription 3: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "duplicate-subscription-wrongfully-test@example.com", + "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": "free", + "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: edit subscriptions Can recover member products when we cancel a subscription 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": "3317", + "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: edit subscriptions Can recover member products when we update a subscription 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "duplicate-subscription-wrongfully-test2@example.com", + "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": 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: edit subscriptions Can recover member products when we update a subscription 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": "4972", + "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: edit subscriptions Can update a subscription for a member with duplicate subscriptions 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "duplicate-subscription-edit-test@example.com", + "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": 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: edit subscriptions Can update a subscription for a member with duplicate subscriptions 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": "4951", + "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: edit subscriptions Can update a subscription for a member with duplicate subscriptions 3: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": null, + "referrer_source": null, + "referrer_url": null, + "title": null, + "type": null, + "url": null, + }, + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "duplicate-subscription-edit-test@example.com", + "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": 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: edit subscriptions Can update a subscription for a member with duplicate subscriptions 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": "4950", + "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", +} +`; diff --git a/ghost/core/test/e2e-api/admin/members-edit-subscriptions.test.js b/ghost/core/test/e2e-api/admin/members-edit-subscriptions.test.js new file mode 100644 index 00000000000..143d7c1745a --- /dev/null +++ b/ghost/core/test/e2e-api/admin/members-edit-subscriptions.test.js @@ -0,0 +1,818 @@ +const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework'); +const {anyContentVersion, anyEtag, anyObjectId, anyUuid, anyISODateTime, anyString, anyArray} = matchers; +const testUtils = require('../../utils'); +const assert = require('assert/strict'); +const models = require('../../../core/server/models'); +const {stripeMocker} = require('../../utils/e2e-framework-mock-manager'); +const DomainEvents = require('@tryghost/domain-events/lib/DomainEvents'); + +const subscriptionSnapshot = { + id: anyString, + start_date: anyString, + current_period_end: anyString, + price: { + id: anyString, + price_id: anyObjectId, + tier: { + id: anyString, + tier_id: anyObjectId + } + }, + plan: { + id: anyString + }, + customer: { + id: anyString + } +}; + +const tierSnapshot = { + id: anyObjectId, + created_at: anyISODateTime, + updated_at: anyISODateTime, + monthly_price_id: anyString, + yearly_price_id: anyString +}; + +const subscriptionSnapshotWithTier = { + ...subscriptionSnapshot, + tier: tierSnapshot +}; + +describe('Members API: edit subscriptions', function () { + let agent; + + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('posts', 'members', 'tiers:extra'); + await agent.loginAsOwner(); + }); + + beforeEach(function () { + mockManager.mockStripe(); + mockManager.mockMail(); + }); + + afterEach(async function () { + await mockManager.restore(); + }); + + it('Can cancel a subscription', async function () { + const memberId = testUtils.DataGenerator.Content.members[1].id; + + // Get the stripe price ID of the default price for month + const price = await stripeMocker.getPriceForTier('default-product', 'year'); + + const res = await agent + .post(`/members/${memberId}/subscriptions/`) + .body({ + stripe_price_id: price.id + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: [subscriptionSnapshotWithTier], + newsletters: anyArray, + tiers: [tierSnapshot] + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + const subscriptionId = res.body.members[0].subscriptions[0].id; + + const editRes = await agent + .put(`/members/${memberId}/subscriptions/${subscriptionId}`) + .body({ + status: 'canceled' + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: [subscriptionSnapshot], + newsletters: anyArray, + tiers: [] + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + assert.equal('canceled', editRes.body.members[0].subscriptions[0].status); + }); + + it('Can cancel a subscription for a member with both comped and paid subscriptions', async function () { + const email = 'comped-paid-combination@example.com'; + + // Create this member with a comped product + let member = await models.Member.add({ + email, + email_disabled: false, + products: [ + { + slug: 'gold' + } + ] + }); + + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + assert.equal(member.related('stripeCustomers').length, 0); + assert.equal(member.related('stripeSubscriptions').length, 0); + assert.equal(member.related('products').length, 1, 'This member should have one product'); + + // Subscribe this to a paid product + const customer1 = stripeMocker.createCustomer({ + email + }); + const price1 = await stripeMocker.getPriceForTier('default-product', 'month'); + const subscription1 = await stripeMocker.createSubscription({ + customer: customer1, + price: price1 + }); + await DomainEvents.allSettled(); + + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to two products + assert.equal(member.related('stripeCustomers').length, 1); + assert.equal(member.related('stripeSubscriptions').length, 1); + assert.equal(member.related('products').length, 2, 'This member should have two products'); + assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['default-product', 'gold']); + + // Cancel the paid subscription at period end + // Now update one of those subscriptions immediately + await agent + .put(`/members/${member.id}/subscriptions/${subscription1.id}`) + .body({ + cancel_at_period_end: true // = just an update, the subscription should remain active until it is ended + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Assert products didn't change + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + assert.equal(member.related('stripeCustomers').length, 1); + assert.equal(member.related('stripeSubscriptions').length, 1); + assert.equal(member.related('products').length, 2, 'This member should have two products'); + assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['default-product', 'gold']); + + // Now cancel for real + await agent + .put(`/members/${member.id}/subscriptions/${subscription1.id}`) + .body({ + status: 'canceled' + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Assert product is removed, but comped is maintained + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + assert.equal(member.related('stripeCustomers').length, 1); + assert.equal(member.related('stripeSubscriptions').length, 1); + assert.equal(member.related('products').length, 1, 'This member should have one product'); + assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['gold']); + }); + + it('Can cancel a subscription for a member with duplicate customers', async function () { + const email = 'duplicate-customers-test@example.com'; + + // We create duplicate customers to mimick a situation where a member is connected to two customers + const customer1 = stripeMocker.createCustomer({ + email + }); + + const customer2 = stripeMocker.createCustomer({ + email + }); + + const price1 = await stripeMocker.getPriceForTier('default-product', 'month'); + const price2 = await stripeMocker.getPriceForTier('gold', 'year'); + + const subscription1 = await stripeMocker.createSubscription({ + customer: customer1, + price: price1 + }); + + const subscription2 = await stripeMocker.createSubscription({ + customer: customer2, + price: price2 + }); + + await DomainEvents.allSettled(); + + let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to two products + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2, 'This member should have two products'); + + // Now cancel one of those subscriptions immediately + await agent + .put(`/members/${member.id}/subscriptions/${subscription2.id}`) + .body({ + status: 'canceled' + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Update member + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to one products + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 1, 'This member should only have one remaning product'); + + // Cancel the other subscription + await agent + .put(`/members/${member.id}/subscriptions/${subscription1.id}`) + .body({ + status: 'canceled' + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Update member + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to one products + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 0, 'This member should only have no remaning products'); + }); + + it('Can cancel a subscription for a member with duplicate subscriptions', async function () { + const email = 'duplicate-subscription-test@example.com'; + + // We create duplicate customers to mimick a situation where a member is connected to two customers + const customer1 = stripeMocker.createCustomer({ + email + }); + + const price1 = await stripeMocker.getPriceForTier('default-product', 'month'); + const price2 = await stripeMocker.getPriceForTier('gold', 'year'); + + const subscription1 = await stripeMocker.createSubscription({ + customer: customer1, + price: price1 + }); + + const subscription2 = await stripeMocker.createSubscription({ + customer: customer1, + price: price2 + }); + + await DomainEvents.allSettled(); + + let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to two products + assert.equal(member.related('stripeCustomers').length, 1); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2, 'This member should have two products'); + + // Now cancel one of those subscriptions immediately + await agent + .put(`/members/${member.id}/subscriptions/${subscription2.id}`) + .body({ + status: 'canceled' + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Update member + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to one products + assert.equal(member.related('stripeCustomers').length, 1); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 1, 'This member should only have one remaning product'); + + // Cancel the other subscription + await agent + .put(`/members/${member.id}/subscriptions/${subscription1.id}`) + .body({ + status: 'canceled' + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Update member + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to one products + assert.equal(member.related('stripeCustomers').length, 1); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 0, 'This member should only have no remaning products'); + }); + + it('Can update a subscription for a member with duplicate subscriptions', async function () { + const email = 'duplicate-subscription-edit-test@example.com'; + + // We create duplicate customers to mimick a situation where a member is connected to two customers + const customer1 = stripeMocker.createCustomer({ + email + }); + + const customer2 = stripeMocker.createCustomer({ + email + }); + + const price1 = await stripeMocker.getPriceForTier('default-product', 'month'); + const price2 = await stripeMocker.getPriceForTier('gold', 'year'); + + const subscription1 = await stripeMocker.createSubscription({ + customer: customer1, + price: price1 + }); + + const subscription2 = await stripeMocker.createSubscription({ + customer: customer2, + price: price2 + }); + + await DomainEvents.allSettled(); + + let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to two products + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2, 'This member should have two products'); + + // Now update one of those subscriptions immediately + await agent + .put(`/members/${member.id}/subscriptions/${subscription2.id}`) + .body({ + cancel_at_period_end: true // = just an update, the subscription should remain active until it is ended + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Update member + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to one products + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2, 'This member should still have two products'); + + // Cancel the other subscription + await agent + .put(`/members/${member.id}/subscriptions/${subscription1.id}`) + .body({ + cancel_at_period_end: true + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Update member + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to one products + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2, 'This member should still have two products'); + }); + + it('Can recover member products when we cancel a subscription', async function () { + /** + * This tests a situation where a bug didn't set the products for a member correctly in the past when it had multiple subscriptions. + * This tests what happens when we cancel the remaining product. To recover from this, we should set the products correctly after the cancelation. + */ + const email = 'duplicate-subscription-wrongfully-test@example.com'; + + // We create duplicate customers to mimick a situation where a member is connected to two customers + const customer1 = stripeMocker.createCustomer({ + email + }); + + const customer2 = stripeMocker.createCustomer({ + email + }); + + const price1 = await stripeMocker.getPriceForTier('default-product', 'month'); + const price2 = await stripeMocker.getPriceForTier('gold', 'year'); + + const subscription1 = await stripeMocker.createSubscription({ + customer: customer1, + price: price1 + }); + + const subscription2 = await stripeMocker.createSubscription({ + customer: customer2, + price: price2 + }); + + await DomainEvents.allSettled(); + + let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to two products + assert.equal(member.get('status'), 'paid'); + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2, 'This member should have two products'); + + // Manually unlink the first product from the member, to simulate a bug from the past + // where we didn't store the products correctly + await models.Member.edit({products: member.related('products').models.filter(p => p.get('slug') !== 'default-product')}, {id: member.id}); + + // Assert only one product left + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + assert.equal(member.related('products').length, 1, 'This member should have one product after the update'); + assert.equal(member.related('products').models[0].get('slug'), 'gold'); + + // Now cancel the second subscription (from the remaining product) + await agent + .put(`/members/${member.id}/subscriptions/${subscription2.id}`) + .body({ + status: 'canceled' + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Update member + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to one products + assert.equal(member.get('status'), 'paid'); + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 1, 'This member should still have the other product that was wrongfully removed in the past'); + assert.equal(member.related('products').models[0].get('slug'), 'default-product', 'This member should still have the other product that was wrongfully removed in the past'); + + // Cancel the other subscription + await agent + .put(`/members/${member.id}/subscriptions/${subscription1.id}`) + .body({ + status: 'canceled' + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Update member + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to one products + assert.equal(member.get('status'), 'free'); + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 0); + }); + + it('Can recover member products when we update a subscription', async function () { + /** + * This tests a situation where a bug didn't set the products for a member correctly in the past when it had multiple subscriptions. + * This tests what happens when we cancel the remaining product. To recover from this, we should set the products correctly after the cancelation. + */ + const email = 'duplicate-subscription-wrongfully-test2@example.com'; + + // We create duplicate customers to mimick a situation where a member is connected to two customers + const customer1 = stripeMocker.createCustomer({ + email + }); + + const customer2 = stripeMocker.createCustomer({ + email + }); + + const price1 = await stripeMocker.getPriceForTier('default-product', 'month'); + const price2 = await stripeMocker.getPriceForTier('gold', 'year'); + + await stripeMocker.createSubscription({ + customer: customer1, + price: price1 + }); + + const subscription2 = await stripeMocker.createSubscription({ + customer: customer2, + price: price2 + }); + + await DomainEvents.allSettled(); + + let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to two products + assert.equal(member.get('status'), 'paid'); + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2, 'This member should have two products'); + + // Manually unlink the first product from the member, to simulate a bug from the past + // where we didn't store the products correctly + await models.Member.edit({products: member.related('products').models.filter(p => p.get('slug') !== 'default-product')}, {id: member.id}); + + // Assert only one product left + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + assert.equal(member.get('status'), 'paid'); + assert.equal(member.related('products').length, 1, 'This member should have one product after the update'); + assert.equal(member.related('products').models[0].get('slug'), 'gold'); + + // Now cancel the second subscription (from the remaining product) + await agent + .put(`/members/${member.id}/subscriptions/${subscription2.id}`) + .body({ + cancel_at_period_end: true // = just an update, the subscription should remain active until it is ended + }) + .expectStatus(200) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + newsletters: anyArray, + tiers: anyArray + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + // Update member + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + // Assert this member is subscribed to one products + assert.equal(member.get('status'), 'paid'); + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2, 'Should readd the product that was wrongfully removed in the past'); + }); + + it('Can edit the price of a subscription directly in Stripe', async function () { + const email = 'edit-subscription-product-in-stripe@example.com'; + + // We create duplicate customers to mimick a situation where a member is connected to two customers + const customer1 = stripeMocker.createCustomer({ + email + }); + + const price1 = await stripeMocker.getPriceForTier('default-product', 'month'); + const price2 = await stripeMocker.getPriceForTier('gold', 'year'); + + const subscription1 = await stripeMocker.createSubscription({ + customer: customer1, + price: price1 + }); + + await DomainEvents.allSettled(); + + let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + assert.equal(member.get('status'), 'paid'); + assert.equal(member.related('stripeCustomers').length, 1); + assert.equal(member.related('stripeSubscriptions').length, 1); + assert.equal(member.related('products').length, 1); + assert.equal(member.related('products').models[0].get('slug'), 'default-product'); + + // Change subscription price in Stripe + // This will send a webhook to Ghost + await stripeMocker.updateSubscription({ + id: subscription1.id, + items: { + type: 'list', + data: [ + { + price: price2 + } + ] + } + }); + + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + assert.equal(member.get('status'), 'paid'); + assert.equal(member.related('stripeCustomers').length, 1); + assert.equal(member.related('stripeSubscriptions').length, 1); + assert.equal(member.related('products').length, 1); + assert.equal(member.related('products').models[0].get('slug'), 'gold'); + }); + + it('Can edit the price of a subscription directly in Stripe when having duplicate subscriptions', async function () { + const email = 'edit-subscription-product-in-stripe-dup@example.com'; + + // We create duplicate customers to mimick a situation where a member is connected to two customers + const customer1 = stripeMocker.createCustomer({ + email + }); + const customer2 = stripeMocker.createCustomer({ + email + }); + + const price1 = await stripeMocker.getPriceForTier('default-product', 'month'); + const price2 = await stripeMocker.getPriceForTier('gold', 'year'); + const price3 = await stripeMocker.getPriceForTier('silver', 'year'); + + const subscription1 = await stripeMocker.createSubscription({ + customer: customer1, + price: price1 + }); + + await stripeMocker.createSubscription({ + customer: customer2, + price: price2 + }); + + await DomainEvents.allSettled(); + + let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + assert.equal(member.get('status'), 'paid'); + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2); + assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['default-product', 'gold']); + + // Change subscription price in Stripe + // This will send a webhook to Ghost + await stripeMocker.updateSubscription({ + id: subscription1.id, + items: { + type: 'list', + data: [ + { + price: price3 + } + ] + } + }); + + member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']}); + + assert.equal(member.get('status'), 'paid'); + assert.equal(member.related('stripeCustomers').length, 2); + assert.equal(member.related('stripeSubscriptions').length, 2); + assert.equal(member.related('products').length, 2); + assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['gold', 'silver']); + }); +}); diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index 2db5ca0ccba..c4fc41baf3a 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -2261,63 +2261,6 @@ describe('Members API', function () { }); }); - it('Can edit a subscription', async function () { - const memberId = testUtils.DataGenerator.Content.members[1].id; - - // Get the stripe price ID of the default price for month - const price = await stripeMocker.getPriceForTier('default-product', 'year'); - - const res = await agent - .post(`/members/${memberId}/subscriptions/`) - .body({ - stripe_price_id: price.id - }) - .expectStatus(200) - .matchBodySnapshot({ - members: new Array(1).fill({ - id: anyObjectId, - uuid: anyUuid, - created_at: anyISODateTime, - updated_at: anyISODateTime, - labels: anyArray, - subscriptions: [subscriptionSnapshotWithTier], - newsletters: anyArray, - tiers: [tierSnapshot] - }) - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }); - - const subscriptionId = res.body.members[0].subscriptions[0].id; - - const editRes = await agent - .put(`/members/${memberId}/subscriptions/${subscriptionId}`) - .body({ - status: 'canceled' - }) - .expectStatus(200) - .matchBodySnapshot({ - members: new Array(1).fill({ - id: anyObjectId, - uuid: anyUuid, - created_at: anyISODateTime, - updated_at: anyISODateTime, - labels: anyArray, - subscriptions: [subscriptionSnapshot], - newsletters: anyArray, - tiers: [] - }) - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }); - - assert.equal('canceled', editRes.body.members[0].subscriptions[0].status); - }); - // Delete a member it('Can destroy', async function () { diff --git a/ghost/core/test/utils/fixture-utils.js b/ghost/core/test/utils/fixture-utils.js index 638d471baf3..6b58cee8680 100644 --- a/ghost/core/test/utils/fixture-utils.js +++ b/ghost/core/test/utils/fixture-utils.js @@ -487,6 +487,13 @@ const fixtures = { return models.Product.add(hiddenTier, context.internal); }, + insertExtraTiers: async function insertExtraTiers() { + const extraTier = DataGenerator.forKnex.createProduct({}); + const extraTier2 = DataGenerator.forKnex.createProduct({slug: 'silver', name: 'Silver'}); + await models.Product.add(extraTier, context.internal); + await models.Product.add(extraTier2, context.internal); + }, + insertProducts: async function insertProducts() { let coreProductFixtures = fixtureManager.findModelFixtures('Product').entries; await Promise.all(coreProductFixtures.map(async (product) => { @@ -822,6 +829,9 @@ const toDoList = { custom_theme_settings: function insertCustomThemeSettings() { return fixtures.insertCustomThemeSettings(); }, + 'tiers:extra': function insertExtraTiers() { + return fixtures.insertExtraTiers(); + }, 'tiers:archived': function insertArchivedTiers() { return fixtures.insertArchivedTiers(); }, diff --git a/ghost/core/test/utils/stripe-mocker.js b/ghost/core/test/utils/stripe-mocker.js index 0e46a9033bd..0794af909c7 100644 --- a/ghost/core/test/utils/stripe-mocker.js +++ b/ghost/core/test/utils/stripe-mocker.js @@ -78,6 +78,10 @@ class StripeMocker { */ async getPriceForTier(tierSlug, cadence) { const product = await models.Product.findOne({slug: tierSlug}); + + if (!product) { + throw new Error('Product not found with slug ' + tierSlug); + } const tier = await tiers.api.read(product.id); const payments = members.api.paymentsService; const {id} = await payments.createPriceForTierCadence(tier, cadence); diff --git a/ghost/members-api/lib/repositories/MemberRepository.js b/ghost/members-api/lib/repositories/MemberRepository.js index 2cd55558253..3f0145d2174 100644 --- a/ghost/members-api/lib/repositories/MemberRepository.js +++ b/ghost/members-api/lib/repositories/MemberRepository.js @@ -1138,28 +1138,32 @@ module.exports = class MemberRepository { status = 'paid'; } - // This is an active subscription! Add the product - if (ghostProduct) { - // memberProducts.push(ghostProduct.toJSON()); - memberProducts = [ghostProduct.toJSON()]; - } if (model) { - if (model.get('stripe_price_id') !== subscriptionData.stripe_price_id) { - // The subscription has changed plan - we may need to update the products + // We might need to... + // 1. delete the previous product from the linked member products (in case an existing subscription changed product/price) + // 2. fix the list of products linked to a member (an existing subscription doesn't have a linked product to this member) - const subscriptions = await member.related('stripeSubscriptions').fetch(options); - const changedProduct = await this._productRepository.get({ - stripe_price_id: model.get('stripe_price_id') - }, options); + const subscriptions = await member.related('stripeSubscriptions').fetch(options); - let activeSubscriptionForChangedProduct = false; + const previousProduct = await this._productRepository.get({ + stripe_price_id: model.get('stripe_price_id') + }, options); + + if (previousProduct) { + let activeSubscriptionForPreviousProduct = false; for (const subscriptionModel of subscriptions.models) { - if (this.isActiveSubscriptionStatus(subscriptionModel.get('status'))) { + if (this.isActiveSubscriptionStatus(subscriptionModel.get('status')) && subscriptionModel.id !== model.id) { try { const subscriptionProduct = await this._productRepository.get({stripe_price_id: subscriptionModel.get('stripe_price_id')}, options); - if (subscriptionProduct && changedProduct && subscriptionProduct.id === changedProduct.id) { - activeSubscriptionForChangedProduct = true; + if (subscriptionProduct && previousProduct && subscriptionProduct.id === previousProduct.id) { + activeSubscriptionForPreviousProduct = true; + } + + if (subscriptionProduct && !memberProducts.find(p => p.id === subscriptionProduct.id)) { + // Due to a bug in the past it is possible that this subscription's product wasn't added to the member products + // So we need to add it again + memberProducts.push(subscriptionProduct.toJSON()); } } catch (e) { logging.error(`Failed to attach products to member - ${data.id}`); @@ -1168,13 +1172,21 @@ module.exports = class MemberRepository { } } - if (!activeSubscriptionForChangedProduct) { + if (!activeSubscriptionForPreviousProduct) { + // We can safely remove the product from this member because it doesn't have any other remaining active subscription for it memberProducts = memberProducts.filter((product) => { - return product.id !== changedProduct.id; + return product.id !== previousProduct.id; }); } } } + + if (ghostProduct) { + // Note: we add the product here + // We don't override the products because in an edge case a member can have multiple subscriptions + // We'll need to keep all the products related to those subscriptions to avoid creating other issues + memberProducts.push(ghostProduct.toJSON()); + } } else { const subscriptions = await member.related('stripeSubscriptions').fetch(options); let activeSubscriptionForGhostProduct = false; @@ -1186,6 +1198,12 @@ module.exports = class MemberRepository { if (subscriptionProduct && ghostProduct && subscriptionProduct.id === ghostProduct.id) { activeSubscriptionForGhostProduct = true; } + + if (subscriptionProduct && !memberProducts.find(p => p.id === subscriptionProduct.id)) { + // Due to a bug in the past it is possible that this subscription's product wasn't added to the member products + // So we need to add it again + memberProducts.push(subscriptionProduct.toJSON()); + } } catch (e) { logging.error(`Failed to attach products to member - ${data.id}`); logging.error(e); @@ -1194,12 +1212,14 @@ module.exports = class MemberRepository { } if (!activeSubscriptionForGhostProduct) { + // We don't have an active subscription for this product anymore, so we can safely unlink it from the member memberProducts = memberProducts.filter((product) => { return product.id !== ghostProduct.id; }); } if (memberProducts.length === 0) { + // If all products were removed, set the status back to 'free' status = 'free'; } } diff --git a/ghost/members-api/test/unit/lib/repositories/member.test.js b/ghost/members-api/test/unit/lib/repositories/member.test.js index b5691be5a27..0baf70977d7 100644 --- a/ghost/members-api/test/unit/lib/repositories/member.test.js +++ b/ghost/members-api/test/unit/lib/repositories/member.test.js @@ -191,14 +191,14 @@ describe('MemberRepository', function () { Member = { findOne: sinon.stub().resolves({ - related: () => { + related: (relation) => { return { query: sinon.stub().returns({ fetchOne: sinon.stub().resolves({}) }), - toJSON: sinon.stub().returns([]), + toJSON: sinon.stub().returns(relation === 'products' ? [] : {}), fetch: sinon.stub().resolves({ - toJSON: sinon.stub().returns({}) + toJSON: sinon.stub().returns(relation === 'products' ? [] : {}) }) }; },