Files
calendar/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts
T
Benny JooGitHubbenny@cal.com <sldisek783@gmail.com>benny@cal.com <sldisek783@gmail.com>benny@cal.com <sldisek783@gmail.com>benny@cal.com <sldisek783@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
96bec9f7b9 refactor: Move repositories from @calcom/lib to @calcom/features domain folders (#27570)
* refactor: move repositories from lib to features domain folders

- Move HolidayRepository to features/holidays/repositories
- Move PrismaTrackingRepository to features/bookings/repositories
- Move PrismaBookingPaymentRepository to features/bookings/repositories
- Move PrismaRoutingFormResponseRepository to features/routing-forms/repositories
- Move PrismaAssignmentReasonRepository to features/assignment-reason/repositories
- Move VerificationTokenRepository to features/auth/repositories
- Move WorkspacePlatformRepository to features/workspace-platform/repositories
- Move DTO files to their respective feature domains
- Merge lib DestinationCalendarRepository into features version
- Merge lib SelectedCalendarRepository into features version
- Update all import paths across the codebase

This follows the vertical slice architecture pattern by organizing
repositories by domain rather than by technical layer.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: update VerificationTokenService import path to new location

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: update test file imports to use new repository locations

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* mv

* fix structure

* fix

* refactor: merge unit tests for SelectedCalendarRepository into single file

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-09 08:41:04 -03:00

124 lines
4.5 KiB
TypeScript

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/features/bookings/repositories/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,
seatReferenceUid: 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;
}
}