fix: BOOKING_PAID webhook & workflow isn't triggering after payment successful (#26044)

* fix booking paid webhook

* Update handlePaymentSuccess to include updatedBooking status

* Fix videoCallUrl undefined error in BOOKING_PAID workflow trigger

- Import getVideoCallUrlFromCalEvent to properly compute meeting URL
- Use conditional metadata to avoid passing undefined videoCallUrl
- Matches original handleConfirmation.ts behavior

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Add missing schedulingType to calendarEventForWorkflow

- Added schedulingType field to match original handleConfirmation.ts behavior
- This field was present in the original implementation but missing in the refactor

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Fix paymentMetadata nullability to match original behavior

- Changed from ?? null to optional chaining (undefined) to match original handleConfirmation.ts
- This ensures webhook consumers using z.string().optional() schemas continue to work
- undefined values are omitted from JSON, while null values would be explicitly included

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Fix type errors: add schedulingType to getBooking and use null for metadata

- Added schedulingType to getBooking.ts eventType select to make it available
- Changed paymentMetadata to use ?? null instead of undefined for type compatibility
- The metadata type expects { [key: string]: string | number | boolean | null }

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* await

* add tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Anik Dhabal Babu
2026-01-07 15:00:54 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent f9635df629
commit 4585d7402d
4 changed files with 499 additions and 100 deletions
@@ -0,0 +1,362 @@
/**
* @vitest-environment node
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
BookingStatus,
WebhookTriggerEvents,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { handlePaymentSuccess } from "./handlePaymentSuccess";
// Mock dependencies
vi.mock("@calcom/features/bookings/lib/payment/getBooking");
vi.mock("@calcom/features/webhooks/lib/getWebhooks");
vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload");
vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType");
vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService");
vi.mock("@calcom/features/tasker");
vi.mock(
"@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials",
() => ({
getAllCredentialsIncludeServiceAccountKey: vi.fn().mockResolvedValue([]),
})
);
vi.mock("@calcom/features/users/repositories/UserRepository", () => ({
UserRepository: class {
constructor() {}
enrichUserWithItsProfile = vi.fn().mockResolvedValue({ profile: null });
},
}));
vi.mock("@calcom/prisma", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/prisma")>();
return {
...actual,
default: {
...actual.default,
payment: { update: vi.fn() },
booking: { update: vi.fn() },
$transaction: vi.fn(),
profile: { findMany: vi.fn().mockResolvedValue([]) },
credential: { findMany: vi.fn().mockResolvedValue([]) },
team: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
},
},
};
});
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId");
vi.mock("@calcom/features/ee/organizations/lib/getBookerUrlServer");
vi.mock("@calcom/lib/getTeamIdFromEventType");
vi.mock("@calcom/lib/CalEventParser");
vi.mock("@calcom/features/ee/billing/credit-service");
vi.mock(
"@calcom/features/platform-oauth-client/platform-oauth-client.repository",
() => ({
PlatformOAuthClientRepository: class {
constructor() {}
getByUserId = vi.fn().mockResolvedValue(null);
},
})
);
vi.mock("@calcom/features/platform-oauth-client/get-platform-params");
vi.mock("@calcom/features/bookings/lib/handleConfirmation");
vi.mock("@calcom/features/bookings/lib/handleBookingRequested");
vi.mock("@calcom/emails/email-manager");
vi.mock("@calcom/lib/tracing/factory");
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: vi.fn(() => ({
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import prisma from "@calcom/prisma";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import type { TraceContext } from "@calcom/lib/tracing";
import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
describe("handlePaymentSuccess", () => {
const mockBookingId = 1;
const mockPaymentId = 100;
const mockTraceContext: TraceContext = {
traceId: "test-trace-id",
spanId: "test-span-id",
operation: "test-operation",
};
const mockBooking = {
id: mockBookingId,
userId: 1,
eventTypeId: 10,
status: BookingStatus.PENDING,
uid: "test-booking-uid",
location: "Test Location",
smsReminderNumber: null,
userPrimaryEmail: "user@example.com",
title: "Test Event",
description: "Test Description",
customInputs: {},
startTime: new Date("2025-01-01T10:00:00Z"),
endTime: new Date("2025-01-01T10:30:00Z"),
attendees: [],
responses: {},
metadata: null,
destinationCalendar: null,
eventType: {
id: 10,
title: "Test Event",
description: "Test Description",
slug: "test-event",
price: 1000,
currency: "USD",
length: 30,
requiresConfirmation: false,
teamId: null,
parentId: null,
schedulingType: null,
hosts: [],
owner: { hideBranding: false },
metadata: {},
},
} as any;
const mockUser = {
id: 1,
email: "user@example.com",
name: "Test User",
username: "testuser",
timeZone: "UTC",
isPlatformManaged: false,
credentials: [],
locale: "en",
timeFormat: 12,
destinationCalendar: null,
} as any;
const mockEvt = {
type: "test-event",
title: "Test Event",
startTime: "2025-01-01T10:00:00Z",
endTime: "2025-01-01T10:30:00Z",
organizer: {
email: "user@example.com",
name: "Test User",
timeZone: "UTC",
language: { locale: "en", translate: vi.fn() },
id: 1,
},
attendees: [
{
email: "attendee@example.com",
name: "Attendee",
timeZone: "UTC",
language: { locale: "en", translate: vi.fn() },
},
],
uid: "test-booking-uid",
bookingId: mockBookingId,
} as any;
const mockEventType = {
id: 10,
metadata: {},
};
const mockWebhookSubscriber = {
id: "webhook-1",
subscriberUrl: "https://example.com/webhook",
secret: "webhook-secret",
appId: "app-id",
payloadTemplate: null,
eventTriggers: [WebhookTriggerEvents.BOOKING_PAID],
timeUnit: null,
time: null,
version: WebhookVersion.V_2021_10_20,
};
const mockWorkflow = {
id: 1,
name: "Test Workflow",
trigger: WorkflowTriggerEvents.BOOKING_PAID,
time: 24,
timeUnit: "HOUR" as const,
userId: 1,
teamId: null,
steps: [],
} as any;
beforeEach(() => {
vi.clearAllMocks();
// Mock getBooking
vi.mocked(getBooking).mockResolvedValue({
booking: mockBooking,
user: mockUser,
evt: mockEvt,
eventType: mockEventType,
});
// Mock prisma transaction
vi.mocked(prisma.$transaction).mockResolvedValue([
{ id: mockPaymentId, externalId: "ext-123" },
{ status: BookingStatus.ACCEPTED },
]);
// Mock webhook functions
vi.mocked(getWebhooks).mockResolvedValue([mockWebhookSubscriber]);
vi.mocked(sendPayload).mockResolvedValue({ ok: true, status: 200 });
// Mock workflow functions
vi.mocked(getAllWorkflowsFromEventType).mockResolvedValue([mockWorkflow]);
vi.mocked(
WorkflowService.scheduleWorkflowsFilteredByTriggerEvent
).mockResolvedValue(undefined);
// Mock utility functions
vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(undefined);
vi.mocked(getBookerBaseUrl).mockResolvedValue("https://cal.com");
vi.mocked(getTeamIdFromEventType).mockResolvedValue(null);
vi.mocked(getVideoCallUrlFromCalEvent).mockReturnValue("");
vi.mocked(CreditService.prototype.hasAvailableCredits).mockResolvedValue(
true
);
});
it("should trigger BOOKING_PAID webhooks with correct payload", async () => {
await expect(
handlePaymentSuccess(mockPaymentId, mockBookingId, mockTraceContext)
).rejects.toThrow(); // Function throws HttpCode 200 at the end
// Verify webhooks were fetched
expect(getWebhooks).toHaveBeenCalledWith({
userId: mockBooking.userId,
eventTypeId: mockBooking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
teamId: null,
orgId: undefined,
oAuthClientId: undefined,
});
// Verify webhook payload was sent
expect(sendPayload).toHaveBeenCalledWith(
mockWebhookSubscriber.secret,
WebhookTriggerEvents.BOOKING_PAID,
expect.any(String),
mockWebhookSubscriber,
expect.objectContaining({
bookingId: mockBookingId,
eventTypeId: mockBooking.eventType.id,
status: BookingStatus.ACCEPTED,
paymentId: mockPaymentId,
metadata: expect.objectContaining({
identifier: "cal.com",
bookingId: mockBookingId,
eventTypeId: mockBooking.eventType.id,
bookerEmail: mockEvt.attendees[0].email,
eventTitle: mockBooking.eventType.title,
externalId: "ext-123",
}),
})
);
});
it("should trigger BOOKING_PAID workflows with correct calendar event", async () => {
await expect(
handlePaymentSuccess(mockPaymentId, mockBookingId, mockTraceContext)
).rejects.toThrow(); // Function throws HttpCode 200 at the end
// Verify workflows were fetched
expect(getAllWorkflowsFromEventType).toHaveBeenCalledWith(
mockBooking.eventType,
mockBooking.userId
);
// Verify workflows were scheduled
expect(
WorkflowService.scheduleWorkflowsFilteredByTriggerEvent
).toHaveBeenCalledWith({
workflows: [mockWorkflow],
smsReminderNumber: null,
calendarEvent: expect.objectContaining({
type: mockEvt.type,
title: mockEvt.title,
startTime: mockEvt.startTime,
endTime: mockEvt.endTime,
eventType: expect.objectContaining({
slug: mockBooking.eventType.slug,
}),
bookerUrl: "https://cal.com",
}),
hideBranding: false,
triggers: [WorkflowTriggerEvents.BOOKING_PAID],
creditCheckFn: expect.any(Function),
});
});
it("should handle webhook errors gracefully without blocking", async () => {
const webhookError = new Error("Webhook failed");
vi.mocked(sendPayload).mockRejectedValueOnce(webhookError);
// Should not throw (webhook errors are caught)
await expect(
handlePaymentSuccess(mockPaymentId, mockBookingId, mockTraceContext)
).rejects.toThrow(); // Throws HttpCode 200 at the end
// Verify webhook was still attempted
expect(sendPayload).toHaveBeenCalled();
});
it("should handle workflow errors gracefully without blocking", async () => {
const workflowError = new Error("Workflow failed");
vi.mocked(
WorkflowService.scheduleWorkflowsFilteredByTriggerEvent
).mockRejectedValueOnce(workflowError);
// Should not throw (workflow errors are caught)
await expect(
handlePaymentSuccess(mockPaymentId, mockBookingId, mockTraceContext)
).rejects.toThrow(); // Throws HttpCode 200 at the end
// Verify workflow was still attempted
expect(
WorkflowService.scheduleWorkflowsFilteredByTriggerEvent
).toHaveBeenCalled();
});
it("should include payment metadata in webhook payload", async () => {
await expect(
handlePaymentSuccess(mockPaymentId, mockBookingId, mockTraceContext)
).rejects.toThrow(); // Function throws HttpCode 200 at the end
expect(sendPayload).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(String),
expect.any(Object),
expect.objectContaining({
metadata: expect.objectContaining({
identifier: "cal.com",
bookingId: mockBookingId,
eventTypeId: mockBooking.eventType.id,
bookerEmail: mockEvt.attendees[0].email,
eventTitle: mockBooking.eventType.title,
externalId: "ext-123",
}),
})
);
});
});
@@ -6,16 +6,27 @@ import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/book
import { handleBookingRequested } from "@calcom/features/bookings/lib/handleBookingRequested";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { getPlatformParams } from "@calcom/features/platform-oauth-client/get-platform-params";
import { PlatformOAuthClientRepository } from "@calcom/features/platform-oauth-client/platform-oauth-client.repository";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import tasker from "@calcom/features/tasker";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { TraceContext } from "@calcom/lib/tracing";
import { distributedTracing } from "@calcom/lib/tracing/factory";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import { BookingStatus, WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
const log = logger.getSubLogger({ prefix: ["[handlePaymentSuccess]"] });
@@ -84,6 +95,10 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number,
data: {
success: true,
},
select: {
id: true,
externalId: true,
},
});
const bookingUpdate = prisma.booking.update({
@@ -91,9 +106,127 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number,
id: booking.id,
},
data: bookingData,
select: {
status: true,
},
});
await prisma.$transaction([paymentUpdate, bookingUpdate]);
const [payment, updatedBooking] = await prisma.$transaction([paymentUpdate, bookingUpdate]);
const platformClientParams = platformOAuthClient ? getPlatformParams(platformOAuthClient) : undefined;
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: booking.eventType?.teamId ?? null },
parentId: booking.eventType?.parentId ?? null,
},
});
const triggerForUser = !teamId || (teamId && booking.eventType?.parentId);
const userId = triggerForUser ? booking.userId : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
const bookerUrl = await getBookerBaseUrl(orgId ?? null);
try {
// Get workflows for BOOKING_PAID trigger
const workflows = await getAllWorkflowsFromEventType(booking.eventType, booking.userId);
const paymentExternalId = payment.externalId;
const paymentMetadata = {
identifier: "cal.com",
bookingId,
eventTypeId: booking.eventType?.id ?? null,
bookerEmail: evt.attendees[0].email,
eventTitle: booking.eventType?.title ?? null,
externalId: paymentExternalId ?? null,
};
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,
};
const payload: EventPayloadType = {
...evt,
...eventTypeInfo,
bookingId,
eventTypeId: booking.eventType?.id,
status: updatedBooking.status,
smsReminderNumber: booking.smsReminderNumber || undefined,
paymentId: paymentId,
metadata: paymentMetadata,
...(platformClientParams ? platformClientParams : {}),
};
// Trigger BOOKING_PAID webhooks
const subscriberMeetingPaid = await getWebhooks({
userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
teamId: booking.eventType?.teamId,
orgId,
oAuthClientId: platformClientParams?.platformClientId,
});
const tracingLogger = distributedTracing.getTracingLogger(updatedTraceContext);
const bookingPaidSubscribers = subscriberMeetingPaid.map((sub) =>
sendPayload(
sub.secret,
WebhookTriggerEvents.BOOKING_PAID,
new Date().toISOString(),
sub,
payload
).catch((e) => {
tracingLogger.error(
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_PAID}, URL: ${sub.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
);
})
);
// Wait for webhook invocations to finish before returning
await Promise.all(bookingPaidSubscribers);
// Trigger BOOKING_PAID workflows
try {
const meetingUrl = getVideoCallUrlFromCalEvent(evt);
const calendarEventForWorkflow = {
...evt,
eventType: {
slug: booking.eventType?.slug || "",
schedulingType: booking.eventType?.schedulingType,
hosts:
booking.eventType?.hosts?.map((host) => ({
user: {
email: host.user.email,
destinationCalendar: host.user.destinationCalendar,
},
})) || [],
},
bookerUrl: bookerUrl,
metadata: meetingUrl ? { videoCallUrl: meetingUrl } : undefined,
};
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
workflows,
smsReminderNumber: booking.smsReminderNumber,
calendarEvent: calendarEventForWorkflow,
hideBranding: !!booking.eventType?.owner?.hideBranding,
triggers: [WorkflowTriggerEvents.BOOKING_PAID],
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} catch (error) {
log.error("Error while scheduling workflow reminders for booking paid", safeStringify(error));
}
} catch (error) {
log.error("Error while triggering BOOKING_PAID webhook", safeStringify(error));
}
if (!isConfirmed) {
if (!requiresConfirmation) {
await handleConfirmation({
@@ -103,7 +236,7 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number,
bookingId: booking.id,
booking,
paid: true,
platformClientParams: platformOAuthClient ? getPlatformParams(platformOAuthClient) : undefined,
platformClientParams,
traceContext: updatedTraceContext,
});
} else {
@@ -504,103 +504,6 @@ export async function handleConfirmation(args: {
);
await Promise.all(promises);
if (paid) {
let paymentExternalId: string | undefined;
const subscriberMeetingPaid = await getWebhooks({
userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
teamId: eventType?.teamId,
orgId,
oAuthClientId: platformClientParams?.platformClientId,
});
const bookingWithPayment = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
payment: {
select: {
id: true,
success: true,
externalId: true,
},
},
},
});
const successPayment = bookingWithPayment?.payment?.find((item) => item.success);
if (successPayment) {
paymentExternalId = successPayment.externalId;
}
const paymentMetadata = {
identifier: "cal.com",
bookingId,
eventTypeId: eventType?.id,
bookerEmail: evt.attendees[0].email,
eventTitle: eventType?.title,
externalId: paymentExternalId,
};
payload.paymentId = bookingWithPayment?.payment?.[0].id;
payload.metadata = {
...(paid ? paymentMetadata : {}),
};
const bookingPaidSubscribers = subscriberMeetingPaid.map((sub) =>
sendPayload(
sub.secret,
WebhookTriggerEvents.BOOKING_PAID,
new Date().toISOString(),
sub,
payload
).catch((e) => {
tracingLogger.error(
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_PAID}, URL: ${sub.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
);
})
);
// I don't need to await for this
Promise.all(bookingPaidSubscribers);
try {
const calendarEventForWorkflow = {
...evt,
eventType: {
slug: updatedBookings[0].eventType?.slug || "",
schedulingType: updatedBookings[0].eventType?.schedulingType,
hosts:
updatedBookings[0].eventType?.hosts?.map((host) => ({
user: {
email: host.user.email,
destinationCalendar: host.user.destinationCalendar,
},
})) || [],
},
bookerUrl: bookerUrl,
metadata: { videoCallUrl: meetingUrl },
};
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
workflows,
smsReminderNumber: booking.smsReminderNumber,
calendarEvent: calendarEventForWorkflow,
hideBranding: !!updatedBookings[0].eventType?.owner?.hideBranding,
triggers: [WorkflowTriggerEvents.BOOKING_PAID],
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} catch (error) {
tracingLogger.error(
"Error while scheduling workflow reminders for booking paid",
safeStringify(error)
);
}
}
} catch (error) {
// Silently fail
console.error(error);
@@ -72,6 +72,7 @@ export async function getBooking(bookingId: number) {
},
},
slug: true,
schedulingType: true,
workflows: {
select: {
workflow: {