From 1daa49d9d2bf597dfdfd0d981f24a3e5bb5f1fb5 Mon Sep 17 00:00:00 2001 From: Liangjun Song <146005915+adai26@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:02:44 +0100 Subject: [PATCH] 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 --- .../SubscriptionGroupController.mjs | 16 +++++ .../components/add-seats/add-seats.tsx | 55 +++++++++------ .../upgrade-subscription.tsx | 36 +++++++--- .../modals/change-to-group-modal.tsx | 11 ++- .../SubscriptionGroupController.test.mjs | 67 +++++++++++++++++++ 5 files changed, 152 insertions(+), 33 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 4276ebd6e7..7091d2916d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -19,6 +19,7 @@ import { SubtotalLimitExceededError, HasPastDueInvoiceError, HasNoAdditionalLicenseWhenManuallyCollectedError, + PaymentActionRequiredError, } 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( { error }, 'error trying to create "add seats" subscription change' @@ -369,6 +378,13 @@ async function upgradeSubscription(req, res) { await SubscriptionGroupHandler.promises.upgradeGroupPlan(userId) return res.sendStatus(200) } 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') return res.sendStatus(500) } diff --git a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx index e795ed6a2d..f598ee9ddc 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx @@ -30,6 +30,7 @@ import { } from '../../../../../../types/subscription/subscription-change-preview' import { MergeAndOverride, Nullable } from '../../../../../../types/utils' 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_PO_NUMBER_CHARACTERS = 50 @@ -60,13 +61,12 @@ function AddSeats() { reset: resetCostSummaryData, error: errorCostSummary, } = useAsync() - const { - isLoading: isAddingSeats, - isError: isErrorAddingSeats, - isSuccess: isSuccessAddingSeats, - runAsync: runAsyncAddSeats, - data: addedSeatsData, - } = useAsync<{ adding: number }>() + const [isAddingSeats, setIsAddingSeats] = useState(false) + const [isErrorAddingSeats, setIsErrorAddingSeats] = useState(false) + const [isSuccessAddingSeats, setIsSuccessAddingSeats] = useState(false) + const [addedSeatsData, setAddedSeatsData] = useState<{ + adding: number + } | null>(null) const { isLoading: isSendingMailToSales, isError: isErrorSendingMailToSales, @@ -217,21 +217,34 @@ function AddSeats() { sendMB('flex-add-users-form', { action: 'click-add-user-button', }) - const post = postJSON('/user/subscription/group/add-users/create', { - signal: addSeatsSignal, - body: { - adding: Number(rawSeats), - poNumber, - }, - }) - runAsyncAddSeats(post) - .then(() => { + setIsAddingSeats(true) + try { + const response = await postJSON<{ + adding: number + }>('/user/subscription/group/add-users/create', { + signal: addSeatsSignal, + body: { + adding: Number(rawSeats), + 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') - }) - .catch(() => { - debugConsole.error() - sendMB('flex-add-users-error') - }) + setIsSuccessAddingSeats(true) + setAddedSeatsData({ adding: Number(rawSeats) }) + return + } + debugConsole.error(error) + sendMB('flex-add-users-error') + setIsErrorAddingSeats(true) + } finally { + setIsAddingSeats(false) + } } } diff --git a/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription.tsx b/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription.tsx index 467ce88dea..b4ce898ecb 100644 --- a/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription.tsx +++ b/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription.tsx @@ -1,35 +1,49 @@ 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 { Card, Row, Col } from 'react-bootstrap' import IconButton from '@/features/ui/components/bootstrap-5/icon-button' import Button from '@/features/ui/components/bootstrap-5/button' import UpgradeSubscriptionPlanDetails from './upgrade-subscription-plan-details' -import useAsync from '@/shared/hooks/use-async' import RequestStatus from '../request-status' import UpgradeSummary, { SubscriptionChange, } from './upgrade-subscription-upgrade-summary' import { debugConsole } from '@/utils/debugging' import { sendMB } from '../../../../infrastructure/event-tracking' +import handleStripePaymentAction from '@/features/subscription/util/handle-stripe-payment-action' +import { useState } from 'react' function UpgradeSubscription() { const { t } = useTranslation() const groupName = getMeta('ol-groupName') const preview = getMeta('ol-subscriptionChangePreview') as SubscriptionChange - const { isError, runAsync, isSuccess, isLoading } = useAsync() - const onSubmit = () => { + const [isError, setIsError] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) + const onSubmit = async () => { sendMB('flex-upgrade-form', { action: 'click-upgrade-button', }) - runAsync(postJSON('/user/subscription/group/upgrade-subscription')) - .then(() => { + setIsLoading(true) + + 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') - }) - .catch(() => { - debugConsole.error() - sendMB('flex-upgrade-error') - }) + setIsSuccess(true) + return + } + debugConsole.error(error) + sendMB('flex-upgrade-error') + setIsError(true) + } finally { + setIsLoading(false) + } } if (isSuccess) { diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx index 8cc992f1f9..f27d024d0c 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx @@ -2,7 +2,10 @@ import { useEffect, useState } from 'react' import { useTranslation, Trans } from 'react-i18next' import { PaidSubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription' 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 { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context' 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 OLButton from '@/features/ui/components/ol/ol-button' import OLNotification from '@/features/ui/components/ol/ol-notification' +import handleStripePaymentAction from '@/features/subscription/util/handle-stripe-payment-action' const educationalPercentDiscount = 40 @@ -136,6 +140,11 @@ export function ChangeToGroupModal() { }) location.reload() } catch (e) { + const { handled } = await handleStripePaymentAction(e as FetchError) + if (handled) { + location.reload() + return + } setError(true) setInflight(false) } diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs index ddfa8b0790..4783a44bcb 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs @@ -137,6 +137,12 @@ describe('SubscriptionGroupController', function () { SubtotalLimitExceededError: class extends Error {}, HasPastDueInvoiceError: class extends Error {}, HasNoAdditionalLicenseWhenManuallyCollectedError: class extends Error {}, + PaymentActionRequiredError: class extends Error { + constructor(info) { + super('Payment action required') + this.info = info + } + }, } vi.doMock( @@ -718,6 +724,38 @@ describe('SubscriptionGroupController', function () { 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 () { @@ -896,5 +934,34 @@ describe('SubscriptionGroupController', function () { 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) + }) + }) }) })