From b587429008e228372ef8ee0afd18e4e13c288fcd Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Wed, 16 Aug 2023 15:25:57 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20importing=20offers=20whe?= =?UTF-8?q?n=20importing=20members=20from=20Stripe=20(#17739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes https://github.com/TryGhost/Product/issues/3728 - When importing members from Stripe with an existing offer, that didn't exist in Ghost, the offer never got linked with the imported subscription because of a missing return statement. - Fixes importing offers with duplicate names - Added E2E tests for creating members from a Stripe Customer ID --- .../admin/__snapshots__/members.test.js.snap | 282 +++++++++++++++++- ghost/core/test/e2e-api/admin/members.test.js | 225 +++++++++++++- .../test/e2e-api/members/webhooks.test.js | 135 --------- .../offers/lib/application/OfferRepository.js | 16 +- .../lib/application/OfferRepository.test.js | 3 +- 5 files changed, 517 insertions(+), 144 deletions(-) diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index 7fb8bffd65..0ee07d0e1c 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -2723,6 +2723,228 @@ Object { } `; +exports[`Members API Can create an offer in Ghost when Stripe subscription has an unknown offer attached 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": "Ghost Admin", + "referrer_source": "Created manually", + "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": "create-member-offer-test@email.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": "Test Member", + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "sans_serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Default Newsletter", + "sender_email": null, + "sender_name": null, + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "default-newsletter", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_color": null, + "title_font_category": "sans_serif", + "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\\}/, + "visibility": "members", + }, + ], + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "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": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "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": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "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 Can create an offer in Ghost when Stripe subscription has an unknown offer attached 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": "3700", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API Can create an offer in Ghost when Stripe subscription has an unknown offer attached with a duplicate name 1: [body] 1`] = ` +Object { + "members": Array [ + Object { + "attribution": Object { + "id": null, + "referrer_medium": "Ghost Admin", + "referrer_source": "Created manually", + "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": "create-member-offer-test2@email.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": "Test Member", + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "sans_serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Default Newsletter", + "sender_email": null, + "sender_name": null, + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "default-newsletter", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_color": null, + "title_font_category": "sans_serif", + "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\\}/, + "visibility": "members", + }, + ], + "note": null, + "status": "paid", + "subscribed": true, + "subscriptions": Any, + "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": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "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": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "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 Can create an offer in Ghost when Stripe subscription has an unknown offer attached with a duplicate name 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": "3727", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Members API Can delete a member while cancelling Stripe Subscription 1: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", @@ -3234,11 +3456,11 @@ Object { "comped": 4, "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, "free": 4, - "paid": 2, + "paid": 4, }, ], "resource": "members", - "total": 10, + "total": 12, } `; @@ -3834,6 +4056,58 @@ Object { "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\\}/, }, + Object { + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "create-member-offer-test2@email.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": "Test Member", + "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\\}/, + }, + Object { + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "create-member-offer-test@email.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": "Test Member", + "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\\}/, + }, Object { "avatar_image": null, "comped": true, @@ -4024,7 +4298,7 @@ Object { "page": 1, "pages": 1, "prev": null, - "total": 8, + "total": 10, }, }, } @@ -4034,7 +4308,7 @@ exports[`Members API Can filter on tier slug 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": "23956", + "content-length": "30800", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index 82ada71cd8..2db5ca0ccb 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -1395,6 +1395,229 @@ describe('Members API', function () { }); }); + it('Can create an offer in Ghost when Stripe subscription has an unknown offer attached', async function () { + const fakePrice = { + id: 'price_test_offer_123', + product: 'product_test_offer_123', + active: true, + nickname: 'Monthly', + unit_amount: 5000, + currency: 'usd', + type: 'recurring', + recurring: { + interval: 'month' + } + }; + + const fakeSubscription = { + id: 'sub_offer_test_123', + customer: 'cus_offer_test_123', + status: 'active', + cancel_at_period_end: false, + metadata: {}, + current_period_end: Date.now() / 1000 + 50000, + start_date: Date.now() / 1000, + plan: fakePrice, + items: { + data: [{ + price: fakePrice + }] + }, + discount: { + coupon: { + id: 'coupon_1', + name: 'Stripe Special', + duration: 'repeating', + duration_in_months: 5, + amount_off: 1000 + } + } + }; + + const fakeCustomer = { + id: 'cus_offer_test_123', + name: 'Test Member', + email: 'create-member-offer-test@email.com', + subscriptions: { + type: 'list', + data: [fakeSubscription] + } + }; + stripeMocker.customers.push(fakeCustomer); + stripeMocker.subscriptions.push(fakeSubscription); + stripeMocker.prices.push(fakePrice); + + const initialMember = { + name: fakeCustomer.name, + email: fakeCustomer.email, + subscribed: true, + newsletters: [newsletters[0]], + stripe_customer_id: fakeCustomer.id + }; + + const {body} = await agent + .post(`/members/`) + .body({members: [initialMember]}) + .expectStatus(201) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + tiers: new Array(1).fill(tierMatcher), + newsletters: new Array(1).fill(newsletterSnapshot) + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('members') + }); + + const newMember = body.members[0]; + assert.equal(newMember.status, 'paid', 'The created member should have the paid status'); + + // Check offer is created in the database + const offers = (await models.Offer.findAll({filter: 'name:\'Stripe Special\''})).models; + assert.equal(offers.length, 1); + assert.equal(offers[0].get('name'), 'Stripe Special'); + assert.equal(offers[0].get('code'), 'stripe-special'); + assert.equal(offers[0].get('stripe_coupon_id'), 'coupon_1'); + assert.equal(offers[0].get('discount_type'), 'amount'); + assert.equal(offers[0].get('discount_amount'), 1000); + assert.equal(offers[0].get('duration'), 'repeating'); + assert.equal(offers[0].get('duration_in_months'), 5); + + // Check subscription is linked to offer + assert.equal(newMember.subscriptions[0].offer.id, offers[0].id); + await assertSubscription('sub_offer_test_123', { + subscription_id: 'sub_offer_test_123', + status: 'active', + cancel_at_period_end: false, + plan_amount: 5000, + plan_interval: 'month', + plan_currency: 'usd', + mrr: 5000, + offer_id: offers[0].get('id') + }); + }); + + it('Can create an offer in Ghost when Stripe subscription has an unknown offer attached with a duplicate name', async function () { + const existingOffers = (await models.Offer.findAll({filter: 'name:\'Stripe Special\''})).models; + assert.equal(existingOffers.length, 1, 'This test expects an offer with the name Stripe Special to already exist'); + + const fakePrice = { + id: 'price_test_offer_1234', + product: 'product_test_offer_1234', + active: true, + nickname: 'Monthly', + unit_amount: 5000, + currency: 'usd', + type: 'recurring', + recurring: { + interval: 'month' + } + }; + + const fakeSubscription = { + id: 'sub_offer_test_1234', + customer: 'cus_offer_test_1234', + status: 'active', + cancel_at_period_end: false, + metadata: {}, + current_period_end: Date.now() / 1000 + 50000, + start_date: Date.now() / 1000, + plan: fakePrice, + items: { + data: [{ + price: fakePrice + }] + }, + discount: { + coupon: { + id: 'coupon_2', + name: 'Stripe Special', // Duplicate name + duration: 'repeating', + duration_in_months: 5, + amount_off: 1000 + } + } + }; + + const fakeCustomer = { + id: 'cus_offer_test_1234', + name: 'Test Member', + email: 'create-member-offer-test2@email.com', + subscriptions: { + type: 'list', + data: [fakeSubscription] + } + }; + stripeMocker.customers.push(fakeCustomer); + stripeMocker.subscriptions.push(fakeSubscription); + stripeMocker.prices.push(fakePrice); + + const initialMember = { + name: fakeCustomer.name, + email: fakeCustomer.email, + subscribed: true, + newsletters: [newsletters[0]], + stripe_customer_id: fakeCustomer.id + }; + + const {body} = await agent + .post(`/members/`) + .body({members: [initialMember]}) + .expectStatus(201) + .matchBodySnapshot({ + members: new Array(1).fill({ + id: anyObjectId, + uuid: anyUuid, + created_at: anyISODateTime, + updated_at: anyISODateTime, + labels: anyArray, + subscriptions: anyArray, + tiers: new Array(1).fill(tierMatcher), + newsletters: new Array(1).fill(newsletterSnapshot) + }) + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('members') + }); + + const newMember = body.members[0]; + assert.equal(newMember.status, 'paid', 'The created member should have the paid status'); + + // Check offer is created in the database + const offers = (await models.Offer.findAll({filter: 'name:\'Stripe Special (coupon_2)\''})).models; + assert.equal(offers.length, 1); + assert.equal(offers[0].get('name'), 'Stripe Special (coupon_2)'); + assert.equal(offers[0].get('code'), 'stripe-special-coupon_2'); + assert.equal(offers[0].get('stripe_coupon_id'), 'coupon_2'); + assert.equal(offers[0].get('discount_type'), 'amount'); + assert.equal(offers[0].get('discount_amount'), 1000); + assert.equal(offers[0].get('duration'), 'repeating'); + assert.equal(offers[0].get('duration_in_months'), 5); + + // Check subscription is linked to offer + assert.equal(newMember.subscriptions[0].offer.id, offers[0].id); + await assertSubscription('sub_offer_test_1234', { + subscription_id: 'sub_offer_test_1234', + status: 'active', + cancel_at_period_end: false, + plan_amount: 5000, + plan_interval: 'month', + plan_currency: 'usd', + mrr: 5000, + offer_id: offers[0].get('id') + }); + }); + let memberWithPaidSubscription; it('Can create a member with an existing paid subscription', async function () { @@ -2328,7 +2551,7 @@ describe('Members API', function () { .get('/members/?include=tiers&filter=tier:default-product') .expectStatus(200) .matchBodySnapshot({ - members: new Array(8).fill(buildMemberMatcherShallowIncludesWithTiers()) + members: new Array(10).fill(buildMemberMatcherShallowIncludesWithTiers()) }) .matchHeaderSnapshot({ 'content-version': anyContentVersion, diff --git a/ghost/core/test/e2e-api/members/webhooks.test.js b/ghost/core/test/e2e-api/members/webhooks.test.js index d91b32ba1f..27774b5dba 100644 --- a/ghost/core/test/e2e-api/members/webhooks.test.js +++ b/ghost/core/test/e2e-api/members/webhooks.test.js @@ -1496,141 +1496,6 @@ describe('Members API', function () { ] }); }); - - it('Silently ignores an invalid offer id in metadata', async function () { - const interval = 'month'; - const unit_amount = 500; - const mrr_with = 400; - - const discount = { - id: 'di_1Knkn7HUEDadPGIBPOQgmzIX', - object: 'discount', - checkout_session: null, - coupon: { - id: 'unknownCoupon', // this one is unknown in Ghost - object: 'coupon', - amount_off: null, - created: 1649774041, - currency: 'eur', - duration: 'forever', - duration_in_months: null, - livemode: false, - max_redemptions: null, - metadata: {}, - name: '20% off', - percent_off: 20, - redeem_by: null, - times_redeemed: 0, - valid: true - }, - end: null, - invoice: null, - invoice_item: null, - promotion_code: null, - start: beforeNow / 1000, - subscription: null - }; - - const customer_id = createStripeID('cust'); - const subscription_id = createStripeID('sub'); - - discount.customer = customer_id; - - set(subscription, { - id: subscription_id, - customer: customer_id, - status: 'active', - discount, - items: { - type: 'list', - data: [{ - id: 'item_123', - price: { - id: 'price_123', - product: 'product_123', - active: true, - nickname: interval, - currency: 'usd', - recurring: { - interval - }, - unit_amount, - type: 'recurring' - } - }] - }, - start_date: beforeNow / 1000, - current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31), - cancel_at_period_end: false - }); - - set(customer, { - id: customer_id, - name: 'Test Member', - email: `${customer_id}@email.com`, - subscriptions: { - type: 'list', - data: [subscription] - } - }); - - let webhookPayload = JSON.stringify({ - type: 'checkout.session.completed', - data: { - object: { - mode: 'subscription', - customer: customer.id, - subscription: subscription.id, - metadata: {} - } - } - }); - - let webhookSignature = stripe.webhooks.generateTestHeaderString({ - payload: webhookPayload, - secret: process.env.WEBHOOK_SECRET - }); - - await membersAgent.post('/webhooks/stripe/') - .body(webhookPayload) - .header('content-type', 'application/json') - .header('stripe-signature', webhookSignature); - - const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`); - assert.equal(body.members.length, 1, 'The member was not created'); - const member = body.members[0]; - - assert.equal(member.status, 'paid', 'The member should be "paid"'); - assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription'); - - // Check whether MRR and status has been set - await assertSubscription(member.subscriptions[0].id, { - subscription_id: subscription.id, - status: 'active', - cancel_at_period_end: false, - plan_amount: unit_amount, - plan_interval: interval, - plan_currency: 'usd', - current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)), - mrr: mrr_with, - offer_id: null - }); - - // Check whether the offer attribute is passed correctly in the response when fetching a single member - member.subscriptions[0].should.match({ - offer: null - }); - - await assertMemberEvents({ - eventType: 'MemberPaidSubscriptionEvent', - memberId: member.id, - asserts: [ - { - mrr_delta: mrr_with - } - ] - }); - }); }); // Test if the session metadata is processed correctly diff --git a/ghost/offers/lib/application/OfferRepository.js b/ghost/offers/lib/application/OfferRepository.js index 682e012a38..948a530ff0 100644 --- a/ghost/offers/lib/application/OfferRepository.js +++ b/ghost/offers/lib/application/OfferRepository.js @@ -194,11 +194,21 @@ class OfferRepository { */ async createFromCoupon(coupon, params, options) { const {productId, currency, interval, active} = params; - const code = coupon.name && coupon.name.split(' ').map(word => word.toLowerCase()).join('-'); + let code = coupon.name && coupon.name.split(' ').map(word => word.toLowerCase()).join('-'); + let name = coupon.name; + + // If name or coupon already exists, we'll append the Stripe id to the name and code + if (await this.existsByName(name, options)) { + name = `${name} (${coupon.id})`; + } + + if (await this.existsByCode(code, options)) { + code = `${code}-${coupon.id}`; + } const data = { active, - name: coupon.name, + name, code, product_id: productId, stripe_coupon_id: coupon.id, @@ -217,7 +227,7 @@ class OfferRepository { data.discount_amount = coupon.amount_off; } - await this.OfferModel.add(data, options); + return await this.OfferModel.add(data, options); } /** diff --git a/ghost/offers/test/lib/application/OfferRepository.test.js b/ghost/offers/test/lib/application/OfferRepository.test.js index 1e39a24dab..dc52d64849 100644 --- a/ghost/offers/test/lib/application/OfferRepository.test.js +++ b/ghost/offers/test/lib/application/OfferRepository.test.js @@ -2,7 +2,8 @@ const sinon = require('sinon'); const OfferRepository = require('../../../lib/application/OfferRepository'); const Offer = { - add: sinon.stub() + add: sinon.stub(), + findOne: sinon.stub() }; describe('OfferRepository', function () {