Files
calendar/packages/lib/payment/handleNoShowFee.ts
T
Joe Au-YeungGitHubjoe@cal.com <j.auyeung419@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Syed Ali ShahbazAlex van Andel
d6b17b76db test: add comprehensive unit tests for no-show fee payment functions (#23626)
* feat: add time-based cancellation fees for no-show fee events

- Add configurable time threshold (minutes/hours/days) for cancellation fees
- Show warnings during booking submission and cancellation
- Automatically charge fees when bookings cancelled within threshold
- Exempt organizer/admin cancellations from fees
- Extend existing no-show fee infrastructure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: replace any type with proper type guards for metadata

- Use Record<string, unknown> instead of any for metadata type
- Add proper type assertions for nested metadata properties
- Maintain type safety while avoiding ESLint no-explicit-any warning

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: handle JsonValue type compatibility in shouldChargeCancellationFee

- Add proper type guard for metadata JsonValue from Prisma
- Ensure metadata is object before casting to Record<string, unknown>
- Fixes TypeScript error on line 390 in handleCancelBooking.ts

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Refactor Devin changes to Stripe options

* Undo Devin changes to advanced tab

* Add translations

* Pass props to CancelBooking

* Display no show fee charge for attendee

* WIP

* Anstract shouldChargeNoSHowCancellationFee

* Abstract `handleNoShowFee`

* Add  to

* Refactor `chargeCard.handler`

* Remove Devin code

* Type fix in `shouldChargeNoShowCancellationFee`

* Create `processNoSHowFeeOnCancellation`

* Process no show fee on cancellation

* Type fix

* Skip processing no show fee if organizer or admin is cancelling

* Add translation

* Dynamically get  and  in

* Remove unused translations

* Undo dev change

* Refactor logic

* Type fix

* remove any

* revert WEBAPP_URL_FOR_OAUTH

* Clean up console.log remnants

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All 23 new tests pass successfully, covering:
- Time-based logic with different units (minutes, hours, days)
- Role-based exemptions (organizer, team admin vs regular attendee)
- Payment charging integration with HOLD payment option
- UI warning display during booking submission and cancellation
- Edge cases like invalid metadata and past bookings

Fixed lint issues:
- Replaced 'any' types with proper Record<string, unknown>
- Fixed Playwright test.skip() usage with ESLint disable comments

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All unit and integration tests pass successfully. E2E test implementation complete.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript issues and prettier formatting
- All tests follow existing Cal.com patterns and pass locally

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive unit and E2E tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions and attendee fee charging
- Add UI component tests for cancellation fee warning display in CancelBooking component
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript errors in CancelBooking.tsx with proper type annotations
- All tests follow existing Cal.com patterns and use proper mocking/test utilities

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Revert dev change

* Add missing translation

* Remove Devin code

* Revert Devin changes

* test: fix attendee cancellation test logic and ensure comprehensive test coverage

- Fix userId in attendee cancellation test to properly test fee charging
- Ensure all test files follow Cal.com patterns with proper mocking
- Add comprehensive coverage for time thresholds, role exemptions, and UI warnings
- All tests pass locally with proper timezone handling

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix tests

* test: add comprehensive unit tests for no-show fee payment functions

- Add unit tests for handleNoShowFee.ts covering successful scenarios, error handling, and edge cases
- Add unit tests for processNoShowFeeOnCancellation.ts covering cancellation logic and payment conditions
- Tests include proper mocking of dependencies and comprehensive coverage of all code paths
- All tests pass with TZ=UTC yarn test and type checking passes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Don't block cancel flow for failed payment processing

* Await single attendee getTranslation in `handleNoShowFee`

* Add missing data

* Undo old strings

* Type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-05 23:01:52 +01:00

174 lines
5.4 KiB
TypeScript

import type { Prisma } from "@prisma/client";
// eslint-disable-next-line
import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated";
import dayjs from "@calcom/dayjs";
import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma from "@calcom/prisma";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
export const handleNoShowFee = async ({
booking,
payment,
}: {
booking: {
id: number;
uid: string;
title: string;
startTime: Date;
endTime: Date;
userPrimaryEmail: string | null;
userId: number | null;
user?: {
email: string;
name?: string | null;
locale: string | null;
timeZone: string;
} | null;
eventType: {
title: string;
hideOrganizerEmail: boolean;
teamId: number | null;
metadata?: Prisma.JsonValue;
} | null;
attendees: {
name: string;
email: string;
timeZone: string;
locale: string | null;
}[];
};
payment: {
id: number;
amount: number;
currency: string;
paymentOption: string | null;
appId: string | null;
};
}) => {
const log = logger.getSubLogger({ prefix: [`[handleNoShowFee] bookingUid ${booking.uid}`] });
const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common");
const userId = booking.userId;
const teamId = booking.eventType?.teamId;
const appId = payment.appId;
const eventTypeMetdata = eventTypeMetaDataSchemaWithTypedApps.parse(booking.eventType?.metadata ?? {});
if (!userId) {
log.error("User ID is required");
throw new Error("User ID is required");
}
const bookingAttendee = booking.attendees[0];
const attendee = {
name: bookingAttendee.name,
email: bookingAttendee.email,
timeZone: bookingAttendee.timeZone,
language: {
translate: await getTranslation(bookingAttendee.locale ?? "en", "common"),
locale: bookingAttendee.locale ?? "en",
},
};
const evt: CalendarEvent = {
type: (booking?.eventType?.title as string) || booking?.title,
title: booking.title,
startTime: dayjs(booking.startTime).format(),
endTime: dayjs(booking.endTime).format(),
organizer: {
email: booking?.userPrimaryEmail ?? booking.user?.email ?? "",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "",
language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" },
},
attendees: [attendee],
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
paymentInfo: {
amount: payment.amount,
currency: payment.currency,
paymentOption: payment.paymentOption,
},
};
if (teamId) {
const userIsInTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({
userId,
teamId,
});
if (!userIsInTeam) {
log.error(`User ${userId} is not a member of team ${teamId}`);
throw new Error("User is not a member of the team");
}
}
let paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId({
appId,
userId,
teamId,
});
if (!paymentCredential && teamId) {
const teamRepository = new TeamRepository(prisma);
// See if the team event belongs to an org
const org = await teamRepository.findParentOrganizationByTeamId(teamId);
if (org) {
paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndTeamId({
appId,
teamId: org.id,
});
}
}
if (!paymentCredential) {
log.error(`No payment credential found for user ${userId} or team ${teamId}`);
throw new Error("No payment credential found");
}
const key = paymentCredential?.app?.dirName;
const paymentAppImportFn = PaymentServiceMap[key as keyof typeof PaymentServiceMap];
if (!paymentAppImportFn) {
log.error(`Payment app ${key} not implemented`);
throw new Error("Payment app not implemented");
}
const paymentApp = await paymentAppImportFn;
if (!paymentApp?.PaymentService) {
log.error(`Payment service not found for app ${key}`);
throw new Error("Payment service not found");
}
const PaymentService = paymentApp.PaymentService;
const paymentInstance = new PaymentService(paymentCredential) as IAbstractPaymentService;
try {
const paymentData = await paymentInstance.chargeCard(payment, booking.id);
if (!paymentData) {
log.error(`Error processing payment with paymentId ${payment.id}`);
throw new Error("Payment processing failed");
}
await sendNoShowFeeChargedEmail(attendee, evt, eventTypeMetdata);
return paymentData;
} catch (err) {
let errorMessage = `Error processing paymentId ${payment.id} with error ${err}`;
if (err instanceof ErrorWithCode && err.code === ErrorCode.ChargeCardFailure) {
errorMessage = err.message;
}
log.error(errorMessage);
throw new Error(tOrganizer(errorMessage));
}
};