From 2f8749b7f05391c03fd11ad56808a2aa2077a30f Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 30 May 2025 10:29:28 +0200 Subject: [PATCH] [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 --- package-lock.json | 35 ++++++------------- .../app/src/Features/Subscription/Errors.js | 7 ++++ .../Subscription/SubscriptionController.js | 11 +++++- .../views/subscriptions/dashboard-react.pug | 1 + .../modals/confirm-change-plan-modal.tsx | 15 ++++++-- .../preview-subscription-change/root.tsx | 32 +++++++++++------ .../util/handle-stripe-payment-action.ts | 28 +++++++++++++++ services/web/frontend/js/utils/meta.ts | 1 + services/web/package.json | 3 +- 9 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts diff --git a/package-lock.json b/package-lock.json index 73b722b1f5..ce941a1670 100644 --- a/package-lock.json +++ b/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" } }, - "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": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -44687,8 +44664,7 @@ "@overleaf/settings": "*", "@phosphor-icons/react": "^2.1.7", "@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", "accepts": "^1.3.7", "ajv": "^8.12.0", @@ -45175,6 +45151,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": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz", diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js index cbcd0014f7..9ebb08c6db 100644 --- a/services/web/app/src/Features/Subscription/Errors.js +++ b/services/web/app/src/Features/Subscription/Errors.js @@ -26,10 +26,17 @@ class SubtotalLimitExceededError extends OError {} class HasPastDueInvoiceError extends OError {} +class PaymentActionRequiredError extends OError { + constructor(info) { + super('Payment action required', info) + } +} + module.exports = { RecurlyTransactionError, DuplicateAddOnError, AddOnNotPresentError, + PaymentActionRequiredError, MissingBillingInfoError, ManuallyCollectedError, PendingChangeError, diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 7aa345e7a8..a38b41f628 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -15,7 +15,11 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager') const RecurlyEventHandler = require('./RecurlyEventHandler') const { expressify } = require('@overleaf/promise-utils') const OError = require('@overleaf/o-error') -const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors') +const { + DuplicateAddOnError, + AddOnNotPresentError, + PaymentActionRequiredError, +} = require('./Errors') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const AuthorizationManager = require('../Authorization/AuthorizationManager') const Modules = require('../../infrastructure/Modules') @@ -425,6 +429,11 @@ async function purchaseAddon(req, res, next) { 'Your subscription already includes this add-on', { addon: addOnCode } ) + } else if (err instanceof PaymentActionRequiredError) { + return res.status(402).json({ + message: 'Payment action required', + clientSecret: err.info.clientSecret, + }) } else { if (err instanceof Error) { OError.tag(err, 'something went wrong purchasing add-ons', { diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index d6a1bff49c..8cc5ec1976 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -27,6 +27,7 @@ block append meta meta(name="ol-user" data-type="json" content=user) if (personalSubscription && personalSubscription.payment) 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-groupPlans" data-type="json" content=groupPlans) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx index 08cbf1743f..a964009dcc 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx +++ b/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 { useTranslation, Trans } from 'react-i18next' 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 { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context' import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url' @@ -14,6 +17,7 @@ import OLModal, { } from '@/features/ui/components/ol/ol-modal' 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' export function ConfirmChangePlanModal() { const modalId: SubscriptionDashModalIds = 'change-to-plan' @@ -37,8 +41,13 @@ export function ConfirmChangePlanModal() { }) location.reload() } catch (e) { - setError(true) - setInflight(false) + const { handled } = await handleStripePaymentAction(e as FetchError) + if (handled) { + location.reload() + } else { + setError(true) + setInflight(false) + } } } diff --git a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx index 367a5e35a9..112d15d7e3 100644 --- a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx +++ b/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 { useLocation } from '@/shared/hooks/use-location' 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 OLCard from '@/features/ui/components/ol/ol-card' 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 sparkleText from '@/shared/svgs/ai-sparkle-text.svg' import { useFeatureFlag } from '@/shared/context/split-test-context' +import handleStripePaymentAction from '../../util/handle-stripe-payment-action' function PreviewSubscriptionChange() { const preview = getMeta( @@ -279,16 +280,25 @@ function PreviewSubscriptionChange() { } 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 + } } } diff --git a/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts new file mode 100644 index 0000000000..fd29674893 --- /dev/null +++ b/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 } +} diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 9461635625..2a396c805b 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -224,6 +224,7 @@ export interface Meta { 'ol-splitTestVariants': { [name: string]: string } 'ol-ssoDisabled': boolean 'ol-ssoErrorMessage': string + 'ol-stripeApiKey': string 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionId': string diff --git a/services/web/package.json b/services/web/package.json index 609d24c0a3..cc286b9225 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -89,8 +89,7 @@ "@overleaf/settings": "*", "@phosphor-icons/react": "^2.1.7", "@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", "accepts": "^1.3.7", "ajv": "^8.12.0",