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
This commit is contained in:
Alaister Young authored and GitHub committed 2024-08-27 19:38:34 +02:00
1 parent 51f45a1b33
commit cd55df330d
7 files changed
+96 -74

No files matched your search

@@ -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<any>()
@@ -137,6 +139,7 @@ const AddNewPaymentMethodModal = ({
returnUrl={returnUrl}
onCancel={onLocalCancel}
onConfirm={onLocalConfirm}
showSetDefaultCheckbox={showSetDefaultCheckbox}
/>
</Elements>
</Modal>
@@ -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'}`}
>
<PaymentElement className="[.p-LinkAutofillPrompt]:pt-0" />
{showSetDefaultCheckbox && (
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="save-as-default"
checked={isDefault}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsDefault(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
Save as default payment method
</Label_Shadcn_>
</div>
)}
</Modal.Content>
<Modal.Separator />
<Modal.Content className="flex items-center space-x-2">
@@ -73,8 +73,7 @@ const PaymentMethods = () => {
<div className="sticky space-y-2 top-12">
<p className="text-foreground text-base m-0">Payment Methods</p>
<p className="text-sm text-foreground-light mb-2 pr-4 m-0">
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.
</p>
</div>
</ScaffoldSectionDetail>
@@ -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}
/>
<ChangePaymentMethodModal
@@ -1,4 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { isArray } from 'lodash'
import { ChevronRight, ExternalLink } from 'lucide-react'
import Link from 'next/link'
@@ -13,6 +14,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import InformationBox from 'components/ui/InformationBox'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
import { organizationKeys } from 'data/organizations/keys'
import { useOrganizationBillingSubscriptionPreview } from 'data/organizations/organization-billing-subscription-preview'
import { useProjectsQuery } from 'data/projects/projects-query'
import { useOrgPlansQuery } from 'data/subscriptions/org-plans-query'
@@ -25,30 +27,21 @@ import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import { formatCurrency } from 'lib/helpers'
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
IconCheck,
IconInfo,
Modal,
SidePanel,
cn,
} from 'ui'
import { Button, IconCheck, IconInfo, Modal, SidePanel, cn } from 'ui'
import DowngradeModal from './DowngradeModal'
import EnterpriseCard from './EnterpriseCard'
import ExitSurveyModal from './ExitSurveyModal'
import MembersExceedLimitModal from './MembersExceedLimitModal'
import PaymentMethodSelection from './PaymentMethodSelection'
import UpgradeSurveyModal from './UpgradeModal'
import PartnerIcon from 'components/ui/PartnerIcon'
const PlanUpdateSidePanel = () => {
const router = useRouter()
const selectedOrganization = useSelectedOrganization()
const slug = selectedOrganization?.slug
const queryClient = useQueryClient()
const originalPlanRef = useRef<string>()
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
@@ -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<boolean>(false)
const onLocalPaymentMethodAdded = () => {
setShowAddPaymentMethodModal(false)
return onPaymentMethodAdded()
}
return (
<div className="mt-4">
<InformationBox
icon={<IconAlertCircle size="large" strokeWidth={1.5} />}
defaultVisibility={true}
hideCollapse
title="Your organization has no payment methods"
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
You need to add a payment method for your organization before creating a paid project.
</p>
<Button type="secondary" onClick={() => setShowAddPaymentMethodModal(true)}>
Add a payment method
</Button>
</div>
}
/>
<AddNewPaymentMethodModal
visible={showAddPaymentMethodModal}
returnUrl={`${getURL()}/new/${slug}`}
onCancel={() => setShowAddPaymentMethodModal(false)}
onConfirm={() => onLocalPaymentMethodAdded()}
/>
</div>
)
}
export default EmptyPaymentMethodWarning
@@ -1,3 +1,2 @@
export { default as NotOrganizationOwnerWarning } from './NotOrganizationOwnerWarning'
export { default as FreeProjectLimitWarning } from './FreeProjectLimitWarning'
export { default as EmptyPaymentMethodWarning } from './EmptyPaymentMethodWarning'
@@ -139,7 +139,7 @@ export const ComputeBadgeWrapper = ({ project }: ComputeBadgeWrapperProps) => {
<div className="flex flex-col gap-0">
<p className="text-foreground">
{isEligibleForFreeUpgrade
? 'Free upgrade to Micro available.'
? 'Free upgrade to Micro available'
: 'Unlock more compute'}
</p>
<p className="text-foreground-light">