feat: refund policies for payment apps (#18428)

* feat: refund policies for payment apps

* tests for payment refund

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Amit Sharma
2025-01-06 11:36:37 +00:00
committed by GitHub
co-authored by Peer Richelsen
parent e722e75a6f
commit c1dbb7cffd
23 changed files with 559 additions and 85 deletions
@@ -11,7 +11,7 @@ import "@calcom/dayjs/locales";
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import classNames from "@calcom/lib/classNames";
import { formatTime } from "@calcom/lib/date-fns";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
@@ -2737,6 +2737,12 @@
"month_to_date": "month to date",
"year_to_date": "year to date",
"custom_range": "custom range",
"refund_policy": "Refund Policy",
"always": "Always",
"never": "Never",
"payment_option": "Payment Option",
"if_cancelled": "If cancelled",
"before": "before",
"show_all_columns": "Show all columns",
"toggle_columns": "Toggle columns",
"no_columns_found": "No columns found",
@@ -24,7 +24,7 @@ import type {
WorkflowTriggerEvents,
WorkflowMethods,
} from "@calcom/prisma/client";
import type { SchedulingType, SMSLockState, TimeUnit } from "@calcom/prisma/enums";
import type { PaymentOption, SchedulingType, SMSLockState, TimeUnit } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { userMetadataType } from "@calcom/prisma/zod-utils";
@@ -74,6 +74,21 @@ type InputWorkflow = {
sendTo?: string;
};
type InputPayment = {
id?: number;
uid: string;
appId?: string | null;
bookingId: number;
amount: number;
fee: number;
currency: string;
success: boolean;
refunded: boolean;
data: Record<string, any>;
externalId: string;
paymentOption?: PaymentOption;
};
type InputWorkflowReminder = {
id?: number;
bookingUid: string;
@@ -108,6 +123,7 @@ export type ScenarioData = {
bookings?: InputBooking[];
webhooks?: InputWebhook[];
workflows?: InputWorkflow[];
payment?: InputPayment[];
};
type InputCredential = typeof TestData.credentials.google & {
@@ -535,6 +551,12 @@ async function addWebhooksToDb(webhooks: any[]) {
});
}
async function addPaymentToDb(payment: InputPayment[]) {
await prismock.payment.createMany({
data: payment,
});
}
async function addWebhooks(webhooks: InputWebhook[]) {
log.silly("TestData: Creating Webhooks", safeStringify(webhooks));
@@ -810,6 +832,7 @@ export async function createBookingScenario(data: ScenarioData) {
await addWebhooks(data.webhooks || []);
// addPaymentMock();
const workflows = await addWorkflows(data.workflows || []);
await addPaymentToDb(data.payment || []);
return {
eventTypes,
@@ -1304,6 +1327,7 @@ export function getScenarioData(
webhooks,
workflows,
bookings,
payment,
}: {
organizer?: ReturnType<typeof getOrganizer>;
eventTypes: ScenarioData["eventTypes"];
@@ -1313,6 +1337,7 @@ export function getScenarioData(
webhooks?: ScenarioData["webhooks"];
workflows?: ScenarioData["workflows"];
bookings?: ScenarioData["bookings"];
payment?: ScenarioData["payment"];
},
org?: { id: number | null } | undefined | null
) {
@@ -1373,6 +1398,7 @@ export function getScenarioData(
webhooks,
bookings: bookings || [],
workflows,
payment,
} satisfies ScenarioData;
}
@@ -1,8 +1,11 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { useState, useEffect } from "react";
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Select, TextField } from "@calcom/ui";
import { RefundPolicy } from "@calcom/lib/payment/types";
import { Alert, RadioField, Select, TextField } from "@calcom/ui";
import {
convertToSmallestCurrencyUnit,
@@ -30,6 +33,8 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
const paymentOption = getAppData("paymentOption");
const paymentOptionSelectValue = paymentOptions.find((option) => paymentOption === option.value);
const requirePayment = getAppData("enabled");
const getSelectedOption = () =>
options.find((opt) => opt.value === (getAppData("refundCountCalendarDays") === true ? 1 : 0));
const { t } = useLocale();
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
@@ -54,8 +59,16 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
setAppData("paymentOption", paymentOptions[0].value);
}
}
if (!getAppData("refundPolicy")) {
setAppData("refundPolicy", RefundPolicy.NEVER);
}
}, [requirePayment, getAppData, setAppData]);
const options = [
{ value: 0, label: t("business_days") },
{ value: 1, label: t("calendar_days") },
];
return (
<>
{recurringEventDefined && (
@@ -109,7 +122,7 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
</div>
<div className="mt-4 w-60">
<label className="text-default mb-1 block text-sm font-medium" htmlFor="currency">
Payment option
{t("payment_option")}
</label>
<Select<Option>
data-testid="stripe-payment-option-select"
@@ -122,7 +135,14 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
return { ...option, label: t(option.label) || option.label };
})}
onChange={(input) => {
if (input) setAppData("paymentOption", input.value);
if (input) {
setAppData("paymentOption", input.value);
if (input.value === "HOLD") {
setAppData("refundPolicy", RefundPolicy.NEVER);
setAppData("refundDaysCount", undefined);
setAppData("refundCountCalendarDays", undefined);
}
}
}}
className="mb-1 h-[38px] w-full"
isDisabled={seatsEnabled || disabled}
@@ -132,6 +152,61 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
{paymentOption !== "HOLD" && (
<div className="mt-4 w-full">
<label className="text-default mb-1 block text-sm font-medium">{t("refund_policy")}</label>
<RadioGroup.Root
disabled={disabled || paymentOption === "HOLD"}
defaultValue="never"
className="flex flex-col space-y-2"
value={getAppData("refundPolicy")}
onValueChange={(val) => {
setAppData("refundPolicy", val);
if (val !== RefundPolicy.DAYS) {
setAppData("refundDaysCount", undefined);
setAppData("refundCountCalendarDays", undefined);
}
}}>
<RadioField className="w-fit" value={RefundPolicy.ALWAYS} label={t("always")} id="always" />
<RadioField className="w-fit" value={RefundPolicy.NEVER} label={t("never")} id="never" />
<div className={classNames("text-default mb-2 flex flex-wrap items-center text-sm")}>
<RadioGroup.Item
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2"
value="days"
id="days">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
<div className="flex items-center">
<span className="me-2 ms-2">&nbsp;{t("if_cancelled")}</span>
<TextField
labelSrOnly
type="number"
className={classNames(
"border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
)}
placeholder="2"
disabled={disabled}
min={0}
defaultValue={getAppData("refundDaysCount")}
required={getAppData("refundPolicy") === RefundPolicy.DAYS}
value={getAppData("refundDaysCount") ?? ""}
onChange={(e) => setAppData("refundDaysCount", parseInt(e.currentTarget.value))}
/>
<Select
options={options}
isSearchable={false}
isDisabled={disabled}
onChange={(option) => setAppData("refundCountCalendarDays", option?.value === 1)}
value={getSelectedOption()}
defaultValue={getSelectedOption()}
/>
<span className="me-2 ms-2">&nbsp;{t("before")}</span>
</div>
</div>
</RadioGroup.Root>
</div>
)}
</>
)}
</>
+5
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
import { RefundPolicy } from "@calcom/lib/payment/types";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
import { paymentOptions } from "./lib/constants";
@@ -18,6 +20,9 @@ export const appDataSchema = eventTypeAppCardZod.merge(
currency: z.string(),
paymentOption: paymentOptionEnum.optional(),
enabled: z.boolean().optional(),
refundPolicy: z.nativeEnum(RefundPolicy).optional(),
refundDaysCount: z.number().optional(),
refundCountCalendarDays: z.boolean().optional(),
})
);
@@ -7,7 +7,7 @@ import type { FieldError } from "react-hook-form";
import { useIsPlatformBookerEmbed } from "@calcom/atoms/monorepo";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, EmptyScreen, Form } from "@calcom/ui";
@@ -4,7 +4,7 @@ import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { PriceIcon } from "@calcom/features/bookings/components/event-meta/PriceIcon";
import type { BookerEvent } from "@calcom/features/bookings/types";
import classNames from "@calcom/lib/classNames";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, type IconName } from "@calcom/ui";
@@ -20,6 +20,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { processPaymentRefund } from "@calcom/lib/payment/processPaymentRefund";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
@@ -495,6 +496,13 @@ async function handler(req: CustomRequest) {
},
});
updatedBookings.push(updatedBooking);
if (!!bookingToDelete.payment.length) {
await processPaymentRefund({
booking: bookingToDelete,
teamId,
});
}
}
/** TODO: Remove this without breaking functionality */
@@ -14,11 +14,16 @@ import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/
import { expectBookingCancelledWebhookToHaveBeenFired } from "@calcom/web/test/utils/bookingScenario/expects";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe } from "vitest";
import { describe, expect, vi } from "vitest";
import { processPaymentRefund } from "@calcom/lib/payment/processPaymentRefund";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
vi.mock("@calcom/lib/payment/processPaymentRefund", () => ({
processPaymentRefund: vi.fn(),
}));
describe("Cancel Booking", () => {
setupAndTeardown();
@@ -133,4 +138,136 @@ describe("Cancel Booking", () => {
},
});
});
test("Should call processPaymentRefund", async () => {
const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const uidOfBookingToBeCancelled = "h5Wv3eHgconAED2j4gcVhP";
const idOfBookingToBeCancelled = 1020;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const booking = {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 1,
userId: 101,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
attendees: [
{
timeZone: "Asia/Kolkata",
email: booker.email,
},
],
};
await createBookingScenario(
getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CANCELLED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
payment: [
{
amount: 12,
bookingId: idOfBookingToBeCancelled,
currency: "usd",
data: {},
externalId: "ext_id",
fee: 12,
refunded: false,
success: true,
uid: uidOfBookingToBeCancelled,
},
],
bookings: [booking],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: organizer.email,
},
});
await handleCancelBooking(req);
expectBookingCancelledWebhookToHaveBeenFired({
booker,
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
payload: {
cancelledBy: organizer.email,
organizer: {
id: organizer.id,
username: organizer.username,
email: organizer.email,
name: organizer.name,
timeZone: organizer.timeZone,
},
},
});
expect(processPaymentRefund).toHaveBeenCalled();
});
});
@@ -55,7 +55,7 @@ import { getErrorFromUnknown } from "@calcom/lib/errors";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -12,7 +12,7 @@ import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { PayIcon } from "@calcom/features/bookings/components/event-meta/PayIcon";
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { APP_NAME, WEBSITE_URL, CURRENT_TIMEZONE } from "@calcom/lib/constants";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
@@ -5,7 +5,7 @@ import type { z } from "zod";
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { PriceIcon } from "@calcom/features/bookings/components/event-meta/PriceIcon";
import { classNames, parseRecurringEvent } from "@calcom/lib";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { baseEventTypeSelect } from "@calcom/prisma";
@@ -1,5 +1,5 @@
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
import InstantEventController from "./InstantEventController";
@@ -1,4 +1,4 @@
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
import type { RecurringEventControllerProps } from "./RecurringEventController";
+7 -1
View File
@@ -8,7 +8,7 @@ import type { BookerEvent } from "@calcom/features/bookings/types";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
export default function getPaymentAppData(
export function getPaymentAppData(
_eventType: Pick<BookerEvent, "price" | "currency"> & {
metadata: z.infer<typeof EventTypeMetaDataSchema>;
},
@@ -38,6 +38,9 @@ export default function getPaymentAppData(
appId: EventTypeAppsList | null;
paymentOption: typeof paymentOptionEnum;
credentialId?: number;
refundPolicy?: string;
refundDaysCount?: number;
refundCountCalendarDays?: boolean;
} | null = null;
for (const appId of paymentAppIds) {
const appData = getEventTypeAppData(eventType, appId, forcedGet);
@@ -58,6 +61,9 @@ export default function getPaymentAppData(
appId: null,
paymentOption: "ON_BOOKING",
credentialId: undefined,
refundPolicy: undefined,
refundDaysCount: undefined,
refundCountCalendarDays: undefined,
}
);
}
@@ -0,0 +1,32 @@
import type { Payment, Prisma } from "@prisma/client";
import appStore from "@calcom/app-store";
import type { AppCategories } from "@calcom/prisma/enums";
import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService";
const handlePaymentRefund = async (
paymentId: Payment["id"],
paymentAppCredentials: {
key: Prisma.JsonValue;
appId: string | null;
app: {
dirName: string;
categories: AppCategories[];
} | null;
}
) => {
const paymentApp = (await appStore[
paymentAppCredentials?.app?.dirName as keyof typeof appStore
]?.()) as PaymentApp;
if (!paymentApp?.lib?.PaymentService) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return false;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const PaymentService = paymentApp.lib.PaymentService as any;
const paymentInstance = new PaymentService(paymentAppCredentials) as IAbstractPaymentService;
const refund = await paymentInstance.refund(paymentId);
return refund;
};
export { handlePaymentRefund };
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import dayjs from "@calcom/dayjs";
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
import { getPaymentAppData } from "../getPaymentAppData";
import { handlePaymentRefund } from "./handlePaymentRefund";
import { processPaymentRefund } from "./processPaymentRefund";
import { RefundPolicy } from "./types";
vi.mock('@calcom/lib/getPaymentAppData', () => ({
getPaymentAppData: vi.fn(),
}));
vi.mock("@calcom/lib/payment/handlePaymentRefund", () => ({
handlePaymentRefund: vi.fn(),
}));
describe("processPaymentRefund", () => {
const mockStartTime = new Date("2025-01-01T10:00:00Z");
const mockPayment = [
{
id: 1,
uid: "unique-id-1",
appId: "123",
bookingId: 456,
amount: 1000,
fee: 50,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext-1234",
paymentOption: null,
},
];
const mockBooking = {
startTime: mockStartTime,
endTime: new Date("2025-01-01T11:00:00Z"),
payment: mockPayment,
eventType: {
owner: { id: 1 },
metadata: {},
},
};
const mockAppData = {
refundPolicy: RefundPolicy.DAYS,
refundCountCalendarDays: false,
refundDaysCount: 3,
};
const mockPaymentAppCredentials = [
{
key: "key1",
appId: "123",
app: {
categories: ["category1"],
dirName: "app1",
},
},
];
beforeEach(() => {
vi.clearAllMocks();
});
it("should not process refund if no teamId or eventType owner", async () => {
await processPaymentRefund({ booking: mockBooking, teamId: null });
expect(handlePaymentRefund).not.toHaveBeenCalled();
});
it("should not process refund if no successful payment found", async () => {
const invalidBooking = { ...mockBooking, payment: [{ ...mockPayment[0], success: false }] };
await processPaymentRefund({ booking: invalidBooking, teamId: 1 });
expect(handlePaymentRefund).not.toHaveBeenCalled();
});
it("should not process refund if refund policy is NEVER", async () => {
(getPaymentAppData as any).mockReturnValueOnce({ ...mockAppData, refundPolicy: RefundPolicy.NEVER });
await processPaymentRefund({ booking: mockBooking, teamId: 1 });
expect(handlePaymentRefund).not.toHaveBeenCalled();
});
it("should process refund if refund policy is DAYS and within refund window", async () => {
(getPaymentAppData as any).mockReturnValueOnce(mockAppData);
prismaMock.credential.findMany.mockResolvedValueOnce(mockPaymentAppCredentials);
const mockNow = dayjs(mockStartTime).subtract(8, "days").toDate();
vi.useFakeTimers();
vi.setSystemTime(mockNow);
await processPaymentRefund({ booking: mockBooking, teamId: 1 });
expect(handlePaymentRefund).toHaveBeenCalledWith(1, mockPaymentAppCredentials[0]);
});
it("should not process refund if past the refund deadline", async () => {
(getPaymentAppData as any).mockReturnValueOnce(mockAppData);
prismaMock.credential.findMany.mockResolvedValueOnce(mockPaymentAppCredentials);
const mockNow = dayjs(mockStartTime).subtract(2, "days").toDate();
vi.useFakeTimers();
vi.setSystemTime(mockNow);
await processPaymentRefund({ booking: mockBooking, teamId: 1 });
expect(handlePaymentRefund).not.toHaveBeenCalled();
});
it("should process refund if business days are considered and before deadline", async () => {
(getPaymentAppData as any).mockReturnValueOnce({ ...mockAppData, refundCountCalendarDays: false });
prismaMock.credential.findMany.mockResolvedValueOnce(mockPaymentAppCredentials);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const mockNow = dayjs(mockStartTime).businessDaysSubtract(3).toDate();
vi.useFakeTimers();
vi.setSystemTime(mockNow);
await processPaymentRefund({ booking: mockBooking, teamId: 1 });
expect(handlePaymentRefund).toHaveBeenCalledWith(1, mockPaymentAppCredentials[0]);
});
it("should not process refund if business days are considered and after deadline", async () => {
(getPaymentAppData as any).mockReturnValueOnce({ ...mockAppData, refundCountCalendarDays: false });
prismaMock.credential.findMany.mockResolvedValueOnce(mockPaymentAppCredentials);
const mockNow = dayjs(mockStartTime).subtract(3, "days").toDate();
vi.useFakeTimers();
vi.setSystemTime(mockNow);
await processPaymentRefund({ booking: mockBooking, teamId: 1 });
expect(handlePaymentRefund).not.toHaveBeenCalled();
});
it("should not process refund if paymentAppCredential is not found", async () => {
(getPaymentAppData as any).mockReturnValueOnce(mockAppData);
prismaMock.credential.findMany.mockResolvedValueOnce([]);
await processPaymentRefund({ booking: mockBooking, teamId: 1 });
expect(handlePaymentRefund).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,86 @@
import type { Payment, Prisma } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getPaymentAppData } from "../getPaymentAppData";
import { handlePaymentRefund } from "./handlePaymentRefund";
import { RefundPolicy } from "./types";
export const processPaymentRefund = async ({
booking,
teamId,
}: {
booking: {
startTime: Date;
endTime: Date;
payment: Payment[];
eventType: {
owner?: {
id: number;
} | null;
metadata?: Prisma.JsonValue;
} | null;
};
teamId?: number | null;
}) => {
const { startTime, eventType, payment } = booking;
if (!teamId && !eventType?.owner) return;
const successPayment = payment.find((p) => p.success);
if (!successPayment) return;
const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType?.metadata);
const appData = getPaymentAppData({
currency: successPayment.currency,
metadata: eventTypeMetadata,
price: successPayment.amount,
});
if (!appData?.refundPolicy || appData.refundPolicy === RefundPolicy.NEVER) return;
const credentialWhereClause: Prisma.CredentialFindManyArgs["where"] = {
appId: successPayment.appId,
};
if (eventType?.owner) {
credentialWhereClause.userId = eventType.owner.id;
} else if (teamId) {
credentialWhereClause.teamId = teamId;
}
const paymentAppCredentials = await prisma.credential.findMany({
where: credentialWhereClause,
select: {
key: true,
appId: true,
app: {
select: {
categories: true,
dirName: true,
},
},
},
});
const paymentAppCredential = paymentAppCredentials.find((credential) => {
return credential.appId === successPayment.appId;
});
if (!paymentAppCredential) return;
const { refundPolicy, refundCountCalendarDays, refundDaysCount } = appData;
//refundDaysCount would always be present in case DAYS is selected, but adding it in AND jut for type safety
if (refundPolicy === RefundPolicy.DAYS && refundDaysCount) {
const refundDeadline =
refundCountCalendarDays === true
? dayjs(startTime).subtract(refundDaysCount, "days")
: // businessDaysSubtract exists on extended dayjs instance, but ts is messing up
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
dayjs(startTime).businessDaysSubtract(refundDaysCount);
if (dayjs().isAfter(refundDeadline)) return;
}
await handlePaymentRefund(successPayment.id, paymentAppCredential);
};
+5
View File
@@ -0,0 +1,5 @@
export enum RefundPolicy {
NEVER = "never",
ALWAYS = "always",
DAYS = "days",
}
@@ -8,7 +8,7 @@ import type { UseFormReturn } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { EventTypeSetupProps, FormValues } from "@calcom/features/eventtypes/lib/types";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { VerticalTabItemProps } from "@calcom/ui";
@@ -13,7 +13,7 @@ import type {
FormValues,
EventTypeApps,
} from "@calcom/features/eventtypes/lib/types";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
import type { VerticalTabItemProps } from "@calcom/ui";
@@ -6,7 +6,7 @@ import { PayIcon } from "@calcom/features/bookings/components/event-meta/PayIcon
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
import { APP_NAME, WEBSITE_URL, CURRENT_TIMEZONE } from "@calcom/lib/constants";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { isBrowserLocale24h } from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
@@ -1,6 +1,5 @@
import { Prisma } from "@prisma/client";
import appStore from "@calcom/app-store";
import type { LocationObject } from "@calcom/app-store/locations";
import { getLocationValueForDB } from "@calcom/app-store/locations";
import { sendDeclinedEmailsAndSMS } from "@calcom/emails";
@@ -14,6 +13,7 @@ import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { processPaymentRefund } from "@calcom/lib/payment/processPaymentRefund";
import { getTranslation } from "@calcom/lib/server";
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
@@ -26,7 +26,6 @@ import {
} from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService";
import { TRPCError } from "@trpc/server";
@@ -299,72 +298,10 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
} else {
// handle refunds
if (!!booking.payment.length) {
const successPayment = booking.payment.find((payment) => payment.success);
if (!successPayment) {
// Disable paymentLink for this booking
} else {
let eventTypeOwnerId;
if (booking.eventType?.owner) {
eventTypeOwnerId = booking.eventType.owner.id;
} else if (booking.eventType?.teamId) {
const teamOwner = await prisma.membership.findFirst({
where: {
teamId: booking.eventType.teamId,
role: MembershipRole.OWNER,
},
select: {
userId: true,
},
});
eventTypeOwnerId = teamOwner?.userId;
}
if (!eventTypeOwnerId) {
throw new Error("Event Type owner not found for obtaining payment app credentials");
}
const paymentAppCredentials = await prisma.credential.findMany({
where: {
userId: eventTypeOwnerId,
appId: successPayment.appId,
},
select: {
key: true,
appId: true,
app: {
select: {
categories: true,
dirName: true,
},
},
},
});
const paymentAppCredential = paymentAppCredentials.find((credential) => {
return credential.appId === successPayment.appId;
});
if (!paymentAppCredential) {
throw new Error("Payment app credentials not found");
}
// Posible to refactor TODO:
const paymentApp = (await appStore[
paymentAppCredential?.app?.dirName as keyof typeof appStore
]?.()) as PaymentApp;
if (!paymentApp?.lib?.PaymentService) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const PaymentService = paymentApp.lib.PaymentService as any;
const paymentInstance = new PaymentService(paymentAppCredential) as IAbstractPaymentService;
const paymentData = await paymentInstance.refund(successPayment.id);
if (!paymentData.refunded) {
throw new Error("Payment could not be refunded");
}
}
await processPaymentRefund({
booking: booking,
teamId: booking.eventType?.teamId,
});
}
// end handle refunds.