diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 4aa7b3c146983..4a367b66d2006 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -27,6 +27,7 @@ Chris Chandler Chris Copplestone Chris Gwilliams Chris Stockton +Chris Ward Craig Cannon Danny White Darren Cunningham @@ -54,6 +55,7 @@ Inian P Ivan Vasilov Jenny Kibiri Jess Shears +Jim Chanco Jr John Pena Jon M Jonny Summers-Muir diff --git a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx index d24f02b803674..e4feb1d958f81 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx @@ -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() const selectedOrganization = useSelectedOrganization() - const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(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} /> diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index cb470dcb3e574..0171d9f56109e 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -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(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 ( + +
+ + + + + + +
+
+ ) + } + return (
- - {showSetDefaultCheckbox && ( -
- { - if (typeof checked === 'boolean') { - setIsDefault(checked) - } - }} - /> - - Save as default payment method - -
- )} + +
+ { + if (typeof checked === 'boolean') { + setIsDefaultPaymentMethod(checked) + } + }} + /> + + Save as default payment method + +
+ +
+ { + if (typeof checked === 'boolean') { + setIsPrimaryBillingAddress(checked) + } + }} + /> + + Use the billing address as my organization's primary address + +
diff --git a/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts b/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts index 51f095f2e3a33..ff8b08bbc7dc0 100644 --- a/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts +++ b/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts @@ -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', + }, }, } } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx index 5082e90df9948..a137720baa988 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx @@ -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>( () => ({ - ...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, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx deleted file mode 100644 index 9fac673f8fcb9..0000000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx +++ /dev/null @@ -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 ( - <> -
- - {!canReadBillingCustomerData ? ( - - ) : ( - <> - {(isLoading || isLoadingTaxId) && ( -
- -
- )} - {(error || errorTaxId) && ( - - )} - {isSuccess && isSuccessTaxId && ( -
-

{getAddressSummary()}

- -
- )} - - )} -
- - { - if (!value) setOpen(false) - else setOpen(true) - }} - > - - - Billing Address & Tax Id - - This will be reflected in every upcoming invoice, past invoices are not affected - - - -
- - - - {!canUpdateBillingCustomerData && ( - - You need additional permissions to manage this organization's billing address - - )} -
- - -
-
- - -
-
- - ) -} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx index 7b7a8da133396..98e84f71649c2 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx @@ -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 (
( - + - + @@ -112,9 +118,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="line1" render={({ field }: { field: any }) => ( - + - + @@ -125,9 +131,13 @@ export const BillingCustomerDataForm = ({ control={form.control} name="line2" render={({ field }: { field: any }) => ( - + - + @@ -139,7 +149,7 @@ export const BillingCustomerDataForm = ({ control={form.control} name="country" render={({ field }: { field: any }) => ( - + @@ -207,9 +217,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="postal_code" render={({ field }: { field: any }) => ( - + - + @@ -222,9 +232,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="city" render={({ field }: { field: any }) => ( - + - + @@ -234,9 +244,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="state" render={({ field }: { field: any }) => ( - + - + @@ -281,26 +291,24 @@ export const BillingCustomerDataForm = ({ No tax ID found. - {TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map( - (option) => ( - { - onSelectTaxIdType(option.name) - setShowTaxIDsPopover(false) - }} - > - - {option.country} - {option.name} - - ) - )} + {availableTaxIds.map((option) => ( + { + onSelectTaxIdType(option.name) + setShowTaxIDsPopover(false) + }} + > + + {option.country} - {option.name} + + ))} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx deleted file mode 100644 index dd072469401f1..0000000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx +++ /dev/null @@ -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 ( -
- - { - if (!value) handleDialogClose() - else setOpen(true) - }} - > - - - - - - Billing Address & Tax Id - - - {/* - [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 - */} - -
- { - 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() - } - }} - > - - -
- - -
-
- - -
-
-
-
- ) -} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts index dcc09c8e795d6..290af4077ed1f 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts @@ -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 diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts index aef1e28637734..176a3e4f19d57 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts @@ -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 } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index 341df59773789..9141ad9efc647 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -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 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(null) const [captchaRef, setCaptchaRef] = useState(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() @@ -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 }) => { /> Top Up Credits - - 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. + +

+ 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. +

+

+ For larger discounted credit packages, please{' '} + + reach out. + +

@@ -252,7 +273,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { name="paymentMethod" render={() => ( form.setValue('paymentMethod', pm)} selectedPaymentMethod={form.getValues('paymentMethod')} readOnly={executingTopUp || paymentConfirmationLoading} @@ -260,16 +281,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { )} /> -

- For larger discounted credit packages, please{' '} - - reach out. - -

- {paymentIntentConfirmation && paymentIntentConfirmation.error && ( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx index 9070143191fbe..11a41994047cb 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx @@ -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 + +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({ + 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 => { + 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 ( - +
+

+ Please ensure CVC and postal codes match what is on file for your card. +

+ + + + {fullyLoaded && ( +
+ setPurchasingAsBusiness(!purchasingAsBusiness)} + /> + +
+ )} + + setStripeAddress(evt.value)} + onReady={() => setFullyLoaded(true)} + /> + + {purchasingAsBusiness && availableTaxIds.length > 0 && ( +
+
+ ( + + + + + + + + + + + + No tax ID found. + + {availableTaxIds.map((option) => ( + { + onSelectTaxIdType(option.name) + setShowTaxIDsPopover(false) + }} + > + + {option.country} - {option.name} + + ))} + + + + + + + + )} + /> + + {selectedTaxId && ( + ( + + + + + + + )} + /> + )} +
+
+ )} +
) } ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx index 15b85fa5ecf4b..93e42b96b5179 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx @@ -176,7 +176,6 @@ const PaymentMethods = () => { setShowAddPaymentMethodModal(false) toast.success('Successfully added new payment method') }} - showSetDefaultCheckbox={true} /> { - const selectedOrganization = useSelectedOrganization() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const orgSlug = selectedOrganization?.slug const features = pickFeatures(plan, billingPartner) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx index 44ea19292d208..510d5a1b8c292 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx @@ -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 }, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 3754491fbd168..b775173a3c83d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -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(null) const [captchaRef, setCaptchaRef] = useState(null) const [setupIntent, setSetupIntent] = useState(undefined) + const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true) const { resolvedTheme } = useTheme() - const paymentRef = useRef<{ createPaymentMethod: () => Promise }>(null) + const paymentRef = useRef(null) const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState(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(

Retrieving payment methods

- ) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? ( -
-
- -

No payment methods

-
- - } - onClick={() => { - if (createPaymentMethodInline) { - setSetupNewPaymentMethod(true) - } else { - setShowAddNewPaymentMethodModal(true) - } - }} - htmlType="button" - tooltip={{ - content: { - side: 'bottom', - text: !canUpdatePaymentMethods ? ( -
- - You need additional permissions to add new payment methods to this - organization - -
- ) : undefined, - }, - }} - > - Add new -
-
) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? ( { - if (createPaymentMethodInline) { - setSetupNewPaymentMethod(true) - } else { - setShowAddNewPaymentMethodModal(true) - } + setSetupNewPaymentMethod(true) }} > @@ -282,17 +276,39 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( ) : null} - {stripePromise && setupIntent && ( - - - + {stripePromise && setupIntent && customerProfile && ( + <> + + + + + {/* 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 && ( +
+ setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)} + /> + +
+ )} + )} - {setupIntentLoading && ( + {(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
@@ -303,26 +319,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
)}
- - 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) - } - }} - /> ) }) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index a91fa6368eb39..e8c1082ff4e08 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -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() diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 4b180b4886291..88892c72e4ba7 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -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() const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) - const paymentMethodSelection = useRef<{ - createPaymentMethod: () => Promise + 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 */}
-

- {changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} - {selectedOrganization?.name} to{' '} - {changeType === 'downgrade' - ? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default'] - : PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']} -

+
+ {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && ( +
+ setSelectedPaymentMethod(pm)} + readOnly={paymentConfirmationLoading || isConfirming || isUpdating} + /> +
+ )} + + {billingViaPartner && ( +
+

+ 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. + )} +

+ {billingViaPartner && + billingPartner === 'fly' && + subscriptionPreview?.plan_change_type === 'downgrade' && ( +

+ Your organization will be downgraded at the end of your current billing + cycle. +

+ )} +
+ )} +
{subscriptionPreviewIsLoading && (
@@ -264,19 +291,27 @@ export const SubscriptionPlanUpdateDialog = ({ {subscriptionPreviewInitialized && ( <>
-
-
- {subscriptionPlanMeta?.name} Plan - - New - -
+
+
Charge today
- {formatCurrency(newPlanCost)} + {formatCurrency(totalCharge)} + {subscription?.plan?.id !== 'free' && ( + <> + {' '} + + + current spend + + + )}
+ {subscription?.plan?.id !== 'free' && ( -
+
Unused Time on {subscription?.plan?.name} Plan @@ -294,7 +329,7 @@ export const SubscriptionPlanUpdateDialog = ({ {/* Ignore rare case with negative balance (debt) */} {customerBalance > 0 && ( -
+
Credits @@ -306,24 +341,7 @@ export const SubscriptionPlanUpdateDialog = ({
)} -
-
Charge today
-
- {formatCurrency(totalCharge)} - {subscription?.plan?.id !== 'free' && ( - <> - {' '} - - + current spend - - - )} -
-
+
Monthly invoice estimate @@ -542,47 +560,11 @@ export const SubscriptionPlanUpdateDialog = ({
- {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && ( -
- - - setSelectedPaymentMethod(pm)} - createPaymentMethodInline={true} - readOnly={paymentConfirmationLoading || isConfirming || isUpdating} - /> -
- )} - - {billingViaPartner && ( -
-

- 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. - )} -

- {billingViaPartner && - billingPartner === 'fly' && - subscriptionPreview?.plan_change_type === 'downgrade' && ( -

- Your organization will be downgraded at the end of your current billing - cycle. -

- )} -
- )} - {projects.filter( (it) => it.status === PROJECT_STATUS.ACTIVE_HEALTHY || it.status === PROJECT_STATUS.COMING_UP - ).length === 0 && + ).length === 5 && subscriptionPreview?.plan_change_type !== 'downgrade' && (
@@ -618,16 +600,13 @@ export const SubscriptionPlanUpdateDialog = ({ )}
- @@ -637,6 +616,13 @@ export const SubscriptionPlanUpdateDialog = ({ {/* Right Column */}
+

+ {changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} + {selectedOrganization?.name} to{' '} + {changeType === 'downgrade' + ? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default'] + : PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']} +

{changeType === 'downgrade' ? featuresToLose.length > 0 && (
diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index 2001d66138505..e70d9a7ab87d2 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -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, diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 9721137b0bb86..8128ed29eb481 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -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 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(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 }>(null) + const paymentRef = useRef(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
} + className="overflow-visible" >

This is your organization within Supabase.

@@ -532,21 +549,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr )} - {formState.plan !== 'FREE' && ( - -
-
- - Billing Address - -
-
- -
-
-
- )} - {setupIntent && formState.plan !== 'FREE' && ( diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index 8f6d7571d4722..86f999ecd7f9c 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -229,19 +229,21 @@ export function LogsSidebarMenuV2() { return (
- Coming soon} - title="New logs" - description="Get early access" - actions={ - - - - } - /> + {IS_PLATFORM && ( + Coming soon} + title="New logs" + description="Get early access" + actions={ + + + + } + /> + )} {isUnifiedLogsPreviewAvailable && ( +export type CustomerTaxId = NonNullable diff --git a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts index d2806ab227bda..e819419678182 100644 --- a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -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 } }, }) diff --git a/apps/studio/hooks/misc/useSelectedOrganization.ts b/apps/studio/hooks/misc/useSelectedOrganization.ts index e6c789d5700a1..d937a06f8ed79 100644 --- a/apps/studio/hooks/misc/useSelectedOrganization.ts +++ b/apps/studio/hooks/misc/useSelectedOrganization.ts @@ -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 } = {}) { diff --git a/apps/studio/package.json b/apps/studio/package.json index 038669dd94734..c2545b712f2e3 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index bbf227f10cbf3..671af91dc794e 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -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) { - {!isTestEnv && } {!isTestEnv && ( )} diff --git a/apps/studio/pages/new/index.tsx b/apps/studio/pages/new/index.tsx index 4c98990860ff3..b8c9cf7653372 100644 --- a/apps/studio/pages/new/index.tsx +++ b/apps/studio/pages/new/index.tsx @@ -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() - const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(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) diff --git a/apps/studio/pages/project/[ref]/logs/index.tsx b/apps/studio/pages/project/[ref]/logs/index.tsx index 8454db4cfaae9..26217594cb81e 100644 --- a/apps/studio/pages/project/[ref]/logs/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/index.tsx @@ -9,6 +9,7 @@ import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { IS_PLATFORM } from 'lib/constants' import type { NextPageWithLayout } from 'types' export const LogPage: NextPageWithLayout = () => { @@ -33,9 +34,9 @@ export const LogPage: NextPageWithLayout = () => { // Handle redirects when unified logs preview flag changes useEffect(() => { // Only handle redirects if we're currently on a logs page - if (!router.asPath.includes('/logs') || !hasLoaded) return + if (!router.asPath.includes('/logs') || (IS_PLATFORM && !hasLoaded)) return - if (isUnifiedLogsEnabled) { + if (IS_PLATFORM && isUnifiedLogsEnabled) { // If unified logs preview is enabled and we're not already on the main logs page if (router.asPath !== `/project/${ref}/logs` && router.asPath.includes('/logs/')) { router.push(`/project/${ref}/logs`) diff --git a/apps/studio/stores/hcaptcha-loaded-store.tsx b/apps/studio/stores/hcaptcha-loaded-store.tsx deleted file mode 100644 index 008053d20b0f6..0000000000000 --- a/apps/studio/stores/hcaptcha-loaded-store.tsx +++ /dev/null @@ -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 ( - - ) -} - -export default HCaptchaLoadedStore diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 0f93564023b48..1a901f0e60de5 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -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 diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 00a3b169b1aba..b34c046fe0963 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -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 */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a239e511204cd..de23cd1f79cf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/turbo.json b/turbo.json index fe75e665de77c..0e9c09686f08d 100644 --- a/turbo.json +++ b/turbo.json @@ -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