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 0ee07d0e1c..7fb8bffd65 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,228 +2723,6 @@ 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", @@ -3456,11 +3234,11 @@ Object { "comped": 4, "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, "free": 4, - "paid": 4, + "paid": 2, }, ], "resource": "members", - "total": 12, + "total": 10, } `; @@ -4056,58 +3834,6 @@ 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, @@ -4298,7 +4024,7 @@ Object { "page": 1, "pages": 1, "prev": null, - "total": 10, + "total": 8, }, }, } @@ -4308,7 +4034,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": "30800", + "content-length": "23956", "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 c4fc41baf3..7bd8fc3913 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -1395,229 +1395,6 @@ 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 () { @@ -2494,7 +2271,7 @@ describe('Members API', function () { .get('/members/?include=tiers&filter=tier:default-product') .expectStatus(200) .matchBodySnapshot({ - members: new Array(10).fill(buildMemberMatcherShallowIncludesWithTiers()) + members: new Array(8).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 27774b5dba..d91b32ba1f 100644 --- a/ghost/core/test/e2e-api/members/webhooks.test.js +++ b/ghost/core/test/e2e-api/members/webhooks.test.js @@ -1496,6 +1496,141 @@ 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/members-api/lib/repositories/MemberRepository.js b/ghost/members-api/lib/repositories/MemberRepository.js index 3f0145d217..bbcd2d315f 100644 --- a/ghost/members-api/lib/repositories/MemberRepository.js +++ b/ghost/members-api/lib/repositories/MemberRepository.js @@ -940,8 +940,7 @@ module.exports = class MemberRepository { logging.error(e); } - const stripeCoupon = subscription.discount?.coupon; - const stripeCouponId = stripeCoupon ? subscription.discount.coupon.id : null; + let stripeCouponId = subscription.discount && subscription.discount.coupon ? subscription.discount.coupon.id : null; // For trial offers, offer id is passed from metadata as there is no stripe coupon let offerId = data.offerId || null; @@ -953,21 +952,7 @@ module.exports = class MemberRepository { if (offer) { offerId = offer.id; } else { - try { - // Create an offer in our database - const productId = ghostProduct.get('id'); - const currency = subscriptionPriceData.currency; - const interval = _.get(subscriptionPriceData, 'recurring.interval', ''); - offer = await this._offerRepository.createFromCoupon( - stripeCoupon, - {productId, currency, interval, active: false}, - {transacting: options.transacting} - ); - offerId = offer?.id; - } catch (e) { - logging.error(`Error when creating an offer from stripe coupon id (${stripeCouponId}) for subscription - ${subscription.id}.`); - logging.error(e); - } + logging.error(`Received an unknown stripe coupon id (${stripeCouponId}) for subscription - ${subscription.id}.`); } } else if (offerId) { offer = await this._offerRepository.getById(offerId, {transacting: options.transacting}); diff --git a/ghost/offers/lib/application/OfferRepository.js b/ghost/offers/lib/application/OfferRepository.js index 948a530ff0..f27f4e5e68 100644 --- a/ghost/offers/lib/application/OfferRepository.js +++ b/ghost/offers/lib/application/OfferRepository.js @@ -43,14 +43,6 @@ const mongoTransformer = flowRight(statusTransformer, rejectNonStatusTransformer * @prop {string} filter */ -/** - * @typedef {object} OfferAdditionalParams - * @prop {string} productId — the Ghost Product ID - * @prop {string} currency — the currency of the plan - * @prop {string} interval — the billing interval of the plan (month, year) - * @prop {boolean} active — whether the offer is active upoon creation - */ - class OfferRepository { /** * @param {{forge: (data: object) => import('bookshelf').Model}} OfferModel @@ -187,49 +179,6 @@ class OfferRepository { return Promise.all(offers); } - /** - * @param {import('stripe').Stripe.CouponCreateParams} coupon - * @param {OfferAdditionalParams} params - * @param {BaseOptions} [options] - */ - async createFromCoupon(coupon, params, options) { - const {productId, currency, interval, active} = params; - 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, - code, - product_id: productId, - stripe_coupon_id: coupon.id, - interval, - currency, - duration: coupon.duration, - duration_in_months: coupon.duration === 'repeating' ? coupon.duration_in_months : null, - portal_title: coupon.name - }; - - if (coupon.percent_off) { - data.discount_type = 'percent'; - data.discount_amount = coupon.percent_off; - } else { - data.discount_type = 'amount'; - data.discount_amount = coupon.amount_off; - } - - return await this.OfferModel.add(data, options); - } - /** * @param {Offer} offer * @param {BaseOptions} [options] diff --git a/ghost/offers/test/lib/application/OfferRepository.test.js b/ghost/offers/test/lib/application/OfferRepository.test.js deleted file mode 100644 index dc52d64849..0000000000 --- a/ghost/offers/test/lib/application/OfferRepository.test.js +++ /dev/null @@ -1,339 +0,0 @@ -const sinon = require('sinon'); -const OfferRepository = require('../../../lib/application/OfferRepository'); - -const Offer = { - add: sinon.stub(), - findOne: sinon.stub() -}; - -describe('OfferRepository', function () { - describe('#createFromCoupon', function () { - it('creates a 50% off for 3 months offer', async function () { - const coupon = { - id: 'coupon-id', - name: 'Coupon Name', - percent_off: 50, - duration: 'repeating', - duration_in_months: 3 - }; - - const params = { - productId: 'product-id', - currency: 'usd', - interval: 'month', - active: true - }; - - const options = { - transacting: true - }; - - const expectedData = { - active: true, - name: 'Coupon Name', - code: 'coupon-name', - product_id: 'product-id', - stripe_coupon_id: 'coupon-id', - interval: 'month', - currency: 'usd', - duration: 'repeating', - duration_in_months: 3, - portal_title: 'Coupon Name', - discount_type: 'percent', - discount_amount: 50 - }; - - const offerRepository = new OfferRepository(Offer); - await offerRepository.createFromCoupon(coupon, params, options); - - Offer.add.calledWith(expectedData, options).should.be.true(); - }); - - it('creates a 1 USD off for 3 months offer', async function () { - const coupon = { - id: 'coupon-id', - name: 'Coupon Name', - amount_off: 1, - duration: 'repeating', - duration_in_months: 3 - }; - - const params = { - productId: 'product-id', - currency: 'usd', - interval: 'month', - active: true - }; - - const options = { - transacting: true - }; - - const expectedData = { - active: true, - name: 'Coupon Name', - code: 'coupon-name', - product_id: 'product-id', - stripe_coupon_id: 'coupon-id', - interval: 'month', - currency: 'usd', - duration: 'repeating', - duration_in_months: 3, - portal_title: 'Coupon Name', - discount_type: 'amount', - discount_amount: 1 - }; - - const offerRepository = new OfferRepository(Offer); - await offerRepository.createFromCoupon(coupon, params, options); - - Offer.add.calledWith(expectedData, options).should.be.true(); - }); - - it('creates a 50% off forever offer', async function () { - const coupon = { - id: 'coupon-id', - name: 'Coupon Name', - percent_off: 50, - duration: 'forever', - duration_in_months: null - }; - - const params = { - productId: 'product-id', - currency: 'usd', - interval: 'month', - active: true - }; - - const options = { - transacting: true - }; - - const expectedData = { - active: true, - name: 'Coupon Name', - code: 'coupon-name', - product_id: 'product-id', - stripe_coupon_id: 'coupon-id', - interval: 'month', - currency: 'usd', - duration: 'forever', - duration_in_months: null, - portal_title: 'Coupon Name', - discount_type: 'percent', - discount_amount: 50 - }; - - const offerRepository = new OfferRepository(Offer); - await offerRepository.createFromCoupon(coupon, params, options); - - Offer.add.calledWith(expectedData, options).should.be.true(); - }); - - it('creates a 1 USD off forever offer', async function () { - const coupon = { - id: 'coupon-id', - name: 'Coupon Name', - amount_off: 1, - duration: 'forever', - duration_in_months: null - }; - - const params = { - productId: 'product-id', - currency: 'usd', - interval: 'month', - active: true - }; - - const options = { - transacting: true - }; - - const expectedData = { - active: true, - name: 'Coupon Name', - code: 'coupon-name', - product_id: 'product-id', - stripe_coupon_id: 'coupon-id', - interval: 'month', - currency: 'usd', - duration: 'forever', - duration_in_months: null, - portal_title: 'Coupon Name', - discount_type: 'amount', - discount_amount: 1 - }; - - const offerRepository = new OfferRepository(Offer); - await offerRepository.createFromCoupon(coupon, params, options); - - Offer.add.calledWith(expectedData, options).should.be.true(); - }); - - it('creates a 50% USD off once yearly offer', async function () { - const coupon = { - id: 'coupon-id', - name: 'Coupon Name', - percent_off: 50, - duration: 'once', - duration_in_months: null - }; - - const params = { - productId: 'product-id', - currency: 'usd', - interval: 'yearly', - active: true - }; - - const options = { - transacting: true - }; - - const expectedData = { - active: true, - name: 'Coupon Name', - code: 'coupon-name', - product_id: 'product-id', - stripe_coupon_id: 'coupon-id', - interval: 'yearly', - currency: 'usd', - duration: 'once', - duration_in_months: null, - portal_title: 'Coupon Name', - discount_type: 'percent', - discount_amount: 50 - }; - - const offerRepository = new OfferRepository(Offer); - await offerRepository.createFromCoupon(coupon, params, options); - - Offer.add.calledWith(expectedData, options).should.be.true(); - }); - - it('creates a 1 USD off once yearly offer', async function () { - const coupon = { - id: 'coupon-id', - name: 'Coupon Name', - amount_off: 1, - duration: 'once', - duration_in_months: null - }; - - const params = { - productId: 'product-id', - currency: 'usd', - interval: 'yearly', - active: true - }; - - const options = { - transacting: true - }; - - const expectedData = { - active: true, - name: 'Coupon Name', - code: 'coupon-name', - product_id: 'product-id', - stripe_coupon_id: 'coupon-id', - interval: 'yearly', - currency: 'usd', - duration: 'once', - duration_in_months: null, - portal_title: 'Coupon Name', - discount_type: 'amount', - discount_amount: 1 - }; - - const offerRepository = new OfferRepository(Offer); - await offerRepository.createFromCoupon(coupon, params, options); - - Offer.add.calledWith(expectedData, options).should.be.true(); - }); - - it('creates a 50% off during one month offer', async function () { - const coupon = { - id: 'coupon-id', - name: 'Coupon Name', - percent_off: 50, - duration: 'repeating', - duration_in_months: 1 - }; - - const params = { - productId: 'product-id', - currency: 'usd', - interval: 'month', - active: true - }; - - const options = { - transacting: true - }; - - const expectedData = { - active: true, - name: 'Coupon Name', - code: 'coupon-name', - product_id: 'product-id', - stripe_coupon_id: 'coupon-id', - interval: 'month', - currency: 'usd', - duration: 'repeating', - duration_in_months: 1, - portal_title: 'Coupon Name', - discount_type: 'percent', - discount_amount: 50 - }; - - const offerRepository = new OfferRepository(Offer); - await offerRepository.createFromCoupon(coupon, params, options); - - Offer.add.calledWith(expectedData, options).should.be.true(); - }); - - it('creates a 1 USD off during one month offer', async function () { - const coupon = { - id: 'coupon-id', - name: 'Coupon Name', - amount_off: 1, - duration: 'repeating', - duration_in_months: 1 - }; - - const params = { - productId: 'product-id', - currency: 'usd', - interval: 'month', - active: true - }; - - const options = { - transacting: true - }; - - const expectedData = { - active: true, - name: 'Coupon Name', - code: 'coupon-name', - product_id: 'product-id', - stripe_coupon_id: 'coupon-id', - interval: 'month', - currency: 'usd', - duration: 'repeating', - duration_in_months: 1, - portal_title: 'Coupon Name', - discount_type: 'amount', - discount_amount: 1 - }; - - const offerRepository = new OfferRepository(Offer); - await offerRepository.createFromCoupon(coupon, params, options); - - Offer.add.calledWith(expectedData, options).should.be.true(); - }); - }); -});