[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
This commit is contained in:
Kristina
2025-05-30 10:29:28 +02:00
committed by Copybot
parent 9b0ddded06
commit 2f8749b7f0
9 changed files with 91 additions and 42 deletions

35
package-lock.json generated
View File

@@ -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",

View File

@@ -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,

View File

@@ -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', {

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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 }
}

View File

@@ -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

View File

@@ -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",