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:
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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user