fix: Handle payment flow webhooks in case of event requiring confirmation (#11458)

Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
Hariom Balhara 2023-09-30 10:22:32 +05:30 committed by GitHub
parent 0bb99fc667
commit 20898e1505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 3379 additions and 1656 deletions

View File

@ -423,6 +423,7 @@
"booking_created": "Booking Created",
"booking_rejected": "Booking Rejected",
"booking_requested": "Booking Requested",
"booking_payment_initiated": "Booking Payment Initiated",
"meeting_ended": "Meeting Ended",
"form_submitted": "Form Submitted",
"booking_paid": "Booking Paid",

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it } from "vitest";

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it } from "vitest";

View File

@ -1,20 +1,18 @@
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { diff } from "jest-diff";
import { describe, expect, vi, beforeEach, afterEach, test } from "vitest";
import prisma from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
import { getDate, getGoogleCalendarCredential, createBookingScenario } from "../utils/bookingScenario";
// TODO: Mock properly
prismaMock.eventType.findUnique.mockResolvedValue(null);
// @ts-expect-error Prisma v5 typings are not yet available
prismaMock.user.findMany.mockResolvedValue([]);
import {
getDate,
getGoogleCalendarCredential,
createBookingScenario,
} from "../utils/bookingScenario/bookingScenario";
vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true,
@ -146,13 +144,13 @@ const TestData = {
};
const cleanup = async () => {
await prisma.eventType.deleteMany();
await prisma.user.deleteMany();
await prisma.schedule.deleteMany();
await prisma.selectedCalendar.deleteMany();
await prisma.credential.deleteMany();
await prisma.booking.deleteMany();
await prisma.app.deleteMany();
await prismock.eventType.deleteMany();
await prismock.user.deleteMany();
await prismock.schedule.deleteMany();
await prismock.selectedCalendar.deleteMany();
await prismock.credential.deleteMany();
await prismock.booking.deleteMany();
await prismock.app.deleteMany();
};
beforeEach(async () => {
@ -201,7 +199,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar],
};
// An event with one accepted booking
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
input: {
@ -228,7 +226,7 @@ describe("getSchedule", () => {
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
// An event with one accepted booking
createBookingScenario({
await createBookingScenario({
// An event with length 30 minutes, slotInterval 45 minutes, and minimumBookingNotice 1440 minutes (24 hours)
eventTypes: [
{
@ -354,7 +352,7 @@ describe("getSchedule", () => {
});
test("slots are available as per `length`, `slotInterval` of the event", async () => {
createBookingScenario({
await createBookingScenario({
eventTypes: [
{
id: 1,
@ -453,7 +451,7 @@ describe("getSchedule", () => {
})()
);
createBookingScenario({
await createBookingScenario({
eventTypes: [
{
id: 1,
@ -569,7 +567,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar],
};
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const scheduleForEventOnADayWithNonCalBooking = await getSchedule({
input: {
@ -643,7 +641,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar],
};
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const scheduleForEventOnADayWithCalBooking = await getSchedule({
input: {
@ -701,7 +699,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar],
};
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const schedule = await getSchedule({
input: {
@ -765,7 +763,7 @@ describe("getSchedule", () => {
],
};
createBookingScenario(scenarioData);
await createBookingScenario(scenarioData);
const scheduleForEventOnADayWithDateOverride = await getSchedule({
input: {
@ -790,7 +788,7 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
createBookingScenario({
await createBookingScenario({
eventTypes: [
// A Collective Event Type hosted by this user
{
@ -885,7 +883,7 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
createBookingScenario({
await createBookingScenario({
eventTypes: [
// An event having two users with one accepted booking
{
@ -1010,7 +1008,7 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
createBookingScenario({
await createBookingScenario({
eventTypes: [
// An event having two users with one accepted booking
{

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import type { EventType } from "@prisma/client";
import { describe, expect, it, vi } from "vitest";

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { expect, it } from "vitest";

View File

@ -0,0 +1,88 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { Payment, Prisma, PaymentOption, Booking } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import "vitest-fetch-mock";
import { sendAwaitingPaymentEmail } from "@calcom/emails";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
export function getMockPaymentService() {
function createPaymentLink(/*{ paymentUid, name, email, date }*/) {
return "http://mock-payment.example.com/";
}
const paymentUid = uuidv4();
const externalId = uuidv4();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
class MockPaymentService implements IAbstractPaymentService {
// TODO: We shouldn't need to implement adding a row to Payment table but that's a requirement right now.
// We should actually delegate table creation to the core app. Here, only the payment app specific logic should come
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
userId: Booking["userId"],
username: string | null,
bookerName: string | null,
bookerEmail: string,
paymentOption: PaymentOption
) {
const paymentCreateData = {
id: 1,
uid: paymentUid,
appId: null,
bookingId,
// booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
fee: 10,
success: true,
refunded: false,
data: {},
externalId,
paymentOption,
amount: payment.amount,
currency: payment.currency,
};
const paymentData = prismaMock.payment.create({
data: paymentCreateData,
});
logger.silly("Created mock payment", JSON.stringify({ paymentData }));
return paymentData;
}
async afterPayment(
event: CalendarEvent,
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
startTime: { toISOString: () => string };
uid: string;
},
paymentData: Payment
): Promise<void> {
// TODO: App implementing PaymentService is supposed to send email by itself at the moment.
await sendAwaitingPaymentEmail({
...event,
paymentInfo: {
link: createPaymentLink(/*{
paymentUid: paymentData.uid,
name: booking.user?.name,
email: booking.user?.email,
date: booking.startTime.toISOString(),
}*/),
paymentOption: paymentData.paymentOption || "ON_BOOKING",
amount: paymentData.amount,
currency: paymentData.currency,
},
});
}
}
return {
paymentUid,
externalId,
MockPaymentService,
};
}

View File

@ -1,25 +1,23 @@
import appStoreMock from "../../../../tests/libs/__mocks__/app-store";
import i18nMock from "../../../../tests/libs/__mocks__/libServerI18n";
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import type {
EventType as PrismaEventType,
User as PrismaUser,
Booking as PrismaBooking,
App as PrismaApp,
} from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client";
import type Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import { expect } from "vitest";
import "vitest-fetch-mock";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { NewCalendarEventType } from "@calcom/types/Calendar";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
import { getMockPaymentService } from "./MockPaymentService";
type App = {
slug: string;
@ -78,7 +76,7 @@ type InputUser = typeof TestData.users.example & { id: number } & {
}[];
};
type InputEventType = {
export type InputEventType = {
id: number;
title?: string;
length?: number;
@ -94,9 +92,11 @@ type InputEventType = {
beforeEventBuffer?: number;
afterEventBuffer?: number;
requiresConfirmation?: boolean;
};
} & Partial<Omit<Prisma.EventTypeCreateInput, "users">>;
type InputBooking = {
id?: number;
uid?: string;
userId?: number;
eventTypeId: number;
startTime: string;
@ -104,14 +104,40 @@ type InputBooking = {
title?: string;
status: BookingStatus;
attendees?: { email: string }[];
references?: {
type: string;
uid: string;
meetingId?: string;
meetingPassword?: string;
meetingUrl?: string;
bookingId?: number;
externalCalendarId?: string;
deleted?: boolean;
credentialId?: number;
}[];
};
const Timezones = {
"+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka",
};
logger.setSettings({ minLevel: "silly" });
function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
async function addEventTypesToDb(
eventTypes: (Prisma.EventTypeCreateInput & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
users?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflows?: any[];
})[]
) {
logger.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
await prismock.eventType.createMany({
data: eventTypes,
});
}
async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
const baseEventType = {
title: "Base EventType Title",
slug: "base-event-type-slug",
@ -119,7 +145,7 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
beforeEventBuffer: 0,
afterEventBuffer: 0,
schedulingType: null,
length: 15,
//TODO: What is the purpose of periodStartDate and periodEndDate? Test these?
periodStartDate: new Date("2022-01-21T09:03:48.000Z"),
periodEndDate: new Date("2022-01-21T09:03:48.000Z"),
@ -150,170 +176,162 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
users,
};
});
logger.silly("TestData: Creating EventType", eventTypes);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const eventTypeMock = ({ where }) => {
return new Promise((resolve) => {
const eventType = eventTypesWithUsers.find((e) => e.id === where.id) as unknown as PrismaEventType & {
users: PrismaUser[];
};
resolve(eventType);
});
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.eventType.findUnique.mockImplementation(eventTypeMock);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.eventType.findUniqueOrThrow.mockImplementation(eventTypeMock);
logger.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
await addEventTypesToDb(eventTypesWithUsers);
}
async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[]) {
logger.silly("TestData: Creating Bookings", bookings);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.booking.findMany.mockImplementation((findManyArg) => {
// @ts-expect-error Prisma v5 breaks this
const where = findManyArg?.where || {};
return new Promise((resolve) => {
resolve(
// @ts-expect-error Prisma v5 breaks this
bookings
// We can improve this filter to support the entire where clause but that isn't necessary yet. So, handle what we know we pass to `findMany` and is needed
.filter((booking) => {
/**
* A user is considered busy within a given time period if there
* is a booking they own OR host. This function mocks some of the logic
* for each condition. For details see the following ticket:
* https://github.com/calcom/cal.com/issues/6374
*/
// ~~ FIRST CONDITION ensures that this booking is owned by this user
// and that the status is what we want
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const statusIn = where.OR[0].status?.in || [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const userIdIn = where.OR[0].userId?.in || [];
const firstConditionMatches =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
statusIn.includes(booking.status) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(booking.userId === where.OR[0].userId || userIdIn.includes(booking.userId));
// We return this booking if either condition is met
return firstConditionMatches;
})
.map((booking) => ({
uid: uuidv4(),
title: "Test Booking Title",
...booking,
eventType: eventTypes.find((eventType) => eventType.id === booking.eventTypeId),
})) as unknown as PrismaBooking[]
);
});
function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) {
prismock.bookingReference.createMany({
data: bookingReferences,
});
}
async function addWebhooks(webhooks: InputWebhook[]) {
prismaMock.webhook.findMany.mockResolvedValue(
// @ts-expect-error Prisma v5 breaks this
webhooks.map((webhook) => {
return {
...webhook,
payloadTemplate: null,
secret: null,
id: uuidv4(),
createdAt: new Date(),
userId: webhook.userId || null,
eventTypeId: webhook.eventTypeId || null,
teamId: webhook.teamId || null,
};
async function addBookingsToDb(
bookings: (Prisma.BookingCreateInput & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
references: any[];
})[]
) {
await prismock.booking.createMany({
data: bookings,
});
logger.silly(
"TestData: Booking as in DB",
JSON.stringify({
bookings: await prismock.booking.findMany({
include: {
references: true,
},
}),
})
);
}
function addUsers(users: InputUser[]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.user.findUniqueOrThrow.mockImplementation((findUniqueArgs) => {
return new Promise((resolve) => {
// @ts-expect-error Prisma v5 breaks this
resolve({
// @ts-expect-error Prisma v5 breaks this
email: `IntegrationTestUser${findUniqueArgs?.where.id}@example.com`,
} as unknown as PrismaUser);
});
async function addBookings(bookings: InputBooking[]) {
logger.silly("TestData: Creating Bookings", JSON.stringify(bookings));
const allBookings = [...bookings].map((booking) => {
if (booking.references) {
addBookingReferencesToDB(
booking.references.map((reference) => {
return {
...reference,
bookingId: booking.id,
};
})
);
}
return {
uid: uuidv4(),
workflowReminders: [],
references: [],
title: "Test Booking Title",
...booking,
};
});
prismaMock.user.findMany.mockResolvedValue(
// @ts-expect-error Prisma v5 breaks this
users.map((user) => {
return {
...user,
username: `IntegrationTestUser${user.id}`,
email: `IntegrationTestUser${user.id}@example.com`,
};
}) as unknown as PrismaUser[]
await addBookingsToDb(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
allBookings.map((booking) => {
const bookingCreate = booking;
if (booking.references) {
bookingCreate.references = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
createMany: {
data: booking.references,
},
};
}
return bookingCreate;
})
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function addWebhooksToDb(webhooks: any[]) {
await prismock.webhook.createMany({
data: webhooks,
});
}
async function addWebhooks(webhooks: InputWebhook[]) {
logger.silly("TestData: Creating Webhooks", webhooks);
await addWebhooksToDb(webhooks);
}
async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma.ScheduleCreateInput[] })[]) {
logger.silly("TestData: Creating Users", JSON.stringify(users));
await prismock.user.createMany({
data: users,
});
}
async function addUsers(users: InputUser[]) {
const prismaUsersCreate = users.map((user) => {
const newUser = user;
if (user.schedules) {
newUser.schedules = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
createMany: {
data: user.schedules.map((schedule) => {
return {
...schedule,
availability: {
createMany: {
data: schedule.availability,
},
},
};
}),
},
};
}
if (user.credentials) {
newUser.credentials = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
createMany: {
data: user.credentials,
},
};
}
return newUser;
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
await addUsersToDb(prismaUsersCreate);
}
export async function createBookingScenario(data: ScenarioData) {
logger.silly("TestData: Creating Scenario", data);
addUsers(data.users);
logger.silly("TestData: Creating Scenario", JSON.stringify({ data }));
await addUsers(data.users);
const eventType = addEventTypes(data.eventTypes, data.users);
const eventType = await addEventTypes(data.eventTypes, data.users);
if (data.apps) {
// @ts-expect-error Prisma v5 breaks this
prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const appMock = ({ where: { slug: whereSlug } }) => {
return new Promise((resolve) => {
if (!data.apps) {
resolve(null);
return;
}
const foundApp = data.apps.find(({ slug }) => slug == whereSlug);
//TODO: Pass just the app name in data.apps and maintain apps in a separate object or load them dyamically
resolve(
({
...foundApp,
...(foundApp?.slug ? TestData.apps[foundApp.slug as keyof typeof TestData.apps] || {} : {}),
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
categories: [],
} as PrismaApp) || null
);
});
};
// FIXME: How do we know which app to return?
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.app.findUnique.mockImplementation(appMock);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.app.findFirst.mockImplementation(appMock);
prismock.app.createMany({
data: data.apps,
});
}
data.bookings = data.bookings || [];
allowSuccessfulBookingCreation();
addBookings(data.bookings, data.eventTypes);
// allowSuccessfulBookingCreation();
await addBookings(data.bookings);
// mockBusyCalendarTimes([]);
addWebhooks(data.webhooks || []);
await addWebhooks(data.webhooks || []);
// addPaymentMock();
return {
eventType,
};
}
// async function addPaymentsToDb(payments: Prisma.PaymentCreateInput[]) {
// await prismaMock.payment.createMany({
// data: payments,
// });
// }
/**
* This fn indents to /ally compute day, month, year for the purpose of testing.
* We are not using DayJS because that's actually being tested by this code.
@ -372,9 +390,11 @@ export function getMockedCredential({
scope: string;
};
}) {
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
return {
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
appId: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].slug,
type: app.type,
appId: app.slug,
app: app,
key: {
expiry_date: Date.now() + 1000000,
token_type: "Bearer",
@ -399,7 +419,16 @@ export function getZoomAppCredential() {
return getMockedCredential({
metadataLookupKey: "zoomvideo",
key: {
scope: "meeting:writed",
scope: "meeting:write",
},
});
}
export function getStripeAppCredential() {
return getMockedCredential({
metadataLookupKey: "stripepayment",
key: {
scope: "read_write",
},
});
}
@ -466,6 +495,7 @@ export const TestData = {
apps: {
"google-calendar": {
slug: "google-calendar",
enabled: true,
dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
@ -479,6 +509,38 @@ export const TestData = {
"daily-video": {
slug: "daily-video",
dirName: "whatever",
enabled: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
expiry_date: Infinity,
api_key: "",
scale_plan: "false",
client_id: "client_id",
client_secret: "client_secret",
redirect_uris: ["http://localhost:3000/auth/callback"],
},
},
zoomvideo: {
slug: "zoom",
enabled: true,
dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
expiry_date: Infinity,
api_key: "",
scale_plan: "false",
client_id: "client_id",
client_secret: "client_secret",
redirect_uris: ["http://localhost:3000/auth/callback"],
},
},
"stripe-payment": {
//TODO: Read from appStoreMeta
slug: "stripe",
enabled: true,
dirName: "stripepayment",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
@ -493,14 +555,6 @@ export const TestData = {
},
};
function allowSuccessfulBookingCreation() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.booking.create.mockImplementation(function (booking) {
return booking.data;
});
}
export class MockError extends Error {
constructor(message: string) {
super(message);
@ -540,6 +594,7 @@ export function getScenarioData({
usersApartFromOrganizer = [],
apps = [],
webhooks,
bookings,
}: // hosts = [],
{
organizer: ReturnType<typeof getOrganizer>;
@ -547,6 +602,7 @@ export function getScenarioData({
apps: ScenarioData["apps"];
usersApartFromOrganizer?: ScenarioData["users"];
webhooks?: ScenarioData["webhooks"];
bookings?: ScenarioData["bookings"];
// hosts?: ScenarioData["hosts"];
}) {
const users = [organizer, ...usersApartFromOrganizer];
@ -561,22 +617,28 @@ export function getScenarioData({
});
return {
// hosts: [...hosts],
eventTypes: [...eventTypes],
eventTypes: eventTypes.map((eventType, index) => {
return {
...eventType,
title: `Test Event Type - ${index + 1}`,
description: `It's a test event type - ${index + 1}`,
};
}),
users,
apps: [...apps],
webhooks,
bookings: bookings || [],
};
}
export function mockEnableEmailFeature() {
// @ts-expect-error Prisma v5 breaks this
prismaMock.feature.findMany.mockResolvedValue([
{
export function enableEmailFeature() {
prismock.feature.create({
data: {
slug: "emails",
// It's a kill switch
enabled: false,
type: "KILL_SWITCH",
},
]);
});
}
export function mockNoTranslations() {
@ -589,20 +651,74 @@ export function mockNoTranslations() {
});
}
export function mockCalendarToHaveNoBusySlots(metadataLookupKey: keyof typeof appStoreMetadata) {
export function mockCalendarToHaveNoBusySlots(
metadataLookupKey: keyof typeof appStoreMetadata,
calendarData?: {
create: {
uid: string;
};
update?: {
uid: string;
};
}
) {
const appStoreLookupKey = metadataLookupKey;
const normalizedCalendarData = calendarData || {
create: {
uid: "MOCK_ID",
},
update: {
uid: "UPDATED_MOCK_ID",
},
};
logger.silly(`Mocking ${appStoreLookupKey} on appStoreMock`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createEventCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateEventCalls: any[] = [];
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
CalendarService: function MockCalendarService() {
return {
createEvent: () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
const [calEvent, credentialId] = rest;
logger.silly(
"mockCalendarToHaveNoBusySlots.createEvent",
JSON.stringify({ calEvent, credentialId })
);
createEventCalls.push(rest);
return Promise.resolve({
type: "daily_video",
id: "dailyEventName",
password: "dailyvideopass",
url: "http://dailyvideo.example.com",
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
id: normalizedCalendarData.create.uid,
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
const [uid, event, externalCalendarId] = rest;
logger.silly(
"mockCalendarToHaveNoBusySlots.updateEvent",
JSON.stringify({ uid, event, externalCalendarId })
);
// eslint-disable-next-line prefer-rest-params
updateEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: normalizedCalendarData.update!.uid!,
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
});
},
getAvailability: (): Promise<EventBusyDate[]> => {
@ -614,16 +730,37 @@ export function mockCalendarToHaveNoBusySlots(metadataLookupKey: keyof typeof ap
},
},
});
return {
createEventCalls,
updateEventCalls,
};
}
export function mockSuccessfulVideoMeetingCreation({
metadataLookupKey,
appStoreLookupKey,
videoMeetingData,
}: {
metadataLookupKey: string;
appStoreLookupKey?: string;
videoMeetingData?: {
password: string;
id: string;
url: string;
};
}) {
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
videoMeetingData = videoMeetingData || {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-${metadataLookupKey}.example.com`,
};
logger.silly(
"mockSuccessfulVideoMeetingCreation",
JSON.stringify({ metadataLookupKey, appStoreLookupKey })
);
const createMeetingCalls: any[] = [];
const updateMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
@ -633,12 +770,31 @@ export function mockSuccessfulVideoMeetingCreation({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
VideoApiAdapter: () => ({
createMeeting: () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeeting: (...rest: any[]) => {
createMeetingCalls.push(rest);
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-${metadataLookupKey}.example.com`,
...videoMeetingData,
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeeting: async (...rest: any[]) => {
const [bookingRef, calEvent] = rest;
updateMeetingCalls.push(rest);
if (!bookingRef.type) {
throw new Error("bookingRef.type is not defined");
}
if (!calEvent.organizer) {
throw new Error("calEvent.organizer is not defined");
}
logger.silly(
"mockSuccessfulVideoMeetingCreation.updateMeeting",
JSON.stringify({ bookingRef, calEvent })
);
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
}),
@ -646,6 +802,37 @@ export function mockSuccessfulVideoMeetingCreation({
});
});
});
return {
createMeetingCalls,
updateMeetingCalls,
};
}
export function mockPaymentApp({
metadataLookupKey,
appStoreLookupKey,
}: {
metadataLookupKey: string;
appStoreLookupKey?: string;
}) {
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
const { paymentUid, externalId, MockPaymentService } = getMockPaymentService();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
return new Promise((resolve) => {
resolve({
lib: {
PaymentService: MockPaymentService,
},
});
});
});
return {
paymentUid,
externalId,
};
}
export function mockErrorOnVideoMeetingCreation({
@ -675,39 +862,6 @@ export function mockErrorOnVideoMeetingCreation({
});
}
export function expectWebhookToHaveBeenCalledWith(
subscriberUrl: string,
data: {
triggerEvent: WebhookTriggerEvents;
payload: { metadata: Record<string, unknown>; responses: Record<string, unknown> };
}
) {
const fetchCalls = fetchMock.mock.calls;
const webhookFetchCall = fetchCalls.find((call) => call[0] === subscriberUrl);
if (!webhookFetchCall) {
throw new Error(`Webhook not called with ${subscriberUrl}`);
}
expect(webhookFetchCall[0]).toBe(subscriberUrl);
const body = webhookFetchCall[1]?.body;
const parsedBody = JSON.parse((body as string) || "{}");
console.log({ payload: parsedBody.payload });
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
: parsedBody.payload.metadata.videoCallUrl;
expect(parsedBody.payload.metadata).toContain(data.payload.metadata);
expect(parsedBody.payload.responses).toEqual(data.payload.responses);
}
export function expectWorkflowToBeTriggered() {
// TODO: Implement this.
}
export function expectBookingToBeInDatabase(booking: Partial<Prisma.BookingCreateInput>) {
const createBookingCalledWithArgs = prismaMock.booking.create.mock.calls[0];
expect(createBookingCalledWithArgs[0].data).toEqual(expect.objectContaining(booking));
}
export function getBooker({ name, email }: { name: string; email: string }) {
return {
name,
@ -715,40 +869,28 @@ export function getBooker({ name, email }: { name: string; email: string }) {
};
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toHaveEmail(expectedEmail: { htmlToContain?: string; to: string }): R;
}
}
export function getMockedStripePaymentEvent({ paymentIntentId }: { paymentIntentId: string }) {
return {
id: null,
data: {
object: {
id: paymentIntentId,
},
},
} as unknown as Stripe.Event;
}
expect.extend({
toHaveEmail(
testEmail: ReturnType<Fixtures["emails"]["get"]>[number],
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
to: string;
export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { externalId: string }) {
let webhookResponse = null;
try {
await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId }));
} catch (e) {
if (!(e instanceof HttpError)) {
logger.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
} else {
logger.error("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
}
) {
let isHtmlContained = true;
let isToAddressExpected = true;
if (expectedEmail.htmlToContain) {
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
}
isToAddressExpected = expectedEmail.to === testEmail.to;
return {
pass: isHtmlContained && isToAddressExpected,
message: () => {
if (!isHtmlContained) {
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
}
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't contained in ${testEmail.to}`;
},
};
},
});
webhookResponse = e as HttpError;
}
return { webhookResponse };
}

View File

@ -0,0 +1,481 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { Booking, BookingReference } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client";
import { expect } from "vitest";
import "vitest-fetch-mock";
import logger from "@calcom/lib/logger";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
import type { InputEventType } from "./bookingScenario";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toHaveEmail(expectedEmail: { htmlToContain?: string; to: string }, to: string): R;
}
}
}
expect.extend({
toHaveEmail(
emails: Fixtures["emails"],
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
to: string;
},
to: string
) {
const testEmail = emails.get().find((email) => email.to.includes(to));
if (!testEmail) {
return {
pass: false,
message: () => `No email sent to ${to}`,
};
}
let isHtmlContained = true;
let isToAddressExpected = true;
if (expectedEmail.htmlToContain) {
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
}
isToAddressExpected = expectedEmail.to === testEmail.to;
return {
pass: isHtmlContained && isToAddressExpected,
message: () => {
if (!isHtmlContained) {
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
}
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't equal to ${testEmail.to}`;
},
};
},
});
export function expectWebhookToHaveBeenCalledWith(
subscriberUrl: string,
data: {
triggerEvent: WebhookTriggerEvents;
payload: Record<string, unknown> | null;
}
) {
const fetchCalls = fetchMock.mock.calls;
const webhooksToSubscriberUrl = fetchCalls.filter((call) => {
return call[0] === subscriberUrl;
});
logger.silly("Scanning fetchCalls for webhook", fetchCalls);
const webhookFetchCall = webhooksToSubscriberUrl.find((call) => {
const body = call[1]?.body;
const parsedBody = JSON.parse((body as string) || "{}");
return parsedBody.triggerEvent === data.triggerEvent;
});
if (!webhookFetchCall) {
throw new Error(
`Webhook not sent to ${subscriberUrl} for ${data.triggerEvent}. All webhooks: ${JSON.stringify(
webhooksToSubscriberUrl
)}`
);
}
expect(webhookFetchCall[0]).toBe(subscriberUrl);
const body = webhookFetchCall[1]?.body;
const parsedBody = JSON.parse((body as string) || "{}");
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
if (parsedBody.payload.metadata?.videoCallUrl) {
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
: parsedBody.payload.metadata.videoCallUrl;
}
if (data.payload) {
if (data.payload.metadata !== undefined) {
expect(parsedBody.payload.metadata).toEqual(expect.objectContaining(data.payload.metadata));
}
if (data.payload.responses !== undefined)
expect(parsedBody.payload.responses).toEqual(expect.objectContaining(data.payload.responses));
const { responses: _1, metadata: _2, ...remainingPayload } = data.payload;
expect(parsedBody.payload).toEqual(expect.objectContaining(remainingPayload));
}
}
export function expectWorkflowToBeTriggered() {
// TODO: Implement this.
}
export async function expectBookingToBeInDatabase(
booking: Partial<Booking> & Pick<Booking, "uid"> & { references?: Partial<BookingReference>[] }
) {
const actualBooking = await prismaMock.booking.findUnique({
where: {
uid: booking.uid,
},
include: {
references: true,
},
});
const { references, ...remainingBooking } = booking;
expect(actualBooking).toEqual(expect.objectContaining(remainingBooking));
expect(actualBooking?.references).toEqual(
expect.arrayContaining((references || []).map((reference) => expect.objectContaining(reference)))
);
}
export function expectSuccessfulBookingCreationEmails({
emails,
organizer,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
to: `${organizer.email}`,
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
to: `${booker.name} <${booker.email}>`,
},
`${booker.name} <${booker.email}>`
);
}
export function expectSuccessfulBookingRescheduledEmails({
emails,
organizer,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
to: `${organizer.email}`,
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
to: `${booker.name} <${booker.email}>`,
},
`${booker.name} <${booker.email}>`
);
}
export function expectAwaitingPaymentEmails({
emails,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>awaiting_payment_subject</title>",
to: `${booker.name} <${booker.email}>`,
},
`${booker.email}`
);
}
export function expectBookingRequestedEmails({
emails,
organizer,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_awaiting_approval_subject</title>",
to: `${organizer.email}`,
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>booking_submitted_subject</title>",
to: `${booker.email}`,
},
`${booker.email}`
);
}
export function expectBookingRequestedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paidEvent,
eventType,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
eventType: InputEventType;
}) {
// There is an inconsistency in the way we send the data to the webhook for paid events and unpaid events. Fix that and then remove this if statement.
if (!paidEvent) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_REQUESTED",
payload: {
eventTitle: eventType.title,
eventDescription: eventType.description,
metadata: {
// In a Pending Booking Request, we don't send the video call url
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
} else {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_REQUESTED",
payload: {
eventTitle: eventType.title,
eventDescription: eventType.description,
metadata: {
// In a Pending Booking Request, we don't send the video call url
},
responses: {
name: { label: "name", value: booker.name },
email: { label: "email", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
}
export function expectBookingCreatedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paidEvent,
videoCallUrl,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
videoCallUrl?: string;
}) {
if (!paidEvent) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_CREATED",
payload: {
metadata: {
...(videoCallUrl ? { videoCallUrl } : null),
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
} else {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_CREATED",
payload: {
// FIXME: File this bug and link ticket here. This is a bug in the code. metadata must be sent here like other BOOKING_CREATED webhook
metadata: null,
responses: {
name: { label: "name", value: booker.name },
email: { label: "email", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
}
export function expectBookingRescheduledWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
videoCallUrl,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
videoCallUrl?: string;
}) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
metadata: {
...(videoCallUrl ? { videoCallUrl } : null),
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
export function expectBookingPaymentIntiatedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paymentId,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paymentId: number;
}) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_PAYMENT_INITIATED",
payload: {
paymentId: paymentId,
metadata: {
// In a Pending Booking Request, we don't send the video call url
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
export function expectSuccessfulCalendarEventCreationInCalendar(
calendarMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEventCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEventCalls: any[];
},
expected: {
externalCalendarId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
uid: string;
}
) {
expect(calendarMock.createEventCalls.length).toBe(1);
const call = calendarMock.createEventCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulCalendarEventUpdationInCalendar(
calendarMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEventCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEventCalls: any[];
},
expected: {
externalCalendarId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
uid: string;
}
) {
expect(calendarMock.updateEventCalls.length).toBe(1);
const call = calendarMock.updateEventCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulVideoMeetingCreationInCalendar(
videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeetingCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeetingCalls: any[];
},
expected: {
externalCalendarId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
uid: string;
}
) {
expect(videoMock.createMeetingCalls.length).toBe(1);
const call = videoMock.createMeetingCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulVideoMeetingUpdationInCalendar(
videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeetingCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeetingCalls: any[];
},
expected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bookingRef: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
}
) {
expect(videoMock.updateMeetingCalls.length).toBe(1);
const call = videoMock.updateMeetingCalls[0];
const bookingRef = call[0];
const calendarEvent = call[1];
expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef));
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
}

View File

@ -88,6 +88,7 @@
"lint-staged": "^12.5.0",
"mailhog": "^4.16.0",
"prettier": "^2.8.6",
"prismock": "^1.21.1",
"tsc-absolute": "^1.0.0",
"typescript": "^4.9.4",
"vitest": "^0.34.3",

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { afterEach, expect, test, vi } from "vitest";

View File

@ -260,6 +260,9 @@ export const createEvent = async (
return undefined;
})
: undefined;
if (!creationResult) {
logger.silly("createEvent failed", { success, uid, creationResult, originalEvent: calEvent, calError });
}
return {
appName: credential.appId || "",

View File

@ -80,6 +80,7 @@ export default class EventManager {
* @param user
*/
constructor(user: EventManagerUser) {
logger.silly("Initializing EventManager", JSON.stringify({ user }));
const appCredentials = getApps(user.credentials, true).flatMap((app) =>
app.credentials.map((creds) => ({ ...creds, appName: app.name }))
);
@ -312,7 +313,6 @@ export default class EventManager {
},
});
}
return {
results,
referencesToCreate: [...booking.references],
@ -361,6 +361,7 @@ export default class EventManager {
[] as DestinationCalendar[]
);
for (const destination of destinationCalendars) {
logger.silly("Creating Calendar event", JSON.stringify({ destination }));
if (destination.credentialId) {
let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
if (!credential) {
@ -400,13 +401,21 @@ export default class EventManager {
}
}
} else {
logger.silly(
"No destination Calendar found, falling back to first connected calendar",
JSON.stringify({
calendarCredentials: this.calendarCredentials,
})
);
/**
* Not ideal but, if we don't find a destination calendar,
* fallback to the first connected calendar
* fallback to the first connected calendar - Shouldn't be a CRM calendar
*/
const [credential] = this.calendarCredentials.filter((cred) => cred.type === "calendar");
const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar"));
if (credential) {
const createdEvent = await createEvent(credential, event);
logger.silly("Created Calendar event", { createdEvent });
if (createdEvent) {
createdEvents.push(createdEvent);
}
@ -503,6 +512,7 @@ export default class EventManager {
): Promise<Array<EventResult<NewCalendarEventType>>> {
let calendarReference: PartialReference[] | undefined = undefined,
credential;
logger.silly("updateAllCalendarEvents", JSON.stringify({ event, booking, newBookingId }));
try {
// If a newBookingId is given, update that calendar event
let newBooking;
@ -564,6 +574,7 @@ export default class EventManager {
(credential) => credential.type === reference?.type
);
for (const credential of credentials) {
logger.silly("updateAllCalendarEvents-credential", JSON.stringify({ credentials }));
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
}
}

View File

@ -24,6 +24,7 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
for (const cred of withCredentials) {
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
logger.silly("getVideoAdapters", JSON.stringify({ appName, cred }));
const appImportFn = appStore[appName as keyof typeof appStore];
// Static Link Video Apps don't exist in packages/app-store/index.ts(it's manually maintained at the moment) and they aren't needed there anyway.
@ -38,6 +39,8 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
const videoAdapter = makeVideoApiAdapter(cred);
videoAdapters.push(videoAdapter);
} else {
log.error(`App ${appName} doesn't have 'lib.VideoApiAdapter' defined`);
}
}
@ -51,7 +54,7 @@ const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) =>
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
const uid: string = getUid(calEvent);
log.silly("videoClient:createMeeting", JSON.stringify({ credential, uid, calEvent }));
if (!credential || !credential.appId) {
throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
@ -116,21 +119,23 @@ const updateMeeting = async (
bookingRef: PartialReference | null
): Promise<EventResult<VideoCallData>> => {
const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
let success = true;
const [firstVideoAdapter] = await getVideoAdapters([credential]);
const updatedMeeting =
credential && bookingRef
? await firstVideoAdapter?.updateMeeting(bookingRef, calEvent).catch(async (e) => {
await sendBrokenIntegrationEmail(calEvent, "video");
log.error("updateMeeting failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
const canCallUpdateMeeting = !!(credential && bookingRef);
const updatedMeeting = canCallUpdateMeeting
? await firstVideoAdapter?.updateMeeting(bookingRef, calEvent).catch(async (e) => {
await sendBrokenIntegrationEmail(calEvent, "video");
log.error("updateMeeting failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
if (!updatedMeeting) {
log.error(
"updateMeeting failed",
JSON.stringify({ bookingRef, canCallUpdateMeeting, calEvent, credential })
);
return {
appName: credential.appId || "",
type: credential.type,

View File

@ -0,0 +1,36 @@
import type { z } from "zod";
import dayjs from "@calcom/dayjs";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
/**
* Determines if a booking actually requires confirmation(considering requiresConfirmationThreshold)
*/
export const doesBookingRequireConfirmation = ({
booking: { startTime, eventType },
}: {
booking: {
startTime: Date;
eventType: {
requiresConfirmation?: boolean;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
} | null;
};
}) => {
let requiresConfirmation = eventType?.requiresConfirmation;
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
if (rcThreshold) {
// Convert startTime to UTC and create Day.js instances
const startTimeUTC = dayjs(startTime).utc();
const currentTime = dayjs();
// Calculate the time difference in the specified unit
const timeDifference = startTimeUTC.diff(currentTime, rcThreshold.unit);
// Check if the time difference exceeds the threshold
if (timeDifference > rcThreshold.time) {
requiresConfirmation = false;
}
}
return requiresConfirmation;
};

View File

@ -0,0 +1,37 @@
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import type { CalendarEvent } from "@calcom/types/Calendar";
export const getWebhookPayloadForBooking = ({
booking,
evt,
}: {
booking: {
eventType: {
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
id: number;
} | null;
id: number;
eventTypeId: number | null;
userId: number | null;
};
evt: CalendarEvent;
}) => {
const eventTypeInfo: EventTypeInfo = {
eventTitle: booking.eventType?.title,
eventDescription: booking.eventType?.description,
requiresConfirmation: booking.eventType?.requiresConfirmation || null,
price: booking.eventType?.price,
currency: booking.eventType?.currency,
length: booking.eventType?.length,
};
return {
...evt,
...eventTypeInfo,
bookingId: booking.id,
};
};

View File

@ -0,0 +1,69 @@
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails";
import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
const log = logger.getChildLogger({ prefix: ["[handleConfirmation] book:user"] });
/**
* Supposed to do whatever is needed when a booking is requested.
*/
export async function handleBookingRequested(args: {
evt: CalendarEvent;
booking: {
eventType: {
currency: string;
description: string | null;
id: number;
length: number;
price: number;
requiresConfirmation: boolean;
title: string;
teamId?: number | null;
} | null;
eventTypeId: number | null;
userId: number | null;
id: number;
};
}) {
const { evt, booking } = args;
await sendOrganizerRequestEmail({ ...evt });
await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]);
try {
const subscribersBookingRequested = await getWebhooks({
userId: booking.userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
teamId: booking.eventType?.teamId,
});
const webhookPayload = getWebhookPayloadForBooking({
booking,
evt,
});
const promises = subscribersBookingRequested.map((sub) =>
sendPayload(
sub.secret,
WebhookTriggerEvents.BOOKING_REQUESTED,
new Date().toISOString(),
sub,
webhookPayload
).catch((e) => {
console.error(
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_REQUESTED}, URL: ${sub.subscriberUrl}`,
e
);
})
);
await Promise.all(promises);
} catch (error) {
// Silently fail
log.error(error);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1210,8 +1210,6 @@ async function handler(
teamId,
};
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
const handleSeats = async () => {
let resultBooking:
| (Partial<Booking> & {
@ -1219,6 +1217,7 @@ async function handler(
seatReferenceUid?: string;
paymentUid?: string;
message?: string;
paymentId?: number;
})
| null = null;
@ -1762,6 +1761,7 @@ async function handler(
resultBooking = { ...foundBooking };
resultBooking["message"] = "Payment required";
resultBooking["paymentUid"] = payment?.uid;
resultBooking["id"] = payment?.id;
} else {
resultBooking = { ...foundBooking };
}
@ -2082,7 +2082,9 @@ async function handler(
}
let videoCallUrl;
if (originalRescheduledBooking?.uid) {
log.silly("Rescheduling booking", originalRescheduledBooking.uid);
try {
// cancel workflow reminders from previous rescheduled booking
await cancelWorkflowReminders(originalRescheduledBooking.workflowReminders);
@ -2288,6 +2290,27 @@ async function handler(
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
}
const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
}
: undefined;
const webhookData = {
...evt,
...eventTypeInfo,
bookingId: booking?.id,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBody.metadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
};
if (bookingRequiresPayment) {
// Load credentials.app.categories
@ -2329,9 +2352,23 @@ async function handler(
fullName,
bookerEmail
);
const subscriberOptionsPaymentInitiated: GetSubscriberOptions = {
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
teamId,
};
await handleWebhookTrigger({
subscriberOptions: subscriberOptionsPaymentInitiated,
eventTrigger: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
webhookData: {
...webhookData,
paymentId: payment?.id,
},
});
req.statusCode = 201;
return { ...booking, message: "Payment required", paymentUid: payment?.uid };
return { ...booking, message: "Payment required", paymentUid: payment?.uid, paymentId: payment?.id };
}
loggerWithEventDetails.debug(`Booking ${organizerUser.username} completed`);
@ -2340,28 +2377,6 @@ async function handler(
videoCallUrl = booking.location;
}
const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
}
: undefined;
const webhookData = {
...evt,
...eventTypeInfo,
bookingId: booking?.id,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBody.metadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
};
if (isConfirmedByDefault) {
try {
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);

View File

@ -5,21 +5,19 @@ import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import EventManager from "@calcom/core/EventManager";
import dayjs from "@calcom/dayjs";
import { sendOrganizerRequestEmail, sendAttendeeRequestEmail } from "@calcom/emails";
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails";
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { getBooking } from "@calcom/lib/payment/getBooking";
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
const log = logger.getChildLogger({ prefix: ["[paymentWebhook]"] });
export const config = {
api: {
@ -27,109 +25,7 @@ export const config = {
},
};
async function getEventType(id: number) {
return prisma.eventType.findUnique({
where: {
id,
},
select: {
recurringEvent: true,
requiresConfirmation: true,
metadata: true,
},
});
}
async function getBooking(bookingId: number) {
const booking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
user: {
select: {
id: true,
username: true,
timeZone: true,
timeFormat: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const eventType = { ...eventTypeRaw, metadata: EventTypeMetaDataSchema.parse(eventTypeRaw?.metadata) };
const { user } = booking;
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
const t = await getTranslation(user.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: t, locale: user.locale ?? "en" },
id: user.id,
},
attendees: attendeesList,
uid: booking.uid,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
};
return {
booking,
user,
evt,
eventType,
};
}
async function handleStripePaymentSuccess(event: Stripe.Event) {
export async function handleStripePaymentSuccess(event: Stripe.Event) {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const payment = await prisma.payment.findFirst({
where: {
@ -140,8 +36,10 @@ async function handleStripePaymentSuccess(event: Stripe.Event) {
bookingId: true,
},
});
if (!payment?.bookingId) {
console.log(JSON.stringify(paymentIntent), JSON.stringify(payment));
log.error(JSON.stringify(paymentIntent), JSON.stringify(payment));
throw new HttpCode({ statusCode: 204, message: "Payment not found" });
}
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
@ -164,34 +62,16 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
paid: true,
};
const userWithCredentials = await prisma.user.findUnique({
where: {
id: user.id,
},
select: {
id: true,
username: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
credentials: { select: credentialForCalendarServiceSelect },
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
const requiresConfirmation = doesBookingRequireConfirmation({
booking: {
...booking,
eventType,
},
});
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
let requiresConfirmation = eventType?.requiresConfirmation;
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
if (rcThreshold) {
if (dayjs(dayjs(booking.startTime).utc().format()).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
requiresConfirmation = false;
}
}
if (!requiresConfirmation) {
const eventManager = new EventManager(userWithCredentials);
const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
bookingData.references = { create: scheduleResult.referencesToCreate };
bookingData.status = BookingStatus.ACCEPTED;
@ -218,7 +98,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
if (!requiresConfirmation) {
await handleConfirmation({
user: userWithCredentials,
user,
evt,
prisma,
bookingId: booking.id,

View File

@ -35,6 +35,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
{ value: WebhookTriggerEvents.BOOKING_CREATED, label: "booking_created" },
{ value: WebhookTriggerEvents.BOOKING_REJECTED, label: "booking_rejected" },
{ value: WebhookTriggerEvents.BOOKING_REQUESTED, label: "booking_requested" },
{ value: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, label: "booking_payment_initiated" },
{ value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" },
{ value: WebhookTriggerEvents.BOOKING_PAID, label: "booking_paid" },
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },

View File

@ -8,6 +8,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.BOOKING_PAID,
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
WebhookTriggerEvents.MEETING_ENDED,
WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_REJECTED,

View File

@ -0,0 +1,118 @@
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
async function getEventType(id: number) {
return prisma.eventType.findUnique({
where: {
id,
},
select: {
id: true,
recurringEvent: true,
requiresConfirmation: true,
metadata: true,
},
});
}
export async function getBooking(bookingId: number) {
const booking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
responses: true,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
user: {
select: {
id: true,
username: true,
timeZone: true,
credentials: { select: credentialForCalendarServiceSelect },
timeFormat: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const eventType = { ...eventTypeRaw, metadata: EventTypeMetaDataSchema.parse(eventTypeRaw?.metadata) };
const { user } = booking;
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
const t = await getTranslation(user.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
booking: booking,
bookingFields: booking.eventType?.bookingFields || null,
}),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: t, locale: user.locale ?? "en" },
id: user.id,
},
attendees: attendeesList,
location: booking.location,
uid: booking.uid,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
};
return {
booking,
user,
evt,
eventType,
};
}

View File

@ -2,114 +2,19 @@ import type { Prisma } from "@prisma/client";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { handleBookingRequested } from "@calcom/features/bookings/lib/handleBookingRequested";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { getBooking } from "@calcom/lib/payment/getBooking";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getTimeFormatStringFromUserTimeFormat } from "../timeFormat";
import logger from "../logger";
const log = logger.getChildLogger({ prefix: ["[handlePaymentSuccess]"] });
export async function handlePaymentSuccess(paymentId: number, bookingId: number) {
const booking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
responses: true,
user: {
select: {
id: true,
username: true,
credentials: { select: credentialForCalendarServiceSelect },
timeZone: true,
timeFormat: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
payment: {
select: {
amount: true,
currency: true,
paymentOption: true,
},
},
},
});
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const { user: userWithCredentials } = booking;
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
const { credentials, ...user } = userWithCredentials;
const t = await getTranslation(user.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
booking: booking,
bookingFields: booking.eventType?.bookingFields || null,
}),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: t, locale: user.locale ?? "en" },
},
attendees: attendeesList,
location: booking.location,
uid: booking.uid,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
paymentInfo: booking.payment?.[0] && {
amount: booking.payment[0].amount,
currency: booking.payment[0].currency,
paymentOption: booking.payment[0].paymentOption,
},
};
const { booking, user: userWithCredentials, evt, eventType } = await getBooking(bookingId);
if (booking.location) evt.location = booking.location;
@ -125,10 +30,16 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
bookingData.references = { create: scheduleResult.referencesToCreate };
}
if (eventTypeRaw?.requiresConfirmation) {
const requiresConfirmation = doesBookingRequireConfirmation({
booking: {
...booking,
eventType,
},
});
if (requiresConfirmation) {
delete bookingData.status;
}
const paymentUpdate = prisma.payment.update({
where: {
id: paymentId,
@ -146,16 +57,23 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
});
await prisma.$transaction([paymentUpdate, bookingUpdate]);
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
await handleConfirmation({
user: userWithCredentials,
evt,
prisma,
bookingId: booking.id,
booking,
paid: true,
});
if (!isConfirmed) {
if (!requiresConfirmation) {
await handleConfirmation({
user: userWithCredentials,
evt,
prisma,
bookingId: booking.id,
booking,
paid: true,
});
} else {
await handleBookingRequested({
evt,
booking,
});
log.debug(`handling booking request for eventId ${eventType.id}`);
}
} else {
await sendScheduledEmails({ ...evt });
}
@ -165,15 +83,3 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
message: `Booking with id '${booking.id}' was paid and confirmed.`,
});
}
async function getEventType(id: number) {
return prisma.eventType.findUnique({
where: {
id,
},
select: {
recurringEvent: true,
requiresConfirmation: true,
},
});
}

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_PAYMENT_INITIATED';

View File

@ -541,6 +541,7 @@ enum PaymentOption {
enum WebhookTriggerEvents {
BOOKING_CREATED
BOOKING_PAYMENT_INITIATED
BOOKING_PAID
BOOKING_RESCHEDULED
BOOKING_REQUESTED

View File

@ -1,18 +1,90 @@
import { PrismockClient } from "prismock";
import { beforeEach, vi } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
import type { PrismaClient } from "@calcom/prisma";
import logger from "@calcom/lib/logger";
import * as selects from "@calcom/prisma/selects";
vi.mock("@calcom/prisma", () => ({
default: prisma,
prisma,
availabilityUserSelect: vi.fn(),
userSelect: vi.fn(),
...selects,
}));
const handlePrismockBugs = () => {
const __updateBooking = prismock.booking.update;
const __findManyWebhook = prismock.webhook.findMany;
const __findManyBooking = prismock.booking.findMany;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismock.booking.update = (...rest: any[]) => {
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
// In booking flows, we encounter such scenario, so let's fix that here directly till it's fixed in prismock
if (rest[0].data.references?.createMany) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
rest[0].data.references.createMany = rest[0].data.references?.createMany.data;
logger.silly("Fixed Prismock bug");
}
if (rest[0].data.references?.create) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
rest[0].data.references.create = rest[0].data.references?.create.data;
logger.silly("Fixed Prismock bug-1");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return __updateBooking(...rest);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismock.webhook.findMany = (...rest: any[]) => {
// There is some bug in prismock where it can't handle complex where clauses
if (rest[0].where?.OR && rest[0].where.AND) {
rest[0].where = undefined;
logger.silly("Fixed Prismock bug-2");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return __findManyWebhook(...rest);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismock.booking.findMany = (...rest: any[]) => {
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
// In booking flows, we encounter such scenario, so let's fix that here directly till it's fixed in prismock
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const where = rest[0]?.where;
if (where?.OR) {
logger.silly("Fixed Prismock bug-3");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
where.OR.forEach((or: any) => {
if (or.startTime?.gte) {
or.startTime.gte = or.startTime.gte.toISOString ? or.startTime.gte.toISOString() : or.startTime.gte;
}
if (or.startTime?.lte) {
or.startTime.lte = or.startTime.lte.toISOString ? or.startTime.lte.toISOString() : or.startTime.lte;
}
if (or.endTime?.gte) {
or.endTime.lte = or.endTime.gte.toISOString ? or.endTime.gte.toISOString() : or.endTime.gte;
}
if (or.endTime?.lte) {
or.endTime.lte = or.endTime.lte.toISOString ? or.endTime.lte.toISOString() : or.endTime.lte;
}
});
}
return __findManyBooking(...rest);
};
};
beforeEach(() => {
mockReset(prisma);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismock.reset();
handlePrismockBugs();
});
const prisma = mockDeep<PrismaClient>();
const prismock = new PrismockClient();
const prisma = prismock;
export default prisma;

View File

@ -0,0 +1,18 @@
import { beforeEach, vi } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
import type { PrismaClient } from "@calcom/prisma";
vi.mock("@calcom/prisma", () => ({
default: prisma,
prisma,
availabilityUserSelect: vi.fn(),
userSelect: vi.fn(),
}));
beforeEach(() => {
mockReset(prisma);
});
const prisma = mockDeep<PrismaClient>();
export default prisma;

1673
yarn.lock

File diff suppressed because it is too large Load Diff