📄 Add Commercial License for ee folder (#1532)
This commit is contained in:
104
ee/packages/billing/api/createCheckoutSession.ts
Normal file
104
ee/packages/billing/api/createCheckoutSession.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
|
||||
import { env } from '@typebot.io/env'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { User } from '@typebot.io/prisma'
|
||||
import Stripe from 'stripe'
|
||||
import { createCheckoutSessionUrl } from '../helpers/createCheckoutSessionUrl'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
returnUrl: string
|
||||
email: string
|
||||
company: string
|
||||
plan: 'STARTER' | 'PRO'
|
||||
currency: 'usd' | 'eur'
|
||||
vat?: {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
}
|
||||
|
||||
export const createCheckoutSession = async ({
|
||||
workspaceId,
|
||||
user,
|
||||
returnUrl,
|
||||
email,
|
||||
company,
|
||||
plan,
|
||||
currency,
|
||||
vat,
|
||||
}: Props) => {
|
||||
if (!env.STRIPE_SECRET_KEY)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Stripe environment variables are missing',
|
||||
})
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
select: {
|
||||
stripeId: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!workspace || isAdminWriteWorkspaceForbidden(workspace, user))
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
if (workspace.stripeId)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Customer already exists, use updateSubscription endpoint.',
|
||||
})
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2022-11-15',
|
||||
})
|
||||
|
||||
await prisma.user.updateMany({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
company,
|
||||
},
|
||||
})
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
name: company,
|
||||
metadata: { workspaceId },
|
||||
tax_id_data: vat
|
||||
? [vat as Stripe.CustomerCreateParams.TaxIdDatum]
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const checkoutUrl = await createCheckoutSessionUrl(stripe)({
|
||||
customerId: customer.id,
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
currency,
|
||||
plan,
|
||||
returnUrl,
|
||||
})
|
||||
|
||||
if (!checkoutUrl)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Stripe checkout session creation failed',
|
||||
})
|
||||
|
||||
return {
|
||||
checkoutUrl,
|
||||
}
|
||||
}
|
158
ee/packages/billing/api/createCustomCheckoutSession.ts
Normal file
158
ee/packages/billing/api/createCustomCheckoutSession.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
|
||||
import { env } from '@typebot.io/env'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { Plan, User } from '@typebot.io/prisma'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
returnUrl: string
|
||||
email: string
|
||||
}
|
||||
export const createCustomCheckoutSession = async ({
|
||||
workspaceId,
|
||||
user,
|
||||
returnUrl,
|
||||
email,
|
||||
}: Props) => {
|
||||
if (!env.STRIPE_SECRET_KEY)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Stripe environment variables are missing',
|
||||
})
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
select: {
|
||||
stripeId: true,
|
||||
claimableCustomPlan: true,
|
||||
name: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (
|
||||
!workspace?.claimableCustomPlan ||
|
||||
workspace.claimableCustomPlan.claimedAt ||
|
||||
isAdminWriteWorkspaceForbidden(workspace, user)
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Custom plan not found',
|
||||
})
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2022-11-15',
|
||||
})
|
||||
|
||||
const vat =
|
||||
workspace.claimableCustomPlan.vatValue &&
|
||||
workspace.claimableCustomPlan.vatType
|
||||
? ({
|
||||
type: workspace.claimableCustomPlan.vatType,
|
||||
value: workspace.claimableCustomPlan.vatValue,
|
||||
} as Stripe.CustomerCreateParams.TaxIdDatum)
|
||||
: undefined
|
||||
|
||||
const customer = workspace.stripeId
|
||||
? await stripe.customers.retrieve(workspace.stripeId)
|
||||
: await stripe.customers.create({
|
||||
email,
|
||||
name: workspace.claimableCustomPlan.companyName ?? workspace.name,
|
||||
metadata: { workspaceId },
|
||||
tax_id_data: vat ? [vat] : undefined,
|
||||
})
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
success_url: `${returnUrl}?stripe=${Plan.CUSTOM}&success=true`,
|
||||
cancel_url: `${returnUrl}?stripe=cancel`,
|
||||
allow_promotion_codes: true,
|
||||
customer: customer.id,
|
||||
customer_update: {
|
||||
address: 'auto',
|
||||
name: 'never',
|
||||
},
|
||||
mode: 'subscription',
|
||||
metadata: {
|
||||
claimableCustomPlanId: workspace.claimableCustomPlan.id,
|
||||
},
|
||||
currency: workspace.claimableCustomPlan.currency,
|
||||
billing_address_collection: 'required',
|
||||
automatic_tax: { enabled: true },
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: workspace.claimableCustomPlan.currency,
|
||||
tax_behavior: 'exclusive',
|
||||
recurring: {
|
||||
interval: workspace.claimableCustomPlan.isYearly ? 'year' : 'month',
|
||||
},
|
||||
product_data: {
|
||||
name: workspace.claimableCustomPlan.name,
|
||||
description: workspace.claimableCustomPlan.description ?? undefined,
|
||||
},
|
||||
unit_amount: workspace.claimableCustomPlan.price * 100,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
price_data: {
|
||||
currency: workspace.claimableCustomPlan.currency,
|
||||
tax_behavior: 'exclusive',
|
||||
recurring: {
|
||||
interval: workspace.claimableCustomPlan.isYearly ? 'year' : 'month',
|
||||
},
|
||||
product_data: {
|
||||
name: 'Included chats per month',
|
||||
},
|
||||
unit_amount: 0,
|
||||
},
|
||||
quantity: workspace.claimableCustomPlan.chatsLimit,
|
||||
},
|
||||
{
|
||||
price_data: {
|
||||
currency: workspace.claimableCustomPlan.currency,
|
||||
tax_behavior: 'exclusive',
|
||||
recurring: {
|
||||
interval: workspace.claimableCustomPlan.isYearly ? 'year' : 'month',
|
||||
},
|
||||
product_data: {
|
||||
name: 'Included storage per month',
|
||||
},
|
||||
unit_amount: 0,
|
||||
},
|
||||
quantity: workspace.claimableCustomPlan.storageLimit,
|
||||
},
|
||||
{
|
||||
price_data: {
|
||||
currency: workspace.claimableCustomPlan.currency,
|
||||
tax_behavior: 'exclusive',
|
||||
recurring: {
|
||||
interval: workspace.claimableCustomPlan.isYearly ? 'year' : 'month',
|
||||
},
|
||||
product_data: {
|
||||
name: 'Included seats',
|
||||
},
|
||||
unit_amount: 0,
|
||||
},
|
||||
quantity: workspace.claimableCustomPlan.seatsLimit,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (!session.url)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Stripe checkout session creation failed',
|
||||
})
|
||||
|
||||
return {
|
||||
checkoutUrl: session.url,
|
||||
}
|
||||
}
|
47
ee/packages/billing/api/getBillingPortalUrl.ts
Normal file
47
ee/packages/billing/api/getBillingPortalUrl.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
|
||||
import { env } from '@typebot.io/env'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { User } from '@typebot.io/prisma'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
}
|
||||
export const getBillingPortalUrl = async ({ workspaceId, user }: Props) => {
|
||||
if (!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,
|
||||
},
|
||||
select: {
|
||||
stripeId: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!workspace?.stripeId || isAdminWriteWorkspaceForbidden(workspace, user))
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2022-11-15',
|
||||
})
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: workspace.stripeId,
|
||||
return_url: `${env.NEXTAUTH_URL}/typebots`,
|
||||
})
|
||||
return {
|
||||
billingPortalUrl: portalSession.url,
|
||||
}
|
||||
}
|
74
ee/packages/billing/api/getSubscription.ts
Normal file
74
ee/packages/billing/api/getSubscription.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { isReadWorkspaceFobidden } from '@typebot.io/db-rules/isReadWorkspaceFobidden'
|
||||
import { env } from '@typebot.io/env'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { User } from '@typebot.io/prisma'
|
||||
import Stripe from 'stripe'
|
||||
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
}
|
||||
|
||||
export const getSubscription = async ({ workspaceId, user }: Props) => {
|
||||
if (!env.STRIPE_SECRET_KEY)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Stripe environment variables are missing',
|
||||
})
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
select: {
|
||||
stripeId: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!workspace || isReadWorkspaceFobidden(workspace, user))
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
if (!workspace?.stripeId)
|
||||
return {
|
||||
subscription: null,
|
||||
}
|
||||
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)
|
||||
return {
|
||||
subscription: null,
|
||||
}
|
||||
|
||||
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),
|
||||
currency: currentSubscription.currency as 'usd' | 'eur',
|
||||
cancelDate: currentSubscription.cancel_at
|
||||
? new Date(currentSubscription.cancel_at * 1000)
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
}
|
96
ee/packages/billing/api/getUsage.ts
Normal file
96
ee/packages/billing/api/getUsage.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { env } from '@typebot.io/env'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { User } from '@typebot.io/prisma'
|
||||
import Stripe from 'stripe'
|
||||
import { isReadWorkspaceFobidden } from '@typebot.io/db-rules/isReadWorkspaceFobidden'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
}
|
||||
|
||||
export const getUsage = async ({ workspaceId, user }: Props) => {
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
select: {
|
||||
stripeId: true,
|
||||
plan: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
typebots: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!workspace || isReadWorkspaceFobidden(workspace, user))
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
|
||||
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: new Date(currentSubscription.current_period_start * 1000),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
totalChatsUsed,
|
||||
resetsAt: new Date(currentSubscription.current_period_end * 1000),
|
||||
}
|
||||
}
|
59
ee/packages/billing/api/listInvoices.ts
Normal file
59
ee/packages/billing/api/listInvoices.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { User } from '@typebot.io/prisma'
|
||||
import { Stripe } from 'stripe'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
}
|
||||
|
||||
export const listInvoices = async ({ workspaceId, user }: Props) => {
|
||||
if (!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,
|
||||
},
|
||||
select: {
|
||||
stripeId: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!workspace?.stripeId || isAdminWriteWorkspaceForbidden(workspace, user))
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2022-11-15',
|
||||
})
|
||||
const invoices = await stripe.invoices.list({
|
||||
customer: workspace.stripeId,
|
||||
limit: 50,
|
||||
})
|
||||
return {
|
||||
invoices: invoices.data
|
||||
.filter(
|
||||
(invoice) => isDefined(invoice.invoice_pdf) && isDefined(invoice.id)
|
||||
)
|
||||
.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,
|
||||
})),
|
||||
}
|
||||
}
|
153
ee/packages/billing/api/updateSubscription.ts
Normal file
153
ee/packages/billing/api/updateSubscription.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { env } from '@typebot.io/env'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import Stripe from 'stripe'
|
||||
import { trackEvents } from '@typebot.io/telemetry/trackEvents'
|
||||
import { isAdminWriteWorkspaceForbidden } from '@typebot.io/db-rules/isAdminWriteWorkspaceForbidden'
|
||||
import { User } from '@typebot.io/schemas'
|
||||
import { createCheckoutSessionUrl } from '../helpers/createCheckoutSessionUrl'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
plan: 'STARTER' | 'PRO'
|
||||
returnUrl: string
|
||||
currency: 'usd' | 'eur'
|
||||
}
|
||||
|
||||
export const updateSubscription = async ({
|
||||
workspaceId,
|
||||
user,
|
||||
plan,
|
||||
returnUrl,
|
||||
currency,
|
||||
}: Props) => {
|
||||
if (!env.STRIPE_SECRET_KEY)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Stripe environment variables are missing',
|
||||
})
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
select: {
|
||||
isPastDue: true,
|
||||
stripeId: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (workspace?.isPastDue)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'You have unpaid invoices. Please head over your billing portal to pay it.',
|
||||
})
|
||||
if (!workspace?.stripeId || isAdminWriteWorkspaceForbidden(workspace, user))
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2022-11-15',
|
||||
})
|
||||
const { data } = await stripe.subscriptions.list({
|
||||
customer: workspace.stripeId,
|
||||
limit: 1,
|
||||
status: 'active',
|
||||
})
|
||||
const subscription = data[0] as Stripe.Subscription | undefined
|
||||
const currentPlanItemId = subscription?.items.data.find((item) =>
|
||||
[env.STRIPE_STARTER_PRICE_ID, env.STRIPE_PRO_PRICE_ID].includes(
|
||||
item.price.id
|
||||
)
|
||||
)?.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 items = [
|
||||
{
|
||||
id: currentPlanItemId,
|
||||
price:
|
||||
plan === Plan.STARTER
|
||||
? env.STRIPE_STARTER_PRICE_ID
|
||||
: env.STRIPE_PRO_PRICE_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
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',
|
||||
})
|
||||
} else {
|
||||
const checkoutUrl = await createCheckoutSessionUrl(stripe)({
|
||||
customerId: workspace.stripeId,
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
currency,
|
||||
plan,
|
||||
returnUrl,
|
||||
})
|
||||
|
||||
return { checkoutUrl }
|
||||
}
|
||||
|
||||
const updatedWorkspace = await prisma.workspace.update({
|
||||
where: { id: workspaceId },
|
||||
data: {
|
||||
plan,
|
||||
isQuarantined: false,
|
||||
},
|
||||
})
|
||||
|
||||
await trackEvents([
|
||||
{
|
||||
name: 'Subscription updated',
|
||||
workspaceId,
|
||||
userId: user.id,
|
||||
data: {
|
||||
plan,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return { workspace: updatedWorkspace }
|
||||
}
|
336
ee/packages/billing/api/webhookHandler.ts
Normal file
336
ee/packages/billing/api/webhookHandler.ts
Normal file
@ -0,0 +1,336 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { Stripe } from 'stripe'
|
||||
import { buffer } from 'micro'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { trackEvents } from '@typebot.io/telemetry/trackEvents'
|
||||
import { prices } from '../constants'
|
||||
import { Settings } from '@typebot.io/schemas'
|
||||
import { methodNotAllowed } from '@typebot.io/lib/api/utils'
|
||||
|
||||
if (!env.STRIPE_SECRET_KEY || !env.STRIPE_WEBHOOK_SECRET)
|
||||
throw new Error('STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET missing')
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2022-11-15',
|
||||
})
|
||||
|
||||
const webhookSecret = env.STRIPE_WEBHOOK_SECRET as string
|
||||
|
||||
export const webhookHandler = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) => {
|
||||
if (req.method === 'POST') {
|
||||
const buf = await buffer(req)
|
||||
const sig = req.headers['stripe-signature']
|
||||
|
||||
if (!sig) return res.status(400).send(`stripe-signature is missing`)
|
||||
try {
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
buf.toString(),
|
||||
sig.toString(),
|
||||
webhookSecret
|
||||
)
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
const metadata = session.metadata as unknown as
|
||||
| {
|
||||
plan: 'STARTER' | 'PRO'
|
||||
workspaceId: string
|
||||
userId: string
|
||||
}
|
||||
| { claimableCustomPlanId: string; userId: string }
|
||||
if ('plan' in metadata) {
|
||||
const { workspaceId, plan } = metadata
|
||||
if (!workspaceId || !plan)
|
||||
return res
|
||||
.status(500)
|
||||
.send({ message: `Couldn't retrieve valid metadata` })
|
||||
|
||||
const workspace = await prisma.workspace.update({
|
||||
where: { id: workspaceId },
|
||||
data: {
|
||||
plan,
|
||||
stripeId: session.customer as string,
|
||||
isQuarantined: false,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: { userId: true },
|
||||
where: {
|
||||
role: WorkspaceRole.ADMIN,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await trackEvents(
|
||||
workspace.members.map((m) => ({
|
||||
name: 'Subscription updated',
|
||||
workspaceId,
|
||||
userId: m.userId,
|
||||
data: {
|
||||
plan,
|
||||
},
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
const { claimableCustomPlanId, userId } = metadata
|
||||
if (!claimableCustomPlanId)
|
||||
return res
|
||||
.status(500)
|
||||
.send({ message: `Couldn't retrieve valid metadata` })
|
||||
const { workspaceId, chatsLimit, seatsLimit, storageLimit } =
|
||||
await prisma.claimableCustomPlan.update({
|
||||
where: { id: claimableCustomPlanId },
|
||||
data: { claimedAt: new Date() },
|
||||
})
|
||||
|
||||
await prisma.workspace.updateMany({
|
||||
where: { id: workspaceId },
|
||||
data: {
|
||||
plan: Plan.CUSTOM,
|
||||
stripeId: session.customer as string,
|
||||
customChatsLimit: chatsLimit,
|
||||
customStorageLimit: storageLimit,
|
||||
customSeatsLimit: seatsLimit,
|
||||
},
|
||||
})
|
||||
|
||||
await trackEvents([
|
||||
{
|
||||
name: 'Subscription updated',
|
||||
workspaceId,
|
||||
userId,
|
||||
data: {
|
||||
plan: Plan.CUSTOM,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
return res.status(200).send({ message: 'workspace upgraded in DB' })
|
||||
}
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
if (subscription.status !== 'past_due')
|
||||
return res.send({ message: 'Not past_due, skipping.' })
|
||||
const existingWorkspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
stripeId: subscription.customer as string,
|
||||
},
|
||||
select: {
|
||||
isPastDue: true,
|
||||
id: true,
|
||||
members: {
|
||||
select: { userId: true, role: true },
|
||||
where: { role: WorkspaceRole.ADMIN },
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!existingWorkspace) throw new Error('Workspace not found')
|
||||
if (existingWorkspace?.isPastDue)
|
||||
return res.send({
|
||||
message: 'Workspace already past due, skipping.',
|
||||
})
|
||||
await prisma.workspace.updateMany({
|
||||
where: {
|
||||
id: existingWorkspace.id,
|
||||
},
|
||||
data: {
|
||||
isPastDue: true,
|
||||
},
|
||||
})
|
||||
await trackEvents(
|
||||
existingWorkspace.members.map((m) => ({
|
||||
name: 'Workspace past due',
|
||||
workspaceId: existingWorkspace.id,
|
||||
userId: m.userId,
|
||||
}))
|
||||
)
|
||||
return res.send({ message: 'Workspace set to past due.' })
|
||||
}
|
||||
case 'invoice.paid': {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
stripeId: invoice.customer as string,
|
||||
},
|
||||
select: {
|
||||
isPastDue: true,
|
||||
},
|
||||
})
|
||||
if (!workspace?.isPastDue)
|
||||
return res.send({ message: 'Workspace not past_due, skipping.' })
|
||||
const outstandingInvoices = await stripe.invoices.list({
|
||||
customer: invoice.customer as string,
|
||||
status: 'open',
|
||||
})
|
||||
const outstandingInvoicesWithAdditionalUsageCosts =
|
||||
outstandingInvoices.data.filter(
|
||||
(invoice) => invoice.amount_due > prices['PRO'] * 100
|
||||
)
|
||||
if (outstandingInvoicesWithAdditionalUsageCosts.length > 0)
|
||||
return res.send({
|
||||
message: 'Workspace has outstanding invoices, skipping.',
|
||||
})
|
||||
const updatedWorkspace = await prisma.workspace.update({
|
||||
where: {
|
||||
stripeId: invoice.customer as string,
|
||||
},
|
||||
data: {
|
||||
isPastDue: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
members: {
|
||||
select: { userId: true },
|
||||
where: {
|
||||
role: WorkspaceRole.ADMIN,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await trackEvents(
|
||||
updatedWorkspace.members.map((m) => ({
|
||||
name: 'Workspace past due status removed',
|
||||
workspaceId: updatedWorkspace.id,
|
||||
userId: m.userId,
|
||||
}))
|
||||
)
|
||||
return res.send({ message: 'Workspace was regulated' })
|
||||
}
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
const { data } = await stripe.subscriptions.list({
|
||||
customer: subscription.customer as string,
|
||||
limit: 1,
|
||||
status: 'active',
|
||||
})
|
||||
const existingSubscription = data[0] as
|
||||
| Stripe.Subscription
|
||||
| undefined
|
||||
if (existingSubscription)
|
||||
return res.send({
|
||||
message:
|
||||
'An active subscription still exists. Skipping downgrade.',
|
||||
})
|
||||
const outstandingInvoices = await stripe.invoices.list({
|
||||
customer: subscription.customer as string,
|
||||
status: 'open',
|
||||
})
|
||||
const outstandingInvoicesWithAdditionalUsageCosts =
|
||||
outstandingInvoices.data.filter(
|
||||
(invoice) => invoice.amount_due > prices['PRO'] * 100
|
||||
)
|
||||
|
||||
const workspaceExist =
|
||||
(await prisma.workspace.count({
|
||||
where: {
|
||||
stripeId: subscription.customer as string,
|
||||
},
|
||||
})) > 0
|
||||
|
||||
if (!workspaceExist)
|
||||
return res.send({ message: 'Workspace not found, skipping...' })
|
||||
|
||||
const workspace = await prisma.workspace.update({
|
||||
where: {
|
||||
stripeId: subscription.customer as string,
|
||||
},
|
||||
data: {
|
||||
plan: Plan.FREE,
|
||||
customChatsLimit: null,
|
||||
customStorageLimit: null,
|
||||
customSeatsLimit: null,
|
||||
isPastDue: outstandingInvoicesWithAdditionalUsageCosts.length > 0,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: { userId: true },
|
||||
where: {
|
||||
role: WorkspaceRole.ADMIN,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await trackEvents(
|
||||
workspace.members.map((m) => ({
|
||||
name: 'Subscription updated',
|
||||
workspaceId: workspace.id,
|
||||
userId: m.userId,
|
||||
data: {
|
||||
plan: Plan.FREE,
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const typebots = await prisma.typebot.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
isArchived: { not: true },
|
||||
},
|
||||
include: { publishedTypebot: true },
|
||||
})
|
||||
for (const typebot of typebots) {
|
||||
const settings = typebot.settings as Settings
|
||||
if (settings.general?.isBrandingEnabled) continue
|
||||
await prisma.typebot.updateMany({
|
||||
where: { id: typebot.id },
|
||||
data: {
|
||||
settings: {
|
||||
...settings,
|
||||
general: {
|
||||
...settings.general,
|
||||
isBrandingEnabled: true,
|
||||
},
|
||||
whatsApp: settings.whatsApp
|
||||
? {
|
||||
...settings.whatsApp,
|
||||
isEnabled: false,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
const publishedTypebotSettings = typebot.publishedTypebot
|
||||
?.settings as Settings | null
|
||||
if (
|
||||
!publishedTypebotSettings ||
|
||||
publishedTypebotSettings?.general?.isBrandingEnabled
|
||||
)
|
||||
continue
|
||||
await prisma.publicTypebot.updateMany({
|
||||
where: { id: typebot.id },
|
||||
data: {
|
||||
settings: {
|
||||
...publishedTypebotSettings,
|
||||
general: {
|
||||
...publishedTypebotSettings.general,
|
||||
isBrandingEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return res.send({ message: 'workspace downgraded in DB' })
|
||||
}
|
||||
default: {
|
||||
return res.status(304).send({ message: 'event not handled' })
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (err instanceof Error) {
|
||||
console.error(err)
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
}
|
||||
return res.status(500).send(`Error occured: ${err}`)
|
||||
}
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
}
|
167
ee/packages/billing/constants.ts
Normal file
167
ee/packages/billing/constants.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import type { Stripe } from 'stripe'
|
||||
|
||||
export const prices = {
|
||||
[Plan.STARTER]: 39,
|
||||
[Plan.PRO]: 89,
|
||||
} as const
|
||||
|
||||
export const chatsLimits = {
|
||||
[Plan.FREE]: 200,
|
||||
[Plan.STARTER]: 2000,
|
||||
[Plan.PRO]: 10000,
|
||||
} as const
|
||||
|
||||
export const seatsLimits = {
|
||||
[Plan.FREE]: 1,
|
||||
[Plan.OFFERED]: 1,
|
||||
[Plan.STARTER]: 2,
|
||||
[Plan.PRO]: 5,
|
||||
[Plan.LIFETIME]: 8,
|
||||
} as const
|
||||
|
||||
export const starterChatTiers = [
|
||||
{
|
||||
up_to: 2000,
|
||||
flat_amount: 0,
|
||||
},
|
||||
{
|
||||
up_to: 2500,
|
||||
flat_amount: 1000,
|
||||
},
|
||||
{
|
||||
up_to: 3000,
|
||||
flat_amount: 2000,
|
||||
},
|
||||
{
|
||||
up_to: 3500,
|
||||
flat_amount: 3000,
|
||||
},
|
||||
{
|
||||
up_to: 4000,
|
||||
flat_amount: 4000,
|
||||
},
|
||||
{
|
||||
up_to: 'inf',
|
||||
unit_amount: 2,
|
||||
},
|
||||
] satisfies Stripe.PriceCreateParams.Tier[]
|
||||
|
||||
export const proChatTiers = [
|
||||
{
|
||||
up_to: 10000,
|
||||
flat_amount: 0,
|
||||
},
|
||||
{
|
||||
up_to: 15000,
|
||||
flat_amount: 5000,
|
||||
},
|
||||
{
|
||||
up_to: 20000,
|
||||
flat_amount: 9500,
|
||||
},
|
||||
{
|
||||
up_to: 30000,
|
||||
flat_amount: 18000,
|
||||
},
|
||||
{
|
||||
up_to: 40000,
|
||||
flat_amount: 26000,
|
||||
},
|
||||
{
|
||||
up_to: 50000,
|
||||
flat_amount: 33500,
|
||||
},
|
||||
{
|
||||
up_to: 60000,
|
||||
flat_amount: 40700,
|
||||
},
|
||||
{
|
||||
up_to: 70000,
|
||||
flat_amount: 47700,
|
||||
},
|
||||
{
|
||||
up_to: 80000,
|
||||
flat_amount: 54500,
|
||||
},
|
||||
{
|
||||
up_to: 90000,
|
||||
flat_amount: 61100,
|
||||
},
|
||||
{
|
||||
up_to: 100000,
|
||||
flat_amount: 67500,
|
||||
},
|
||||
{
|
||||
up_to: 120000,
|
||||
flat_amount: 79900,
|
||||
},
|
||||
{
|
||||
up_to: 140000,
|
||||
flat_amount: 91900,
|
||||
},
|
||||
{
|
||||
up_to: 160000,
|
||||
flat_amount: 103700,
|
||||
},
|
||||
{
|
||||
up_to: 180000,
|
||||
flat_amount: 115300,
|
||||
},
|
||||
{
|
||||
up_to: 200000,
|
||||
flat_amount: 126700,
|
||||
},
|
||||
{
|
||||
up_to: 300000,
|
||||
flat_amount: 181700,
|
||||
},
|
||||
{
|
||||
up_to: 400000,
|
||||
flat_amount: 234700,
|
||||
},
|
||||
{
|
||||
up_to: 500000,
|
||||
flat_amount: 285700,
|
||||
},
|
||||
{
|
||||
up_to: 600000,
|
||||
flat_amount: 335700,
|
||||
},
|
||||
{
|
||||
up_to: 700000,
|
||||
flat_amount: 384700,
|
||||
},
|
||||
{
|
||||
up_to: 800000,
|
||||
flat_amount: 432700,
|
||||
},
|
||||
{
|
||||
up_to: 900000,
|
||||
flat_amount: 479700,
|
||||
},
|
||||
{
|
||||
up_to: 1000000,
|
||||
flat_amount: 525700,
|
||||
},
|
||||
{
|
||||
up_to: 1200000,
|
||||
flat_amount: 617100,
|
||||
},
|
||||
{
|
||||
up_to: 1400000,
|
||||
flat_amount: 707900,
|
||||
},
|
||||
{
|
||||
up_to: 1600000,
|
||||
flat_amount: 797900,
|
||||
},
|
||||
{
|
||||
up_to: 1800000,
|
||||
flat_amount: 887300,
|
||||
},
|
||||
{
|
||||
up_to: 'inf',
|
||||
unit_amount_decimal: '0.442',
|
||||
},
|
||||
] satisfies Stripe.PriceCreateParams.Tier[]
|
51
ee/packages/billing/helpers/createCheckoutSessionUrl.ts
Normal file
51
ee/packages/billing/helpers/createCheckoutSessionUrl.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { env } from '@typebot.io/env'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
type Props = {
|
||||
customerId: string
|
||||
workspaceId: string
|
||||
currency: 'usd' | 'eur'
|
||||
plan: 'STARTER' | 'PRO'
|
||||
returnUrl: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
export const createCheckoutSessionUrl =
|
||||
(stripe: Stripe) =>
|
||||
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`,
|
||||
allow_promotion_codes: true,
|
||||
customer: customerId,
|
||||
customer_update: {
|
||||
address: 'auto',
|
||||
name: 'never',
|
||||
},
|
||||
mode: 'subscription',
|
||||
metadata: {
|
||||
workspaceId,
|
||||
plan,
|
||||
},
|
||||
currency,
|
||||
billing_address_collection: 'required',
|
||||
automatic_tax: { enabled: true },
|
||||
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
|
||||
}
|
21
ee/packages/billing/helpers/formatPrice.ts
Normal file
21
ee/packages/billing/helpers/formatPrice.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { guessIfUserIsEuropean } from './guessIfUserIsEuropean'
|
||||
|
||||
type FormatPriceParams = {
|
||||
currency?: 'eur' | 'usd'
|
||||
maxFractionDigits?: number
|
||||
}
|
||||
|
||||
export const formatPrice = (
|
||||
price: number,
|
||||
{ currency, maxFractionDigits = 0 }: FormatPriceParams = {
|
||||
maxFractionDigits: 0,
|
||||
}
|
||||
) => {
|
||||
const isEuropean = guessIfUserIsEuropean()
|
||||
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: currency?.toUpperCase() ?? (isEuropean ? 'EUR' : 'USD'),
|
||||
maximumFractionDigits: maxFractionDigits,
|
||||
})
|
||||
return formatter.format(price)
|
||||
}
|
19
ee/packages/billing/helpers/getChatsLimit.ts
Normal file
19
ee/packages/billing/helpers/getChatsLimit.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import { chatsLimits } from '../constants'
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
|
||||
export const getChatsLimit = ({
|
||||
plan,
|
||||
customChatsLimit,
|
||||
}: Pick<Workspace, 'plan'> & {
|
||||
customChatsLimit?: Workspace['customChatsLimit']
|
||||
}) => {
|
||||
if (
|
||||
plan === Plan.UNLIMITED ||
|
||||
plan === Plan.LIFETIME ||
|
||||
plan === Plan.OFFERED
|
||||
)
|
||||
return 'inf'
|
||||
if (plan === Plan.CUSTOM) return customChatsLimit ?? 'inf'
|
||||
return chatsLimits[plan]
|
||||
}
|
12
ee/packages/billing/helpers/getSeatsLimit.ts
Normal file
12
ee/packages/billing/helpers/getSeatsLimit.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
import { seatsLimits } from '../constants'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
|
||||
export const getSeatsLimit = ({
|
||||
plan,
|
||||
customSeatsLimit,
|
||||
}: Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
|
||||
if (plan === Plan.UNLIMITED) return 'inf'
|
||||
if (plan === Plan.CUSTOM) return customSeatsLimit ? customSeatsLimit : 'inf'
|
||||
return seatsLimits[plan]
|
||||
}
|
56
ee/packages/billing/helpers/guessIfUserIsEuropean.ts
Normal file
56
ee/packages/billing/helpers/guessIfUserIsEuropean.ts
Normal file
@ -0,0 +1,56 @@
|
||||
const europeanUnionCountryCodes = [
|
||||
'AT',
|
||||
'BE',
|
||||
'BG',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DE',
|
||||
'DK',
|
||||
'EE',
|
||||
'ES',
|
||||
'FI',
|
||||
'FR',
|
||||
'GR',
|
||||
'HR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LT',
|
||||
'LU',
|
||||
'LV',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SE',
|
||||
'SI',
|
||||
'SK',
|
||||
]
|
||||
|
||||
const europeanUnionExclusiveLanguageCodes = [
|
||||
'fr',
|
||||
'de',
|
||||
'it',
|
||||
'el',
|
||||
'pl',
|
||||
'fi',
|
||||
'nl',
|
||||
'hr',
|
||||
'cs',
|
||||
'hu',
|
||||
'ro',
|
||||
'sl',
|
||||
'sv',
|
||||
'bg',
|
||||
]
|
||||
|
||||
export const guessIfUserIsEuropean = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.navigator.languages.some((language) => {
|
||||
const [languageCode, countryCode] = language.split('-')
|
||||
return countryCode
|
||||
? europeanUnionCountryCodes.includes(countryCode)
|
||||
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
|
||||
})
|
||||
}
|
28
ee/packages/billing/package.json
Normal file
28
ee/packages/billing/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@typebot.io/billing",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Baptiste Arnaud",
|
||||
"dependencies": {
|
||||
"@typebot.io/prisma": "workspace:*",
|
||||
"@typebot.io/schemas": "workspace:*",
|
||||
"stripe": "12.13.0",
|
||||
"@typebot.io/lib": "workspace:*",
|
||||
"@trpc/server": "10.40.0",
|
||||
"@typebot.io/env": "workspace:*",
|
||||
"@typebot.io/telemetry": "workspace:*",
|
||||
"@typebot.io/db-rules": "workspace:*",
|
||||
"next": "14.1.0",
|
||||
"micro": "10.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typebot.io/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
555
ee/packages/billing/taxIdTypes.ts
Normal file
555
ee/packages/billing/taxIdTypes.ts
Normal file
@ -0,0 +1,555 @@
|
||||
export const taxIdTypes = [
|
||||
{
|
||||
type: 'ae_trn',
|
||||
code: 'AE TRN',
|
||||
name: 'United Arab Emirates',
|
||||
emoji: '🇦🇪',
|
||||
placeholder: '123456789012345',
|
||||
},
|
||||
{
|
||||
type: 'au_abn',
|
||||
code: 'AU ABN',
|
||||
name: 'Australia',
|
||||
emoji: '🇦🇺',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'au_arn',
|
||||
code: 'AU ARN',
|
||||
name: 'Australia',
|
||||
emoji: '🇦🇺',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'bg_uic',
|
||||
code: 'BG UIC',
|
||||
name: 'Bulgaria',
|
||||
emoji: '🇧🇬',
|
||||
placeholder: 'BG123456789',
|
||||
},
|
||||
{
|
||||
type: 'br_cnpj',
|
||||
code: 'BR CNPJ',
|
||||
name: 'Brazil',
|
||||
emoji: '🇧🇷',
|
||||
placeholder: '12.345.678/0001-23',
|
||||
},
|
||||
{
|
||||
type: 'br_cpf',
|
||||
code: 'BR CPF',
|
||||
name: 'Brazil',
|
||||
emoji: '🇧🇷',
|
||||
placeholder: '123.456.789-01',
|
||||
},
|
||||
{
|
||||
type: 'ca_bn',
|
||||
code: 'CA BN',
|
||||
name: 'Canada',
|
||||
emoji: '🇨🇦',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'ca_gst_hst',
|
||||
code: 'CA GST/HST',
|
||||
name: 'Canada',
|
||||
emoji: '🇨🇦',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'ca_pst_bc',
|
||||
code: 'CA PST-BC',
|
||||
name: 'Canada',
|
||||
emoji: '🇨🇦',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'ca_pst_mb',
|
||||
code: 'CA PST-MB',
|
||||
name: 'Canada',
|
||||
emoji: '🇨🇦',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'ca_pst_sk',
|
||||
code: 'CA PST-SK',
|
||||
name: 'Canada',
|
||||
emoji: '🇨🇦',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'ca_qst',
|
||||
code: 'CA QST',
|
||||
name: 'Canada',
|
||||
emoji: '🇨🇦',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'ch_vat',
|
||||
code: 'CH VAT',
|
||||
name: 'Switzerland',
|
||||
emoji: '🇨🇭',
|
||||
placeholder: 'CHE-123.456.789 MWST',
|
||||
},
|
||||
{
|
||||
type: 'cl_tin',
|
||||
code: 'CL TIN',
|
||||
name: 'Chile',
|
||||
emoji: '🇨🇱',
|
||||
placeholder: '12345678-9',
|
||||
},
|
||||
{
|
||||
type: 'eg_tin',
|
||||
code: 'EG TIN',
|
||||
name: 'Egypt',
|
||||
emoji: '🇪🇬',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'es_cif',
|
||||
code: 'ES CIF',
|
||||
name: 'Spain',
|
||||
emoji: '🇪🇸',
|
||||
placeholder: 'A12345678',
|
||||
},
|
||||
{
|
||||
type: 'eu_oss_vat',
|
||||
code: 'EU OSS VAT',
|
||||
name: 'European Union',
|
||||
emoji: '🇪🇺',
|
||||
placeholder: 'XX123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'AT VAT',
|
||||
name: 'Austria',
|
||||
emoji: '🇦🇹',
|
||||
placeholder: 'ATU12345678',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'DE VAT',
|
||||
name: 'Germany',
|
||||
emoji: '🇩🇪',
|
||||
placeholder: 'DE123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'BE VAT',
|
||||
name: 'Belgium',
|
||||
emoji: '🇧🇪',
|
||||
placeholder: 'BE0123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'BG VAT',
|
||||
name: 'Bulgaria',
|
||||
emoji: '🇧🇬',
|
||||
placeholder: 'BG123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'CY VAT',
|
||||
name: 'Cyprus',
|
||||
emoji: '🇨🇾',
|
||||
placeholder: 'CY12345678L',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'DK VAT',
|
||||
name: 'Denmark',
|
||||
emoji: '🇩🇰',
|
||||
placeholder: 'DK12345678',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'EE VAT',
|
||||
name: 'Estonia',
|
||||
emoji: '🇪🇪',
|
||||
placeholder: 'EE123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'GR VAT',
|
||||
name: 'Greece',
|
||||
emoji: '🇬🇷',
|
||||
placeholder: 'EL123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'ES VAT',
|
||||
name: 'Spain',
|
||||
emoji: '🇪🇸',
|
||||
placeholder: 'ESX12345678',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'FI VAT',
|
||||
name: 'Finland',
|
||||
emoji: '🇫🇮',
|
||||
placeholder: 'FI12345678',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'FR VAT',
|
||||
name: 'France',
|
||||
emoji: '🇫🇷',
|
||||
placeholder: 'FRXX123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'HR VAT',
|
||||
name: 'Croatia',
|
||||
emoji: '🇭🇷',
|
||||
placeholder: 'HR12345678901',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'IE VAT',
|
||||
name: 'Ireland',
|
||||
emoji: '🇮🇪',
|
||||
placeholder: 'IE1X12345X',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'IT VAT',
|
||||
name: 'Italy',
|
||||
emoji: '🇮🇹',
|
||||
placeholder: 'IT12345678901',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'LT VAT',
|
||||
name: 'Lithuania',
|
||||
emoji: '🇱🇹',
|
||||
placeholder: 'LT123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'LU VAT',
|
||||
name: 'Luxembourg',
|
||||
emoji: '🇱🇺',
|
||||
placeholder: 'LU12345678',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'LV VAT',
|
||||
name: 'Latvia',
|
||||
emoji: '🇱🇻',
|
||||
placeholder: 'LV12345678901',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'MT VAT',
|
||||
name: 'Malta',
|
||||
emoji: '🇲🇹',
|
||||
placeholder: 'MT12345678',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'NL VAT',
|
||||
name: 'Netherlands',
|
||||
emoji: '🇳🇱',
|
||||
placeholder: 'NL123456789B01',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'PL VAT',
|
||||
name: 'Poland',
|
||||
emoji: '🇵🇱',
|
||||
placeholder: 'PL1234567890',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'PT VAT',
|
||||
name: 'Portugal',
|
||||
emoji: '🇵🇹',
|
||||
placeholder: 'PT123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'RO VAT',
|
||||
name: 'Romania',
|
||||
emoji: '🇷🇴',
|
||||
placeholder: 'RO1234567891',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'SE VAT',
|
||||
name: 'Sweden',
|
||||
emoji: '🇸🇪',
|
||||
placeholder: 'SE123456789101',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'SI VAT',
|
||||
name: 'Slovenia',
|
||||
emoji: '🇸🇮',
|
||||
placeholder: 'SI12345678',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'SK VAT',
|
||||
name: 'Slovakia',
|
||||
emoji: '🇸🇰',
|
||||
placeholder: 'SK1234567890',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'XI VAT',
|
||||
name: 'Northern Ireland',
|
||||
emoji: '🇮🇪',
|
||||
placeholder: 'XI123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'GE VAT',
|
||||
name: 'Georgia',
|
||||
emoji: '🇬🇪',
|
||||
placeholder: 'GE123456789',
|
||||
},
|
||||
{
|
||||
type: 'eu_vat',
|
||||
code: 'CZ VAT',
|
||||
name: 'Czech Republic',
|
||||
emoji: '🇨🇿',
|
||||
placeholder: 'CZ12345678',
|
||||
},
|
||||
{
|
||||
type: 'gb_vat',
|
||||
code: 'GB VAT',
|
||||
name: 'United Kingdom',
|
||||
emoji: '🇬🇧',
|
||||
placeholder: 'GB123456789',
|
||||
},
|
||||
{
|
||||
type: 'ge_vat',
|
||||
code: 'GE VAT',
|
||||
name: 'Georgia',
|
||||
emoji: '🇬🇪',
|
||||
placeholder: 'GE123456789',
|
||||
},
|
||||
{
|
||||
type: 'hk_br',
|
||||
code: 'HK BR',
|
||||
name: 'Hong Kong',
|
||||
emoji: '🇭🇰',
|
||||
placeholder: '12345678',
|
||||
},
|
||||
{
|
||||
type: 'hu_tin',
|
||||
code: 'HU TIN',
|
||||
name: 'Hungary',
|
||||
emoji: '🇭🇺',
|
||||
placeholder: '13804079-2-13',
|
||||
},
|
||||
{
|
||||
type: 'hu_vat',
|
||||
code: 'HU VAT',
|
||||
name: 'Hungary',
|
||||
emoji: '🇭🇺',
|
||||
placeholder: 'HU12345678',
|
||||
},
|
||||
{
|
||||
type: 'id_npwp',
|
||||
code: 'ID NPWP',
|
||||
name: 'Indonesia',
|
||||
emoji: '🇮🇩',
|
||||
placeholder: '123.456.7-8910.000',
|
||||
},
|
||||
{
|
||||
type: 'il_vat',
|
||||
code: 'IL VAT',
|
||||
name: 'Israel',
|
||||
emoji: '🇮🇱',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'in_gst',
|
||||
code: 'IN GST',
|
||||
name: 'India',
|
||||
emoji: '🇮🇳',
|
||||
placeholder: '12ABCDE1234F1Z5',
|
||||
},
|
||||
{
|
||||
type: 'is_vat',
|
||||
code: 'IS VAT',
|
||||
name: 'Iceland',
|
||||
emoji: '🇮🇸',
|
||||
placeholder: '123456-7890',
|
||||
},
|
||||
{
|
||||
type: 'jp_cn',
|
||||
code: 'JP CN',
|
||||
name: 'Japan',
|
||||
emoji: '🇯🇵',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'jp_rn',
|
||||
code: 'JP RN',
|
||||
name: 'Japan',
|
||||
emoji: '🇯🇵',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'jp_trn',
|
||||
code: 'JP TRN',
|
||||
name: 'Japan',
|
||||
emoji: '🇯🇵',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'ke_pin',
|
||||
code: 'KE PIN',
|
||||
name: 'Kenya',
|
||||
emoji: '🇰🇪',
|
||||
placeholder: '12345678A',
|
||||
},
|
||||
{
|
||||
type: 'kr_brn',
|
||||
code: 'KR BRN',
|
||||
name: 'South Korea',
|
||||
emoji: '🇰🇷',
|
||||
placeholder: '1234567890',
|
||||
},
|
||||
{
|
||||
type: 'li_uid',
|
||||
code: 'LI UID',
|
||||
name: 'Liechtenstein',
|
||||
emoji: '🇱🇮',
|
||||
placeholder: 'CHE-123.456.789',
|
||||
},
|
||||
{
|
||||
type: 'mx_rfc',
|
||||
code: 'MX RFC',
|
||||
name: 'Mexico',
|
||||
emoji: '🇲🇽',
|
||||
placeholder: 'XAXX010101000',
|
||||
},
|
||||
{
|
||||
type: 'my_frp',
|
||||
code: 'MY FRP',
|
||||
name: 'Malaysia',
|
||||
emoji: '🇲🇾',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'my_itn',
|
||||
code: 'MY ITN',
|
||||
name: 'Malaysia',
|
||||
emoji: '🇲🇾',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'my_sst',
|
||||
code: 'MY SST',
|
||||
name: 'Malaysia',
|
||||
emoji: '🇲🇾',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'no_vat',
|
||||
code: 'NO VAT',
|
||||
name: 'Norway',
|
||||
emoji: '🇳🇴',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'nz_gst',
|
||||
code: 'NZ GST',
|
||||
name: 'New Zealand',
|
||||
emoji: '🇳🇿',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'ph_tin',
|
||||
code: 'PH TIN',
|
||||
name: 'Philippines',
|
||||
emoji: '🇵🇭',
|
||||
placeholder: '123-456-789-012',
|
||||
},
|
||||
{
|
||||
type: 'ru_inn',
|
||||
code: 'RU INN',
|
||||
name: 'Russia',
|
||||
emoji: '🇷🇺',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'ru_kpp',
|
||||
code: 'RU KPP',
|
||||
name: 'Russia',
|
||||
emoji: '🇷🇺',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'sa_vat',
|
||||
code: 'SA VAT',
|
||||
name: 'Saudi Arabia',
|
||||
emoji: '🇸🇦',
|
||||
placeholder: '123456789012',
|
||||
},
|
||||
{
|
||||
type: 'sg_gst',
|
||||
code: 'SG GST',
|
||||
name: 'Singapore',
|
||||
emoji: '🇸🇬',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'sg_uen',
|
||||
code: 'SG UEN',
|
||||
name: 'Singapore',
|
||||
emoji: '🇸🇬',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
type: 'si_tin',
|
||||
code: 'SI TIN',
|
||||
name: 'Slovenia',
|
||||
emoji: '🇸🇮',
|
||||
placeholder: '12345678',
|
||||
},
|
||||
{
|
||||
type: 'th_vat',
|
||||
code: 'TH VAT',
|
||||
name: 'Thailand',
|
||||
emoji: '🇹🇭',
|
||||
placeholder: '1234567890123',
|
||||
},
|
||||
{
|
||||
type: 'tr_tin',
|
||||
code: 'TR TIN',
|
||||
name: 'Turkey',
|
||||
emoji: '🇹🇷',
|
||||
placeholder: '1234567890',
|
||||
},
|
||||
{
|
||||
type: 'tw_vat',
|
||||
code: 'TW VAT',
|
||||
name: 'Taiwan',
|
||||
emoji: '🇹🇼',
|
||||
placeholder: '12345678',
|
||||
},
|
||||
{
|
||||
type: 'ua_vat',
|
||||
code: 'UA VAT',
|
||||
name: 'Ukraine',
|
||||
emoji: '🇺🇦',
|
||||
placeholder: '12345678',
|
||||
},
|
||||
{
|
||||
type: 'us_ein',
|
||||
code: 'US EIN',
|
||||
name: 'United States',
|
||||
emoji: '🇺🇸',
|
||||
placeholder: '12-3456789',
|
||||
},
|
||||
{
|
||||
type: 'za_vat',
|
||||
code: 'ZA VAT',
|
||||
name: 'South Africa',
|
||||
emoji: '🇿🇦',
|
||||
placeholder: '1234567890',
|
||||
},
|
||||
]
|
8
ee/packages/billing/tsconfig.json
Normal file
8
ee/packages/billing/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@typebot.io/tsconfig/base.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2021", "DOM"]
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user