♻️ (billing) Refactor billing server code to trpc

This commit is contained in:
Baptiste Arnaud
2023-02-17 16:19:39 +01:00
parent 962438768e
commit b73282d810
38 changed files with 1565 additions and 367 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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