Browse Source

Merge pull request #34602 from overleaf/td-stripe-3ds1-redirect

Fix for Stripe redirecting to incorrect page

GitOrigin-RevId: ac90b2e501c4bf5f2738d61866ea72ba861e5539
Tim Down 1 month ago
parent
commit
3e29ce839f

+ 7 - 1
services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx

@@ -360,6 +360,10 @@ function PreviewSubscriptionChange() {
 }
 
 async function payNow(preview: SubscriptionChangePreview) {
+  const successPath =
+    preview.change.type === 'premium-subscription'
+      ? '/user/subscription/thank-you?upgrade=true'
+      : '/user/subscription/thank-you'
   try {
     if (preview.change.type === 'add-on-purchase') {
       await postJSON(
@@ -375,7 +379,9 @@ async function payNow(preview: SubscriptionChangePreview) {
       )
     }
   } catch (e) {
-    const { handled } = await handleStripePaymentAction(e as FetchError)
+    const { handled } = await handleStripePaymentAction(e as FetchError, {
+      successPath,
+    })
     if (!handled) {
       throw e
     }

+ 7 - 5
services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts

@@ -3,7 +3,8 @@ import getMeta from '@/utils/meta'
 import { loadStripe } from '@stripe/stripe-js/pure'
 
 export default async function handleStripePaymentAction(
-  error: FetchError
+  error: FetchError,
+  options?: { successPath?: string }
 ): Promise<{ handled: boolean }> {
   const clientSecret = error?.data?.clientSecret
   const publicKey = error?.data?.publicKey
@@ -11,14 +12,15 @@ export default async function handleStripePaymentAction(
   if (clientSecret && publicKey) {
     const stripe = await loadStripe(publicKey)
     if (stripe) {
-      const currentPath = window.location.pathname
+      const currentPath = window.location.pathname + window.location.search
       const returnUrl = new URL(
         '/user/subscription/offsite',
         getMeta('ol-ExposedSettings').siteUrl
       )
-      const returnParams = new URLSearchParams({
-        path: currentPath,
-      })
+      const returnParams = new URLSearchParams({ path: currentPath })
+      if (options?.successPath) {
+        returnParams.set('successPath', options.successPath)
+      }
       returnUrl.search = returnParams.toString()
 
       const manualConfirmationFlow = await stripe.confirmPayment({

+ 131 - 0
services/web/test/frontend/features/subscription/util/handle-stripe-payment-action.test.ts

@@ -0,0 +1,131 @@
+import sinon from 'sinon'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import * as stripeModule from '@stripe/stripe-js/pure'
+import { FetchError } from '@/infrastructure/fetch-json'
+import handleStripePaymentAction from '../../../../../frontend/js/features/subscription/util/handle-stripe-payment-action'
+
+describe('handleStripePaymentAction', function () {
+  let sandbox: sinon.SinonSandbox
+  let confirmPaymentStub: sinon.SinonStub
+
+  beforeEach(function () {
+    sandbox = sinon.createSandbox()
+
+    confirmPaymentStub = sandbox.stub()
+    sandbox.stub(stripeModule, 'loadStripe').resolves({
+      confirmPayment: confirmPaymentStub,
+    } as any)
+
+    window.history.pushState(
+      {},
+      '',
+      '/user/subscription/preview?planCode=professional-annual'
+    )
+
+    window.metaAttributesCache.set('ol-ExposedSettings', {
+      siteUrl: 'https://www.overleaf.com',
+    })
+  })
+
+  afterEach(function () {
+    sandbox.restore()
+    fetchMock.removeRoutes().clearHistory()
+    window.history.pushState({}, '', '/')
+  })
+
+  describe('when error has no clientSecret or publicKey', function () {
+    it('returns { handled: false } without calling Stripe', async function () {
+      const error = new FetchError('error', 'url', undefined, undefined, {})
+      const result = await handleStripePaymentAction(error)
+      expect(result).to.deep.equal({ handled: false })
+      sinon.assert.notCalled(
+        stripeModule.loadStripe as unknown as sinon.SinonStub
+      )
+    })
+  })
+
+  describe('when error has clientSecret and publicKey', function () {
+    let error: FetchError
+
+    beforeEach(function () {
+      error = new FetchError(
+        'Payment action required',
+        'url',
+        undefined,
+        undefined,
+        {
+          clientSecret: 'cs_test_123',
+          publicKey: 'pk_test_abc',
+        }
+      )
+    })
+
+    it('passes return_url with full path including query string', async function () {
+      confirmPaymentStub.resolves({})
+      fetchMock.post('/user/subscription/sync', 200)
+
+      await handleStripePaymentAction(error)
+
+      sinon.assert.calledOnce(confirmPaymentStub)
+      const { confirmParams } = confirmPaymentStub.firstCall.args[0]
+      const returnUrl = new URL(confirmParams.return_url)
+      expect(returnUrl.searchParams.get('path')).to.equal(
+        '/user/subscription/preview?planCode=professional-annual'
+      )
+    })
+
+    it('includes successPath in return_url when provided', async function () {
+      confirmPaymentStub.resolves({})
+      fetchMock.post('/user/subscription/sync', 200)
+
+      await handleStripePaymentAction(error, {
+        successPath: '/user/subscription/thank-you?upgrade=true',
+      })
+
+      const { confirmParams } = confirmPaymentStub.firstCall.args[0]
+      const returnUrl = new URL(confirmParams.return_url)
+      expect(returnUrl.searchParams.get('successPath')).to.equal(
+        '/user/subscription/thank-you?upgrade=true'
+      )
+    })
+
+    it('uses redirect: if_required', async function () {
+      confirmPaymentStub.resolves({})
+      fetchMock.post('/user/subscription/sync', 200)
+
+      await handleStripePaymentAction(error)
+
+      expect(confirmPaymentStub.firstCall.args[0].redirect).to.equal(
+        'if_required'
+      )
+    })
+
+    describe('when confirmPayment succeeds', function () {
+      it('returns { handled: true }', async function () {
+        confirmPaymentStub.resolves({})
+        fetchMock.post('/user/subscription/sync', 200)
+
+        const result = await handleStripePaymentAction(error)
+
+        expect(result).to.deep.equal({ handled: true })
+      })
+    })
+
+    describe('when confirmPayment returns an error', function () {
+      it('returns { handled: false }', async function () {
+        confirmPaymentStub.resolves({
+          error: { payment_intent: { id: 'pi_test_456' } },
+        })
+        fetchMock.post(
+          '/user/subscription/void-change?payment_intent_id=pi_test_456',
+          200
+        )
+
+        const result = await handleStripePaymentAction(error)
+
+        expect(result).to.deep.equal({ handled: false })
+      })
+    })
+  })
+})