mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03:00
Merge branch 'master' into aliwaseem/fe-2819-remove-the-old-filter-bar-completely
This commit is contained in:
7 files changed
+217
-126
No files matched your search
@@ -1,5 +1,4 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui'
|
||||
@@ -14,7 +13,6 @@ import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organi
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation'
|
||||
import { useOrganizationPaymentMethodMarkAsDefaultMutation } from '@/data/organizations/organization-payment-method-default-mutation'
|
||||
import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
|
||||
import { useOrganizationTaxIdUpdateMutation } from '@/data/organizations/organization-tax-id-update-mutation'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
|
||||
interface AddPaymentMethodFormProps {
|
||||
@@ -41,9 +39,14 @@ const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation()
|
||||
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
|
||||
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
|
||||
const { data: taxId, isPending: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({
|
||||
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({
|
||||
onError: () => {},
|
||||
})
|
||||
const {
|
||||
data: taxId,
|
||||
isPending: isCustomerTaxIdLoading,
|
||||
isError: isTaxIdError,
|
||||
} = useOrganizationTaxIdQuery({
|
||||
slug: selectedOrganization?.slug,
|
||||
})
|
||||
|
||||
@@ -59,11 +62,70 @@ const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps
|
||||
document.body.classList.add('!pointer-events-auto')
|
||||
}
|
||||
|
||||
if (isPrimaryBillingAddress && isTaxIdError) {
|
||||
toast.error('Unable to load current tax ID. Please try again.')
|
||||
setIsSaving(false)
|
||||
if (document !== undefined) {
|
||||
document.body.classList.remove('!pointer-events-auto')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate address/tax ID with a dry run before proceeding with Stripe,
|
||||
// so validation errors (e.g. invalid tax ID) block the flow early.
|
||||
const formValues = isPrimaryBillingAddress
|
||||
? await paymentRef.current?.getFormValues()
|
||||
: undefined
|
||||
|
||||
if (isPrimaryBillingAddress && !formValues) {
|
||||
setIsSaving(false)
|
||||
if (document !== undefined) {
|
||||
document.body.classList.remove('!pointer-events-auto')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isPrimaryBillingAddress && formValues) {
|
||||
try {
|
||||
await updateCustomerProfile({
|
||||
slug: selectedOrganization?.slug,
|
||||
address: formValues.address,
|
||||
billing_name: formValues.customerName,
|
||||
tax_id: formValues.taxId,
|
||||
dry_run: true,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to validate billing profile')
|
||||
setIsSaving(false)
|
||||
if (document !== undefined) {
|
||||
document.body.classList.remove('!pointer-events-auto')
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Dry run passed — proceed with Stripe payment method creation / 3DS
|
||||
const result = await paymentRef.current?.confirmSetup()
|
||||
|
||||
if (!result) {
|
||||
setIsSaving(false)
|
||||
} else {
|
||||
// Stripe succeeded — persist the customer profile update for real
|
||||
if (isPrimaryBillingAddress && formValues) {
|
||||
try {
|
||||
await updateCustomerProfile({
|
||||
slug: selectedOrganization?.slug,
|
||||
address: formValues.address,
|
||||
billing_name: formValues.customerName,
|
||||
tax_id: formValues.taxId,
|
||||
})
|
||||
} catch {
|
||||
toast.error(
|
||||
'Your payment method was added successfully, but we could not save your billing address. Please update it in your organization settings.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isDefaultPaymentMethod &&
|
||||
selectedOrganization &&
|
||||
@@ -104,28 +166,6 @@ const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps
|
||||
}
|
||||
}
|
||||
|
||||
if (isPrimaryBillingAddress) {
|
||||
try {
|
||||
if (
|
||||
result.address &&
|
||||
(!isEqual(result.address, customerProfile?.address) ||
|
||||
customerProfile?.billing_name !== result.customerName)
|
||||
) {
|
||||
await updateCustomerProfile({
|
||||
slug: selectedOrganization?.slug,
|
||||
billing_name: result.customerName,
|
||||
address: result.address,
|
||||
})
|
||||
}
|
||||
|
||||
if (result.taxId && !isEqual(result.taxId, taxId)) {
|
||||
await updateTaxId({ taxId: result.taxId, slug: selectedOrganization?.slug })
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update billing address')
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
onConfirm()
|
||||
}
|
||||
|
||||
+58
-10
@@ -75,6 +75,14 @@ export type PaymentMethodElementRef = {
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
getFormValues: () => Promise<
|
||||
| {
|
||||
address: CustomerAddress
|
||||
customerName: string
|
||||
taxId: CustomerTaxId | null
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
}
|
||||
|
||||
export const NewPaymentMethodElement = forwardRef(
|
||||
@@ -162,9 +170,13 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
PaymentMethodElementRef['createPaymentMethod']
|
||||
> => {
|
||||
if (!stripe || !elements) return
|
||||
await form.trigger()
|
||||
const isValid = await form.trigger()
|
||||
|
||||
if (purchasingAsBusiness && availableTaxIds.length > 0 && !form.getValues('tax_id_value')) {
|
||||
if (
|
||||
purchasingAsBusiness &&
|
||||
availableTaxIds.length > 0 &&
|
||||
(!isValid || !form.getValues('tax_id_value'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -192,7 +204,8 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
}
|
||||
|
||||
function getConfiguredTaxId(): CustomerTaxId | null {
|
||||
return purchasingAsBusiness && selectedTaxId
|
||||
const isValidForCountry = selectedTaxId && availableTaxIds.includes(selectedTaxId)
|
||||
return purchasingAsBusiness && isValidForCountry
|
||||
? {
|
||||
country: getEffectiveTaxCountry(selectedTaxId),
|
||||
type: selectedTaxId.type,
|
||||
@@ -229,9 +242,37 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
}
|
||||
}
|
||||
|
||||
const getFormValues = async (): ReturnType<PaymentMethodElementRef['getFormValues']> => {
|
||||
if (!elements) return
|
||||
|
||||
const isValid = await form.trigger()
|
||||
if (
|
||||
purchasingAsBusiness &&
|
||||
availableTaxIds.length > 0 &&
|
||||
(!isValid || !form.getValues('tax_id_value'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const { error: submitError } = await elements.submit()
|
||||
if (submitError) return
|
||||
|
||||
const addressElement = await elements.getElement('address')!.getValue()
|
||||
|
||||
return {
|
||||
address: {
|
||||
...addressElement.value.address,
|
||||
line2: addressElement.value.address.line2 || undefined,
|
||||
},
|
||||
customerName: addressElement.value.name,
|
||||
taxId: getConfiguredTaxId(),
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
createPaymentMethod,
|
||||
confirmSetup,
|
||||
getFormValues,
|
||||
}))
|
||||
|
||||
const addressOptions: StripeAddressElementOptions = useMemo(
|
||||
@@ -254,10 +295,11 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
[purchasingAsBusiness]
|
||||
)
|
||||
|
||||
// Preselect tax id when the country changes (if there are available tax ids for that country)
|
||||
// Reset tax ID fields when the billing country changes and preselect the
|
||||
// first available tax ID for the new country.
|
||||
const prevCountryRef = useRef(addressCountry)
|
||||
useEffect(() => {
|
||||
if (!availableTaxIds.length || !addressCountry) return
|
||||
if (!addressCountry) return
|
||||
|
||||
const isCountryChange =
|
||||
prevCountryRef.current !== undefined && prevCountryRef.current !== addressCountry
|
||||
@@ -266,12 +308,18 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
// On country change: always reset to the new country's default
|
||||
// On initial load: only preselect if there's no existing tax id
|
||||
if (isCountryChange || !currentTaxId) {
|
||||
const taxIdOption = availableTaxIds[0]
|
||||
form.setValue('tax_id_type', taxIdOption.type)
|
||||
form.setValue('tax_id_value', '')
|
||||
form.setValue('tax_id_name', taxIdOption.name)
|
||||
if (availableTaxIds.length) {
|
||||
const taxIdOption = availableTaxIds[0]
|
||||
form.setValue('tax_id_type', taxIdOption.type)
|
||||
form.setValue('tax_id_value', '')
|
||||
form.setValue('tax_id_name', taxIdOption.name)
|
||||
} else {
|
||||
form.setValue('tax_id_type', '')
|
||||
form.setValue('tax_id_value', '')
|
||||
form.setValue('tax_id_name', '')
|
||||
}
|
||||
}
|
||||
}, [availableTaxIds, addressCountry, currentTaxId])
|
||||
}, [availableTaxIds, addressCountry, currentTaxId, form])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -62,6 +62,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const paymentMethodSelectionRef = useRef<{
|
||||
createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
|
||||
validateBillingProfile: () => Promise<boolean>
|
||||
}>(null)
|
||||
|
||||
const { can: canTopUpCredits, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
|
||||
@@ -129,6 +130,9 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
|
||||
const token = await initHcaptcha()
|
||||
|
||||
const isValid = await paymentMethodSelectionRef.current?.validateBillingProfile()
|
||||
if (!isValid) return
|
||||
|
||||
const paymentMethodResult = await paymentMethodSelectionRef.current?.createPaymentMethod()
|
||||
if (!paymentMethodResult) {
|
||||
return
|
||||
|
||||
+55
-1
@@ -23,6 +23,7 @@ import {
|
||||
type PaymentMethodElementRef,
|
||||
} from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement'
|
||||
import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation'
|
||||
import { useOrganizationPaymentMethodSetupIntent } from '@/data/organizations/organization-payment-method-setup-intent-mutation'
|
||||
import { useOrganizationPaymentMethodsQuery } from '@/data/organizations/organization-payment-methods-query'
|
||||
import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
|
||||
@@ -69,7 +70,14 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
useOrganizationCustomerProfileQuery({
|
||||
slug,
|
||||
})
|
||||
const { data: taxId, isPending: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ slug })
|
||||
const {
|
||||
data: taxId,
|
||||
isPending: isCustomerTaxIdLoading,
|
||||
isError: isTaxIdError,
|
||||
} = useOrganizationTaxIdQuery({ slug })
|
||||
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({
|
||||
onError: () => {},
|
||||
})
|
||||
|
||||
const { data: allPaymentMethods, isPending: isLoading } = useOrganizationPaymentMethodsQuery({
|
||||
slug,
|
||||
@@ -176,6 +184,51 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
}
|
||||
}, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod])
|
||||
|
||||
const getFormValues = async (): ReturnType<PaymentMethodElementRef['getFormValues']> => {
|
||||
if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) {
|
||||
return paymentRef.current?.getFormValues()
|
||||
} else {
|
||||
return {
|
||||
address: customerProfile?.address ?? ({} as CustomerAddress),
|
||||
customerName: customerProfile?.billing_name || '',
|
||||
taxId: taxId ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate address/tax ID with a dry run before proceeding with Stripe,
|
||||
// so validation errors (e.g. invalid tax ID) block the flow early.
|
||||
const validateBillingProfile = async (): Promise<boolean> => {
|
||||
if (!useAsDefaultBillingAddress) return true
|
||||
|
||||
if (isTaxIdError || isCustomerTaxIdLoading) {
|
||||
toast.error(
|
||||
isTaxIdError
|
||||
? 'Unable to load current tax ID. Please try again.'
|
||||
: 'Tax ID is still loading. Please wait and try again.'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const formValues = await getFormValues()
|
||||
if (!formValues) return false
|
||||
|
||||
try {
|
||||
await updateCustomerProfile({
|
||||
slug,
|
||||
address: formValues.address,
|
||||
billing_name: formValues.customerName,
|
||||
tax_id: formValues.taxId,
|
||||
dry_run: true,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to validate billing profile')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// If createPaymentMethod already exists, use it. Otherwise, define it here.
|
||||
const createPaymentMethod = async (): ReturnType<
|
||||
PaymentMethodElementRef['createPaymentMethod']
|
||||
@@ -203,6 +256,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
createPaymentMethod,
|
||||
validateBillingProfile,
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
+9
@@ -101,6 +101,7 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
|
||||
const paymentMethodSelectionRef = useRef<{
|
||||
createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
|
||||
validateBillingProfile: () => Promise<boolean>
|
||||
}>(null)
|
||||
|
||||
const billingViaPartner = subscription?.billing_via_partner === true
|
||||
@@ -180,6 +181,14 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
|
||||
setPaymentConfirmationLoading(true)
|
||||
|
||||
if (paymentMethodSelectionRef.current) {
|
||||
const isValid = await paymentMethodSelectionRef.current.validateBillingProfile()
|
||||
if (!isValid) {
|
||||
setPaymentConfirmationLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const result = await paymentMethodSelectionRef.current?.createPaymentMethod()
|
||||
if (result) {
|
||||
setSelectedPaymentMethod(result.paymentMethod.id)
|
||||
|
||||
@@ -12,6 +12,8 @@ export type OrganizationCustomerProfileUpdateVariables = {
|
||||
billing_name: string
|
||||
/** Pass a tax ID object to set/update, `null` to clear, or `undefined` to leave unchanged */
|
||||
tax_id?: CustomerTaxId | null
|
||||
/** When true, validates the request without persisting changes */
|
||||
dry_run?: boolean
|
||||
}
|
||||
|
||||
export async function updateOrganizationCustomerProfile({
|
||||
@@ -19,6 +21,7 @@ export async function updateOrganizationCustomerProfile({
|
||||
address,
|
||||
billing_name,
|
||||
tax_id,
|
||||
dry_run,
|
||||
}: OrganizationCustomerProfileUpdateVariables) {
|
||||
if (!slug) return console.error('Slug is required')
|
||||
|
||||
@@ -36,6 +39,7 @@ export async function updateOrganizationCustomerProfile({
|
||||
: tax_id !== undefined
|
||||
? { tax_id }
|
||||
: {}),
|
||||
...(dry_run ? { dry_run } : {}),
|
||||
},
|
||||
})
|
||||
if (error) throw handleError(error)
|
||||
@@ -67,9 +71,14 @@ export const useOrganizationCustomerProfileUpdateMutation = ({
|
||||
>({
|
||||
mutationFn: (vars) => updateOrganizationCustomerProfile(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { address, slug, billing_name, tax_id } = variables
|
||||
const { address, slug, billing_name, tax_id, dry_run } = variables
|
||||
|
||||
// We do not invalidate here as GET endpoint data is stale for 1-2 seconds, so we handle state manually
|
||||
if (dry_run) {
|
||||
await onSuccess?.(data, variables, context)
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistically update the cache for immediate UI consistency
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: organizationKeys.customerProfile(slug) },
|
||||
(prev: any) => {
|
||||
@@ -82,11 +91,23 @@ export const useOrganizationCustomerProfileUpdateMutation = ({
|
||||
}
|
||||
)
|
||||
|
||||
// Update tax ID cache if tax_id was part of this update
|
||||
if (tax_id !== undefined) {
|
||||
queryClient.setQueryData(organizationKeys.taxId(slug), tax_id)
|
||||
}
|
||||
|
||||
// Refetch after a delay to pick up server-canonical values (e.g. normalized tax IDs).
|
||||
// The GET endpoint can be stale for 1-2 seconds after an update.
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.customerProfile(slug),
|
||||
})
|
||||
if (tax_id !== undefined) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.taxId(slug),
|
||||
})
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { organizationKeys } from './keys'
|
||||
import { invalidateOrganizationsQuery } from './organizations-query'
|
||||
import { del, handleError, put } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
|
||||
export type OrganizationTaxIdUpdateVariables = {
|
||||
slug?: string
|
||||
taxId: { type: string; value: string; country?: string } | null
|
||||
}
|
||||
|
||||
export async function updateOrganizationTaxId({ slug, taxId }: OrganizationTaxIdUpdateVariables) {
|
||||
if (!slug) {
|
||||
throw new Error('Slug is required')
|
||||
}
|
||||
|
||||
if (taxId != null) {
|
||||
const { data, error } = await put(`/platform/organizations/{slug}/tax-ids`, {
|
||||
params: {
|
||||
path: {
|
||||
slug,
|
||||
},
|
||||
},
|
||||
body: { type: taxId.type, value: taxId.value, country: taxId.country },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
|
||||
return data
|
||||
} else {
|
||||
// @ts-ignore wrong type due to API versioning
|
||||
const { data, error } = await del(`/platform/organizations/{slug}/tax-ids`, {
|
||||
params: {
|
||||
path: {
|
||||
slug,
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Version: '2',
|
||||
},
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
type OrganizationTaxIdUpdateData = Awaited<ReturnType<typeof updateOrganizationTaxId>>
|
||||
|
||||
export const useOrganizationTaxIdUpdateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<
|
||||
OrganizationTaxIdUpdateData,
|
||||
ResponseError,
|
||||
OrganizationTaxIdUpdateVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<OrganizationTaxIdUpdateData, ResponseError, OrganizationTaxIdUpdateVariables>({
|
||||
mutationFn: (vars) => updateOrganizationTaxId(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { slug } = variables
|
||||
|
||||
// We already have the data, no need to refetch
|
||||
queryClient.setQueryData(organizationKeys.taxId(slug), data.tax_id)
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to update tax id: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user