Skip to content

Commit

Permalink
🐛 (billing) Collect tax ID manually before checkout
Browse files Browse the repository at this point in the history
This allows Typebot to always display a company name on invoices.
  • Loading branch information
baptisteArno committed Mar 7, 2023
1 parent 67a3f42 commit 767a820
Show file tree
Hide file tree
Showing 8 changed files with 733 additions and 66 deletions.
37 changes: 25 additions & 12 deletions apps/builder/src/components/inputs/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,30 @@ import { ChevronDownIcon, CloseIcon } from '../icons'

const dropdownCloseAnimationDuration = 300

type Item = string | { icon?: JSX.Element; label: string; value: string }
type Item =
| string
| {
icon?: JSX.Element
label: string
value: string
extras?: Record<string, unknown>
}

type Props = {
type Props<T extends Item> = {
isPopoverMatchingInputWidth?: boolean
selectedItem?: string
items: Item[]
items: T[]
placeholder?: string
onSelect?: (value: string | undefined) => void
onSelect?: (value: string | undefined, item?: T) => void
}

export const Select = ({
export const Select = <T extends Item>({
isPopoverMatchingInputWidth = true,
selectedItem,
placeholder,
items,
onSelect,
}: Props) => {
}: Props<T>) => {
const focusedItemBgColor = useColorModeValue('gray.200', 'gray.700')
const selectedItemBgColor = useColorModeValue('blue.50', 'blue.400')
const [isTouched, setIsTouched] = useState(false)
Expand Down Expand Up @@ -87,20 +96,22 @@ export const Select = ({
setInputValue(e.target.value)
}

const handleItemClick = (item: Item) => () => {
const handleItemClick = (item: T) => () => {
if (!isTouched) setIsTouched(true)
setInputValue(getItemLabel(item))
onSelect?.(getItemValue(item))
onSelect?.(getItemValue(item), item)
setKeyboardFocusIndex(undefined)
closeDropwdown()
}

const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && isDefined(keyboardFocusIndex)) {
e.preventDefault()
handleItemClick(filteredItems[keyboardFocusIndex])()
return setKeyboardFocusIndex(undefined)
}
if (e.key === 'ArrowDown') {
e.preventDefault()
if (keyboardFocusIndex === undefined) return setKeyboardFocusIndex(0)
if (keyboardFocusIndex === filteredItems.length - 1)
return setKeyboardFocusIndex(0)
Expand All @@ -111,6 +122,7 @@ export const Select = ({
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (keyboardFocusIndex === 0 || keyboardFocusIndex === undefined)
return setKeyboardFocusIndex(filteredItems.length - 1)
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
Expand Down Expand Up @@ -140,7 +152,8 @@ export const Select = ({
<Popover
isOpen={isOpen}
initialFocusRef={inputRef}
matchWidth
matchWidth={isPopoverMatchingInputWidth}
placement="bottom-start"
offset={[0, 1]}
isLazy
>
Expand All @@ -150,7 +163,7 @@ export const Select = ({
pos="absolute"
pb={2}
// We need absolute positioning the overlay match the underlying input
pt="8.5px"
pt="8px"
pl="17px"
pr={selectedItem ? 16 : 8}
w="full"
Expand All @@ -173,7 +186,7 @@ export const Select = ({
onBlur={resetIsTouched}
onChange={handleInputChange}
onFocus={onOpen}
onKeyDown={handleKeyUp}
onKeyDown={handleKeyDown}
pr={selectedItem ? 16 : undefined}
/>

Expand Down
47 changes: 29 additions & 18 deletions apps/builder/src/components/inputs/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import {
InputProps,
} from '@chakra-ui/react'
import { Variable } from 'models'
import React, { ReactNode, useEffect, useRef, useState } from 'react'
import React, {
forwardRef,
ReactNode,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { env } from 'utils'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
Expand All @@ -29,23 +36,27 @@ export type TextInputProps = {
'autoComplete' | 'onFocus' | 'onKeyUp' | 'type' | 'autoFocus'
>

export const TextInput = ({
type,
defaultValue,
debounceTimeout = 1000,
label,
moreInfoTooltip,
withVariableButton = true,
isRequired,
placeholder,
autoComplete,
isDisabled,
autoFocus,
onChange: _onChange,
onFocus,
onKeyUp,
}: TextInputProps) => {
export const TextInput = forwardRef(function TextInput(
{
type,
defaultValue,
debounceTimeout = 1000,
label,
moreInfoTooltip,
withVariableButton = true,
isRequired,
placeholder,
autoComplete,
isDisabled,
autoFocus,
onChange: _onChange,
onFocus,
onKeyUp,
}: TextInputProps,
ref
) {
const inputRef = useRef<HTMLInputElement | null>(null)
useImperativeHandle(ref, () => inputRef.current)
const [isTouched, setIsTouched] = useState(false)
const [localValue, setLocalValue] = useState<string>(defaultValue ?? '')
const [carretPosition, setCarretPosition] = useState<number>(
Expand Down Expand Up @@ -128,4 +139,4 @@ export const TextInput = ({
)}
</FormControl>
)
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export const createCheckoutSession = authenticatedProcedure
returnUrl: z.string(),
additionalChats: z.number(),
additionalStorage: z.number(),
vat: z
.object({
type: z.string(),
value: z.string(),
})
.optional(),
})
)
.output(
Expand All @@ -37,6 +43,7 @@ export const createCheckoutSession = authenticatedProcedure
.mutation(
async ({
input: {
vat,
email,
company,
workspaceId,
Expand Down Expand Up @@ -72,10 +79,22 @@ export const createCheckoutSession = authenticatedProcedure
apiVersion: '2022-11-15',
})

await prisma.user.updateMany({
where: {
id: user.id,
},
data: {
company,
},
})

const customer = await stripe.customers.create({
email,
name: company,
metadata: { workspaceId },
tax_id_data: vat
? [vat as Stripe.CustomerCreateParams.TaxIdDatum]
: undefined,
})

const session = await stripe.checkout.sessions.create({
Expand All @@ -85,14 +104,11 @@ export const createCheckoutSession = authenticatedProcedure
customer: customer.id,
customer_update: {
address: 'auto',
name: 'auto',
name: 'never',
},
mode: 'subscription',
metadata: { workspaceId, plan, additionalChats, additionalStorage },
currency,
tax_id_collection: {
enabled: true,
},
billing_address_collection: 'required',
automatic_tax: { enabled: true },
line_items: parseSubscriptionItems(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { guessIfUserIsEuropean } from 'utils/pricing'
import { Workspace } from 'models'
import { PreCheckoutModal, PreCheckoutModalProps } from '../PreCheckoutModal'
import { useState } from 'react'
import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider'

type Props = {
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'>
Expand Down Expand Up @@ -77,12 +78,14 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
return (
<Stack spacing={6}>
{!workspace.stripeId && (
<PreCheckoutModal
selectedSubscription={preCheckoutPlan}
existingEmail={user?.email ?? undefined}
existingCompany={user?.company ?? undefined}
onClose={() => setPreCheckoutPlan(undefined)}
/>
<ParentModalProvider>
<PreCheckoutModal
selectedSubscription={preCheckoutPlan}
existingEmail={user?.email ?? undefined}
existingCompany={user?.company ?? undefined}
onClose={() => setPreCheckoutPlan(undefined)}
/>
</ParentModalProvider>
)}
<HStack alignItems="stretch" spacing="4" w="full">
<StarterPlanContent
Expand Down
80 changes: 75 additions & 5 deletions apps/builder/src/features/billing/components/PreCheckoutModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { TextInput } from '@/components/inputs'
import { Select } from '@/components/inputs/Select'
import { useParentModal } from '@/features/graph'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import {
Button,
FormControl,
FormLabel,
HStack,
Modal,
ModalBody,
ModalContent,
Expand All @@ -12,6 +17,7 @@ import {
import { useRouter } from 'next/router'
import React, { FormEvent, useState } from 'react'
import { isDefined } from 'utils'
import { taxIdTypes } from '../taxIdTypes'

export type PreCheckoutModalProps = {
selectedSubscription:
Expand All @@ -28,12 +34,22 @@ export type PreCheckoutModalProps = {
onClose: () => void
}

const vatCodeLabels = taxIdTypes.map((taxIdType) => ({
label: `${taxIdType.emoji} ${taxIdType.name} (${taxIdType.code})`,
value: taxIdType.type,
extras: {
placeholder: taxIdType.placeholder,
},
}))

export const PreCheckoutModal = ({
selectedSubscription,
existingCompany,
existingEmail,
onClose,
}: PreCheckoutModalProps) => {
const { ref } = useParentModal()
const vatValueInputRef = React.useRef<HTMLInputElement>(null)
const router = useRouter()
const { showToast } = useToast()
const { mutate: createCheckoutSession, isLoading: isCreatingCheckout } =
Expand All @@ -51,7 +67,12 @@ export const PreCheckoutModal = ({
const [customer, setCustomer] = useState({
company: existingCompany ?? '',
email: existingEmail ?? '',
vat: {
type: undefined as string | undefined,
value: '',
},
})
const [vatValuePlaceholder, setVatValuePlaceholder] = useState('')

const updateCustomerCompany = (company: string) => {
setCustomer((customer) => ({ ...customer, company }))
Expand All @@ -61,23 +82,53 @@ export const PreCheckoutModal = ({
setCustomer((customer) => ({ ...customer, email }))
}

const createCustomer = (e: FormEvent) => {
const updateVatType = (
type: string | undefined,
vatCode?: (typeof vatCodeLabels)[number]
) => {
setCustomer((customer) => ({
...customer,
vat: {
...customer.vat,
type,
},
}))
setVatValuePlaceholder(vatCode?.extras?.placeholder ?? '')
vatValueInputRef.current?.focus()
}

const updateVatValue = (value: string) => {
setCustomer((customer) => ({
...customer,
vat: {
...customer.vat,
value,
},
}))
}

const goToCheckout = (e: FormEvent) => {
e.preventDefault()
if (!selectedSubscription) return
const { email, company, vat } = customer
createCheckoutSession({
...selectedSubscription,
email: customer.email,
company: customer.company,
email,
company,
returnUrl: window.location.href,
vat:
vat.value && vat.type
? { type: vat.type, value: vat.value }
: undefined,
})
}

return (
<Modal isOpen={isDefined(selectedSubscription)} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalContent ref={ref}>
<ModalBody py="8">
<Stack as="form" onSubmit={createCustomer} spacing="4">
<Stack as="form" spacing="4" onSubmit={goToCheckout}>
<TextInput
isRequired
label="Company name"
Expand All @@ -95,6 +146,25 @@ export const PreCheckoutModal = ({
withVariableButton={false}
debounceTimeout={0}
/>
<FormControl>
<FormLabel>Tax ID</FormLabel>
<HStack>
<Select
placeholder="ID type"
items={vatCodeLabels}
isPopoverMatchingInputWidth={false}
onSelect={updateVatType}
/>
<TextInput
ref={vatValueInputRef}
onChange={updateVatValue}
withVariableButton={false}
debounceTimeout={0}
placeholder={vatValuePlaceholder}
/>
</HStack>
</FormControl>

<Button
type="submit"
isLoading={isCreatingCheckout}
Expand Down
Loading

4 comments on commit 767a820

@vercel
Copy link

@vercel vercel bot commented on 767a820 Mar 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 767a820 Mar 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

docs – ./apps/docs

docs-git-main-typebot-io.vercel.app
docs-typebot-io.vercel.app
docs.typebot.io

@vercel
Copy link

@vercel vercel bot commented on 767a820 Mar 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

builder-v2 – ./apps/builder

app.typebot.io
builder-v2-typebot-io.vercel.app
builder-v2-git-main-typebot-io.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 767a820 Mar 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

viewer-v2 – ./apps/viewer

ns8.vn
1stop.au
yobot.me
klujo.com
me.cr8.ai
247987.com
8jours.top
aginap.com
ai.mprs.in
bee.cr8.ai
bot.aws.bj
bot.bbc.bj
cat.cr8.ai
finplex.be
nepkit.com
pig.cr8.ai
sat.cr8.ai
bot.aipr.kr
bot.joof.it
bull.cr8.ai
docs.cr8.ai
minipost.uk
mole.cr8.ai
team.cr8.ai
wolf.cr8.ai
cinecorn.com
help.taxt.co
kusamint.com
rhino.cr8.ai
sheep.cr8.ai
snake.cr8.ai
svhm.mprs.in
tiger.cr8.ai
video.cr8.ai
yoda.riku.ai
zebra.cr8.ai
bergamo.store
bot.krdfy.com
bot.tvbeat.it
cgcassets.com
cnvhub.com.br
facelabko.com
filmylogy.com
goldorayo.com
rabbit.cr8.ai
signup.cr8.ai
start.taxt.co
turkey.cr8.ai
vhpage.cr8.ai
start.taxtree.io
typebot.aloe.bot
voicehelp.cr8.ai
zap.fundviser.in
app.chatforms.net
newsletter.itshcormeos.com
tarian.theiofoundation.org
ted.meujalecobrasil.com.br
type.dericsoncalari.com.br
bot.pinpointinteractive.com
bot.polychromes-project.com
bot.seidinembroseanchetu.it
chatbot.berbelanjabiz.trade
designguide.techyscouts.com
jcapp.virtuesocialmedia.com
liveconvert2.kandalearn.com
presente.empresarias.com.mx
sell.sellthemotorhome.co.uk
anamnese.odontopavani.com.br
austin.channelautomation.com
bot.marketingplusmindset.com
bot.seidibergamoseanchetu.it
desabafe.sergiolimajr.com.br
download.venturemarketing.in
jc-app.virtuesocialmedia.com
piazzatorre.barrettamario.it
type.cookieacademyonline.com
upload.atlasoutfittersk9.com
bot.brigadeirosemdrama.com.br
forms.escoladeautomacao.com.br
onboarding.libertydreamcare.ie
type.talitasouzamarques.com.br
agendamento.sergiolimajr.com.br
anamnese.clinicamegasjdr.com.br
bookings.littlepartymonkeys.com
bot.comercializadoraomicron.com
elevateyourmind.groovepages.com
viewer-v2-typebot-io.vercel.app
yourfeedback.comebackreward.com
gerador.verificadordehospedes.com
personal-trainer.barrettamario.it
preagendamento.sergiolimajr.com.br
studiotecnicoimmobiliaremerelli.it
download.thailandmicespecialist.com
register.thailandmicespecialist.com
bot.studiotecnicoimmobiliaremerelli.it
pesquisa.escolamodacomproposito.com.br
anamnese.clinicaramosodontologia.com.br
chrome-os-inquiry-system.itschromeos.com
viewer-v2-git-main-typebot-io.vercel.app
main-menu-for-itschromeos.itschromeos.com

Please sign in to comment.