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:
parent
fd5b2cc0cf
commit
9f438972f1
@ -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
|
||||
|
@ -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()}]
|
||||
|
2
ghost/members-importer/test/fixtures/auto-stripe-customer-id.csv
vendored
Normal file
2
ghost/members-importer/test/fixtures/auto-stripe-customer-id.csv
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
email,stripe_customer_id
|
||||
paidmember@mail.com,auto
|
|
@ -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 = [
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user