fix: 3ds confirmation / downgrades (#36600)

- Remove captcha for downgrades to Free, it is not needed
- Properly handle 3DS if customer has multiple payment methods
- Move payment method invalidation to react-query
This commit is contained in:
Kevin Grüneberg authored and GitHub committed 2025-06-23 19:03:27 +08:00
1 parent c8dbc57051
commit 680af04a06
7 files changed
+26 -66

No files matched your search

@@ -7,11 +7,9 @@ export const PaymentConfirmation = ({
paymentIntentSecret,
onPaymentIntentConfirm,
onLoadingChange,
paymentMethodId,
onError,
}: {
paymentIntentSecret: string
paymentMethodId: string
onPaymentIntentConfirm: (response: PaymentIntentResult) => void
onLoadingChange: (loading: boolean) => void
onError?: (error: Error) => void
@@ -22,7 +20,7 @@ export const PaymentConfirmation = ({
if (stripe && paymentIntentSecret) {
onLoadingChange(true)
stripe!
.confirmCardPayment(paymentIntentSecret, { payment_method: paymentMethodId })
.confirmCardPayment(paymentIntentSecret)
.then((res) => {
onPaymentIntentConfirm(res)
onLoadingChange(false)
@@ -325,7 +325,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
paymentIntentConfirmed(paymentIntentConfirmation)
}
onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
paymentMethodId={form.getValues().paymentMethod}
/>
</Elements>
)}
@@ -1,12 +1,10 @@
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { includes, without } from 'lodash'
import { useReducer, useRef, useState } from 'react'
import { useReducer, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send'
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
import type { OrgSubscription } from 'data/subscriptions/types'
import { useFlag } from 'hooks/ui/useFlag'
import { Alert, Button, Input, Modal } from 'ui'
import type { ProjectInfo } from '../../../../../data/projects/projects-query'
@@ -22,17 +20,14 @@ export interface ExitSurveyModalProps {
// [Joshen] For context - Exit survey is only when going to Free Plan from a paid plan
const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) => {
const { slug } = useParams()
const captchaRef = useRef<HCaptcha>(null)
const [message, setMessage] = useState('')
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [selectedReasons, dispatchSelectedReasons] = useReducer(reducer, [])
const subscriptionUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation(
{
onError: (error) => {
resetCaptcha()
toast.error(`Failed to downgrade project: ${error.message}`)
},
}
@@ -55,23 +50,12 @@ const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) =
}
}
const resetCaptcha = () => {
setCaptchaToken(null)
captchaRef.current?.resetCaptcha()
}
const onSubmit = async () => {
if (selectedReasons.length === 0) {
return toast.error('Please select at least one reason for canceling your subscription')
}
let token = captchaToken
if (!token) {
const captchaResponse = await captchaRef.current?.execute({ async: true })
token = captchaResponse?.response ?? null
await downgradeOrganization()
}
await downgradeOrganization()
}
const downgradeOrganization = async () => {
@@ -83,7 +67,6 @@ const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) =
{ slug, tier: 'tier_free' },
{
onSuccess: async () => {
resetCaptcha()
try {
await sendExitSurvey({
orgSlug: slug,
@@ -110,26 +93,6 @@ const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) =
return (
<>
<div className="self-center">
<HCaptcha
ref={captchaRef}
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
size="invisible"
onVerify={(token) => {
setCaptchaToken(token)
if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
}}
onExpire={() => setCaptchaToken(null)}
onOpen={() => {
// [Joshen] This is to ensure that hCaptcha popup remains clickable
if (document !== undefined) document.body.classList.add('!pointer-events-auto')
}}
onClose={() => {
if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
}}
/>
</div>
<Modal
hideFooter
size="xlarge"
@@ -85,7 +85,6 @@ export const SubscriptionPlanUpdateDialog = ({
projects,
}: Props) => {
const { resolvedTheme } = useTheme()
const queryClient = useQueryClient()
const selectedOrganization = useSelectedOrganization()
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null)
@@ -182,23 +181,6 @@ export const SubscriptionPlanUpdateDialog = ({
return
}
if (paymentMethod) {
queryClient.setQueriesData(
organizationKeys.paymentMethods(selectedOrganization.slug),
(prev: any) => {
if (!prev) return prev
return {
...prev,
defaultPaymentMethodId: paymentMethod?.id,
data: prev.data.map((pm: any) => ({
...pm,
is_default: pm.id === paymentMethod?.id,
})),
}
}
)
}
// If the user is downgrading from team, should have spend cap disabled by default
const tier =
subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO
@@ -253,6 +235,10 @@ export const SubscriptionPlanUpdateDialog = ({
<Dialog
open={selectedTier !== undefined && selectedTier !== 'tier_free'}
onOpenChange={(open) => {
// Do not allow closing mid-change
if (isUpdating || paymentConfirmationLoading || isConfirming) {
return
}
if (!open) onClose()
}}
>
@@ -561,16 +547,16 @@ export const SubscriptionPlanUpdateDialog = ({
</div>
<div className="pt-4">
{!billingViaPartner && !subscriptionPreviewIsLoading && changeType === 'upgrade' && (
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
<div className="space-y-2 mb-4">
<BillingCustomerDataExistingOrgDialog />
<PaymentMethodSelection
ref={paymentMethodSelection}
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={setSelectedPaymentMethod}
onSelectPaymentMethod={() => {}}
createPaymentMethodInline={
subscriptionPreview?.pending_subscription_flow === true
subscriptionPreview.pending_subscription_flow === true
}
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
/>
@@ -700,7 +686,6 @@ export const SubscriptionPlanUpdateDialog = ({
paymentIntentConfirmed(paymentIntentConfirmation)
}
onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
paymentMethodId={selectedPaymentMethod!}
/>
</Elements>
)}
@@ -645,7 +645,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
paymentIntentConfirmed(paymentIntentConfirmation)
}
onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
paymentMethodId={paymentMethod.id}
onError={(err) => {
toast.error(err.message, { duration: 10_000 })
setNewOrgLoading(false)
@@ -74,7 +74,9 @@ export const useConfirmPendingSubscriptionChangeMutation = ({
queryClient.invalidateQueries(invoicesKeys.orgUpcomingPreview(slug)),
queryClient.invalidateQueries(organizationKeys.detail(slug)),
queryClient.invalidateQueries(organizationKeys.list()),
queryClient.invalidateQueries(organizationKeys.paymentMethods(slug)),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -68,6 +68,20 @@ export const useOrgSubscriptionUpdateMutation = ({
queryClient.invalidateQueries(organizationKeys.detail(slug)),
queryClient.invalidateQueries(organizationKeys.list()),
])
if (variables.paymentMethod) {
queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => {
if (!prev) return prev
return {
...prev,
defaultPaymentMethodId: variables.paymentMethod,
data: prev.data.map((pm: any) => ({
...pm,
is_default: pm.id === variables.paymentMethod,
})),
}
})
}
}
await onSuccess?.(data, variables, context)