♻️ (billing) Refactor billing server code to trpc
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const redirectToBillingPortal = ({
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: string
|
||||
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user