(billing) Automatic usage-based billing (#924)

BREAKING CHANGE: Stripe environment variables simplified. Check out the
new configs to adapt your existing system.

Closes #906





<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
### Summary by CodeRabbit

**New Features:**
- Introduced a usage-based billing system, providing more flexibility
and options for users.
- Integrated with Stripe for a smoother and more secure payment process.
- Enhanced the user interface with improvements to the billing,
workspace, and pricing pages for a more intuitive experience.

**Improvements:**
- Simplified the billing logic, removing additional chats and yearly
billing for a more streamlined user experience.
- Updated email notifications to keep users informed about their usage
and limits.
- Improved pricing and currency formatting for better clarity and
understanding.

**Testing:**
- Updated tests and specifications to ensure the reliability of new
features and improvements.

**Note:** These changes aim to provide a more flexible and user-friendly
billing system, with clearer pricing and improved notifications. Users
should find the new system more intuitive and easier to navigate.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2023-10-17 08:03:30 +02:00
committed by GitHub
parent a8c2deb258
commit 797751b418
55 changed files with 1589 additions and 1497 deletions

View File

@@ -4,7 +4,6 @@ import { TRPCError } from '@trpc/server'
import { Plan } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
import { parseSubscriptionItems } from '../helpers/parseSubscriptionItems'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
import { env } from '@typebot.io/env'
@@ -26,14 +25,12 @@ export const createCheckoutSession = authenticatedProcedure
currency: z.enum(['usd', 'eur']),
plan: z.enum([Plan.STARTER, Plan.PRO]),
returnUrl: z.string(),
additionalChats: z.number(),
vat: z
.object({
type: z.string(),
value: z.string(),
})
.optional(),
isYearly: z.boolean(),
})
)
.output(
@@ -43,17 +40,7 @@ export const createCheckoutSession = authenticatedProcedure
)
.mutation(
async ({
input: {
vat,
email,
company,
workspaceId,
currency,
plan,
returnUrl,
additionalChats,
isYearly,
},
input: { vat, email, company, workspaceId, currency, plan, returnUrl },
ctx: { user },
}) => {
if (!env.STRIPE_SECRET_KEY)
@@ -116,8 +103,6 @@ export const createCheckoutSession = authenticatedProcedure
currency,
plan,
returnUrl,
additionalChats,
isYearly,
})
if (!checkoutUrl)
@@ -138,22 +123,12 @@ type Props = {
currency: 'usd' | 'eur'
plan: 'STARTER' | 'PRO'
returnUrl: string
additionalChats: number
isYearly: boolean
userId: string
}
export const createCheckoutSessionUrl =
(stripe: Stripe) =>
async ({
customerId,
workspaceId,
currency,
plan,
returnUrl,
additionalChats,
isYearly,
}: Props) => {
async ({ customerId, workspaceId, currency, plan, returnUrl }: Props) => {
const session = await stripe.checkout.sessions.create({
success_url: `${returnUrl}?stripe=${plan}&success=true`,
cancel_url: `${returnUrl}?stripe=cancel`,
@@ -167,12 +142,25 @@ export const createCheckoutSessionUrl =
metadata: {
workspaceId,
plan,
additionalChats,
},
currency,
billing_address_collection: 'required',
automatic_tax: { enabled: true },
line_items: parseSubscriptionItems(plan, additionalChats, isYearly),
line_items: [
{
price:
plan === 'STARTER'
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
{
price:
plan === 'STARTER'
? env.STRIPE_STARTER_CHATS_PRICE_ID
: env.STRIPE_PRO_CHATS_PRICE_ID,
},
],
})
return session.url

View File

@@ -5,7 +5,6 @@ import Stripe from 'stripe'
import { z } from 'zod'
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { priceIds } from '@typebot.io/lib/api/pricing'
import { env } from '@typebot.io/env'
export const getSubscription = authenticatedProcedure
@@ -75,15 +74,14 @@ export const getSubscription = authenticatedProcedure
return {
subscription: {
currentBillingPeriod:
subscriptionSchema.shape.currentBillingPeriod.parse({
start: new Date(currentSubscription.current_period_start),
end: new Date(currentSubscription.current_period_end),
}),
status: subscriptionSchema.shape.status.parse(
currentSubscription.status
),
isYearly: currentSubscription.items.data.some((item) => {
return (
priceIds.STARTER.chats.yearly === item.price.id ||
priceIds.PRO.chats.yearly === item.price.id
)
}),
currency: currentSubscription.currency as 'usd' | 'eur',
cancelDate: currentSubscription.cancel_at
? new Date(currentSubscription.cancel_at * 1000)
@@ -91,8 +89,3 @@ export const getSubscription = authenticatedProcedure
},
}
})
export const chatPriceIds = [priceIds.STARTER.chats.monthly]
.concat(priceIds.STARTER.chats.yearly)
.concat(priceIds.PRO.chats.monthly)
.concat(priceIds.PRO.chats.yearly)

View File

@@ -3,6 +3,8 @@ import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { env } from '@typebot.io/env'
import Stripe from 'stripe'
export const getUsage = authenticatedProcedure
.meta({
@@ -19,13 +21,15 @@ export const getUsage = authenticatedProcedure
workspaceId: z.string(),
})
)
.output(z.object({ totalChatsUsed: z.number() }))
.output(z.object({ totalChatsUsed: z.number(), resetsAt: z.date() }))
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
stripeId: true,
plan: true,
members: {
select: {
userId: true,
@@ -42,19 +46,63 @@ export const getUsage = authenticatedProcedure
message: 'Workspace not found',
})
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
if (
!env.STRIPE_SECRET_KEY ||
!workspace.stripeId ||
(workspace.plan !== 'STARTER' && workspace.plan !== 'PRO')
) {
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const totalChatsUsed = await prisma.result.count({
where: {
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
},
},
})
const firstDayOfNextMonth = new Date(
firstDayOfMonth.getFullYear(),
firstDayOfMonth.getMonth() + 1,
1
)
return { totalChatsUsed, resetsAt: firstDayOfNextMonth }
}
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const subscriptions = await stripe.subscriptions.list({
customer: workspace.stripeId,
})
const currentSubscription = subscriptions.data
.filter((sub) => ['past_due', 'active'].includes(sub.status))
.sort((a, b) => a.created - b.created)
.shift()
if (!currentSubscription)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `No subscription found on workspace: ${workspaceId}`,
})
const totalChatsUsed = await prisma.result.count({
where: {
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
gte: new Date(currentSubscription.current_period_start * 1000),
},
},
})
return {
totalChatsUsed,
resetsAt: new Date(currentSubscription.current_period_end * 1000),
}
})

View File

@@ -64,12 +64,12 @@ export const listInvoices = authenticatedProcedure
.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,
.map((invoice) => ({
id: invoice.number as string,
url: invoice.invoice_pdf as string,
amount: invoice.subtotal,
currency: invoice.currency,
date: invoice.status_transitions.paid_at,
})),
}
})

View File

@@ -5,15 +5,10 @@ import { TRPCError } from '@trpc/server'
import { Plan } from '@typebot.io/prisma'
import { workspaceSchema } from '@typebot.io/schemas'
import Stripe from 'stripe'
import { isDefined } from '@typebot.io/lib'
import { z } from 'zod'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { chatPriceIds } from './getSubscription'
import { createCheckoutSessionUrl } from './createCheckoutSession'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
import { getUsage } from '@typebot.io/lib/api/getUsage'
import { env } from '@typebot.io/env'
import { priceIds } from '@typebot.io/lib/api/pricing'
export const updateSubscription = authenticatedProcedure
.meta({
@@ -30,9 +25,7 @@ export const updateSubscription = authenticatedProcedure
returnUrl: z.string(),
workspaceId: z.string(),
plan: z.enum([Plan.STARTER, Plan.PRO]),
additionalChats: z.number(),
currency: z.enum(['usd', 'eur']),
isYearly: z.boolean(),
})
)
.output(
@@ -43,14 +36,7 @@ export const updateSubscription = authenticatedProcedure
)
.mutation(
async ({
input: {
workspaceId,
plan,
additionalChats,
currency,
isYearly,
returnUrl,
},
input: { workspaceId, plan, currency, returnUrl },
ctx: { user },
}) => {
if (!env.STRIPE_SECRET_KEY)
@@ -81,6 +67,7 @@ export const updateSubscription = authenticatedProcedure
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
@@ -91,39 +78,57 @@ export const updateSubscription = authenticatedProcedure
})
const subscription = data[0] as Stripe.Subscription | undefined
const currentPlanItemId = subscription?.items.data.find((item) =>
[env.STRIPE_STARTER_PRODUCT_ID, env.STRIPE_PRO_PRODUCT_ID].includes(
item.price.product.toString()
[env.STRIPE_STARTER_PRICE_ID, env.STRIPE_PRO_PRICE_ID].includes(
item.price.id
)
)?.id
const currentAdditionalChatsItemId = subscription?.items.data.find(
(item) => chatPriceIds.includes(item.price.id)
const currentUsageItemId = subscription?.items.data.find(
(item) =>
item.price.id === env.STRIPE_STARTER_CHATS_PRICE_ID ||
item.price.id === env.STRIPE_PRO_CHATS_PRICE_ID
)?.id
const frequency = isYearly ? 'yearly' : 'monthly'
const items = [
{
id: currentPlanItemId,
price: priceIds[plan].base[frequency],
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
additionalChats === 0 && !currentAdditionalChatsItemId
? undefined
: {
id: currentAdditionalChatsItemId,
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
deleted: subscription ? additionalChats === 0 : undefined,
},
].filter(isDefined)
{
id: currentUsageItemId,
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_CHATS_PRICE_ID
: env.STRIPE_PRO_CHATS_PRICE_ID,
},
]
if (subscription) {
if (plan === 'STARTER') {
const totalChatsUsed = await prisma.result.count({
where: {
typebot: { workspaceId },
hasStarted: true,
createdAt: {
gte: new Date(subscription.current_period_start * 1000),
},
},
})
if (totalChatsUsed >= 4000) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
"You have collected more than 4000 chats during this billing cycle. You can't downgrade to the Starter.",
})
}
}
await stripe.subscriptions.update(subscription.id, {
items,
proration_behavior: 'always_invoice',
proration_behavior:
plan === 'PRO' ? 'always_invoice' : 'create_prorations',
})
} else {
const checkoutUrl = await createCheckoutSessionUrl(stripe)({
@@ -133,31 +138,16 @@ export const updateSubscription = authenticatedProcedure
currency,
plan,
returnUrl,
additionalChats,
isYearly,
})
return { checkoutUrl }
}
let isQuarantined = workspace.isQuarantined
if (isQuarantined) {
const newChatsLimit = getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
})
const { totalChatsUsed } = await getUsage(prisma)(workspaceId)
if (totalChatsUsed < newChatsLimit) isQuarantined = false
}
const updatedWorkspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
additionalChatsIndex: additionalChats,
isQuarantined,
isQuarantined: false,
},
})
@@ -168,7 +158,6 @@ export const updateSubscription = authenticatedProcedure
userId: user.id,
data: {
plan,
additionalChatsIndex: additionalChats,
},
},
])