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:
co-authored by
Hariom Balhara
parent
7e876e3457
commit
31270ca86b
@@ -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 || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user