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

* test: update buildDryRunBooking test to include uuid and isPlatformManaged fields

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: simplify to only check isPlatformManaged in normal booking flow

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* test: add E2E tests for billing behavior based on isPlatformManaged flag

Co-Authored-By: morgan@cal.com <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 <morgan@cal.com>

* fix: correct expected status code for cancel booking endpoint (201 instead of 200)

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-03 12:51:29 +02:00
committed by GitHub
co-authored by morgan@cal.com <morgan@cal.com> morgan@cal.com <morgan@cal.com> morgan@cal.com <morgan@cal.com> morgan@cal.com <morgan@cal.com> morgan@cal.com <morgan@cal.com> morgan@cal.com <morgan@cal.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 19e89dfcd5
commit 6eafb4b4bc
9 changed files with 632 additions and 114 deletions
@@ -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>(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,
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<RegularBookingCreateResult> = 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<RegularBookingCreateResult> = 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<BookingResponse[]> = 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>(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-${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<RegularBookingCreateResult> = 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<RegularBookingCreateResult> = 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<BookingResponse[]> = 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);
});
});
});
@@ -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<GetBookingsOutput_2024_04_15> {
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<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
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<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
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);
+1
View File
@@ -32,4 +32,5 @@ export type HandleCancelBookingResponse = {
onlyRemovedAttendee: boolean;
bookingId: number;
bookingUid: string;
isPlatformManagedUserBooking: boolean;
};
@@ -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,
@@ -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;
}
@@ -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,
@@ -24,6 +24,7 @@ const _findBookingQuery = async (bookingId: number) => {
email: true,
timeZone: true,
username: true,
isPlatformManaged: true,
},
},
eventType: {
@@ -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),
@@ -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,