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

Fix for Stripe redirecting to incorrect page

GitOrigin-RevId: ac90b2e501c4bf5f2738d61866ea72ba861e5539
This commit is contained in:
Tim Down
2026-06-25 08:05:54 +00:00
committed by Copybot
parent 13bc31cf25
commit 3e29ce839f
3 changed files with 145 additions and 6 deletions
@@ -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
}
@@ -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({
@@ -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 })
})
})
})
})