feat: mandatory address input for paying customers (#37337)

- Mandatory address input when adding a new payment method
- Removed the global HCaptcha store that wasn't used consistently and would sometimes block payment method changes
- Remove the custom billing address data & tax id form on org creation and plan upgrades in favour of Stripe's `AddressElement`
- Unify usage of the Stripe payment elements into a single component
- Customers can mark "Purchasing as a business" and will then be able to put down a tax id
- Adjusted billing address form to have better labels + tax id is filtered down to selected country
- Adjusted Stripe Elements styling to use floating labels (otherwise very hard to use with address element) + additional styling changes
- New flag to filter out payment methods that do not have an address for org upgrades and credit top ups, this will be enforced a few days after rolling this out
- Added Google Maps Places API integration for address auto-completion via Stripe AddressElement
- Upgraded Stripe dependencies
- Slight adjustments to styling of plan upgrade modal
This commit is contained in:
Kevin Grüneberg authored and GitHub committed 2025-07-23 18:16:10 +08:00
1 parent 7505c2923e
commit 740eeb8504
34 files changed
+953 -843

No files matched your search

@@ -9,7 +9,6 @@ import { Modal } from 'ui'
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import AddPaymentMethodForm from './AddPaymentMethodForm'
import { getStripeElementsAppearanceOptions } from './Payment.utils'
@@ -18,8 +17,6 @@ interface AddNewPaymentMethodModalProps {
returnUrl: string
onCancel: () => void
onConfirm: () => void
showSetDefaultCheckbox?: boolean
autoMarkAsDefaultPaymentMethod?: boolean
}
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -29,14 +26,11 @@ const AddNewPaymentMethodModal = ({
returnUrl,
onCancel,
onConfirm,
showSetDefaultCheckbox,
autoMarkAsDefaultPaymentMethod,
}: AddNewPaymentMethodModalProps) => {
const { resolvedTheme } = useTheme()
const [intent, setIntent] = useState<any>()
const selectedOrganization = useSelectedOrganization()
const captchaLoaded = useIsHCaptchaLoaded()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
@@ -64,7 +58,7 @@ const AddNewPaymentMethodModal = ({
}
const loadPaymentForm = async () => {
if (visible && captchaRef && captchaLoaded) {
if (visible && captchaRef) {
let token = captchaToken
try {
@@ -82,7 +76,7 @@ const AddNewPaymentMethodModal = ({
}
loadPaymentForm()
}, [visible, captchaRef, captchaLoaded])
}, [visible, captchaRef])
const resetCaptcha = () => {
setCaptchaToken(null)
@@ -142,8 +136,6 @@ const AddNewPaymentMethodModal = ({
returnUrl={returnUrl}
onCancel={onLocalCancel}
onConfirm={onLocalConfirm}
showSetDefaultCheckbox={showSetDefaultCheckbox}
autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod}
/>
</Elements>
</Modal>
@@ -1,49 +1,56 @@
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import { useQueryClient } from '@tanstack/react-query'
import {
NewPaymentMethodElement,
type PaymentMethodElementRef,
} from 'components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement'
import { organizationKeys } from 'data/organizations/keys'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
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 { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useState } from 'react'
import { isEqual } from 'lodash'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
interface AddPaymentMethodFormProps {
returnUrl: string
onCancel: () => void
onConfirm: () => void
showSetDefaultCheckbox?: boolean
autoMarkAsDefaultPaymentMethod?: 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,
showSetDefaultCheckbox = false,
autoMarkAsDefaultPaymentMethod = false,
}: AddPaymentMethodFormProps) => {
const stripe = useStripe()
const elements = useElements()
const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps) => {
const selectedOrganization = useSelectedOrganization()
const { data: customerProfile, isLoading: customerProfileLoading } =
useOrganizationCustomerProfileQuery({
slug: selectedOrganization?.slug,
})
const [isSaving, setIsSaving] = useState(false)
const [isDefault, setIsDefault] = useState(showSetDefaultCheckbox)
const [isDefaultPaymentMethod, setIsDefaultPaymentMethod] = useState(true)
const [isPrimaryBillingAddress, setIsPrimaryBillingAddress] = useState(true)
const queryClient = useQueryClient()
const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation()
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({
slug: selectedOrganization?.slug,
})
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
const handleSubmit = async (event: any) => {
event.preventDefault()
if (!stripe || !elements) {
console.error('Stripe.js has not loaded')
return
}
setIsSaving(true)
if (document !== undefined) {
@@ -51,25 +58,20 @@ const AddPaymentMethodForm = ({
document.body.classList.add('!pointer-events-auto')
}
const { error, setupIntent } = await stripe.confirmSetup({
elements,
redirect: 'if_required',
confirmParams: { return_url: returnUrl },
})
const result = await paymentRef.current?.confirmSetup()
if (error) {
if (!result) {
setIsSaving(false)
toast.error(error?.message ?? ' Failed to save card details')
} else {
if (
(isDefault || autoMarkAsDefaultPaymentMethod) &&
isDefaultPaymentMethod &&
selectedOrganization &&
typeof setupIntent?.payment_method === 'string'
typeof result.setupIntent?.payment_method === 'string'
) {
try {
await markAsDefault({
slug: selectedOrganization.slug,
paymentMethodId: setupIntent.payment_method,
paymentMethodId: result.setupIntent.payment_method,
})
await queryClient.invalidateQueries(
@@ -82,10 +84,10 @@ const AddPaymentMethodForm = ({
if (!prev) return prev
return {
...prev,
defaultPaymentMethodId: setupIntent.payment_method,
defaultPaymentMethodId: result.setupIntent.payment_method,
data: prev.data.map((pm: any) => ({
...pm,
is_default: pm.id === setupIntent.payment_method,
is_default: pm.id === result.setupIntent.payment_method,
})),
}
}
@@ -101,6 +103,28 @@ const AddPaymentMethodForm = ({
}
}
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()
}
@@ -110,33 +134,64 @@ const AddPaymentMethodForm = ({
}
}
if (customerProfileLoading || isCustomerTaxIdLoading) {
return (
<Modal.Content>
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader />
<ShimmeringLoader />
<ShimmeringLoader />
</div>
</Modal.Content>
)
}
return (
<div>
<Modal.Content
className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`}
>
<PaymentElement
className="[.p-LinkAutofillPrompt]:pt-0"
options={{
defaultValues: { billingDetails: { email: selectedOrganization?.billing_email ?? '' } },
}}
<NewPaymentMethodElement
readOnly={isSaving}
email={selectedOrganization?.billing_email}
currentAddress={customerProfile?.address}
customerName={customerProfile?.billing_name}
currentTaxId={taxId}
ref={paymentRef}
/>
{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>
)}
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="save-as-default"
checked={isDefaultPaymentMethod}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsDefaultPaymentMethod(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
Save as default payment method
</Label_Shadcn_>
</div>
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="is-primary-billing-address"
checked={isPrimaryBillingAddress}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsPrimaryBillingAddress(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="is-primary-billing-address" className="text-foreground-light">
Use the billing address as my organization's primary address
</Label_Shadcn_>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content className="flex items-center space-x-2">
@@ -4,6 +4,7 @@ export const getStripeElementsAppearanceOptions = (
resolvedTheme: string | undefined
): Appearance => {
return {
labels: 'floating',
theme: (resolvedTheme?.includes('dark') ? 'night' : 'flat') as 'night' | 'flat',
variables: {
fontSizeBase: '14px',
@@ -12,18 +13,27 @@ export const getStripeElementsAppearanceOptions = (
: 'hsl(0deg 0% 95.3%)',
fontFamily:
'var(--font-custom, Circular, custom-font, Helvetica Neue, Helvetica, Arial, sans-serif)',
spacingUnit: '4px',
borderRadius: '.375rem',
gridRowSpacing: '4px',
},
rules: {
'.Label': {
// Hide labels - it is obvious enough what the fields are for
fontSize: '0',
},
'.TermsText': {
fontSize: '12px',
},
'.Label--floating': {
fontSize: '14px',
},
'.Label--resting': {
fontSize: '14px',
color: 'rgb(137, 137, 137)',
},
'.Input': {
boxShadow: 'none',
height: '34px',
lineHeight: '16px',
padding: '8px 12px',
},
'.AccordionItem': {
boxShadow: 'none',
},
},
}
}
@@ -20,15 +20,18 @@ import {
useAsyncCheckProjectPermissions,
useCheckPermissions,
} from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { Button, Card, CardFooter, Form_Shadcn_ as Form } from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import {
BillingCustomerDataForm,
type BillingCustomerDataFormValues,
} from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
export const BillingCustomerData = () => {
const { slug } = useParams()
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { isSuccess: isPermissionsLoaded, can: canReadBillingCustomerData } =
useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.customer')
@@ -51,9 +54,13 @@ export const BillingCustomerData = () => {
isSuccess: loadedTaxId,
} = useOrganizationTaxIdQuery({ slug })
const initialCustomerData = useMemo(
const initialCustomerData = useMemo<Partial<BillingCustomerDataFormValues>>(
() => ({
...customerProfile?.address,
city: customerProfile?.address?.city ?? undefined,
country: customerProfile?.address?.country,
line1: customerProfile?.address?.line1,
line2: customerProfile?.address?.line2 ?? undefined,
postal_code: customerProfile?.address?.postal_code ?? undefined,
billing_name: customerProfile?.billing_name,
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
@@ -1,215 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Pencil } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import AlertError from 'components/ui/AlertError'
import NoPermission from 'components/ui/NoPermission'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSectionSeparator,
DialogTitle,
Form_Shadcn_ as Form,
Label_Shadcn_ as Label,
} from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
export const BillingCustomerDataExistingOrgDialog = () => {
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { slug } = useSelectedOrganization() ?? {}
const canReadBillingCustomerData = useCheckPermissions(
PermissionAction.BILLING_READ,
'stripe.customer'
)
const canUpdateBillingCustomerData = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.customer'
)
const {
data: customerProfile,
error,
isLoading,
isSuccess,
} = useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingCustomerData })
const {
data: taxId,
error: errorTaxId,
isLoading: isLoadingTaxId,
isSuccess: isSuccessTaxId,
} = useOrganizationTaxIdQuery({ slug })
const initialCustomerData = useMemo(
() => ({
...customerProfile?.address,
billing_name: customerProfile?.billing_name,
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
tax_id_name: taxId
? TAX_IDS.find(
(option) => option.type === taxId.type && option.countryIso2 === taxId.country
)?.name || ''
: '',
}),
[customerProfile, taxId]
)
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({
initialCustomerData,
onCustomerDataChange: async (data) => {
setIsSubmitting(true)
try {
await updateCustomerProfile({
slug,
address: data.address ?? undefined,
billing_name: data.billing_name,
})
await updateTaxId({ slug, taxId: data.tax_id })
toast.success('Successfully updated billing data')
setOpen(false)
setIsSubmitting(false)
} catch (error: any) {
toast.error(`Failed updating billing data: ${error.message}`)
setIsSubmitting(false)
}
},
})
const handleClose = () => {
handleReset()
setOpen(false)
}
const getAddressSummary = () => {
if (!customerProfile?.address?.line1) return 'Optionally add a billing address'
const parts = [
customerProfile?.billing_name,
customerProfile?.address?.line1,
customerProfile?.address?.city,
customerProfile?.address?.state,
customerProfile?.address?.country,
].filter(Boolean)
return parts.join(', ')
}
const isSubmitDisabled = !isDirty || !canUpdateBillingCustomerData || isSubmitting
return (
<>
<div>
<Label htmlFor="billing-address-btn" className="text-foreground-light block mb-0">
Billing Address / Tax Id
</Label>
{!canReadBillingCustomerData ? (
<NoPermission resourceText="view this organization's billing address" />
) : (
<>
{(isLoading || isLoadingTaxId) && (
<div className="space-y-2">
<ShimmeringLoader />
</div>
)}
{(error || errorTaxId) && (
<AlertError
subject="Failed to retrieve organization customer profile"
error={(error || errorTaxId) as any}
/>
)}
{isSuccess && isSuccessTaxId && (
<div className="flex items-center justify-between">
<p className="text-xs text-foreground">{getAddressSummary()}</p>
<Button
id="billing-address-btn"
onClick={() => setOpen(true)}
type="text"
aria-label="Edit"
size={'tiny'}
className="w-8 h-8 p-0 text-foreground-light"
disabled={!canUpdateBillingCustomerData}
>
<Pencil size={14} strokeWidth={1.5} />
</Button>
</div>
)}
</>
)}
</div>
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) setOpen(false)
else setOpen(true)
}}
>
<DialogContent size={'large'}>
<DialogHeader>
<DialogTitle>Billing Address &amp; Tax Id</DialogTitle>
<DialogDescription>
This will be reflected in every upcoming invoice, past invoices are not affected
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<BillingCustomerDataForm
className="p-5"
form={form}
disabled={!canUpdateBillingCustomerData}
/>
<DialogFooter className="justify-end">
{!canUpdateBillingCustomerData && (
<span className="text-sm text-foreground-lighter">
You need additional permissions to manage this organization's billing address
</span>
)}
<div className="flex items-center gap-2">
<Button type="default" onClick={handleClose}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
disabled={isSubmitDisabled}
loading={isSubmitting}
>
Save
</Button>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
)
}
@@ -2,7 +2,7 @@ import { Check, ChevronsUpDown, X } from 'lucide-react'
import { UseFormReturn } from 'react-hook-form'
import { z } from 'zod'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import {
Button,
cn,
@@ -90,18 +90,24 @@ export const BillingCustomerDataForm = ({
form.setValue('tax_id_value', '', { shouldDirty: true })
}
const { tax_id_name } = form.watch()
const { tax_id_name, country } = form.watch()
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
const availableTaxIds = useMemo(() => {
return TAX_IDS.filter((taxId) => !country || taxId.countryIso2 === country).sort((a, b) =>
a.country.localeCompare(b.country)
)
}, [country])
return (
<div className={cn('flex flex-col space-y-4', className)}>
<FormField
control={form.control}
name="billing_name"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Name">
<FormControl>
<Input {...field} placeholder="Name" disabled={disabled} />
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -112,9 +118,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="line1"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Address line 1">
<FormControl>
<Input {...field} placeholder="Address line 1" disabled={disabled} />
<Input {...field} placeholder="123 Main Street" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -125,9 +131,13 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="line2"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Address line 2 (optional)">
<FormControl>
<Input {...field} placeholder="Address line 2 (Optional)" disabled={disabled} />
<Input
{...field}
placeholder="Apartment, suite, unit, building, floor, etc."
disabled={disabled}
/>
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -139,7 +149,7 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="country"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Country">
<Popover open={showCountriesPopover} onOpenChange={setShowCountriesPopover}>
<PopoverTrigger asChild>
<FormControl>
@@ -207,9 +217,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="postal_code"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Postal code">
<FormControl>
<Input {...field} placeholder="Postal code" disabled={disabled} />
<Input {...field} placeholder="12345" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -222,9 +232,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="city"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="City">
<FormControl>
<Input {...field} placeholder="City" disabled={disabled} />
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -234,9 +244,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="state"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="State / Province">
<FormControl>
<Input {...field} placeholder="State / Province" disabled={disabled} />
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -281,26 +291,24 @@ export const BillingCustomerDataForm = ({
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map(
(option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
)
)}
{availableTaxIds.map((option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
@@ -1,122 +0,0 @@
import { FocusTrap } from '@headlessui/react'
import { Pencil } from 'lucide-react'
import { useState } from 'react'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_ as Form,
} from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import { FormCustomerData, useBillingCustomerDataForm } from './useBillingCustomerDataForm'
interface BillingCustomerDataNewOrgDialogProps {
onCustomerDataChange: (data: FormCustomerData) => void
}
export const BillingCustomerDataNewOrgDialog = ({
onCustomerDataChange,
}: BillingCustomerDataNewOrgDialogProps) => {
const [open, setOpen] = useState(false)
const handleDialogClose = () => {
setOpen(false)
}
const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({
onCustomerDataChange,
})
const handleClose = () => {
handleReset()
handleDialogClose()
}
const getAddressSummary = () => {
const { line1, city, state, country, billing_name } = form.getValues()
if (!line1) return 'Optionally add a billing address'
const parts = [billing_name, line1, city, state, country].filter(Boolean)
return parts.join(', ')
}
const isSubmitDisabled = !isDirty
return (
<div className="flex items-center justify-between">
<label className="text-sm text-foreground-light" htmlFor="billing-address-btn">
{getAddressSummary()}
</label>
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) handleDialogClose()
else setOpen(true)
}}
>
<DialogTrigger asChild>
<Button
id="billing-address-btn"
onClick={() => setOpen(true)}
type="text"
aria-label="Edit"
size={'tiny'}
className="w-8 h-8 p-0 text-foreground-light"
>
<Pencil size={14} strokeWidth={1.5} />
</Button>
</DialogTrigger>
<DialogContent size="large">
<DialogHeader>
<DialogTitle>Billing Address &amp; Tax Id</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
{/*
[Joshen] There's something odd going on with using Dialog and RHF FormField here
where the focus keeps going to the Dialog when tabbing across the input fields, hence the FocusTrap
What's weirder is that once you've cycled the focus through all the inputs at least once, the focus
then no longer goes to the Dialog thereafter
FWIW it's likely happening across the dashboard whereever we're using Dialog and FormField
*/}
<FocusTrap>
<Form {...form}>
<form
id="new-org-billing-data-form"
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit(handleSubmit)(e)
// Only close the dialog if the form is valid
if (!Object.keys(form.formState.errors).length) {
handleDialogClose()
}
}}
>
<BillingCustomerDataForm className="p-5" form={form} />
<DialogFooter className="justify-end">
<div className="flex items-center gap-2">
<Button type="default" onClick={handleClose}>
Cancel
</Button>
<Button type="primary" htmlType="submit" disabled={isSubmitDisabled}>
Continue
</Button>
</div>
</DialogFooter>
</form>
</Form>
</FocusTrap>
</DialogContent>
</Dialog>
</div>
)
}
@@ -1,8 +1,3 @@
// List of available Tax IDs as reflected in Stripe's web portal
// This was manually ported over so there may be a chance of mistakes
// Last updated as of 29th March 2022.
// The code may not necessarily match with the name (ref SE_VAT)
// https://stripe.com/docs/api/customer_tax_ids/create
export interface TaxId {
name: string
type: string
@@ -6,9 +6,10 @@ import { BillingCustomerDataFormValues, BillingCustomerDataSchema } from './Bill
import { TAX_IDS } from './TaxID.constants'
import { sanitizeTaxIdValue } from './TaxID.utils'
import { components } from 'api-types'
import type { CustomerAddress } from 'data/organizations/types'
export type FormCustomerData = {
address: components['schemas']['CustomerResponse']['address'] | undefined
address: CustomerAddress | undefined
billing_name: string
tax_id: components['schemas']['TaxIdResponse']['tax_id'] | null
}
@@ -7,7 +7,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { AlertCircle, Info } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
@@ -17,7 +17,6 @@ import { useOrganizationCreditTopUpMutation } from 'data/organizations/organizat
import { subscriptionKeys } from 'data/subscriptions/keys'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
@@ -40,6 +39,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import PaymentMethodSelection from './Subscription/PaymentMethodSelection'
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
import type { PaymentMethodElementRef } from './PaymentMethods/NewPaymentMethodElement'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -59,6 +59,9 @@ type CreditTopUpForm = z.infer<typeof FormSchema>
export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
const { resolvedTheme } = useTheme()
const queryClient = useQueryClient()
const paymentMethodSelectionRef = useRef<{
createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
}>(null)
const canTopUpCredits = useCheckPermissions(
PermissionAction.BILLING_WRITE,
@@ -82,7 +85,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
const [topUpModalVisible, setTopUpModalVisible] = useState(false)
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
const captchaLoaded = useIsHCaptchaLoaded()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
@@ -96,7 +98,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
}
const initHcaptcha = async () => {
if (topUpModalVisible && captchaRef && captchaLoaded) {
if (topUpModalVisible && captchaRef) {
let token = captchaToken
try {
@@ -116,7 +118,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
useEffect(() => {
initHcaptcha()
}, [topUpModalVisible, captchaRef, captchaLoaded])
}, [topUpModalVisible, captchaRef])
const [paymentIntentSecret, setPaymentIntentSecret] = useState('')
const [paymentIntentConfirmation, setPaymentIntentConfirmation] = useState<PaymentIntentResult>()
@@ -126,12 +128,20 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
const token = await initHcaptcha()
const paymentMethodResult = await paymentMethodSelectionRef.current?.createPaymentMethod()
if (!paymentMethodResult) {
return
}
await topUpCredits(
{
slug,
amount,
payment_method_id: paymentMethod,
payment_method_id: paymentMethodResult.paymentMethod.id,
hcaptchaToken: token,
address: paymentMethodResult.address,
tax_id: paymentMethodResult.taxId ?? undefined,
billing_name: paymentMethodResult.customerName,
},
{
onSuccess: (data) => {
@@ -225,10 +235,21 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
/>
<DialogHeader>
<DialogTitle>Top Up Credits</DialogTitle>
<DialogDescription>
On successful payment, an invoice will be issued and you'll be granted credits.
Credits will be applied to outstanding and future invoices and are not refundable. The
topped up credits do not expire.
<DialogDescription className="space-y-2">
<p className="prose text-sm">
On successful payment, an invoice will be issued and you'll be granted credits.
Credits will be applied to outstanding and future invoices and are not refundable.
The topped up credits do not expire.
</p>
<p className="prose text-sm">
For larger discounted credit packages, please{' '}
<Link
href={`/support/new?slug=${slug}&subject=${encodeURIComponent('I would like to inquire about larger credit packages')}&category=${SupportCategories.SALES_ENQUIRY}`}
target="_blank"
>
reach out.
</Link>
</p>
</DialogDescription>
</DialogHeader>
@@ -252,7 +273,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
name="paymentMethod"
render={() => (
<PaymentMethodSelection
createPaymentMethodInline={false}
ref={paymentMethodSelectionRef}
onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)}
selectedPaymentMethod={form.getValues('paymentMethod')}
readOnly={executingTopUp || paymentConfirmationLoading}
@@ -260,16 +281,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
)}
/>
<p className="prose text-sm">
For larger discounted credit packages, please{' '}
<Link
href={`/support/new?slug=${slug}&subject=${encodeURIComponent('I would like to inquire about larger credit packages')}&category=${SupportCategories.SALES_ENQUIRY}`}
target="_blank"
>
reach out.
</Link>
</p>
{paymentIntentConfirmation && paymentIntentConfirmation.error && (
<Alert_Shadcn_ variant="destructive">
<AlertCircle className="h-4 w-4" />
@@ -4,26 +4,148 @@
* If Elements is on a higher level, we risk losing all form state in case a payment fails.
*/
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import { forwardRef, useImperativeHandle } from 'react'
import { AddressElement, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import {
StripeAddressElementChangeEvent,
StripeAddressElementOptions,
type SetupIntent,
} from '@stripe/stripe-js'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Checkbox_Shadcn_,
cn,
Command_Shadcn_ as Command,
CommandEmpty_Shadcn_ as CommandEmpty,
CommandGroup_Shadcn_ as CommandGroup,
CommandInput_Shadcn_ as CommandInput,
CommandItem_Shadcn_ as CommandItem,
CommandList_Shadcn_ as CommandList,
FormControl_Shadcn_ as FormControl,
FormField_Shadcn_ as FormField,
FormItem_Shadcn_,
FormMessage_Shadcn_ as FormMessage,
Input_Shadcn_ as Input,
Popover_Shadcn_ as Popover,
PopoverContent_Shadcn_ as PopoverContent,
PopoverTrigger_Shadcn_ as PopoverTrigger,
} from 'ui'
import { TAX_IDS, type TaxId } from '../BillingCustomerData/TaxID.constants'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { Form } from '@ui/components/shadcn/ui/form'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { Check, ChevronsUpDown } from 'lucide-react'
import { zodResolver } from '@hookform/resolvers/zod'
import { getURL } from 'lib/helpers'
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
import type { PaymentMethod } from '@stripe/stripe-js'
export const BillingCustomerDataSchema = z.object({
tax_id_type: z.string(),
tax_id_value: z.string().min(2, {
message: 'Tax ID needs to be set.',
}),
tax_id_name: z.string(),
})
type BillingCustomerDataFormValues = z.infer<typeof BillingCustomerDataSchema>
export type PaymentMethodElementRef = {
confirmSetup: () => Promise<
| {
setupIntent: SetupIntent
address: CustomerAddress
customerName: string
taxId: CustomerTaxId | null
}
| undefined
>
createPaymentMethod: () => Promise<
| {
paymentMethod: PaymentMethod
address: CustomerAddress | null
customerName: string | null
taxId: CustomerTaxId | null
}
| undefined
>
}
const NewPaymentMethodElement = forwardRef(
(
{
email,
readOnly,
currentAddress,
currentTaxId,
customerName,
}: {
email?: string | null | undefined
readOnly: boolean
currentAddress?: CustomerAddress | null
currentTaxId?: CustomerTaxId | null
customerName?: string | undefined
},
ref
) => {
const stripe = useStripe()
const elements = useElements()
const createPaymentMethod = async () => {
const form = useForm<BillingCustomerDataFormValues>({
resolver: zodResolver(BillingCustomerDataSchema),
defaultValues: {
tax_id_name: currentTaxId
? TAX_IDS.find(
(option) =>
option.type === currentTaxId.type && option.countryIso2 === currentTaxId.country
)?.name || ''
: '',
tax_id_type: currentTaxId ? currentTaxId.type : '',
tax_id_value: currentTaxId ? currentTaxId.value : '',
},
})
// To avoid rendering the business checkbox prematurely and causing weird layout shifts, we wait until the address element is fully loaded
const [fullyLoaded, setFullyLoaded] = useState(false)
const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false)
const onSelectTaxIdType = (name: string) => {
const selectedTaxIdOption = TAX_IDS.find((option) => option.name === name)
if (!selectedTaxIdOption) return
form.setValue('tax_id_type', selectedTaxIdOption.type)
form.setValue('tax_id_value', '')
form.setValue('tax_id_name', name)
}
const { tax_id_name } = form.watch()
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null)
const [stripeAddress, setStripeAddress] = useState<
StripeAddressElementChangeEvent['value'] | undefined
>(undefined)
const availableTaxIds = useMemo(() => {
const country = stripeAddress?.address.country || null
return TAX_IDS.filter((taxId) => country == null || taxId.countryIso2 === country).sort(
(a, b) => a.country.localeCompare(b.country)
)
}, [stripeAddress])
const createPaymentMethod = async (): ReturnType<
PaymentMethodElementRef['createPaymentMethod']
> => {
if (!stripe || !elements) return
await form.trigger()
if (purchasingAsBusiness && availableTaxIds.length > 0 && !form.getValues('tax_id_value')) {
return
}
await elements.submit()
// To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment
@@ -34,17 +156,209 @@ const NewPaymentMethodElement = forwardRef(
toast.error(error?.message ?? ' Failed to process card details')
return
}
return paymentMethod
const addressElement = await elements.getElement('address')!.getValue()
return {
paymentMethod,
address: {
...addressElement.value.address,
line2: addressElement.value.address.line2 || undefined,
},
customerName: addressElement.value.name,
taxId: getConfiguredTaxId(),
}
}
function getConfiguredTaxId(): CustomerTaxId | null {
return purchasingAsBusiness && selectedTaxId
? {
country: selectedTaxId.countryIso2,
type: selectedTaxId.type,
value: form.getValues('tax_id_value'),
}
: null
}
const confirmSetup = async (): ReturnType<PaymentMethodElementRef['confirmSetup']> => {
if (!stripe || !elements) return
await elements.submit()
const { error, setupIntent } = await stripe.confirmSetup({
elements,
redirect: 'if_required',
confirmParams: { return_url: `${getURL()}/org/_/billing` },
})
if (error || setupIntent == null) {
toast.error(error?.message ?? ' Failed to process card details')
return
}
const addressElement = await elements.getElement('address')!.getValue()
return {
setupIntent,
address: {
...addressElement.value.address,
line2: addressElement.value.address.line2 || undefined,
},
customerName: addressElement.value.name,
taxId: getConfiguredTaxId(),
}
}
useImperativeHandle(ref, () => ({
createPaymentMethod,
confirmSetup,
}))
const addressOptions: StripeAddressElementOptions = useMemo(
() => ({
mode: 'billing',
autocomplete: {
apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!,
mode: 'google_maps_api',
},
display: { name: purchasingAsBusiness ? 'organization' : 'full' },
defaultValues: {
address: currentAddress ?? undefined,
name: customerName,
},
}),
[purchasingAsBusiness]
)
// Preselect tax id if there is no more than 2 available tax ids (even if there are two options, first one in the list is likely to be it)
useEffect(() => {
if (availableTaxIds.length && stripeAddress?.address.country && !currentTaxId) {
const taxIdOption = availableTaxIds[0]
form.setValue('tax_id_type', taxIdOption.type)
form.setValue('tax_id_value', '')
form.setValue('tax_id_name', taxIdOption.name)
}
}, [availableTaxIds, stripeAddress])
return (
<PaymentElement
options={{ defaultValues: { billingDetails: { email: email ?? undefined } }, readOnly }}
/>
<div className="space-y-4">
<p className="text-sm text-foreground-light mb-2">
Please ensure CVC and postal codes match what is on file for your card.
</p>
<PaymentElement
options={{
layout: 'tabs',
defaultValues: { billingDetails: { email: email ?? undefined } },
readOnly,
}}
/>
{fullyLoaded && (
<div className="flex items-center space-x-2">
<Checkbox_Shadcn_
id="business"
checked={purchasingAsBusiness}
onCheckedChange={() => setPurchasingAsBusiness(!purchasingAsBusiness)}
/>
<label htmlFor="business" className="text-foreground-light text-sm leading-none">
I'm purchasing as a business
</label>
</div>
)}
<AddressElement
options={addressOptions}
// Force reload after changing purchasingAsBusiness setting, it seems like the element does not reload otherwise
key={`address-elements-${purchasingAsBusiness}`}
onChange={(evt) => setStripeAddress(evt.value)}
onReady={() => setFullyLoaded(true)}
/>
{purchasingAsBusiness && availableTaxIds.length > 0 && (
<Form {...form}>
<div className="grid grid-cols-2 gap-x-2 w-full">
<FormField
name="tax_id_name"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage layout="vertical">
<Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
<PopoverTrigger asChild>
<FormControl>
<Button
type="default"
role="combobox"
size="medium"
className={cn(
'w-full justify-between h-[34px]',
!selectedTaxId && 'text-muted'
)}
iconRight={
<ChevronsUpDown
className="ml-2 h-4 w-4 shrink-0 opacity-50"
strokeWidth={1.5}
/>
}
>
{selectedTaxId
? `${selectedTaxId.country} - ${selectedTaxId.name}`
: 'Select tax ID'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent sameWidthAsTrigger className="p-0" align="start">
<Command>
<CommandInput placeholder="Search tax ID..." />
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{availableTaxIds.map((option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name
? 'opacity-100'
: 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItemLayout>
)}
/>
{selectedTaxId && (
<FormField
name="tax_id_value"
control={form.control}
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl>
<Input {...field} placeholder={selectedTaxId?.placeholder} />
</FormControl>
<FormMessage />
</FormItem_Shadcn_>
)}
/>
)}
</div>
</Form>
)}
</div>
)
}
)
@@ -176,7 +176,6 @@ const PaymentMethods = () => {
setShowAddPaymentMethodModal(false)
toast.success('Successfully added new payment method')
}}
showSetDefaultCheckbox={true}
/>
<ChangePaymentMethodModal
@@ -1,7 +1,7 @@
import { Check } from 'lucide-react'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { PricingInformation } from 'shared-data'
import { pickFeatures } from 'shared-data/plans'
import { Button, cn } from 'ui'
@@ -13,7 +13,7 @@ export interface EnterpriseCardProps {
}
export const EnterpriseCard = ({ plan, isCurrentPlan, billingPartner }: EnterpriseCardProps) => {
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const orgSlug = selectedOrganization?.slug
const features = pickFeatures(plan, billingPartner)
@@ -1,5 +1,5 @@
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { Button, Modal } from 'ui'
export interface MembersExceedLimitModalProps {
@@ -8,7 +8,7 @@ export interface MembersExceedLimitModalProps {
}
const MembersExceedLimitModal = ({ visible, onClose }: MembersExceedLimitModalProps) => {
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const slug = selectedOrganization?.slug
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
{ slug },
@@ -1,4 +1,3 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import {
forwardRef,
useCallback,
@@ -9,26 +8,30 @@ import {
useState,
} from 'react'
import { toast } from 'sonner'
import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
useSelectedOrganization,
useSelectedOrganizationQuery,
} from 'hooks/misc/useSelectedOrganization'
import { BASE_PATH, STRIPE_PUBLIC_KEY } from 'lib/constants'
import { getURL } from 'lib/helpers'
import { AlertCircle, CreditCard, Loader, Plus } from 'lucide-react'
import { Listbox } from 'ui'
import { Loader, Plus } from 'lucide-react'
import { Checkbox_Shadcn_, Listbox } from 'ui'
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
import { useTheme } from 'next-themes'
import { Elements } from '@stripe/react-stripe-js'
import { NewPaymentMethodElement } from '../PaymentMethods/NewPaymentMethodElement'
import {
NewPaymentMethodElement,
type PaymentMethodElementRef,
} from '../PaymentMethods/NewPaymentMethodElement'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { useFlag } from 'hooks/ui/useFlag'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useParams } from 'common'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -36,7 +39,6 @@ export interface PaymentMethodSelectionProps {
selectedPaymentMethod?: string
onSelectPaymentMethod: (id: string) => void
layout?: 'vertical' | 'horizontal'
createPaymentMethodInline: boolean
readOnly: boolean
}
@@ -45,27 +47,48 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
selectedPaymentMethod,
onSelectPaymentMethod,
layout = 'vertical',
createPaymentMethodInline = false,
readOnly,
}: PaymentMethodSelectionProps,
ref
) {
const selectedOrganization = useSelectedOrganization()
const slug = selectedOrganization?.slug
const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false)
const captchaLoaded = useIsHCaptchaLoaded()
const { slug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
const [setupIntent, setSetupIntent] = useState<SetupIntentResponse | undefined>(undefined)
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
const { resolvedTheme } = useTheme()
const paymentRef = useRef<{ createPaymentMethod: () => Promise<PaymentMethod | undefined> }>(null)
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState<boolean | null>(null)
const { data: customerProfile, isLoading: isCustomerProfileLoading } =
useOrganizationCustomerProfileQuery({
slug,
})
const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ slug })
const {
data: paymentMethods,
isLoading,
refetch: refetchPaymentMethods,
} = useOrganizationPaymentMethodsQuery({ slug })
const hidePaymentMethodsWithoutAddress = useFlag('hidePaymentMethodsWithoutAddress')
const { data: allPaymentMethods, isLoading } = useOrganizationPaymentMethodsQuery({ slug })
const paymentMethods = useMemo(() => {
if (!allPaymentMethods)
return {
data: [],
defaultPaymentMethodId: null,
}
const filtered = allPaymentMethods.data.filter(
(pm) => !hidePaymentMethodsWithoutAddress || pm.has_address
)
return {
data: filtered,
defaultPaymentMethodId: allPaymentMethods.data.some(
(pm) => pm.id === allPaymentMethods.defaultPaymentMethodId
)
? allPaymentMethods.defaultPaymentMethodId
: null,
}
}, [allPaymentMethods])
const captchaRefCallback = useCallback((node: any) => {
setCaptchaRef(node)
@@ -98,7 +121,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
}
const loadPaymentForm = async () => {
if (setupNewPaymentMethod && createPaymentMethodInline && captchaRef && captchaLoaded) {
if (setupNewPaymentMethod && captchaRef) {
let token = captchaToken
try {
@@ -116,18 +139,13 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
}
loadPaymentForm()
}, [createPaymentMethodInline, captchaRef, captchaLoaded, setupNewPaymentMethod])
}, [captchaRef, setupNewPaymentMethod])
const resetCaptcha = () => {
setCaptchaToken(null)
captchaRef?.resetCaptcha()
}
const canUpdatePaymentMethods = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.payment_methods'
)
const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
() =>
({
@@ -156,11 +174,27 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
}, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod])
// If createPaymentMethod already exists, use it. Otherwise, define it here.
const createPaymentMethod = async () => {
const createPaymentMethod = async (): ReturnType<
PaymentMethodElementRef['createPaymentMethod']
> => {
if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) {
return paymentRef.current?.createPaymentMethod()
const paymentResult = await paymentRef.current?.createPaymentMethod()
if (!paymentResult) return paymentResult
return {
paymentMethod: paymentResult.paymentMethod,
customerName: useAsDefaultBillingAddress ? paymentResult.customerName : null,
address: useAsDefaultBillingAddress ? paymentResult.address : null,
taxId: useAsDefaultBillingAddress ? paymentResult.taxId : null,
}
} else {
return { id: selectedPaymentMethod }
return {
paymentMethod: { id: selectedPaymentMethod } as PaymentMethod,
customerName: useAsDefaultBillingAddress ? customerProfile?.billing_name || '' : null,
address: useAsDefaultBillingAddress ? customerProfile?.address ?? null : null,
taxId: useAsDefaultBillingAddress ? taxId ?? null : null,
}
}
}
@@ -197,42 +231,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
<Loader className="animate-spin" size={14} />
<p className="text-sm text-foreground-light">Retrieving payment methods</p>
</div>
) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? (
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative">
<div className="flex items-center space-x-4 text-foreground-light">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm">No payment methods</p>
</div>
<ButtonTooltip
type="default"
disabled={!canUpdatePaymentMethods}
icon={<CreditCard />}
onClick={() => {
if (createPaymentMethodInline) {
setSetupNewPaymentMethod(true)
} else {
setShowAddNewPaymentMethodModal(true)
}
}}
htmlType="button"
tooltip={{
content: {
side: 'bottom',
text: !canUpdatePaymentMethods ? (
<div className="w-48 text-center">
<span>
You need additional permissions to add new payment methods to this
organization
</span>
</div>
) : undefined,
},
}}
>
Add new
</ButtonTooltip>
</div>
) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? (
<Listbox
layout={layout}
@@ -267,11 +265,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
<div
className="flex items-center px-3 py-2 space-x-2 transition cursor-pointer group hover:bg-surface-300"
onClick={() => {
if (createPaymentMethodInline) {
setSetupNewPaymentMethod(true)
} else {
setShowAddNewPaymentMethodModal(true)
}
setSetupNewPaymentMethod(true)
}}
>
<Plus size={16} />
@@ -282,17 +276,39 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
</Listbox>
) : null}
{stripePromise && setupIntent && (
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
<NewPaymentMethodElement
ref={paymentRef}
email={selectedOrganization?.billing_email ?? undefined}
readOnly={readOnly}
/>
</Elements>
{stripePromise && setupIntent && customerProfile && (
<>
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
<NewPaymentMethodElement
ref={paymentRef}
email={selectedOrganization?.billing_email ?? undefined}
readOnly={readOnly}
customerName={customerProfile?.billing_name}
currentAddress={customerProfile?.address}
currentTaxId={taxId}
/>
</Elements>
{/* If the customer already has a billing address, optionally allow overwriting it - if they have no address, we use that as a default */}
{customerProfile?.address != null && (
<div className="flex items-center space-x-2 mt-4">
<Checkbox_Shadcn_
id="defaultBillingAddress"
checked={useAsDefaultBillingAddress}
onCheckedChange={() => setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)}
/>
<label
htmlFor="defaultBillingAddress"
className="text-sm leading-none text-foreground-light"
>
Use address as my org's billing address
</label>
</div>
)}
</>
)}
{setupIntentLoading && (
{(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
<div className="space-y-2">
<ShimmeringLoader className="h-10" />
<div className="grid grid-cols-2 gap-4">
@@ -303,26 +319,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
</div>
)}
</div>
<AddNewPaymentMethodModal
visible={showAddNewPaymentMethodModal}
returnUrl={`${getURL()}/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan&source=paymentMethod`}
onCancel={() => setShowAddNewPaymentMethodModal(false)}
autoMarkAsDefaultPaymentMethod={true}
onConfirm={async () => {
setShowAddNewPaymentMethodModal(false)
toast.success('Successfully added new payment method')
const { data: refetchedPaymentMethods } = await refetchPaymentMethods()
if (refetchedPaymentMethods?.data?.length) {
// Preselect the card that was just added
const mostRecentPaymentMethod = refetchedPaymentMethods?.data.reduce(
(prev, current) => (prev.created > current.created ? prev : current),
refetchedPaymentMethods.data[0]
)
onSelectPaymentMethod(mostRecentPaymentMethod.id)
}
}}
/>
</>
)
})
@@ -18,7 +18,10 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import type { OrgPlan } from 'data/subscriptions/types'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
useSelectedOrganization,
useSelectedOrganizationQuery,
} from 'hooks/misc/useSelectedOrganization'
import { formatCurrency } from 'lib/helpers'
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
@@ -26,14 +29,15 @@ import { Button, SidePanel, cn } from 'ui'
import DowngradeModal from './DowngradeModal'
import { EnterpriseCard } from './EnterpriseCard'
import { ExitSurveyModal } from './ExitSurveyModal'
import { useParams } from 'common'
import MembersExceedLimitModal from './MembersExceedLimitModal'
import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog'
import UpgradeSurveyModal from './UpgradeModal'
const PlanUpdateSidePanel = () => {
const router = useRouter()
const selectedOrganization = useSelectedOrganization()
const slug = selectedOrganization?.slug
const { slug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
const originalPlanRef = useRef<string>()
@@ -13,22 +13,23 @@ import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizatio
import { ProjectInfo } from 'data/projects/projects-query'
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
import { SubscriptionTier } from 'data/subscriptions/types'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS, STRIPE_PUBLIC_KEY } from 'lib/constants'
import { formatCurrency } from 'lib/helpers'
import { Badge, Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
import { Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
import { Admonition } from 'ui-patterns'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { BillingCustomerDataExistingOrgDialog } from '../BillingCustomerData/BillingCustomerDataExistingOrgDialog'
import PaymentMethodSelection from './PaymentMethodSelection'
import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change'
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
import { Elements } from '@stripe/react-stripe-js'
import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js'
import { useTheme } from 'next-themes'
import { PaymentIntentResult } from '@stripe/stripe-js'
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
import { plans as subscriptionsPlans } from 'shared-data/plans'
import type { PaymentMethodElementRef } from '../PaymentMethods/NewPaymentMethodElement'
import { useParams } from 'common'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -76,12 +77,12 @@ export const SubscriptionPlanUpdateDialog = ({
projects,
}: Props) => {
const { resolvedTheme } = useTheme()
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null)
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
const paymentMethodSelection = useRef<{
createPaymentMethod: () => Promise<PaymentMethod | undefined>
const paymentMethodSelectionRef = useRef<{
createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
}>(null)
const billingViaPartner = subscription?.billing_via_partner === true
@@ -161,18 +162,14 @@ export const SubscriptionPlanUpdateDialog = ({
setPaymentConfirmationLoading(true)
const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod()
if (paymentMethod) {
setSelectedPaymentMethod(paymentMethod.id)
const result = await paymentMethodSelectionRef.current?.createPaymentMethod()
if (result) {
setSelectedPaymentMethod(result.paymentMethod.id)
} else {
setPaymentConfirmationLoading(false)
}
if (
!paymentMethod &&
subscription?.payment_method_type !== 'invoice' &&
changeType === 'upgrade'
) {
if (!result && subscription?.payment_method_type !== 'invoice' && changeType === 'upgrade') {
return
}
@@ -185,7 +182,10 @@ export const SubscriptionPlanUpdateDialog = ({
updateOrgSubscription({
slug: selectedOrganization?.slug,
tier,
paymentMethod: paymentMethod?.id,
paymentMethod: result?.paymentMethod?.id,
address: result?.address,
tax_id: result?.taxId ?? undefined,
billing_name: result?.customerName ?? undefined,
})
}
@@ -246,13 +246,40 @@ export const SubscriptionPlanUpdateDialog = ({
{/* Left Column */}
<div className="p-8 pb-8 flex flex-col xl:col-span-3">
<div className="flex-1">
<h3 className="text-base mb-4">
{changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
<span className="font-bold">{selectedOrganization?.name}</span> to{' '}
{changeType === 'downgrade'
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
</h3>
<div>
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
<div className="space-y-2 mb-4">
<PaymentMethodSelection
ref={paymentMethodSelectionRef}
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
/>
</div>
)}
{billingViaPartner && (
<div className="mb-4">
<p className="text-sm">
This organization is billed through our partner{' '}
{billingPartnerLabel(billingPartner)}.{' '}
{billingPartner === 'aws' ? (
<>The organization's credit balance will be decreased accordingly.</>
) : (
<>You will be charged by them directly.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing
cycle.
</p>
)}
</div>
)}
</div>
{subscriptionPreviewIsLoading && (
<div className="space-y-2 mb-4 mt-2">
@@ -264,48 +291,6 @@ export const SubscriptionPlanUpdateDialog = ({
{subscriptionPreviewInitialized && (
<>
<div className="mt-2 mb-4 text-foreground-light text-sm">
<div className="flex items-center justify-between gap-2 border-b border-muted">
<div className="py-2 pl-0 flex items-center gap-1">
<span>{subscriptionPlanMeta?.name} Plan</span>
<Badge variant={'brand'} size={'small'} className="ml-1">
New
</Badge>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(newPlanCost)}
</div>
</div>
{subscription?.plan?.id !== 'free' && (
<div className="flex items-center justify-between gap-2 border-b border-muted">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Unused Time on {subscription?.plan?.name} Plan</span>
<InfoTooltip className="max-w-sm">
Your previous plan was charged upfront, so a plan change will prorate
any unused time in credits. If the prorated credits exceed the new plan
charge, the excessive credits are added to your organization for future
use.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
-{formatCurrency(proratedCredit)}
</div>
</div>
)}
{/* Ignore rare case with negative balance (debt) */}
{customerBalance > 0 && (
<div className="flex items-center justify-between gap-2 border-b border-muted">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Credits</span>
<InfoTooltip>
Credits will be used first before charging your card.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(customerBalance)}
</div>
</div>
)}
<div className="flex items-center justify-between gap-2 border-b border-muted text-foreground">
<div className="py-2 pl-0">Charge today</div>
<div className="py-2 pr-0 text-right" translate="no">
@@ -324,6 +309,39 @@ export const SubscriptionPlanUpdateDialog = ({
)}
</div>
</div>
{subscription?.plan?.id !== 'free' && (
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Unused Time on {subscription?.plan?.name} Plan</span>
<InfoTooltip className="max-w-sm">
Your previous plan was charged upfront, so a plan change will prorate
any unused time in credits. If the prorated credits exceed the new plan
charge, the excessive credits are added to your organization for future
use.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
-{formatCurrency(proratedCredit)}
</div>
</div>
)}
{/* Ignore rare case with negative balance (debt) */}
{customerBalance > 0 && (
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Credits</span>
<InfoTooltip>
Credits will be used first before charging your card.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(customerBalance)}
</div>
</div>
)}
<div className="flex items-center justify-between gap-2 text-foreground-lighter text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Monthly invoice estimate</span>
@@ -542,47 +560,11 @@ export const SubscriptionPlanUpdateDialog = ({
</div>
<div className="pt-4">
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
<div className="space-y-2 mb-4">
<BillingCustomerDataExistingOrgDialog />
<PaymentMethodSelection
ref={paymentMethodSelection}
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
createPaymentMethodInline={true}
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
/>
</div>
)}
{billingViaPartner && (
<div className="mb-4">
<p className="text-sm">
This organization is billed through our partner{' '}
{billingPartnerLabel(billingPartner)}.{' '}
{billingPartner === 'aws' ? (
<>The organization's credit balance will be decreased accordingly.</>
) : (
<>You will be charged by them directly.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing
cycle.
</p>
)}
</div>
)}
{projects.filter(
(it) =>
it.status === PROJECT_STATUS.ACTIVE_HEALTHY ||
it.status === PROJECT_STATUS.COMING_UP
).length === 0 &&
).length === 5 &&
subscriptionPreview?.plan_change_type !== 'downgrade' && (
<div className="pb-2">
<Admonition title="Empty organization" type="warning">
@@ -618,16 +600,13 @@ export const SubscriptionPlanUpdateDialog = ({
)}
<div className="flex space-x-2">
<Button type="default" size="medium" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
loading={isUpdating || paymentConfirmationLoading || isConfirming}
disabled={subscriptionPreviewIsLoading}
type="primary"
onClick={onUpdateSubscription}
className="flex-1"
size="medium"
size="small"
>
Confirm {changeType === 'downgrade' ? 'downgrade' : 'upgrade'}
</Button>
@@ -637,6 +616,13 @@ export const SubscriptionPlanUpdateDialog = ({
{/* Right Column */}
<div className="bg-surface-100 p-8 flex flex-col border-l xl:col-span-2">
<h3 className="text-base mb-8">
{changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
<span className="font-bold">{selectedOrganization?.name}</span> to{' '}
{changeType === 'downgrade'
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
</h3>
{changeType === 'downgrade'
? featuresToLose.length > 0 && (
<div className="mb-4">
@@ -11,20 +11,20 @@ import PartnerManagedResource from 'components/ui/PartnerManagedResource'
import { getInvoice } from 'data/invoices/invoice-query'
import { useInvoicesCountQuery } from 'data/invoices/invoices-count-query'
import { useInvoicesQuery } from 'data/invoices/invoices-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { formatCurrency } from 'lib/helpers'
import { ChevronLeft, ChevronRight, Download, FileText } from 'lucide-react'
import { Button } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import InvoicePayButton from './InvoicePayButton'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
const PAGE_LIMIT = 5
const InvoicesSettings = () => {
const [page, setPage] = useState(1)
const selectedOrganization = useSelectedOrganization()
const { slug } = selectedOrganization ?? {}
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const slug = selectedOrganization?.slug
const offset = (page - 1) * PAGE_LIMIT
const { data: count, isError: isErrorCount } = useInvoicesCountQuery(
@@ -33,7 +33,7 @@ const InvoicesSettings = () => {
},
{ enabled: selectedOrganization?.managed_by === 'supabase' }
)
const { data, error, isLoading, isError, isSuccess } = useInvoicesQuery(
const { data, error, isLoading, isError } = useInvoicesQuery(
{
slug,
offset,
@@ -32,8 +32,6 @@ import {
TooltipTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { BillingCustomerDataNewOrgDialog } from '../BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog'
import { FormCustomerData } from '../BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
import { useConfirmPendingSubscriptionCreateMutation } from 'data/subscriptions/org-subscription-confirm-pending-create'
import { loadStripe } from '@stripe/stripe-js'
import { useTheme } from 'next-themes'
@@ -41,7 +39,12 @@ import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
import { useProfile } from 'lib/profile'
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
import { NewPaymentMethodElement } from '../BillingSettings/PaymentMethods/NewPaymentMethodElement'
import {
NewPaymentMethodElement,
type PaymentMethodElementRef,
} from '../BillingSettings/PaymentMethods/NewPaymentMethodElement'
import { components } from 'api-types'
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
const ORG_KIND_TYPES = {
PERSONAL: 'Personal',
@@ -90,6 +93,8 @@ type FormState = z.infer<typeof formSchema>
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
const newMandatoryAddressInput = true
/**
* No org selected yet, create a new one
* [Joshen] Need to refactor to use Form_Shadcn here
@@ -115,8 +120,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
const [isOrgCreationConfirmationModalVisible, setIsOrgCreationConfirmationModalVisible] =
useState(false)
const [customerData, setCustomerData] = useState<FormCustomerData | null>(null)
const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
() =>
({
@@ -244,7 +247,14 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
} as StripeElementsOptions
}, [paymentIntentSecret, resolvedTheme])
async function createOrg(paymentMethodId?: string) {
async function createOrg(
paymentMethodId?: string,
customerData?: {
address: CustomerAddress | null
billing_name: string | null
tax_id: CustomerTaxId | null
}
) {
const dbTier = formState.plan === 'PRO' && !formState.spend_cap ? 'PAYG' : formState.plan
createOrganization({
@@ -258,12 +268,12 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
...(formState.kind == 'COMPANY' ? { size: formState.size } : {}),
payment_method: paymentMethodId,
billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name,
address: dbTier === 'FREE' ? undefined : customerData?.address,
address: dbTier === 'FREE' ? null : customerData?.address,
tax_id: dbTier === 'FREE' ? undefined : customerData?.tax_id ?? undefined,
})
}
const paymentRef = useRef<{ createPaymentMethod: () => Promise<any> }>(null)
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
const handleSubmit = async () => {
setNewOrgLoading(true)
@@ -271,10 +281,16 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
if (formState.plan === 'FREE') {
await createOrg()
} else if (!paymentMethod) {
const paymentMethod = await paymentRef.current?.createPaymentMethod()
if (paymentMethod) {
setPaymentMethod(paymentMethod)
createOrg(paymentMethod.id)
const result = await paymentRef.current?.createPaymentMethod()
if (result) {
setPaymentMethod(result.paymentMethod)
const customerData = {
address: result.address,
billing_name: result.customerName,
tax_id: result.taxId,
}
createOrg(result.paymentMethod.id, customerData)
} else {
setNewOrgLoading(false)
}
@@ -340,6 +356,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
</div>
</div>
}
className="overflow-visible"
>
<Panel.Content>
<p className="text-sm">This is your organization within Supabase.</p>
@@ -532,21 +549,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
</>
)}
{formState.plan !== 'FREE' && (
<Panel.Content className="border-b border-panel-border-interior-light dark:border-panel-border-interior-dark">
<div className="grid grid-cols-3">
<div className="col-span-1 flex space-x-2 text-sm items-center">
<Label_Shadcn_ htmlFor="spend-cap" className=" leading-normal">
Billing Address
</Label_Shadcn_>
</div>
<div className="col-span-2">
<BillingCustomerDataNewOrgDialog onCustomerDataChange={setCustomerData} />
</div>
</div>
</Panel.Content>
)}
{setupIntent && formState.plan !== 'FREE' && (
<Panel.Content>
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
+4
View File
@@ -66,6 +66,8 @@ const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu'
const PUSHER_URL = 'https://*.pusher.com'
const PUSHER_URL_WS = 'wss://*.pusher.com'
const GOOGLE_MAPS_API_URL = 'https://maps.googleapis.com'
module.exports.getCSP = function getCSP() {
const DEFAULT_SRC_URLS = [
API_URL,
@@ -86,6 +88,7 @@ module.exports.getCSP = function getCSP() {
SUPABASE_ASSETS_URL,
USERCENTRICS_URLS,
STAPE_URL,
GOOGLE_MAPS_API_URL,
]
const SCRIPT_SRC_URLS = [
CLOUDFLARE_CDN_URL,
@@ -144,6 +147,7 @@ module.exports.getCSP = function getCSP() {
...SCRIPT_SRC_URLS,
VERCEL_LIVE_URL,
PUSHER_URL,
GOOGLE_MAPS_API_URL,
].join(' ')
const frameSrcDirective = [`frame-src 'self'`, ...FRAME_SRC_URLS, VERCEL_LIVE_URL].join(' ')
@@ -1,12 +1,12 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { components } from 'data/api'
import { handleError, post } from 'data/fetchers'
import { permissionKeys } from 'data/permissions/keys'
import type { ResponseError } from 'types'
import { organizationKeys } from './keys'
import { castOrganizationResponseToOrganization } from './organizations-query'
import type { CustomerAddress, CustomerTaxId } from './types'
export type OrganizationCreateVariables = {
name: string
@@ -14,9 +14,9 @@ export type OrganizationCreateVariables = {
size?: string
tier: 'tier_payg' | 'tier_pro' | 'tier_free' | 'tier_team' | 'tier_enterprise'
payment_method?: string
billing_name?: string
address?: components['schemas']['CreateOrganizationBody']['address']
tax_id?: components['schemas']['CreateOrganizationBody']['tax_id']
billing_name?: string | null
address?: CustomerAddress | null
tax_id?: CustomerTaxId | null
}
export async function createOrganization({
@@ -36,9 +36,9 @@ export async function createOrganization({
size,
tier,
payment_method,
address,
billing_name,
tax_id,
billing_name: billing_name ?? undefined,
tax_id: tax_id ?? undefined,
address: address ?? undefined,
},
})
@@ -3,12 +3,16 @@ import { toast } from 'sonner'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import type { CustomerAddress, CustomerTaxId } from './types'
export type OrganizationCreditTopUpVariables = {
payment_method_id: string
amount: number
slug?: string
hcaptchaToken?: string | null
address?: CustomerAddress | null
tax_id?: CustomerTaxId | null
billing_name?: string | null
}
export async function topUpCredits({
@@ -16,6 +20,9 @@ export async function topUpCredits({
amount,
slug,
hcaptchaToken,
address,
tax_id,
billing_name,
}: OrganizationCreditTopUpVariables) {
if (!payment_method_id) {
throw new Error('Payment method id required')
@@ -35,7 +42,14 @@ export async function topUpCredits({
slug,
},
},
body: { payment_method_id, amount, hcaptcha_token: hcaptchaToken },
body: {
payment_method_id,
amount,
hcaptcha_token: hcaptchaToken,
address: address ?? undefined,
tax_id: tax_id ?? undefined,
billing_name: billing_name ?? undefined,
},
})
if (error) handleError(error)
@@ -1,14 +1,14 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { components } from 'api-types'
import { handleError, put } from 'data/fetchers'
import type { ResponseError } from 'types'
import { organizationKeys } from './keys'
import type { CustomerAddress } from './types'
export type OrganizationCustomerProfileUpdateVariables = {
slug?: string
address?: components['schemas']['CustomerResponse']['address']
address?: CustomerAddress
billing_name: string
}
+4
View File
@@ -0,0 +1,4 @@
import type { components } from 'api-types'
export type CustomerAddress = NonNullable<components['schemas']['CustomerResponse']['address']>
export type CustomerTaxId = NonNullable<components['schemas']['TaxIdResponse']['tax_id']>
@@ -7,17 +7,24 @@ import type { ResponseError } from 'types/base'
import { subscriptionKeys } from './keys'
import type { SubscriptionTier } from './types'
import { organizationKeys } from 'data/organizations/keys'
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
export type OrgSubscriptionUpdateVariables = {
slug: string
paymentMethod?: string
tier: SubscriptionTier
address?: CustomerAddress | null
tax_id?: CustomerTaxId | null
billing_name?: string
}
export async function updateOrgSubscription({
slug,
tier,
paymentMethod,
address,
tax_id,
billing_name,
}: OrgSubscriptionUpdateVariables) {
if (!slug) throw new Error('slug is required')
if (!tier) throw new Error('tier is required')
@@ -29,6 +36,9 @@ export async function updateOrgSubscription({
body: {
payment_method: payload.payment_method,
tier: payload.tier,
address: address ?? undefined,
tax_id: tax_id ?? undefined,
billing_name,
},
params: { path: { slug } },
})
@@ -10,10 +10,10 @@ import { useProjectByRef, useProjectByRefQuery } from './useSelectedProject'
* Example migration:
* ```
* // Old:
* const organization = useSelectedOrganization(ref)
* const organization = useSelectedOrganization()
*
* // New:
* const { data: organization } = useSelectedOrganizationQuery(ref)
* const { data: organization } = useSelectedOrganizationQuery()
* ```
*/
export function useSelectedOrganization({ enabled = true } = {}) {
+2 -2
View File
@@ -51,8 +51,8 @@
"@radix-ui/react-visually-hidden": "^1.1.3",
"@sentry/nextjs": "^8.52.1",
"@std/path": "npm:@jsr/std__path@^1.0.8",
"@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.5.0",
"@supabase/auth-js": "catalog:",
"@supabase/mcp-server-supabase": "^0.4.4",
"@supabase/mcp-utils": "^0.2.0",
-2
View File
@@ -47,7 +47,6 @@ import { getFlags as getConfigCatFlags } from 'lib/configcat'
import { API_URL, BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { ProfileProvider } from 'lib/profile'
import { Telemetry } from 'lib/telemetry'
import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store'
import { AppPropsWithLayout } from 'types'
import { SonnerToaster, TooltipProvider } from 'ui'
import { CommandProvider } from 'ui-patterns/CommandMenu'
@@ -140,7 +139,6 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
</RouteValidationWrapper>
</TooltipProvider>
<Telemetry />
{!isTestEnv && <HCaptchaLoadedStore />}
{!isTestEnv && (
<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
)}
+2 -4
View File
@@ -6,7 +6,6 @@ import AppLayout from 'components/layouts/AppLayout/AppLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import WizardLayout from 'components/layouts/WizardLayout'
import { SetupIntentResponse, useSetupIntent } from 'data/stripe/setup-intent-mutation'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import type { NextPageWithLayout } from 'types'
/**
@@ -14,7 +13,6 @@ import type { NextPageWithLayout } from 'types'
*/
const Wizard: NextPageWithLayout = () => {
const [intent, setIntent] = useState<SetupIntentResponse>()
const captchaLoaded = useIsHCaptchaLoaded()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
@@ -40,7 +38,7 @@ const Wizard: NextPageWithLayout = () => {
if (selectedPlan == null || selectedPlan === 'FREE') return
if (intent != null && !force) return
if (captchaRef && captchaLoaded) {
if (captchaRef) {
let token = captchaToken
try {
@@ -59,7 +57,7 @@ const Wizard: NextPageWithLayout = () => {
useEffect(() => {
loadPaymentForm()
}, [captchaRef, captchaLoaded, selectedPlan])
}, [captchaRef, selectedPlan])
const resetSetupIntent = () => {
setIntent(undefined)
@@ -1,31 +0,0 @@
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { proxy, useSnapshot } from 'valtio'
const hCaptchaLoadedStoreState = proxy({
loaded: false,
setLoaded() {
hCaptchaLoadedStoreState.loaded = true
},
})
export const useIsHCaptchaLoaded = () => {
const snap = useSnapshot(hCaptchaLoadedStoreState)
return snap.loaded
}
const HCaptchaLoadedStore = () => {
const onLoad = () => {
hCaptchaLoadedStoreState.setLoaded()
}
return (
<HCaptcha
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
size="invisible"
onLoad={onLoad}
/>
)
}
export default HCaptchaLoadedStore
+2 -1
View File
@@ -1603,7 +1603,7 @@ export interface components {
role: string
} | null
/** @enum {string|null} */
type?: 'publishable' | 'secret' | 'legacy' | null
type?: 'legacy' | 'publishable' | 'secret' | null
/** Format: date-time */
updated_at?: string | null
}
@@ -1984,6 +1984,7 @@ export interface components {
secrets?: {
[key: string]: string
}
with_data?: boolean
}
CreateOrganizationV1: {
name: string
+119 -51
View File
@@ -4282,12 +4282,12 @@ export interface components {
BillingCustomerUpdateBody: {
additional_emails?: string[]
address?: {
city?: string
city?: string | null
country: string
line1: string
line2?: string
postal_code?: string
state?: string
line2?: string | null
postal_code?: string | null
state?: string | null
}
billing_name?: string
}
@@ -4621,12 +4621,12 @@ export interface components {
}
CreateOrganizationBody: {
address?: {
city?: string
city?: string | null
country: string
line1: string
line2?: string
postal_code?: string
state?: string
line2?: string | null
postal_code?: string | null
state?: string | null
}
billing_name?: string
kind?: string
@@ -5027,9 +5027,23 @@ export interface components {
teamId?: string
}
CreditsTopUpRequest: {
address?: {
city?: string | null
country: string
line1: string
line2?: string | null
postal_code?: string | null
state?: string | null
}
amount: number
billing_name?: string
hcaptcha_token?: string
payment_method_id: string
tax_id?: {
country: string
type: string
value: string
}
}
CreditsTopUpResponse: {
payment_intent_secret?: string
@@ -5046,12 +5060,12 @@ export interface components {
CustomerResponse: {
additional_emails: string[] | null
address?: {
city?: string
city?: string | null
country: string
line1: string
line2?: string
postal_code?: string
state?: string
line2?: string | null
postal_code?: string | null
state?: string | null
}
balance: number
billing_name?: string
@@ -6536,6 +6550,7 @@ export interface components {
last4: string
}
created: number
has_address: boolean
id: string
is_default: boolean
type: string
@@ -8584,7 +8599,21 @@ export interface components {
fileSizeLimit?: number
}
UpdateSubscriptionBody: {
address?: {
city?: string | null
country: string
line1: string
line2?: string | null
postal_code?: string | null
state?: string | null
}
billing_name?: string
payment_method?: string
tax_id?: {
country: string
type: string
value: string
}
/** @enum {string} */
tier: 'tier_free' | 'tier_pro' | 'tier_payg' | 'tier_team' | 'tier_enterprise'
}
@@ -12635,7 +12664,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -12673,7 +12703,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -12715,7 +12746,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -12760,7 +12792,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -12798,7 +12831,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -12844,7 +12878,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -12884,7 +12919,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -12926,7 +12962,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -12964,7 +13001,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13008,7 +13046,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13051,7 +13090,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13092,7 +13132,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13130,7 +13171,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13174,7 +13216,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13214,7 +13257,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13264,7 +13308,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13305,7 +13350,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13343,7 +13389,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13387,7 +13434,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13427,7 +13475,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13469,7 +13518,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13507,7 +13557,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13551,7 +13602,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13591,7 +13643,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13633,7 +13686,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13737,7 +13791,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13775,7 +13830,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13819,7 +13875,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13859,7 +13916,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13909,7 +13967,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13947,7 +14006,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -13993,7 +14053,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -14033,7 +14094,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -14078,7 +14140,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -14116,7 +14179,8 @@ export interface operations {
query?: never
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -14160,7 +14224,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -14200,7 +14265,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -14245,7 +14311,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
@@ -14291,7 +14358,8 @@ export interface operations {
}
header: {
'x-connection-encrypted': string
'x-pg-application-name': string
/** @description PostgreSQL connection application name */
'x-pg-application-name'?: string
}
path: {
/** @description Project ref */
+12 -12
View File
@@ -733,11 +733,11 @@ importers:
specifier: npm:@jsr/std__path@^1.0.8
version: '@jsr/std__path@1.0.8'
'@stripe/react-stripe-js':
specifier: ^3.1.1
version: 3.1.1(@stripe/stripe-js@5.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: ^3.7.0
version: 3.7.0(@stripe/stripe-js@7.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@stripe/stripe-js':
specifier: ^5.5.0
version: 5.5.0
specifier: ^7.5.0
version: 7.5.0
'@supabase/auth-js':
specifier: 'catalog:'
version: 2.71.1-rc.1
@@ -8056,15 +8056,15 @@ packages:
'@stitches/core@1.2.8':
resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==}
'@stripe/react-stripe-js@3.1.1':
resolution: {integrity: sha512-+JzYFgUivVD7koqYV7LmLlt9edDMAwKH7XhZAHFQMo7NeRC+6D2JmQGzp9tygWerzwttwFLlExGp4rAOvD6l9g==}
'@stripe/react-stripe-js@3.7.0':
resolution: {integrity: sha512-PYls/2S9l0FF+2n0wHaEJsEU8x7CmBagiH7zYOsxbBlLIHEsqUIQ4MlIAbV9Zg6xwT8jlYdlRIyBTHmO3yM7kQ==}
peerDependencies:
'@stripe/stripe-js': ^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0
'@stripe/stripe-js': '>=1.44.1 <8.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/stripe-js@5.5.0':
resolution: {integrity: sha512-lkfjyAd34aeMpTKKcEVfy8IUyEsjuAT3t9EXr5yZDtdIUncnZpedl/xLV16Dkd4z+fQwixScsCCDxSMNtBOgpQ==}
'@stripe/stripe-js@7.5.0':
resolution: {integrity: sha512-Cq3KKe+G1o7PSBMbmrgpT2JgBeyH2THHr3RdIX2MqF7AnBuspIMgtZ3ktcCgP7kZsTMvnmWymr7zZCT1zeWbMw==}
engines: {node: '>=12.16'}
'@supabase/auth-js@2.71.1-rc.1':
@@ -26958,14 +26958,14 @@ snapshots:
'@stitches/core@1.2.8': {}
'@stripe/react-stripe-js@3.1.1(@stripe/stripe-js@5.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@stripe/react-stripe-js@3.7.0(@stripe/stripe-js@7.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@stripe/stripe-js': 5.5.0
'@stripe/stripe-js': 7.5.0
prop-types: 15.8.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@stripe/stripe-js@5.5.0': {}
'@stripe/stripe-js@7.5.0': {}
'@supabase/auth-js@2.71.1-rc.1':
dependencies:
+1
View File
@@ -61,6 +61,7 @@
"NEXT_PUBLIC_NODE_ENV",
"NEXT_PUBLIC_GOTRUE_URL",
"NEXT_PUBLIC_VERCEL_BRANCH_URL",
"NEXT_PUBLIC_GOOGLE_MAPS_KEY",
"NODE_ENV",
"SUPABASE_URL",
// These envs are used in the packages