diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example index e06b413342..634317d3d6 100644 --- a/apps/api/v2/.env.example +++ b/apps/api/v2/.env.example @@ -10,4 +10,13 @@ JWT_SECRET= SENTRY_DSN= # KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2 -NEXT_PUBLIC_SENTRY_DSN= \ No newline at end of file +NEXT_PUBLIC_SENTRY_DSN= + +# Stripe Billing +STRIPE_PRICE_ID_STARTER= +STRIPE_PRICE_ID_ESSENTIALS= +STRIPE_PRICE_ID_ENTERPRISE= +STRIPE_API_KEY= +STRIPE_WEBHOOK_SECRET= + +WEB_APP_URL=http://localhost:3000/ diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index b92132db01..dd2a23e7e7 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -13,7 +13,7 @@ "dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build", "dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node ./dist/apps/api/v2/src/main.js", + "start:prod": "SKIP_DOCS_GENERATION=1 node ./dist/apps/api/v2/src/main.js", "test": "yarn dev:build && jest", "test:watch": "yarn dev:build && jest --watch", "test:cov": "yarn dev:build && jest --coverage", @@ -40,6 +40,7 @@ "@nestjs/throttler": "^5.1.2", "@sentry/node": "^7.86.0", "@sentry/tracing": "^7.86.0", + "body-parser": "^1.20.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", @@ -56,6 +57,7 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", + "stripe": "^15.3.0", "uuid": "^8.3.2", "winston": "^3.11.0", "zod": "^3.22.4" diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index 2d7dc5aeb5..2c84b82f37 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -1,16 +1,18 @@ -import { AppLoggerMiddleware } from "@/app.logger.middleware"; -import { RewriterMiddleware } from "@/app.rewrites.middleware"; import appConfig from "@/config/app"; +import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware"; +import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware"; +import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware"; +import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware"; import { AuthModule } from "@/modules/auth/auth.module"; import { EndpointsModule } from "@/modules/endpoints.module"; import { JwtModule } from "@/modules/jwt/jwt.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; import { RedisService } from "@/modules/redis/redis.service"; -import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"; +import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { RouterModule } from "@nestjs/core"; -import { ThrottlerModule, seconds } from "@nestjs/throttler"; +import { seconds, ThrottlerModule } from "@nestjs/throttler"; import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis"; import { AppController } from "./app.controller"; @@ -53,7 +55,17 @@ import { AppController } from "./app.controller"; }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer): void { - consumer.apply(AppLoggerMiddleware).forRoutes("*"); - consumer.apply(RewriterMiddleware).forRoutes("/"); + consumer + .apply(RawBodyMiddleware) + .forRoutes({ + path: "/api/v2/billing/webhook", + method: RequestMethod.POST, + }) + .apply(JsonBodyMiddleware) + .forRoutes("*") + .apply(AppLoggerMiddleware) + .forRoutes("*") + .apply(RewriterMiddleware) + .forRoutes("/"); } } diff --git a/apps/api/v2/src/config/app.ts b/apps/api/v2/src/config/app.ts index 904579396a..eee500895f 100644 --- a/apps/api/v2/src/config/app.ts +++ b/apps/api/v2/src/config/app.ts @@ -24,6 +24,13 @@ const loadConfig = (): AppConfig => { next: { authSecret: getEnv("NEXTAUTH_SECRET"), }, + stripe: { + apiKey: getEnv("STRIPE_API_KEY"), + webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"), + }, + app: { + baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"), + }, }; }; diff --git a/apps/api/v2/src/config/type.ts b/apps/api/v2/src/config/type.ts index e7690ac63f..d9a77bd354 100644 --- a/apps/api/v2/src/config/type.ts +++ b/apps/api/v2/src/config/type.ts @@ -15,4 +15,11 @@ export type AppConfig = { next: { authSecret: string; }; + stripe: { + apiKey: string; + webhookSecret: string; + }; + app: { + baseUrl: string; + }; }; diff --git a/apps/api/v2/src/ee/bookings/bookings.module.ts b/apps/api/v2/src/ee/bookings/bookings.module.ts index c7472cafe7..8e1be0f3e7 100644 --- a/apps/api/v2/src/ee/bookings/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/bookings.module.ts @@ -1,4 +1,5 @@ import { BookingsController } from "@/ee/bookings/controllers/bookings.controller"; +import { BillingModule } from "@/modules/billing/billing.module"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; @@ -8,7 +9,7 @@ import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { Module } from "@nestjs/common"; @Module({ - imports: [PrismaModule, RedisModule, TokensModule], + imports: [PrismaModule, RedisModule, TokensModule, BillingModule], providers: [TokensRepository, OAuthFlowService, OAuthClientRepository], controllers: [BookingsController], }) diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts index 5981893abb..74fbc355b6 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -6,6 +6,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { BillingService } from "@/modules/billing/services/billing.service"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; @@ -77,12 +78,13 @@ const DEFAULT_PLATFORM_PARAMS = { @UseGuards(PermissionsGuard) @DocsTags("Bookings") export class BookingsController { - private readonly logger = new Logger("ee bookings controller"); + private readonly logger = new Logger("BookingsController"); constructor( private readonly oAuthFlowService: OAuthFlowService, private readonly prismaReadService: PrismaReadService, - private readonly oAuthClientRepository: OAuthClientRepository + private readonly oAuthClientRepository: OAuthClientRepository, + private readonly billingService: BillingService ) {} @Get("/") @@ -153,12 +155,14 @@ export class BookingsController { const booking = await handleNewBooking( await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl) ); + + void (await this.billingService.increaseUsageByClientId(oAuthClientId!)); return { status: SUCCESS_STATUS, data: booking, }; } catch (err) { - handleBookingErrors(err); + this.handleBookingErrors(err); } throw new InternalServerErrorException("Could not create booking."); } @@ -179,7 +183,7 @@ export class BookingsController { status: SUCCESS_STATUS, }; } catch (err) { - handleBookingErrors(err); + this.handleBookingErrors(err); } } else { throw new NotFoundException("Booking ID is required."); @@ -198,12 +202,15 @@ export class BookingsController { const createdBookings: BookingResponse[] = await handleNewRecurringBooking( await this.createNextApiBookingRequest(req, oAuthClientId) ); + + void (await this.billingService.increaseUsageByClientId(oAuthClientId!)); + return { status: SUCCESS_STATUS, data: createdBookings, }; } catch (err) { - handleBookingErrors(err, "recurring"); + this.handleBookingErrors(err, "recurring"); } throw new InternalServerErrorException("Could not create recurring booking."); } @@ -220,17 +227,20 @@ export class BookingsController { const instantMeeting = await handleInstantMeeting( await this.createNextApiBookingRequest(req, oAuthClientId) ); + + void (await this.billingService.increaseUsageByClientId(oAuthClientId!)); + return { status: SUCCESS_STATUS, data: instantMeeting, }; } catch (err) { - handleBookingErrors(err, "instant"); + this.handleBookingErrors(err, "instant"); } throw new InternalServerErrorException("Could not create instant booking."); } - async getOwnerId(req: Request): Promise { + private async getOwnerId(req: Request): Promise { try { const accessToken = req.get("Authorization")?.replace("Bearer ", ""); if (accessToken) { @@ -241,7 +251,7 @@ export class BookingsController { } } - async getOAuthClientsParams(req: BookingRequest, clientId: string): Promise { + private async getOAuthClientsParams(clientId: string): Promise { const res = DEFAULT_PLATFORM_PARAMS; try { const client = await this.oAuthClientRepository.getOAuthClient(clientId); @@ -260,32 +270,32 @@ export class BookingsController { } } - async createNextApiBookingRequest( + private async createNextApiBookingRequest( req: BookingRequest, oAuthClientId?: string, platformBookingLocation?: string ): Promise { const userId = (await this.getOwnerId(req)) ?? -1; const oAuthParams = oAuthClientId - ? await this.getOAuthClientsParams(req, oAuthClientId) + ? await this.getOAuthClientsParams(oAuthClientId) : DEFAULT_PLATFORM_PARAMS; Object.assign(req, { userId, ...oAuthParams, platformBookingLocation }); req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled }; return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams; } -} -function handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void { - const errMsg = `Error while creating ${type ? type + " " : ""}booking.`; - if (err instanceof HttpError) { - const httpError = err as HttpError; - throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500); + private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void { + const errMsg = `Error while creating ${type ? type + " " : ""}booking.`; + if (err instanceof HttpError) { + const httpError = err as HttpError; + throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500); + } + + if (err instanceof Error) { + const error = err as Error; + throw new InternalServerErrorException(error?.message ?? errMsg); + } + + throw new InternalServerErrorException(errMsg); } - - if (err instanceof Error) { - const error = err as Error; - throw new InternalServerErrorException(error?.message ?? errMsg); - } - - throw new InternalServerErrorException(errMsg); } diff --git a/apps/api/v2/src/env.ts b/apps/api/v2/src/env.ts index d1840bf6a5..1af1955ed8 100644 --- a/apps/api/v2/src/env.ts +++ b/apps/api/v2/src/env.ts @@ -12,6 +12,9 @@ export type Environment = { SENTRY_DSN: string; LOG_LEVEL: keyof typeof logLevels; REDIS_URL: string; + STRIPE_API_KEY: string; + STRIPE_WEBHOOK_SECRET: string; + WEB_APP_URL: string; }; export const getEnv = (key: K, fallback?: Environment[K]): Environment[K] => { diff --git a/apps/api/v2/src/main.ts b/apps/api/v2/src/main.ts index 6f2d13429f..ac9f10c801 100644 --- a/apps/api/v2/src/main.ts +++ b/apps/api/v2/src/main.ts @@ -16,6 +16,7 @@ import { loggerConfig } from "./lib/logger"; const run = async () => { const app = await NestFactory.create(AppModule, { logger: WinstonModule.createLogger(loggerConfig()), + bodyParser: false, }); const logger = new Logger("App"); @@ -23,7 +24,9 @@ const run = async () => { try { bootstrap(app); const port = app.get(ConfigService).get("api.port", { infer: true }); - generateSwagger(app); + if (process.env.SKIP_DOCS_GENERATION !== "1") { + void generateSwagger(app); + } await app.listen(port); logger.log(`Application started on port: ${port}`); } catch (error) { diff --git a/apps/api/v2/src/app.logger.middleware.ts b/apps/api/v2/src/middleware/app.logger.middleware.ts similarity index 100% rename from apps/api/v2/src/app.logger.middleware.ts rename to apps/api/v2/src/middleware/app.logger.middleware.ts diff --git a/apps/api/v2/src/app.rewrites.middleware.ts b/apps/api/v2/src/middleware/app.rewrites.middleware.ts similarity index 100% rename from apps/api/v2/src/app.rewrites.middleware.ts rename to apps/api/v2/src/middleware/app.rewrites.middleware.ts diff --git a/apps/api/v2/src/middleware/body/json.body.middleware.ts b/apps/api/v2/src/middleware/body/json.body.middleware.ts new file mode 100644 index 0000000000..8f454090cf --- /dev/null +++ b/apps/api/v2/src/middleware/body/json.body.middleware.ts @@ -0,0 +1,10 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import * as bodyParser from "body-parser"; +import type { Request, Response } from "express"; + +@Injectable() +export class JsonBodyMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: () => any) { + bodyParser.json()(req, res, next); + } +} diff --git a/apps/api/v2/src/middleware/body/raw.body.middleware.ts b/apps/api/v2/src/middleware/body/raw.body.middleware.ts new file mode 100644 index 0000000000..6cdae2f617 --- /dev/null +++ b/apps/api/v2/src/middleware/body/raw.body.middleware.ts @@ -0,0 +1,10 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import * as bodyParser from "body-parser"; +import type { Request, Response } from "express"; + +@Injectable() +export class RawBodyMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: () => any) { + bodyParser.raw({ type: "*/*" })(req, res, next); + } +} diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts new file mode 100644 index 0000000000..e9dc616eb7 --- /dev/null +++ b/apps/api/v2/src/modules/billing/billing.module.ts @@ -0,0 +1,17 @@ +import { BillingRepository } from "@/modules/billing/billing.repository"; +import { BillingController } from "@/modules/billing/controllers/billing.controller"; +import { BillingConfigService } from "@/modules/billing/services/billing.config.service"; +import { BillingService } from "@/modules/billing/services/billing.service"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsModule } from "@/modules/organizations/organizations.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule], + providers: [BillingConfigService, BillingService, BillingRepository], + exports: [BillingService, BillingRepository], + controllers: [BillingController], +}) +export class BillingModule {} diff --git a/apps/api/v2/src/modules/billing/billing.repository.ts b/apps/api/v2/src/modules/billing/billing.repository.ts new file mode 100644 index 0000000000..54b8cf0b2a --- /dev/null +++ b/apps/api/v2/src/modules/billing/billing.repository.ts @@ -0,0 +1,36 @@ +import { PlatformPlan } from "@/modules/billing/types"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class BillingRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + getBillingForTeam = (teamId: number) => + this.dbRead.prisma.platformBilling.findUnique({ + where: { + id: teamId, + }, + }); + + async updateTeamBilling( + teamId: number, + billingStart: number, + billingEnd: number, + plan: PlatformPlan, + subscription?: string + ) { + return this.dbWrite.prisma.platformBilling.update({ + where: { + id: teamId, + }, + data: { + billingCycleStart: billingStart, + billingCycleEnd: billingEnd, + subscriptionId: subscription, + plan: plan.toString(), + }, + }); + } +} diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts new file mode 100644 index 0000000000..00bca4190a --- /dev/null +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts @@ -0,0 +1,126 @@ +import { AppConfig } from "@/config/type"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard"; +import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input"; +import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto"; +import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto"; +import { BillingService } from "@/modules/billing/services/billing.service"; +import { PlatformPlan } from "@/modules/billing/types"; +import { + BadRequestException, + Body, + Controller, + Get, + Param, + Post, + Req, + UseGuards, + Headers, + HttpCode, + HttpStatus, + Logger, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { Request } from "express"; +import { Stripe } from "stripe"; + +import { ApiResponse } from "@calcom/platform-types"; + +@Controller({ + path: "/billing", + version: "2", +}) +@ApiExcludeController(true) +export class BillingController { + private readonly stripeWhSecret: string; + private logger = new Logger("Billing Controller"); + + constructor( + private readonly billingService: BillingService, + private readonly configService: ConfigService + ) { + this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? ""; + } + + @Get("/:teamId/check") + @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @Roles(["OWNER", "ADMIN"]) + async checkTeamBilling( + @Param("teamId") teamId: number + ): Promise> { + const { status, plan } = await this.billingService.getBillingData(teamId); + + return { + status: "success", + data: { + valid: status === "valid", + plan, + }, + }; + } + + @Post("/:teamId/subscribe") + @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @Roles(["OWNER", "ADMIN"]) + async subscribeTeamToStripe( + @Param("teamId") teamId: number, + @Body() input: SubscribeToPlanInput + ): Promise> { + const { status } = await this.billingService.getBillingData(teamId); + + if (status === "valid") { + throw new BadRequestException("This team is already subscribed to a plan."); + } + + const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan); + if (action === "redirect") { + return { + status: "success", + data: { + action: "redirect", + url, + }, + }; + } + + return { + status: "success", + }; + } + + @Post("/webhook") + @HttpCode(HttpStatus.OK) + async stripeWebhook( + @Req() request: Request, + @Headers("stripe-signature") stripeSignature: string + ): Promise { + const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync( + request.body, + stripeSignature, + this.stripeWhSecret + ); + + if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") { + const subscription = event.data.object as Stripe.Subscription; + const teamId = Number.parseInt(subscription.metadata.teamId); + const plan = subscription.metadata.plan; + if (!plan || !teamId) { + throw new Error("Invalid webhook received."); + } + + await this.billingService.setSubscriptionForTeam( + teamId, + subscription, + PlatformPlan[plan as keyof typeof PlatformPlan] + ); + + return { + status: "success", + }; + } + + throw new BadRequestException(`Unhandled event type ${event.type}`); + } +} diff --git a/apps/api/v2/src/modules/billing/controllers/inputs/subscribe-to-plan.input.ts b/apps/api/v2/src/modules/billing/controllers/inputs/subscribe-to-plan.input.ts new file mode 100644 index 0000000000..0a4ee10ee2 --- /dev/null +++ b/apps/api/v2/src/modules/billing/controllers/inputs/subscribe-to-plan.input.ts @@ -0,0 +1,7 @@ +import { PlatformPlan } from "@/modules/billing/types"; +import { IsEnum } from "class-validator"; + +export class SubscribeToPlanInput { + @IsEnum(PlatformPlan) + plan!: PlatformPlan; +} diff --git a/apps/api/v2/src/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto.ts b/apps/api/v2/src/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto.ts new file mode 100644 index 0000000000..c510fe8c6b --- /dev/null +++ b/apps/api/v2/src/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto.ts @@ -0,0 +1,5 @@ +export class CheckPlatformBillingResponseDto { + valid!: boolean; + + plan?: string; +} diff --git a/apps/api/v2/src/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto.ts b/apps/api/v2/src/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto.ts new file mode 100644 index 0000000000..9bae0150dc --- /dev/null +++ b/apps/api/v2/src/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto.ts @@ -0,0 +1,4 @@ +export class SubscribeTeamToBillingResponseDto { + url?: string; + action?: "redirect"; +} diff --git a/apps/api/v2/src/modules/billing/services/billing.config.service.ts b/apps/api/v2/src/modules/billing/services/billing.config.service.ts new file mode 100644 index 0000000000..a31d3ed261 --- /dev/null +++ b/apps/api/v2/src/modules/billing/services/billing.config.service.ts @@ -0,0 +1,23 @@ +import { PlatformPlan } from "@/modules/billing/types"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class BillingConfigService { + private readonly config: Map; + + constructor() { + this.config = new Map(); + + const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key))); + for (const key of planKeys) { + this.config.set( + PlatformPlan[key as keyof typeof PlatformPlan], + process.env[`STRIPE_PRICE_ID_${key}`] ?? "" + ); + } + } + + get(plan: PlatformPlan): string | undefined { + return this.config.get(plan); + } +} diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts new file mode 100644 index 0000000000..cdd44dddab --- /dev/null +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -0,0 +1,126 @@ +import { AppConfig } from "@/config/type"; +import { BillingRepository } from "@/modules/billing/billing.repository"; +import { BillingConfigService } from "@/modules/billing/services/billing.config.service"; +import { PlatformPlan } from "@/modules/billing/types"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { DateTime } from "luxon"; +import Stripe from "stripe"; + +@Injectable() +export class BillingService { + private logger = new Logger("BillingService"); + private readonly webAppUrl: string; + + constructor( + private readonly teamsRepository: OrganizationsRepository, + public readonly stripeService: StripeService, + private readonly billingRepository: BillingRepository, + private readonly configService: ConfigService, + private readonly billingConfigService: BillingConfigService + ) { + this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com"; + } + + async getBillingData(teamId: number) { + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + if (teamWithBilling?.platformBilling) { + if (!teamWithBilling?.platformBilling.subscriptionId) { + return { team: teamWithBilling, status: "no_subscription", plan: "none" }; + } + + return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan }; + } else { + return { team: teamWithBilling, status: "no_billing", plan: "none" }; + } + } + + async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) { + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + let brandNewBilling = false; + + let customerId = teamWithBilling?.platformBilling?.customerId; + + if (!teamWithBilling?.platformBilling) { + brandNewBilling = true; + customerId = await this.teamsRepository.createNewBillingRelation(teamId); + + this.logger.log("Team had no Stripe Customer ID, created one for them.", { + id: teamId, + stripeId: customerId, + }); + } + + if (brandNewBilling || !teamWithBilling?.platformBilling?.subscriptionId) { + const { url } = await this.stripeService.stripe.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: this.billingConfigService.get(plan), + quantity: 1, + }, + ], + success_url: `${this.webAppUrl}/settings/platform/oauth-clients`, + cancel_url: `${this.webAppUrl}/settings/platform/oauth-clients`, + mode: "subscription", + metadata: { + teamId: teamId.toString(), + plan: plan.toString(), + }, + subscription_data: { + metadata: { + teamId: teamId.toString(), + plan: plan.toString(), + }, + }, + }); + + if (!url) throw new InternalServerErrorException("Failed to create Stripe session."); + + return { action: "redirect", url }; + } + + return { action: "none" }; + } + + async setSubscriptionForTeam(teamId: number, subscription: Stripe.Subscription, plan: PlatformPlan) { + const billingCycleStart = DateTime.now().get("day"); + const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day"); + + return this.billingRepository.updateTeamBilling( + teamId, + billingCycleStart, + billingCycleEnd, + plan, + subscription.id + ); + } + + async increaseUsageForTeam(teamId: number) { + // TODO - if we support multiple subscription items per team, we may need to track which plan they're + // subscribed to so we can do one less query. + const billingSubscription = await this.billingRepository.getBillingForTeam(teamId); + if (!billingSubscription || !billingSubscription?.subscriptionId) { + throw new Error(`Failed to increase usage for team ${teamId}`); + } + + const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve( + billingSubscription.subscriptionId + ); + const items = stripeSubscription.items.data[0]; // first (and only) subscription item. + await this.stripeService.stripe.subscriptionItems.createUsageRecord(items.id, { + action: "increment", + quantity: 1, + timestamp: "now", + }); + } + + async increaseUsageByClientId(clientId: string) { + const team = await this.teamsRepository.findTeamIdFromClientId(clientId); + if (!team.id) return Promise.resolve(); // noop resolution. + + return this.increaseUsageForTeam(team?.id); + } +} diff --git a/apps/api/v2/src/modules/billing/types.ts b/apps/api/v2/src/modules/billing/types.ts new file mode 100644 index 0000000000..ff713b99f9 --- /dev/null +++ b/apps/api/v2/src/modules/billing/types.ts @@ -0,0 +1,5 @@ +export enum PlatformPlan { + STARTER = "STARTER", + ESSENTIALS = "ESSENTIALS", + ENTERPRISE = "ENTERPRISE", +} diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts index de9be4b261..3d8b402e2d 100644 --- a/apps/api/v2/src/modules/endpoints.module.ts +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -1,11 +1,12 @@ import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module"; +import { BillingModule } from "@/modules/billing/billing.module"; import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; import { TimezoneModule } from "@/modules/timezones/timezones.module"; import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; import { Module } from "@nestjs/common"; @Module({ - imports: [OAuthClientModule, PlatformEndpointsModule, TimezoneModule], + imports: [OAuthClientModule, BillingModule, PlatformEndpointsModule, TimezoneModule], }) export class EndpointsModule implements NestModule { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts index a0dd99e122..303cb6e71c 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts @@ -8,6 +8,7 @@ import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/o import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto"; import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; import { Body, @@ -22,6 +23,7 @@ import { Logger, UseGuards, NotFoundException, + BadRequestException, } from "@nestjs/common"; import { ApiTags as DocsTags, @@ -47,7 +49,10 @@ Second, make sure that the logged in user has organizationId set to pass the Org export class OAuthClientsController { private readonly logger = new Logger("OAuthClientController"); - constructor(private readonly oauthClientRepository: OAuthClientRepository) {} + constructor( + private readonly oauthClientRepository: OAuthClientRepository, + private readonly teamsRepository: OrganizationsRepository + ) {} @Post("/") @HttpCode(HttpStatus.CREATED) @@ -65,6 +70,12 @@ export class OAuthClientsController { this.logger.log( `For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}` ); + + const organization = await this.teamsRepository.findByIdIncludeBilling(organizationId); + if (!organization?.platformBilling || !organization?.platformBilling?.subscriptionId) { + throw new BadRequestException("Team is not subscribed, cannot create an OAuth Client."); + } + const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body); return { status: SUCCESS_STATUS, diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts index fcee72c195..c7eaee2584 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsIn, ValidateNested, IsNotEmptyObject, IsString } from "class-validator"; -import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { SUCCESS_STATUS, ERROR_STATUS, REDIRECT_STATUS } from "@calcom/platform-constants"; class DataDto { @ApiProperty({ @@ -34,3 +34,9 @@ export class CreateOAuthClientResponseDto { @Type(() => DataDto) data!: DataDto; } + +export class CreateOauthClientRedirect { + status!: typeof REDIRECT_STATUS; + + url!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts index 9998f9e273..52c99eca64 100644 --- a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -1,6 +1,7 @@ import { EventTypesModule } from "@/ee/event-types/event-types.module"; import { SchedulesModule } from "@/ee/schedules/schedules.module"; import { AuthModule } from "@/modules/auth/auth.module"; +import { BillingModule } from "@/modules/billing/billing.module"; import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller"; import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller"; @@ -12,6 +13,7 @@ import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.se import { OrganizationsModule } from "@/modules/organizations/organizations.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersModule } from "@/modules/users/users.module"; @@ -28,6 +30,8 @@ import { Global, Module } from "@nestjs/common"; MembershipsModule, EventTypesModule, OrganizationsModule, + StripeModule, + BillingModule, SchedulesModule, ], providers: [ diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts index 6dc399e2e4..ed356afaf1 100644 --- a/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts @@ -59,7 +59,7 @@ export class OAuthClientRepository { } async getOAuthClientWithRefreshSecret(clientId: string, clientSecret: string, refreshToken: string) { - return await this.dbRead.prisma.platformOAuthClient.findFirst({ + return this.dbRead.prisma.platformOAuthClient.findFirst({ where: { id: clientId, secret: clientSecret, diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts index e04b52dcae..8c81aa1a1d 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -7,8 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, Injectable } from "@nestjs/common"; import { User } from "@prisma/client"; -import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries"; -import { slugify } from "@calcom/platform-libraries"; +import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries"; @Injectable() export class OAuthClientUsersService { @@ -100,8 +99,6 @@ export class OAuthClientUsersService { getOAuthUserEmail(oAuthClientId: string, userEmail: string) { const [username, emailDomain] = userEmail.split("@"); - const email = `${username}+${oAuthClientId}@${emailDomain}`; - - return email; + return `${username}+${oAuthClientId}@${emailDomain}`; } } diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 0ec5c5d73f..01a96611d7 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -1,11 +1,12 @@ import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; import { Module } from "@nestjs/common"; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, StripeModule], providers: [OrganizationsRepository, OrganizationsService], - exports: [OrganizationsService], + exports: [OrganizationsService, OrganizationsRepository], }) export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/organizations/organizations.repository.ts b/apps/api/v2/src/modules/organizations/organizations.repository.ts index 98dfa20159..586a2b3b10 100644 --- a/apps/api/v2/src/modules/organizations/organizations.repository.ts +++ b/apps/api/v2/src/modules/organizations/organizations.repository.ts @@ -1,9 +1,16 @@ +import { PlatformPlan } from "@/modules/billing/types"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { StripeService } from "@/modules/stripe/stripe.service"; import { Injectable } from "@nestjs/common"; @Injectable() export class OrganizationsRepository { - constructor(private readonly dbRead: PrismaReadService) {} + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private readonly stripeService: StripeService + ) {} async findById(organizationId: number) { return this.dbRead.prisma.team.findUnique({ @@ -12,4 +19,54 @@ export class OrganizationsRepository { }, }); } + + async findByIdIncludeBilling(orgId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { + id: orgId, + }, + include: { + platformBilling: true, + }, + }); + } + + async createNewBillingRelation(orgId: number, plan?: PlatformPlan) { + const { id } = await this.stripeService.stripe.customers.create({ + metadata: { + createdBy: "oauth_client_no_csid", // mark in case this is needed in the future. + }, + }); + + await this.dbWrite.prisma.team.update({ + where: { + id: orgId, + }, + data: { + platformBilling: { + create: { + customerId: id, + plan: plan ? plan.toString() : "none", + }, + }, + }, + }); + + return id; + } + + async findTeamIdFromClientId(clientId: string) { + return this.dbRead.prisma.team.findFirstOrThrow({ + where: { + platformOAuthClient: { + some: { + id: clientId, + }, + }, + }, + select: { + id: true, + }, + }); + } } diff --git a/apps/api/v2/src/modules/stripe/stripe.module.ts b/apps/api/v2/src/modules/stripe/stripe.module.ts new file mode 100644 index 0000000000..2452e10b6e --- /dev/null +++ b/apps/api/v2/src/modules/stripe/stripe.module.ts @@ -0,0 +1,10 @@ +import { StripeService } from "@/modules/stripe/stripe.service"; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +@Module({ + imports: [ConfigModule], + exports: [StripeService], + providers: [StripeService], +}) +export class StripeModule {} diff --git a/apps/api/v2/src/modules/stripe/stripe.service.ts b/apps/api/v2/src/modules/stripe/stripe.service.ts new file mode 100644 index 0000000000..b93f9f5e9a --- /dev/null +++ b/apps/api/v2/src/modules/stripe/stripe.service.ts @@ -0,0 +1,15 @@ +import { AppConfig } from "@/config/type"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import Stripe from "stripe"; + +@Injectable() +export class StripeService { + public stripe: Stripe; + + constructor(configService: ConfigService) { + this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", { + apiVersion: "2020-08-27", + }); + } +} diff --git a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts b/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts index 25ee51e7a5..eadc889a77 100644 --- a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts +++ b/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts @@ -19,7 +19,7 @@ export const useCreateOAuthClient = ( }, } ) => { - const mutation = useMutation< + return useMutation< ApiResponse<{ clientId: string; clientSecret: string }>, unknown, CreateOAuthClientInput @@ -42,8 +42,6 @@ export const useCreateOAuthClient = ( onError?.(); }, }); - - return mutation; }; export const useUpdateOAuthClient = ( diff --git a/packages/platform/constants/api.ts b/packages/platform/constants/api.ts index 15a1df0b68..1120ef73d7 100644 --- a/packages/platform/constants/api.ts +++ b/packages/platform/constants/api.ts @@ -9,6 +9,7 @@ export const V2_ENDPOINTS = { export const SUCCESS_STATUS = "success"; export const ERROR_STATUS = "error"; +export const REDIRECT_STATUS = "redirect"; // Client Errors (4xx) export const BAD_REQUEST = "BAD_REQUEST"; export const UNAUTHORIZED = "UNAUTHORIZED"; diff --git a/packages/platform/types/api.ts b/packages/platform/types/api.ts index b2039ed22f..402bcc40e7 100644 --- a/packages/platform/types/api.ts +++ b/packages/platform/types/api.ts @@ -2,7 +2,12 @@ import { Transform } from "class-transformer"; import { IsNumber, Min, Max, IsOptional } from "class-validator"; import type { Response as BaseResponse } from "express"; -import type { ERROR_STATUS, SUCCESS_STATUS, API_ERROR_CODES } from "@calcom/platform-constants"; +import type { + ERROR_STATUS, + SUCCESS_STATUS, + API_ERROR_CODES, + REDIRECT_STATUS, +} from "@calcom/platform-constants"; export type ApiSuccessResponse = { status: typeof SUCCESS_STATUS; data: T }; export type ApiSuccessResponseWithoutData = { status: typeof SUCCESS_STATUS }; @@ -23,6 +28,7 @@ export type ApiErrorResponse = { }; export type ApiRedirectResponseType = { + status: typeof REDIRECT_STATUS; url: string; }; @@ -32,6 +38,8 @@ export type ApiResponse = T extends undefined ? ApiSuccessResponseWithoutData | ApiErrorResponse : ApiSuccessResponse | ApiErrorResponse; +export type ApiResponseMaybeRedirect = ApiResponse | ApiRedirectResponseType; + export class Pagination { @Transform(({ value }: { value: string }) => value && parseInt(value)) @IsNumber() diff --git a/packages/prisma/migrations/20240425121424_platform_billing/migration.sql b/packages/prisma/migrations/20240425121424_platform_billing/migration.sql new file mode 100644 index 0000000000..6056800b8d --- /dev/null +++ b/packages/prisma/migrations/20240425121424_platform_billing/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "PlatformBilling" ( + "id" INTEGER NOT NULL, + "customerId" TEXT NOT NULL, + "plan" TEXT NOT NULL DEFAULT 'none', + "subscriptionId" TEXT, + "billingCycleStart" INTEGER, + "billingCycleEnd" INTEGER, + "overdue" BOOLEAN DEFAULT false, + + CONSTRAINT "PlatformBilling_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PlatformBilling_id_key" ON "PlatformBilling"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "PlatformBilling_customerId_key" ON "PlatformBilling"("customerId"); + +-- AddForeignKey +ALTER TABLE "PlatformBilling" ADD CONSTRAINT "PlatformBilling_id_fkey" FOREIGN KEY ("id") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index bf7b424767..3443f74ac9 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -400,6 +400,7 @@ model Team { isPlatform Boolean @default(false) platformOAuthClient PlatformOAuthClient[] smsLockState SMSLockState @default(UNLOCKED) + platformBilling PlatformBilling? @@unique([slug, parentId]) @@index([parentId]) @@ -1357,3 +1358,17 @@ enum SMSLockState { UNLOCKED REVIEW_NEEDED } + +model PlatformBilling { + id Int @id @unique // team id + + customerId String @unique + subscriptionId String? + plan String + + billingCycleStart Int? + billingCycleEnd Int? + overdue Boolean? @default(false) + + team Team @relation(fields: [id], references: [id], onDelete: Cascade) +} diff --git a/yarn.lock b/yarn.lock index 5e9bbcf258..40cbf647cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -290,25 +290,6 @@ __metadata: languageName: node linkType: hard -"@auth/core@npm:^0.1.4": - version: 0.1.4 - resolution: "@auth/core@npm:0.1.4" - dependencies: - "@panva/hkdf": 1.0.2 - cookie: 0.5.0 - jose: 4.11.1 - oauth4webapi: 2.0.5 - preact: 10.11.3 - preact-render-to-string: 5.2.3 - peerDependencies: - nodemailer: 6.8.0 - peerDependenciesMeta: - nodemailer: - optional: true - checksum: 64854404ea1883e0deb5535b34bed95cd43fc85094aeaf4f15a79e14045020eb944f844defe857edfc8528a0a024be89cbb2a3069dedef0e9217a74ca6c3eb79 - languageName: node - linkType: hard - "@aw-web-design/x-default-browser@npm:1.4.126": version: 1.4.126 resolution: "@aw-web-design/x-default-browser@npm:1.4.126" @@ -3891,7 +3872,7 @@ __metadata: "@nestjs/schematics": ^10.0.0 "@nestjs/swagger": ^7.3.0 "@nestjs/testing": ^10.0.0 - "@nestjs/throttler": ^5.1.1 + "@nestjs/throttler": ^5.1.2 "@sentry/node": ^7.86.0 "@sentry/tracing": ^7.86.0 "@types/cookie-parser": ^1.4.6 @@ -3902,6 +3883,7 @@ __metadata: "@types/node": ^20.3.1 "@types/passport-jwt": ^3.0.13 "@types/supertest": ^2.0.12 + body-parser: ^1.20.2 class-transformer: ^0.5.1 class-validator: ^0.14.0 cookie-parser: ^1.4.6 @@ -3921,6 +3903,7 @@ __metadata: reflect-metadata: ^0.1.13 rxjs: ^7.8.1 source-map-support: ^0.5.21 + stripe: ^15.3.0 supertest: ^6.3.3 ts-jest: ^29.1.1 ts-loader: ^9.4.3 @@ -4055,38 +4038,12 @@ __metadata: languageName: unknown linkType: soft -"@calcom/auth@workspace:apps/auth": +"@calcom/autocheckin@workspace:packages/app-store/autocheckin": version: 0.0.0-use.local - resolution: "@calcom/auth@workspace:apps/auth" + resolution: "@calcom/autocheckin@workspace:packages/app-store/autocheckin" dependencies: - "@auth/core": ^0.1.4 - "@calcom/app-store": "*" - "@calcom/app-store-cli": "*" - "@calcom/config": "*" - "@calcom/core": "*" - "@calcom/dayjs": "*" - "@calcom/embed-core": "workspace:*" - "@calcom/embed-react": "workspace:*" - "@calcom/embed-snippet": "workspace:*" - "@calcom/features": "*" "@calcom/lib": "*" - "@calcom/prisma": "*" - "@calcom/trpc": "*" - "@calcom/tsconfig": "*" "@calcom/types": "*" - "@calcom/ui": "*" - "@types/node": 16.9.1 - "@types/react": 18.0.26 - "@types/react-dom": ^18.0.9 - eslint: ^8.34.0 - eslint-config-next: ^13.2.1 - next: ^13.5.4 - next-auth: ^4.22.1 - postcss: ^8.4.18 - react: ^18.2.0 - react-dom: ^18.2.0 - tailwindcss: ^3.3.3 - typescript: ^4.9.4 languageName: unknown linkType: soft @@ -4152,6 +4109,15 @@ __metadata: languageName: unknown linkType: soft +"@calcom/clic@workspace:packages/app-store/clic": + version: 0.0.0-use.local + resolution: "@calcom/clic@workspace:packages/app-store/clic" + dependencies: + "@calcom/lib": "*" + "@calcom/types": "*" + languageName: unknown + linkType: soft + "@calcom/closecom@workspace:packages/app-store/closecom": version: 0.0.0-use.local resolution: "@calcom/closecom@workspace:packages/app-store/closecom" @@ -5195,12 +5161,14 @@ __metadata: class-variance-authority: ^0.4.0 cmdk: ^0.2.0 cmk: ^0.1.1 + date-fns: ^3.6.0 downshift: ^6.1.9 lucide-react: ^0.364.0 next: ^13.5.4 next-seo: ^6.0.0 react: ^18.2.0 react-colorful: ^5.6.0 + react-day-picker: ^8.10.1 react-feather: ^2.0.10 react-hook-form: ^7.43.3 react-select: ^5.7.0 @@ -9488,7 +9456,7 @@ __metadata: languageName: node linkType: hard -"@nestjs/throttler@npm:^5.1.1": +"@nestjs/throttler@npm:^5.1.2": version: 5.1.2 resolution: "@nestjs/throttler@npm:5.1.2" peerDependencies: @@ -10374,13 +10342,6 @@ __metadata: languageName: node linkType: hard -"@panva/hkdf@npm:1.0.2": - version: 1.0.2 - resolution: "@panva/hkdf@npm:1.0.2" - checksum: 75183b4d5ea816ef516dcea70985c610683579a9e2ac540c2d59b9a3ed27eedaff830a43a1c43c1683556a457c92ac66e09109ee995ab173090e4042c4c4bb03 - languageName: node - linkType: hard - "@panva/hkdf@npm:^1.0.2": version: 1.0.4 resolution: "@panva/hkdf@npm:1.0.4" @@ -19705,7 +19666,7 @@ __metadata: languageName: node linkType: hard -"body-parser@npm:1.20.2": +"body-parser@npm:1.20.2, body-parser@npm:^1.20.2": version: 1.20.2 resolution: "body-parser@npm:1.20.2" dependencies: @@ -22766,6 +22727,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^3.6.0": + version: 3.6.0 + resolution: "date-fns@npm:3.6.0" + checksum: 0daa1e9a436cf99f9f2ae9232b55e11f3dd46132bee10987164f3eebd29f245b2e066d7d7db40782627411ecf18551d8f4c9fcdf2226e48bb66545407d448ab7 + languageName: node + linkType: hard + "datocms-listen@npm:^0.1.9": version: 0.1.15 resolution: "datocms-listen@npm:0.1.15" @@ -31146,13 +31114,6 @@ __metadata: languageName: node linkType: hard -"jose@npm:4.11.1": - version: 4.11.1 - resolution: "jose@npm:4.11.1" - checksum: cd15cba258d0fd20f6168631ce2e94fda8442df80e43c1033c523915cecdf390a1cc8efe0eab0c2d65935ca973d791c668aea80724d2aa9c2879d4e70f3081d7 - languageName: node - linkType: hard - "jose@npm:5.2.1": version: 5.2.1 resolution: "jose@npm:5.2.1" @@ -36152,13 +36113,6 @@ __metadata: languageName: node linkType: hard -"oauth4webapi@npm:2.0.5": - version: 2.0.5 - resolution: "oauth4webapi@npm:2.0.5" - checksum: 32d0cb7b1cca42d51dfb88075ca2d69fe33172a807e8ea50e317d17cab3bc80588ab8ebcb7eb4600c371a70af4674595b4b341daf6f3a655f1efa1ab715bb6c9 - languageName: node - linkType: hard - "oauth@npm:^0.9.15": version: 0.9.15 resolution: "oauth@npm:0.9.15" @@ -38333,17 +38287,6 @@ __metadata: languageName: node linkType: hard -"preact-render-to-string@npm:5.2.3": - version: 5.2.3 - resolution: "preact-render-to-string@npm:5.2.3" - dependencies: - pretty-format: ^3.8.0 - peerDependencies: - preact: ">=10" - checksum: 6e46288d8956adde35b9fe3a21aecd9dea29751b40f0f155dea62f3896f27cb8614d457b32f48d33909d2da81135afcca6c55077520feacd7d15164d1371fb44 - languageName: node - linkType: hard - "preact-render-to-string@npm:^5.1.19": version: 5.2.6 resolution: "preact-render-to-string@npm:5.2.6" @@ -38355,7 +38298,7 @@ __metadata: languageName: node linkType: hard -"preact@npm:10.11.3, preact@npm:^10.6.3": +"preact@npm:^10.6.3": version: 10.11.3 resolution: "preact@npm:10.11.3" checksum: 9387115aa0581e8226309e6456e9856f17dfc0e3d3e63f774de80f3d462a882ba7c60914c05942cb51d51e23e120dcfe904b8d392d46f29ad15802941fe7a367 @@ -39393,6 +39336,16 @@ __metadata: languageName: node linkType: hard +"react-day-picker@npm:^8.10.1": + version: 8.10.1 + resolution: "react-day-picker@npm:8.10.1" + peerDependencies: + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: daf7fb5aad1e355df9149e954fafc2cd40ceb90de799db4f36ef86d24729aaecfdf022fa9e63f24d093d81dc2beb60f65ae796741dff0f7305a2af80b333fb4c + languageName: node + linkType: hard + "react-debounce-input@npm:=3.2.4": version: 3.2.4 resolution: "react-debounce-input@npm:3.2.4" @@ -43652,6 +43605,16 @@ __metadata: languageName: node linkType: hard +"stripe@npm:^15.3.0": + version: 15.4.0 + resolution: "stripe@npm:15.4.0" + dependencies: + "@types/node": ">=8.1.0" + qs: ^6.11.0 + checksum: 0302b740ef76084b8cc0fbec14f80c864101a0b04b1d85ecd120d921f3d665a512230a905be1388f777d9677850ffcf066c8cce37935e2f8f3d0d3d5d29d7a4b + languageName: node + linkType: hard + "stripe@npm:^9.16.0": version: 9.16.0 resolution: "stripe@npm:9.16.0" @@ -48180,4 +48143,4 @@ __metadata: resolution: "zwitch@npm:2.0.4" checksum: f22ec5fc2d5f02c423c93d35cdfa83573a3a3bd98c66b927c368ea4d0e7252a500df2a90a6b45522be536a96a73404393c958e945fdba95e6832c200791702b6 languageName: node - linkType: hard \ No newline at end of file + linkType: hard