Repository: TryGhost/Ghost (JavaScript)

Issue:
Ghost appears to redundantly create Stripe Customer objects via API
### Issue Summary

While this isn't high impact, it appears that checkouts initiated in Ghost to create new subscriptions will redundantly create customer objects in Stripe, with one copy having only a name/email address and the other copy having the actual subscription inside of it. This will pollute search results and make customer counts inaccurate.

Note here, for example, how the count of new customers is almost 2X the count of new successful payments.

<img width="680" alt="スクリーンショット 2023-01-03 10 25 20" src="https://user-images.githubusercontent.com/32929/210289537-65d2f069-15b2-43af-bfcf-2f6db9e714c1.png">

I believe I've figured out what is happening via Ghost's invocation of Stripe APIs. Here's what the history of API calls looks like for a single new subscriber:

<img width="561" alt="スクリーンショット 2023-01-03 10 17 43" src="https://user-images.githubusercontent.com/32929/210289211-f6eacd83-a02b-458c-88bb-fad8ced723ca.png">

We see Ghost pre-creating a Customer object prior to creating a Checkout session, which is one valid way to do it. However, the call to create the checkout session (POST /v1/checkout/sessions) does not reference the created Customer object. This results in Stripe creating a second Customer "at need", per the description [here](https://stripe.com/docs/api/checkout/sessions/create)

> customer: ... If blank for Checkout Sessions in payment or subscription mode, Checkout will create a new Customer object based on information provided during the payment flow.

The fix is simple, I think: cause the /v1/checkout/sessions POST API call to contain the customer ID, which will look something like cust_... , in the request.

There's some cleanup which could be done relatively trivially by Ghost on users' behalf, or in the alternative, you could release a snippet for users to query their Stripe API, search for the empty customers that duplicate live customers, and delete the empties. (I might write a script for this.)



### Steps to Reproduce

I think this should be observable for any Ghost instance making new subscriptions against Stripe.

1. Connect Ghost to Stripe.
2. Start a new paid subscription using a new email address.
3. Wait a few seconds for bookkeeping to happen.
4. Check the associated Stripe account; you will likely see two new customer objects, one with only the name/email and the other with the name/email and associated subscription/payment method/etc.

### Ghost Version

Current version via Ghost Pro hosting

### Node.js Version

Current version via Ghost Pro hosting

### How did you install Ghost?

Current version via Ghost Pro hosting

### Database type

MySQL 5.7

### Browser & OS version

_No response_

### Relevant log / error output

_No response_

### Code of Conduct

- [X] I agree to be friendly and polite to people in this repository


Discussion:
I'm still fighting my local JS environment to test the above code against the Stripe test environment to verify it actually fixes the issue, though I think from perusal of the code it likely will. Feel free to run with it or wait for me to get to it; it might take a day or two.
Hey @patio11 thanks for the detailed bug report and the WIP fix. @allouis would be happy to work with you to get the fix over the line 🙂 
@ErisDS Thanks much! It turned out that I did need to do the manual testing, as the Stripe API did not like when I specified a customer object and customer email in the same Checkout session creation request. I've fixed this issue.

@allouis I believe the above PR should work.



Patch:
diff --git a/ghost/stripe/lib/StripeAPI.js b/ghost/stripe/lib/StripeAPI.js
index b5ae3be4edb..3a0b31f64fd 100644
--- a/ghost/stripe/lib/StripeAPI.js
+++ b/ghost/stripe/lib/StripeAPI.js
@@ -364,7 +364,9 @@ module.exports = class StripeAPI {
      */
     async createCheckoutSession(priceId, customer, options) {
         const metadata = options.metadata || undefined;
+        const customerId = customer ? customer.id : undefined;
         const customerEmail = customer ? customer.email : options.customerEmail;
+ 
         await this._rateLimitBucket.throttle();
         let discounts;
         if (options.coupon) {
@@ -386,11 +388,11 @@ module.exports = class StripeAPI {
             delete subscriptionData.trial_from_plan;
             subscriptionData.trial_period_days = options.trialDays;
         }
-        const session = await this._stripe.checkout.sessions.create({
+
+        let stripeSessionOptions = {
             payment_method_types: ['card'],
             success_url: options.successUrl || this._config.checkoutSessionSuccessUrl,
             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
             allow_promotion_codes: discounts ? undefined : this._config.enablePromoCodes,
             metadata,
@@ -405,7 +407,17 @@ module.exports = class StripeAPI {
             // however, this would lose the "trial from plan" feature which has also
             // been deprecated by Stripe
             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;
     }


Test patch:
diff --git a/ghost/stripe/test/unit/lib/StripeAPI.test.js b/ghost/stripe/test/unit/lib/StripeAPI.test.js
index 76bd89de915..f834b67c366 100644
--- a/ghost/stripe/test/unit/lib/StripeAPI.test.js
+++ b/ghost/stripe/test/unit/lib/StripeAPI.test.js
@@ -80,4 +80,59 @@ describe('StripeAPI', function () {
         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');
+    });
 });