Skip to content

Commit f3ded61

Browse files
committed
⚡ (billing) Automatic usage-based billing
BREAKING CHANGE: Stripe environment variables simplified. Check out the new configs to adapt your existing system. Closes #906
1 parent 9bbb30f commit f3ded61

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1420
-1255
lines changed

apps/builder/src/features/billing/api/createCheckoutSession.ts

+17-29
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { TRPCError } from '@trpc/server'
44
import { Plan } from '@typebot.io/prisma'
55
import Stripe from 'stripe'
66
import { z } from 'zod'
7-
import { parseSubscriptionItems } from '../helpers/parseSubscriptionItems'
87
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
98
import { env } from '@typebot.io/env'
109

@@ -26,14 +25,12 @@ export const createCheckoutSession = authenticatedProcedure
2625
currency: z.enum(['usd', 'eur']),
2726
plan: z.enum([Plan.STARTER, Plan.PRO]),
2827
returnUrl: z.string(),
29-
additionalChats: z.number(),
3028
vat: z
3129
.object({
3230
type: z.string(),
3331
value: z.string(),
3432
})
3533
.optional(),
36-
isYearly: z.boolean(),
3734
})
3835
)
3936
.output(
@@ -43,17 +40,7 @@ export const createCheckoutSession = authenticatedProcedure
4340
)
4441
.mutation(
4542
async ({
46-
input: {
47-
vat,
48-
email,
49-
company,
50-
workspaceId,
51-
currency,
52-
plan,
53-
returnUrl,
54-
additionalChats,
55-
isYearly,
56-
},
43+
input: { vat, email, company, workspaceId, currency, plan, returnUrl },
5744
ctx: { user },
5845
}) => {
5946
if (!env.STRIPE_SECRET_KEY)
@@ -116,8 +103,6 @@ export const createCheckoutSession = authenticatedProcedure
116103
currency,
117104
plan,
118105
returnUrl,
119-
additionalChats,
120-
isYearly,
121106
})
122107

123108
if (!checkoutUrl)
@@ -138,22 +123,12 @@ type Props = {
138123
currency: 'usd' | 'eur'
139124
plan: 'STARTER' | 'PRO'
140125
returnUrl: string
141-
additionalChats: number
142-
isYearly: boolean
143126
userId: string
144127
}
145128

146129
export const createCheckoutSessionUrl =
147130
(stripe: Stripe) =>
148-
async ({
149-
customerId,
150-
workspaceId,
151-
currency,
152-
plan,
153-
returnUrl,
154-
additionalChats,
155-
isYearly,
156-
}: Props) => {
131+
async ({ customerId, workspaceId, currency, plan, returnUrl }: Props) => {
157132
const session = await stripe.checkout.sessions.create({
158133
success_url: `${returnUrl}?stripe=${plan}&success=true`,
159134
cancel_url: `${returnUrl}?stripe=cancel`,
@@ -167,12 +142,25 @@ export const createCheckoutSessionUrl =
167142
metadata: {
168143
workspaceId,
169144
plan,
170-
additionalChats,
171145
},
172146
currency,
173147
billing_address_collection: 'required',
174148
automatic_tax: { enabled: true },
175-
line_items: parseSubscriptionItems(plan, additionalChats, isYearly),
149+
line_items: [
150+
{
151+
price:
152+
plan === 'STARTER'
153+
? env.STRIPE_STARTER_PRICE_ID
154+
: env.STRIPE_PRO_PRICE_ID,
155+
quantity: 1,
156+
},
157+
{
158+
price:
159+
plan === 'STARTER'
160+
? env.STRIPE_STARTER_CHATS_PRICE_ID
161+
: env.STRIPE_PRO_CHATS_PRICE_ID,
162+
},
163+
],
176164
})
177165

178166
return session.url

apps/builder/src/features/billing/api/getSubscription.ts

+5-12
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import Stripe from 'stripe'
55
import { z } from 'zod'
66
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
77
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
8-
import { priceIds } from '@typebot.io/lib/api/pricing'
98
import { env } from '@typebot.io/env'
109

1110
export const getSubscription = authenticatedProcedure
@@ -75,24 +74,18 @@ export const getSubscription = authenticatedProcedure
7574

7675
return {
7776
subscription: {
77+
currentBillingPeriod:
78+
subscriptionSchema.shape.currentBillingPeriod.parse({
79+
start: new Date(currentSubscription.current_period_start),
80+
end: new Date(currentSubscription.current_period_end),
81+
}),
7882
status: subscriptionSchema.shape.status.parse(
7983
currentSubscription.status
8084
),
81-
isYearly: currentSubscription.items.data.some((item) => {
82-
return (
83-
priceIds.STARTER.chats.yearly === item.price.id ||
84-
priceIds.PRO.chats.yearly === item.price.id
85-
)
86-
}),
8785
currency: currentSubscription.currency as 'usd' | 'eur',
8886
cancelDate: currentSubscription.cancel_at
8987
? new Date(currentSubscription.cancel_at * 1000)
9088
: undefined,
9189
},
9290
}
9391
})
94-
95-
export const chatPriceIds = [priceIds.STARTER.chats.monthly]
96-
.concat(priceIds.STARTER.chats.yearly)
97-
.concat(priceIds.PRO.chats.monthly)
98-
.concat(priceIds.PRO.chats.yearly)

apps/builder/src/features/billing/api/getUsage.ts

+52-4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { authenticatedProcedure } from '@/helpers/server/trpc'
33
import { TRPCError } from '@trpc/server'
44
import { z } from 'zod'
55
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
6+
import { env } from '@typebot.io/env'
7+
import Stripe from 'stripe'
68

79
export const getUsage = authenticatedProcedure
810
.meta({
@@ -19,13 +21,15 @@ export const getUsage = authenticatedProcedure
1921
workspaceId: z.string(),
2022
})
2123
)
22-
.output(z.object({ totalChatsUsed: z.number() }))
24+
.output(z.object({ totalChatsUsed: z.number(), resetsAt: z.date() }))
2325
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
2426
const workspace = await prisma.workspace.findFirst({
2527
where: {
2628
id: workspaceId,
2729
},
2830
select: {
31+
stripeId: true,
32+
plan: true,
2933
members: {
3034
select: {
3135
userId: true,
@@ -42,19 +46,63 @@ export const getUsage = authenticatedProcedure
4246
message: 'Workspace not found',
4347
})
4448

45-
const now = new Date()
46-
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
49+
if (
50+
!env.STRIPE_SECRET_KEY ||
51+
!workspace.stripeId ||
52+
(workspace.plan !== 'STARTER' && workspace.plan !== 'PRO')
53+
) {
54+
const now = new Date()
55+
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
56+
57+
const totalChatsUsed = await prisma.result.count({
58+
where: {
59+
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
60+
hasStarted: true,
61+
createdAt: {
62+
gte: firstDayOfMonth,
63+
},
64+
},
65+
})
66+
67+
const firstDayOfNextMonth = new Date(
68+
firstDayOfMonth.getFullYear(),
69+
firstDayOfMonth.getMonth() + 1,
70+
1
71+
)
72+
return { totalChatsUsed, resetsAt: firstDayOfNextMonth }
73+
}
74+
75+
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
76+
apiVersion: '2022-11-15',
77+
})
78+
79+
const subscriptions = await stripe.subscriptions.list({
80+
customer: workspace.stripeId,
81+
})
82+
83+
const currentSubscription = subscriptions.data
84+
.filter((sub) => ['past_due', 'active'].includes(sub.status))
85+
.sort((a, b) => a.created - b.created)
86+
.shift()
87+
88+
if (!currentSubscription)
89+
throw new TRPCError({
90+
code: 'INTERNAL_SERVER_ERROR',
91+
message: `No subscription found on workspace: ${workspaceId}`,
92+
})
93+
4794
const totalChatsUsed = await prisma.result.count({
4895
where: {
4996
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
5097
hasStarted: true,
5198
createdAt: {
52-
gte: firstDayOfMonth,
99+
gte: new Date(currentSubscription.current_period_start * 1000),
53100
},
54101
},
55102
})
56103

57104
return {
58105
totalChatsUsed,
106+
resetsAt: new Date(currentSubscription.current_period_end * 1000),
59107
}
60108
})

apps/builder/src/features/billing/api/listInvoices.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ export const listInvoices = authenticatedProcedure
6464
.filter(
6565
(invoice) => isDefined(invoice.invoice_pdf) && isDefined(invoice.id)
6666
)
67-
.map((i) => ({
68-
id: i.number as string,
69-
url: i.invoice_pdf as string,
70-
amount: i.subtotal,
71-
currency: i.currency,
72-
date: i.status_transitions.paid_at,
67+
.map((invoice) => ({
68+
id: invoice.number as string,
69+
url: invoice.invoice_pdf as string,
70+
amount: invoice.subtotal,
71+
currency: invoice.currency,
72+
date: invoice.status_transitions.paid_at,
7373
})),
7474
}
7575
})

0 commit comments

Comments
 (0)