mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
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:
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">
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+19
-12
@@ -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
|
||||
|
||||
-53
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user