♻️ (billing) Refactor billing server code to trpc
This commit is contained in:
@ -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' }
|
||||
})
|
@ -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,
|
||||
}
|
||||
}
|
||||
)
|
@ -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,
|
||||
}
|
||||
})
|
@ -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',
|
||||
},
|
||||
}
|
||||
})
|
94
apps/builder/src/features/billing/api/procedures/getUsage.ts
Normal file
94
apps/builder/src/features/billing/api/procedures/getUsage.ts
Normal 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,
|
||||
}
|
||||
})
|
@ -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,
|
||||
})),
|
||||
}
|
||||
})
|
@ -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 }
|
||||
}
|
||||
)
|
18
apps/builder/src/features/billing/api/router.ts
Normal file
18
apps/builder/src/features/billing/api/router.ts
Normal 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,
|
||||
})
|
@ -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,
|
||||
},
|
||||
]
|
||||
: []
|
||||
)
|
@ -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,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>
|
||||
|
@ -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,
|
||||
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">
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -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'
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
@ -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">
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -158,6 +158,7 @@ export const WorkspaceProvider = ({
|
||||
|
||||
const refreshWorkspace = () => {
|
||||
trpcContext.workspace.getWorkspace.invalidate()
|
||||
trpcContext.billing.getSubscription.invalidate()
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -11,5 +11,9 @@ export default createNextApiHandler({
|
||||
captureException(error)
|
||||
console.error('Something went wrong', error)
|
||||
}
|
||||
return error
|
||||
},
|
||||
batching: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
})
|
||||
|
@ -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": {
|
||||
|
11
packages/models/features/billing/invoice.ts
Normal file
11
packages/models/features/billing/invoice.ts
Normal 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>
|
9
packages/models/features/billing/subscription.ts
Normal file
9
packages/models/features/billing/subscription.ts
Normal 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>
|
Reference in New Issue
Block a user