chore: [app-router-migration 17]: migrate payments page (#13039)

* chore: migrate payments page

* migrate page to WithLayout HOC, replace new ssrInit

* fix type error

* fix

* revert version changes

* fix

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
DmytroHryshyn 2024-01-12 23:36:48 +02:00 committed by GitHub
parent abd90f6af8
commit a28b9cacd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 748 additions and 105 deletions

View File

@ -1,7 +1,6 @@
import LegacyPage from "@pages/getting-started/[[...step]]";
import { WithLayout } from "app/layoutHOC";
import type { GetServerSidePropsContext } from "next";
import { cookies, headers } from "next/headers";
import { type GetServerSidePropsContext } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
@ -10,10 +9,7 @@ import prisma from "@calcom/prisma";
import { ssrInit } from "@server/lib/ssr";
const getData = async (ctx: GetServerSidePropsContext) => {
const req = { headers: headers(), cookies: cookies() };
//@ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest
const session = await getServerSession({ req });
const session = await getServerSession({ req: ctx.req });
if (!session?.user?.id) {
return redirect("/auth/login");

View File

@ -0,0 +1,167 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { redirect, notFound } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import PaymentPage from "@calcom/features/ee/payments/components/PaymentPage";
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
import { APP_NAME } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { ssrInit } from "@server/lib/ssr";
export const generateMetadata = async () =>
await _generateMetadata(
// the title does not contain the eventName as in the legacy page
(t) => `${t("payment")} | ${APP_NAME}`,
() => ""
);
const querySchema = z.object({
uid: z.string(),
});
async function getData(context: GetServerSidePropsContext) {
const session = await getServerSession({ req: context.req });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const ssr = await ssrInit(context);
await ssr.viewer.me.prefetch();
const { uid } = querySchema.parse(context.params);
const rawPayment = await prisma.payment.findFirst({
where: {
uid,
},
select: {
data: true,
success: true,
uid: true,
refunded: true,
bookingId: true,
appId: true,
amount: true,
currency: true,
paymentOption: true,
booking: {
select: {
id: true,
uid: true,
description: true,
title: true,
startTime: true,
endTime: true,
attendees: {
select: {
email: true,
name: true,
},
},
eventTypeId: true,
location: true,
status: true,
rejectionReason: true,
cancellationReason: true,
eventType: {
select: {
id: true,
title: true,
description: true,
length: true,
eventName: true,
requiresConfirmation: true,
userId: true,
metadata: true,
users: {
select: {
name: true,
username: true,
hideBranding: true,
theme: true,
},
},
team: {
select: {
name: true,
hideBranding: true,
},
},
price: true,
currency: true,
successRedirectUrl: true,
},
},
},
},
},
});
if (!rawPayment) {
return notFound();
}
const { data, booking: _booking, ...restPayment } = rawPayment;
const payment = {
...restPayment,
data: data as Record<string, unknown>,
};
if (!_booking) {
return notFound();
}
const { startTime, endTime, eventType, ...restBooking } = _booking;
const booking = {
...restBooking,
startTime: startTime.toString(),
endTime: endTime.toString(),
};
if (!eventType) {
return notFound();
}
if (eventType.users.length === 0 && !!!eventType.team) {
return notFound();
}
const [user] = eventType?.users.length
? eventType.users
: [{ name: null, theme: null, hideBranding: null, username: null }];
const profile = {
name: eventType.team?.name || user?.name || null,
theme: (!eventType.team?.name && user?.theme) || null,
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
};
if (
([BookingStatus.CANCELLED, BookingStatus.REJECTED] as BookingStatus[]).includes(
booking.status as BookingStatus
)
) {
return redirect(`/booking/${booking.uid}`);
}
return {
user,
eventType: {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
},
booking,
dehydratedState: ssr.dehydrate(),
payment,
clientSecret: getClientSecretFromPayment(payment),
profile,
};
}
export default WithLayout({ getLayout: null, getData, Page: PaymentPage });

View File

@ -0,0 +1,68 @@
import { expect } from "@playwright/test";
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("Payment", () => {
test.describe("user", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("should create a mock payment for a user", async ({ context, users, page }) => {
test.skip(process.env.MOCK_PAYMENT_APP_ENABLED === undefined, "Skipped as Stripe is not installed");
const user = await users.create();
await user.apiLogin();
await context.addCookies([
{
name: "x-calcom-future-routes-override",
value: "1",
url: "http://localhost:3000",
},
]);
await page.goto("/apps");
await page.getByPlaceholder("Search").click();
await page.getByPlaceholder("Search").fill("mock");
await page.getByTestId("install-app-button").click();
await page.waitForURL((url) => url.pathname.endsWith("/apps/installed/payment"));
await page.getByRole("link", { name: "Event Types" }).click();
await page.getByRole("link", { name: /^30 min/ }).click();
await page.getByTestId("vertical-tab-apps").click();
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("1");
await page.locator("#test-mock-payment-app-currency-id").click();
await page.getByTestId("select-option-USD").click();
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/30-min`);
await page.waitForLoadState("networkidle");
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await page.waitForURL((url) => url.pathname.includes("/payment/"));
const dataNextJsRouter = await page.evaluate(() =>
window.document.documentElement.getAttribute("data-nextjs-router")
);
expect(dataNextJsRouter).toEqual("app");
await page.getByText("Payment", { exact: true }).waitFor();
});
});
});

View File

@ -1046,72 +1046,77 @@ export function mockCalendar(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deleteEventCalls: any[] = [];
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
CalendarService: function MockCalendarService() {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
if (calendarData?.creationCrash) {
throw new Error("MockCalendarService.createEvent fake error");
}
const [calEvent, credentialId] = rest;
log.silly("mockCalendar.createEvent", JSON.stringify({ calEvent, credentialId }));
createEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
// A Calendar is always expected to return an id.
id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID",
iCalUID: normalizedCalendarData.create?.iCalUID,
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
if (calendarData?.updationCrash) {
throw new Error("MockCalendarService.updateEvent fake error");
}
const [uid, event, externalCalendarId] = rest;
log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId }));
// eslint-disable-next-line prefer-rest-params
updateEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
iCalUID: normalizedCalendarData.update?.iCalUID,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID",
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deleteEvent: async (...rest: any[]) => {
log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest }));
// eslint-disable-next-line prefer-rest-params
deleteEventCalls.push(rest);
},
getAvailability: async (): Promise<EventBusyDate[]> => {
if (calendarData?.getAvailabilityCrash) {
throw new Error("MockCalendarService.getAvailability fake error");
}
return new Promise((resolve) => {
resolve(calendarData?.busySlots || []);
});
},
};
const appMock = appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default];
appMock &&
`mockResolvedValue` in appMock &&
appMock.mockResolvedValue({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
CalendarService: function MockCalendarService() {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
if (calendarData?.creationCrash) {
throw new Error("MockCalendarService.createEvent fake error");
}
const [calEvent, credentialId] = rest;
log.silly("mockCalendar.createEvent", JSON.stringify({ calEvent, credentialId }));
createEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
// A Calendar is always expected to return an id.
id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID",
iCalUID: normalizedCalendarData.create?.iCalUID,
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
if (calendarData?.updationCrash) {
throw new Error("MockCalendarService.updateEvent fake error");
}
const [uid, event, externalCalendarId] = rest;
log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId }));
// eslint-disable-next-line prefer-rest-params
updateEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
iCalUID: normalizedCalendarData.update?.iCalUID,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID",
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deleteEvent: async (...rest: any[]) => {
log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest }));
// eslint-disable-next-line prefer-rest-params
deleteEventCalls.push(rest);
},
getAvailability: async (): Promise<EventBusyDate[]> => {
if (calendarData?.getAvailabilityCrash) {
throw new Error("MockCalendarService.getAvailability fake error");
}
return new Promise((resolve) => {
resolve(calendarData?.busySlots || []);
});
},
};
},
},
},
});
});
return {
createEventCalls,
deleteEventCalls,

View File

@ -109,6 +109,7 @@
"turbo": "^1.10.1"
},
"resolutions": {
"types-ramda": "0.29.4",
"@apidevtools/json-schema-ref-parser": "9.0.9",
"@types/node": "16.9.1",
"@types/react": "18.0.26",

View File

@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useMemo } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
@ -21,7 +21,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
eventType,
eventTypeFormMetadata,
}) {
const { asPath } = useRouter();
const searchParams = useSearchParams();
/** TODO "pathname" no longer contains square-bracket expressions. Rewrite the code relying on them if required. **/
const pathname = usePathname();
const asPath = useMemo(
() => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`,
[pathname, searchParams]
);
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");

View File

@ -29,6 +29,7 @@ export const EventTypeAddonMap = {
gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")),
matomo: dynamic(() => import("./matomo/components/EventTypeAppCardInterface")),
metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")),
"mock-payment-app": dynamic(() => import("./mock-payment-app/components/EventTypeAppCardInterface")),
paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")),
plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")),
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),

View File

@ -17,6 +17,7 @@ import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appKeysSchema as make_zod_ts } from "./make/zod";
import { appKeysSchema as matomo_zod_ts } from "./matomo/zod";
import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appKeysSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod";
import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appKeysSchema as office365video_zod_ts } from "./office365video/zod";
import { appKeysSchema as paypal_zod_ts } from "./paypal/zod";
@ -55,6 +56,7 @@ export const appKeysSchemas = {
make: make_zod_ts,
matomo: matomo_zod_ts,
metapixel: metapixel_zod_ts,
"mock-payment-app": mock_payment_app_zod_ts,
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
paypal: paypal_zod_ts,

View File

@ -36,6 +36,7 @@ import make_config_json from "./make/config.json";
import matomo_config_json from "./matomo/config.json";
import metapixel_config_json from "./metapixel/config.json";
import mirotalk_config_json from "./mirotalk/config.json";
import mock_payment_app_config_json from "./mock-payment-app/config.json";
import n8n_config_json from "./n8n/config.json";
import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata";
import office365video_config_json from "./office365video/config.json";
@ -115,6 +116,7 @@ export const appStoreMetadata = {
matomo: matomo_config_json,
metapixel: metapixel_config_json,
mirotalk: mirotalk_config_json,
"mock-payment-app": mock_payment_app_config_json,
n8n: n8n_config_json,
office365calendar: office365calendar__metadata_ts,
office365video: office365video_config_json,

View File

@ -17,6 +17,7 @@ import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appDataSchema as make_zod_ts } from "./make/zod";
import { appDataSchema as matomo_zod_ts } from "./matomo/zod";
import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appDataSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod";
import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appDataSchema as office365video_zod_ts } from "./office365video/zod";
import { appDataSchema as paypal_zod_ts } from "./paypal/zod";
@ -55,6 +56,7 @@ export const appDataSchemas = {
make: make_zod_ts,
matomo: matomo_zod_ts,
metapixel: metapixel_zod_ts,
"mock-payment-app": mock_payment_app_zod_ts,
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
paypal: paypal_zod_ts,

View File

@ -36,6 +36,7 @@ export const apiHandlers = {
matomo: import("./matomo/api"),
metapixel: import("./metapixel/api"),
mirotalk: import("./mirotalk/api"),
"mock-payment-app": import("./mock-payment-app/api"),
n8n: import("./n8n/api"),
office365calendar: import("./office365calendar/api"),
office365video: import("./office365video/api"),

View File

@ -41,4 +41,12 @@ const appStore = {
shimmervideo: () => import("./shimmervideo"),
};
export default appStore;
const exportedAppStore: typeof appStore & {
["mock-payment-app"]?: () => Promise<typeof import("./mock-payment-app/index")>;
} = appStore;
if (process.env.MOCK_PAYMENT_APP_ENABLED !== undefined) {
exportedAppStore["mock-payment-app"] = () => import("./mock-payment-app/index");
}
export default exportedAppStore;

View File

@ -0,0 +1,8 @@
---
items:
- 1.jpeg
- 2.jpeg
- 3.jpeg
---
{DESCRIPTION}

View File

@ -0,0 +1,16 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
};
export default handler;

View File

@ -0,0 +1 @@
export { default as add } from "./add";

View File

@ -0,0 +1,136 @@
import { usePathname, useSearchParams } from "next/navigation";
import { useState, useMemo } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import {
currencyOptions,
currencySymbols,
isAcceptedCurrencyCode,
} from "@calcom/app-store/paypal/lib/currencyOptions";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Select, TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
import { paymentOptions } from "../zod";
type Option = { value: string; label: string };
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const searchParams = useSearchParams();
/** TODO "pathname" no longer contains square-bracket expressions. Rewrite the code relying on them if required. **/
const pathname = usePathname();
const asPath = useMemo(
() => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`,
[pathname, searchParams]
);
const { t } = useLocale();
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
const paymentOption = getAppData("paymentOption");
const enable = getAppData("enabled");
const [selectedCurrency, setSelectedCurrency] = useState(currencyOptions.find((c) => c.value === currency));
const [currencySymbol, setCurrencySymbol] = useState(
isAcceptedCurrencyCode(currency) ? currencySymbols[currency] : ""
);
const [requirePayment, setRequirePayment] = useState(enable);
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
label: paymentOptions[0].label,
value: paymentOptions[0].value,
};
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
return (
<AppCard
returnTo={WEBAPP_URL + asPath}
app={app}
switchChecked={requirePayment}
switchOnClick={(enabled) => {
setRequirePayment(enabled);
}}
description={<>Add a mock payment to your events</>}>
<>
{recurringEventDefined ? (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<>
<div className="mt-2 block items-center sm:flex">
<TextField
id="test-mock-payment-app-price"
label="Price"
labelSrOnly
addOnLeading={currencySymbol}
addOnSuffix={currency}
step="0.01"
min="0.5"
type="number"
required
className="block w-full rounded-sm pl-2 text-sm"
placeholder="Price"
onChange={(e) => {
setAppData("price", Number(e.target.value) * 100);
if (selectedCurrency) {
setAppData("currency", selectedCurrency.value);
}
}}
value={price > 0 ? price / 100 : undefined}
/>
</div>
<div className="mt-5 w-60">
<label className="text-default mb-1 block text-sm font-medium" htmlFor="currency">
{t("currency")}
</label>
<Select
id="test-mock-payment-app-currency-id"
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
setSelectedCurrency(e);
setCurrencySymbol(currencySymbols[e.value]);
setAppData("currency", e.value);
}
}}
/>
</div>
<div className="mt-4 w-60">
<label className="text-default mb-1 block text-sm font-medium" htmlFor="currency">
Payment option
</label>
<Select<Option>
defaultValue={
paymentOptionSelectValue
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
}
options={paymentOptions.map((option) => {
return { ...option, label: t(option.label) || option.label };
})}
onChange={(input) => {
if (input) setAppData("paymentOption", input.value);
}}
className="mb-1 h-[38px] w-full"
isDisabled={false}
/>
</div>
</>
)
)}
</>
</AppCard>
);
};
export default EventTypeAppCard;

View File

@ -0,0 +1,18 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Mock Payment App",
"slug": "mock-payment-app",
"type": "mock-payment-app_payment",
"logo": "icon.svg",
"url": "https://example.com/link",
"variant": "payment",
"categories": ["payment"],
"publisher": "Intuita",
"email": "greg@intuita.io",
"description": "The mock payment app for tests",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
"dirName": "mock-payment-app",
"extendsFeature": "EventType"
}

View File

@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";

View File

@ -0,0 +1,141 @@
import type { Booking, Payment, Prisma, PaymentOption } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
export class PaymentService implements IAbstractPaymentService {
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"]
) {
try {
const booking = await prisma.booking.findFirst({
select: {
uid: true,
title: true,
},
where: {
id: bookingId,
},
});
if (booking === null) {
throw new Error("Booking not found");
}
const uid = uuidv4();
console.log("CREATE payment");
const paymentData = await prisma.payment.create({
data: {
uid,
app: {
connect: {
slug: "mock-payment-app",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
externalId: uid,
currency: payment.currency,
data: {} as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
},
});
return paymentData;
} catch (error) {
console.error(error);
throw new Error("Payment could not be created");
}
}
async update(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async refund(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async collectCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: number,
_bookerEmail: string,
paymentOption: PaymentOption
): Promise<Payment> {
try {
const booking = await prisma.booking.findFirst({
select: {
uid: true,
title: true,
},
where: {
id: bookingId,
},
});
if (booking === null) {
throw new Error("Booking not found");
}
const uid = uuidv4();
const paymentData = await prisma.payment.create({
data: {
uid,
app: {
connect: {
slug: "paypal",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
currency: payment.currency,
data: {} as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
paymentOption,
externalId: "",
},
});
if (!paymentData) {
throw new Error();
}
return paymentData;
} catch (error) {
console.error(error);
throw new Error("Payment could not be created");
}
}
chargeCard(): Promise<Payment> {
throw new Error("Method not implemented.");
}
getPaymentPaidStatus(): Promise<string> {
throw new Error("Method not implemented.");
}
getPaymentDetails(): Promise<Payment> {
throw new Error("Method not implemented.");
}
afterPayment(): Promise<void> {
return Promise.resolve();
}
deletePayment(): Promise<boolean> {
return Promise.resolve(false);
}
isSetupAlready(): boolean {
return true;
}
}

View File

@ -0,0 +1 @@
export { PaymentService } from "./PaymentService";

View File

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/mock-payment-app",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "The mock payment app for tests"
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" viewBox="0 0 48 48"><title>blockchain</title><g class="nc-icon-wrapper"><path d="M32.874,10.405a.99.99,0,0,0-.353-.369l-8-4.889a1,1,0,0,0-1.042,0l-8,4.889a.99.99,0,0,0-.353.369L24,15.828Z" fill="#a0e6ee"></path><path d="M15.126,10.405a1,1,0,0,0-.126.484v8.878a1,1,0,0,0,.479.853l8,4.889a1,1,0,0,0,.521.147V15.828Z" fill="#6cc4f5"></path><path d="M32.874,10.405a1,1,0,0,1,.126.484v8.878a1,1,0,0,1-.479.853l-8,4.889a1,1,0,0,1-.521.147V15.828Z" fill="#2594d0"></path><path d="M47.874,19.589a.99.99,0,0,0-.353-.369l-8-4.889a1,1,0,0,0-1.042,0l-8,4.889a.99.99,0,0,0-.353.369L39,25.013Z" fill="#a0e6ee"></path><path d="M30.126,19.589a1,1,0,0,0-.126.485v8.878a1,1,0,0,0,.479.853l8,4.889a1,1,0,0,0,.521.147V25.013Z" fill="#6cc4f5"></path><path d="M47.874,19.589a1,1,0,0,1,.126.485v8.878a1,1,0,0,1-.479.853l-8,4.889a1,1,0,0,1-.521.147V25.013Z" fill="#2594d0"></path><path d="M17.874,19.589a.99.99,0,0,0-.353-.369l-8-4.889a1,1,0,0,0-1.042,0l-8,4.889a.99.99,0,0,0-.353.369L9,25.013Z" fill="#a0e6ee"></path><path d="M.126,19.589A1,1,0,0,0,0,20.074v8.878a1,1,0,0,0,.479.853l8,4.889A1,1,0,0,0,9,34.841V25.013Z" fill="#6cc4f5"></path><path d="M17.874,19.589a1,1,0,0,1,.126.485v8.878a1,1,0,0,1-.479.853l-8,4.889A1,1,0,0,1,9,34.841V25.013Z" fill="#2594d0"></path><path d="M32.874,29.577a.99.99,0,0,0-.353-.369l-8-4.889a1,1,0,0,0-1.042,0l-8,4.889a.99.99,0,0,0-.353.369L24,35Z" fill="#a0e6ee"></path><path d="M15.126,29.577a1,1,0,0,0-.126.484v8.878a1,1,0,0,0,.479.853l8,4.889a1,1,0,0,0,.521.147V35Z" fill="#6cc4f5"></path><path d="M32.874,29.577a1,1,0,0,1,.126.484v8.878a1,1,0,0,1-.479.853l-8,4.889a1,1,0,0,1-.521.147V35Z" fill="#2594d0"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,35 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
const paymentOptionSchema = z.object({
label: z.string(),
value: z.string(),
});
export const paymentOptionsSchema = z.array(paymentOptionSchema);
export const paymentOptions = [
{
label: "on_booking_option",
value: "ON_BOOKING",
},
];
type PaymentOption = (typeof paymentOptions)[number]["value"];
const VALUES: [PaymentOption, ...PaymentOption[]] = [
paymentOptions[0].value,
...paymentOptions.slice(1).map((option) => option.value),
];
export const paymentOptionEnum = z.enum(VALUES);
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
price: z.number(),
currency: z.string(),
paymentOption: z.string().optional(),
enabled: z.boolean().optional(),
})
);
export const appKeysSchema = z.object({});

View File

@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useMemo } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
@ -24,7 +24,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
eventType,
eventTypeFormMetadata,
}) {
const { asPath } = useRouter();
const searchParams = useSearchParams();
/** TODO "pathname" no longer contains square-bracket expressions. Rewrite the code relying on them if required. **/
const pathname = usePathname();
const asPath = useMemo(
() => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`,
[pathname, searchParams]
);
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");

View File

@ -597,7 +597,8 @@ async function handler(req: CustomRequest) {
// Posible to refactor TODO:
const paymentApp = (await appStore[
paymentAppCredential?.app?.dirName as keyof typeof appStore
]()) as PaymentApp;
]?.()) as PaymentApp;
if (!paymentApp?.lib?.PaymentService) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return null;

View File

@ -1,3 +1,5 @@
"use client";
import classNames from "classnames";
import dynamic from "next/dynamic";
import Head from "next/head";

View File

@ -0,0 +1,21 @@
import type { Payment } from "@calcom/prisma/client";
function hasStringProp<T extends string>(x: unknown, key: T): x is { [key in T]: string } {
return !!x && typeof x === "object" && key in x;
}
export function getClientSecretFromPayment(
payment: Omit<Partial<Payment>, "data"> & { data: Record<string, unknown> }
) {
if (
payment.paymentOption === "HOLD" &&
hasStringProp(payment.data, "setupIntent") &&
hasStringProp(payment.data.setupIntent, "client_secret")
) {
return payment.data.setupIntent.client_secret;
}
if (hasStringProp(payment.data, "client_secret")) {
return payment.data.client_secret;
}
return "";
}

View File

@ -1,7 +1,7 @@
import type { Payment } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@ -9,7 +9,7 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { ssrInit } from "../../../../../apps/web/server/lib/ssr";
export type PaymentPageProps = inferSSRProps<typeof getServerSideProps>;
export type PaymentPageProps = Omit<inferSSRProps<typeof getServerSideProps>, "trpcState">;
const querySchema = z.object({
uid: z.string(),
@ -145,23 +145,3 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
};
};
function hasStringProp<T extends string>(x: unknown, key: T): x is { [key in T]: string } {
return !!x && typeof x === "object" && key in x;
}
function getClientSecretFromPayment(
payment: Omit<Partial<Payment>, "data"> & { data: Record<string, unknown> }
) {
if (
payment.paymentOption === "HOLD" &&
hasStringProp(payment.data, "setupIntent") &&
hasStringProp(payment.data.setupIntent, "client_secret")
) {
return payment.data.setupIntent.client_secret;
}
if (hasStringProp(payment.data, "client_secret")) {
return payment.data.client_secret;
}
return "";
}

View File

@ -17,7 +17,7 @@ const deletePayment = async (
): Promise<boolean> => {
const paymentApp = (await appStore[
paymentAppCredentials?.app?.dirName as keyof typeof appStore
]()) as PaymentApp;
]?.()) as PaymentApp;
if (!paymentApp?.lib?.PaymentService) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return false;

View File

@ -29,7 +29,7 @@ const handlePayment = async (
) => {
const paymentApp = (await appStore[
paymentAppCredentials?.app?.dirName as keyof typeof appStore
]()) as PaymentApp;
]?.()) as PaymentApp;
if (!paymentApp?.lib?.PaymentService) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return null;

View File

@ -168,7 +168,7 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
// undefined it means that app don't require app/setup/page
let isSetupAlready = undefined;
if (credential && app.categories.includes("payment")) {
const paymentApp = (await appStore[app.dirName as keyof typeof appStore]()) as PaymentApp | null;
const paymentApp = (await appStore[app.dirName as keyof typeof appStore]?.()) as PaymentApp | null;
if (paymentApp && "lib" in paymentApp && paymentApp?.lib && "PaymentService" in paymentApp?.lib) {
const PaymentService = paymentApp.lib.PaymentService;
const paymentInstance = new PaymentService(credential);

View File

@ -302,7 +302,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
// Posible to refactor TODO:
const paymentApp = (await appStore[
paymentAppCredential?.app?.dirName as keyof typeof appStore
]()) as PaymentApp;
]?.()) as PaymentApp;
if (!paymentApp?.lib?.PaymentService) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return null;

View File

@ -96,7 +96,7 @@ export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions
const paymentApp = (await appStore[
paymentCredential?.app?.dirName as keyof typeof appStore
]()) as PaymentApp;
]?.()) as PaymentApp;
if (!paymentApp?.lib?.PaymentService) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Payment service not found" });

View File

@ -276,6 +276,7 @@
"LARK_OPEN_APP_ID",
"LARK_OPEN_APP_SECRET",
"LARK_OPEN_VERIFICATION_TOKEN",
"MOCK_PAYMENT_APP_ENABLED",
"MS_GRAPH_CLIENT_ID",
"MS_GRAPH_CLIENT_SECRET",
"NEXT_PUBLIC_API_URL",