From 6eafb4b4bcd1b10265200bb08d97958e03f2824f Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:51:29 +0200 Subject: [PATCH] feat: skip platform billing for non-platform-managed users (#27521) * feat: skip platform billing for non-platform-managed users - Add isPlatformManaged to user select in saveBooking and findBookingQuery - Update 2024-04-15 booking controller to check isPlatformManaged before billing - Update 2024-08-13 bookings service billBooking methods to check isPlatformManaged - Update buildDryRunBooking to include isPlatformManaged in user object Co-Authored-By: morgan@cal.com * test: update buildDryRunBooking test to include uuid and isPlatformManaged fields Co-Authored-By: morgan@cal.com * refactor: simplify to only check isPlatformManaged in normal booking flow Co-Authored-By: morgan@cal.com * test: add E2E tests for billing behavior based on isPlatformManaged flag Co-Authored-By: morgan@cal.com * chore: only trigger platform billing for platform user bookingd * test: add E2E tests for cancel and recurring booking billing behavior Co-Authored-By: morgan@cal.com * fix: correct expected status code for cancel booking endpoint (201 instead of 200) Co-Authored-By: morgan@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../controllers/bookings-billing.e2e-spec.ts | 542 ++++++++++++++++++ .../controllers/bookings.controller.ts | 146 ++--- .../bookings/lib/dto/BookingCancel.d.ts | 1 + .../bookings/lib/getBookingToDelete.ts | 1 + .../bookings/lib/handleCancelBooking.ts | 47 +- .../lib/handleNewBooking/createBooking.ts | 2 +- .../lib/handleNewBooking/findBookingQuery.ts | 1 + .../test/buildDryRunBooking.test.ts | 4 + .../lib/service/RegularBookingService.ts | 2 + 9 files changed, 632 insertions(+), 114 deletions(-) create mode 100644 apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings-billing.e2e-spec.ts diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings-billing.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings-billing.e2e-spec.ts new file mode 100644 index 0000000000..15013b555a --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings-billing.e2e-spec.ts @@ -0,0 +1,542 @@ +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { BookingResponse } from "@calcom/platform-libraries"; +import type { RegularBookingCreateResult } from "@calcom/platform-libraries/bookings"; +import type { ApiSuccessResponse } from "@calcom/platform-types"; +import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; +import { AppModule } from "@/app.module"; +import { bootstrap } from "@/bootstrap"; +import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; +import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { BillingService } from "@/modules/billing/services/billing.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; + +const CLIENT_REDIRECT_URI = "http://localhost:4321"; + +describe("Bookings Billing E2E - 2024-04-15", () => { + describe("Regular user (non-platform-managed)", () => { + jest.setTimeout(30000); + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let billingService: BillingService; + let increaseUsageSpy: jest.SpyInstance; + let cancelUsageSpy: jest.SpyInstance; + + const userEmail = `billing-regular-user-${randomString()}@api.com`; + let user: User; + let eventTypeId: number; + let recEventTypeId: number; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + billingService = moduleRef.get(BillingService); + + // Spy on the billing service methods + increaseUsageSpy = jest.spyOn(billingService, "increaseUsageByUserId"); + cancelUsageSpy = jest.spyOn(billingService, "cancelUsageByBookingUid"); + + // Create a regular user (not platform-managed) + user = await userRepositoryFixture.create({ + email: userEmail, + isPlatformManaged: false, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `billing-test-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const event = await eventTypesRepositoryFixture.create( + { + title: `billing-test-event-type-${randomString()}`, + slug: `billing-test-event-type-${randomString()}`, + length: 60, + }, + user.id + ); + eventTypeId = event.id; + + const recEventType = await eventTypesRepositoryFixture.create( + { + title: `billing-rec-event-type-${randomString()}`, + slug: `billing-rec-event-type-${randomString()}`, + length: 60, + recurringEvent: { freq: 2, count: 4, interval: 1 }, + }, + user.id + ); + recEventTypeId = recEventType.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + afterAll(async () => { + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); + + it("should NOT call billing service when creating a booking for a regular user", async () => { + increaseUsageSpy.mockClear(); + + const body: CreateBookingInput_2024_04_15 = { + start: "2040-05-21T09:30:00.000Z", + end: "2040-05-21T10:30:00.000Z", + eventTypeId: eventTypeId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "Test Attendee", + email: "attendee@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test booking for billing", + }, + }; + + const response = await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + + // Verify billing service was NOT called for regular user + expect(increaseUsageSpy).not.toHaveBeenCalled(); + }); + + it("should NOT call billing cancel service when cancelling a booking for a regular user", async () => { + // First create a booking + const createBody: CreateBookingInput_2024_04_15 = { + start: "2040-05-22T09:30:00.000Z", + end: "2040-05-22T10:30:00.000Z", + eventTypeId: eventTypeId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "Test Attendee Cancel", + email: "attendee-cancel@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test booking for cancel billing", + }, + }; + + const createResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBody) + .expect(201); + + const createResponseBody: ApiSuccessResponse = createResponse.body; + const bookingUid = createResponseBody.data.uid; + + // Clear the spy before cancelling + cancelUsageSpy.mockClear(); + + // Cancel the booking (returns 201 Created) + await request(app.getHttpServer()).post(`/v2/bookings/${bookingUid}/cancel`).send({}).expect(201); + + // Verify billing cancel service was NOT called for regular user + expect(cancelUsageSpy).not.toHaveBeenCalled(); + }); + + it("should NOT call billing service when creating recurring bookings for a regular user", async () => { + increaseUsageSpy.mockClear(); + + const body: CreateRecurringBookingInput_2024_04_15[] = [ + { + start: "2040-06-21T09:30:00.000Z", + end: "2040-06-21T10:30:00.000Z", + eventTypeId: recEventTypeId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "Test Attendee Recurring", + email: "attendee-recurring@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test recurring booking for billing", + }, + recurringEventId: `test-recurring-${randomString()}`, + }, + { + start: "2040-06-28T09:30:00.000Z", + end: "2040-06-28T10:30:00.000Z", + eventTypeId: recEventTypeId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "Test Attendee Recurring", + email: "attendee-recurring@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test recurring booking for billing", + }, + recurringEventId: `test-recurring-${randomString()}`, + }, + ]; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings/recurring") + .send(body) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.length).toBeGreaterThan(0); + + // Verify billing service was NOT called for regular user recurring bookings + expect(increaseUsageSpy).not.toHaveBeenCalled(); + }); + }); + + describe("Platform-managed user", () => { + jest.setTimeout(30000); + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let billingService: BillingService; + let increaseUsageSpy: jest.SpyInstance; + let cancelUsageSpy: jest.SpyInstance; + + const platformAdminEmail = `billing-platform-admin-${randomString()}@api.com`; + const managedUserEmail = `billing-managed-user-${randomString()}@api.com`; + let platformAdmin: User; + let managedUser: User; + let organization: Team; + let oAuthClient: PlatformOAuthClient; + let eventTypeId: number; + let recEventTypeId: number; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + managedUserEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + billingService = moduleRef.get(BillingService); + + // Spy on the billing service methods + increaseUsageSpy = jest.spyOn(billingService, "increaseUsageByUserId"); + cancelUsageSpy = jest.spyOn(billingService, "cancelUsageByBookingUid"); + + // Create platform admin + platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail }); + + // Create organization + organization = await teamRepositoryFixture.create({ + name: `billing-test-organization-${randomString()}`, + isPlatform: true, + isOrganization: true, + }); + + // Create OAuth client + oAuthClient = await oauthClientRepositoryFixture.create( + organization.id, + { + logo: "logo-url", + name: "billing-test-oauth-client", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 1023, + }, + "secret" + ); + + // Create profile for platform admin + await profilesRepositoryFixture.create({ + uid: `billing-test-profile-${randomString()}`, + username: platformAdminEmail, + organization: { connect: { id: organization.id } }, + user: { connect: { id: platformAdmin.id } }, + }); + + // Create membership for platform admin + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: platformAdmin.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + // Create a platform-managed user + managedUser = await userRepositoryFixture.create({ + email: managedUserEmail, + isPlatformManaged: true, + platformOAuthClients: { + connect: { id: oAuthClient.id }, + }, + }); + + // Create profile for managed user + await profilesRepositoryFixture.create({ + uid: `billing-managed-user-profile-${randomString()}`, + username: managedUserEmail, + organization: { connect: { id: organization.id } }, + user: { connect: { id: managedUser.id } }, + }); + + // Create membership for managed user + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: managedUser.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `billing-managed-user-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(managedUser.id, userSchedule); + + const event = await eventTypesRepositoryFixture.create( + { + title: `billing-managed-user-event-type-${randomString()}`, + slug: `billing-managed-user-event-type-${randomString()}`, + length: 60, + }, + managedUser.id + ); + eventTypeId = event.id; + + const recEventType = await eventTypesRepositoryFixture.create( + { + title: `billing-managed-rec-event-type-${randomString()}`, + slug: `billing-managed-rec-event-type-${randomString()}`, + length: 60, + recurringEvent: { freq: 2, count: 4, interval: 1 }, + }, + managedUser.id + ); + recEventTypeId = recEventType.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + afterAll(async () => { + await bookingsRepositoryFixture.deleteAllBookings(managedUser.id, managedUser.email); + await userRepositoryFixture.deleteByEmail(managedUser.email); + await userRepositoryFixture.deleteByEmail(platformAdmin.email); + await app.close(); + }); + + it("should call billing service when creating a booking for a platform-managed user", async () => { + increaseUsageSpy.mockClear(); + + const body: CreateBookingInput_2024_04_15 = { + start: "2040-05-21T09:30:00.000Z", + end: "2040-05-21T10:30:00.000Z", + eventTypeId: eventTypeId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "Test Attendee", + email: "attendee@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test booking for billing", + }, + }; + + const response = await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + + // Verify billing service WAS called for platform-managed user + expect(increaseUsageSpy).toHaveBeenCalledTimes(1); + expect(increaseUsageSpy).toHaveBeenCalledWith( + managedUser.id, + expect.objectContaining({ + uid: responseBody.data.uid, + startTime: expect.any(Date), + }) + ); + }); + + it("should call billing cancel service when cancelling a booking for a platform-managed user", async () => { + // First create a booking + const createBody: CreateBookingInput_2024_04_15 = { + start: "2040-05-22T09:30:00.000Z", + end: "2040-05-22T10:30:00.000Z", + eventTypeId: eventTypeId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "Test Attendee Cancel", + email: "attendee-cancel-managed@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test booking for cancel billing", + }, + }; + + const createResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBody) + .expect(201); + + const createResponseBody: ApiSuccessResponse = createResponse.body; + const bookingUid = createResponseBody.data.uid; + + // Clear the spy before cancelling + cancelUsageSpy.mockClear(); + + // Cancel the booking (returns 201 Created) + await request(app.getHttpServer()).post(`/v2/bookings/${bookingUid}/cancel`).send({}).expect(201); + + // Verify billing cancel service WAS called for platform-managed user + expect(cancelUsageSpy).toHaveBeenCalledTimes(1); + expect(cancelUsageSpy).toHaveBeenCalledWith(bookingUid); + }); + + it("should call billing service when creating recurring bookings for a platform-managed user", async () => { + increaseUsageSpy.mockClear(); + + const body: CreateRecurringBookingInput_2024_04_15[] = [ + { + start: "2040-06-21T09:30:00.000Z", + end: "2040-06-21T10:30:00.000Z", + eventTypeId: recEventTypeId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "Test Attendee Recurring Managed", + email: "attendee-recurring-managed@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test recurring booking for billing", + }, + recurringEventId: `test-recurring-managed-${randomString()}`, + }, + { + start: "2040-06-28T09:30:00.000Z", + end: "2040-06-28T10:30:00.000Z", + eventTypeId: recEventTypeId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "Test Attendee Recurring Managed", + email: "attendee-recurring-managed@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test recurring booking for billing", + }, + recurringEventId: `test-recurring-managed-${randomString()}`, + }, + ]; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings/recurring") + .send(body) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.length).toBeGreaterThan(0); + + // Verify billing service WAS called for platform-managed user recurring bookings + // Should be called once for each booking created + expect(increaseUsageSpy).toHaveBeenCalledTimes(responseBody.data.length); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index 55ffd326a7..d43feb75f5 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -1,3 +1,51 @@ +import { + BOOKING_READ, + BOOKING_WRITE, + SUCCESS_STATUS, + X_CAL_CLIENT_ID, + X_CAL_PLATFORM_EMBED, +} from "@calcom/platform-constants"; +import { + BookingResponse, + CreationSource, + getAllUserBookings, + getBookingForReschedule, + getBookingInfo, + handleCancelBooking, + handleMarkNoShow, +} from "@calcom/platform-libraries"; +import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings"; +import { ErrorCode, HttpError } from "@calcom/platform-libraries/errors"; +import type { ApiResponse } from "@calcom/platform-types"; +import { + CancelBookingInput_2024_04_15, + GetBookingsInput_2024_04_15, + Status_2024_04_15, +} from "@calcom/platform-types"; +import type { PrismaClient } from "@calcom/prisma"; +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + Headers, + HttpException, + InternalServerErrorException, + Logger, + NotFoundException, + Param, + Post, + Query, + Req, + UnauthorizedException, + UseGuards, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; +import { Request } from "express"; +import { NextApiRequest } from "next/types"; +import { v4 as uuidv4 } from "uuid"; import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input"; import { MarkNoShowInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/mark-no-show.input"; @@ -5,7 +53,7 @@ import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ge import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output"; import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output"; import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service"; -import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key"; +import { isApiKey, sha256Hash, stripApiKey } from "@/lib/api-key"; import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions"; import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"; @@ -30,50 +78,6 @@ import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.se import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { UsersService } from "@/modules/users/services/users.service"; import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository"; -import { - Controller, - Post, - Logger, - Req, - InternalServerErrorException, - Body, - Headers, - HttpException, - Param, - Get, - Query, - NotFoundException, - UseGuards, - BadRequestException, - UnauthorizedException, - ForbiddenException, -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; -import { Request } from "express"; -import { NextApiRequest } from "next/types"; -import { v4 as uuidv4 } from "uuid"; - -import { X_CAL_CLIENT_ID, X_CAL_PLATFORM_EMBED } from "@calcom/platform-constants"; -import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants"; -import { - BookingResponse, - handleMarkNoShow, - getAllUserBookings, - getBookingInfo, - handleCancelBooking, - getBookingForReschedule, -} from "@calcom/platform-libraries"; -import { CreationSource } from "@calcom/platform-libraries"; -import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings"; -import { HttpError, ErrorCode } from "@calcom/platform-libraries/errors"; -import { - GetBookingsInput_2024_04_15, - CancelBookingInput_2024_04_15, - Status_2024_04_15, -} from "@calcom/platform-types"; -import type { ApiResponse } from "@calcom/platform-types"; -import type { PrismaClient } from "@calcom/prisma"; type BookingRequest = Request & { userId?: number; @@ -137,7 +141,7 @@ export class BookingsController_2024_04_15 { @Query() queryParams: GetBookingsInput_2024_04_15 ): Promise { const { filters, cursor, limit } = queryParams; - const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"]; + const bookingListingByStatus = filters?.status ?? Status_2024_04_15.upcoming; const profile = this.usersService.getUserMainProfile(user); const bookings = await getAllUserBookings({ bookingListingByStatus: [bookingListingByStatus], @@ -221,7 +225,7 @@ export class BookingsController_2024_04_15 { areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, }, }); - if (booking.userId && booking.uid && booking.startTime) { + if (booking.userId && booking.uid && booking.startTime && booking.user?.isPlatformManaged) { void (await this.billingService.increaseUsageByUserId(booking.userId, { uid: booking.uid, startTime: booking.startTime, @@ -242,12 +246,12 @@ export class BookingsController_2024_04_15 { async cancelBooking( @Req() req: BookingRequest, @Param("bookingUid") bookingUid: string, - @Body() body: CancelBookingInput_2024_04_15, + @Body() _body: CancelBookingInput_2024_04_15, @Headers(X_CAL_CLIENT_ID) clientId?: string, @Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string ): Promise> { const oAuthClientId = clientId?.toString(); - const isUidNumber = !isNaN(Number(bookingUid)); + const isUidNumber = !Number.isNaN(Number(bookingUid)); if (isUidNumber) { throw new BadRequestException("Please provide booking uid instead of booking id."); @@ -276,7 +280,7 @@ export class BookingsController_2024_04_15 { platformRescheduleUrl: bookingRequest.platformRescheduleUrl, platformBookingUrl: bookingRequest.platformBookingUrl, }); - if (!res.onlyRemovedAttendee) { + if (!res.onlyRemovedAttendee && res.isPlatformManagedUserBooking) { void (await this.billingService.cancelUsageByBookingUid(res.bookingUid)); } return { @@ -353,7 +357,7 @@ export class BookingsController_2024_04_15 { }); createdBookings.forEach(async (booking) => { - if (booking.userId && booking.uid && booking.startTime) { + if (booking.userId && booking.uid && booking.startTime && booking.user.isPlatformManaged) { void (await this.billingService.increaseUsageByUserId(booking.userId, { uid: booking.uid, startTime: booking.startTime, @@ -623,7 +627,7 @@ export class BookingsController_2024_04_15 { oAuthClientId ); } - if (requestBody?.responses?.guests && requestBody?.responses?.guests.length) { + if (requestBody?.responses?.guests?.length) { requestBody.responses.guests = await this.platformBookingsService.getPlatformAttendeesEmails( requestBody.responses.guests, oAuthClientId @@ -631,42 +635,6 @@ export class BookingsController_2024_04_15 { } } - private async createNextApiRecurringBookingRequest( - req: BookingRequest, - oAuthClientId?: string, - platformBookingLocation?: string, - isEmbed?: string - ): Promise { - const clone = { ...req }; - const owner = await this.getOwner(req); - const userId = owner?.id ?? -1; - const userUuid = owner?.uuid; - - const oAuthParams = oAuthClientId - ? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed)) - : DEFAULT_PLATFORM_PARAMS; - const requestId = req.get("X-Request-Id"); - this.logger.log(`createNextApiRecurringBookingRequest_2024_04_15`, { - requestId, - ownerId: userId, - platformBookingLocation, - oAuthClientId, - ...oAuthParams, - }); - Object.assign(clone, { - userId, - userUuid, - ...oAuthParams, - platformBookingLocation, - noEmail: !oAuthParams.arePlatformEmailsEnabled, - creationSource: CreationSource.API_V2, - }); - if (oAuthClientId) { - await this.setPlatformAttendeesEmails(clone.body, oAuthClientId); - } - return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams; - } - private handleBookingErrors( err: Error | HttpError | unknown, type?: "recurring" | `instant` | "no-show" @@ -674,7 +642,7 @@ export class BookingsController_2024_04_15 { const errMsg = type === "no-show" ? `Error while marking no-show.` - : `Error while creating ${type ? type + " " : ""}booking.`; + : `Error while creating ${type ? `${type} ` : ""}booking.`; if (err instanceof HttpError) { const httpError = err as HttpError; throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500); diff --git a/packages/features/bookings/lib/dto/BookingCancel.d.ts b/packages/features/bookings/lib/dto/BookingCancel.d.ts index e341d24c26..5632fae3a4 100644 --- a/packages/features/bookings/lib/dto/BookingCancel.d.ts +++ b/packages/features/bookings/lib/dto/BookingCancel.d.ts @@ -32,4 +32,5 @@ export type HandleCancelBookingResponse = { onlyRemovedAttendee: boolean; bookingId: number; bookingUid: string; + isPlatformManagedUserBooking: boolean; }; diff --git a/packages/features/bookings/lib/getBookingToDelete.ts b/packages/features/bookings/lib/getBookingToDelete.ts index ecc33ee3d0..f69a4042af 100644 --- a/packages/features/bookings/lib/getBookingToDelete.ts +++ b/packages/features/bookings/lib/getBookingToDelete.ts @@ -23,6 +23,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u name: true, destinationCalendar: true, locale: true, + isPlatformManaged: true, profiles: { select: { organizationId: true, diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 483b9e919c..8f4bcfbd7a 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -1,12 +1,17 @@ -import type { z } from "zod"; -import { v4 as uuidv4 } from "uuid"; - import { DailyLocationType } from "@calcom/app-store/constants"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmailsAndSMS } from "@calcom/emails/email-manager"; +import type { Actor } from "@calcom/features/booking-audit/lib/dto/types"; +import { + buildActorEmail, + getUniqueIdentifier, + makeGuestActor, + makeUserActor, +} from "@calcom/features/booking-audit/lib/makeActor"; import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; +import { BookingReferenceRepository } from "@calcom/features/bookingReference/repositories/BookingReferenceRepository"; import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; @@ -23,8 +28,8 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { - deleteWebhookScheduledTriggers, cancelNoShowTasksForBooking, + deleteWebhookScheduledTriggers, } from "@calcom/features/webhooks/lib/scheduleTrigger"; import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; @@ -36,22 +41,21 @@ import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; -import { BookingReferenceRepository } from "@calcom/features/bookingReference/repositories/BookingReferenceRepository"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; // TODO: Prisma import would be used from DI in a followup PR when we remove `handler` export import prisma from "@calcom/prisma"; -import type { WorkflowMethods } from "@calcom/prisma/enums"; -import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { WebhookTriggerEvents, WorkflowMethods } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; -import { bookingMetadataSchema, bookingCancelInput } from "@calcom/prisma/zod-utils"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; +import { bookingCancelInput, bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; - +import { v4 as uuidv4 } from "uuid"; +import type { z } from "zod"; import { BookingRepository } from "../repositories/BookingRepository"; import { PrismaBookingAttendeeRepository } from "../repositories/PrismaBookingAttendeeRepository"; import type { - CancelRegularBookingData, CancelBookingMeta, + CancelRegularBookingData, HandleCancelBookingResponse, } from "./dto/BookingCancel"; import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials"; @@ -59,13 +63,6 @@ import { getBookingToDelete } from "./getBookingToDelete"; import { handleInternalNote } from "./handleInternalNote"; import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat"; import type { IBookingCancelService } from "./interfaces/IBookingCancelService"; -import { - buildActorEmail, - getUniqueIdentifier, - makeGuestActor, - makeUserActor, -} from "@calcom/features/booking-audit/lib/makeActor"; -import type { Actor } from "@calcom/features/booking-audit/lib/dto/types"; const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] }); @@ -218,7 +215,7 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { } const isCancellationUserHost = - bookingToDelete.userId == userId || bookingToDelete.user.email === cancelledBy; + bookingToDelete.userId === userId || bookingToDelete.user.email === cancelledBy; if ( !platformClientId && @@ -385,12 +382,12 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { cancellationReason: cancellationReason, ...(teamMembers && teamId && { - team: { - name: bookingToDelete?.eventType?.team?.name || "Nameless", - members: teamMembers, - id: teamId, - }, - }), + team: { + name: bookingToDelete?.eventType?.team?.name || "Nameless", + members: teamMembers, + id: teamId, + }, + }), seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot, seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees, iCalUID: bookingToDelete.iCalUID, @@ -423,6 +420,7 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { bookingId: bookingToDelete.id, bookingUid: bookingToDelete.uid, message: "Attendee successfully removed.", + isPlatformManagedUserBooking: bookingToDelete.user.isPlatformManaged, } satisfies HandleCancelBookingResponse; const promises = webhooks.map((webhook) => @@ -698,6 +696,7 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { onlyRemovedAttendee: false, bookingId: bookingToDelete.id, bookingUid: bookingToDelete.uid, + isPlatformManagedUserBooking: bookingToDelete.user.isPlatformManaged, } satisfies HandleCancelBookingResponse; } diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index e5fe34cf54..e33790cec9 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -143,7 +143,7 @@ async function saveBooking( const createBookingObj = { include: { user: { - select: { uuid: true, email: true, name: true, timeZone: true, username: true }, + select: { uuid: true, email: true, name: true, timeZone: true, username: true, isPlatformManaged: true }, }, attendees: true, payment: true, diff --git a/packages/features/bookings/lib/handleNewBooking/findBookingQuery.ts b/packages/features/bookings/lib/handleNewBooking/findBookingQuery.ts index 6ed36325a0..84b72f3ac4 100644 --- a/packages/features/bookings/lib/handleNewBooking/findBookingQuery.ts +++ b/packages/features/bookings/lib/handleNewBooking/findBookingQuery.ts @@ -24,6 +24,7 @@ const _findBookingQuery = async (bookingId: number) => { email: true, timeZone: true, username: true, + isPlatformManaged: true, }, }, eventType: { diff --git a/packages/features/bookings/lib/handleNewBooking/test/buildDryRunBooking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/buildDryRunBooking.test.ts index 96a0b046be..3a7ff73690 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/buildDryRunBooking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/buildDryRunBooking.test.ts @@ -12,6 +12,7 @@ vi.mock("@calcom/prisma", () => ({ describe("buildDryRunBooking", () => { const baseOrganizerUser = { id: 1, + uuid: "test-uuid-123", name: "Test User", username: "testuser", email: "testuser@example.com", @@ -41,10 +42,12 @@ describe("buildDryRunBooking", () => { const { user, ...bookingExceptUser } = booking; expect(user).toEqual({ id: baseOrganizerUser.id, + uuid: baseOrganizerUser.uuid, name: baseOrganizerUser.name, username: baseOrganizerUser.username, email: baseOrganizerUser.email, timeZone: baseOrganizerUser.timeZone, + isPlatformManaged: false, }); expect(bookingExceptUser).toEqual({ @@ -54,6 +57,7 @@ describe("buildDryRunBooking", () => { status: BookingStatus.ACCEPTED, eventTypeId: baseInputs.eventTypeId, userId: baseOrganizerUser.id, + userUuid: baseOrganizerUser.uuid, title: baseInputs.eventName, startTime: new Date(baseInputs.startTime), endTime: new Date(baseInputs.endTime), diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index e6431fce26..074412c9a0 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -188,6 +188,7 @@ export const buildDryRunBooking = ({ username: string | null; email: string; timeZone: string; + isPlatformManaged?: boolean; }; eventName: string; startTime: string; @@ -204,6 +205,7 @@ export const buildDryRunBooking = ({ username: organizerUser.username, email: organizerUser.email, timeZone: organizerUser.timeZone, + isPlatformManaged: organizerUser.isPlatformManaged ?? false, }; const booking = { id: -101,