Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
13 changes: 9 additions & 4 deletions apps/web/modules/bookings/views/bookings-single-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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>
Expand Down
11 changes: 11 additions & 0 deletions packages/app-store/_utils/payments/handlePaymentSuccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
Expand Down
39 changes: 17 additions & 22 deletions packages/app-store/stripepayment/lib/PaymentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ 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";
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";

Expand Down Expand Up @@ -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,
}
);
}

Expand Down
41 changes: 28 additions & 13 deletions packages/features/bookings/repositories/AttendeeRepository.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions packages/features/tasker/tasker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>;
Expand Down
2 changes: 2 additions & 0 deletions packages/features/tasker/tasks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};

Expand Down
123 changes: 123 additions & 0 deletions packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Payment, PaymentOption, Prisma } from "@calcom/prisma/client";
import type { JsonValue } from "@calcom/types/Json";

export interface BookingPaymentWithCredentials {
Expand All @@ -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 {
Expand All @@ -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>;
}
Loading
Loading