From 3e29ce839f4fbca0679ef567deb1414876682b76 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:02:13 +0100 Subject: [PATCH] Merge pull request #34602 from overleaf/td-stripe-3ds1-redirect Fix for Stripe redirecting to incorrect page GitOrigin-RevId: ac90b2e501c4bf5f2738d61866ea72ba861e5539 --- .../preview-subscription-change/root.tsx | 8 +- .../util/handle-stripe-payment-action.ts | 12 +- .../util/handle-stripe-payment-action.test.ts | 131 ++++++++++++++++++ 3 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 services/web/test/frontend/features/subscription/util/handle-stripe-payment-action.test.ts diff --git a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx index dcc82e08a5..dd1cc4fef2 100644 --- a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx +++ b/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 } diff --git a/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts index 399ec435a5..0ca1733aa7 100644 --- a/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts +++ b/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({ diff --git a/services/web/test/frontend/features/subscription/util/handle-stripe-payment-action.test.ts b/services/web/test/frontend/features/subscription/util/handle-stripe-payment-action.test.ts new file mode 100644 index 0000000000..11a3d3bf6b --- /dev/null +++ b/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 }) + }) + }) + }) +})