فهرست منبع

[web] handle 3DS challenges for Stripe (#25918)

* handle 3DS challenges on the subscription dashboard
* add `/user/subscription/sync` endpoint
* upgrade `stripe-js` & rm `react-stripe-js`
* group related unit tests together
* add modules `SubscriptionController` unit tests and convert to async/await
* add `StripeClient` unit tests for 3DS failure

GitOrigin-RevId: 9da4758703f6ef4ec08248b328abddbbdd8e44ad
Kristina 1 سال پیش
والد
کامیت
9ba772b18f

+ 10 - 25
package-lock.json

@@ -11575,29 +11575,6 @@
         "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0"
         "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0"
       }
       }
     },
     },
-    "node_modules/@stripe/react-stripe-js": {
-      "version": "3.5.0",
-      "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.5.0.tgz",
-      "integrity": "sha512-oo5J2SNbuAUjE9XmQv/SOD7vgZCa1Y9OcZyRAfvQPkyrDrru35sg5c64ANdHEmOWUibism3+25rKdARSw3HOfA==",
-      "license": "MIT",
-      "dependencies": {
-        "prop-types": "^15.7.2"
-      },
-      "peerDependencies": {
-        "@stripe/stripe-js": ">=1.44.1 <7.0.0",
-        "react": ">=16.8.0 <20.0.0",
-        "react-dom": ">=16.8.0 <20.0.0"
-      }
-    },
-    "node_modules/@stripe/stripe-js": {
-      "version": "5.10.0",
-      "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.10.0.tgz",
-      "integrity": "sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=12.16"
-      }
-    },
     "node_modules/@swc/helpers": {
     "node_modules/@swc/helpers": {
       "version": "0.5.17",
       "version": "0.5.17",
       "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
       "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
@@ -44687,8 +44664,7 @@
         "@overleaf/settings": "*",
         "@overleaf/settings": "*",
         "@phosphor-icons/react": "^2.1.7",
         "@phosphor-icons/react": "^2.1.7",
         "@slack/webhook": "^7.0.2",
         "@slack/webhook": "^7.0.2",
-        "@stripe/react-stripe-js": "^3.1.1",
-        "@stripe/stripe-js": "^5.6.0",
+        "@stripe/stripe-js": "^7.3.0",
         "@xmldom/xmldom": "^0.7.13",
         "@xmldom/xmldom": "^0.7.13",
         "accepts": "^1.3.7",
         "accepts": "^1.3.7",
         "ajv": "^8.12.0",
         "ajv": "^8.12.0",
@@ -45175,6 +45151,15 @@
         "lodash": "^4.17.15"
         "lodash": "^4.17.15"
       }
       }
     },
     },
+    "services/web/node_modules/@stripe/stripe-js": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.3.0.tgz",
+      "integrity": "sha512-xnCyFIEI5SQnQrKkCxVj7nS5fWTZap+zuIGzmmxLMdlmgahFJaihK4zogqE8YyKKTLtrp/EldkEijSgtXsRVDg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.16"
+      }
+    },
     "services/web/node_modules/@transloadit/prettier-bytes": {
     "services/web/node_modules/@transloadit/prettier-bytes": {
       "version": "0.0.9",
       "version": "0.0.9",
       "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz",
       "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz",

+ 7 - 0
services/web/app/src/Features/Subscription/Errors.js

@@ -26,10 +26,17 @@ class SubtotalLimitExceededError extends OError {}
 
 
 class HasPastDueInvoiceError extends OError {}
 class HasPastDueInvoiceError extends OError {}
 
 
+class PaymentActionRequiredError extends OError {
+  constructor(info) {
+    super('Payment action required', info)
+  }
+}
+
 module.exports = {
 module.exports = {
   RecurlyTransactionError,
   RecurlyTransactionError,
   DuplicateAddOnError,
   DuplicateAddOnError,
   AddOnNotPresentError,
   AddOnNotPresentError,
+  PaymentActionRequiredError,
   MissingBillingInfoError,
   MissingBillingInfoError,
   ManuallyCollectedError,
   ManuallyCollectedError,
   PendingChangeError,
   PendingChangeError,

+ 10 - 1
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -15,7 +15,11 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager')
 const RecurlyEventHandler = require('./RecurlyEventHandler')
 const RecurlyEventHandler = require('./RecurlyEventHandler')
 const { expressify } = require('@overleaf/promise-utils')
 const { expressify } = require('@overleaf/promise-utils')
 const OError = require('@overleaf/o-error')
 const OError = require('@overleaf/o-error')
-const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
+const {
+  DuplicateAddOnError,
+  AddOnNotPresentError,
+  PaymentActionRequiredError,
+} = require('./Errors')
 const SplitTestHandler = require('../SplitTests/SplitTestHandler')
 const SplitTestHandler = require('../SplitTests/SplitTestHandler')
 const AuthorizationManager = require('../Authorization/AuthorizationManager')
 const AuthorizationManager = require('../Authorization/AuthorizationManager')
 const Modules = require('../../infrastructure/Modules')
 const Modules = require('../../infrastructure/Modules')
@@ -425,6 +429,11 @@ async function purchaseAddon(req, res, next) {
         'Your subscription already includes this add-on',
         'Your subscription already includes this add-on',
         { addon: addOnCode }
         { addon: addOnCode }
       )
       )
+    } else if (err instanceof PaymentActionRequiredError) {
+      return res.status(402).json({
+        message: 'Payment action required',
+        clientSecret: err.info.clientSecret,
+      })
     } else {
     } else {
       if (err instanceof Error) {
       if (err instanceof Error) {
         OError.tag(err, 'something went wrong purchasing add-ons', {
         OError.tag(err, 'something went wrong purchasing add-ons', {

+ 1 - 0
services/web/app/views/subscriptions/dashboard-react.pug

@@ -27,6 +27,7 @@ block append meta
   meta(name="ol-user" data-type="json" content=user)
   meta(name="ol-user" data-type="json" content=user)
   if (personalSubscription && personalSubscription.payment)
   if (personalSubscription && personalSubscription.payment)
     meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
     meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
+    meta(name="ol-stripeApiKey" content=settings.apis.stripe.publishableKey)
     meta(name="ol-recommendedCurrency" content=personalSubscription.payment.currency)
     meta(name="ol-recommendedCurrency" content=personalSubscription.payment.currency)
     meta(name="ol-groupPlans" data-type="json" content=groupPlans)
     meta(name="ol-groupPlans" data-type="json" content=groupPlans)
 
 

+ 12 - 3
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx

@@ -1,7 +1,10 @@
 import { useState } from 'react'
 import { useState } from 'react'
 import { useTranslation, Trans } from 'react-i18next'
 import { useTranslation, Trans } from 'react-i18next'
 import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
 import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
-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 { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
 import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
@@ -14,6 +17,7 @@ import OLModal, {
 } from '@/features/ui/components/ol/ol-modal'
 } from '@/features/ui/components/ol/ol-modal'
 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'
 
 
 export function ConfirmChangePlanModal() {
 export function ConfirmChangePlanModal() {
   const modalId: SubscriptionDashModalIds = 'change-to-plan'
   const modalId: SubscriptionDashModalIds = 'change-to-plan'
@@ -37,8 +41,13 @@ export function ConfirmChangePlanModal() {
       })
       })
       location.reload()
       location.reload()
     } catch (e) {
     } catch (e) {
-      setError(true)
-      setInflight(false)
+      const { handled } = await handleStripePaymentAction(e as FetchError)
+      if (handled) {
+        location.reload()
+      } else {
+        setError(true)
+        setInflight(false)
+      }
     }
     }
   }
   }
 
 

+ 21 - 11
services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx

@@ -11,7 +11,7 @@ import { formatCurrency } from '@/shared/utils/currency'
 import useAsync from '@/shared/hooks/use-async'
 import useAsync from '@/shared/hooks/use-async'
 import { useLocation } from '@/shared/hooks/use-location'
 import { useLocation } from '@/shared/hooks/use-location'
 import { debugConsole } from '@/utils/debugging'
 import { debugConsole } from '@/utils/debugging'
-import { postJSON } from '@/infrastructure/fetch-json'
+import { FetchError, postJSON } from '@/infrastructure/fetch-json'
 import Notification from '@/shared/components/notification'
 import Notification from '@/shared/components/notification'
 import OLCard from '@/features/ui/components/ol/ol-card'
 import OLCard from '@/features/ui/components/ol/ol-card'
 import OLRow from '@/features/ui/components/ol/ol-row'
 import OLRow from '@/features/ui/components/ol/ol-row'
@@ -21,6 +21,7 @@ import { subscriptionUpdateUrl } from '@/features/subscription/data/subscription
 import * as eventTracking from '@/infrastructure/event-tracking'
 import * as eventTracking from '@/infrastructure/event-tracking'
 import sparkleText from '@/shared/svgs/ai-sparkle-text.svg'
 import sparkleText from '@/shared/svgs/ai-sparkle-text.svg'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
+import handleStripePaymentAction from '../../util/handle-stripe-payment-action'
 
 
 function PreviewSubscriptionChange() {
 function PreviewSubscriptionChange() {
   const preview = getMeta(
   const preview = getMeta(
@@ -279,16 +280,25 @@ function PreviewSubscriptionChange() {
 }
 }
 
 
 async function payNow(preview: SubscriptionChangePreview) {
 async function payNow(preview: SubscriptionChangePreview) {
-  if (preview.change.type === 'add-on-purchase') {
-    await postJSON(`/user/subscription/addon/${preview.change.addOn.code}/add`)
-  } else if (preview.change.type === 'premium-subscription') {
-    await postJSON(subscriptionUpdateUrl, {
-      body: { plan_code: preview.change.plan.code },
-    })
-  } else {
-    throw new Error(
-      `Unknown subscription change preview type: ${preview.change}`
-    )
+  try {
+    if (preview.change.type === 'add-on-purchase') {
+      await postJSON(
+        `/user/subscription/addon/${preview.change.addOn.code}/add`
+      )
+    } else if (preview.change.type === 'premium-subscription') {
+      await postJSON(subscriptionUpdateUrl, {
+        body: { plan_code: preview.change.plan.code },
+      })
+    } else {
+      throw new Error(
+        `Unknown subscription change preview type: ${preview.change}`
+      )
+    }
+  } catch (e) {
+    const { handled } = await handleStripePaymentAction(e as FetchError)
+    if (!handled) {
+      throw e
+    }
   }
   }
 }
 }
 
 

+ 28 - 0
services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts

@@ -0,0 +1,28 @@
+import { FetchError, postJSON } from '@/infrastructure/fetch-json'
+import getMeta from '../../../utils/meta'
+import { loadStripe } from '@stripe/stripe-js/pure'
+
+export default async function handleStripePaymentAction(
+  error: FetchError
+): Promise<{ handled: boolean }> {
+  const clientSecret = error?.data?.clientSecret
+
+  if (clientSecret) {
+    const stripePublicKey = getMeta('ol-stripeApiKey')
+    const stripe = await loadStripe(stripePublicKey)
+    if (stripe) {
+      const manualConfirmationFlow =
+        await stripe.confirmCardPayment(clientSecret)
+      if (!manualConfirmationFlow.error) {
+        try {
+          await postJSON(`/user/subscription/sync`)
+        } catch (error) {
+          // if the sync fails, there may be stale data until the webhook is
+          // processed but we can't do any special handling for that in here
+        }
+        return { handled: true }
+      }
+    }
+  }
+  return { handled: false }
+}

+ 1 - 0
services/web/frontend/js/utils/meta.ts

@@ -224,6 +224,7 @@ export interface Meta {
   'ol-splitTestVariants': { [name: string]: string }
   'ol-splitTestVariants': { [name: string]: string }
   'ol-ssoDisabled': boolean
   'ol-ssoDisabled': boolean
   'ol-ssoErrorMessage': string
   'ol-ssoErrorMessage': string
+  'ol-stripeApiKey': string
   'ol-subscription': any // TODO: mixed types, split into two fields
   'ol-subscription': any // TODO: mixed types, split into two fields
   'ol-subscriptionChangePreview': SubscriptionChangePreview
   'ol-subscriptionChangePreview': SubscriptionChangePreview
   'ol-subscriptionId': string
   'ol-subscriptionId': string

+ 1 - 2
services/web/package.json

@@ -89,8 +89,7 @@
     "@overleaf/settings": "*",
     "@overleaf/settings": "*",
     "@phosphor-icons/react": "^2.1.7",
     "@phosphor-icons/react": "^2.1.7",
     "@slack/webhook": "^7.0.2",
     "@slack/webhook": "^7.0.2",
-    "@stripe/stripe-js": "^5.6.0",
-    "@stripe/react-stripe-js": "^3.1.1",
+    "@stripe/stripe-js": "^7.3.0",
     "@xmldom/xmldom": "^0.7.13",
     "@xmldom/xmldom": "^0.7.13",
     "accepts": "^1.3.7",
     "accepts": "^1.3.7",
     "ajv": "^8.12.0",
     "ajv": "^8.12.0",