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:
co-authored by
Peer Richelsen
parent
e722e75a6f
commit
c1dbb7cffd
@@ -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"> {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"> {t("before")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+138
-1
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user