diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx new file mode 100644 index 00000000000..5b568e36437 --- /dev/null +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx @@ -0,0 +1,39 @@ +import { useStripe } from '@stripe/react-stripe-js' +import { PaymentIntentResult } from '@stripe/stripe-js' +import { useEffect } from 'react' +import { LoadingLine } from 'ui' + +export const PaymentConfirmation = ({ + paymentIntentSecret, + onPaymentIntentConfirm, + onLoadingChange, + paymentMethodId, + onError, +}: { + paymentIntentSecret: string + paymentMethodId: string + onPaymentIntentConfirm: (response: PaymentIntentResult) => void + onLoadingChange: (loading: boolean) => void + onError?: (error: Error) => void +}) => { + const stripe = useStripe() + + useEffect(() => { + if (stripe && paymentIntentSecret) { + onLoadingChange(true) + stripe! + .confirmCardPayment(paymentIntentSecret, { payment_method: paymentMethodId }) + .then((res) => { + onPaymentIntentConfirm(res) + onLoadingChange(false) + }) + .catch((err) => { + console.error(err) + onError?.(err) + onLoadingChange(false) + }) + } + }, [paymentIntentSecret, stripe]) + + return +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index eed17a4eb47..a4954c689e5 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -1,6 +1,6 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { zodResolver } from '@hookform/resolvers/zod' -import { Elements, useStripe } from '@stripe/react-stripe-js' +import { Elements } from '@stripe/react-stripe-js' import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js' import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' @@ -35,10 +35,10 @@ import { Form_Shadcn_, FormField_Shadcn_, Input_Shadcn_, - LoadingLine, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import PaymentMethodSelection from './Subscription/PaymentMethodSelection' +import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -331,34 +331,3 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { ) } - -const PaymentConfirmation = ({ - paymentIntentSecret, - onPaymentIntentConfirm, - onLoadingChange, - paymentMethodId, -}: { - paymentIntentSecret: string - paymentMethodId: string - onPaymentIntentConfirm: (response: PaymentIntentResult) => void - onLoadingChange: (loading: boolean) => void -}) => { - const stripe = useStripe() - - useEffect(() => { - if (stripe && paymentIntentSecret) { - onLoadingChange(true) - stripe! - .confirmCardPayment(paymentIntentSecret, { payment_method: paymentMethodId }) - .then((res) => { - onPaymentIntentConfirm(res) - onLoadingChange(false) - }) - .catch((err) => { - onLoadingChange(false) - }) - } - }, [paymentIntentSecret, stripe]) - - return -} diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 2f16d232697..8d52683c54e 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -1,11 +1,11 @@ -import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' -import type { PaymentMethod } from '@stripe/stripe-js' +import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import type { PaymentIntentResult, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' import _ from 'lodash' -import { Edit2, ExternalLink, HelpCircle } from 'lucide-react' +import { ExternalLink, HelpCircle } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { parseAsString, useQueryStates } from 'nuqs' -import { useEffect, useMemo, useState } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { z } from 'zod' @@ -16,9 +16,7 @@ import { useOrganizationCreateMutation } from 'data/organizations/organization-c import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants' -import { getURL } from 'lib/helpers' -import { useProfile } from 'lib/profile' +import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from 'lib/constants' import { Button, Input, @@ -37,6 +35,13 @@ import { import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { BillingCustomerDataNewOrgDialog } from '../BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog' import { FormCustomerData } from '../BillingSettings/BillingCustomerData/useBillingCustomerDataForm' +import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change' +import { loadStripe } from '@stripe/stripe-js' +import { useTheme } from 'next-themes' +import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation' +import { useProfile } from 'lib/profile' +import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' +import { getURL } from 'lib/helpers' const ORG_KIND_TYPES = { PERSONAL: 'Personal', @@ -59,6 +64,8 @@ const ORG_SIZE_DEFAULT = '1' interface NewOrgFormProps { onPaymentMethodReset: () => void + setupIntent?: SetupIntentResponse + onPlanSelected: (plan: string) => void } const formSchema = z.object({ @@ -79,17 +86,18 @@ const formSchema = z.object({ type FormState = z.infer +const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) + /** * No org selected yet, create a new one * [Joshen] Need to refactor to use Form_Shadcn here */ -const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { +const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOrgFormProps) => { const router = useRouter() const user = useProfile() const { data: organizations, isSuccess } = useOrganizationsQuery() const { data: projects } = useProjectsQuery() - const stripe = useStripe() - const elements = useElements() + const { resolvedTheme } = useTheme() const [lastVisitedOrganization] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, @@ -107,6 +115,21 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { const [customerData, setCustomerData] = useState(null) + const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo( + () => + ({ + clientSecret: setupIntent ? setupIntent.client_secret! : '', + appearance: { + theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', + labels: 'floating', + }, + ...(setupIntent?.pending_subscription_flow_enabled_for_creation === true + ? { paymentMethodCreation: 'manual' } + : {}), + }) as const, + [setupIntent, resolvedTheme] + ) + const [formState, setFormState] = useState({ plan: 'FREE', name: '', @@ -118,7 +141,6 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { const [searchParams] = useQueryStates({ returnTo: parseAsString.withDefault(''), auth_id: parseAsString.withDefault(''), - token: parseAsString.withDefault(''), }) const updateForm = (key: keyof FormState, value: unknown) => { @@ -132,7 +154,10 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { if (typeof name === 'string') updateForm('name', name) if (typeof kind === 'string') updateForm('kind', kind) - if (typeof plan === 'string') updateForm('plan', plan) + if (typeof plan === 'string') { + updateForm('plan', plan) + onPlanSelected(plan) + } if (typeof size === 'string') updateForm('size', size) if (typeof spend_cap === 'string') updateForm('spend_cap', spend_cap === 'true') }, [router.isReady]) @@ -147,26 +172,16 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { const [newOrgLoading, setNewOrgLoading] = useState(false) const [paymentMethod, setPaymentMethod] = useState() + const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false) + const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) const { mutate: createOrganization } = useOrganizationCreateMutation({ onSuccess: async (org) => { - const prefilledProjectName = user.profile?.username - ? user.profile.username + `'s Project` - : 'My Project' - - if (searchParams.returnTo && searchParams.auth_id) { - router.push( - `${searchParams.returnTo}?auth_id=${searchParams.auth_id}${ - searchParams.token && `&token=${searchParams.token}&org=${org.name}` - }`, - undefined, - { - shallow: false, - } - ) + if ('pending_payment_intent_secret' in org && org.pending_payment_intent_secret) { + setPaymentIntentSecret(org.pending_payment_intent_secret) } else { - router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`) + onOrganizationCreated(org as { slug: string }) } }, onError: (data) => { @@ -176,11 +191,59 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { }, }) + const { mutate: confirmPendingSubscriptionChange } = useConfirmPendingSubscriptionChangeMutation({ + onSuccess: (data) => { + if (data && 'slug' in data) { + onOrganizationCreated({ slug: data.slug }) + } + }, + }) + + const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => { + // Reset payment intent secret to ensure another attempt works as expected + setPaymentIntentSecret('') + + if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') { + await confirmPendingSubscriptionChange({ + payment_intent_id: paymentIntentConfirmation.paymentIntent.id, + name: formState.name, + kind: formState.kind, + size: formState.size, + }) + } else { + // If the payment intent is not successful, we reset the payment method and show an error + toast.error(`Could not confirm payment. Please try again or use a different card.`) + resetPaymentMethod() + setNewOrgLoading(false) + } + } + + const onOrganizationCreated = (org: { slug: string }) => { + const prefilledProjectName = user.profile?.username + ? user.profile.username + `'s Project` + : 'My Project' + + if (searchParams.returnTo && searchParams.auth_id) { + router.push(`${searchParams.returnTo}?auth_id=${searchParams.auth_id}`, undefined, { + shallow: false, + }) + } else { + router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`) + } + } + function validateOrgName(name: any) { const value = name ? name.trim() : '' return value.length >= 1 } + const stripeOptionsConfirm = useMemo(() => { + return { + clientSecret: paymentIntentSecret, + appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' }, + } as StripeElementsOptions + }, [paymentIntentSecret, resolvedTheme]) + async function createOrg(paymentMethodId?: string) { const dbTier = formState.plan === 'PRO' && !formState.spend_cap ? 'PAYG' : formState.plan @@ -201,35 +264,21 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { }) } - const handleSubmit = async () => { - if (!stripe || !elements) { - return console.error('Stripe.js has not loaded') - } + const paymentRef = useRef<{ createPaymentMethod: () => Promise }>(null) + const handleSubmit = async () => { setNewOrgLoading(true) if (formState.plan === 'FREE') { await createOrg() } else if (!paymentMethod) { - const { error, setupIntent } = await stripe.confirmSetup({ - elements, - redirect: 'if_required', - confirmParams: { - return_url: `${getURL()}/new`, - expand: ['payment_method'], - }, - }) - - if (error || !setupIntent.payment_method) { - toast.error(error?.message ?? ' Failed to save card details') + const paymentMethod = await paymentRef.current?.createPaymentMethod() + if (paymentMethod) { + setPaymentMethod(paymentMethod) + createOrg(paymentMethod.id) + } else { setNewOrgLoading(false) - return } - - const paymentMethodFromSetup = setupIntent.payment_method as PaymentMethod - - setPaymentMethod(paymentMethodFromSetup) - createOrg(paymentMethodFromSetup.id) } else { createOrg(paymentMethod.id) } @@ -269,7 +318,7 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { { if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`) else router.push('/organizations') @@ -416,7 +465,10 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { updateForm('plan', value)} + onValueChange={(value) => { + updateForm('plan', value) + onPlanSelected(value) + }} > @@ -496,41 +548,16 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { )} - {formState.plan !== 'FREE' && ( + {setupIntent && formState.plan !== 'FREE' && ( - {paymentMethod?.card !== undefined ? ( - - - - - - Expires: {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year} - - - - } - onClick={() => resetPaymentMethod()} - disabled={newOrgLoading} - className="hover:border-muted" - /> - - - ) : ( - - )} + + + )} @@ -606,8 +633,88 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { })} + + {stripePromise && paymentIntentSecret && paymentMethod && ( + + + paymentIntentConfirmed(paymentIntentConfirmation) + } + onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} + paymentMethodId={paymentMethod.id} + onError={(err) => { + toast.error(`Failed to confirm payment: ${err.message}`) + setNewOrgLoading(false) + resetPaymentMethod() + }} + /> + + )} ) } export default NewOrgForm + +/** + * Set up as a separate component, as we need any component using stripe/elements to be wrapped in Elements. + * + * If Elements is on a higher level, we risk losing all form state in case a payment fails. + */ +const Payment = forwardRef( + ( + { + pending_subscription_flow_enabled_for_creation, + }: { pending_subscription_flow_enabled_for_creation: boolean }, + ref + ) => { + const stripe = useStripe() + const elements = useElements() + + const createPaymentMethod = async () => { + if (!stripe || !elements) return + await elements.submit() + + if (pending_subscription_flow_enabled_for_creation) { + // To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment + const { error, paymentMethod } = await stripe.createPaymentMethod({ + elements, + }) + if (error || paymentMethod == null) { + toast.error(error?.message ?? ' Failed to process card details') + return + } + return paymentMethod + } else { + const { error, setupIntent } = await stripe.confirmSetup({ + elements, + redirect: 'if_required', + confirmParams: { + return_url: `${getURL()}/new`, + expand: ['payment_method'], + }, + }) + + if (error || !setupIntent.payment_method) { + toast.error(error?.message ?? ' Failed to save card details') + return + } + + return setupIntent.payment_method as PaymentMethod + } + } + + useImperativeHandle(ref, () => ({ + createPaymentMethod, + })) + + return ( + + + + ) + } +) + +Payment.displayName = 'Payment' diff --git a/apps/studio/data/organizations/organization-create-mutation.ts b/apps/studio/data/organizations/organization-create-mutation.ts index 4273e98b2d3..3c28c0a4941 100644 --- a/apps/studio/data/organizations/organization-create-mutation.ts +++ b/apps/studio/data/organizations/organization-create-mutation.ts @@ -62,21 +62,24 @@ export const useOrganizationCreateMutation = ({ (vars) => createOrganization(vars), { async onSuccess(data, variables, context) { - // [Joshen] We're manually updating the query client here as the org's subscription is - // created async, and the invalidation will happen too quick where the GET organizations - // endpoint will error out with a 500 since the subscription isn't created yet. - queryClient.setQueriesData( - { - queryKey: organizationKeys.list(), - exact: true, - }, - (prev: any) => { - if (!prev) return prev - return [...prev, castOrganizationResponseToOrganization(data)] - } - ) + if (data && !('pending_payment_intent_secret' in data)) { + // [Joshen] We're manually updating the query client here as the org's subscription is + // created async, and the invalidation will happen too quick where the GET organizations + // endpoint will error out with a 500 since the subscription isn't created yet. + queryClient.setQueriesData( + { + queryKey: organizationKeys.list(), + exact: true, + }, + (prev: any) => { + if (!prev) return prev + return [...prev, castOrganizationResponseToOrganization(data)] + } + ) + + await queryClient.invalidateQueries(permissionKeys.list()) + } - await queryClient.invalidateQueries(permissionKeys.list()) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/organizations/organization-project-claim-mutation.ts b/apps/studio/data/organizations/organization-project-claim-mutation.ts index 16d42a31571..25b29c20ea4 100644 --- a/apps/studio/data/organizations/organization-project-claim-mutation.ts +++ b/apps/studio/data/organizations/organization-project-claim-mutation.ts @@ -10,6 +10,7 @@ type OrganizationProjectClaimVariables = { } async function claimOrganizationProject({ slug, token }: OrganizationProjectClaimVariables) { + // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await post('/v1/organizations/{slug}/project-claim/{token}', { params: { path: { slug, token } }, }) diff --git a/apps/studio/data/organizations/organization-project-claim-query.ts b/apps/studio/data/organizations/organization-project-claim-query.ts index b02534661a2..f620074a041 100644 --- a/apps/studio/data/organizations/organization-project-claim-query.ts +++ b/apps/studio/data/organizations/organization-project-claim-query.ts @@ -1,7 +1,5 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { organizationKeys } from './keys' - -import { components } from 'api-types' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' @@ -10,8 +8,42 @@ type OrganizationProjectClaimVariables = { token: string } -export type OrganizationProjectClaimResponse = - components['schemas']['OrganizationProjectClaimResponse'] +// todo replace with API type once exposed again - components['schemas']['OrganizationProjectClaimResponse'] +export type OrganizationProjectClaimResponse = { + created_at: string + /** Format: uuid */ + created_by: string + expires_at: string + preview: { + errors: { + key: string + message: string + }[] + info: { + key: string + message: string + }[] + members_exceeding_free_project_limit: { + limit: number + name: string + }[] + /** @enum {string} */ + source_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' + target_organization_eligible: boolean | null + target_organization_has_free_project_slots: boolean | null + /** @enum {string|null} */ + target_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' | null + valid: boolean + warnings: { + key: string + message: string + }[] + } + project: { + name: string + ref: string + } +} async function getOrganizationProjectClaim( { slug, token }: OrganizationProjectClaimVariables, @@ -19,13 +51,14 @@ async function getOrganizationProjectClaim( ) { if (!slug || !token) throw new Error('Slug and token are required') + // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await get(`/v1/organizations/{slug}/project-claim/{token}`, { params: { path: { slug, token } }, signal, }) if (error) handleError(error) - return data + return data as OrganizationProjectClaimResponse } export type OrganizationProjectClaimData = Awaited> diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts new file mode 100644 index 00000000000..ed6065771c8 --- /dev/null +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts @@ -0,0 +1,92 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { organizationKeys } from 'data/organizations/keys' +import { permissionKeys } from 'data/permissions/keys' +import { castOrganizationResponseToOrganization } from 'data/organizations/organizations-query' +import type { components } from 'api-types' + +export type PendingSubscriptionChangeVariables = { + payment_intent_id: string + name: string + kind?: string + size?: string +} + +export async function confirmPendingSubscriptionChange({ + payment_intent_id, + name, + kind, + size, +}: PendingSubscriptionChangeVariables) { + const { data, error } = await post('/platform/organizations/confirm-subscription', { + body: { + payment_intent_id, + name, + kind, + size, + }, + }) + + if (error) handleError(error) + return data +} + +type PendingSubscriptionChangeData = Awaited> + +export const useConfirmPendingSubscriptionChangeMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + PendingSubscriptionChangeData, + ResponseError, + PendingSubscriptionChangeVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + PendingSubscriptionChangeData, + ResponseError, + PendingSubscriptionChangeVariables + >((vars) => confirmPendingSubscriptionChange(vars), { + async onSuccess(data, variables, context) { + // [Joshen] We're manually updating the query client here as the org's subscription is + // created async, and the invalidation will happen too quick where the GET organizations + // endpoint will error out with a 500 since the subscription isn't created yet. + queryClient.setQueriesData( + { + queryKey: organizationKeys.list(), + exact: true, + }, + (prev: any) => { + if (!prev) return prev + return [ + ...prev, + castOrganizationResponseToOrganization( + data as components['schemas']['OrganizationResponse'] + ), + ] + } + ) + + await queryClient.invalidateQueries(permissionKeys.list()) + + // todo replace plan in org + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to confirm payment: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/pages/new/index.tsx b/apps/studio/pages/new/index.tsx index 01395da9a8f..4c98990860f 100644 --- a/apps/studio/pages/new/index.tsx +++ b/apps/studio/pages/new/index.tsx @@ -1,7 +1,4 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' -import { Elements } from '@stripe/react-stripe-js' -import { loadStripe } from '@stripe/stripe-js' -import { useTheme } from 'next-themes' import { useCallback, useEffect, useState } from 'react' import { NewOrgForm } from 'components/interfaces/Organization' @@ -9,24 +6,21 @@ 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 { STRIPE_PUBLIC_KEY } from 'lib/constants' import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' import type { NextPageWithLayout } from 'types' -const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) - /** * No org selected yet, create a new one */ const Wizard: NextPageWithLayout = () => { - const { resolvedTheme } = useTheme() - const [intent, setIntent] = useState() const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) + const [selectedPlan, setSelectedPlan] = useState(null) + const { mutate: setupIntent } = useSetupIntent({ onSuccess: (res) => setIntent(res) }) const captchaRefCallback = useCallback((node: any) => { @@ -37,16 +31,15 @@ const Wizard: NextPageWithLayout = () => { if (!hcaptchaToken) return console.error('Hcaptcha token is required') // Force a reload of Elements, necessary for Stripe + // Also mitigates card testing to some extent as we generate a new captcha token setIntent(undefined) setupIntent({ hcaptchaToken }) } - const options = { - clientSecret: intent ? intent.client_secret : '', - appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' }, - } as const + const loadPaymentForm = async (force = false) => { + if (selectedPlan == null || selectedPlan === 'FREE') return + if (intent != null && !force) return - const loadPaymentForm = async () => { if (captchaRef && captchaLoaded) { let token = captchaToken @@ -66,10 +59,11 @@ const Wizard: NextPageWithLayout = () => { useEffect(() => { loadPaymentForm() - }, [captchaRef, captchaLoaded]) + }, [captchaRef, captchaLoaded, selectedPlan]) const resetSetupIntent = () => { - return loadPaymentForm() + setIntent(undefined) + return loadPaymentForm(true) } const onLocalCancel = () => { @@ -96,11 +90,11 @@ const Wizard: NextPageWithLayout = () => { }} /> - {intent && ( - - resetSetupIntent()} /> - - )} + resetSetupIntent()} + onPlanSelected={(plan) => setSelectedPlan(plan)} + /> > ) } diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 9cd8b1d107a..e1ec2f4d4ba 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -218,24 +218,6 @@ export interface paths { patch?: never trace?: never } - '/v1/organizations/{slug}/project-claim/{token}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Gets project details for the specified organization and claim token */ - get: operations['v1-get-organization-project-claim'] - put?: never - /** Claims project for the specified organization */ - post: operations['v1-claim-project-for-organization'] - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } '/v1/projects': { parameters: { query?: never @@ -386,7 +368,7 @@ export interface paths { /** Get project api keys */ get: operations['v1-get-project-api-keys'] put?: never - /** [Alpha] Creates a new API key for the project */ + /** [Beta] Creates a new API key for the project */ post: operations['createApiKey'] delete?: never options?: never @@ -401,15 +383,15 @@ export interface paths { path?: never cookie?: never } - /** [Alpha] Get API key */ + /** [Beta] Get API key */ get: operations['getApiKey'] put?: never post?: never - /** [Alpha] Deletes an API key for the project */ + /** [Beta] Deletes an API key for the project */ delete: operations['deleteApiKey'] options?: never head?: never - /** [Alpha] Updates an API key for the project */ + /** [Beta] Updates an API key for the project */ patch: operations['updateApiKey'] trace?: never } @@ -476,25 +458,6 @@ export interface paths { patch?: never trace?: never } - '/v1/projects/{ref}/claim-token': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Gets project claim token */ - get: operations['v1-get-project-claim-token'] - put?: never - /** Creates project claim token */ - post: operations['v1-create-project-claim-token'] - /** Revokes project claim token */ - delete: operations['v1-delete-project-claim-token'] - options?: never - head?: never - patch?: never - trace?: never - } '/v1/projects/{ref}/config/auth': { parameters: { query?: never @@ -1606,6 +1569,9 @@ export interface components { external_zoom_client_id: string | null external_zoom_enabled: boolean | null external_zoom_secret: string | null + hook_before_user_created_enabled: boolean | null + hook_before_user_created_secrets: string | null + hook_before_user_created_uri: string | null hook_custom_access_token_enabled: boolean | null hook_custom_access_token_secrets: string | null hook_custom_access_token_uri: string | null @@ -1865,14 +1831,6 @@ export interface components { CreateOrganizationV1: { name: string } - CreateProjectClaimTokenResponse: { - created_at: string - /** Format: uuid */ - created_by: string - expires_at: string - token: string - token_alias: string - } CreateProviderBody: { attribute_mapping?: { keys: { @@ -2292,41 +2250,6 @@ export interface components { /** @enum {string} */ token_type: 'Bearer' } - OrganizationProjectClaimResponse: { - created_at: string - /** Format: uuid */ - created_by: string - expires_at: string - preview: { - errors: { - key: string - message: string - }[] - info: { - key: string - message: string - }[] - members_exceeding_free_project_limit: { - limit: number - name: string - }[] - /** @enum {string} */ - source_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' - target_organization_eligible: boolean | null - target_organization_has_free_project_slots: boolean | null - /** @enum {string|null} */ - target_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' | null - valid: boolean - warnings: { - key: string - message: string - }[] - } - project: { - name: string - ref: string - } - } OrganizationResponseV1: { id: string name: string @@ -2368,13 +2291,6 @@ export interface components { jwt_secret?: string max_rows: number } - ProjectClaimTokenResponse: { - created_at: string - /** Format: uuid */ - created_by: string - expires_at: string - token_alias: string - } ProjectUpgradeEligibilityResponse: { current_app_version: string /** @enum {string} */ @@ -2668,6 +2584,9 @@ export interface components { external_zoom_client_id?: string | null external_zoom_enabled?: boolean | null external_zoom_secret?: string | null + hook_before_user_created_enabled?: boolean | null + hook_before_user_created_secrets?: string | null + hook_before_user_created_uri?: string | null hook_custom_access_token_enabled?: boolean | null hook_custom_access_token_secrets?: string | null hook_custom_access_token_uri?: string | null @@ -3676,62 +3595,6 @@ export interface operations { } } } - 'v1-get-organization-project-claim': { - parameters: { - query?: never - header?: never - path: { - /** @description Organization slug */ - slug: string - token: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['OrganizationProjectClaimResponse'] - } - } - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - 'v1-claim-project-for-organization': { - parameters: { - query?: never - header?: never - path: { - /** @description Organization slug */ - slug: string - token: string - } - cookie?: never - } - requestBody?: never - responses: { - 204: { - headers: { - [name: string]: unknown - } - content?: never - } - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } 'v1-list-all-projects': { parameters: { query?: never @@ -4370,88 +4233,6 @@ export interface operations { } } } - 'v1-get-project-claim-token': { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['ProjectClaimTokenResponse'] - } - } - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - 'v1-create-project-claim-token': { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['CreateProjectClaimTokenResponse'] - } - } - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - 'v1-delete-project-claim-token': { - parameters: { - query?: never - header?: never - path: { - /** @description Project ref */ - ref: string - } - cookie?: never - } - requestBody?: never - responses: { - 204: { - headers: { - [name: string]: unknown - } - content?: never - } - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } 'v1-get-auth-service-config': { parameters: { query?: never diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 4b9aa895269..ff4237991c3 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -1496,6 +1496,23 @@ export interface paths { patch?: never trace?: never } + '/platform/organizations/confirm-subscription': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Confirm subscription change and apply pending changes */ + post: operations['OrganizationsController_confirmSubscription'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/organizations/fly/{fly_organization_id}': { parameters: { query?: never @@ -4234,6 +4251,12 @@ export interface components { /** @default 0 */ recoveryTimeTarget?: number } + ConfirmSubscriptionChangeBody: { + kind?: string + name: string + payment_intent_id: string + size?: string + } CopyObjectBody: { from: string to: string @@ -4521,6 +4544,31 @@ export interface components { /** @enum {string} */ tier: 'tier_free' | 'tier_pro' | 'tier_payg' | 'tier_team' | 'tier_enterprise' } + CreateOrganizationResponse: + | { + pending_payment_intent_secret: string | null + } + | { + billing_email: string | null + id: number + is_owner: boolean + name: string + opt_in_tags: string[] + plan: { + /** @enum {string} */ + id: 'free' | 'pro' | 'team' | 'enterprise' + name: string + } + restriction_data: { + [key: string]: string + } | null + /** @enum {string|null} */ + restriction_status: 'grace_period' | 'grace_period_over' | 'restricted' | null + slug: string + stripe_customer_id: string | null + subscription_id: string | null + usage_billing_enabled: boolean + } CreatePipelineResponse: { id: number } @@ -5622,6 +5670,9 @@ export interface components { EXTERNAL_ZOOM_CLIENT_ID: string EXTERNAL_ZOOM_ENABLED: boolean EXTERNAL_ZOOM_SECRET: string + HOOK_BEFORE_USER_CREATED_ENABLED: boolean + HOOK_BEFORE_USER_CREATED_SECRETS: string + HOOK_BEFORE_USER_CREATED_URI: string HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: boolean HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: string HOOK_CUSTOM_ACCESS_TOKEN_URI: string @@ -7280,11 +7331,7 @@ export interface components { } SetupIntentResponse: { client_secret: string - payment_method: - | string - | { - [key: string]: unknown - } + pending_subscription_flow_enabled_for_creation: boolean } SignedUrlResponse: { signedUrl: string @@ -7819,6 +7866,9 @@ export interface components { EXTERNAL_ZOOM_CLIENT_ID?: string | null EXTERNAL_ZOOM_ENABLED?: boolean | null EXTERNAL_ZOOM_SECRET?: string | null + HOOK_BEFORE_USER_CREATED_ENABLED?: boolean | null + HOOK_BEFORE_USER_CREATED_SECRETS?: string | null + HOOK_BEFORE_USER_CREATED_URI?: string | null HOOK_CUSTOM_ACCESS_TOKEN_ENABLED?: boolean | null HOOK_CUSTOM_ACCESS_TOKEN_SECRETS?: string | null HOOK_CUSTOM_ACCESS_TOKEN_URI?: string | null @@ -7929,6 +7979,9 @@ export interface components { URI_ALLOW_LIST?: string | null } UpdateGoTrueConfigHooksBody: { + HOOK_BEFORE_USER_CREATED_ENABLED?: boolean | null + HOOK_BEFORE_USER_CREATED_SECRETS?: string | null + HOOK_BEFORE_USER_CREATED_URI?: string | null HOOK_CUSTOM_ACCESS_TOKEN_ENABLED?: boolean | null HOOK_CUSTOM_ACCESS_TOKEN_SECRETS?: string | null HOOK_CUSTOM_ACCESS_TOKEN_URI?: string | null @@ -10151,7 +10204,7 @@ export interface operations { [name: string]: unknown } content: { - 'application/json': components['schemas']['OrganizationResponse'] + 'application/json': components['schemas']['CreateOrganizationResponse'] } } /** @description Unexpected error creating an organization */ @@ -11984,6 +12037,36 @@ export interface operations { } } } + OrganizationsController_confirmSubscription: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['ConfirmSubscriptionChangeBody'] + } + } + responses: { + 201: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['CreateOrganizationResponse'] + } + } + /** @description Failed to confirm subscription changes */ + 500: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } OrganizationsController_getOrganizationByFlyOrganizationId: { parameters: { query?: never
- Expires: {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year} -