Added search by email for Stripe Customer ID during member import (#17326)

closes https://github.com/TryGhost/Product/issues/3593

- when importing members via CSV, it's now possible to add the value "auto" for the "stripe_customer_id" field. When this option is passed, the importer will search for a Stripe customer based on the email address provided
- if there are multiple Stripe customers with the same email address, the customer with the most recent subscription is returned
This commit is contained in:
Sag 2023-07-13 13:20:54 +02:00 committed by GitHub
parent fd5b2cc0cf
commit 9f438972f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 319 additions and 121 deletions

View File

@ -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

View File

@ -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()}]

View File

@ -0,0 +1,2 @@
email,stripe_customer_id
paidmember@mail.com,auto
1 email stripe_customer_id
2 paidmember@mail.com auto

View File

@ -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 = [

View File

@ -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<string|null>} 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
*

View File

@ -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');
});
});