From 2a30ba9d3cfa17feba3fe4b80e9bfc5bde9b0315 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:26:38 +0200 Subject: [PATCH] feat: skip platform billing for non-platform-managed users in 2024-08-13 API (#27586) * feat: skip platform billing for non-platform-managed users in 2024-08-13 API - Add isPlatformManaged check in billBooking() to skip billing for regular users - Add isPlatformManaged check in billRescheduledBooking() to skip billing for regular users - Add isPlatformManagedUserBooking check in cancelBooking() to skip billing cancellation for regular users - Add E2E tests to verify billing behavior for both regular and platform-managed users Co-Authored-By: morgan@cal.com * refactor: reuse booking data for isPlatformManaged instead of fetching user - Capture isPlatformManagedUserBooking from raw booking data returned by regularBookingService.createBooking() and recurringBookingService.createBooking() - Pass the flag through the output booking objects - Update billBooking() and billRescheduledBooking() to use the flag instead of fetching user from database - Matches the pattern used in 2024-04-15 controller Co-Authored-By: morgan@cal.com * fix: skip platform billing in booking service for non platform * fix: use double negation for boolean type safety in isPlatformManagedUserBooking Co-Authored-By: morgan@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../e2e/bookings-billing.e2e-spec.ts | 392 ++++++++++++++++++ .../2024-08-13/services/bookings.service.ts | 65 ++- 2 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/bookings-billing.e2e-spec.ts diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/bookings-billing.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/bookings-billing.e2e-spec.ts new file mode 100644 index 0000000000..3d950b505c --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/bookings-billing.e2e-spec.ts @@ -0,0 +1,392 @@ +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } 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 { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +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 { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; +import { AppModule } from "@/app.module"; +import { bootstrap } from "@/bootstrap"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +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-08-13", () => { + 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-2024-08-13-${randomString()}@api.com`; + let user: User; + let eventTypeId: 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, + username: userEmail, + isPlatformManaged: false, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `billing-test-schedule-2024-08-13-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const event = await eventTypesRepositoryFixture.create( + { + title: `billing-test-event-type-2024-08-13-${randomString()}`, + slug: `billing-test-event-type-2024-08-13-${randomString()}`, + length: 60, + }, + user.id + ); + eventTypeId = event.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_08_13 = { + start: new Date(Date.UTC(2040, 4, 21, 9, 30, 0)).toISOString(), + eventTypeId: eventTypeId, + attendee: { + name: "Test Attendee", + email: "attendee-billing-2024-08-13@example.com", + timeZone: "Europe/London", + language: "en", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).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_08_13 = { + start: new Date(Date.UTC(2040, 4, 22, 9, 30, 0)).toISOString(), + eventTypeId: eventTypeId, + attendee: { + name: "Test Attendee Cancel", + email: "attendee-cancel-billing-2024-08-13@example.com", + timeZone: "Europe/London", + language: "en", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + const createResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body; + const data = createResponseBody.data as BookingOutput_2024_08_13; + const bookingUid = data.uid; + + // Clear the spy before cancelling + cancelUsageSpy.mockClear(); + + // Cancel the booking + await request(app.getHttpServer()) + .post(`/v2/bookings/${bookingUid}/cancel`) + .send({}) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200); + + // Verify billing cancel service was NOT called for regular user + expect(cancelUsageSpy).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-2024-08-13-${randomString()}@api.com`; + const managedUserEmail = `billing-managed-user-2024-08-13-${randomString()}@api.com`; + let platformAdmin: User; + let managedUser: User; + let organization: Team; + let oAuthClient: PlatformOAuthClient; + let eventTypeId: 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-2024-08-13-${randomString()}`, + isPlatform: true, + isOrganization: true, + }); + + // Create OAuth client + oAuthClient = await oauthClientRepositoryFixture.create( + organization.id, + { + logo: "logo-url", + name: "billing-test-oauth-client-2024-08-13", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 1023, + }, + "secret" + ); + + // Create profile for platform admin + await profilesRepositoryFixture.create({ + uid: `billing-test-profile-2024-08-13-${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, + username: managedUserEmail, + isPlatformManaged: true, + platformOAuthClients: { + connect: { id: oAuthClient.id }, + }, + }); + + // Create profile for managed user + await profilesRepositoryFixture.create({ + uid: `billing-managed-user-profile-2024-08-13-${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-2024-08-13-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(managedUser.id, userSchedule); + + const event = await eventTypesRepositoryFixture.create( + { + title: `billing-managed-user-event-type-2024-08-13-${randomString()}`, + slug: `billing-managed-user-event-type-2024-08-13-${randomString()}`, + length: 60, + }, + managedUser.id + ); + eventTypeId = event.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_08_13 = { + start: new Date(Date.UTC(2040, 4, 21, 9, 30, 0)).toISOString(), + eventTypeId: eventTypeId, + attendee: { + name: "Test Attendee", + email: "attendee-billing-managed-2024-08-13@example.com", + timeZone: "Europe/London", + language: "en", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + const data = responseBody.data as BookingOutput_2024_08_13; + + // Verify billing service WAS called for platform-managed user + expect(increaseUsageSpy).toHaveBeenCalledTimes(1); + expect(increaseUsageSpy).toHaveBeenCalledWith( + managedUser.id, + expect.objectContaining({ + uid: 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_08_13 = { + start: new Date(Date.UTC(2040, 4, 22, 9, 30, 0)).toISOString(), + eventTypeId: eventTypeId, + attendee: { + name: "Test Attendee Cancel", + email: "attendee-cancel-managed-2024-08-13@example.com", + timeZone: "Europe/London", + language: "en", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + const createResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body; + const data = createResponseBody.data as BookingOutput_2024_08_13; + const bookingUid = data.uid; + + // Clear the spy before cancelling + cancelUsageSpy.mockClear(); + + // Cancel the booking + await request(app.getHttpServer()) + .post(`/v2/bookings/${bookingUid}/cancel`) + .send({}) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200); + + // Verify billing cancel service WAS called for platform-managed user + expect(cancelUsageSpy).toHaveBeenCalledTimes(1); + expect(cancelUsageSpy).toHaveBeenCalledWith(bookingUid); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index 556eb9ffcd..5b339310c0 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -73,6 +73,7 @@ type CreatedBooking = { hosts: { id: number }[]; uid: string; start: string; + isPlatformManagedUserBooking?: boolean; }; const eventTypeBookingFieldSchema = z @@ -441,7 +442,12 @@ export class BookingsService_2024_08_13 { throw new Error(`Booking with id=${booking.bookingId} was not found in the database`); } - return this.outputService.getOutputBooking(databaseBooking); + const outputBooking = await this.outputService.getOutputBooking(databaseBooking); + // Instant bookings don't have a host assigned yet (AWAITING_HOST status), + // so we can't determine isPlatformManaged. Default to false to skip billing. + return Object.assign(outputBooking, { + isPlatformManagedUserBooking: false, + }); } async createRecurringBooking( @@ -466,7 +472,11 @@ export class BookingsService_2024_08_13 { creationSource: "API_V2", }); const ids = bookings.map((booking) => booking.id || 0); - return this.outputService.getOutputRecurringBookings(ids); + const outputBookings = await this.outputService.getOutputRecurringBookings(ids); + const isPlatformManagedUserBooking = !!(bookings[0]?.userId && bookings[0]?.user?.isPlatformManaged); + return outputBookings.map((outputBooking) => + Object.assign(outputBooking, { isPlatformManagedUserBooking }) + ); } async createRecurringSeatedBooking( @@ -490,10 +500,14 @@ export class BookingsService_2024_08_13 { }, creationSource: "API_V2", }); - return this.outputService.getOutputCreateRecurringSeatedBookings( + const outputBookings = await this.outputService.getOutputCreateRecurringSeatedBookings( bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })), userIsEventTypeAdminOrOwner ); + const isPlatformManagedUserBooking = !!(bookings[0]?.userId && bookings[0]?.user?.isPlatformManaged); + return outputBookings.map((outputBooking) => + Object.assign(outputBooking, { isPlatformManagedUserBooking }) + ); } async createRegularBooking( @@ -525,7 +539,15 @@ export class BookingsService_2024_08_13 { throw new Error(`Booking with uid=${booking.uid} was not found in the database`); } - return this.outputService.getOutputBooking(databaseBooking); + const outputBooking = await this.outputService.getOutputBooking(databaseBooking); + return Object.assign( + outputBooking, + booking.userId + ? { + isPlatformManagedUserBooking: booking.user?.isPlatformManaged ?? false, + } + : {} + ); } async createSeatedBooking( @@ -560,11 +582,19 @@ export class BookingsService_2024_08_13 { throw new Error(`Booking with uid=${booking.uid} was not found in the database`); } - return this.outputService.getOutputCreateSeatedBooking( + const outputBooking = await this.outputService.getOutputCreateSeatedBooking( databaseBooking, booking.seatReferenceUid || "", userIsEventTypeAdminOrOwner ); + return Object.assign( + outputBooking, + booking.userId + ? { + isPlatformManagedUserBooking: booking.user?.isPlatformManaged ?? false, + } + : {} + ); } catch (error) { if (error instanceof Error) { if (error.message === "booking_seats_full_error") { @@ -816,25 +846,30 @@ export class BookingsService_2024_08_13 { : false; const isRecurring = !!databaseBooking.recurringEventId; const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot; + const isPlatformManagedUserBooking = !!(booking.userId && booking.user?.isPlatformManaged); if (isRecurring && !isSeated) { - return this.outputService.getOutputRecurringBooking(databaseBooking); + const outputBooking = await this.outputService.getOutputRecurringBooking(databaseBooking); + return Object.assign(outputBooking, { isPlatformManagedUserBooking }); } if (isRecurring && isSeated) { - return this.outputService.getOutputCreateRecurringSeatedBooking( + const outputBooking = await this.outputService.getOutputCreateRecurringSeatedBooking( databaseBooking, booking?.seatReferenceUid || "", userIsEventTypeAdminOrOwner ); + return Object.assign(outputBooking, { isPlatformManagedUserBooking }); } if (isSeated) { - return this.outputService.getOutputCreateSeatedBooking( + const outputBooking = await this.outputService.getOutputCreateSeatedBooking( databaseBooking, booking.seatReferenceUid || "", userIsEventTypeAdminOrOwner ); + return Object.assign(outputBooking, { isPlatformManagedUserBooking }); } - return this.outputService.getOutputBooking(databaseBooking); + const outputBooking = await this.outputService.getOutputBooking(databaseBooking); + return Object.assign(outputBooking, { isPlatformManagedUserBooking }); } catch (error) { this.errorsBookingsService.handleBookingError(error, false); } @@ -943,7 +978,7 @@ export class BookingsService_2024_08_13 { platformBookingUrl: bookingRequest.platformBookingUrl, }); - if (!res.onlyRemovedAttendee) { + if (!res.onlyRemovedAttendee && res.isPlatformManagedUserBooking) { await this.billingService.cancelUsageByBookingUid(res.bookingUid); } @@ -1021,6 +1056,10 @@ export class BookingsService_2024_08_13 { } async billBooking(booking: CreatedBooking) { + if (!booking.isPlatformManagedUserBooking) { + return; + } + const hostId = booking.hosts?.[0]?.id; if (!hostId) { this.logger.error(`Booking with uid=${booking.uid} has no host`); @@ -1034,7 +1073,11 @@ export class BookingsService_2024_08_13 { } async billRescheduledBooking(newBooking: CreatedBooking, oldBookingUid: string) { - const hostId = newBooking.hosts[0].id; + if (!newBooking.isPlatformManagedUserBooking) { + return; + } + + const hostId = newBooking.hosts[0]?.id; if (!hostId) { this.logger.error(`Booking with uid=${newBooking.uid} has no host`); return;