2
0

🚸 (billing) Add precheckout form

Collects required company name and email and create the customer before redirecting to checkout
This commit is contained in:
Baptiste Arnaud
2023-03-06 11:30:01 +01:00
parent c1a636b965
commit 26e5d9c282
5 changed files with 161 additions and 34 deletions

View File

@ -18,6 +18,8 @@ export const createCheckoutSession = authenticatedProcedure
}) })
.input( .input(
z.object({ z.object({
email: z.string(),
company: z.string(),
workspaceId: z.string(), workspaceId: z.string(),
prefilledEmail: z.string().optional(), prefilledEmail: z.string().optional(),
currency: z.enum(['usd', 'eur']), currency: z.enum(['usd', 'eur']),
@ -35,8 +37,9 @@ export const createCheckoutSession = authenticatedProcedure
.mutation( .mutation(
async ({ async ({
input: { input: {
email,
company,
workspaceId, workspaceId,
prefilledEmail,
currency, currency,
plan, plan,
returnUrl, returnUrl,
@ -69,11 +72,21 @@ export const createCheckoutSession = authenticatedProcedure
apiVersion: '2022-11-15', apiVersion: '2022-11-15',
}) })
const customer = await stripe.customers.create({
email,
name: company,
metadata: { workspaceId },
})
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
success_url: `${returnUrl}?stripe=${plan}&success=true`, success_url: `${returnUrl}?stripe=${plan}&success=true`,
cancel_url: `${returnUrl}?stripe=cancel`, cancel_url: `${returnUrl}?stripe=cancel`,
allow_promotion_codes: true, allow_promotion_codes: true,
customer_email: prefilledEmail, customer: customer.id,
customer_update: {
address: 'auto',
name: 'auto',
},
mode: 'subscription', mode: 'subscription',
metadata: { workspaceId, plan, additionalChats, additionalStorage }, metadata: { workspaceId, plan, additionalChats, additionalStorage },
currency, currency,

View File

@ -134,6 +134,8 @@ test('plan changes should work', async ({ page }) => {
await page.click('button >> text="4"') await page.click('button >> text="4"')
await expect(page.locator('text="$73"')).toBeVisible() await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Upgrade >> nth=0') await page.click('button >> text=Upgrade >> nth=0')
await page.getByLabel('Company name').fill('Company LLC')
await page.getByRole('button', { name: 'Go to checkout' }).click()
await page.waitForNavigation() await page.waitForNavigation()
expect(page.url()).toContain('https://checkout.stripe.com') expect(page.url()).toContain('https://checkout.stripe.com')
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible() await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible()

View File

@ -7,8 +7,9 @@ import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
import { guessIfUserIsEuropean } from 'utils/pricing' import { guessIfUserIsEuropean } from 'utils/pricing'
import { useRouter } from 'next/router'
import { Workspace } from 'models' import { Workspace } from 'models'
import { PreCheckoutModal, PreCheckoutModalProps } from '../PreCheckoutModal'
import { useState } from 'react'
type Props = { type Props = {
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'> workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'>
@ -16,25 +17,15 @@ type Props = {
} }
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => { export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
const router = useRouter()
const { user } = useUser() const { user } = useUser()
const { showToast } = useToast() const { showToast } = useToast()
const [preCheckoutPlan, setPreCheckoutPlan] =
useState<PreCheckoutModalProps['selectedSubscription']>()
const { data } = trpc.billing.getSubscription.useQuery({ const { data } = trpc.billing.getSubscription.useQuery({
workspaceId: workspace.id, workspaceId: workspace.id,
}) })
const { mutate: createCheckoutSession, isLoading: isCreatingCheckout } =
trpc.billing.createCheckoutSession.useMutation({
onError: (error) => {
showToast({
description: error.message,
})
},
onSuccess: ({ checkoutUrl }) => {
router.push(checkoutUrl)
},
})
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } = const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
trpc.billing.updateSubscription.useMutation({ trpc.billing.updateSubscription.useMutation({
onError: (error) => { onError: (error) => {
@ -79,15 +70,20 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
if (workspace.stripeId) { if (workspace.stripeId) {
updateSubscription(newSubscription) updateSubscription(newSubscription)
} else { } else {
createCheckoutSession({ setPreCheckoutPlan(newSubscription)
...newSubscription,
returnUrl: window.location.href,
})
} }
} }
return ( return (
<Stack spacing={6}> <Stack spacing={6}>
{!workspace.stripeId && (
<PreCheckoutModal
selectedSubscription={preCheckoutPlan}
existingEmail={user?.email ?? undefined}
existingCompany={user?.company ?? undefined}
onClose={() => setPreCheckoutPlan(undefined)}
/>
)}
<HStack alignItems="stretch" spacing="4" w="full"> <HStack alignItems="stretch" spacing="4" w="full">
<StarterPlanContent <StarterPlanContent
initialChatsLimitIndex={ initialChatsLimitIndex={
@ -103,7 +99,7 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
onPayClick={(props) => onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER }) handlePayClick({ ...props, plan: Plan.STARTER })
} }
isLoading={isCreatingCheckout || isUpdatingSubscription} isLoading={isUpdatingSubscription}
currency={data?.subscription.currency} currency={data?.subscription.currency}
/> />
@ -119,7 +115,7 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
: 0 : 0
} }
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })} onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
isLoading={isCreatingCheckout || isUpdatingSubscription} isLoading={isUpdatingSubscription}
currency={data?.subscription.currency} currency={data?.subscription.currency}
/> />
</HStack> </HStack>

View File

@ -0,0 +1,111 @@
import { TextInput } from '@/components/inputs'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalOverlay,
Stack,
} from '@chakra-ui/react'
import { useRouter } from 'next/router'
import React, { FormEvent, useState } from 'react'
import { isDefined } from 'utils'
export type PreCheckoutModalProps = {
selectedSubscription:
| {
plan: 'STARTER' | 'PRO'
workspaceId: string
additionalChats: number
additionalStorage: number
currency: 'eur' | 'usd'
}
| undefined
existingCompany?: string
existingEmail?: string
onClose: () => void
}
export const PreCheckoutModal = ({
selectedSubscription,
existingCompany,
existingEmail,
onClose,
}: PreCheckoutModalProps) => {
const router = useRouter()
const { showToast } = useToast()
const { mutate: createCheckoutSession, isLoading: isCreatingCheckout } =
trpc.billing.createCheckoutSession.useMutation({
onError: (error) => {
showToast({
description: error.message,
})
},
onSuccess: ({ checkoutUrl }) => {
router.push(checkoutUrl)
},
})
const [customer, setCustomer] = useState({
company: existingCompany ?? '',
email: existingEmail ?? '',
})
const updateCustomerCompany = (company: string) => {
setCustomer((customer) => ({ ...customer, company }))
}
const updateCustomerEmail = (email: string) => {
setCustomer((customer) => ({ ...customer, email }))
}
const createCustomer = (e: FormEvent) => {
e.preventDefault()
if (!selectedSubscription) return
createCheckoutSession({
...selectedSubscription,
email: customer.email,
company: customer.company,
returnUrl: window.location.href,
})
}
return (
<Modal isOpen={isDefined(selectedSubscription)} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalBody py="8">
<Stack as="form" onSubmit={createCustomer} spacing="4">
<TextInput
isRequired
label="Company name"
defaultValue={customer.company}
onChange={updateCustomerCompany}
withVariableButton={false}
debounceTimeout={0}
/>
<TextInput
isRequired
type="email"
label="Email"
defaultValue={customer.email}
onChange={updateCustomerEmail}
withVariableButton={false}
debounceTimeout={0}
/>
<Button
type="submit"
isLoading={isCreatingCheckout}
colorScheme="blue"
isDisabled={customer.company === '' || customer.email === ''}
>
Go to checkout
</Button>
</Stack>
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@ -1,8 +1,11 @@
import { Seo } from '@/components/Seo' import { Seo } from '@/components/Seo'
import { useUser } from '@/features/account' import { useUser } from '@/features/account'
import {
PreCheckoutModal,
PreCheckoutModalProps,
} from '@/features/billing/components/PreCheckoutModal'
import { TypebotDndProvider, FolderContent } from '@/features/folders' import { TypebotDndProvider, FolderContent } from '@/features/folders'
import { useWorkspace } from '@/features/workspace' import { useWorkspace } from '@/features/workspace'
import { trpc } from '@/lib/trpc'
import { Stack, VStack, Spinner, Text } from '@chakra-ui/react' import { Stack, VStack, Spinner, Text } from '@chakra-ui/react'
import { Plan } from 'db' import { Plan } from 'db'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -12,15 +15,11 @@ import { DashboardHeader } from './DashboardHeader'
export const DashboardPage = () => { export const DashboardPage = () => {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { query, push } = useRouter() const { query } = useRouter()
const { user } = useUser() const { user } = useUser()
const { workspace } = useWorkspace() const { workspace } = useWorkspace()
const { mutate: createCheckoutSession } = const [preCheckoutPlan, setPreCheckoutPlan] =
trpc.billing.createCheckoutSession.useMutation({ useState<PreCheckoutModalProps['selectedSubscription']>()
onSuccess: (data) => {
push(data.checkoutUrl)
},
})
useEffect(() => { useEffect(() => {
const { subscribePlan, chats, storage } = query as { const { subscribePlan, chats, storage } = query as {
@ -30,22 +29,28 @@ export const DashboardPage = () => {
} }
if (workspace && subscribePlan && user && workspace.plan === 'FREE') { if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
setIsLoading(true) setIsLoading(true)
createCheckoutSession({ setPreCheckoutPlan({
plan: subscribePlan as 'PRO' | 'STARTER', plan: subscribePlan as 'PRO' | 'STARTER',
workspaceId: workspace.id, workspaceId: workspace.id,
additionalChats: chats ? parseInt(chats) : 0, additionalChats: chats ? parseInt(chats) : 0,
additionalStorage: storage ? parseInt(storage) : 0, additionalStorage: storage ? parseInt(storage) : 0,
returnUrl: window.location.href,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd', currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
prefilledEmail: user.email ?? undefined,
}) })
} }
}, [createCheckoutSession, query, user, workspace]) }, [query, user, workspace])
return ( return (
<Stack minH="100vh"> <Stack minH="100vh">
<Seo title={workspace?.name ?? 'My typebots'} /> <Seo title={workspace?.name ?? 'My typebots'} />
<DashboardHeader /> <DashboardHeader />
{!workspace?.stripeId && (
<PreCheckoutModal
selectedSubscription={preCheckoutPlan}
existingEmail={user?.email ?? undefined}
existingCompany={workspace?.name ?? undefined}
onClose={() => setPreCheckoutPlan(undefined)}
/>
)}
<TypebotDndProvider> <TypebotDndProvider>
{isLoading ? ( {isLoading ? (
<VStack w="full" justifyContent="center" pt="10" spacing={6}> <VStack w="full" justifyContent="center" pt="10" spacing={6}>