From b73282d8108dbeca7d09e46920d627d07ed52c1a Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Fri, 17 Feb 2023 16:19:39 +0100 Subject: [PATCH] :recycle: (billing) Refactor billing server code to trpc --- .../api/procedures/cancelSubscription.ts | 70 +++ .../api/procedures/createCheckoutSession.ts | 98 +++ .../api/procedures/getBillingPortalUrl.ts | 58 ++ .../billing/api/procedures/getSubscription.ts | 81 +++ .../billing/api/procedures/getUsage.ts | 94 +++ .../billing/api/procedures/listInvoices.ts | 66 +++ .../api/procedures/updateSubscription.ts | 146 +++++ .../src/features/billing/api/router.ts | 18 + .../api/utils/parseSubscriptionItems.ts | 36 ++ .../BillingContent/BillingContent.tsx | 12 +- .../BillingContent/BillingPortalButton.tsx | 28 + .../CurrentSubscriptionContent.tsx | 64 +- .../BillingContent/InvoicesList.tsx | 79 +-- .../UsageContent/UsageContent.tsx | 6 +- .../queries/redirectToBillingPortal.ts | 7 - .../queries/useInvoicesQuery.ts | 24 - .../ChangePlanForm/ChangePlanForm.tsx | 104 ++-- .../ChangePlanForm/ProPlanContent.tsx | 11 +- .../ChangePlanForm/StarterPlanContent.tsx | 11 +- .../billing/components/ChangePlanModal.tsx | 9 +- .../hooks/useCurrentSubscriptionInfo.ts | 31 - .../src/features/billing/hooks/useUsage.ts | 16 - apps/builder/src/features/billing/index.ts | 2 - .../billing/queries/upgradePlanQuery.tsx | 75 --- .../dashboard/components/DashboardPage.tsx | 21 +- .../results/components/ResultsPage.tsx | 80 +-- .../results/components/UsageAlertBanners.tsx | 95 +++ .../features/workspace/WorkspaceProvider.tsx | 1 + .../src/pages/api/stripe/billing-portal.ts | 1 + apps/builder/src/pages/api/stripe/invoices.ts | 1 + .../src/pages/api/stripe/subscription.ts | 1 + apps/builder/src/pages/api/trpc/[trpc].ts | 4 + .../api/workspaces/[workspaceId]/usage.ts | 1 + .../src/utils/server/routers/v1/trpcRouter.ts | 2 + apps/builder/src/utils/server/trpc.ts | 2 +- apps/docs/openapi/builder/_spec_.json | 557 ++++++++++++++++++ packages/models/features/billing/invoice.ts | 11 + .../models/features/billing/subscription.ts | 9 + 38 files changed, 1565 insertions(+), 367 deletions(-) create mode 100644 apps/builder/src/features/billing/api/procedures/cancelSubscription.ts create mode 100644 apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts create mode 100644 apps/builder/src/features/billing/api/procedures/getBillingPortalUrl.ts create mode 100644 apps/builder/src/features/billing/api/procedures/getSubscription.ts create mode 100644 apps/builder/src/features/billing/api/procedures/getUsage.ts create mode 100644 apps/builder/src/features/billing/api/procedures/listInvoices.ts create mode 100644 apps/builder/src/features/billing/api/procedures/updateSubscription.ts create mode 100644 apps/builder/src/features/billing/api/router.ts create mode 100644 apps/builder/src/features/billing/api/utils/parseSubscriptionItems.ts create mode 100644 apps/builder/src/features/billing/components/BillingContent/BillingPortalButton.tsx delete mode 100644 apps/builder/src/features/billing/components/BillingContent/queries/redirectToBillingPortal.ts delete mode 100644 apps/builder/src/features/billing/components/BillingContent/queries/useInvoicesQuery.ts delete mode 100644 apps/builder/src/features/billing/hooks/useCurrentSubscriptionInfo.ts delete mode 100644 apps/builder/src/features/billing/hooks/useUsage.ts delete mode 100644 apps/builder/src/features/billing/queries/upgradePlanQuery.tsx create mode 100644 apps/builder/src/features/results/components/UsageAlertBanners.tsx create mode 100644 packages/models/features/billing/invoice.ts create mode 100644 packages/models/features/billing/subscription.ts diff --git a/apps/builder/src/features/billing/api/procedures/cancelSubscription.ts b/apps/builder/src/features/billing/api/procedures/cancelSubscription.ts new file mode 100644 index 000000000..8b743d705 --- /dev/null +++ b/apps/builder/src/features/billing/api/procedures/cancelSubscription.ts @@ -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' } + }) diff --git a/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts b/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts new file mode 100644 index 000000000..d224de25a --- /dev/null +++ b/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts @@ -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, + } + } + ) diff --git a/apps/builder/src/features/billing/api/procedures/getBillingPortalUrl.ts b/apps/builder/src/features/billing/api/procedures/getBillingPortalUrl.ts new file mode 100644 index 000000000..1eaeeea50 --- /dev/null +++ b/apps/builder/src/features/billing/api/procedures/getBillingPortalUrl.ts @@ -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, + } + }) diff --git a/apps/builder/src/features/billing/api/procedures/getSubscription.ts b/apps/builder/src/features/billing/api/procedures/getSubscription.ts new file mode 100644 index 000000000..0d5466cba --- /dev/null +++ b/apps/builder/src/features/billing/api/procedures/getSubscription.ts @@ -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', + }, + } + }) diff --git a/apps/builder/src/features/billing/api/procedures/getUsage.ts b/apps/builder/src/features/billing/api/procedures/getUsage.ts new file mode 100644 index 000000000..f0d86b8b3 --- /dev/null +++ b/apps/builder/src/features/billing/api/procedures/getUsage.ts @@ -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, + } + }) diff --git a/apps/builder/src/features/billing/api/procedures/listInvoices.ts b/apps/builder/src/features/billing/api/procedures/listInvoices.ts new file mode 100644 index 000000000..029be2dd0 --- /dev/null +++ b/apps/builder/src/features/billing/api/procedures/listInvoices.ts @@ -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, + })), + } + }) diff --git a/apps/builder/src/features/billing/api/procedures/updateSubscription.ts b/apps/builder/src/features/billing/api/procedures/updateSubscription.ts new file mode 100644 index 000000000..ddaec42d5 --- /dev/null +++ b/apps/builder/src/features/billing/api/procedures/updateSubscription.ts @@ -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 } + } + ) diff --git a/apps/builder/src/features/billing/api/router.ts b/apps/builder/src/features/billing/api/router.ts new file mode 100644 index 000000000..0c300a8b7 --- /dev/null +++ b/apps/builder/src/features/billing/api/router.ts @@ -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, +}) diff --git a/apps/builder/src/features/billing/api/utils/parseSubscriptionItems.ts b/apps/builder/src/features/billing/api/utils/parseSubscriptionItems.ts new file mode 100644 index 000000000..f9bcc2252 --- /dev/null +++ b/apps/builder/src/features/billing/api/utils/parseSubscriptionItems.ts @@ -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, + }, + ] + : [] + ) diff --git a/apps/builder/src/features/billing/components/BillingContent/BillingContent.tsx b/apps/builder/src/features/billing/components/BillingContent/BillingContent.tsx index e856968d1..682208166 100644 --- a/apps/builder/src/features/billing/components/BillingContent/BillingContent.tsx +++ b/apps/builder/src/features/billing/components/BillingContent/BillingContent.tsx @@ -18,8 +18,7 @@ export const BillingContent = () => { @@ -35,10 +34,15 @@ export const BillingContent = () => { {workspace.plan !== Plan.CUSTOM && workspace.plan !== Plan.LIFETIME && workspace.plan !== Plan.UNLIMITED && - workspace.plan !== Plan.OFFERED && } + workspace.plan !== Plan.OFFERED && ( + + )} - {workspace.stripeId && } + {workspace.stripeId && } ) } diff --git a/apps/builder/src/features/billing/components/BillingContent/BillingPortalButton.tsx b/apps/builder/src/features/billing/components/BillingContent/BillingPortalButton.tsx new file mode 100644 index 000000000..22add6ef8 --- /dev/null +++ b/apps/builder/src/features/billing/components/BillingContent/BillingPortalButton.tsx @@ -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 ( + + ) +} diff --git a/apps/builder/src/features/billing/components/BillingContent/CurrentSubscriptionContent.tsx b/apps/builder/src/features/billing/components/BillingContent/CurrentSubscriptionContent.tsx index 1e3cca667..4b9108224 100644 --- a/apps/builder/src/features/billing/components/BillingContent/CurrentSubscriptionContent.tsx +++ b/apps/builder/src/features/billing/components/BillingContent/CurrentSubscriptionContent.tsx @@ -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 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 ( @@ -52,14 +41,16 @@ export const CurrentSubscriptionContent = ({ ) : ( <> - + {isSubscribed && ( + cancelSubscription({ workspaceId: workspace.id }) + } > Cancel my subscription @@ -75,14 +66,7 @@ export const CurrentSubscriptionContent = ({ Need to change payment method or billing information? Head over to your billing portal: - + )} diff --git a/apps/builder/src/features/billing/components/BillingContent/InvoicesList.tsx b/apps/builder/src/features/billing/components/BillingContent/InvoicesList.tsx index d120cf5f8..3ac018b26 100644 --- a/apps/builder/src/features/billing/components/BillingContent/InvoicesList.tsx +++ b/apps/builder/src/features/billing/components/BillingContent/InvoicesList.tsx @@ -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 ( Invoices - {invoices.length === 0 && !isLoading ? ( + {data?.invoices.length === 0 && status !== 'loading' ? ( No invoices found for this workspace. ) : ( @@ -45,34 +54,34 @@ export const InvoicesList = ({ workspace }: Props) => { - {invoices - ?.filter((invoice) => isDefined(invoice.url)) - .map((invoice) => ( - - - - - {invoice.id} - {new Date(invoice.date * 1000).toDateString()} - - {getFormattedPrice(invoice.amount, invoice.currency)} - - - {invoice.url && ( - } - variant="outline" - href={invoice.url} - target="_blank" - aria-label={'Download invoice'} - /> - )} - - - ))} - {isLoading && + {data?.invoices.map((invoice) => ( + + + + + {invoice.id} + + {invoice.date + ? new Date(invoice.date * 1000).toDateString() + : ''} + + {getFormattedPrice(invoice.amount, invoice.currency)} + + {invoice.url && ( + } + variant="outline" + href={invoice.url} + target="_blank" + aria-label={'Download invoice'} + /> + )} + + + ))} + {status === 'loading' && Array.from({ length: 3 }).map((_, idx) => ( diff --git a/apps/builder/src/features/billing/components/BillingContent/UsageContent/UsageContent.tsx b/apps/builder/src/features/billing/components/BillingContent/UsageContent/UsageContent.tsx index 0cc8122dd..119075e6f 100644 --- a/apps/builder/src/features/billing/components/BillingContent/UsageContent/UsageContent.tsx +++ b/apps/builder/src/features/billing/components/BillingContent/UsageContent/UsageContent.tsx @@ -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 diff --git a/apps/builder/src/features/billing/components/BillingContent/queries/redirectToBillingPortal.ts b/apps/builder/src/features/billing/components/BillingContent/queries/redirectToBillingPortal.ts deleted file mode 100644 index f40dd5e54..000000000 --- a/apps/builder/src/features/billing/components/BillingContent/queries/redirectToBillingPortal.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { sendRequest } from 'utils' - -export const redirectToBillingPortal = ({ - workspaceId, -}: { - workspaceId: string -}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`) diff --git a/apps/builder/src/features/billing/components/BillingContent/queries/useInvoicesQuery.ts b/apps/builder/src/features/billing/components/BillingContent/queries/useInvoicesQuery.ts deleted file mode 100644 index 2949ae14e..000000000 --- a/apps/builder/src/features/billing/components/BillingContent/queries/useInvoicesQuery.ts +++ /dev/null @@ -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, - } -} diff --git a/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx b/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx index 85e0efb15..cd3bb933a 100644 --- a/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx +++ b/apps/builder/src/features/billing/components/ChangePlanForm/ChangePlanForm.tsx @@ -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 + onUpgradeSuccess: () => void +} + +export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => { + const router = useRouter() const { user } = useUser() - const { workspace, refreshWorkspace } = useWorkspace() const { showToast } = useToast() - const { data, mutate: refreshCurrentSubscriptionInfo } = - useCurrentSubscriptionInfo({ - stripeId: workspace?.stripeId, - plan: workspace?.plan, + const { data } = trpc.billing.getSubscription.useQuery({ + workspaceId: workspace.id, + }) + + const { mutate: createCheckoutSession, isLoading: isCreatingCheckout } = + trpc.billing.createCheckoutSession.useMutation({ + onError: (error) => { + showToast({ + description: error.message, + }) + }, + onSuccess: ({ checkoutUrl }) => { + router.push(checkoutUrl) + }, + }) + + const { mutate: updateSubscription, isLoading: isUpdatingSubscription } = + trpc.billing.updateSubscription.useMutation({ + onError: (error) => { + showToast({ + description: error.message, + }) + }, + onSuccess: ({ workspace: { plan } }) => { + onUpgradeSuccess() + showToast({ + status: 'success', + description: `Workspace ${plan} plan successfully updated 🎉`, + }) + }, }) const handlePayClick = async ({ @@ -30,33 +62,29 @@ export const ChangePlanForm = () => { }) => { if ( !user || - !workspace || selectedChatsLimitIndex === undefined || selectedStorageLimitIndex === undefined ) return - const response = await upgradePlanQuery({ - stripeId: workspace.stripeId ?? undefined, - user, + + const newSubscription = { plan, workspaceId: workspace.id, additionalChats: selectedChatsLimitIndex, additionalStorage: selectedStorageLimitIndex, - currency: data?.currency, - }) - if (typeof response === 'object' && response?.error) { - showToast({ description: response.error.message }) - return + currency: + data?.subscription.currency ?? + (guessIfUserIsEuropean() ? 'eur' : 'usd'), + } as const + if (workspace.stripeId) { + updateSubscription(newSubscription) + } else { + createCheckoutSession({ + ...newSubscription, + returnUrl: window.location.href, + prefilledEmail: user.email ?? undefined, + }) } - refreshCurrentSubscriptionInfo({ - additionalChatsIndex: selectedChatsLimitIndex, - additionalStorageIndex: selectedStorageLimitIndex, - }) - refreshWorkspace() - showToast({ - status: 'success', - description: `Workspace ${plan} plan successfully updated 🎉`, - }) } return ( @@ -64,26 +92,36 @@ export const ChangePlanForm = () => { handlePayClick({ ...props, plan: Plan.STARTER }) } - currency={data?.currency} + isLoading={isCreatingCheckout || isUpdatingSubscription} + currency={data?.subscription.currency} /> handlePayClick({ ...props, plan: Plan.PRO })} - currency={data?.currency} + isLoading={isCreatingCheckout || isUpdatingSubscription} + currency={data?.subscription.currency} /> diff --git a/apps/builder/src/features/billing/components/ChangePlanForm/ProPlanContent.tsx b/apps/builder/src/features/billing/components/ChangePlanForm/ProPlanContent.tsx index f409ae43e..e27c5b87e 100644 --- a/apps/builder/src/features/billing/components/ChangePlanForm/ProPlanContent.tsx +++ b/apps/builder/src/features/billing/components/ChangePlanForm/ProPlanContent.tsx @@ -34,16 +34,18 @@ type ProPlanContentProps = { initialChatsLimitIndex?: number initialStorageLimitIndex?: number currency?: 'usd' | 'eur' + isLoading: boolean onPayClick: (props: { selectedChatsLimitIndex: number selectedStorageLimitIndex: number - }) => Promise + }) => void } export const ProPlanContent = ({ initialChatsLimitIndex, initialStorageLimitIndex, currency, + isLoading, onPayClick, }: ProPlanContentProps) => { const { workspace } = useWorkspace() @@ -51,7 +53,6 @@ export const ProPlanContent = ({ useState() const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] = useState() - 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()} diff --git a/apps/builder/src/features/billing/components/ChangePlanForm/StarterPlanContent.tsx b/apps/builder/src/features/billing/components/ChangePlanForm/StarterPlanContent.tsx index c2ad70306..cc9e33147 100644 --- a/apps/builder/src/features/billing/components/ChangePlanForm/StarterPlanContent.tsx +++ b/apps/builder/src/features/billing/components/ChangePlanForm/StarterPlanContent.tsx @@ -30,15 +30,17 @@ type StarterPlanContentProps = { initialChatsLimitIndex?: number initialStorageLimitIndex?: number currency?: 'eur' | 'usd' + isLoading?: boolean onPayClick: (props: { selectedChatsLimitIndex: number selectedStorageLimitIndex: number - }) => Promise + }) => void } export const StarterPlanContent = ({ initialChatsLimitIndex, initialStorageLimitIndex, + isLoading, currency, onPayClick, }: StarterPlanContentProps) => { @@ -47,7 +49,6 @@ export const StarterPlanContent = ({ useState() const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] = useState() - 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()} diff --git a/apps/builder/src/features/billing/components/ChangePlanModal.tsx b/apps/builder/src/features/billing/components/ChangePlanModal.tsx index 1ed23392a..1b09d0d89 100644 --- a/apps/builder/src/features/billing/components/ChangePlanModal.tsx +++ b/apps/builder/src/features/billing/components/ChangePlanModal.tsx @@ -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 ( @@ -40,7 +42,12 @@ export const ChangePlanModal = ({ You need to upgrade your plan in order to {type} )} - + {workspace && ( + + )} diff --git a/apps/builder/src/features/billing/hooks/useCurrentSubscriptionInfo.ts b/apps/builder/src/features/billing/hooks/useCurrentSubscriptionInfo.ts deleted file mode 100644 index 0d30f8a43..000000000 --- a/apps/builder/src/features/billing/hooks/useCurrentSubscriptionInfo.ts +++ /dev/null @@ -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, - } -} diff --git a/apps/builder/src/features/billing/hooks/useUsage.ts b/apps/builder/src/features/billing/hooks/useUsage.ts deleted file mode 100644 index fdda942a2..000000000 --- a/apps/builder/src/features/billing/hooks/useUsage.ts +++ /dev/null @@ -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, - } -} diff --git a/apps/builder/src/features/billing/index.ts b/apps/builder/src/features/billing/index.ts index 48f158845..2665d9091 100644 --- a/apps/builder/src/features/billing/index.ts +++ b/apps/builder/src/features/billing/index.ts @@ -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' diff --git a/apps/builder/src/features/billing/queries/upgradePlanQuery.tsx b/apps/builder/src/features/billing/queries/upgradePlanQuery.tsx deleted file mode 100644 index e7e2f2d5a..000000000 --- a/apps/builder/src/features/billing/queries/upgradePlanQuery.tsx +++ /dev/null @@ -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): 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) => { - 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, - }) -} diff --git a/apps/builder/src/features/dashboard/components/DashboardPage.tsx b/apps/builder/src/features/dashboard/components/DashboardPage.tsx index 91f49200d..e180e1a88 100644 --- a/apps/builder/src/features/dashboard/components/DashboardPage.tsx +++ b/apps/builder/src/features/dashboard/components/DashboardPage.tsx @@ -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 ( diff --git a/apps/builder/src/features/results/components/ResultsPage.tsx b/apps/builder/src/features/results/components/ResultsPage.tsx index ddb326d87..bbf601be2 100644 --- a/apps/builder/src/features/results/components/ResultsPage.tsx +++ b/apps/builder/src/features/results/components/ResultsPage.tsx @@ -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 = () => { } /> - {chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && ( - - - Your workspace collected{' '} - {chatsLimitPercentage}% of your total chats - limit this month. Upgrade your plan to continue chatting with - your customers beyond this limit. - - } - buttonLabel="Upgrade" - /> - - )} - {storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && ( - - - Your workspace collected{' '} - {storageLimitPercentage}% 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" - /> - - )} + {workspace && } { + 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 && ( + + + Your workspace collected{' '} + {chatsLimitPercentage}% of your total chats + limit this month. Upgrade your plan to continue chatting with + your customers beyond this limit. + + } + buttonLabel="Upgrade" + /> + + )} + {storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && ( + + + Your workspace collected{' '} + {storageLimitPercentage}% 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" + /> + + )} + + ) +} diff --git a/apps/builder/src/features/workspace/WorkspaceProvider.tsx b/apps/builder/src/features/workspace/WorkspaceProvider.tsx index 5f4b1b1cb..ed20f26df 100644 --- a/apps/builder/src/features/workspace/WorkspaceProvider.tsx +++ b/apps/builder/src/features/workspace/WorkspaceProvider.tsx @@ -158,6 +158,7 @@ export const WorkspaceProvider = ({ const refreshWorkspace = () => { trpcContext.workspace.getWorkspace.invalidate() + trpcContext.billing.getSubscription.invalidate() } return ( diff --git a/apps/builder/src/pages/api/stripe/billing-portal.ts b/apps/builder/src/pages/api/stripe/billing-portal.ts index 315f6d6bc..08bfb9dca 100644 --- a/apps/builder/src/pages/api/stripe/billing-portal.ts +++ b/apps/builder/src/pages/api/stripe/billing-portal.ts @@ -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) diff --git a/apps/builder/src/pages/api/stripe/invoices.ts b/apps/builder/src/pages/api/stripe/invoices.ts index 56c90bce9..a63b9494f 100644 --- a/apps/builder/src/pages/api/stripe/invoices.ts +++ b/apps/builder/src/pages/api/stripe/invoices.ts @@ -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) diff --git a/apps/builder/src/pages/api/stripe/subscription.ts b/apps/builder/src/pages/api/stripe/subscription.ts index af28b0f3e..632db9bc8 100644 --- a/apps/builder/src/pages/api/stripe/subscription.ts +++ b/apps/builder/src/pages/api/stripe/subscription.ts @@ -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) diff --git a/apps/builder/src/pages/api/trpc/[trpc].ts b/apps/builder/src/pages/api/trpc/[trpc].ts index b8415cfe4..5332602ea 100644 --- a/apps/builder/src/pages/api/trpc/[trpc].ts +++ b/apps/builder/src/pages/api/trpc/[trpc].ts @@ -11,5 +11,9 @@ export default createNextApiHandler({ captureException(error) console.error('Something went wrong', error) } + return error + }, + batching: { + enabled: true, }, }) diff --git a/apps/builder/src/pages/api/workspaces/[workspaceId]/usage.ts b/apps/builder/src/pages/api/workspaces/[workspaceId]/usage.ts index bb9b5e0c1..259c0220c 100644 --- a/apps/builder/src/pages/api/workspaces/[workspaceId]/usage.ts +++ b/apps/builder/src/pages/api/workspaces/[workspaceId]/usage.ts @@ -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) diff --git a/apps/builder/src/utils/server/routers/v1/trpcRouter.ts b/apps/builder/src/utils/server/routers/v1/trpcRouter.ts index f947686ad..dc78b5a5a 100644 --- a/apps/builder/src/utils/server/routers/v1/trpcRouter.ts +++ b/apps/builder/src/utils/server/routers/v1/trpcRouter.ts @@ -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 diff --git a/apps/builder/src/utils/server/trpc.ts b/apps/builder/src/utils/server/trpc.ts index 2b661ffe3..5314fbe07 100644 --- a/apps/builder/src/utils/server/trpc.ts +++ b/apps/builder/src/utils/server/trpc.ts @@ -8,7 +8,7 @@ const t = initTRPC.context().meta().create({ }) const isAuthed = t.middleware(({ next, ctx }) => { - if (!ctx.user) { + if (!ctx.user?.id) { throw new TRPCError({ code: 'UNAUTHORIZED', }) diff --git a/apps/docs/openapi/builder/_spec_.json b/apps/docs/openapi/builder/_spec_.json index 34c54de03..1adf1261f 100644 --- a/apps/docs/openapi/builder/_spec_.json +++ b/apps/docs/openapi/builder/_spec_.json @@ -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": { diff --git a/packages/models/features/billing/invoice.ts b/packages/models/features/billing/invoice.ts new file mode 100644 index 000000000..727290f0a --- /dev/null +++ b/packages/models/features/billing/invoice.ts @@ -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 diff --git a/packages/models/features/billing/subscription.ts b/packages/models/features/billing/subscription.ts new file mode 100644 index 000000000..2fa571206 --- /dev/null +++ b/packages/models/features/billing/subscription.ts @@ -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