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 <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 <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 <morgan@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Morgan
2026-02-04 10:26:38 -03:00
committed by GitHub
co-authored by morgan@cal.com <morgan@cal.com> morgan@cal.com <morgan@cal.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 2c28c9c028
commit 2a30ba9d3c
2 changed files with 446 additions and 11 deletions
@@ -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>(SchedulesService_2024_04_15);
billingService = moduleRef.get<BillingService>(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>(SchedulesService_2024_04_15);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
billingService = moduleRef.get<BillingService>(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);
});
});
});
@@ -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;