diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts index 6b4d355fa0..7cf287b617 100644 --- a/apps/api/v2/src/modules/billing/billing.module.ts +++ b/apps/api/v2/src/modules/billing/billing.module.ts @@ -1,3 +1,4 @@ +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; import { BillingProcessor } from "@/modules/billing/billing.processor"; import { BillingRepository } from "@/modules/billing/billing.repository"; import { BillingController } from "@/modules/billing/controllers/billing.controller"; @@ -5,6 +6,7 @@ import { BillingConfigService } from "@/modules/billing/services/billing.config. import { BillingService } from "@/modules/billing/services/billing.service"; import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service"; import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OrganizationsModule } from "@/modules/organizations/organizations.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { StripeModule } from "@/modules/stripe/stripe.module"; @@ -33,6 +35,8 @@ import { Module } from "@nestjs/common"; BillingRepository, BillingProcessor, ManagedOrganizationsBillingService, + OAuthClientRepository, + BookingsRepository_2024_08_13, ], exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService], controllers: [BillingController], diff --git a/apps/api/v2/src/modules/billing/billing.repository.ts b/apps/api/v2/src/modules/billing/billing.repository.ts index 4d8cc01919..8b7cbef2c6 100644 --- a/apps/api/v2/src/modules/billing/billing.repository.ts +++ b/apps/api/v2/src/modules/billing/billing.repository.ts @@ -15,12 +15,21 @@ export class BillingRepository { }, }); + async getBillingForTeamBySubscriptionId(subscriptionId: string) { + return this.dbRead.prisma.platformBilling.findFirst({ + where: { + subscriptionId, + }, + }); + } + async updateTeamBilling( teamId: number, billingStart: number, billingEnd: number, plan: PlatformPlan, - subscriptionId?: string + subscriptionId?: string, + priceId?: string ) { return this.dbWrite.prisma.platformBilling.update({ where: { @@ -32,6 +41,7 @@ export class BillingRepository { subscriptionId, plan: plan.toString(), overdue: false, + priceId, }, }); } diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts index 8ac55636ed..8b53a5a680 100644 --- a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts @@ -127,6 +127,9 @@ export class BillingController { case "customer.subscription.deleted": await this.billingService.handleStripeSubscriptionDeleted(event); break; + case "invoice.created": + await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event); + break; case "invoice.payment_failed": await this.billingService.handleStripePaymentFailed(event); break; diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts index 55812befbc..5844ff83e2 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -1,10 +1,13 @@ import { AppConfig } from "@/config/type"; +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor"; import { BillingRepository } from "@/modules/billing/billing.repository"; import { BillingConfigService } from "@/modules/billing/services/billing.config.service"; import { PlatformPlan } from "@/modules/billing/types"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; import { StripeService } from "@/modules/stripe/stripe.service"; +import { UsersRepository } from "@/modules/users/users.repository"; import { InjectQueue } from "@nestjs/bull"; import { BadRequestException, @@ -30,6 +33,9 @@ export class BillingService implements OnModuleDestroy { private readonly billingRepository: BillingRepository, private readonly configService: ConfigService, private readonly billingConfigService: BillingConfigService, + private readonly usersRepository: UsersRepository, + private readonly oAuthClientRepository: OAuthClientRepository, + private readonly bookingsRepository: BookingsRepository_2024_08_13, @InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue ) { this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com"; @@ -119,7 +125,7 @@ export class BillingService implements OnModuleDestroy { return url; } - async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) { + async setPerBookingSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) { const billingCycleStart = DateTime.now().get("day"); const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day"); @@ -132,6 +138,25 @@ export class BillingService implements OnModuleDestroy { ); } + async setPerActiveUserSubscriptionForTeam( + teamId: number, + subscriptionId: string, + plan: PlatformPlan, + priceId: string + ) { + const billingCycleStart = DateTime.now().get("day"); + const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day"); + + return this.billingRepository.updateTeamBilling( + teamId, + billingCycleStart, + billingCycleEnd, + plan, + subscriptionId, + priceId + ); + } + async handleStripeSubscriptionDeleted(event: Stripe.Event) { const subscription = event.data.object as Stripe.Subscription; const teamId = subscription?.metadata?.teamId; @@ -204,9 +229,19 @@ export class BillingService implements OnModuleDestroy { this.logger.log("Webhook received but not pertaining to Platform, discarding."); return; } + const isPriceIdPresent = Boolean(checkoutSession.metadata?.priceId); - if (checkoutSession.mode === "subscription") { - await this.setSubscriptionForTeam( + if (checkoutSession.mode === "subscription" && isPriceIdPresent) { + await this.setPerActiveUserSubscriptionForTeam( + teamId, + checkoutSession.subscription as string, + PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan], + checkoutSession.metadata?.priceId + ); + } + + if (checkoutSession.mode === "subscription" && !isPriceIdPresent) { + await this.setPerBookingSubscriptionForTeam( teamId, checkoutSession.subscription as string, PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan] @@ -220,6 +255,67 @@ export class BillingService implements OnModuleDestroy { return; } + async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event) { + const invoice = event.data.object as Stripe.Invoice; + const subscriptionId = this.getSubscriptionIdFromInvoice(invoice); + + if (!subscriptionId) { + throw new NotFoundException("No subscription found for team"); + } + + const teamWithBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId); + + if (teamWithBilling?.plan === "PER_ACTIVE_USER") { + const activeManagedUsersCount = await this.getActiveManagedUsersCount( + subscriptionId, + new Date(invoice.period_start * 1000), + new Date(invoice.period_end * 1000) + ); + + await this.stripeService.getStripe().subscriptions.update(subscriptionId, { + items: [ + { + quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1, + }, + ], + }); + } + } + + async getActiveManagedUsersCount(subscriptionId: string, invoiceStart: Date, invoiceEnd: Date) { + const managedUsersEmails = await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId( + subscriptionId + ); + + if (!managedUsersEmails) return 0; + + if (!invoiceStart || !invoiceEnd) { + this.logger.log("Invoice period start or end date is null"); + return 0; + } + + const activeManagedUserEmailsAsHost = await this.usersRepository.getActiveManagedUsersAsHost( + subscriptionId, + invoiceStart, + invoiceEnd + ); + + const activeHostEmails = activeManagedUserEmailsAsHost.map((email) => email.email); + const notActiveHostEmails = managedUsersEmails + .filter((email) => !activeHostEmails.includes(email.email)) + .map((email) => email.email); + + if (notActiveHostEmails.length === 0) return activeManagedUserEmailsAsHost.length; + + const activeManagedUserEmailsAsAttendee = await this.usersRepository.getActiveManagedUsersAsAttendee( + notActiveHostEmails, + invoiceStart, + invoiceEnd + ); + + return activeManagedUserEmailsAsAttendee.length + activeManagedUserEmailsAsHost.length; + } + async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) { const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); @@ -263,7 +359,7 @@ export class BillingService implements OnModuleDestroy { proration_behavior: "create_prorations", }); - await this.setSubscriptionForTeam( + await this.setPerBookingSubscriptionForTeam( teamId, teamWithBilling?.platformBilling?.subscriptionId, PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan] diff --git a/apps/api/v2/src/modules/billing/types.ts b/apps/api/v2/src/modules/billing/types.ts index d46928613b..106d8d0afe 100644 --- a/apps/api/v2/src/modules/billing/types.ts +++ b/apps/api/v2/src/modules/billing/types.ts @@ -4,6 +4,7 @@ export enum PlatformPlan { ESSENTIALS = "ESSENTIALS", SCALE = "SCALE", ENTERPRISE = "ENTERPRISE", + PER_ACTIVE_USER = "PER_ACTIVE_USER", } -export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE"; +export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE" | "PER_ACTIVE_USER"; diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index 54b06dfd79..81a033c7f1 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -317,4 +317,73 @@ export class UsersRepository { }, }); } + + async getOrgsManagedUserEmailsBySubscriptionId(subscriptionId: string) { + return await this.dbRead.prisma.user.findMany({ + distinct: ["email"], + where: { + isPlatformManaged: true, + profiles: { + some: { + organization: { + platformBilling: { + subscriptionId, + }, + }, + }, + }, + }, + select: { + email: true, + }, + }); + } + + async getActiveManagedUsersAsHost(subscriptionId: string, startTime: Date, endTime: Date) { + return await this.dbRead.prisma.user.findMany({ + distinct: ["email"], + where: { + isPlatformManaged: true, + profiles: { + some: { + organization: { + platformBilling: { + subscriptionId, + }, + }, + }, + }, + bookings: { + some: { + userId: { not: null }, + startTime: { + gte: startTime, + lte: endTime, + }, + }, + }, + }, + select: { + email: true, + }, + }); + } + + async getActiveManagedUsersAsAttendee(managedUsersEmails: string[], startTime: Date, endTime: Date) { + return await this.dbRead.prisma.attendee.findMany({ + distinct: ["email"], + where: { + email: { in: managedUsersEmails }, + booking: { + startTime: { + gte: startTime, + lte: endTime, + }, + }, + }, + select: { + email: true, + }, + }); + } } diff --git a/packages/platform/examples/base/src/pages/_app.tsx b/packages/platform/examples/base/src/pages/_app.tsx index c826006aca..f28e99ef04 100644 --- a/packages/platform/examples/base/src/pages/_app.tsx +++ b/packages/platform/examples/base/src/pages/_app.tsx @@ -52,12 +52,18 @@ export default function App({ Component, pageProps }: AppProps) { useEffect(() => { const randomEmailOne = generateRandomEmail(); const randomEmailTwo = generateRandomEmail(); + const randomEmailThree = generateRandomEmail(); + const randomEmailFour = generateRandomEmail(); + const randomEmailFive = generateRandomEmail(); + if (!seeding) { seeding = true; fetch("/api/managed-user", { method: "POST", - body: JSON.stringify({ emails: [randomEmailOne, randomEmailTwo] }), + body: JSON.stringify({ + emails: [randomEmailOne, randomEmailTwo, randomEmailThree, randomEmailFour, randomEmailFive], + }), }).then(async (res) => { const data = await res.json(); setAccessToken(data.accessToken); @@ -129,7 +135,7 @@ export default function App({ Component, pageProps }: AppProps) { onDisplayBookerEmbed={() => { console.log("render booker embed"); }} - bookerBannerUrl="https://i0.wp.com/mahala.co.uk/wp-content/uploads/2014/12/img_banner-thin_mountains.jpg?fit=800%2C258&ssl=1" + bannerUrl="https://i0.wp.com/mahala.co.uk/wp-content/uploads/2014/12/img_banner-thin_mountains.jpg?fit=800%2C258&ssl=1" bookerCustomClassNames={{ bookerWrapper: "dark", }} diff --git a/packages/platform/examples/base/src/pages/api/managed-user.ts b/packages/platform/examples/base/src/pages/api/managed-user.ts index 344be347fb..4cbb1c4355 100644 --- a/packages/platform/examples/base/src/pages/api/managed-user.ts +++ b/packages/platform/examples/base/src/pages/api/managed-user.ts @@ -17,6 +17,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< const { emails } = JSON.parse(req.body); const emailOne = emails[0]; const emailTwo = emails[1]; + const emailThree = emails[2]; + const emailFour = emails[3]; + const emailFive = emails[4]; const existingUser = await prisma.user.findFirst({ orderBy: { createdAt: "desc" } }); if (existingUser && existingUser.calcomUserId) { @@ -40,6 +43,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< }, }); + const localUserThree = await prisma.user.create({ + data: { + email: emailThree, + }, + }); + + const localUserFour = await prisma.user.create({ + data: { + email: emailFour, + }, + }); + + const localUserFive = await prisma.user.create({ + data: { + email: emailFive, + }, + }); + const response = await fetch( // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, @@ -101,8 +122,104 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< where: { id: localUserTwo.id }, }); + const responseThree = await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + email: emailThree, + name: "Rajiv", + avatarUrl: + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }), + } + ); + const bodyThree = await responseThree.json(); + + await prisma.user.update({ + data: { + refreshToken: (bodyThree.data?.refreshToken as string) ?? "", + accessToken: (bodyThree.data?.accessToken as string) ?? "", + calcomUserId: bodyThree.data?.user.id, + calcomUsername: (bodyThree.data?.user.username as string) ?? "", + }, + where: { id: localUserThree.id }, + }); + + const responseFour = await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + email: emailFour, + name: "Morgan", + avatarUrl: + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }), + } + ); + const bodyFour = await responseFour.json(); + + await prisma.user.update({ + data: { + refreshToken: (bodyFour.data?.refreshToken as string) ?? "", + accessToken: (bodyFour.data?.accessToken as string) ?? "", + calcomUserId: bodyFour.data?.user.id, + calcomUsername: (bodyFour.data?.user.username as string) ?? "", + }, + where: { id: localUserFour.id }, + }); + + const responseFive = await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + email: emailFive, + name: "Lauris", + avatarUrl: + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }), + } + ); + const bodyFive = await responseFive.json(); + + await prisma.user.update({ + data: { + refreshToken: (bodyFive.data?.refreshToken as string) ?? "", + accessToken: (bodyFive.data?.accessToken as string) ?? "", + calcomUserId: bodyFive.data?.user.id, + calcomUsername: (bodyFive.data?.user.username as string) ?? "", + }, + where: { id: localUserFive.id }, + }); + await createDefaultSchedule(body.data?.accessToken as string); await createDefaultSchedule(bodyTwo.data?.accessToken as string); + await createDefaultSchedule(bodyThree.data?.accessToken as string); + await createDefaultSchedule(bodyFour.data?.accessToken as string); + await createDefaultSchedule(bodyFive.data?.accessToken as string); // eslint-disable-next-line turbo/no-undeclared-env-vars const organizationId = process.env.ORGANIZATION_ID; @@ -117,7 +234,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< await createMembership(+organizationId, team.id, body.data?.user.id); await createMembership(+organizationId, team.id, bodyTwo.data?.user.id); - await createCollectiveEventType(+organizationId, team.id, [body.data?.user.id, bodyTwo.data?.user.id]); + await createMembership(+organizationId, team.id, bodyThree.data?.user.id); + await createCollectiveEventType(+organizationId, team.id, [ + body.data?.user.id, + bodyTwo.data?.user.id, + bodyThree.data?.user.id, + bodyFour.data?.user.id, + bodyFive.data?.user.id, + ]); return res.status(200).json({ id: body?.data?.user?.id, diff --git a/packages/prisma/migrations/20250401191319_/migration.sql b/packages/prisma/migrations/20250401191319_/migration.sql new file mode 100644 index 0000000000..b5eefce0c7 --- /dev/null +++ b/packages/prisma/migrations/20250401191319_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "PlatformBilling" ADD COLUMN "priceId" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 199b5d9bb7..d21ce43f2d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1781,6 +1781,7 @@ model PlatformBilling { customerId String subscriptionId String? + priceId String? plan String @default("none") billingCycleStart Int?