♻️ (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} />
|
<UsageContent workspace={workspace} />
|
||||||
<Stack spacing="4">
|
<Stack spacing="4">
|
||||||
<CurrentSubscriptionContent
|
<CurrentSubscriptionContent
|
||||||
plan={workspace.plan}
|
workspace={workspace}
|
||||||
stripeId={workspace.stripeId}
|
|
||||||
onCancelSuccess={refreshWorkspace}
|
onCancelSuccess={refreshWorkspace}
|
||||||
/>
|
/>
|
||||||
<HStack maxW="500px">
|
<HStack maxW="500px">
|
||||||
@ -35,10 +34,15 @@ export const BillingContent = () => {
|
|||||||
{workspace.plan !== Plan.CUSTOM &&
|
{workspace.plan !== Plan.CUSTOM &&
|
||||||
workspace.plan !== Plan.LIFETIME &&
|
workspace.plan !== Plan.LIFETIME &&
|
||||||
workspace.plan !== Plan.UNLIMITED &&
|
workspace.plan !== Plan.UNLIMITED &&
|
||||||
workspace.plan !== Plan.OFFERED && <ChangePlanForm />}
|
workspace.plan !== Plan.OFFERED && (
|
||||||
|
<ChangePlanForm
|
||||||
|
workspace={workspace}
|
||||||
|
onUpgradeSuccess={refreshWorkspace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{workspace.stripeId && <InvoicesList workspace={workspace} />}
|
{workspace.stripeId && <InvoicesList workspaceId={workspace.id} />}
|
||||||
</Stack>
|
</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 {
|
import { Text, HStack, Link, Spinner, Stack, Heading } from '@chakra-ui/react'
|
||||||
Text,
|
|
||||||
HStack,
|
|
||||||
Link,
|
|
||||||
Spinner,
|
|
||||||
Stack,
|
|
||||||
Button,
|
|
||||||
Heading,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { useToast } from '@/hooks/useToast'
|
import { useToast } from '@/hooks/useToast'
|
||||||
import { Plan } from 'db'
|
import { Plan } from 'db'
|
||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import { cancelSubscriptionQuery } from './queries/cancelSubscriptionQuery'
|
|
||||||
import { PlanTag } from '../PlanTag'
|
import { PlanTag } from '../PlanTag'
|
||||||
|
import { BillingPortalButton } from './BillingPortalButton'
|
||||||
|
import { trpc } from '@/lib/trpc'
|
||||||
|
import { Workspace } from 'models'
|
||||||
|
|
||||||
type CurrentSubscriptionContentProps = {
|
type CurrentSubscriptionContentProps = {
|
||||||
plan: Plan
|
workspace: Pick<Workspace, 'id' | 'plan' | 'stripeId'>
|
||||||
stripeId?: string | null
|
|
||||||
onCancelSuccess: () => void
|
onCancelSuccess: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CurrentSubscriptionContent = ({
|
export const CurrentSubscriptionContent = ({
|
||||||
plan,
|
workspace,
|
||||||
stripeId,
|
|
||||||
onCancelSuccess,
|
onCancelSuccess,
|
||||||
}: CurrentSubscriptionContentProps) => {
|
}: CurrentSubscriptionContentProps) => {
|
||||||
const [isCancelling, setIsCancelling] = useState(false)
|
|
||||||
const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] =
|
|
||||||
useState(false)
|
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
|
||||||
const cancelSubscription = async () => {
|
const { mutate: cancelSubscription, isLoading: isCancelling } =
|
||||||
if (!stripeId) return
|
trpc.billing.cancelSubscription.useMutation({
|
||||||
setIsCancelling(true)
|
onError: (error) => {
|
||||||
const { error } = await cancelSubscriptionQuery(stripeId)
|
showToast({
|
||||||
if (error) {
|
description: error.message,
|
||||||
showToast({ description: error.message })
|
})
|
||||||
return
|
},
|
||||||
}
|
onSuccess: onCancelSuccess,
|
||||||
onCancelSuccess()
|
})
|
||||||
setIsCancelling(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSubscribed = (plan === Plan.STARTER || plan === Plan.PRO) && stripeId
|
const isSubscribed =
|
||||||
|
(workspace.plan === Plan.STARTER || workspace.plan === Plan.PRO) &&
|
||||||
|
workspace.stripeId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing="4">
|
<Stack spacing="4">
|
||||||
@ -52,14 +41,16 @@ export const CurrentSubscriptionContent = ({
|
|||||||
<Spinner color="gray.500" size="xs" />
|
<Spinner color="gray.500" size="xs" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PlanTag plan={plan} />
|
<PlanTag plan={workspace.plan} />
|
||||||
{isSubscribed && (
|
{isSubscribed && (
|
||||||
<Link
|
<Link
|
||||||
as="button"
|
as="button"
|
||||||
color="gray.500"
|
color="gray.500"
|
||||||
textDecor="underline"
|
textDecor="underline"
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
onClick={cancelSubscription}
|
onClick={() =>
|
||||||
|
cancelSubscription({ workspaceId: workspace.id })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Cancel my subscription
|
Cancel my subscription
|
||||||
</Link>
|
</Link>
|
||||||
@ -75,14 +66,7 @@ export const CurrentSubscriptionContent = ({
|
|||||||
Need to change payment method or billing information? Head over to
|
Need to change payment method or billing information? Head over to
|
||||||
your billing portal:
|
your billing portal:
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<BillingPortalButton workspaceId={workspace.id} />
|
||||||
as={Link}
|
|
||||||
href={`/api/stripe/billing-portal?stripeId=${stripeId}`}
|
|
||||||
onClick={() => setIsRedirectingToBillingPortal(true)}
|
|
||||||
isLoading={isRedirectingToBillingPortal}
|
|
||||||
>
|
|
||||||
Billing Portal
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -14,23 +14,32 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { DownloadIcon, FileIcon } from '@/components/icons'
|
import { DownloadIcon, FileIcon } from '@/components/icons'
|
||||||
import { Workspace } from 'db'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useInvoicesQuery } from './queries/useInvoicesQuery'
|
import { trpc } from '@/lib/trpc'
|
||||||
import { isDefined } from 'utils'
|
import { useToast } from '@/hooks/useToast'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: Workspace
|
workspaceId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InvoicesList = ({ workspace }: Props) => {
|
export const InvoicesList = ({ workspaceId }: Props) => {
|
||||||
const { invoices, isLoading } = useInvoicesQuery(workspace.stripeId)
|
const { showToast } = useToast()
|
||||||
|
const { data, status } = trpc.billing.listInvoices.useQuery(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
showToast({ description: error.message })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={6}>
|
<Stack spacing={6}>
|
||||||
<Heading fontSize="3xl">Invoices</Heading>
|
<Heading fontSize="3xl">Invoices</Heading>
|
||||||
{invoices.length === 0 && !isLoading ? (
|
{data?.invoices.length === 0 && status !== 'loading' ? (
|
||||||
<Text>No invoices found for this workspace.</Text>
|
<Text>No invoices found for this workspace.</Text>
|
||||||
) : (
|
) : (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
@ -45,34 +54,34 @@ export const InvoicesList = ({ workspace }: Props) => {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{invoices
|
{data?.invoices.map((invoice) => (
|
||||||
?.filter((invoice) => isDefined(invoice.url))
|
<Tr key={invoice.id}>
|
||||||
.map((invoice) => (
|
<Td>
|
||||||
<Tr key={invoice.id}>
|
<FileIcon />
|
||||||
<Td>
|
</Td>
|
||||||
<FileIcon />
|
<Td>{invoice.id}</Td>
|
||||||
</Td>
|
<Td>
|
||||||
<Td>{invoice.id}</Td>
|
{invoice.date
|
||||||
<Td>{new Date(invoice.date * 1000).toDateString()}</Td>
|
? new Date(invoice.date * 1000).toDateString()
|
||||||
<Td>
|
: ''}
|
||||||
{getFormattedPrice(invoice.amount, invoice.currency)}
|
</Td>
|
||||||
</Td>
|
<Td>{getFormattedPrice(invoice.amount, invoice.currency)}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
{invoice.url && (
|
{invoice.url && (
|
||||||
<IconButton
|
<IconButton
|
||||||
as={Link}
|
as={Link}
|
||||||
size="xs"
|
size="xs"
|
||||||
icon={<DownloadIcon />}
|
icon={<DownloadIcon />}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
href={invoice.url}
|
href={invoice.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
aria-label={'Download invoice'}
|
aria-label={'Download invoice'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
))}
|
))}
|
||||||
{isLoading &&
|
{status === 'loading' &&
|
||||||
Array.from({ length: 3 }).map((_, idx) => (
|
Array.from({ length: 3 }).map((_, idx) => (
|
||||||
<Tr key={idx}>
|
<Tr key={idx}>
|
||||||
<Td>
|
<Td>
|
||||||
|
@ -14,14 +14,16 @@ import React from 'react'
|
|||||||
import { parseNumberWithCommas } from 'utils'
|
import { parseNumberWithCommas } from 'utils'
|
||||||
import { getChatsLimit, getStorageLimit } from 'utils/pricing'
|
import { getChatsLimit, getStorageLimit } from 'utils/pricing'
|
||||||
import { storageToReadable } from './helpers'
|
import { storageToReadable } from './helpers'
|
||||||
import { useUsage } from '../../../hooks/useUsage'
|
import { trpc } from '@/lib/trpc'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: Workspace
|
workspace: Workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UsageContent = ({ workspace }: Props) => {
|
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 totalChatsUsed = data?.totalChatsUsed ?? 0
|
||||||
const totalStorageUsed = data?.totalStorageUsed ?? 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 { Stack, HStack, Text } from '@chakra-ui/react'
|
||||||
import { useUser } from '@/features/account'
|
import { useUser } from '@/features/account'
|
||||||
import { useWorkspace } from '@/features/workspace'
|
|
||||||
import { Plan } from 'db'
|
import { Plan } from 'db'
|
||||||
import { ProPlanContent } from './ProPlanContent'
|
import { ProPlanContent } from './ProPlanContent'
|
||||||
import { upgradePlanQuery } from '../../queries/upgradePlanQuery'
|
|
||||||
import { useCurrentSubscriptionInfo } from '../../hooks/useCurrentSubscriptionInfo'
|
|
||||||
import { StarterPlanContent } from './StarterPlanContent'
|
import { StarterPlanContent } from './StarterPlanContent'
|
||||||
import { TextLink } from '@/components/TextLink'
|
import { TextLink } from '@/components/TextLink'
|
||||||
import { useToast } from '@/hooks/useToast'
|
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 { user } = useUser()
|
||||||
const { workspace, refreshWorkspace } = useWorkspace()
|
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const { data, mutate: refreshCurrentSubscriptionInfo } =
|
const { data } = trpc.billing.getSubscription.useQuery({
|
||||||
useCurrentSubscriptionInfo({
|
workspaceId: workspace.id,
|
||||||
stripeId: workspace?.stripeId,
|
})
|
||||||
plan: workspace?.plan,
|
|
||||||
|
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 ({
|
const handlePayClick = async ({
|
||||||
@ -30,33 +62,29 @@ export const ChangePlanForm = () => {
|
|||||||
}) => {
|
}) => {
|
||||||
if (
|
if (
|
||||||
!user ||
|
!user ||
|
||||||
!workspace ||
|
|
||||||
selectedChatsLimitIndex === undefined ||
|
selectedChatsLimitIndex === undefined ||
|
||||||
selectedStorageLimitIndex === undefined
|
selectedStorageLimitIndex === undefined
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
const response = await upgradePlanQuery({
|
|
||||||
stripeId: workspace.stripeId ?? undefined,
|
const newSubscription = {
|
||||||
user,
|
|
||||||
plan,
|
plan,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
additionalChats: selectedChatsLimitIndex,
|
additionalChats: selectedChatsLimitIndex,
|
||||||
additionalStorage: selectedStorageLimitIndex,
|
additionalStorage: selectedStorageLimitIndex,
|
||||||
currency: data?.currency,
|
currency:
|
||||||
})
|
data?.subscription.currency ??
|
||||||
if (typeof response === 'object' && response?.error) {
|
(guessIfUserIsEuropean() ? 'eur' : 'usd'),
|
||||||
showToast({ description: response.error.message })
|
} as const
|
||||||
return
|
if (workspace.stripeId) {
|
||||||
|
updateSubscription(newSubscription)
|
||||||
|
} else {
|
||||||
|
createCheckoutSession({
|
||||||
|
...newSubscription,
|
||||||
|
returnUrl: window.location.href,
|
||||||
|
prefilledEmail: user.email ?? undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
refreshCurrentSubscriptionInfo({
|
|
||||||
additionalChatsIndex: selectedChatsLimitIndex,
|
|
||||||
additionalStorageIndex: selectedStorageLimitIndex,
|
|
||||||
})
|
|
||||||
refreshWorkspace()
|
|
||||||
showToast({
|
|
||||||
status: 'success',
|
|
||||||
description: `Workspace ${plan} plan successfully updated 🎉`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -64,26 +92,36 @@ export const ChangePlanForm = () => {
|
|||||||
<HStack alignItems="stretch" spacing="4" w="full">
|
<HStack alignItems="stretch" spacing="4" w="full">
|
||||||
<StarterPlanContent
|
<StarterPlanContent
|
||||||
initialChatsLimitIndex={
|
initialChatsLimitIndex={
|
||||||
workspace?.plan === Plan.STARTER ? data?.additionalChatsIndex : 0
|
workspace?.plan === Plan.STARTER
|
||||||
|
? data?.subscription.additionalChatsIndex
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
initialStorageLimitIndex={
|
initialStorageLimitIndex={
|
||||||
workspace?.plan === Plan.STARTER ? data?.additionalStorageIndex : 0
|
workspace?.plan === Plan.STARTER
|
||||||
|
? data?.subscription.additionalStorageIndex
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
onPayClick={(props) =>
|
onPayClick={(props) =>
|
||||||
handlePayClick({ ...props, plan: Plan.STARTER })
|
handlePayClick({ ...props, plan: Plan.STARTER })
|
||||||
}
|
}
|
||||||
currency={data?.currency}
|
isLoading={isCreatingCheckout || isUpdatingSubscription}
|
||||||
|
currency={data?.subscription.currency}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProPlanContent
|
<ProPlanContent
|
||||||
initialChatsLimitIndex={
|
initialChatsLimitIndex={
|
||||||
workspace?.plan === Plan.PRO ? data?.additionalChatsIndex : 0
|
workspace?.plan === Plan.PRO
|
||||||
|
? data?.subscription.additionalChatsIndex
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
initialStorageLimitIndex={
|
initialStorageLimitIndex={
|
||||||
workspace?.plan === Plan.PRO ? data?.additionalStorageIndex : 0
|
workspace?.plan === Plan.PRO
|
||||||
|
? data?.subscription.additionalStorageIndex
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
|
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
|
||||||
currency={data?.currency}
|
isLoading={isCreatingCheckout || isUpdatingSubscription}
|
||||||
|
currency={data?.subscription.currency}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Text color="gray.500">
|
<Text color="gray.500">
|
||||||
|
@ -34,16 +34,18 @@ type ProPlanContentProps = {
|
|||||||
initialChatsLimitIndex?: number
|
initialChatsLimitIndex?: number
|
||||||
initialStorageLimitIndex?: number
|
initialStorageLimitIndex?: number
|
||||||
currency?: 'usd' | 'eur'
|
currency?: 'usd' | 'eur'
|
||||||
|
isLoading: boolean
|
||||||
onPayClick: (props: {
|
onPayClick: (props: {
|
||||||
selectedChatsLimitIndex: number
|
selectedChatsLimitIndex: number
|
||||||
selectedStorageLimitIndex: number
|
selectedStorageLimitIndex: number
|
||||||
}) => Promise<void>
|
}) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProPlanContent = ({
|
export const ProPlanContent = ({
|
||||||
initialChatsLimitIndex,
|
initialChatsLimitIndex,
|
||||||
initialStorageLimitIndex,
|
initialStorageLimitIndex,
|
||||||
currency,
|
currency,
|
||||||
|
isLoading,
|
||||||
onPayClick,
|
onPayClick,
|
||||||
}: ProPlanContentProps) => {
|
}: ProPlanContentProps) => {
|
||||||
const { workspace } = useWorkspace()
|
const { workspace } = useWorkspace()
|
||||||
@ -51,7 +53,6 @@ export const ProPlanContent = ({
|
|||||||
useState<number>()
|
useState<number>()
|
||||||
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
||||||
useState<number>()
|
useState<number>()
|
||||||
const [isPaying, setIsPaying] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -110,12 +111,10 @@ export const ProPlanContent = ({
|
|||||||
selectedStorageLimitIndex === undefined
|
selectedStorageLimitIndex === undefined
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
setIsPaying(true)
|
onPayClick({
|
||||||
await onPayClick({
|
|
||||||
selectedChatsLimitIndex,
|
selectedChatsLimitIndex,
|
||||||
selectedStorageLimitIndex,
|
selectedStorageLimitIndex,
|
||||||
})
|
})
|
||||||
setIsPaying(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -335,7 +334,7 @@ export const ProPlanContent = ({
|
|||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handlePayClick}
|
onClick={handlePayClick}
|
||||||
isLoading={isPaying}
|
isLoading={isLoading}
|
||||||
isDisabled={isCurrentPlan}
|
isDisabled={isCurrentPlan}
|
||||||
>
|
>
|
||||||
{getButtonLabel()}
|
{getButtonLabel()}
|
||||||
|
@ -30,15 +30,17 @@ type StarterPlanContentProps = {
|
|||||||
initialChatsLimitIndex?: number
|
initialChatsLimitIndex?: number
|
||||||
initialStorageLimitIndex?: number
|
initialStorageLimitIndex?: number
|
||||||
currency?: 'eur' | 'usd'
|
currency?: 'eur' | 'usd'
|
||||||
|
isLoading?: boolean
|
||||||
onPayClick: (props: {
|
onPayClick: (props: {
|
||||||
selectedChatsLimitIndex: number
|
selectedChatsLimitIndex: number
|
||||||
selectedStorageLimitIndex: number
|
selectedStorageLimitIndex: number
|
||||||
}) => Promise<void>
|
}) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StarterPlanContent = ({
|
export const StarterPlanContent = ({
|
||||||
initialChatsLimitIndex,
|
initialChatsLimitIndex,
|
||||||
initialStorageLimitIndex,
|
initialStorageLimitIndex,
|
||||||
|
isLoading,
|
||||||
currency,
|
currency,
|
||||||
onPayClick,
|
onPayClick,
|
||||||
}: StarterPlanContentProps) => {
|
}: StarterPlanContentProps) => {
|
||||||
@ -47,7 +49,6 @@ export const StarterPlanContent = ({
|
|||||||
useState<number>()
|
useState<number>()
|
||||||
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
||||||
useState<number>()
|
useState<number>()
|
||||||
const [isPaying, setIsPaying] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -107,12 +108,10 @@ export const StarterPlanContent = ({
|
|||||||
selectedStorageLimitIndex === undefined
|
selectedStorageLimitIndex === undefined
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
setIsPaying(true)
|
onPayClick({
|
||||||
await onPayClick({
|
|
||||||
selectedChatsLimitIndex,
|
selectedChatsLimitIndex,
|
||||||
selectedStorageLimitIndex,
|
selectedStorageLimitIndex,
|
||||||
})
|
})
|
||||||
setIsPaying(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -278,7 +277,7 @@ export const StarterPlanContent = ({
|
|||||||
colorScheme="orange"
|
colorScheme="orange"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handlePayClick}
|
onClick={handlePayClick}
|
||||||
isLoading={isPaying}
|
isLoading={isLoading}
|
||||||
isDisabled={isCurrentPlan}
|
isDisabled={isCurrentPlan}
|
||||||
>
|
>
|
||||||
{getButtonLabel()}
|
{getButtonLabel()}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AlertInfo } from '@/components/AlertInfo'
|
import { AlertInfo } from '@/components/AlertInfo'
|
||||||
|
import { useWorkspace } from '@/features/workspace'
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
@ -30,6 +31,7 @@ export const ChangePlanModal = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
type,
|
type,
|
||||||
}: ChangePlanModalProps) => {
|
}: ChangePlanModalProps) => {
|
||||||
|
const { workspace, refreshWorkspace } = useWorkspace()
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
|
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
@ -40,7 +42,12 @@ export const ChangePlanModal = ({
|
|||||||
You need to upgrade your plan in order to {type}
|
You need to upgrade your plan in order to {type}
|
||||||
</AlertInfo>
|
</AlertInfo>
|
||||||
)}
|
)}
|
||||||
<ChangePlanForm />
|
{workspace && (
|
||||||
|
<ChangePlanForm
|
||||||
|
workspace={workspace}
|
||||||
|
onUpgradeSuccess={refreshWorkspace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<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 { ChangePlanModal, LimitReached } from './components/ChangePlanModal'
|
||||||
export { planToReadable, isFreePlan, isProPlan } from './utils'
|
export { planToReadable, isFreePlan, isProPlan } from './utils'
|
||||||
export { upgradePlanQuery } from './queries/upgradePlanQuery'
|
|
||||||
export { BillingContent } from './components/BillingContent'
|
export { BillingContent } from './components/BillingContent'
|
||||||
export { LockTag } from './components/LockTag'
|
export { LockTag } from './components/LockTag'
|
||||||
export { useUsage } from './hooks/useUsage'
|
|
||||||
export { UpgradeButton } from './components/UpgradeButton'
|
export { UpgradeButton } from './components/UpgradeButton'
|
||||||
export { PlanTag } from './components/PlanTag'
|
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 { Seo } from '@/components/Seo'
|
||||||
import { useUser } from '@/features/account'
|
import { useUser } from '@/features/account'
|
||||||
import { upgradePlanQuery } from '@/features/billing'
|
|
||||||
import { TypebotDndProvider, FolderContent } from '@/features/folders'
|
import { TypebotDndProvider, FolderContent } from '@/features/folders'
|
||||||
import { useWorkspace } from '@/features/workspace'
|
import { useWorkspace } from '@/features/workspace'
|
||||||
|
import { trpc } from '@/lib/trpc'
|
||||||
import { Stack, VStack, Spinner, Text } from '@chakra-ui/react'
|
import { Stack, VStack, Spinner, Text } from '@chakra-ui/react'
|
||||||
import { Plan } from 'db'
|
import { Plan } from 'db'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { guessIfUserIsEuropean } from 'utils/pricing'
|
||||||
import { DashboardHeader } from './DashboardHeader'
|
import { DashboardHeader } from './DashboardHeader'
|
||||||
|
|
||||||
export const DashboardPage = () => {
|
export const DashboardPage = () => {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { query } = useRouter()
|
const { query, push } = useRouter()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
const { workspace } = useWorkspace()
|
const { workspace } = useWorkspace()
|
||||||
|
const { mutate: createCheckoutSession } =
|
||||||
|
trpc.billing.createCheckoutSession.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
push(data.checkoutUrl)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { subscribePlan, chats, storage } = query as {
|
const { subscribePlan, chats, storage } = query as {
|
||||||
@ -23,15 +30,17 @@ export const DashboardPage = () => {
|
|||||||
}
|
}
|
||||||
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
|
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
upgradePlanQuery({
|
createCheckoutSession({
|
||||||
user,
|
plan: subscribePlan as 'PRO' | 'STARTER',
|
||||||
plan: subscribePlan,
|
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
additionalChats: chats ? parseInt(chats) : 0,
|
additionalChats: chats ? parseInt(chats) : 0,
|
||||||
additionalStorage: storage ? parseInt(storage) : 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 (
|
return (
|
||||||
<Stack minH="100vh">
|
<Stack minH="100vh">
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { Seo } from '@/components/Seo'
|
import { Seo } from '@/components/Seo'
|
||||||
import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
|
|
||||||
import { AnalyticsGraphContainer } from '@/features/analytics'
|
import { AnalyticsGraphContainer } from '@/features/analytics'
|
||||||
import { useUsage } from '@/features/billing'
|
|
||||||
import { useTypebot, TypebotHeader } from '@/features/editor'
|
import { useTypebot, TypebotHeader } from '@/features/editor'
|
||||||
import { useWorkspace } from '@/features/workspace'
|
import { useWorkspace } from '@/features/workspace'
|
||||||
import { useToast } from '@/hooks/useToast'
|
import { useToast } from '@/hooks/useToast'
|
||||||
@ -16,13 +14,10 @@ import {
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { getChatsLimit, getStorageLimit } from 'utils/pricing'
|
|
||||||
import { useStats } from '../hooks/useStats'
|
import { useStats } from '../hooks/useStats'
|
||||||
import { ResultsProvider } from '../ResultsProvider'
|
import { ResultsProvider } from '../ResultsProvider'
|
||||||
import { ResultsTableContainer } from './ResultsTableContainer'
|
import { ResultsTableContainer } from './ResultsTableContainer'
|
||||||
|
import { UsageAlertBanners } from './UsageAlertBanners'
|
||||||
const ALERT_CHATS_PERCENT_THRESHOLD = 80
|
|
||||||
const ALERT_STORAGE_PERCENT_THRESHOLD = 80
|
|
||||||
|
|
||||||
export const ResultsPage = () => {
|
export const ResultsPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -38,46 +33,6 @@ export const ResultsPage = () => {
|
|||||||
typebotId: publishedTypebot?.typebotId,
|
typebotId: publishedTypebot?.typebotId,
|
||||||
onError: (err) => showToast({ title: err.name, description: err.message }),
|
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) => {
|
const handleDeletedResults = (total: number) => {
|
||||||
if (!stats) return
|
if (!stats) return
|
||||||
@ -100,38 +55,7 @@ export const ResultsPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TypebotHeader />
|
<TypebotHeader />
|
||||||
{chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && (
|
{workspace && <UsageAlertBanners workspace={workspace} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<Flex h="full" w="full">
|
<Flex h="full" w="full">
|
||||||
<Flex
|
<Flex
|
||||||
pos="absolute"
|
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 = () => {
|
const refreshWorkspace = () => {
|
||||||
trpcContext.workspace.getWorkspace.invalidate()
|
trpcContext.workspace.getWorkspace.invalidate()
|
||||||
|
trpcContext.billing.getSubscription.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -10,6 +10,7 @@ import { getAuthenticatedUser } from '@/features/auth/api'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { WorkspaceRole } from 'db'
|
import { WorkspaceRole } from 'db'
|
||||||
|
|
||||||
|
// TO-DO: Delete
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const user = await getAuthenticatedUser(req)
|
const user = await getAuthenticatedUser(req)
|
||||||
if (!user) return notAuthenticated(res)
|
if (!user) return notAuthenticated(res)
|
||||||
|
@ -10,6 +10,7 @@ import { getAuthenticatedUser } from '@/features/auth/api'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { WorkspaceRole } from 'db'
|
import { WorkspaceRole } from 'db'
|
||||||
|
|
||||||
|
// TODO: Delete
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const user = await getAuthenticatedUser(req)
|
const user = await getAuthenticatedUser(req)
|
||||||
if (!user) return notAuthenticated(res)
|
if (!user) return notAuthenticated(res)
|
||||||
|
@ -11,6 +11,7 @@ import { getAuthenticatedUser } from '@/features/auth/api'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { Plan, WorkspaceRole } from 'db'
|
import { Plan, WorkspaceRole } from 'db'
|
||||||
|
|
||||||
|
// TODO: Delete
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const user = await getAuthenticatedUser(req)
|
const user = await getAuthenticatedUser(req)
|
||||||
if (!user) return notAuthenticated(res)
|
if (!user) return notAuthenticated(res)
|
||||||
|
@ -11,5 +11,9 @@ export default createNextApiHandler({
|
|||||||
captureException(error)
|
captureException(error)
|
||||||
console.error('Something went wrong', 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 { getAuthenticatedUser } from '@/features/auth/api'
|
||||||
import { methodNotAllowed, notAuthenticated } from 'utils/api'
|
import { methodNotAllowed, notAuthenticated } from 'utils/api'
|
||||||
|
|
||||||
|
// TODO: Delete
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const user = await getAuthenticatedUser(req)
|
const user = await getAuthenticatedUser(req)
|
||||||
if (!user) return notAuthenticated(res)
|
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 { webhookRouter } from '@/features/blocks/integrations/webhook/api'
|
||||||
import { resultsRouter } from '@/features/results/api'
|
import { resultsRouter } from '@/features/results/api'
|
||||||
import { typebotRouter } from '@/features/typebot/api'
|
import { typebotRouter } from '@/features/typebot/api'
|
||||||
@ -9,6 +10,7 @@ export const trpcRouter = router({
|
|||||||
typebot: typebotRouter,
|
typebot: typebotRouter,
|
||||||
webhook: webhookRouter,
|
webhook: webhookRouter,
|
||||||
results: resultsRouter,
|
results: resultsRouter,
|
||||||
|
billing: billingRouter,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof trpcRouter
|
export type AppRouter = typeof trpcRouter
|
||||||
|
@ -8,7 +8,7 @@ const t = initTRPC.context<Context>().meta<OpenApiMeta>().create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isAuthed = t.middleware(({ next, ctx }) => {
|
const isAuthed = t.middleware(({ next, ctx }) => {
|
||||||
if (!ctx.user) {
|
if (!ctx.user?.id) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'UNAUTHORIZED',
|
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": {
|
"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