From 26e5d9c282df0f338401430f590b2571e97b2f55 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Mon, 6 Mar 2023 11:30:01 +0100 Subject: [PATCH] :children_crossing: (billing) Add precheckout form Collects required company name and email and create the customer before redirecting to checkout --- .../api/procedures/createCheckoutSession.ts | 17 ++- .../src/features/billing/billing.spec.ts | 2 + .../ChangePlanForm/ChangePlanForm.tsx | 36 +++--- .../billing/components/PreCheckoutModal.tsx | 111 ++++++++++++++++++ .../dashboard/components/DashboardPage.tsx | 29 +++-- 5 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 apps/builder/src/features/billing/components/PreCheckoutModal.tsx diff --git a/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts b/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts index d7c7da24a3..0c1327bd06 100644 --- a/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts +++ b/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts @@ -18,6 +18,8 @@ export const createCheckoutSession = authenticatedProcedure }) .input( z.object({ + email: z.string(), + company: z.string(), workspaceId: z.string(), prefilledEmail: z.string().optional(), currency: z.enum(['usd', 'eur']), @@ -35,8 +37,9 @@ export const createCheckoutSession = authenticatedProcedure .mutation( async ({ input: { + email, + company, workspaceId, - prefilledEmail, currency, plan, returnUrl, @@ -69,11 +72,21 @@ export const createCheckoutSession = authenticatedProcedure apiVersion: '2022-11-15', }) + const customer = await stripe.customers.create({ + email, + name: company, + metadata: { workspaceId }, + }) + const session = await stripe.checkout.sessions.create({ success_url: `${returnUrl}?stripe=${plan}&success=true`, cancel_url: `${returnUrl}?stripe=cancel`, allow_promotion_codes: true, - customer_email: prefilledEmail, + customer: customer.id, + customer_update: { + address: 'auto', + name: 'auto', + }, mode: 'subscription', metadata: { workspaceId, plan, additionalChats, additionalStorage }, currency, diff --git a/apps/builder/src/features/billing/billing.spec.ts b/apps/builder/src/features/billing/billing.spec.ts index b12eefe44e..1ce1634177 100644 --- a/apps/builder/src/features/billing/billing.spec.ts +++ b/apps/builder/src/features/billing/billing.spec.ts @@ -134,6 +134,8 @@ test('plan changes should work', async ({ page }) => { await page.click('button >> text="4"') await expect(page.locator('text="$73"')).toBeVisible() 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() expect(page.url()).toContain('https://checkout.stripe.com') await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible() diff --git a/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx b/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx index ebd7546386..57965d65cb 100644 --- a/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx +++ b/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx @@ -7,8 +7,9 @@ import { TextLink } from '@/components/TextLink' import { useToast } from '@/hooks/useToast' import { trpc } from '@/lib/trpc' import { guessIfUserIsEuropean } from 'utils/pricing' -import { useRouter } from 'next/router' import { Workspace } from 'models' +import { PreCheckoutModal, PreCheckoutModalProps } from '../PreCheckoutModal' +import { useState } from 'react' type Props = { workspace: Pick @@ -16,25 +17,15 @@ type Props = { } export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => { - const router = useRouter() const { user } = useUser() const { showToast } = useToast() + const [preCheckoutPlan, setPreCheckoutPlan] = + useState() + const { data } = trpc.billing.getSubscription.useQuery({ 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 } = trpc.billing.updateSubscription.useMutation({ onError: (error) => { @@ -79,15 +70,20 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => { if (workspace.stripeId) { updateSubscription(newSubscription) } else { - createCheckoutSession({ - ...newSubscription, - returnUrl: window.location.href, - }) + setPreCheckoutPlan(newSubscription) } } return ( + {!workspace.stripeId && ( + setPreCheckoutPlan(undefined)} + /> + )} { onPayClick={(props) => handlePayClick({ ...props, plan: Plan.STARTER }) } - isLoading={isCreatingCheckout || isUpdatingSubscription} + isLoading={isUpdatingSubscription} currency={data?.subscription.currency} /> @@ -119,7 +115,7 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => { : 0 } onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })} - isLoading={isCreatingCheckout || isUpdatingSubscription} + isLoading={isUpdatingSubscription} currency={data?.subscription.currency} /> diff --git a/apps/builder/src/features/billing/components/PreCheckoutModal.tsx b/apps/builder/src/features/billing/components/PreCheckoutModal.tsx new file mode 100644 index 0000000000..883501d683 --- /dev/null +++ b/apps/builder/src/features/billing/components/PreCheckoutModal.tsx @@ -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 ( + + + + + + + + + + + + + ) +} diff --git a/apps/builder/src/features/dashboard/components/DashboardPage.tsx b/apps/builder/src/features/dashboard/components/DashboardPage.tsx index e180e1a882..554e3304e2 100644 --- a/apps/builder/src/features/dashboard/components/DashboardPage.tsx +++ b/apps/builder/src/features/dashboard/components/DashboardPage.tsx @@ -1,8 +1,11 @@ import { Seo } from '@/components/Seo' import { useUser } from '@/features/account' +import { + PreCheckoutModal, + PreCheckoutModalProps, +} from '@/features/billing/components/PreCheckoutModal' import { TypebotDndProvider, FolderContent } from '@/features/folders' import { useWorkspace } from '@/features/workspace' -import { trpc } from '@/lib/trpc' import { Stack, VStack, Spinner, Text } from '@chakra-ui/react' import { Plan } from 'db' import { useRouter } from 'next/router' @@ -12,15 +15,11 @@ import { DashboardHeader } from './DashboardHeader' export const DashboardPage = () => { const [isLoading, setIsLoading] = useState(false) - const { query, push } = useRouter() + const { query } = useRouter() const { user } = useUser() const { workspace } = useWorkspace() - const { mutate: createCheckoutSession } = - trpc.billing.createCheckoutSession.useMutation({ - onSuccess: (data) => { - push(data.checkoutUrl) - }, - }) + const [preCheckoutPlan, setPreCheckoutPlan] = + useState() useEffect(() => { const { subscribePlan, chats, storage } = query as { @@ -30,22 +29,28 @@ export const DashboardPage = () => { } if (workspace && subscribePlan && user && workspace.plan === 'FREE') { setIsLoading(true) - createCheckoutSession({ + setPreCheckoutPlan({ plan: subscribePlan as 'PRO' | 'STARTER', workspaceId: workspace.id, additionalChats: chats ? parseInt(chats) : 0, additionalStorage: storage ? parseInt(storage) : 0, - returnUrl: window.location.href, currency: guessIfUserIsEuropean() ? 'eur' : 'usd', - prefilledEmail: user.email ?? undefined, }) } - }, [createCheckoutSession, query, user, workspace]) + }, [query, user, workspace]) return ( + {!workspace?.stripeId && ( + setPreCheckoutPlan(undefined)} + /> + )} {isLoading ? (