Files
calendar/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts
T
Hariom BalharaGitHubunknown <>hariom@cal.com <hariombalhara@gmail.com>hariom@cal.com <hariombalhara@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2802310268 feat: Add infrastructure for no-show audit integration (#27187)
* feat: Add infrastructure for no-show audit integration

- Add Prisma migrations for SYSTEM source and NO_SHOW_UPDATED audit action
- Add NoShowUpdatedAuditActionService with array-based attendeesNoShow schema
- Update BookingAuditActionServiceRegistry to include NO_SHOW_UPDATED
- Update BookingAuditTaskConsumer and BookingAuditViewerService
- Add AttendeeRepository methods for no-show queries
- Update IAuditActionService interface with values array support
- Update locales with no-show audit translation keys

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED from BookingAuditAction type to match Prisma schema

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update BookingAuditActionSchema to use NO_SHOW_UPDATED instead of HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Remove deprecated no-show audit services and unify to NoShowUpdatedAuditActionService

- Delete HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService
- Update BookingAuditProducerService.interface.ts to use queueNoShowUpdatedAudit
- Update BookingAuditTaskerProducerService.ts to use queueNoShowUpdatedAudit
- Update BookingEventHandlerService.ts to use onNoShowUpdated
- Add integration tests for NoShowUpdatedAuditActionService

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Add data migration step for deprecated no-show enum values

Addresses Cubic AI review feedback (confidence 9/10): The migration now
includes an UPDATE statement to convert existing records using the
deprecated 'host_no_show_updated' or 'attendee_no_show_updated' enum
values to the new unified 'no_show_updated' value before the type cast.
This prevents migration failures if any existing data uses the old values.

Co-Authored-By: unknown <>

* fix: Use CASE expression in USING clause for enum migration

Fixes PostgreSQL error 'unsafe use of new value of enum type' by avoiding
the ADD VALUE statement and instead using a CASE expression in the ALTER
TABLE USING clause to convert deprecated enum values (host_no_show_updated,
attendee_no_show_updated) to the new unified value (no_show_updated) during
the type conversion.

Co-Authored-By: unknown <>

* fix: Replace hardcoded color with semantic text-success class

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove color class completely from display fields

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 08:47:26 -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/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,
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;
}
}