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:
12 files changed
+236
-41
No files matched your search
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user