feat: queue or cancel payment reminder flow (#24889)

Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-01-05 08:10:55 +00:00
1 parent acee514c42
commit 87cf3210dc
12 files changed
+236 -41

No files matched your search

+3
View File
@@ -261,6 +261,9 @@ EMAIL_SERVER_PORT=1025
## @see https://support.google.com/accounts/answer/185833
# EMAIL_SERVER_PASSWORD='<gmail_app_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`
@@ -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 && (
<>
<div
className={classNames(isRoundRobin && "relative mx-auto h-24 min-h-24 w-32 min-w-32")}>
className={classNames(isRoundRobin && "min-h-24 min-w-32 relative mx-auto h-24 w-32")}>
{isRoundRobin && bookingInfo.user && (
<Avatar
className="mx-auto flex items-center justify-center"
@@ -549,7 +554,7 @@ export default function Success(props: PageProps) {
(bookingInfo.status === BookingStatus.CANCELLED ||
bookingInfo.status === BookingStatus.REJECTED) && <h4>{paymentStatusMessage}</h4>}
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left sm:gap-x-0 rtl:text-right">
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
{(isCancelled || reschedule) && cancellationReason && (
<>
<div className="font-medium">
@@ -1084,7 +1089,7 @@ export default function Success(props: PageProps) {
</div>
{isGmail && !isFeedbackMode && (
<Alert
className="main -mb-20 mt-4 inline-block sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle ltr:text-left rtl:text-right"
className="main -mb-20 mt-4 inline-block ltr:text-left rtl:text-right sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
severity="warning"
message={
<div>
@@ -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 = {
@@ -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<void> {
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,
}
);
}
@@ -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 },
});
}
}
+4
View File
@@ -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<void>;
+2
View File
@@ -30,6 +30,8 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
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),
};
@@ -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<void> {
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;
}
}
@@ -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<string, any>;
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<BookingPaymentWithCredentials | null>;
createPaymentRecord(data: CreatePaymentData): Promise<any>;
createPaymentRecord(data: CreatePaymentData): Promise<Payment>;
findByIdForAwaitingPaymentEmail(id: number): Promise<PaymentForAwaitingEmail | null>;
}
@@ -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<PaymentForAwaitingEmail | null> {
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,
},
},
},
});
}
}
+1
View File
@@ -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;
+1
View File
@@ -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",