mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -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,
|
||||
|
||||
-215
@@ -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 & 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+43
-35
@@ -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>
|
||||
|
||||
-122
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
-5
@@ -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
|
||||
|
||||
+2
-1
@@ -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" />
|
||||
|
||||
+321
-7
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
-1
@@ -176,7 +176,6 @@ const PaymentMethods = () => {
|
||||
setShowAddPaymentMethodModal(false)
|
||||
toast.success('Successfully added new payment method')
|
||||
}}
|
||||
showSetDefaultCheckbox={true}
|
||||
/>
|
||||
|
||||
<ChangePaymentMethodModal
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+2
-2
@@ -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 },
|
||||
|
||||
+99
-103
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
+7
-3
@@ -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>()
|
||||
|
||||
+92
-106
@@ -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">
|
||||
|
||||
+4
-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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
Vendored
+2
-1
@@ -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
@@ -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 */
|
||||
|
||||
Generated
+12
-12
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user