diff --git a/ghost/members-api/lib/repositories/MemberRepository.js b/ghost/members-api/lib/repositories/MemberRepository.js index 419fc29017..c110470358 100644 --- a/ghost/members-api/lib/repositories/MemberRepository.js +++ b/ghost/members-api/lib/repositories/MemberRepository.js @@ -829,6 +829,10 @@ module.exports = class MemberRepository { } } + async getCustomerIdByEmail(email) { + return this._stripeAPIService.getCustomerIdByEmail(email); + } + async getSubscriptionByStripeID(id, options) { const subscription = await this._StripeCustomerSubscription.findOne({ subscription_id: id diff --git a/ghost/members-importer/lib/MembersCSVImporter.js b/ghost/members-importer/lib/MembersCSVImporter.js index 2e65c7f618..9dda306bca 100644 --- a/ghost/members-importer/lib/MembersCSVImporter.js +++ b/ghost/members-importer/lib/MembersCSVImporter.js @@ -172,10 +172,21 @@ module.exports = class MembersCSVImporter { } if (row.stripe_customer_id && typeof row.stripe_customer_id === 'string') { - await membersRepository.linkStripeCustomer({ - customer_id: row.stripe_customer_id, - member_id: member.id - }, options); + let stripeCustomerId; + + // If 'auto' is passed, try to find the Stripe customer by email + if (row.stripe_customer_id.toLowerCase() === 'auto') { + stripeCustomerId = await membersRepository.getCustomerIdByEmail(row.email); + } else { + stripeCustomerId = row.stripe_customer_id; + } + + if (stripeCustomerId) { + await membersRepository.linkStripeCustomer({ + customer_id: stripeCustomerId, + member_id: member.id + }, options); + } } else if (row.complimentary_plan) { await membersRepository.update({ products: [{id: defaultTier.id.toString()}] diff --git a/ghost/members-importer/test/fixtures/auto-stripe-customer-id.csv b/ghost/members-importer/test/fixtures/auto-stripe-customer-id.csv new file mode 100644 index 0000000000..8fed44ff48 --- /dev/null +++ b/ghost/members-importer/test/fixtures/auto-stripe-customer-id.csv @@ -0,0 +1,2 @@ +email,stripe_customer_id +paidmember@mail.com,auto \ No newline at end of file diff --git a/ghost/members-importer/test/importer.test.js b/ghost/members-importer/test/importer.test.js index 093d3b927a..155d91d4af 100644 --- a/ghost/members-importer/test/importer.test.js +++ b/ghost/members-importer/test/importer.test.js @@ -60,7 +60,8 @@ describe('Importer', function () { }, create: memberCreateStub, update: sinon.stub().resolves(null), - linkStripeCustomer: sinon.stub().resolves(null) + linkStripeCustomer: sinon.stub().resolves(null), + getCustomerIdByEmail: sinon.stub().resolves('cus_mock_123456') }; knexStub = { @@ -325,7 +326,7 @@ describe('Importer', function () { assert.equal(result.errors.length, 0); }); - it ('handles various special cases', async function () { + it('handles various special cases', async function () { const importer = buildMockImporterInstance(); const result = await importer.perform(`${csvPath}/special-cases.csv`); @@ -344,7 +345,19 @@ describe('Importer', function () { assert.equal(result.errors.length, 0); }); - it ('respects existing member newsletter subscription preferences', async function () { + it('searches for stripe customer ID by email when "auto" is passed', async function () { + const importer = buildMockImporterInstance(); + + const result = await importer.perform(`${csvPath}/auto-stripe-customer-id.csv`); + + should.equal(membersRepositoryStub.linkStripeCustomer.args[0][0].customer_id, 'cus_mock_123456'); + + assert.equal(result.total, 1); + assert.equal(result.imported, 1); + assert.equal(result.errors.length, 0); + }); + + it('respects existing member newsletter subscription preferences', async function () { const importer = buildMockImporterInstance(); const newsletters = [ @@ -375,7 +388,7 @@ describe('Importer', function () { assert.deepEqual(membersRepositoryStub.update.args[0][0].newsletters, newsletters); }); - it ('does not add subscriptions for existing member when they do not have any subscriptions', async function () { + it('does not add subscriptions for existing member when they do not have any subscriptions', async function () { const importer = buildMockImporterInstance(); const member = { @@ -396,7 +409,7 @@ describe('Importer', function () { assert.deepEqual(membersRepositoryStub.update.args[0][0].subscribed, false); }); - it ('removes existing member newsletter subscriptions when set to not be subscribed', async function () { + it('removes existing member newsletter subscriptions when set to not be subscribed', async function () { const importer = buildMockImporterInstance(); const newsletters = [ diff --git a/ghost/stripe/lib/StripeAPI.js b/ghost/stripe/lib/StripeAPI.js index 5f9d53c0f4..ec3a4f0e0d 100644 --- a/ghost/stripe/lib/StripeAPI.js +++ b/ghost/stripe/lib/StripeAPI.js @@ -222,6 +222,59 @@ module.exports = class StripeAPI { return customer; } + /** + * Finds a Stripe Customer ID based on the provided email address. Returns null if no customer is found. + * @param {string} email + * @see https://stripe.com/docs/api/customers/search + * + * @returns {Promise} Stripe Customer ID, if found + */ + async getCustomerIdByEmail(email) { + try { + const result = await this._stripe.customers.search({ + query: `email:"${email}"`, + limit: 10, + expand: ['data.subscriptions'] + }); + const customers = result.data; + + // No customer found, return null + if (customers.length === 0) { + return; + } + + // Return the only customer found + if (customers.length === 1) { + return customers[0].id; + } + + // Multiple customers found, return the one with the most recent subscription + if (customers.length > 1) { + let latestCustomer = customers[0]; + let latestSubscriptionTime = 0; + + for (let customer of customers) { + // skip customers with no subscriptions + if (!customer.subscriptions || !customer.subscriptions.data || customer.subscriptions.data.length === 0) { + continue; + } + + // find the customer with the most recent subscription + for (let subscription of customer.subscriptions.data) { + if (subscription.created > latestSubscriptionTime) { + latestSubscriptionTime = subscription.created; + latestCustomer = customer; + } + } + } + + return latestCustomer.id; + } + } catch (err) { + debug(`getCustomerByEmail(${email}) -> ${err.type}:${err.message}`); + } + } + /** * @param {import('stripe').Stripe.CustomerCreateParams} options * diff --git a/ghost/stripe/test/unit/lib/StripeAPI.test.js b/ghost/stripe/test/unit/lib/StripeAPI.test.js index f834b67c36..10ca8ed797 100644 --- a/ghost/stripe/test/unit/lib/StripeAPI.test.js +++ b/ghost/stripe/test/unit/lib/StripeAPI.test.js @@ -5,134 +5,249 @@ const StripeAPI = rewire('../../../lib/StripeAPI'); const api = new StripeAPI(); describe('StripeAPI', function () { + const mockCustomerEmail = 'foo@example.com'; + const mockCustomerId = 'cust_mock_123456'; + const mockCustomerName = 'Example Customer'; + let mockStripe; - beforeEach(function () { - mockStripe = { - checkout: { - sessions: { - create: sinon.stub().resolves() + + describe('createCheckoutSession', function () { + beforeEach(function () { + mockStripe = { + checkout: { + sessions: { + create: sinon.stub().resolves() + } } - } - }; - const mockStripeConstructor = sinon.stub().returns(mockStripe); - StripeAPI.__set__('Stripe', mockStripeConstructor); - api.configure({ - checkoutSessionSuccessUrl: '/success', - checkoutSessionCancelUrl: '/cancel', - checkoutSetupSessionSuccessUrl: '/setup-success', - checkoutSetupSessionCancelUrl: '/setup-cancel', - secretKey: '' + }; + const mockStripeConstructor = sinon.stub().returns(mockStripe); + StripeAPI.__set__('Stripe', mockStripeConstructor); + api.configure({ + checkoutSessionSuccessUrl: '/success', + checkoutSessionCancelUrl: '/cancel', + checkoutSetupSessionSuccessUrl: '/setup-success', + checkoutSetupSessionCancelUrl: '/setup-cancel', + secretKey: '' + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('sends success_url and cancel_url', async function () { + await api.createCheckoutSession('priceId', null, {}); + + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.success_url); + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.cancel_url); + }); + + it('createCheckoutSetupSession sends success_url and cancel_url', async function () { + await api.createCheckoutSetupSession('priceId', {}); + + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.success_url); + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.cancel_url); + }); + + it('sets valid trialDays', async function () { + await api.createCheckoutSession('priceId', null, { + trialDays: 12 + }); + + should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan); + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days); + should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days, 12); + }); + + it('uses trial_from_plan without trialDays', async function () { + await api.createCheckoutSession('priceId', null, {}); + + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan); + should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan, true); + should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days); + }); + + it('ignores 0 trialDays', async function () { + await api.createCheckoutSession('priceId', null, { + trialDays: 0 + }); + + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan); + should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan, true); + should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days); + }); + + it('ignores null trialDays', async function () { + await api.createCheckoutSession('priceId', null, { + trialDays: null + }); + + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan); + should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan, true); + should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days); + }); + + it('passes customer ID successfully to Stripe', async function () { + const mockCustomer = { + id: mockCustomerId, + customer_email: mockCustomerEmail, + name: 'Example Customer' + }; + + await api.createCheckoutSession('priceId', mockCustomer, { + trialDays: null + }); + + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer); + should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.customer, 'cust_mock_123456'); + }); + + it('passes email if no customer object provided', async function () { + await api.createCheckoutSession('priceId', undefined, { + customerEmail: mockCustomerEmail, + trialDays: null + }); + + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email); + should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email, 'foo@example.com'); + }); + + it('passes email if customer object provided w/o ID', async function () { + const mockCustomer = { + email: mockCustomerEmail, + name: mockCustomerName + }; + + await api.createCheckoutSession('priceId', mockCustomer, { + trialDays: null + }); + + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email); + should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email, 'foo@example.com'); + }); + + it('passes only one of customer ID and email', async function () { + const mockCustomer = { + id: mockCustomerId, + email: mockCustomerEmail, + name: mockCustomerName + }; + + await api.createCheckoutSession('priceId', mockCustomer, { + trialDays: null + }); + + should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email); + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer); + should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.customer, 'cust_mock_123456'); }); }); - afterEach(function () { - sinon.restore(); - }); + describe('getCustomerIdByEmail', function () { + describe('when no customer is found', function () { + beforeEach(function () { + mockStripe = { + customers: { + search: sinon.stub().resolves({ + data: [] + }) + } + }; + const mockStripeConstructor = sinon.stub().returns(mockStripe); + StripeAPI.__set__('Stripe', mockStripeConstructor); + api.configure({ + secretKey: '' + }); + }); - it('createCheckoutSession sends success_url and cancel_url', async function (){ - await api.createCheckoutSession('priceId', null, {}); + afterEach(function () { + sinon.restore(); + }); - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.success_url); - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.cancel_url); - }); + it('returns null if customer exists', async function () { + const stripeCustomerId = await api.getCustomerIdByEmail(mockCustomerEmail); - it('createCheckoutSetupSession sends success_url and cancel_url', async function (){ - await api.createCheckoutSetupSession('priceId', {}); - - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.success_url); - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.cancel_url); - }); - - it('createCheckoutSession sets valid trialDays', async function (){ - await api.createCheckoutSession('priceId', null, { - trialDays: 12 + should.equal(stripeCustomerId, null); + }); }); - should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan); - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days); - should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days, 12); - }); + describe('when only one customer is found', function () { + beforeEach(function () { + mockStripe = { + customers: { + search: sinon.stub().resolves({ + data: [{ + id: mockCustomerId + }] + }) + } + }; + const mockStripeConstructor = sinon.stub().returns(mockStripe); + StripeAPI.__set__('Stripe', mockStripeConstructor); + api.configure({ + secretKey: '' + }); + }); - it('createCheckoutSession uses trial_from_plan without trialDays', async function (){ - await api.createCheckoutSession('priceId', null, {}); + afterEach(function () { + sinon.restore(); + }); - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan); - should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan, true); - should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days); - }); + it('returns customer ID if customer exists', async function () { + const stripeCustomerId = await api.getCustomerIdByEmail(mockCustomerEmail); - it('createCheckoutSession ignores 0 trialDays', async function (){ - await api.createCheckoutSession('priceId', null, { - trialDays: 0 + should.equal(stripeCustomerId, mockCustomerId); + }); }); - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan); - should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan, true); - should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days); - }); + describe('when multiple customers are found', function () { + beforeEach(function () { + mockStripe = { + customers: { + search: sinon.stub().resolves({ + data: [{ + id: 'recent_customer_id', + subscriptions: { + data: [ + {created: 1000}, + {created: 9000} + ] + } + }, + { + id: 'customer_with_no_sub_id', + subscriptions: { + data: [] + } + }, + { + id: 'old_customer_id', + subscriptions: { + data: [ + {created: 5000} + ] + } + } + ] + }) + } + }; + const mockStripeConstructor = sinon.stub().returns(mockStripe); + StripeAPI.__set__('Stripe', mockStripeConstructor); + api.configure({ + secretKey: '' + }); + }); - it('createCheckoutSession ignores null trialDays', async function (){ - await api.createCheckoutSession('priceId', null, { - trialDays: null + afterEach(function () { + sinon.restore(); + }); + + it('returns the customer with the most recent subscription', async function () { + const stripeCustomerId = await api.getCustomerIdByEmail(mockCustomerEmail); + + should.equal(stripeCustomerId, 'recent_customer_id'); + }); }); - - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan); - should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan, true); - should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_period_days); - }); - - it('createCheckoutSession passes customer ID successfully to Stripe', async function (){ - const mockCustomer = { - id: 'cust_mock_123456', - customer_email: 'foo@example.com', - name: 'Example Customer' - }; - - await api.createCheckoutSession('priceId', mockCustomer, { - trialDays: null - }); - - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer); - should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.customer, 'cust_mock_123456'); - }); - - it('createCheckoutSession passes email if no customer object provided', async function (){ - await api.createCheckoutSession('priceId', undefined, { - customerEmail: 'foo@example.com', - trialDays: null - }); - - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email); - should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email, 'foo@example.com'); - }); - - it('createCheckoutSession passes email if customer object provided w/o ID', async function (){ - const mockCustomer = { - email: 'foo@example.com', - name: 'Example Customer' - }; - - await api.createCheckoutSession('priceId', mockCustomer, { - trialDays: null - }); - - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email); - should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email, 'foo@example.com'); - }); - - it('createCheckoutSession passes only one of customer ID and email', async function (){ - const mockCustomer = { - id: 'cust_mock_123456', - email: 'foo@example.com', - name: 'Example Customer' - }; - - await api.createCheckoutSession('priceId', mockCustomer, { - trialDays: null - }); - - should.not.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer_email); - should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.customer); - should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.customer, 'cust_mock_123456'); }); });