♻️ (billing) Refactor billing server code to trpc

This commit is contained in:
Baptiste Arnaud
2023-02-17 16:19:39 +01:00
parent 962438768e
commit b73282d810
38 changed files with 1565 additions and 367 deletions

View File

@@ -18,8 +18,7 @@ export const BillingContent = () => {
<UsageContent workspace={workspace} />
<Stack spacing="4">
<CurrentSubscriptionContent
plan={workspace.plan}
stripeId={workspace.stripeId}
workspace={workspace}
onCancelSuccess={refreshWorkspace}
/>
<HStack maxW="500px">
@@ -35,10 +34,15 @@ export const BillingContent = () => {
{workspace.plan !== Plan.CUSTOM &&
workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.UNLIMITED &&
workspace.plan !== Plan.OFFERED && <ChangePlanForm />}
workspace.plan !== Plan.OFFERED && (
<ChangePlanForm
workspace={workspace}
onUpgradeSuccess={refreshWorkspace}
/>
)}
</Stack>
{workspace.stripeId && <InvoicesList workspace={workspace} />}
{workspace.stripeId && <InvoicesList workspaceId={workspace.id} />}
</Stack>
)
}

View File

@@ -0,0 +1,28 @@
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import { Button, Link } from '@chakra-ui/react'
type Props = {
workspaceId: string
}
export const BillingPortalButton = ({ workspaceId }: Props) => {
const { showToast } = useToast()
const { data } = trpc.billing.getBillingPortalUrl.useQuery(
{
workspaceId,
},
{
onError: (error) => {
showToast({
description: error.message,
})
},
}
)
return (
<Button as={Link} href={data?.billingPortalUrl} isLoading={!data}>
Billing Portal
</Button>
)
}

View File

@@ -1,47 +1,36 @@
import {
Text,
HStack,
Link,
Spinner,
Stack,
Button,
Heading,
} from '@chakra-ui/react'
import { Text, HStack, Link, Spinner, Stack, Heading } from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { Plan } from 'db'
import React, { useState } from 'react'
import { cancelSubscriptionQuery } from './queries/cancelSubscriptionQuery'
import React from 'react'
import { PlanTag } from '../PlanTag'
import { BillingPortalButton } from './BillingPortalButton'
import { trpc } from '@/lib/trpc'
import { Workspace } from 'models'
type CurrentSubscriptionContentProps = {
plan: Plan
stripeId?: string | null
workspace: Pick<Workspace, 'id' | 'plan' | 'stripeId'>
onCancelSuccess: () => void
}
export const CurrentSubscriptionContent = ({
plan,
stripeId,
workspace,
onCancelSuccess,
}: CurrentSubscriptionContentProps) => {
const [isCancelling, setIsCancelling] = useState(false)
const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] =
useState(false)
const { showToast } = useToast()
const cancelSubscription = async () => {
if (!stripeId) return
setIsCancelling(true)
const { error } = await cancelSubscriptionQuery(stripeId)
if (error) {
showToast({ description: error.message })
return
}
onCancelSuccess()
setIsCancelling(false)
}
const { mutate: cancelSubscription, isLoading: isCancelling } =
trpc.billing.cancelSubscription.useMutation({
onError: (error) => {
showToast({
description: error.message,
})
},
onSuccess: onCancelSuccess,
})
const isSubscribed = (plan === Plan.STARTER || plan === Plan.PRO) && stripeId
const isSubscribed =
(workspace.plan === Plan.STARTER || workspace.plan === Plan.PRO) &&
workspace.stripeId
return (
<Stack spacing="4">
@@ -52,14 +41,16 @@ export const CurrentSubscriptionContent = ({
<Spinner color="gray.500" size="xs" />
) : (
<>
<PlanTag plan={plan} />
<PlanTag plan={workspace.plan} />
{isSubscribed && (
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={cancelSubscription}
onClick={() =>
cancelSubscription({ workspaceId: workspace.id })
}
>
Cancel my subscription
</Link>
@@ -75,14 +66,7 @@ export const CurrentSubscriptionContent = ({
Need to change payment method or billing information? Head over to
your billing portal:
</Text>
<Button
as={Link}
href={`/api/stripe/billing-portal?stripeId=${stripeId}`}
onClick={() => setIsRedirectingToBillingPortal(true)}
isLoading={isRedirectingToBillingPortal}
>
Billing Portal
</Button>
<BillingPortalButton workspaceId={workspace.id} />
</Stack>
</>
)}

View File

@@ -14,23 +14,32 @@ import {
Text,
} from '@chakra-ui/react'
import { DownloadIcon, FileIcon } from '@/components/icons'
import { Workspace } from 'db'
import Link from 'next/link'
import React from 'react'
import { useInvoicesQuery } from './queries/useInvoicesQuery'
import { isDefined } from 'utils'
import { trpc } from '@/lib/trpc'
import { useToast } from '@/hooks/useToast'
type Props = {
workspace: Workspace
workspaceId: string
}
export const InvoicesList = ({ workspace }: Props) => {
const { invoices, isLoading } = useInvoicesQuery(workspace.stripeId)
export const InvoicesList = ({ workspaceId }: Props) => {
const { showToast } = useToast()
const { data, status } = trpc.billing.listInvoices.useQuery(
{
workspaceId,
},
{
onError: (error) => {
showToast({ description: error.message })
},
}
)
return (
<Stack spacing={6}>
<Heading fontSize="3xl">Invoices</Heading>
{invoices.length === 0 && !isLoading ? (
{data?.invoices.length === 0 && status !== 'loading' ? (
<Text>No invoices found for this workspace.</Text>
) : (
<TableContainer>
@@ -45,34 +54,34 @@ export const InvoicesList = ({ workspace }: Props) => {
</Tr>
</Thead>
<Tbody>
{invoices
?.filter((invoice) => isDefined(invoice.url))
.map((invoice) => (
<Tr key={invoice.id}>
<Td>
<FileIcon />
</Td>
<Td>{invoice.id}</Td>
<Td>{new Date(invoice.date * 1000).toDateString()}</Td>
<Td>
{getFormattedPrice(invoice.amount, invoice.currency)}
</Td>
<Td>
{invoice.url && (
<IconButton
as={Link}
size="xs"
icon={<DownloadIcon />}
variant="outline"
href={invoice.url}
target="_blank"
aria-label={'Download invoice'}
/>
)}
</Td>
</Tr>
))}
{isLoading &&
{data?.invoices.map((invoice) => (
<Tr key={invoice.id}>
<Td>
<FileIcon />
</Td>
<Td>{invoice.id}</Td>
<Td>
{invoice.date
? new Date(invoice.date * 1000).toDateString()
: ''}
</Td>
<Td>{getFormattedPrice(invoice.amount, invoice.currency)}</Td>
<Td>
{invoice.url && (
<IconButton
as={Link}
size="xs"
icon={<DownloadIcon />}
variant="outline"
href={invoice.url}
target="_blank"
aria-label={'Download invoice'}
/>
)}
</Td>
</Tr>
))}
{status === 'loading' &&
Array.from({ length: 3 }).map((_, idx) => (
<Tr key={idx}>
<Td>

View File

@@ -14,14 +14,16 @@ import React from 'react'
import { parseNumberWithCommas } from 'utils'
import { getChatsLimit, getStorageLimit } from 'utils/pricing'
import { storageToReadable } from './helpers'
import { useUsage } from '../../../hooks/useUsage'
import { trpc } from '@/lib/trpc'
type Props = {
workspace: Workspace
}
export const UsageContent = ({ workspace }: Props) => {
const { data, isLoading } = useUsage(workspace.id)
const { data, isLoading } = trpc.billing.getUsage.useQuery({
workspaceId: workspace.id,
})
const totalChatsUsed = data?.totalChatsUsed ?? 0
const totalStorageUsed = data?.totalStorageUsed ?? 0

View File

@@ -1,7 +0,0 @@
import { sendRequest } from 'utils'
export const redirectToBillingPortal = ({
workspaceId,
}: {
workspaceId: string
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)

View File

@@ -1,24 +0,0 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { env } from 'utils'
type Invoice = {
id: string
url: string | null
date: number
currency: string
amount: number
}
export const useInvoicesQuery = (stripeId?: string | null) => {
const { data, error } = useSWR<{ invoices: Invoice[] }, Error>(
stripeId ? `/api/stripe/invoices?stripeId=${stripeId}` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
return {
invoices: data?.invoices ?? [],
isLoading: !error && !data,
}
}

View File

@@ -1,22 +1,54 @@
import { Stack, HStack, Text } from '@chakra-ui/react'
import { useUser } from '@/features/account'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import { ProPlanContent } from './ProPlanContent'
import { upgradePlanQuery } from '../../queries/upgradePlanQuery'
import { useCurrentSubscriptionInfo } from '../../hooks/useCurrentSubscriptionInfo'
import { StarterPlanContent } from './StarterPlanContent'
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'
export const ChangePlanForm = () => {
type Props = {
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'>
onUpgradeSuccess: () => void
}
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
const router = useRouter()
const { user } = useUser()
const { workspace, refreshWorkspace } = useWorkspace()
const { showToast } = useToast()
const { data, mutate: refreshCurrentSubscriptionInfo } =
useCurrentSubscriptionInfo({
stripeId: workspace?.stripeId,
plan: workspace?.plan,
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) => {
showToast({
description: error.message,
})
},
onSuccess: ({ workspace: { plan } }) => {
onUpgradeSuccess()
showToast({
status: 'success',
description: `Workspace ${plan} plan successfully updated 🎉`,
})
},
})
const handlePayClick = async ({
@@ -30,33 +62,29 @@ export const ChangePlanForm = () => {
}) => {
if (
!user ||
!workspace ||
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return
const response = await upgradePlanQuery({
stripeId: workspace.stripeId ?? undefined,
user,
const newSubscription = {
plan,
workspaceId: workspace.id,
additionalChats: selectedChatsLimitIndex,
additionalStorage: selectedStorageLimitIndex,
currency: data?.currency,
})
if (typeof response === 'object' && response?.error) {
showToast({ description: response.error.message })
return
currency:
data?.subscription.currency ??
(guessIfUserIsEuropean() ? 'eur' : 'usd'),
} as const
if (workspace.stripeId) {
updateSubscription(newSubscription)
} else {
createCheckoutSession({
...newSubscription,
returnUrl: window.location.href,
prefilledEmail: user.email ?? undefined,
})
}
refreshCurrentSubscriptionInfo({
additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex,
})
refreshWorkspace()
showToast({
status: 'success',
description: `Workspace ${plan} plan successfully updated 🎉`,
})
}
return (
@@ -64,26 +92,36 @@ export const ChangePlanForm = () => {
<HStack alignItems="stretch" spacing="4" w="full">
<StarterPlanContent
initialChatsLimitIndex={
workspace?.plan === Plan.STARTER ? data?.additionalChatsIndex : 0
workspace?.plan === Plan.STARTER
? data?.subscription.additionalChatsIndex
: 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.STARTER ? data?.additionalStorageIndex : 0
workspace?.plan === Plan.STARTER
? data?.subscription.additionalStorageIndex
: 0
}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
currency={data?.currency}
isLoading={isCreatingCheckout || isUpdatingSubscription}
currency={data?.subscription.currency}
/>
<ProPlanContent
initialChatsLimitIndex={
workspace?.plan === Plan.PRO ? data?.additionalChatsIndex : 0
workspace?.plan === Plan.PRO
? data?.subscription.additionalChatsIndex
: 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.PRO ? data?.additionalStorageIndex : 0
workspace?.plan === Plan.PRO
? data?.subscription.additionalStorageIndex
: 0
}
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
currency={data?.currency}
isLoading={isCreatingCheckout || isUpdatingSubscription}
currency={data?.subscription.currency}
/>
</HStack>
<Text color="gray.500">

View File

@@ -34,16 +34,18 @@ type ProPlanContentProps = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
currency?: 'usd' | 'eur'
isLoading: boolean
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
}) => Promise<void>
}) => void
}
export const ProPlanContent = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
currency,
isLoading,
onPayClick,
}: ProPlanContentProps) => {
const { workspace } = useWorkspace()
@@ -51,7 +53,6 @@ export const ProPlanContent = ({
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState<number>()
const [isPaying, setIsPaying] = useState(false)
useEffect(() => {
if (
@@ -110,12 +111,10 @@ export const ProPlanContent = ({
selectedStorageLimitIndex === undefined
)
return
setIsPaying(true)
await onPayClick({
onPayClick({
selectedChatsLimitIndex,
selectedStorageLimitIndex,
})
setIsPaying(false)
}
return (
@@ -335,7 +334,7 @@ export const ProPlanContent = ({
colorScheme="blue"
variant="outline"
onClick={handlePayClick}
isLoading={isPaying}
isLoading={isLoading}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}

View File

@@ -30,15 +30,17 @@ type StarterPlanContentProps = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
currency?: 'eur' | 'usd'
isLoading?: boolean
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
}) => Promise<void>
}) => void
}
export const StarterPlanContent = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
isLoading,
currency,
onPayClick,
}: StarterPlanContentProps) => {
@@ -47,7 +49,6 @@ export const StarterPlanContent = ({
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState<number>()
const [isPaying, setIsPaying] = useState(false)
useEffect(() => {
if (
@@ -107,12 +108,10 @@ export const StarterPlanContent = ({
selectedStorageLimitIndex === undefined
)
return
setIsPaying(true)
await onPayClick({
onPayClick({
selectedChatsLimitIndex,
selectedStorageLimitIndex,
})
setIsPaying(false)
}
return (
@@ -278,7 +277,7 @@ export const StarterPlanContent = ({
colorScheme="orange"
variant="outline"
onClick={handlePayClick}
isLoading={isPaying}
isLoading={isLoading}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}

View File

@@ -1,4 +1,5 @@
import { AlertInfo } from '@/components/AlertInfo'
import { useWorkspace } from '@/features/workspace'
import {
Modal,
ModalBody,
@@ -30,6 +31,7 @@ export const ChangePlanModal = ({
isOpen,
type,
}: ChangePlanModalProps) => {
const { workspace, refreshWorkspace } = useWorkspace()
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay />
@@ -40,7 +42,12 @@ export const ChangePlanModal = ({
You need to upgrade your plan in order to {type}
</AlertInfo>
)}
<ChangePlanForm />
{workspace && (
<ChangePlanForm
workspace={workspace}
onUpgradeSuccess={refreshWorkspace}
/>
)}
</ModalBody>
<ModalFooter>