feat: Write same metadata to Stripe for booking payment and no show fee payment (#23634)

* Add `findBookingById` to `BookingRepository`

* Create `generateMetadata` to Stripe `PaymentService`

* `create` method to use `generateMetadata`

* Remove unused trpc endpoint

* Pass booking id to chargeCard method

* Use generateMetadata in chargeCard method

* Remove unused stripeAppKeys

* Add error handling if booking doesn't contain attendee

* Rename  to

* Return attendees in a consistent order

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
Joe Au-Yeung
2025-09-15 14:20:05 +05:30
committed by GitHub
co-authored by Hariom Balhara
parent 7e876e3457
commit 31270ca86b
3 changed files with 94 additions and 243 deletions
@@ -8,6 +8,7 @@ import { getErrorFromUnknown } from "@calcom/lib/errors";
import { ErrorWithCode } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import prisma from "@calcom/prisma";
import type { Booking, Payment, PaymentOption, Prisma } from "@calcom/prisma/client";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
@@ -86,17 +87,16 @@ export class PaymentService implements IAbstractPaymentService {
automatic_payment_methods: {
enabled: true,
},
metadata: {
identifier: "cal.com",
metadata: this.generateMetadata({
bookingId,
calAccountId: userId,
calUsername: username,
userId,
username,
bookerName,
bookerEmail: bookerEmail,
bookerPhoneNumber: bookerPhoneNumber ?? null,
eventTitle: eventTitle || "",
bookingTitle: bookingTitle || "",
},
}),
};
const paymentIntent = await this.stripe.paymentIntents.create(params, {
@@ -217,20 +217,18 @@ export class PaymentService implements IAbstractPaymentService {
}
}
async chargeCard(payment: Payment, _bookingId?: Booking["id"]): Promise<Payment> {
async chargeCard(payment: Payment, bookingId: Booking["id"]): Promise<Payment> {
try {
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
},
where: {
slug: "stripe",
},
});
const bookingRepository = new BookingRepository(prisma);
const booking = await bookingRepository.findByIdIncludeUserAndAttendees(bookingId);
if (!booking) {
throw new Error(`Booking ${bookingId} not found`);
}
const paymentObject = payment.data as unknown as StripeSetupIntentData;
@@ -252,6 +250,10 @@ export class PaymentService implements IAbstractPaymentService {
throw new Error(`Stripe paymentMethod does not exist for setupIntent ${setupIntent.id}`);
}
if (!booking.attendees[0]) {
throw new Error(`Booking attendees are empty for setupIntent ${setupIntent.id}`);
}
const params: Stripe.PaymentIntentCreateParams = {
amount: payment.amount,
currency: payment.currency,
@@ -259,6 +261,16 @@ export class PaymentService implements IAbstractPaymentService {
payment_method: setupIntent.payment_method as string,
off_session: true,
confirm: true,
metadata: this.generateMetadata({
bookingId,
userId: booking.user?.id,
username: booking.user?.username,
bookerName: booking.attendees[0].name,
bookerEmail: booking.attendees[0].email,
bookerPhoneNumber: booking.attendees[0].phoneNumber ?? null,
eventTitle: booking.eventType?.title || null,
bookingTitle: booking.title,
}),
};
const paymentIntent = await this.stripe.paymentIntents.create(params, {
@@ -284,7 +296,7 @@ export class PaymentService implements IAbstractPaymentService {
return paymentData;
} catch (error) {
log.error("Stripe: Could not charge card for payment", _bookingId, safeStringify(error));
log.error("Stripe: Could not charge card for payment", bookingId, safeStringify(error));
const errorMappings = {
"your card was declined": "your_card_was_declined",
@@ -422,4 +434,36 @@ export class PaymentService implements IAbstractPaymentService {
isSetupAlready(): boolean {
return !!this.credentials;
}
private generateMetadata({
bookingId,
userId,
username,
bookerName,
bookerEmail,
bookerPhoneNumber,
eventTitle,
bookingTitle,
}: {
bookingId: number;
userId: number | null | undefined;
username: string | null | undefined;
bookerName: string;
bookerEmail: string;
bookerPhoneNumber: string | null;
eventTitle: string | null;
bookingTitle: string;
}) {
return {
identifier: "cal.com",
bookingId,
calAccountId: userId ?? null,
calUsername: username ?? null,
bookerName,
bookerEmail: bookerEmail,
bookerPhoneNumber: bookerPhoneNumber ?? null,
eventTitle: eventTitle || "",
bookingTitle: bookingTitle || "",
};
}
}
+35
View File
@@ -387,6 +387,41 @@ export class BookingRepository {
});
}
async findByIdIncludeUserAndAttendees(bookingId: number) {
return await this.prismaClient.booking.findUnique({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
eventType: {
select: {
title: true,
},
},
user: {
select: {
id: true,
username: true,
},
},
attendees: {
select: {
name: true,
email: true,
phoneNumber: true,
},
// Ascending order ensures that the first attendee in the list is the booker and others are guests
// See why it is important https://github.com/calcom/cal.com/pull/20935
// TODO: Ideally we should return `booker` property directly from the booking
orderBy: {
id: "asc",
},
},
},
});
}
async findBookingForMeetingPage({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
@@ -1,228 +0,0 @@
import { z } from "zod";
import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated";
import dayjs from "@calcom/dayjs";
import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows";
import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import { WebhookService } from "@calcom/features/webhooks/lib/WebhookService";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTranslation } from "@calcom/lib/server/i18n";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
import authedProcedure from "../../procedures/authedProcedure";
import { router } from "../../trpc";
export const paymentsRouter = router({
chargeCard: authedProcedure
.input(
z.object({
bookingId: z.number(),
})
)
.mutation(async ({ ctx, input }) => {
const { prisma } = ctx;
const booking = await prisma.booking.findUniqueOrThrow({
where: {
id: input.bookingId,
},
include: {
payment: true,
user: {
select: {
email: true,
locale: true,
name: true,
timeZone: true,
},
},
attendees: true,
eventType: {
select: {
schedulingType: true,
owner: {
select: {
hideBranding: true,
},
},
hosts: {
select: {
user: {
select: {
email: true,
destinationCalendar: {
select: {
primaryEmail: true,
},
},
},
},
},
},
customReplyToEmail: true,
slug: true,
metadata: true,
workflows: {
select: {
workflow: {
select: workflowSelect,
},
},
},
},
},
},
});
const payment = booking.payment[0];
if (payment.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `The no show fee for ${booking.id} has already been charged.`,
});
}
const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common");
const attendeesListPromises = [];
for (const attendee of booking.attendees) {
const attendeeObject = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
attendeesListPromises.push(attendeeObject);
}
const attendeesList = await Promise.all(attendeesListPromises);
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: ctx.user.id });
const workflows = await getAllWorkflowsFromEventType(booking.eventType, ctx.user.id);
const bookerUrl = await getBookerBaseUrl(orgId ?? null);
const evt: CalendarEvent = {
type: booking?.eventType?.slug as string,
title: booking.title,
startTime: dayjs(booking.startTime).format(),
endTime: dayjs(booking.endTime).format(),
organizer: {
email: booking.user?.email || "",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "",
language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" },
},
attendees: attendeesList,
paymentInfo: {
amount: payment.amount,
currency: payment.currency,
paymentOption: payment.paymentOption,
},
customReplyToEmail: booking.eventType?.customReplyToEmail,
bookerUrl,
};
const paymentCredential = await prisma.credential.findFirst({
where: {
userId: ctx.user.id,
appId: payment.appId,
},
include: {
app: true,
},
});
if (!paymentCredential?.app) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid payment credential" });
}
const key = paymentCredential?.app?.dirName;
const paymentAppImportFn = PaymentServiceMap[key as keyof typeof PaymentServiceMap];
if (!paymentAppImportFn) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Payment app not implemented" });
}
const paymentApp = await paymentAppImportFn;
if (!(paymentApp && "PaymentService" in paymentApp && paymentApp?.PaymentService)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Payment service not found" });
}
const PaymentService = paymentApp.PaymentService;
const paymentInstance = new PaymentService(paymentCredential);
try {
const paymentData = await paymentInstance.chargeCard(payment, booking.id);
if (!paymentData) {
throw new TRPCError({ code: "NOT_FOUND", message: `Could not generate payment data` });
}
const userId = ctx.user.id || 0;
const eventTypeId = booking.eventTypeId || 0;
const webhooks = await WebhookService.init({
userId,
eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
orgId,
});
await webhooks.sendPayload({
...evt,
bookingId: booking.id,
paymentId: payment.id,
paymentData,
eventTypeId,
});
await sendNoShowFeeChargedEmail(
attendeesListPromises[0],
evt,
booking?.eventType?.metadata as EventTypeMetadata
);
if (workflows.length > 0) {
try {
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
workflows,
smsReminderNumber: booking.smsReminderNumber,
calendarEvent: {
...evt,
bookerUrl,
eventType: {
...booking.eventType,
slug: booking.eventType?.slug || "",
},
},
hideBranding: !!booking.eventType?.owner?.hideBranding,
triggers: [WorkflowTriggerEvents.BOOKING_PAID],
});
} catch (error) {
// Silently fail
console.error(
"Error while scheduling workflow reminders for BOOKING_PAID:",
error instanceof Error ? error.message : String(error)
);
}
}
return paymentData;
} catch (err) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error processing payment with error ${err}`,
});
}
}),
});