From 680af04a06c350a258d2069064d99b44fa9767fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Mon, 23 Jun 2025 19:03:27 +0800 Subject: [PATCH] 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 --- .../Billing/Payment/PaymentConfirmation.tsx | 4 +- .../BillingSettings/CreditTopUp.tsx | 1 - .../Subscription/ExitSurveyModal.tsx | 41 +------------------ .../SubscriptionPlanUpdateDialog.tsx | 29 ++++--------- .../Organization/NewOrg/NewOrgForm.tsx | 1 - ...org-subscription-confirm-pending-change.ts | 2 + .../org-subscription-update-mutation.ts | 14 +++++++ 7 files changed, 26 insertions(+), 66 deletions(-) diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx index 5b568e36437..dc32d28afbd 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx @@ -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) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index a2e71206e62..b33f047760d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -325,7 +325,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { paymentIntentConfirmed(paymentIntentConfirmation) } onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} - paymentMethodId={form.getValues().paymentMethod} /> )} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx index 99307541b11..2a10e1bf6ae 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx @@ -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(null) const [message, setMessage] = useState('') - const [captchaToken, setCaptchaToken] = useState(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 ( <> -
- { - 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') - }} - /> -
- { const { resolvedTheme } = useTheme() - const queryClient = useQueryClient() const selectedOrganization = useSelectedOrganization() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [paymentIntentSecret, setPaymentIntentSecret] = useState(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 = ({ { + // Do not allow closing mid-change + if (isUpdating || paymentConfirmationLoading || isConfirming) { + return + } if (!open) onClose() }} > @@ -561,16 +547,16 @@ export const SubscriptionPlanUpdateDialog = ({
- {!billingViaPartner && !subscriptionPreviewIsLoading && changeType === 'upgrade' && ( + {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
{}} 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!} /> )} diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index c6598e97661..40233432b40 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -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) diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts index 44df6615185..fc660901b76 100644 --- a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts @@ -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) { diff --git a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts index d899039b23c..d2806ab227b 100644 --- a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -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)