diff --git a/.env.example b/.env.example index b04ed91301dd36..65c95204332a8a 100644 --- a/.env.example +++ b/.env.example @@ -261,6 +261,9 @@ EMAIL_SERVER_PORT=1025 ## @see https://support.google.com/accounts/answer/185833 # EMAIL_SERVER_PASSWORD='' +# queue or cancel payment reminder email/flow +AWAITING_PAYMENT_EMAIL_DELAY_MINUTES= + # Used for E2E for email testing # Set it to "1" if you need to email checks in E2E tests locally # Make sure to run mailhog container manually or with `yarn dx` diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index e7acd2d831970b..19ea066159810d 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -79,6 +79,7 @@ const querySchema = z.object({ seatReferenceUid: z.string().optional(), rating: z.string().optional(), noShow: stringToBoolean, + redirect_status: z.string().optional(), }); const useBrandColors = ({ @@ -120,6 +121,7 @@ export default function Success(props: PageProps) { seatReferenceUid, noShow, rating, + redirect_status, } = querySchema.parse(routerQuery); const attendeeTimeZone = bookingInfo?.attendees.find((attendee) => attendee.email === email)?.timeZone; @@ -148,7 +150,10 @@ export default function Success(props: PageProps) { const status = bookingInfo?.status; const reschedule = bookingInfo.status === BookingStatus.ACCEPTED; const cancellationReason = bookingInfo.cancellationReason || bookingInfo.rejectionReason; - const isAwaitingPayment = props.paymentStatus && !props.paymentStatus.success; + + const isPaymentSucceededFromRedirect = redirect_status === "succeeded"; + const isAwaitingPayment = + props.paymentStatus && !props.paymentStatus.success && !isPaymentSucceededFromRedirect; const attendees = bookingInfo?.attendees; @@ -499,7 +504,7 @@ export default function Success(props: PageProps) { {!isFeedbackMode && ( <>
+ className={classNames(isRoundRobin && "min-h-24 min-w-32 relative mx-auto h-24 w-32")}> {isRoundRobin && bookingInfo.user && ( {paymentStatusMessage}} -
+
{(isCancelled || reschedule) && cancellationReason && ( <>
@@ -1084,7 +1089,7 @@ export default function Success(props: PageProps) {
{isGmail && !isFeedbackMode && ( diff --git a/packages/app-store/_utils/payments/handlePaymentSuccess.ts b/packages/app-store/_utils/payments/handlePaymentSuccess.ts index 84d7848db3cc83..024056bb541891 100644 --- a/packages/app-store/_utils/payments/handlePaymentSuccess.ts +++ b/packages/app-store/_utils/payments/handlePaymentSuccess.ts @@ -8,6 +8,7 @@ import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirma import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking"; import { getPlatformParams } from "@calcom/features/platform-oauth-client/get-platform-params"; import { PlatformOAuthClientRepository } from "@calcom/features/platform-oauth-client/platform-oauth-client.repository"; +import tasker from "@calcom/features/tasker"; import { HttpError as HttpCode } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import type { TraceContext } from "@calcom/lib/tracing"; @@ -26,6 +27,16 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number, log.debug(`handling payment success for bookingId ${bookingId}`); const { booking, user: userWithCredentials, evt, eventType } = await getBooking(bookingId); + try { + await tasker.cancelWithReference(booking.uid, "sendAwaitingPaymentEmail"); + log.debug(`Cancelled scheduled awaiting payment email for booking ${bookingId}`); + } catch (error) { + log.warn( + { bookingId, error }, + `Failed to cancel awaiting payment task - email may still be sent but will be suppressed by task handler` + ); + } + if (booking.location) evt.location = booking.location; const bookingData: Prisma.BookingUpdateInput = { diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index cb1d1bc42ba6d7..7653e30a6a2424 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -2,13 +2,14 @@ import Stripe from "stripe"; import { v4 as uuidv4 } from "uuid"; import z from "zod"; -import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails/email-manager"; +import dayjs from "@calcom/dayjs"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import tasker from "@calcom/features/tasker"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; -import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; import prisma from "@calcom/prisma"; import type { Booking, Payment, PaymentOption, Prisma } from "@calcom/prisma/client"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; @@ -16,7 +17,6 @@ import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; import { paymentOptionEnum } from "../zod"; -import { createPaymentLink } from "./client"; import { retrieveOrCreateStripeCustomerByEmail } from "./customer"; import type { StripePaymentData, StripeSetupIntentData } from "./server"; @@ -382,29 +382,24 @@ export class PaymentService implements IAbstractPaymentService { uid: string; }, paymentData: Payment, - eventTypeMetadata?: EventTypeMetadata + _eventTypeMetadata?: EventTypeMetadata ): Promise { - const attendeesToEmail = event.attendeeSeatId - ? event.attendees.filter((attendee) => attendee.bookingSeat?.referenceUid === event.attendeeSeatId) - : event.attendees; + const delayMinutes = Number(process.env.AWAITING_PAYMENT_EMAIL_DELAY_MINUTES) || 15; + const scheduledEmailAt = dayjs().add(delayMinutes, "minutes").toDate(); - await sendAwaitingPaymentEmailAndSMS( + // we give the user 15 minutes to complete the payment + // if the payment is still not processed after 15 minutes, we send an awaiting payment email + await tasker.create( + "sendAwaitingPaymentEmail", { - ...event, - attendees: attendeesToEmail, - 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, - }, + bookingId: booking.id, + paymentId: paymentData.id, + attendeeSeatId: event.attendeeSeatId || null, }, - eventTypeMetadata + { + scheduledAt: scheduledEmailAt, + referenceUid: booking.uid, + } ); } diff --git a/packages/features/bookings/repositories/AttendeeRepository.ts b/packages/features/bookings/repositories/AttendeeRepository.ts index 8803f1c7e08d0d..7230f415525182 100644 --- a/packages/features/bookings/repositories/AttendeeRepository.ts +++ b/packages/features/bookings/repositories/AttendeeRepository.ts @@ -1,24 +1,39 @@ import type { PrismaClient } from "@calcom/prisma"; + import type { IAttendeeRepository } from "./IAttendeeRepository"; /** * Prisma-based implementation of IAttendeeRepository - * + * * This repository provides methods for looking up attendee information. */ export class AttendeeRepository implements IAttendeeRepository { - constructor(private prismaClient: PrismaClient) {} + constructor(private prismaClient: PrismaClient) {} - async findById(id: number): Promise<{ name: string; email: string } | null> { - const attendee = await this.prismaClient.attendee.findUnique({ - where: { id }, - select: { - name: true, - email: true, - }, - }); + async findById(id: number): Promise<{ name: string; email: string } | null> { + const attendee = await this.prismaClient.attendee.findUnique({ + where: { id }, + select: { + name: true, + email: true, + }, + }); - return attendee; - } -} + return attendee; + } + async findByBookingIdAndSeatReference( + bookingId: number, + seatReferenceUid: string + ): Promise<{ email: string }[]> { + return this.prismaClient.attendee.findMany({ + where: { + bookingId, + bookingSeat: { + referenceUid: seatReferenceUid, + }, + }, + select: { email: true }, + }); + } +} diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index d66d2650da31c9..c550230a02e96e 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -2,6 +2,7 @@ import type { z } from "zod"; import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; import type { BookingAuditTaskConsumerPayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask"; + export type TaskerTypes = "internal" | "redis"; type TaskPayloads = { sendWebhook: string; @@ -38,6 +39,9 @@ type TaskPayloads = { routedEventTypeId?: number | null; }; bookingAudit: BookingAuditTaskConsumerPayload; + sendAwaitingPaymentEmail: z.infer< + typeof import("./tasks/sendAwaitingPaymentEmail").sendAwaitingPaymentEmailPayloadSchema + >; }; export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string, taskId?: string) => Promise; diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index f60c5e15c18adf..89b4ac7a809ef8 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -30,6 +30,8 @@ const tasks: Record Promise> = { sendAnalyticsEvent: () => import("./analytics/sendAnalyticsEvent").then((module) => module.sendAnalyticsEvent), executeAIPhoneCall: () => import("./executeAIPhoneCall").then((module) => module.executeAIPhoneCall), + sendAwaitingPaymentEmail: () => + import("./sendAwaitingPaymentEmail").then((module) => module.sendAwaitingPaymentEmail), bookingAudit: () => import("./bookingAudit").then((module) => module.bookingAudit), }; diff --git a/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts b/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts new file mode 100644 index 00000000000000..08f6e79c035964 --- /dev/null +++ b/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client"; +import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails/email-manager"; +import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking"; +import { AttendeeRepository } from "@calcom/features/bookings/repositories/AttendeeRepository"; +import stripe from "@calcom/features/ee/payments/server/stripe"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { PrismaBookingPaymentRepository } from "@calcom/lib/server/repository/PrismaBookingPaymentRepository"; +import prisma from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["sendAwaitingPaymentEmail"] }); + +export const sendAwaitingPaymentEmailPayloadSchema = z.object({ + bookingId: z.number(), + paymentId: z.number(), + attendeeSeatId: z.string().nullable().optional(), +}); + +export async function sendAwaitingPaymentEmail(payload: string): Promise { + const paymentRepository = new PrismaBookingPaymentRepository(); + + try { + const { bookingId, paymentId, attendeeSeatId } = sendAwaitingPaymentEmailPayloadSchema.parse( + JSON.parse(payload) + ); + + log.debug(`Processing sendAwaitingPaymentEmail task for bookingId ${bookingId}, paymentId ${paymentId}`); + + const { booking, evt, eventType } = await getBooking(bookingId); + + const payment = await paymentRepository.findByIdForAwaitingPaymentEmail(paymentId); + + if (!payment) { + log.warn(`Payment ${paymentId} not found, skipping email`); + return; + } + + if (payment.success || booking.paid) { + log.debug( + `Payment ${paymentId} already succeeded or booking ${bookingId} already paid, skipping email` + ); + return; + } + + // verify stripe payment intent status directly in case of a delayed webhook scenario + if (payment.externalId && payment.app?.slug === "stripe") { + try { + const paymentIntent = await stripe.paymentIntents.retrieve(payment.externalId); + if (paymentIntent.status === "succeeded") { + log.debug( + `Stripe PaymentIntent ${payment.externalId} already succeeded, skipping email (webhook may be delayed)` + ); + return; + } + } catch (error) { + log.warn( + `Could not verify Stripe PaymentIntent status for ${payment.externalId}, continuing with email send`, + safeStringify(error) + ); + } + } + + // filter attendees if this is for a specific seat + let attendeesToEmail = evt.attendees; + if (attendeeSeatId) { + const attendeeRepository = new AttendeeRepository(prisma); + const seatAttendees = await attendeeRepository.findByBookingIdAndSeatReference( + bookingId, + attendeeSeatId + ); + const seatEmails = new Set(seatAttendees.map((a) => (a.email || "").toLowerCase())); + attendeesToEmail = evt.attendees.filter((attendee) => + seatEmails.has((attendee.email || "").toLowerCase()) + ); + + if (attendeesToEmail.length === 0) { + log.warn(`No attendees found for seat ${attendeeSeatId} in booking ${bookingId}, skipping email`); + return; + } + } + + /* + the reason why we use the first attendee's info for the payment link is because: + 1. for regular bookings: the first attendee in the array is typically the booker (the person who made the booking and is responsible for payment) + 2. for seated events: after filtering by attendeeSeatId, there's usually only one attendee anyway + */ + const primaryAttendee = attendeesToEmail[0]; + + if (!primaryAttendee) { + log.warn(`No attendees found for booking ${bookingId}, skipping email`); + return; + } + + await sendAwaitingPaymentEmailAndSMS( + { + ...evt, + attendees: attendeesToEmail, + paymentInfo: { + link: createPaymentLink({ + paymentUid: payment.uid, + name: primaryAttendee.name ?? null, + email: primaryAttendee.email ?? null, + date: booking.startTime.toISOString(), + }), + paymentOption: payment.paymentOption || "ON_BOOKING", + amount: payment.amount, + currency: payment.currency, + }, + }, + eventType.metadata + ); + + log.debug(`Successfully sent awaiting payment email for bookingId ${bookingId}`); + } catch (error) { + log.error( + `Failed to send awaiting payment email`, + safeStringify({ payload, error: error instanceof Error ? error.message : String(error) }) + ); + throw error; + } +} diff --git a/packages/lib/server/repository/BookingPaymentRepository.interface.ts b/packages/lib/server/repository/BookingPaymentRepository.interface.ts index e17cfb2689be86..509cf065151b7c 100644 --- a/packages/lib/server/repository/BookingPaymentRepository.interface.ts +++ b/packages/lib/server/repository/BookingPaymentRepository.interface.ts @@ -1,3 +1,4 @@ +import type { Payment, PaymentOption, Prisma } from "@calcom/prisma/client"; import type { JsonValue } from "@calcom/types/Json"; export interface BookingPaymentWithCredentials { @@ -24,7 +25,19 @@ export interface CreatePaymentData { refunded: boolean; success: boolean; currency: string; - data: Record; + data: Prisma.InputJsonValue; +} + +export interface PaymentForAwaitingEmail { + success: boolean; + externalId: string | null; + uid: string; + paymentOption: PaymentOption | null; + amount: number; + currency: string; + app: { + slug: string | null; + } | null; } export interface IBookingPaymentRepository { @@ -33,5 +46,7 @@ export interface IBookingPaymentRepository { credentialType: string ): Promise; - createPaymentRecord(data: CreatePaymentData): Promise; + createPaymentRecord(data: CreatePaymentData): Promise; + + findByIdForAwaitingPaymentEmail(id: number): Promise; } diff --git a/packages/lib/server/repository/PrismaBookingPaymentRepository.ts b/packages/lib/server/repository/PrismaBookingPaymentRepository.ts index d3a4b64eb67d37..32a7656e74d02d 100644 --- a/packages/lib/server/repository/PrismaBookingPaymentRepository.ts +++ b/packages/lib/server/repository/PrismaBookingPaymentRepository.ts @@ -5,6 +5,7 @@ import type { IBookingPaymentRepository, BookingPaymentWithCredentials, CreatePaymentData, + PaymentForAwaitingEmail, } from "./BookingPaymentRepository.interface"; export class PrismaBookingPaymentRepository implements IBookingPaymentRepository { @@ -45,4 +46,23 @@ export class PrismaBookingPaymentRepository implements IBookingPaymentRepository }); return createdPayment; } + + async findByIdForAwaitingPaymentEmail(id: number): Promise { + return await this.prismaClient.payment.findUnique({ + where: { id }, + select: { + success: true, + externalId: true, + uid: true, + paymentOption: true, + amount: true, + currency: true, + app: { + select: { + slug: true, + }, + }, + }, + }); + } } diff --git a/packages/types/environment.d.ts b/packages/types/environment.d.ts index 392e60ce675dfd..12ecfbb09fb42d 100644 --- a/packages/types/environment.d.ts +++ b/packages/types/environment.d.ts @@ -34,6 +34,7 @@ declare namespace NodeJS { readonly STRIPE_TEAM_PRODUCT_ID: `prod_${string}` | undefined; readonly PAYMENT_FEE_PERCENTAGE: number | undefined; readonly PAYMENT_FEE_FIXED: number | undefined; + readonly AWAITING_PAYMENT_EMAIL_DELAY_MINUTES: number | undefined; readonly NEXT_PUBLIC_INTERCOM_APP_ID: string | undefined; readonly NEXT_PUBLIC_POSTHOG_KEY: string | undefined; readonly NEXT_PUBLIC_POSTHOG_HOST: string | undefined; diff --git a/turbo.json b/turbo.json index ea030b915ee59f..6b9db636d6ef5c 100644 --- a/turbo.json +++ b/turbo.json @@ -4,6 +4,7 @@ "globalEnv": [ "ALLOWED_HOSTNAMES", "ANALYZE", + "AWAITING_PAYMENT_EMAIL_DELAY_MINUTES", "API_KEY_PREFIX", "ATOMS_E2E_API_URL", "ATOMS_E2E_OAUTH_CLIENT_ID",