Merge pull request #27093 from overleaf/ls-support-3ds-in-group-plan-update-flows

Support 3DS verification in group plan update flows

GitOrigin-RevId: 3206f612e5699f39ac44864daf6610da2956e6ca
This commit is contained in:
Liangjun Song
2025-07-16 08:05:25 +00:00
committed by Copybot
parent 9e22ed9c3f
commit 1daa49d9d2
5 changed files with 152 additions and 33 deletions
@@ -19,6 +19,7 @@ import {
SubtotalLimitExceededError, SubtotalLimitExceededError,
HasPastDueInvoiceError, HasPastDueInvoiceError,
HasNoAdditionalLicenseWhenManuallyCollectedError, HasNoAdditionalLicenseWhenManuallyCollectedError,
PaymentActionRequiredError,
} from './Errors.js' } from './Errors.js'
/** /**
@@ -273,6 +274,14 @@ async function createAddSeatsSubscriptionChange(req, res) {
}) })
} }
if (error instanceof PaymentActionRequiredError) {
return res.status(402).json({
message: 'Payment action required',
clientSecret: error.info.clientSecret,
publicKey: error.info.publicKey,
})
}
logger.err( logger.err(
{ error }, { error },
'error trying to create "add seats" subscription change' 'error trying to create "add seats" subscription change'
@@ -369,6 +378,13 @@ async function upgradeSubscription(req, res) {
await SubscriptionGroupHandler.promises.upgradeGroupPlan(userId) await SubscriptionGroupHandler.promises.upgradeGroupPlan(userId)
return res.sendStatus(200) return res.sendStatus(200)
} catch (error) { } catch (error) {
if (error instanceof PaymentActionRequiredError) {
return res.status(402).json({
message: 'Payment action required',
clientSecret: error.info.clientSecret,
publicKey: error.info.publicKey,
})
}
logger.err({ error }, 'error trying to upgrade subscription') logger.err({ error }, 'error trying to upgrade subscription')
return res.sendStatus(500) return res.sendStatus(500)
} }
@@ -30,6 +30,7 @@ import {
} from '../../../../../../types/subscription/subscription-change-preview' } from '../../../../../../types/subscription/subscription-change-preview'
import { MergeAndOverride, Nullable } from '../../../../../../types/utils' import { MergeAndOverride, Nullable } from '../../../../../../types/utils'
import { sendMB } from '../../../../infrastructure/event-tracking' import { sendMB } from '../../../../infrastructure/event-tracking'
import handleStripePaymentAction from '@/features/subscription/util/handle-stripe-payment-action'
export const MAX_NUMBER_OF_USERS = 20 export const MAX_NUMBER_OF_USERS = 20
export const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50 export const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50
@@ -60,13 +61,12 @@ function AddSeats() {
reset: resetCostSummaryData, reset: resetCostSummaryData,
error: errorCostSummary, error: errorCostSummary,
} = useAsync<CostSummaryData, FetchError>() } = useAsync<CostSummaryData, FetchError>()
const { const [isAddingSeats, setIsAddingSeats] = useState(false)
isLoading: isAddingSeats, const [isErrorAddingSeats, setIsErrorAddingSeats] = useState(false)
isError: isErrorAddingSeats, const [isSuccessAddingSeats, setIsSuccessAddingSeats] = useState(false)
isSuccess: isSuccessAddingSeats, const [addedSeatsData, setAddedSeatsData] = useState<{
runAsync: runAsyncAddSeats, adding: number
data: addedSeatsData, } | null>(null)
} = useAsync<{ adding: number }>()
const { const {
isLoading: isSendingMailToSales, isLoading: isSendingMailToSales,
isError: isErrorSendingMailToSales, isError: isErrorSendingMailToSales,
@@ -217,21 +217,34 @@ function AddSeats() {
sendMB('flex-add-users-form', { sendMB('flex-add-users-form', {
action: 'click-add-user-button', action: 'click-add-user-button',
}) })
const post = postJSON('/user/subscription/group/add-users/create', { setIsAddingSeats(true)
signal: addSeatsSignal, try {
body: { const response = await postJSON<{
adding: Number(rawSeats), adding: number
poNumber, }>('/user/subscription/group/add-users/create', {
}, signal: addSeatsSignal,
}) body: {
runAsyncAddSeats(post) adding: Number(rawSeats),
.then(() => { poNumber,
},
})
sendMB('flex-add-users-success')
setIsSuccessAddingSeats(true)
setAddedSeatsData(response)
} catch (error) {
const { handled } = await handleStripePaymentAction(error as FetchError)
if (handled) {
sendMB('flex-add-users-success') sendMB('flex-add-users-success')
}) setIsSuccessAddingSeats(true)
.catch(() => { setAddedSeatsData({ adding: Number(rawSeats) })
debugConsole.error() return
sendMB('flex-add-users-error') }
}) debugConsole.error(error)
sendMB('flex-add-users-error')
setIsErrorAddingSeats(true)
} finally {
setIsAddingSeats(false)
}
} }
} }
@@ -1,35 +1,49 @@
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
import { postJSON } from '@/infrastructure/fetch-json' import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import { useTranslation, Trans } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { Card, Row, Col } from 'react-bootstrap' import { Card, Row, Col } from 'react-bootstrap'
import IconButton from '@/features/ui/components/bootstrap-5/icon-button' import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
import Button from '@/features/ui/components/bootstrap-5/button' import Button from '@/features/ui/components/bootstrap-5/button'
import UpgradeSubscriptionPlanDetails from './upgrade-subscription-plan-details' import UpgradeSubscriptionPlanDetails from './upgrade-subscription-plan-details'
import useAsync from '@/shared/hooks/use-async'
import RequestStatus from '../request-status' import RequestStatus from '../request-status'
import UpgradeSummary, { import UpgradeSummary, {
SubscriptionChange, SubscriptionChange,
} from './upgrade-subscription-upgrade-summary' } from './upgrade-subscription-upgrade-summary'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { sendMB } from '../../../../infrastructure/event-tracking' import { sendMB } from '../../../../infrastructure/event-tracking'
import handleStripePaymentAction from '@/features/subscription/util/handle-stripe-payment-action'
import { useState } from 'react'
function UpgradeSubscription() { function UpgradeSubscription() {
const { t } = useTranslation() const { t } = useTranslation()
const groupName = getMeta('ol-groupName') const groupName = getMeta('ol-groupName')
const preview = getMeta('ol-subscriptionChangePreview') as SubscriptionChange const preview = getMeta('ol-subscriptionChangePreview') as SubscriptionChange
const { isError, runAsync, isSuccess, isLoading } = useAsync() const [isError, setIsError] = useState(false)
const onSubmit = () => { const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const onSubmit = async () => {
sendMB('flex-upgrade-form', { sendMB('flex-upgrade-form', {
action: 'click-upgrade-button', action: 'click-upgrade-button',
}) })
runAsync(postJSON('/user/subscription/group/upgrade-subscription')) setIsLoading(true)
.then(() => {
try {
await postJSON('/user/subscription/group/upgrade-subscription')
sendMB('flex-upgrade-success')
setIsSuccess(true)
} catch (error) {
const { handled } = await handleStripePaymentAction(error as FetchError)
if (handled) {
sendMB('flex-upgrade-success') sendMB('flex-upgrade-success')
}) setIsSuccess(true)
.catch(() => { return
debugConsole.error() }
sendMB('flex-upgrade-error') debugConsole.error(error)
}) sendMB('flex-upgrade-error')
setIsError(true)
} finally {
setIsLoading(false)
}
} }
if (isSuccess) { if (isSuccess) {
@@ -2,7 +2,10 @@ import { useEffect, useState } from 'react'
import { useTranslation, Trans } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { PaidSubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription' import { PaidSubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan' import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json' import {
postJSON,
FetchError,
} from '../../../../../../../../infrastructure/fetch-json'
import getMeta from '../../../../../../../../utils/meta' import getMeta from '../../../../../../../../utils/meta'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
import GenericErrorAlert from '../../../../generic-error-alert' import GenericErrorAlert from '../../../../generic-error-alert'
@@ -23,6 +26,7 @@ import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
import { UserProvider } from '@/shared/context/user-context' import { UserProvider } from '@/shared/context/user-context'
import OLButton from '@/features/ui/components/ol/ol-button' import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification' import OLNotification from '@/features/ui/components/ol/ol-notification'
import handleStripePaymentAction from '@/features/subscription/util/handle-stripe-payment-action'
const educationalPercentDiscount = 40 const educationalPercentDiscount = 40
@@ -136,6 +140,11 @@ export function ChangeToGroupModal() {
}) })
location.reload() location.reload()
} catch (e) { } catch (e) {
const { handled } = await handleStripePaymentAction(e as FetchError)
if (handled) {
location.reload()
return
}
setError(true) setError(true)
setInflight(false) setInflight(false)
} }
@@ -137,6 +137,12 @@ describe('SubscriptionGroupController', function () {
SubtotalLimitExceededError: class extends Error {}, SubtotalLimitExceededError: class extends Error {},
HasPastDueInvoiceError: class extends Error {}, HasPastDueInvoiceError: class extends Error {},
HasNoAdditionalLicenseWhenManuallyCollectedError: class extends Error {}, HasNoAdditionalLicenseWhenManuallyCollectedError: class extends Error {},
PaymentActionRequiredError: class extends Error {
constructor(info) {
super('Payment action required')
this.info = info
}
},
} }
vi.doMock( vi.doMock(
@@ -718,6 +724,38 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res) ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
}) })
}) })
it('should send 402 response with PaymentActionRequiredError', async function (ctx) {
await new Promise(resolve => {
const adding = 2
ctx.req.body = { adding }
const error = new ctx.Errors.PaymentActionRequiredError({
clientSecret: 'secret',
publicKey: 'key',
})
ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
sinon.stub().throws(error)
const res = {
status: statusCode => {
statusCode.should.equal(402)
return {
json: data => {
data.should.deep.equal({
message: 'Payment action required',
clientSecret: error.info.clientSecret,
publicKey: error.info.publicKey,
})
resolve()
},
}
},
}
ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
})
})
}) })
describe('submitForm', function () { describe('submitForm', function () {
@@ -896,5 +934,34 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.upgradeSubscription(ctx.req, res) ctx.Controller.upgradeSubscription(ctx.req, res)
}) })
}) })
it('should send 402 response with PaymentActionRequiredError', async function (ctx) {
await new Promise(resolve => {
const error = new ctx.Errors.PaymentActionRequiredError({
clientSecret: 'secret',
publicKey: 'public',
})
ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
.stub()
.rejects(error)
const res = {
status: code => {
code.should.equal(402)
return {
json: data => {
data.should.deep.equal({
message: 'Payment action required',
clientSecret: error.info.clientSecret,
publicKey: error.info.publicKey,
})
resolve()
},
}
},
}
ctx.Controller.upgradeSubscription(ctx.req, res)
})
})
}) })
}) })