🐛 Stopped creating redundant Stripe Customers for Members

fixes https://github.com/TryGhost/Ghost/issues/16057

Briefly, Ghost created two Customer objects via the Stripe API when an
existing subscriber would upgrade to a paid subscription, one in an API
call to create the Customer and then a second as a side effect of an API
call to create a Checkout session for the user. The fix is passing the
reference to the Customer object to the API call to create the Checkout
session; Stripe will no longer redundantly create a Customer object in
this case.

This largely impacts the owner's experience of the Stripe Dashboard; it
will correct their new Customer count (going forward) and make searches
for users by name or email address return one responsive object which
has the actual subscription in it versus returning two and forcing them
to look in each to e.g. refund a transaction or similar.
This commit is contained in:
Patrick McKenzie 2023-01-05 22:44:56 -06:00 committed by GitHub
parent 78384dd9eb
commit 559ca9d866
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 70 additions and 3 deletions

View File

@ -364,7 +364,9 @@ module.exports = class StripeAPI {
*/ */
async createCheckoutSession(priceId, customer, options) { async createCheckoutSession(priceId, customer, options) {
const metadata = options.metadata || undefined; const metadata = options.metadata || undefined;
const customerId = customer ? customer.id : undefined;
const customerEmail = customer ? customer.email : options.customerEmail; const customerEmail = customer ? customer.email : options.customerEmail;
await this._rateLimitBucket.throttle(); await this._rateLimitBucket.throttle();
let discounts; let discounts;
if (options.coupon) { if (options.coupon) {
@ -386,11 +388,11 @@ module.exports = class StripeAPI {
delete subscriptionData.trial_from_plan; delete subscriptionData.trial_from_plan;
subscriptionData.trial_period_days = options.trialDays; subscriptionData.trial_period_days = options.trialDays;
} }
const session = await this._stripe.checkout.sessions.create({
let stripeSessionOptions = {
payment_method_types: ['card'], payment_method_types: ['card'],
success_url: options.successUrl || this._config.checkoutSessionSuccessUrl, success_url: options.successUrl || this._config.checkoutSessionSuccessUrl,
cancel_url: options.cancelUrl || this._config.checkoutSessionCancelUrl, cancel_url: options.cancelUrl || this._config.checkoutSessionCancelUrl,
customer_email: customerEmail,
// @ts-ignore - we need to update to latest stripe library to correctly use newer features // @ts-ignore - we need to update to latest stripe library to correctly use newer features
allow_promotion_codes: discounts ? undefined : this._config.enablePromoCodes, allow_promotion_codes: discounts ? undefined : this._config.enablePromoCodes,
metadata, metadata,
@ -405,7 +407,17 @@ module.exports = class StripeAPI {
// however, this would lose the "trial from plan" feature which has also // however, this would lose the "trial from plan" feature which has also
// been deprecated by Stripe // been deprecated by Stripe
subscription_data: subscriptionData subscription_data: subscriptionData
}); };
/* We are only allowed to specify one of these; email will be pulled from
customer object on Stripe side if that object already exists. */
if (customerId) {
stripeSessionOptions.customer = customerId;
} else {
stripeSessionOptions.customer_email = customerEmail;
}
const session = await this._stripe.checkout.sessions.create(stripeSessionOptions);
return session; return session;
} }

View File

@ -80,4 +80,59 @@ describe('StripeAPI', function () {
should.equal(mockStripe.checkout.sessions.create.firstCall.firstArg.subscription_data.trial_from_plan, true); 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); 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');
});
}); });