Files
calendar/packages/features/ee/payments/api/webhook.ts
T
MorganGitHubmorgan@cal.com <morgan@cal.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>hbjORbj
a73b804d48 refactor: Split EmailManager into focused service files (#24997)
* refactor: Split EmailManager into focused service files

- Created separate service files for different email categories:
  - auth-email-service.ts: Authentication and verification emails
  - organization-email-service.ts: Organization and team emails
  - billing-email-service.ts: Payment and credit-related emails
  - integration-email-service.ts: Integration and app-related emails
  - workflow-email-service.ts: Workflow and custom emails
  - recording-email-service.ts: Recording and transcript emails

- Refactored email-manager.ts to keep only core booking lifecycle functions
- Removed unused imports from email-manager.ts
- Updated index.ts to export from all new service files
- Updated all imports across the codebase to use package root (@calcom/emails)
- Fixed lint warnings in handleChildrenEventTypes.ts

This reduces the import cost of EmailManager by allowing consumers to import only the specific email services they need.

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: Update all imports to use direct service file paths

- Update 49 files to import directly from service files instead of barrel file
- Update packages/emails/index.ts to keep only email-manager and renderEmail exports
- Fix dynamic import in passwordResetRequest.ts
- Update renderEmail imports to use direct path
- Update test file to import from specific service module
- Fix ESLint warnings in modified files (unused variables, unused expressions)

This ensures consumers only import the specific email services they need,
reducing import cost by avoiding the barrel file pattern for service files.

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: Use default import for renderEmail

renderEmail is exported as a default export, not a named export.
Changed from 'import { renderEmail }' to 'import renderEmail'.

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: Update test mocks to use direct service file imports

- Update handleNoShowFee.test.ts to mock @calcom/emails/billing-email-service
- Update credit-service.test.ts to mock @calcom/emails/billing-email-service
- These tests were failing because they were mocking the barrel file @calcom/emails
  which no longer exports service functions after the refactoring

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: unit test spy

* fix: unit test mock

* address cubic comments

* fix: type error sendMonthlyDigestEmail

* remove barrel file and sendEmail unused task

* fixup! remove barrel file and sendEmail unused task

* fixup! fixup! remove barrel file and sendEmail unused task

* fix: integration test mock emails

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-11-11 14:21:10 +02:00

195 lines
7.0 KiB
TypeScript

import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import { handlePaymentSuccess } from "@calcom/app-store/_utils/payments/handlePaymentSuccess";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails/email-manager";
import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager";
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { getPlatformParams } from "@calcom/features/platform-oauth-client/get-platform-params";
import { PlatformOAuthClientRepository } from "@calcom/features/platform-oauth-client/platform-oauth-client.repository";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["[paymentWebhook]"] });
export const config = {
api: {
bodyParser: false,
},
};
export async function handleStripePaymentSuccess(event: Stripe.Event) {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const payment = await prisma.payment.findFirst({
where: {
externalId: paymentIntent.id,
},
select: {
id: true,
bookingId: true,
},
});
if (!payment?.bookingId) {
log.error("Stripe: Payment Not Found", safeStringify(paymentIntent), safeStringify(payment));
throw new HttpCode({ statusCode: 204, message: "Payment not found" });
}
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
await handlePaymentSuccess(payment.id, payment.bookingId);
}
const handleSetupSuccess = async (event: Stripe.Event) => {
const setupIntent = event.data.object as Stripe.SetupIntent;
const payment = await prisma.payment.findFirst({
where: {
externalId: setupIntent.id,
},
});
if (!payment?.data || !payment?.id) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
const { booking, user, evt, eventType } = await getBooking(payment.bookingId);
const bookingData: Prisma.BookingUpdateInput = {
paid: true,
};
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
const requiresConfirmation = doesBookingRequireConfirmation({
booking: {
...booking,
eventType,
},
});
const metadata = eventTypeMetaDataSchemaWithTypedApps.parse(eventType?.metadata);
const allCredentials = await getAllCredentialsIncludeServiceAccountKey(user, {
...booking.eventType,
metadata,
});
const platformOAuthClientRepository = new PlatformOAuthClientRepository();
const platformOAuthClient = user.isPlatformManaged
? await platformOAuthClientRepository.getByUserId(user.id)
: null;
const areCalendarEventsEnabled = platformOAuthClient?.areCalendarEventsEnabled ?? true;
const areEmailsEnabled = platformOAuthClient?.areEmailsEnabled ?? true;
if (!requiresConfirmation) {
const eventManager = new EventManager({ ...user, credentials: allCredentials }, metadata?.apps);
const scheduleResult = areCalendarEventsEnabled
? await eventManager.create(evt)
: placeholderCreatedEvent;
bookingData.references = { create: scheduleResult.referencesToCreate };
bookingData.status = BookingStatus.ACCEPTED;
}
await prisma.payment.update({
where: {
id: payment.id,
},
data: {
data: {
...(payment.data as Prisma.JsonObject),
setupIntent: setupIntent as unknown as Prisma.JsonObject,
},
booking: {
update: {
...bookingData,
},
},
},
});
// If the card information was already captured in the same customer. Delete the previous payment method
if (!requiresConfirmation) {
await handleConfirmation({
user: { ...user, credentials: allCredentials },
evt,
prisma,
bookingId: booking.id,
booking,
paid: true,
platformClientParams: platformOAuthClient ? getPlatformParams(platformOAuthClient) : undefined,
});
} else if (areEmailsEnabled) {
await sendOrganizerRequestEmail({ ...evt }, eventType.metadata);
await sendAttendeeRequestEmailAndSMS({ ...evt }, evt.attendees[0], eventType.metadata);
}
};
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"payment_intent.succeeded": handleStripePaymentSuccess,
"setup_intent.succeeded": handleSetupSuccess,
};
/**
* @deprecated
* We need to create a PaymentManager in `@calcom/lib`
* to prevent circular dependencies on App Store migration
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const sig = req.headers["stripe-signature"];
if (!sig) {
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
}
if (!process.env.STRIPE_WEBHOOK_SECRET) {
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
}
const requestBuffer = await buffer(req);
const payload = requestBuffer.toString();
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
// bypassing this validation for e2e tests
// in order to successfully confirm the payment
if (!event.account && !process.env.NEXT_PUBLIC_IS_E2E) {
throw new HttpCode({ statusCode: 202, message: "Incoming connected account" });
}
const handler = webhookHandlers[event.type];
if (handler) {
await handler(event);
} else {
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpCode({
statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`,
});
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}
// Return a response to acknowledge receipt of the event
res.json({ received: true });
}