From cd55df330df8d9415c7ff183aa92538c14c32e06 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 28 Aug 2024 01:38:34 +0800 Subject: [PATCH] feat: set default payment method (#28921) * feat: set default payment method * fix invalidation issue * cheeky copy fix * more invalidation fixes * fix invalidation one more time * update copy --- .../Payment/AddNewPaymentMethodModal.tsx | 3 + .../Billing/Payment/AddPaymentMethodForm.tsx | 73 ++++++++++++++++++- .../PaymentMethods/PaymentMethods.tsx | 7 +- .../Subscription/PlanUpdateSidePanel.tsx | 31 +++++--- .../NewProject/EmptyPaymentMethodWarning.tsx | 53 -------------- .../Organization/NewProject/index.ts | 1 - .../components/ui/ComputeBadgeWrapper.tsx | 2 +- 7 files changed, 96 insertions(+), 74 deletions(-) delete mode 100644 apps/studio/components/interfaces/Organization/NewProject/EmptyPaymentMethodWarning.tsx diff --git a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx index b8c8ea73352..ab55a174629 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx @@ -17,6 +17,7 @@ interface AddNewPaymentMethodModalProps { returnUrl: string onCancel: () => void onConfirm: () => void + showSetDefaultCheckbox?: boolean } const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -26,6 +27,7 @@ const AddNewPaymentMethodModal = ({ returnUrl, onCancel, onConfirm, + showSetDefaultCheckbox, }: AddNewPaymentMethodModalProps) => { const { resolvedTheme } = useTheme() const [intent, setIntent] = useState() @@ -137,6 +139,7 @@ const AddNewPaymentMethodModal = ({ returnUrl={returnUrl} onCancel={onLocalCancel} onConfirm={onLocalConfirm} + showSetDefaultCheckbox={showSetDefaultCheckbox} /> diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index 3d1fa69eca8..e638cd4a3fa 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -1,23 +1,38 @@ import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import { useQueryClient } from '@tanstack/react-query' +import { organizationKeys } from 'data/organizations/keys' +import { useOrganizationPaymentMethodMarkAsDefaultMutation } from 'data/organizations/organization-payment-method-default-mutation' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useState } from 'react' import toast from 'react-hot-toast' -import { Button, Modal } from 'ui' +import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui' interface AddPaymentMethodFormProps { returnUrl: string onCancel: () => void onConfirm: () => void + showSetDefaultCheckbox?: boolean } // Stripe docs recommend to use the new SetupIntent flow over // manually creating and attaching payment methods via the API // Small UX annoyance here, that the page will be refreshed -const AddPaymentMethodForm = ({ returnUrl, onCancel, onConfirm }: AddPaymentMethodFormProps) => { +const AddPaymentMethodForm = ({ + returnUrl, + onCancel, + onConfirm, + showSetDefaultCheckbox = false, +}: AddPaymentMethodFormProps) => { const stripe = useStripe() const elements = useElements() + const selectedOrganization = useSelectedOrganization() const [isSaving, setIsSaving] = useState(false) + const [isDefault, setIsDefault] = useState(showSetDefaultCheckbox) + + const queryClient = useQueryClient() + const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation() const handleSubmit = async (event: any) => { event.preventDefault() @@ -34,7 +49,7 @@ const AddPaymentMethodForm = ({ returnUrl, onCancel, onConfirm }: AddPaymentMeth document.body.classList.add('!pointer-events-auto') } - const { error } = await stripe.confirmSetup({ + const { error, setupIntent } = await stripe.confirmSetup({ elements, redirect: 'if_required', confirmParams: { return_url: returnUrl }, @@ -44,6 +59,42 @@ const AddPaymentMethodForm = ({ returnUrl, onCancel, onConfirm }: AddPaymentMeth setIsSaving(false) toast.error(error?.message ?? ' Failed to save card details') } else { + if (isDefault && selectedOrganization && typeof setupIntent?.payment_method === 'string') { + try { + await markAsDefault({ + slug: selectedOrganization.slug, + paymentMethodId: setupIntent.payment_method, + }) + + await queryClient.invalidateQueries( + organizationKeys.paymentMethods(selectedOrganization.slug) + ) + + queryClient.setQueriesData( + organizationKeys.paymentMethods(selectedOrganization.slug), + (prev: any) => { + if (!prev) return prev + return { + ...prev, + defaultPaymentMethodId: setupIntent.payment_method, + data: prev.data.map((pm: any) => ({ + ...pm, + is_default: pm.id === setupIntent.payment_method, + })), + } + } + ) + } catch (error) { + toast.error('Failed to set payment method as default') + } + } else { + if (selectedOrganization) { + await queryClient.invalidateQueries( + organizationKeys.paymentMethods(selectedOrganization.slug) + ) + } + } + setIsSaving(false) onConfirm() } @@ -59,6 +110,22 @@ const AddPaymentMethodForm = ({ returnUrl, onCancel, onConfirm }: AddPaymentMeth className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`} > + {showSetDefaultCheckbox && ( +
+ { + if (typeof checked === 'boolean') { + setIsDefault(checked) + } + }} + /> + + Save as default payment method + +
+ )} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx index bbc4d550150..fe5d2fedc6a 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx @@ -73,8 +73,7 @@ const PaymentMethods = () => {

Payment Methods

- After adding a payment method, make sure to mark it as active to use it for billing. - You can remove unused cards. + Payments for your subscription are made using the default card.

@@ -232,11 +231,11 @@ const PaymentMethods = () => { visible={showAddPaymentMethodModal} returnUrl={`${getURL()}/org/${slug}/billing`} onCancel={() => setShowAddPaymentMethodModal(false)} - onConfirm={async () => { + onConfirm={() => { setShowAddPaymentMethodModal(false) toast.success('Successfully added new payment method') - await queryClient.invalidateQueries(organizationKeys.paymentMethods(slug)) }} + showSetDefaultCheckbox={true} /> { const router = useRouter() const selectedOrganization = useSelectedOrganization() const slug = selectedOrganization?.slug + const queryClient = useQueryClient() + const originalPlanRef = useRef() const [showExitSurvey, setShowExitSurvey] = useState(false) @@ -149,6 +142,20 @@ const PlanUpdateSidePanel = () => { return toast.error('Please select a payment method') } + if (selectedPaymentMethod) { + queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => { + if (!prev) return prev + return { + ...prev, + defaultPaymentMethodId: selectedPaymentMethod, + data: prev.data.map((pm: any) => ({ + ...pm, + is_default: pm.id === selectedPaymentMethod, + })), + } + }) + } + // 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 diff --git a/apps/studio/components/interfaces/Organization/NewProject/EmptyPaymentMethodWarning.tsx b/apps/studio/components/interfaces/Organization/NewProject/EmptyPaymentMethodWarning.tsx deleted file mode 100644 index 43ffa6d69a6..00000000000 --- a/apps/studio/components/interfaces/Organization/NewProject/EmptyPaymentMethodWarning.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useState } from 'react' - -import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal' -import InformationBox from 'components/ui/InformationBox' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { getURL } from 'lib/helpers' -import { Button, IconAlertCircle } from 'ui' - -const EmptyPaymentMethodWarning = ({ - onPaymentMethodAdded, -}: { - onPaymentMethodAdded: () => void -}) => { - const selectedOrganization = useSelectedOrganization() - const slug = selectedOrganization?.slug - - const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false) - - const onLocalPaymentMethodAdded = () => { - setShowAddPaymentMethodModal(false) - - return onPaymentMethodAdded() - } - - return ( -
- } - defaultVisibility={true} - hideCollapse - title="Your organization has no payment methods" - description={ -
-

- You need to add a payment method for your organization before creating a paid project. -

- -
- } - /> - setShowAddPaymentMethodModal(false)} - onConfirm={() => onLocalPaymentMethodAdded()} - /> -
- ) -} - -export default EmptyPaymentMethodWarning diff --git a/apps/studio/components/interfaces/Organization/NewProject/index.ts b/apps/studio/components/interfaces/Organization/NewProject/index.ts index 0b4cb8f09e7..2b5935378f5 100644 --- a/apps/studio/components/interfaces/Organization/NewProject/index.ts +++ b/apps/studio/components/interfaces/Organization/NewProject/index.ts @@ -1,3 +1,2 @@ export { default as NotOrganizationOwnerWarning } from './NotOrganizationOwnerWarning' export { default as FreeProjectLimitWarning } from './FreeProjectLimitWarning' -export { default as EmptyPaymentMethodWarning } from './EmptyPaymentMethodWarning' diff --git a/apps/studio/components/ui/ComputeBadgeWrapper.tsx b/apps/studio/components/ui/ComputeBadgeWrapper.tsx index 9c5e29b21a8..dde8f6096a2 100644 --- a/apps/studio/components/ui/ComputeBadgeWrapper.tsx +++ b/apps/studio/components/ui/ComputeBadgeWrapper.tsx @@ -139,7 +139,7 @@ export const ComputeBadgeWrapper = ({ project }: ComputeBadgeWrapperProps) => {

{isEligibleForFreeUpgrade - ? 'Free upgrade to Micro available.' + ? 'Free upgrade to Micro available' : 'Unlock more compute'}