Преглед на файлове

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
Liangjun Song преди 1 година
родител
ревизия
1daa49d9d2

+ 16 - 0
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)
   }

+ 34 - 21
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<CostSummaryData, FetchError>()
-  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(() => {
-          sendMB('flex-add-users-success')
-        })
-        .catch(() => {
-          debugConsole.error()
-          sendMB('flex-add-users-error')
+      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')
+          setIsSuccessAddingSeats(true)
+          setAddedSeatsData({ adding: Number(rawSeats) })
+          return
+        }
+        debugConsole.error(error)
+        sendMB('flex-add-users-error')
+        setIsErrorAddingSeats(true)
+      } finally {
+        setIsAddingSeats(false)
+      }
     }
   }
 

+ 25 - 11
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) {

+ 10 - 1
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)
     }

+ 67 - 0
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)
+      })
+    })
   })
 })