2
0

♻️ (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

@ -0,0 +1,70 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from 'db'
import Stripe from 'stripe'
import { z } from 'zod'
export const cancelSubscription = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/billing/subscription',
protect: true,
summary: 'Cancel current subscription',
tags: ['Billing'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
message: z.literal('success'),
})
)
.mutation(async ({ input: { workspaceId }, ctx: { user } }) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const currentSubscriptionId = (
await stripe.subscriptions.list({
customer: workspace.stripeId,
})
).data.shift()?.id
if (currentSubscriptionId)
await stripe.subscriptions.del(currentSubscriptionId)
await prisma.workspace.update({
where: { id: workspace.id },
data: {
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
},
})
return { message: 'success' }
})

View File

@ -0,0 +1,98 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from 'db'
import Stripe from 'stripe'
import { z } from 'zod'
import { parseSubscriptionItems } from '../utils/parseSubscriptionItems'
export const createCheckoutSession = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/billing/subscription/checkout',
protect: true,
summary: 'Create checkout session to create a new subscription',
tags: ['Billing'],
},
})
.input(
z.object({
workspaceId: z.string(),
prefilledEmail: z.string().optional(),
currency: z.enum(['usd', 'eur']),
plan: z.enum([Plan.STARTER, Plan.PRO]),
returnUrl: z.string(),
additionalChats: z.number(),
additionalStorage: z.number(),
})
)
.output(
z.object({
checkoutUrl: z.string(),
})
)
.mutation(
async ({
input: {
workspaceId,
prefilledEmail,
currency,
plan,
returnUrl,
additionalChats,
additionalStorage,
},
ctx: { user },
}) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
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,
mode: 'subscription',
metadata: { workspaceId, plan, additionalChats, additionalStorage },
currency,
automatic_tax: { enabled: true },
line_items: parseSubscriptionItems(
plan,
additionalChats,
additionalStorage
),
})
if (!session.url)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe checkout session creation failed',
})
return {
checkoutUrl: session.url,
}
}
)

View File

@ -0,0 +1,58 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from 'db'
import Stripe from 'stripe'
import { z } from 'zod'
export const getBillingPortalUrl = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/billing/subscription/portal',
protect: true,
summary: 'Get Stripe billing portal URL',
tags: ['Billing'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
billingPortalUrl: z.string(),
})
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
if (!process.env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'STRIPE_SECRET_KEY var is missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
select: {
stripeId: true,
},
})
if (!workspace?.stripeId)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const portalSession = await stripe.billingPortal.sessions.create({
customer: workspace.stripeId,
return_url: `${process.env.NEXTAUTH_URL}/typebots`,
})
return {
billingPortalUrl: portalSession.url,
}
})

View File

@ -0,0 +1,81 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from 'db'
import Stripe from 'stripe'
import { z } from 'zod'
import { subscriptionSchema } from 'models/features/billing/subscription'
export const getSubscription = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/billing/subscription',
protect: true,
summary: 'List invoices',
tags: ['Billing'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
subscription: subscriptionSchema,
})
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const subscriptions = await stripe.subscriptions.list({
customer: workspace.stripeId,
limit: 1,
})
const subscription = subscriptions?.data.shift()
if (!subscription)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Subscription not found',
})
return {
subscription: {
additionalChatsIndex:
subscription?.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.quantity ?? 0,
additionalStorageIndex:
subscription.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.quantity ?? 0,
currency: subscription.currency as 'usd' | 'eur',
},
}
})

View File

@ -0,0 +1,94 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
export const getUsage = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/billing/usage',
protect: true,
summary: 'Get current plan usage',
tags: ['Billing'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({ totalChatsUsed: z.number(), totalStorageUsed: z.number() })
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id } },
},
})
if (!workspace)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const firstDayOfNextMonth = new Date(
now.getFullYear(),
now.getMonth() + 1,
1
)
const [
totalChatsUsed,
{
_sum: { storageUsed: totalStorageUsed },
},
] = await prisma.$transaction(async (tx) => {
const typebots = await tx.typebot.findMany({
where: {
workspace: {
id: workspaceId,
},
},
})
return Promise.all([
prisma.result.count({
where: {
typebotId: { in: typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
lt: firstDayOfNextMonth,
},
},
}),
prisma.answer.aggregate({
where: {
storageUsed: { gt: 0 },
result: {
typebotId: { in: typebots.map((typebot) => typebot.id) },
},
},
_sum: { storageUsed: true },
}),
])
})
return {
totalChatsUsed,
totalStorageUsed: totalStorageUsed ?? 0,
}
})

View File

@ -0,0 +1,66 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from 'db'
import Stripe from 'stripe'
import { isDefined } from 'utils'
import { z } from 'zod'
import { invoiceSchema } from 'models/features/billing/invoice'
export const listInvoices = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/billing/invoices',
protect: true,
summary: 'List invoices',
tags: ['Billing'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
invoices: z.array(invoiceSchema),
})
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
if (!process.env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'STRIPE_SECRET_KEY var is missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const invoices = await stripe.invoices.list({
customer: workspace.stripeId,
})
return {
invoices: invoices.data
.filter(
(invoice) => isDefined(invoice.invoice_pdf) && isDefined(invoice.id)
)
.map((i) => ({
id: i.number as string,
url: i.invoice_pdf as string,
amount: i.subtotal,
currency: i.currency,
date: i.status_transitions.paid_at,
})),
}
})

View File

@ -0,0 +1,146 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from 'db'
import { workspaceSchema } from 'models'
import Stripe from 'stripe'
import { isDefined } from 'utils'
import { z } from 'zod'
export const updateSubscription = authenticatedProcedure
.meta({
openapi: {
method: 'PATCH',
path: '/billing/subscription',
protect: true,
summary: 'Update subscription',
tags: ['Billing'],
},
})
.input(
z.object({
workspaceId: z.string(),
plan: z.enum([Plan.STARTER, Plan.PRO]),
additionalChats: z.number(),
additionalStorage: z.number(),
currency: z.enum(['usd', 'eur']),
})
)
.output(
z.object({
workspace: workspaceSchema,
})
)
.mutation(
async ({
input: {
workspaceId,
plan,
additionalChats,
additionalStorage,
currency,
},
ctx: { user },
}) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const { data } = await stripe.subscriptions.list({
customer: workspace.stripeId,
})
const subscription = data[0] as Stripe.Subscription | undefined
const currentStarterPlanItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID
)?.id
const currentProPlanItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID
)?.id
const currentAdditionalChatsItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.id
const currentAdditionalStorageItemId = subscription?.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.id
const items = [
{
id: currentStarterPlanItemId ?? currentProPlanItemId,
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
additionalChats === 0 && !currentAdditionalChatsItemId
? undefined
: {
id: currentAdditionalChatsItemId,
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
deleted: subscription ? additionalChats === 0 : undefined,
},
additionalStorage === 0 && !currentAdditionalStorageItemId
? undefined
: {
id: currentAdditionalStorageItemId,
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
deleted: subscription ? additionalStorage === 0 : undefined,
},
].filter(isDefined)
if (subscription) {
await stripe.subscriptions.update(subscription.id, {
items,
})
} else {
const { data: paymentMethods } = await stripe.paymentMethods.list({
customer: workspace.stripeId,
})
if (paymentMethods.length === 0) {
throw Error('No payment method found')
}
await stripe.subscriptions.create({
customer: workspace.stripeId,
items,
currency,
default_payment_method: paymentMethods[0].id,
})
}
const updatedWorkspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
additionalChatsIndex: additionalChats,
additionalStorageIndex: additionalStorage,
chatsLimitFirstEmailSentAt: null,
chatsLimitSecondEmailSentAt: null,
storageLimitFirstEmailSentAt: null,
storageLimitSecondEmailSentAt: null,
},
})
return { workspace: updatedWorkspace }
}
)

View File

@ -0,0 +1,18 @@
import { router } from '@/utils/server/trpc'
import { getBillingPortalUrl } from './procedures/getBillingPortalUrl'
import { listInvoices } from './procedures/listInvoices'
import { cancelSubscription } from './procedures/cancelSubscription'
import { createCheckoutSession } from './procedures/createCheckoutSession'
import { updateSubscription } from './procedures/updateSubscription'
import { getSubscription } from './procedures/getSubscription'
import { getUsage } from './procedures/getUsage'
export const billingRouter = router({
getBillingPortalUrl,
listInvoices,
cancelSubscription,
createCheckoutSession,
updateSubscription,
getSubscription,
getUsage,
})

View File

@ -0,0 +1,36 @@
import { Plan } from 'db'
export const parseSubscriptionItems = (
plan: Plan,
additionalChats: number,
additionalStorage: number
) =>
[
{
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
]
.concat(
additionalChats > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
},
]
: []
)
.concat(
additionalStorage > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
},
]
: []
)

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,18 +54,18 @@ export const InvoicesList = ({ workspace }: Props) => {
</Tr>
</Thead>
<Tbody>
{invoices
?.filter((invoice) => isDefined(invoice.url))
.map((invoice) => (
{data?.invoices.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)}
{invoice.date
? new Date(invoice.date * 1000).toDateString()
: ''}
</Td>
<Td>{getFormattedPrice(invoice.amount, invoice.currency)}</Td>
<Td>
{invoice.url && (
<IconButton
@ -72,7 +81,7 @@ export const InvoicesList = ({ workspace }: Props) => {
</Td>
</Tr>
))}
{isLoading &&
{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,
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,
})
if (typeof response === 'object' && response?.error) {
showToast({ description: response.error.message })
return
}
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>

View File

@ -1,31 +0,0 @@
import { fetcher } from '@/utils/helpers'
import { Plan } from 'db'
import useSWR from 'swr'
export const useCurrentSubscriptionInfo = ({
stripeId,
plan,
}: {
stripeId?: string | null
plan?: Plan
}) => {
const { data, mutate } = useSWR<
{
additionalChatsIndex: number
additionalStorageIndex: number
currency?: 'eur' | 'usd'
},
Error
>(
stripeId && (plan === Plan.STARTER || plan === Plan.PRO)
? `/api/stripe/subscription?stripeId=${stripeId}`
: null,
fetcher
)
return {
data: !stripeId
? { additionalChatsIndex: 0, additionalStorageIndex: 0 }
: data,
mutate,
}
}

View File

@ -1,16 +0,0 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { env } from 'utils'
export const useUsage = (workspaceId?: string) => {
const { data, error } = useSWR<
{ totalChatsUsed: number; totalStorageUsed: number },
Error
>(workspaceId ? `/api/workspaces/${workspaceId}/usage` : null, fetcher, {
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
})
return {
data,
isLoading: !error && !data,
}
}

View File

@ -1,8 +1,6 @@
export { ChangePlanModal, LimitReached } from './components/ChangePlanModal'
export { planToReadable, isFreePlan, isProPlan } from './utils'
export { upgradePlanQuery } from './queries/upgradePlanQuery'
export { BillingContent } from './components/BillingContent'
export { LockTag } from './components/LockTag'
export { useUsage } from './hooks/useUsage'
export { UpgradeButton } from './components/UpgradeButton'
export { PlanTag } from './components/PlanTag'

View File

@ -1,75 +0,0 @@
import { loadStripe } from '@stripe/stripe-js/pure'
import { Plan, User } from 'db'
import { env, isDefined, isEmpty, sendRequest } from 'utils'
import { guessIfUserIsEuropean } from 'utils/pricing'
type UpgradeProps = {
user: User
stripeId?: string
plan: Plan
workspaceId: string
additionalChats: number
additionalStorage: number
currency?: 'eur' | 'usd'
}
export const upgradePlanQuery = async ({
stripeId,
...props
}: UpgradeProps): Promise<{ newPlan?: Plan; error?: Error } | void> =>
isDefined(stripeId)
? updatePlan({ ...props, stripeId })
: redirectToCheckout(props)
const updatePlan = async ({
stripeId,
plan,
workspaceId,
additionalChats,
additionalStorage,
currency,
}: Omit<UpgradeProps, 'user'>): Promise<{ newPlan?: Plan; error?: Error }> => {
const { data, error } = await sendRequest<{ message: string }>({
method: 'PUT',
url: '/api/stripe/subscription',
body: {
workspaceId,
plan,
stripeId,
additionalChats,
additionalStorage,
currency: currency ?? (guessIfUserIsEuropean() ? 'eur' : 'usd'),
},
})
if (error || !data) return { error }
return { newPlan: plan }
}
const redirectToCheckout = async ({
user,
plan,
workspaceId,
additionalChats,
additionalStorage,
}: Omit<UpgradeProps, 'customerId'>) => {
if (isEmpty(env('STRIPE_PUBLIC_KEY')))
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
const { data, error } = await sendRequest<{ sessionId: string }>({
method: 'POST',
url: '/api/stripe/subscription',
body: {
email: user.email,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
plan,
workspaceId,
href: location.origin + location.pathname,
additionalChats,
additionalStorage,
},
})
if (error || !data) return
const stripe = await loadStripe(env('STRIPE_PUBLIC_KEY') as string)
await stripe?.redirectToCheckout({
sessionId: data?.sessionId,
})
}

View File

@ -1,19 +1,26 @@
import { Seo } from '@/components/Seo'
import { useUser } from '@/features/account'
import { upgradePlanQuery } from '@/features/billing'
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'
import { useState, useEffect } from 'react'
import { guessIfUserIsEuropean } from 'utils/pricing'
import { DashboardHeader } from './DashboardHeader'
export const DashboardPage = () => {
const [isLoading, setIsLoading] = useState(false)
const { query } = useRouter()
const { query, push } = useRouter()
const { user } = useUser()
const { workspace } = useWorkspace()
const { mutate: createCheckoutSession } =
trpc.billing.createCheckoutSession.useMutation({
onSuccess: (data) => {
push(data.checkoutUrl)
},
})
useEffect(() => {
const { subscribePlan, chats, storage } = query as {
@ -23,15 +30,17 @@ export const DashboardPage = () => {
}
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
setIsLoading(true)
upgradePlanQuery({
user,
plan: subscribePlan,
createCheckoutSession({
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,
})
}
}, [query, user, workspace])
}, [createCheckoutSession, query, user, workspace])
return (
<Stack minH="100vh">

View File

@ -1,7 +1,5 @@
import { Seo } from '@/components/Seo'
import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { AnalyticsGraphContainer } from '@/features/analytics'
import { useUsage } from '@/features/billing'
import { useTypebot, TypebotHeader } from '@/features/editor'
import { useWorkspace } from '@/features/workspace'
import { useToast } from '@/hooks/useToast'
@ -16,13 +14,10 @@ import {
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { getChatsLimit, getStorageLimit } from 'utils/pricing'
import { useStats } from '../hooks/useStats'
import { ResultsProvider } from '../ResultsProvider'
import { ResultsTableContainer } from './ResultsTableContainer'
const ALERT_CHATS_PERCENT_THRESHOLD = 80
const ALERT_STORAGE_PERCENT_THRESHOLD = 80
import { UsageAlertBanners } from './UsageAlertBanners'
export const ResultsPage = () => {
const router = useRouter()
@ -38,46 +33,6 @@ export const ResultsPage = () => {
typebotId: publishedTypebot?.typebotId,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
const { data: usageData } = useUsage(workspace?.id)
const chatsLimitPercentage = useMemo(() => {
if (!usageData?.totalChatsUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalChatsUsed /
getChatsLimit({
additionalChatsIndex: workspace.additionalChatsIndex,
plan: workspace.plan,
customChatsLimit: workspace.customChatsLimit,
})) *
100
)
}, [
usageData?.totalChatsUsed,
workspace?.additionalChatsIndex,
workspace?.customChatsLimit,
workspace?.plan,
])
const storageLimitPercentage = useMemo(() => {
if (!usageData?.totalStorageUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalStorageUsed /
1024 /
1024 /
1024 /
getStorageLimit({
additionalStorageIndex: workspace.additionalStorageIndex,
plan: workspace.plan,
customStorageLimit: workspace.customStorageLimit,
})) *
100
)
}, [
usageData?.totalStorageUsed,
workspace?.additionalStorageIndex,
workspace?.customStorageLimit,
workspace?.plan,
])
const handleDeletedResults = (total: number) => {
if (!stats) return
@ -100,38 +55,7 @@ export const ResultsPage = () => {
}
/>
<TypebotHeader />
{chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanAlertInfo
status="warning"
contentLabel={
<>
Your workspace collected{' '}
<strong>{chatsLimitPercentage}%</strong> of your total chats
limit this month. Upgrade your plan to continue chatting with
your customers beyond this limit.
</>
}
buttonLabel="Upgrade"
/>
</Flex>
)}
{storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanAlertInfo
status="warning"
contentLabel={
<>
Your workspace collected{' '}
<strong>{storageLimitPercentage}%</strong> of your total storage
allowed. Upgrade your plan or delete some existing results to
continue collecting files from your user beyond this limit.
</>
}
buttonLabel="Upgrade"
/>
</Flex>
)}
{workspace && <UsageAlertBanners workspace={workspace} />}
<Flex h="full" w="full">
<Flex
pos="absolute"

View File

@ -0,0 +1,95 @@
import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { trpc } from '@/lib/trpc'
import { Flex } from '@chakra-ui/react'
import { Workspace } from 'models'
import { useMemo } from 'react'
import { getChatsLimit, getStorageLimit } from 'utils/pricing'
const ALERT_CHATS_PERCENT_THRESHOLD = 80
const ALERT_STORAGE_PERCENT_THRESHOLD = 80
type Props = {
workspace: Workspace
}
export const UsageAlertBanners = ({ workspace }: Props) => {
const { data: usageData } = trpc.billing.getUsage.useQuery({
workspaceId: workspace?.id,
})
const chatsLimitPercentage = useMemo(() => {
if (!usageData?.totalChatsUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalChatsUsed /
getChatsLimit({
additionalChatsIndex: workspace.additionalChatsIndex,
plan: workspace.plan,
customChatsLimit: workspace.customChatsLimit,
})) *
100
)
}, [
usageData?.totalChatsUsed,
workspace?.additionalChatsIndex,
workspace?.customChatsLimit,
workspace?.plan,
])
const storageLimitPercentage = useMemo(() => {
if (!usageData?.totalStorageUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalStorageUsed /
1024 /
1024 /
1024 /
getStorageLimit({
additionalStorageIndex: workspace.additionalStorageIndex,
plan: workspace.plan,
customStorageLimit: workspace.customStorageLimit,
})) *
100
)
}, [
usageData?.totalStorageUsed,
workspace?.additionalStorageIndex,
workspace?.customStorageLimit,
workspace?.plan,
])
return (
<>
{chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanAlertInfo
status="warning"
contentLabel={
<>
Your workspace collected{' '}
<strong>{chatsLimitPercentage}%</strong> of your total chats
limit this month. Upgrade your plan to continue chatting with
your customers beyond this limit.
</>
}
buttonLabel="Upgrade"
/>
</Flex>
)}
{storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanAlertInfo
status="warning"
contentLabel={
<>
Your workspace collected{' '}
<strong>{storageLimitPercentage}%</strong> of your total storage
allowed. Upgrade your plan or delete some existing results to
continue collecting files from your user beyond this limit.
</>
}
buttonLabel="Upgrade"
/>
</Flex>
)}
</>
)
}

View File

@ -158,6 +158,7 @@ export const WorkspaceProvider = ({
const refreshWorkspace = () => {
trpcContext.workspace.getWorkspace.invalidate()
trpcContext.billing.getSubscription.invalidate()
}
return (

View File

@ -10,6 +10,7 @@ import { getAuthenticatedUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { WorkspaceRole } from 'db'
// TO-DO: Delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)

View File

@ -10,6 +10,7 @@ import { getAuthenticatedUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { WorkspaceRole } from 'db'
// TODO: Delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)

View File

@ -11,6 +11,7 @@ import { getAuthenticatedUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { Plan, WorkspaceRole } from 'db'
// TODO: Delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)

View File

@ -11,5 +11,9 @@ export default createNextApiHandler({
captureException(error)
console.error('Something went wrong', error)
}
return error
},
batching: {
enabled: true,
},
})

View File

@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from '@/features/auth/api'
import { methodNotAllowed, notAuthenticated } from 'utils/api'
// TODO: Delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)

View File

@ -1,3 +1,4 @@
import { billingRouter } from '@/features/billing/api/router'
import { webhookRouter } from '@/features/blocks/integrations/webhook/api'
import { resultsRouter } from '@/features/results/api'
import { typebotRouter } from '@/features/typebot/api'
@ -9,6 +10,7 @@ export const trpcRouter = router({
typebot: typebotRouter,
webhook: webhookRouter,
results: resultsRouter,
billing: billingRouter,
})
export type AppRouter = typeof trpcRouter

View File

@ -8,7 +8,7 @@ const t = initTRPC.context<Context>().meta<OpenApiMeta>().create({
})
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user) {
if (!ctx.user?.id) {
throw new TRPCError({
code: 'UNAUTHORIZED',
})

View File

@ -1400,6 +1400,563 @@
}
}
}
},
"/billing/subscription/portal": {
"get": {
"operationId": "query.billing.getBillingPortalUrl",
"summary": "Get Stripe billing portal URL",
"tags": [
"Billing"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"billingPortalUrl": {
"type": "string"
}
},
"required": [
"billingPortalUrl"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
},
"/billing/invoices": {
"get": {
"operationId": "query.billing.listInvoices",
"summary": "List invoices",
"tags": [
"Billing"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"invoices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"url": {
"type": "string"
},
"amount": {
"type": "number"
},
"currency": {
"type": "string"
},
"date": {
"type": "number",
"nullable": true
}
},
"required": [
"id",
"url",
"amount",
"currency",
"date"
],
"additionalProperties": false
}
}
},
"required": [
"invoices"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
},
"/billing/subscription": {
"delete": {
"operationId": "mutation.billing.cancelSubscription",
"summary": "Cancel current subscription",
"tags": [
"Billing"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"enum": [
"success"
]
}
},
"required": [
"message"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
},
"patch": {
"operationId": "mutation.billing.updateSubscription",
"summary": "Update subscription",
"tags": [
"Billing"
],
"security": [
{
"Authorization": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"workspaceId": {
"type": "string"
},
"plan": {
"type": "string",
"enum": [
"STARTER",
"PRO"
]
},
"additionalChats": {
"type": "number"
},
"additionalStorage": {
"type": "number"
},
"currency": {
"type": "string",
"enum": [
"usd",
"eur"
]
}
},
"required": [
"workspaceId",
"plan",
"additionalChats",
"additionalStorage",
"currency"
],
"additionalProperties": false
}
}
}
},
"parameters": [],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"workspace": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
},
"name": {
"type": "string"
},
"icon": {
"type": "string",
"nullable": true
},
"plan": {
"type": "string",
"enum": [
"FREE",
"STARTER",
"PRO",
"LIFETIME",
"OFFERED",
"CUSTOM",
"UNLIMITED"
]
},
"stripeId": {
"type": "string",
"nullable": true
},
"additionalChatsIndex": {
"type": "number"
},
"additionalStorageIndex": {
"type": "number"
},
"chatsLimitFirstEmailSentAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"chatsLimitSecondEmailSentAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"storageLimitFirstEmailSentAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"storageLimitSecondEmailSentAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"customChatsLimit": {
"type": "number",
"nullable": true
},
"customStorageLimit": {
"type": "number",
"nullable": true
},
"customSeatsLimit": {
"type": "number",
"nullable": true
}
},
"required": [
"id",
"createdAt",
"updatedAt",
"name",
"icon",
"plan",
"stripeId",
"additionalChatsIndex",
"additionalStorageIndex",
"chatsLimitFirstEmailSentAt",
"chatsLimitSecondEmailSentAt",
"storageLimitFirstEmailSentAt",
"storageLimitSecondEmailSentAt",
"customChatsLimit",
"customStorageLimit",
"customSeatsLimit"
],
"additionalProperties": false
}
},
"required": [
"workspace"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
},
"get": {
"operationId": "query.billing.getSubscription",
"summary": "List invoices",
"tags": [
"Billing"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"subscription": {
"type": "object",
"properties": {
"additionalChatsIndex": {
"type": "number"
},
"additionalStorageIndex": {
"type": "number"
},
"currency": {
"type": "string",
"enum": [
"eur",
"usd"
]
}
},
"required": [
"additionalChatsIndex",
"additionalStorageIndex",
"currency"
],
"additionalProperties": false
}
},
"required": [
"subscription"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
},
"/billing/subscription/checkout": {
"post": {
"operationId": "mutation.billing.createCheckoutSession",
"summary": "Create checkout session to create a new subscription",
"tags": [
"Billing"
],
"security": [
{
"Authorization": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"workspaceId": {
"type": "string"
},
"prefilledEmail": {
"type": "string"
},
"currency": {
"type": "string",
"enum": [
"usd",
"eur"
]
},
"plan": {
"type": "string",
"enum": [
"STARTER",
"PRO"
]
},
"returnUrl": {
"type": "string"
},
"additionalChats": {
"type": "number"
},
"additionalStorage": {
"type": "number"
}
},
"required": [
"workspaceId",
"currency",
"plan",
"returnUrl",
"additionalChats",
"additionalStorage"
],
"additionalProperties": false
}
}
}
},
"parameters": [],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"checkoutUrl": {
"type": "string"
}
},
"required": [
"checkoutUrl"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
},
"/billing/usage": {
"get": {
"operationId": "query.billing.getUsage",
"summary": "Get current plan usage",
"tags": [
"Billing"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"totalChatsUsed": {
"type": "number"
},
"totalStorageUsed": {
"type": "number"
}
},
"required": [
"totalChatsUsed",
"totalStorageUsed"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
}
},
"components": {

View File

@ -0,0 +1,11 @@
import { z } from 'zod'
export const invoiceSchema = z.object({
id: z.string(),
url: z.string(),
amount: z.number(),
currency: z.string(),
date: z.number().nullable(),
})
export type Invoice = z.infer<typeof invoiceSchema>

View File

@ -0,0 +1,9 @@
import { z } from 'zod'
export const subscriptionSchema = z.object({
additionalChatsIndex: z.number(),
additionalStorageIndex: z.number(),
currency: z.enum(['eur', 'usd']),
})
export type Subscription = z.infer<typeof subscriptionSchema>