diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index e6f24c06a9410a..167098fa820760 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -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 { + async chargeCard(payment: Payment, bookingId: Booking["id"]): Promise { 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 || "", + }; + } } diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 360984f8204fae..67adbf554c9e12 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -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: { diff --git a/packages/trpc/server/routers/viewer/payments.tsx b/packages/trpc/server/routers/viewer/payments.tsx deleted file mode 100644 index d14e6fdc637691..00000000000000 --- a/packages/trpc/server/routers/viewer/payments.tsx +++ /dev/null @@ -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}`, - }); - } - }), -});