Files
cal.diy/packages/app-store/stripepayment/lib/PaymentService.ts
T
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

488 lines
15 KiB
TypeScript

import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails/email-manager";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { ErrorWithCode } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
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";
const log = logger.getSubLogger({ prefix: ["payment-service:stripe"] });
export const stripeCredentialKeysSchema = z.object({
stripe_user_id: z.string(),
default_currency: z.string(),
stripe_publishable_key: z.string(),
});
export class PaymentService implements IAbstractPaymentService {
private stripe: Stripe;
private credentials: z.infer<typeof stripeCredentialKeysSchema> | null;
constructor(credentials: { key: Prisma.JsonValue }) {
const keyParsing = stripeCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
this.credentials = keyParsing.data;
} else {
this.credentials = null;
}
this.stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY || "", {
apiVersion: "2020-08-27",
});
}
private async getPayment(where: Prisma.PaymentWhereInput) {
const payment = await prisma.payment.findFirst({ where });
// if payment isn't found, return null.
if (!payment) {
return null;
}
// if it is found, but there's no externalId - it indicates invalid state and an error should be thrown.
if (!payment.externalId) {
throw new Error("Payment externalId not found");
}
return { ...payment, externalId: payment.externalId };
}
/* This method is for creating charges at the time of booking */
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
userId: Booking["userId"],
username: string | null,
bookerName: string,
paymentOption: PaymentOption,
bookerEmail: string,
bookerPhoneNumber?: string | null,
eventTitle?: string,
bookingTitle?: string
) {
try {
// Ensure that the payment service can support the passed payment option
if (paymentOptionEnum.parse(paymentOption) !== "ON_BOOKING") {
throw new Error("Payment option is not compatible with create method");
}
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
const customer = await retrieveOrCreateStripeCustomerByEmail(
this.credentials.stripe_user_id,
bookerEmail,
bookerPhoneNumber
);
const params: Stripe.PaymentIntentCreateParams = {
amount: payment.amount,
currency: payment.currency,
customer: customer.id,
automatic_payment_methods: {
enabled: true,
},
metadata: this.generateMetadata({
bookingId,
userId,
username,
bookerName,
bookerEmail: bookerEmail,
bookerPhoneNumber: bookerPhoneNumber ?? null,
eventTitle: eventTitle || "",
bookingTitle: bookingTitle || "",
}),
};
const paymentIntent = await this.stripe.paymentIntents.create(params, {
stripeAccount: this.credentials.stripe_user_id,
});
const paymentData = await prisma.payment.create({
data: {
uid: uuidv4(),
app: {
connect: {
slug: "stripe",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
currency: payment.currency,
externalId: paymentIntent.id,
data: Object.assign({}, paymentIntent, {
stripe_publishable_key: this.credentials.stripe_publishable_key,
stripeAccount: this.credentials.stripe_user_id,
}) as unknown as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
paymentOption: paymentOption || "ON_BOOKING",
},
});
if (!paymentData) {
throw new Error();
}
return paymentData;
} catch (error) {
log.error("Stripe: Payment could not be created", bookingId, safeStringify(error));
throw new Error("payment_not_created_error");
}
}
async collectCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
paymentOption: PaymentOption,
bookerEmail: string,
bookerPhoneNumber?: string | null
): Promise<Payment> {
try {
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
// Ensure that the payment service can support the passed payment option
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
throw new Error("Payment option is not compatible with create method");
}
const customer = await retrieveOrCreateStripeCustomerByEmail(
this.credentials.stripe_user_id,
bookerEmail,
bookerPhoneNumber
);
const params = {
customer: customer.id,
payment_method_types: ["card"],
metadata: {
bookingId,
bookerPhoneNumber: bookerPhoneNumber ?? null,
},
};
const setupIntent = await this.stripe.setupIntents.create(params, {
stripeAccount: this.credentials.stripe_user_id,
});
const paymentData = await prisma.payment.create({
data: {
uid: uuidv4(),
app: {
connect: {
slug: "stripe",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
currency: payment.currency,
externalId: setupIntent.id,
data: Object.assign(
{},
{
setupIntent,
stripe_publishable_key: this.credentials.stripe_publishable_key,
stripeAccount: this.credentials.stripe_user_id,
}
) as unknown as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
paymentOption: paymentOption || "ON_BOOKING",
},
});
return paymentData;
} catch (error) {
log.error(
"Stripe: Payment method could not be collected for bookingId",
bookingId,
safeStringify(error)
);
throw new Error("Stripe: Payment method could not be collected");
}
}
async chargeCard(payment: Payment, bookingId: Booking["id"]): Promise<Payment> {
try {
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
const bookingRepository = new BookingRepository(prisma);
const booking = await bookingRepository.findByIdIncludeUserAndAttendees(bookingId);
if (!booking) {
throw new Error(`Booking ${bookingId} not found`);
}
const paymentObject = payment.data as unknown as StripeSetupIntentData;
const setupIntent = paymentObject.setupIntent;
// Ensure that the stripe customer & payment method still exists
const customer = await this.stripe.customers.retrieve(setupIntent.customer as string, {
stripeAccount: this.credentials.stripe_user_id,
});
const paymentMethod = await this.stripe.paymentMethods.retrieve(setupIntent.payment_method as string, {
stripeAccount: this.credentials.stripe_user_id,
});
if (!customer) {
throw new Error(`Stripe customer does not exist for setupIntent ${setupIntent.id}`);
}
if (!paymentMethod) {
throw new Error(`Stripe paymentMethod does not exist for setupIntent ${setupIntent.id}`);
}
if (!booking.attendees[0]) {
throw new Error(`Booking attendees are empty for setupIntent ${setupIntent.id}`);
}
const params: Stripe.PaymentIntentCreateParams = {
amount: payment.amount,
currency: payment.currency,
customer: setupIntent.customer as string,
payment_method: setupIntent.payment_method as string,
off_session: true,
confirm: true,
metadata: this.generateMetadata({
bookingId,
userId: booking.user?.id,
username: booking.user?.username,
bookerName: booking.attendees[0].name,
bookerEmail: booking.attendees[0].email,
bookerPhoneNumber: booking.attendees[0].phoneNumber ?? null,
eventTitle: booking.eventType?.title || null,
bookingTitle: booking.title,
}),
};
const paymentIntent = await this.stripe.paymentIntents.create(params, {
stripeAccount: this.credentials.stripe_user_id,
});
const paymentData = await prisma.payment.update({
where: {
id: payment.id,
},
data: {
success: true,
data: {
...paymentObject,
paymentIntent,
} as unknown as Prisma.InputJsonValue,
},
});
if (!paymentData) {
throw new Error();
}
return paymentData;
} catch (error) {
log.error("Stripe: Could not charge card for payment", bookingId, safeStringify(error));
const errorMappings = {
"your card was declined": "your_card_was_declined",
"your card does not support this type of purchase":
"your_card_does_not_support_this_type_of_purchase",
"amount must convert to at least": "amount_must_convert_to_at_least",
};
let userMessage = "could_not_charge_card";
if (error instanceof Error) {
const errorMessage = error.message.toLowerCase();
for (const [key, message] of Object.entries(errorMappings)) {
if (errorMessage.includes(key)) {
userMessage = message;
break;
}
}
}
throw new ErrorWithCode(ErrorCode.ChargeCardFailure, userMessage);
}
}
async update(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async refund(paymentId: Payment["id"]): Promise<Payment | null> {
const payment = await this.getPayment({
id: paymentId,
});
if (!payment) {
return null;
}
if (!payment.success) {
throw new Error("Unable to refund failed payment");
}
if (payment.refunded) {
// refunded already, bail early as success without throwing an error.
return payment;
}
try {
const refund = await this.stripe.refunds.create(
{
payment_intent: payment.externalId,
},
{ stripeAccount: (payment.data as unknown as StripePaymentData)["stripeAccount"] }
);
if (!refund || refund.status === "failed") {
throw new Error("Refund failed");
}
const updatedPayment = await prisma.payment.update({
where: {
id: payment.id,
},
data: {
refunded: true,
},
});
return updatedPayment;
} catch (e) {
const err = getErrorFromUnknown(e);
throw err;
}
}
async afterPayment(
event: CalendarEvent,
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
startTime: { toISOString: () => string };
uid: string;
},
paymentData: Payment,
eventTypeMetadata?: EventTypeMetadata
): Promise<void> {
const attendeesToEmail = event.attendeeSeatId
? event.attendees.filter((attendee) => attendee.bookingSeat?.referenceUid === event.attendeeSeatId)
: event.attendees;
await sendAwaitingPaymentEmailAndSMS(
{
...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,
},
},
eventTypeMetadata
);
}
async deletePayment(paymentId: Payment["id"]): Promise<boolean> {
try {
const payment = await this.getPayment({
id: paymentId,
});
// no payment found, return false.
if (!payment) {
return false;
}
const stripeAccount = (payment.data as unknown as StripePaymentData).stripeAccount;
if (!stripeAccount) {
throw new Error("Stripe account not found");
}
// Expire all current sessions
const sessions = await this.stripe.checkout.sessions.list(
{
payment_intent: payment.externalId,
},
{ stripeAccount }
);
for (const session of sessions.data) {
await this.stripe.checkout.sessions.expire(session.id, { stripeAccount });
}
// Then cancel the payment intent
await this.stripe.paymentIntents.cancel(payment.externalId, { stripeAccount });
return true;
} catch (e) {
log.error("Stripe: Unable to delete Payment in stripe of paymentId", paymentId, safeStringify(e));
return false;
}
}
getPaymentPaidStatus(): Promise<string> {
throw new Error("Method not implemented.");
}
getPaymentDetails(): Promise<Payment> {
throw new Error("Method not implemented.");
}
isSetupAlready(): boolean {
return !!this.credentials;
}
private generateMetadata({
bookingId,
userId,
username,
bookerName,
bookerEmail,
bookerPhoneNumber,
eventTitle,
bookingTitle,
}: {
bookingId: number;
userId: number | null | undefined;
username: string | null | undefined;
bookerName: string;
bookerEmail: string;
bookerPhoneNumber: string | null;
eventTitle: string | null;
bookingTitle: string;
}) {
return {
identifier: "cal.com",
bookingId,
calAccountId: userId ?? null,
calUsername: username ?? null,
bookerName,
bookerEmail: bookerEmail,
bookerPhoneNumber: bookerPhoneNumber ?? null,
eventTitle: eventTitle || "",
bookingTitle: bookingTitle || "",
};
}
}