diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example index 0826ed44ce..1775086bb9 100644 --- a/apps/api/v2/.env.example +++ b/apps/api/v2/.env.example @@ -14,8 +14,11 @@ NEXT_PUBLIC_SENTRY_DSN= # Stripe Billing STRIPE_PRICE_ID_STARTER= +STRIPE_PRICE_ID_STARTER_OVERAGE= STRIPE_PRICE_ID_ESSENTIALS= +STRIPE_PRICE_ID_ESSENTIALS_OVERAGE= STRIPE_PRICE_ID_ENTERPRISE= +STRIPE_PRICE_ID_ENTERPRISE_OVERAGE= STRIPE_API_KEY= STRIPE_WEBHOOK_SECRET= diff --git a/apps/api/v2/jest-e2e.json b/apps/api/v2/jest-e2e.json index 3f5b257332..a0c04f1046 100644 --- a/apps/api/v2/jest-e2e.json +++ b/apps/api/v2/jest-e2e.json @@ -10,5 +10,6 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "setupFiles": ["/test/setEnvVars.ts"] + "setupFiles": ["/test/setEnvVars.ts"], + "reporters": ["default", "jest-summarizing-reporter"] } diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 133a29b7e6..3852af3408 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -26,12 +26,13 @@ "dependencies": { "@calcom/platform-constants": "*", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", - "@calcom/platform-libraries-0.0.20": "npm:@calcom/platform-libraries@0.0.20", + "@calcom/platform-libraries-0.0.21": "npm:@calcom/platform-libraries@0.0.21", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", "@golevelup/ts-jest": "^0.4.0", "@microsoft/microsoft-graph-types-beta": "^0.42.0-preview", + "@nestjs/bull": "^10.1.1", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", @@ -42,6 +43,7 @@ "@nestjs/throttler": "^5.1.2", "@sentry/node": "^8.8.0", "body-parser": "^1.20.2", + "bull": "^4.12.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index ea4ab52594..5fad042f86 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -11,6 +11,7 @@ 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 { BullModule } from "@nestjs/bull"; import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core"; @@ -26,7 +27,18 @@ import { AppController } from "./app.controller"; isGlobal: true, load: [appConfig], }), + RedisModule, + BullModule.forRootAsync({ + imports: [RedisModule], + useFactory: async (redisService: RedisService) => ({ + redis: { + host: redisService.redis.options.host, + port: redisService.redis.options.port, + }, + }), + inject: [RedisService], + }), ThrottlerModule.forRootAsync({ imports: [RedisModule], inject: [RedisService], diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts index ecef942761..d7fc779d7a 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts @@ -19,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository import { withApiAuth } from "test/utils/withApiAuth"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; -import { handleNewBooking } from "@calcom/platform-libraries-0.0.20"; +import { handleNewBooking } from "@calcom/platform-libraries-0.0.21"; import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types"; describe("Bookings Endpoints", () => { 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 267e6116ee..c539f7a531 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -46,7 +46,7 @@ import { getBookingInfo, handleCancelBooking, getBookingForReschedule, -} from "@calcom/platform-libraries-0.0.20"; +} from "@calcom/platform-libraries-0.0.21"; import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types"; import { ApiResponse } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; @@ -150,17 +150,21 @@ export class BookingsController { @Req() req: BookingRequest, @Body() body: CreateBookingInput, @Headers(X_CAL_CLIENT_ID) clientId?: string - ): Promise> { + ): Promise>> { const oAuthClientId = clientId?.toString(); - const { orgSlug, locationUrl } = body; req.headers["x-cal-force-slug"] = orgSlug; try { const booking = await handleNewBooking( await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl) ); - - void (await this.billingService.increaseUsageByClientId(oAuthClientId!)); + if (booking.userId && booking.uid && booking.startTime) { + void (await this.billingService.increaseUsageByUserId(booking.userId, { + uid: booking.uid, + startTime: booking.startTime, + fromReschedule: booking.fromReschedule, + })); + } return { status: SUCCESS_STATUS, data: booking, @@ -177,14 +181,22 @@ export class BookingsController { @Param("bookingId") bookingId: string, @Body() _: CancelBookingInput, @Headers(X_CAL_CLIENT_ID) clientId?: string - ): Promise { + ): Promise> { const oAuthClientId = clientId?.toString(); if (bookingId) { try { req.body.id = parseInt(bookingId); - await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId)); + const res = await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId)); + if (!res.onlyRemovedAttendee) { + void (await this.billingService.cancelUsageByBookingUid(res.bookingUid)); + } return { status: SUCCESS_STATUS, + data: { + bookingId: res.bookingId, + bookingUid: res.bookingUid, + onlyRemovedAttendee: res.onlyRemovedAttendee, + }, }; } catch (err) { this.handleBookingErrors(err); @@ -228,7 +240,14 @@ export class BookingsController { await this.createNextApiBookingRequest(req, oAuthClientId) ); - void (await this.billingService.increaseUsageByClientId(oAuthClientId!)); + createdBookings.forEach(async (booking) => { + if (booking.userId && booking.uid && booking.startTime) { + void (await this.billingService.increaseUsageByUserId(booking.userId, { + uid: booking.uid, + startTime: booking.startTime, + })); + } + }); return { status: SUCCESS_STATUS, @@ -253,7 +272,15 @@ export class BookingsController { await this.createNextApiBookingRequest(req, oAuthClientId) ); - void (await this.billingService.increaseUsageByClientId(oAuthClientId!)); + if (instantMeeting.userId && instantMeeting.bookingUid) { + const now = new Date(); + // add a 10 secondes delay to the usage incrementation to give some time to cancel the booking if needed + now.setSeconds(now.getSeconds() + 10); + void (await this.billingService.increaseUsageByUserId(instantMeeting.userId, { + uid: instantMeeting.bookingUid, + startTime: now, + })); + } return { status: SUCCESS_STATUS, diff --git a/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts b/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts index a570ca6e03..043ce80c24 100644 --- a/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts @@ -1,7 +1,7 @@ import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; import { IsBoolean, IsNumber, IsOptional } from "class-validator"; -import type { AppsStatus } from "@calcom/platform-libraries-0.0.20"; +import type { AppsStatus } from "@calcom/platform-libraries-0.0.21"; export class CreateRecurringBookingInput extends CreateBookingInput { @IsBoolean() diff --git a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts index 9061e1e3aa..a75d8faac2 100644 --- a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts +++ b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts @@ -5,7 +5,7 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common"; import { Injectable } from "@nestjs/common"; import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants"; -import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.20"; +import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.21"; @Injectable() export class AppleCalendarService implements CredentialSyncCalendarApp { diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts index fa63654804..42a78970a9 100644 --- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -18,7 +18,7 @@ import { User } from "@prisma/client"; import { DateTime } from "luxon"; import { z } from "zod"; -import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.20"; +import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.21"; import { Calendar } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts index e503597101..af85596b89 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts @@ -35,7 +35,7 @@ import { EventTypesPublic, eventTypeBookingFields, eventTypeLocations, -} from "@calcom/platform-libraries-0.0.20"; +} from "@calcom/platform-libraries-0.0.21"; import { ApiSuccessResponse } from "@calcom/platform-types"; describe("Event types Endpoints", () => { diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts index f285f35072..4fe2c46580 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts @@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { UserWithProfile } from "@/modules/users/users.repository"; import { Injectable } from "@nestjs/common"; -import { getEventTypeById } from "@calcom/platform-libraries-0.0.20"; +import { getEventTypeById } from "@calcom/platform-libraries-0.0.21"; import type { PrismaClient } from "@calcom/prisma"; @Injectable() diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts index da136562ef..0092bc6bf1 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts @@ -14,7 +14,7 @@ import { updateEventType, EventTypesPublic, getEventTypesPublic, -} from "@calcom/platform-libraries-0.0.20"; +} from "@calcom/platform-libraries-0.0.21"; import { EventType } from "@calcom/prisma/client"; @Injectable() diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts index c002610c86..4807be6ae5 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts @@ -7,7 +7,7 @@ import { getEventTypeById, transformApiEventTypeBookingFields, transformApiEventTypeLocations, -} from "@calcom/platform-libraries-0.0.20"; +} from "@calcom/platform-libraries-0.0.21"; import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types"; import type { PrismaClient } from "@calcom/prisma"; diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts index e352368696..7ff490ec9c 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts @@ -10,9 +10,9 @@ import { UsersService } from "@/modules/users/services/users.service"; import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; -import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.20"; -import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.20"; -import { dynamicEvent } from "@calcom/platform-libraries-0.0.20"; +import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21"; +import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.21"; +import { dynamicEvent } from "@calcom/platform-libraries-0.0.21"; import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14, diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts index ed5a3a69c3..88ecd3923c 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts @@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common"; import { transformApiEventTypeBookingFields, transformApiEventTypeLocations, -} from "@calcom/platform-libraries-0.0.20"; +} from "@calcom/platform-libraries-0.0.21"; import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types"; @Injectable() diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts index f33350daaa..0d558814dd 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts @@ -9,7 +9,7 @@ import { parseRecurringEvent, TransformedLocationsSchema, BookingFieldsSchema, -} from "@calcom/platform-libraries-0.0.20"; +} from "@calcom/platform-libraries-0.0.21"; type EventTypeRelations = { users: User[]; schedule: Schedule | null }; type DatabaseEventType = EventType & EventTypeRelations; diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/input-schedules.service.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/input-schedules.service.ts index cbafc77a55..67d5aeabb8 100644 --- a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/input-schedules.service.ts +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/input-schedules.service.ts @@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common"; import { transformApiScheduleOverrides, transformApiScheduleAvailability, -} from "@calcom/platform-libraries-0.0.20"; +} from "@calcom/platform-libraries-0.0.21"; import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types"; import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types"; diff --git a/apps/api/v2/src/filters/trpc-exception.filter.ts b/apps/api/v2/src/filters/trpc-exception.filter.ts index 3b1a76a9b4..d2f8edb6bc 100644 --- a/apps/api/v2/src/filters/trpc-exception.filter.ts +++ b/apps/api/v2/src/filters/trpc-exception.filter.ts @@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common"; import { Request } from "express"; import { ERROR_STATUS } from "@calcom/platform-constants"; -import { TRPCError } from "@calcom/platform-libraries-0.0.20"; +import { TRPCError } from "@calcom/platform-libraries-0.0.21"; import { Response } from "@calcom/platform-types"; @Catch(TRPCError) diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts index e9dc616eb7..2bd209a9cf 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 { BillingProcessor } from "@/modules/billing/billing.processor"; import { BillingRepository } from "@/modules/billing/billing.repository"; import { BillingController } from "@/modules/billing/controllers/billing.controller"; import { BillingConfigService } from "@/modules/billing/services/billing.config.service"; @@ -6,11 +7,24 @@ 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 { BullModule } from "@nestjs/bull"; import { Module } from "@nestjs/common"; @Module({ - imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule], - providers: [BillingConfigService, BillingService, BillingRepository], + imports: [ + PrismaModule, + StripeModule, + MembershipsModule, + OrganizationsModule, + BullModule.registerQueue({ + name: "billing", + limiter: { + max: 1, + duration: 1000, + }, + }), + ], + providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor], exports: [BillingService, BillingRepository], controllers: [BillingController], }) diff --git a/apps/api/v2/src/modules/billing/billing.processor.ts b/apps/api/v2/src/modules/billing/billing.processor.ts new file mode 100644 index 0000000000..68074711a6 --- /dev/null +++ b/apps/api/v2/src/modules/billing/billing.processor.ts @@ -0,0 +1,91 @@ +import { BillingRepository } from "@/modules/billing/billing.repository"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { Process, Processor } from "@nestjs/bull"; +import { Logger } from "@nestjs/common"; +import { Job } from "bull"; + +export const INCREMENT_JOB = "increment"; +export const BILLING_QUEUE = "billing"; +export type IncrementJobDataType = { + userId: number; +}; + +export type DecrementJobDataType = IncrementJobDataType; + +@Processor(BILLING_QUEUE) +export class BillingProcessor { + private readonly logger = new Logger(BillingProcessor.name); + + constructor( + public readonly stripeService: StripeService, + private readonly billingRepository: BillingRepository, + private readonly teamsRepository: OrganizationsRepository + ) {} + + @Process(INCREMENT_JOB) + async handleIncrement(job: Job) { + const { userId } = job.data; + try { + // get the platform organization of the managed user + const team = await this.teamsRepository.findPlatformOrgFromUserId(userId); + const teamId = team.id; + if (!team.id) { + this.logger.error(`User (${userId}) is not part of the platform organization (${teamId}) `, { + teamId, + userId, + }); + return; + } + + const billingSubscription = await this.billingRepository.getBillingForTeam(teamId); + if (!billingSubscription || !billingSubscription?.subscriptionId) { + this.logger.error(`Team ${teamId} did not have stripe subscription associated to it`, { + teamId, + }); + return; + } + + const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve( + billingSubscription.subscriptionId + ); + if (!stripeSubscription?.id) { + this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, { + teamId, + subscriptionId: billingSubscription.subscriptionId, + }); + return; + } + + const meteredItem = stripeSubscription.items.data.find( + (item) => item.price?.recurring?.usage_type === "metered" + ); + // no metered item found to increase usage, return early + if (!meteredItem) { + this.logger.error(`Stripe subscription (${stripeSubscription.id} is not usage based`, { + teamId, + subscriptionId: stripeSubscription.id, + }); + return; + } + + await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, { + action: "increment", + quantity: 1, + timestamp: "now", + }); + this.logger.log("Increased organization usage for subscription", { + subscriptionId: billingSubscription.subscriptionId, + teamId, + userId, + itemId: meteredItem.id, + }); + } catch (err) { + this.logger.error("Failed to increase usage for Organization", { + userId, + err, + }); + } + return; + } +} 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 index a9a25e35b1..e5b19a09c8 100644 --- a/apps/api/v2/src/modules/billing/services/billing.config.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.config.service.ts @@ -3,21 +3,38 @@ import { Injectable } from "@nestjs/common"; @Injectable() export class BillingConfigService { - private readonly config: Map; + private readonly config: Map< + PlatformPlan, + { + base: string; + overage: string; + } + >; constructor() { - this.config = new Map(); + this.config = new Map< + PlatformPlan, + { + base: string; + overage: string; + } + >(); const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key))); for (const key of planKeys) { - this.config.set( - PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan], - process.env[`STRIPE_PRICE_ID_${key}`] ?? "" - ); + this.config.set(PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan], { + base: process.env[`STRIPE_PRICE_ID_${key}`] ?? "", + overage: process.env[`STRIPE_PRICE_ID_${key}_OVERAGE`] ?? "", + }); } } - get(plan: PlatformPlan): string | undefined { + get(plan: PlatformPlan): + | { + base: string; + overage: 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 index d196cb00ec..2cc24e2c74 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -1,16 +1,19 @@ import { AppConfig } from "@/config/type"; +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 { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; import { StripeService } from "@/modules/stripe/stripe.service"; -import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; +import { InjectQueue } from "@nestjs/bull"; +import { Injectable, InternalServerErrorException, Logger, OnModuleDestroy } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { Queue } from "bull"; import { DateTime } from "luxon"; import Stripe from "stripe"; @Injectable() -export class BillingService { +export class BillingService implements OnModuleDestroy { private logger = new Logger("BillingService"); private readonly webAppUrl: string; @@ -19,9 +22,10 @@ export class BillingService { public readonly stripeService: StripeService, private readonly billingRepository: BillingRepository, private readonly configService: ConfigService, - private readonly billingConfigService: BillingConfigService + private readonly billingConfigService: BillingConfigService, + @InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue ) { - this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com"; + this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com"; } async getBillingData(teamId: number) { @@ -58,7 +62,10 @@ export class BillingService { customer: customerId, line_items: [ { - price: this.billingConfigService.get(plan), + price: this.billingConfigService.get(plan)?.overage, + }, + { + price: this.billingConfigService.get(plan)?.base, quantity: 1, }, ], @@ -99,46 +106,56 @@ export class BillingService { ); } - async increaseUsageForTeam(teamId: number) { - try { - const billingSubscription = await this.billingRepository.getBillingForTeam(teamId); - if (!billingSubscription || !billingSubscription?.subscriptionId) { - this.logger.error("Team did not have stripe subscription associated to it", { - teamId, - }); - return void 0; - } + /** + * + * Adds a job to the queue to increment usage of a stripe subscription. + * we delay the job until the booking starts. + * the delay ensure we can adapt to cancel / reschedule. + */ + async increaseUsageByUserId( + userId: number, + booking: { + uid: string; + startTime: Date; + fromReschedule?: string | null; + } + ) { + const { uid, startTime, fromReschedule } = booking; - const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve( - billingSubscription.subscriptionId - ); - const item = stripeSubscription.items.data[0]; - // legacy plans are licensed, we cannot create usage records against them - if (item.price?.recurring?.usage_type === "licensed") { - return void 0; - } + const delay = startTime.getTime() - Date.now(); + if (fromReschedule) { + // cancel the usage increment job for the booking that is being rescheduled + await this.cancelUsageByBookingUid(fromReschedule); + this.logger.log(`Cancelled usage increment job for rescheduled booking uid: ${fromReschedule}`); + } + await this.billingQueue.add( + INCREMENT_JOB, + { + userId, + } satisfies IncrementJobDataType, + { delay: delay > 0 ? delay : 0, jobId: `increment-${uid}`, removeOnComplete: true } + ); + this.logger.log(`Added stripe usage increment job for booking ${uid} and user ${userId}`); + } - await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, { - action: "increment", - quantity: 1, - timestamp: "now", - }); - } catch (error) { - // don't fail the request, log it. - this.logger.error("Failed to increase usage for team", { - teamId: teamId, - error, - }); + /** + * + * Cancels the usage increment job for a booking when it is cancelled. + * Removing an attendee from a booking does not cancel the usage increment job. + */ + async cancelUsageByBookingUid(bookingUid: string) { + const job = await this.billingQueue.getJob(`increment-${bookingUid}`); + if (job) { + await job.remove(); + this.logger.log(`Removed increment job for cancelled booking ${bookingUid}`); } } - async increaseUsageByClientId(clientId: string) { - if (this.configService.get("e2e")) { - return void 0; + async onModuleDestroy() { + try { + await this.billingQueue.close(); + } catch (err) { + this.logger.error(err); } - 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/email/email.service.ts b/apps/api/v2/src/modules/email/email.service.ts index e308b38e39..848067f50a 100644 --- a/apps/api/v2/src/modules/email/email.service.ts +++ b/apps/api/v2/src/modules/email/email.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; -import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.20"; +import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.21"; @Injectable() export class EmailService { diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts index e291156c47..41fe8249e5 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts @@ -163,7 +163,7 @@ describe("OAuth Clients Endpoints", () => { await app.init(); }); - describe("User is not in an organization", () => { + describe("User is not part of an organization", () => { it(`/GET`, () => { return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403); }); @@ -371,6 +371,7 @@ describe("OAuth Clients Endpoints", () => { afterAll(async () => { await teamFixtures.delete(org.id); await usersFixtures.delete(user.id); + await platformBillingRepositoryFixture.deleteSubscriptionForTeam(org.id); await app.close(); }); }); 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 8cf4e70821..98a7d08dfd 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,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, Injectable } from "@nestjs/common"; import { User } from "@prisma/client"; -import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.20"; +import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.21"; @Injectable() export class OAuthClientUsersService { diff --git a/apps/api/v2/src/modules/organizations/organizations.repository.ts b/apps/api/v2/src/modules/organizations/organizations.repository.ts index 0d43c09560..25f89e4b70 100644 --- a/apps/api/v2/src/modules/organizations/organizations.repository.ts +++ b/apps/api/v2/src/modules/organizations/organizations.repository.ts @@ -70,6 +70,26 @@ export class OrganizationsRepository { }, }); } + + async findPlatformOrgFromUserId(userId: number) { + return this.dbRead.prisma.team.findFirstOrThrow({ + where: { + orgProfiles: { + some: { + userId: userId, + }, + }, + isPlatform: true, + isOrganization: true, + }, + select: { + id: true, + isPlatform: true, + isOrganization: true, + }, + }); + } + async findOrgUser(organizationId: number, userId: number) { return this.dbRead.prisma.user.findUnique({ where: { diff --git a/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts index 780b593a3b..fccf357232 100644 --- a/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts +++ b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts @@ -9,7 +9,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { UserWithProfile } from "@/modules/users/users.repository"; import { Injectable, NotFoundException } from "@nestjs/common"; -import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.20"; +import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21"; import { CreateTeamEventTypeInput_2024_06_14, UpdateTeamEventTypeInput_2024_06_14, diff --git a/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts b/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts index d65d9c03fe..f28ac9e21a 100644 --- a/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts +++ b/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts @@ -6,7 +6,7 @@ import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; import { Injectable, ConflictException } from "@nestjs/common"; import { plainToInstance } from "class-transformer"; -import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.20"; +import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.21"; import { Team } from "@calcom/prisma/client"; @Injectable() diff --git a/apps/api/v2/src/modules/redis/redis.service.ts b/apps/api/v2/src/modules/redis/redis.service.ts index ea55ec4638..d683c50a35 100644 --- a/apps/api/v2/src/modules/redis/redis.service.ts +++ b/apps/api/v2/src/modules/redis/redis.service.ts @@ -1,11 +1,12 @@ import { AppConfig } from "@/config/type"; -import { Injectable, OnModuleDestroy } from "@nestjs/common"; +import { Injectable, OnModuleDestroy, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; @Injectable() export class RedisService implements OnModuleDestroy { public redis: Redis; + private readonly logger = new Logger("RedisService"); constructor(readonly configService: ConfigService) { const dbUrl = configService.get("db.redisUrl", { infer: true }); @@ -15,6 +16,10 @@ export class RedisService implements OnModuleDestroy { } async onModuleDestroy() { - await this.redis.disconnect(); + try { + await this.redis.quit(); + } catch (err) { + this.logger.error(err); + } } } diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts index 1b6ea67cfc..604bf23c55 100644 --- a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts @@ -5,8 +5,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger"; import { Response as ExpressResponse, Request as ExpressRequest } from "express"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { getAvailableSlots } from "@calcom/platform-libraries-0.0.20"; -import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.20"; +import { getAvailableSlots } from "@calcom/platform-libraries-0.0.21"; +import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.21"; import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types"; import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types"; diff --git a/apps/api/v2/src/modules/slots/slots.repository.ts b/apps/api/v2/src/modules/slots/slots.repository.ts index f51fe8290c..0181eebc8b 100644 --- a/apps/api/v2/src/modules/slots/slots.repository.ts +++ b/apps/api/v2/src/modules/slots/slots.repository.ts @@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { Injectable } from "@nestjs/common"; import { DateTime } from "luxon"; -import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.20"; +import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.21"; import { ReserveSlotInput } from "@calcom/platform-types"; @Injectable() diff --git a/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts b/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts index 07e009fef9..ecbbb9132e 100644 --- a/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts +++ b/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts @@ -4,7 +4,7 @@ import { Controller, Get } from "@nestjs/common"; import { ApiTags as DocsTags } from "@nestjs/swagger"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import type { CityTimezones } from "@calcom/platform-libraries-0.0.20"; +import type { CityTimezones } from "@calcom/platform-libraries-0.0.21"; import { ApiResponse } from "@calcom/platform-types"; @Controller({ diff --git a/apps/api/v2/src/modules/timezones/services/timezones.service.ts b/apps/api/v2/src/modules/timezones/services/timezones.service.ts index ac8b98c803..a885219b1f 100644 --- a/apps/api/v2/src/modules/timezones/services/timezones.service.ts +++ b/apps/api/v2/src/modules/timezones/services/timezones.service.ts @@ -1,8 +1,8 @@ import { RedisService } from "@/modules/redis/redis.service"; import { Injectable } from "@nestjs/common"; -import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.20"; -import type { CityTimezones } from "@calcom/platform-libraries-0.0.20"; +import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.21"; +import type { CityTimezones } from "@calcom/platform-libraries-0.0.21"; @Injectable() export class TimezonesService { diff --git a/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts index 472623e564..818956db90 100644 --- a/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts @@ -22,4 +22,17 @@ export class PlatformBillingRepositoryFixture { }, }); } + + async deleteSubscriptionForTeam(teamId: number) { + // silently try to delete the subscription + try { + await this.prismaWriteClient.platformBilling.delete({ + where: { + id: teamId, + }, + }); + } catch (err) { + console.error(err); + } + } } diff --git a/package.json b/package.json index 92e83ee775..909738846f 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "husky": "^8.0.0", "i18n-unused": "^0.13.0", "jest-diff": "^29.5.0", + "jest-summarizing-reporter": "^1.1.4", "jsdom": "^22.0.0", "lint-staged": "^12.5.0", "mailhog": "^4.16.0", diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 98a630941f..855ee6c78e 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -144,6 +144,14 @@ export type CustomRequest = NextApiRequest & { arePlatformEmailsEnabled?: boolean; }; +export type HandleCancelBookingResponse = { + success: boolean; + message: string; + onlyRemovedAttendee: boolean; + bookingId: number; + bookingUid: string; +}; + async function handler(req: CustomRequest) { const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid } = schemaBookingCancelParams.parse(req.body); @@ -312,7 +320,14 @@ async function handler(req: CustomRequest) { // If it's just an attendee of a booking then just remove them from that booking const result = await cancelAttendeeSeat(req, dataForWebhooks); - if (result) return { success: true }; + if (result) + return { + success: true, + onlyRemovedAttendee: true, + bookingId: bookingToDelete.id, + bookingUid: bookingToDelete.uid, + message: "Attendee successfully removed.", + } satisfies HandleCancelBookingResponse; const promises = webhooks.map((webhook) => sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { @@ -492,7 +507,13 @@ async function handler(req: CustomRequest) { console.error("Error deleting event", error); } req.statusCode = 200; - return { message: "Booking successfully cancelled." }; + return { + success: true, + message: "Booking successfully cancelled.", + onlyRemovedAttendee: false, + bookingId: bookingToDelete.id, + bookingUid: bookingToDelete.uid, + } satisfies HandleCancelBookingResponse; } export default handler; diff --git a/packages/features/instant-meeting/handleInstantMeeting.ts b/packages/features/instant-meeting/handleInstantMeeting.ts index 49707c8cc8..9c3d73bfcb 100644 --- a/packages/features/instant-meeting/handleInstantMeeting.ts +++ b/packages/features/instant-meeting/handleInstantMeeting.ts @@ -86,6 +86,15 @@ const handleInstantMeetingWebhookTrigger = async (args: { } }; +export type HandleInstantMeetingResponse = { + message: string; + meetingTokenId: number; + bookingId: number; + bookingUid: string; + expires: Date; + userId: number | null; +}; + async function handler(req: NextApiRequest) { let eventType = await getEventTypesFromDB(req.body.eventTypeId); eventType = { @@ -247,8 +256,10 @@ async function handler(req: NextApiRequest) { message: "Success", meetingTokenId: instantMeetingToken.id, bookingId: newBooking.id, + bookingUid: newBooking.uid, expires: instantMeetingToken.expires, - }; + userId: newBooking.userId, + } satisfies HandleInstantMeetingResponse; } export default handler;