diff --git a/apps/web/pages/api/integrations/paypal/webhook.ts b/apps/web/pages/api/integrations/paypal/webhook.ts new file mode 100644 index 0000000000..d085cb74cb --- /dev/null +++ b/apps/web/pages/api/integrations/paypal/webhook.ts @@ -0,0 +1 @@ +export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook"; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 066a1ca9ba..f9318aa350 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1903,6 +1903,7 @@ "first_event_type_webhook_description": "Create your first webhook for this event type", "install_app_on": "Install app on", "create_for": "Create for", + "currency": "Currency", "setup_organization": "Setup an Organization", "organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.", "organization_banner_title": "Manage organizations with multiple teams", diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index 6d5ae061b6..22331872a6 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -11,6 +11,7 @@ export const AppSetupMap = { zapier: dynamic(() => import("../../zapier/pages/setup")), closecom: dynamic(() => import("../../closecom/pages/setup")), sendgrid: dynamic(() => import("../../sendgrid/pages/setup")), + paypal: dynamic(() => import("../../paypal/pages/setup")), }; export const AppSetupPage = (props: { slug: string }) => { diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index 1149b82063..a879018b4e 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -25,6 +25,7 @@ export const EventTypeAddonMap = { giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")), gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")), metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")), + paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")), plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")), qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")), stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")), diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index 5e28e70e72..b0086b3022 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -14,6 +14,7 @@ import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; import { appKeysSchema as metapixel_zod_ts } from "./metapixel/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"; import { appKeysSchema as plausible_zod_ts } from "./plausible/zod"; import { appKeysSchema as qr_code_zod_ts } from "./qr_code/zod"; import { appKeysSchema as routing_forms_zod_ts } from "./routing-forms/zod"; @@ -43,6 +44,7 @@ export const appKeysSchemas = { metapixel: metapixel_zod_ts, office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, + paypal: paypal_zod_ts, plausible: plausible_zod_ts, qr_code: qr_code_zod_ts, "routing-forms": routing_forms_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 94e8d7b00e..17dfaee2e6 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -32,6 +32,7 @@ import mirotalk_config_json from "./mirotalk/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"; +import paypal_config_json from "./paypal/config.json"; import ping_config_json from "./ping/config.json"; import pipedream_config_json from "./pipedream/config.json"; import plausible_config_json from "./plausible/config.json"; @@ -99,6 +100,7 @@ export const appStoreMetadata = { n8n: n8n_config_json, office365calendar: office365calendar__metadata_ts, office365video: office365video_config_json, + paypal: paypal_config_json, ping: ping_config_json, pipedream: pipedream_config_json, plausible: plausible_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 98c57b824f..deaabc768f 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -14,6 +14,7 @@ import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; import { appDataSchema as metapixel_zod_ts } from "./metapixel/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"; import { appDataSchema as plausible_zod_ts } from "./plausible/zod"; import { appDataSchema as qr_code_zod_ts } from "./qr_code/zod"; import { appDataSchema as routing_forms_zod_ts } from "./routing-forms/zod"; @@ -43,6 +44,7 @@ export const appDataSchemas = { metapixel: metapixel_zod_ts, office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, + paypal: paypal_zod_ts, plausible: plausible_zod_ts, qr_code: qr_code_zod_ts, "routing-forms": routing_forms_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 5016fe0356..01c09129f4 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -32,6 +32,7 @@ export const apiHandlers = { n8n: import("./n8n/api"), office365calendar: import("./office365calendar/api"), office365video: import("./office365video/api"), + paypal: import("./paypal/api"), ping: import("./ping/api"), pipedream: import("./pipedream/api"), plausible: import("./plausible/api"), diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 54168d2e8d..5dd9d7face 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -14,6 +14,7 @@ const appStore = { office365calendar: () => import("./office365calendar"), office365video: () => import("./office365video"), plausible: () => import("./plausible"), + paypal: () => import("./paypal"), salesforce: () => import("./salesforce"), zohocrm: () => import("./zohocrm"), sendgrid: () => import("./sendgrid"), diff --git a/packages/app-store/paypal/DESCRIPTION.md b/packages/app-store/paypal/DESCRIPTION.md new file mode 100644 index 0000000000..a4b15502c3 --- /dev/null +++ b/packages/app-store/paypal/DESCRIPTION.md @@ -0,0 +1,8 @@ +--- +items: + - 1.jpeg + - 2.jpeg + - 3.jpeg +--- + +{DESCRIPTION} diff --git a/packages/app-store/paypal/api/add.ts b/packages/app-store/paypal/api/add.ts new file mode 100644 index 0000000000..bab1b9390c --- /dev/null +++ b/packages/app-store/paypal/api/add.ts @@ -0,0 +1,42 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; + +import config from "../config.json"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session?.user?.id) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + const appType = config.type; + try { + const alreadyInstalled = await prisma.credential.findFirst({ + where: { + type: appType, + userId: req.session.user.id, + }, + }); + if (alreadyInstalled) { + throw new Error("Already installed"); + } + const installation = await prisma.credential.create({ + data: { + type: appType, + key: {}, + userId: req.session.user.id, + appId: "paypal", + }, + }); + + if (!installation) { + throw new Error("Unable to create user credential for Paypal"); + } + } catch (error: unknown) { + if (error instanceof Error) { + return res.status(500).json({ message: error.message }); + } + return res.status(500); + } + + return res.status(200).json({ url: "/apps/paypal/setup" }); +} diff --git a/packages/app-store/paypal/api/capture.ts b/packages/app-store/paypal/api/capture.ts new file mode 100644 index 0000000000..14c577f14c --- /dev/null +++ b/packages/app-store/paypal/api/capture.ts @@ -0,0 +1,94 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import z from "zod"; + +import Paypal from "@calcom/app-store/paypal/lib/Paypal"; +import { findPaymentCredentials } from "@calcom/features/ee/payments/api/paypal-webhook"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { getErrorFromUnknown } from "@calcom/lib/errors"; +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + // Look if it's get + if (req.method !== "GET") { + throw new Error("Invalid method"); + } + + // Look if bookingUid it's provided in query params using zod + const parseRequest = captureRequestSchema.safeParse(req.query); + if (!parseRequest.success) { + throw new Error("Request is malformed"); + } + + // Get bookingUid and token from query params + const { bookingUid, token } = parseRequest.data; + + // Get booking credentials + const booking = await prisma.booking.findUnique({ + where: { + uid: bookingUid, + }, + select: { + id: true, + userId: true, + }, + }); + + if (!booking) { + throw new Error("Booking not found"); + } + + const credentials = await findPaymentCredentials(booking?.id); + + if (!credentials) { + throw new Error("Credentials not found"); + } + + // Get paypal instance + const paypalClient = new Paypal(credentials); + + // capture payment + const capture = await paypalClient.captureOrder(token); + + if (!capture) { + res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=failed`); + } + if (IS_PRODUCTION) { + res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=success`); + } else { + // For cal.dev, paypal sandbox doesn't send webhooks + const updateBooking = prisma.booking.update({ + where: { + uid: bookingUid, + }, + data: { + paid: true, + }, + }); + const updatePayment = prisma.payment.update({ + where: { + id: booking?.id, + }, + data: { + success: true, + }, + }); + await Promise.all([updateBooking, updatePayment]); + res.redirect(`/booking/${bookingUid}?paypalPaymentStatus=success`); + } + return; + } catch (_err) { + const err = getErrorFromUnknown(_err); + + res.status(200).send({ + message: err.message, + stack: IS_PRODUCTION ? undefined : err.stack, + }); + res.redirect(`/booking/${req.query.bookingUid}?paypalPaymentStatus=failed`); + } +} + +const captureRequestSchema = z.object({ + bookingUid: z.string(), + token: z.string(), +}); diff --git a/packages/app-store/paypal/api/index.ts b/packages/app-store/paypal/api/index.ts new file mode 100644 index 0000000000..991a95c0c7 --- /dev/null +++ b/packages/app-store/paypal/api/index.ts @@ -0,0 +1,3 @@ +export { default as add } from "./add"; +export { default as webhook, config } from "@calcom/web/pages/api/integrations/paypal/webhook"; +export { default as capture } from "./capture"; diff --git a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..fe90a8ed6c --- /dev/null +++ b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,125 @@ +import { useRouter } from "next/router"; +import { useState } from "react"; + +import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; +import AppCard from "@calcom/app-store/_components/AppCard"; +import { currencyOptions } from "@calcom/app-store/paypal/pages/setup"; +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 { PaypalPaymentOptions as paymentOptions } from "../zod"; + +type Option = { value: string; label: string }; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const { asPath } = useRouter(); + const [getAppData, setAppData] = useAppContextWithSchema(); + const price = getAppData("price"); + const currency = getAppData("currency"); + const [selectedCurrency, setSelectedCurrency] = useState( + currencyOptions.find((c) => c.value === currency) || { + label: currencyOptions[0].label, + value: currencyOptions[0].value, + } + ); + const paymentOption = getAppData("paymentOption"); + const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || { + label: paymentOptions[0].label, + value: paymentOptions[0].value, + }; + const seatsEnabled = !!eventType.seatsPerTimeSlot; + const [requirePayment, setRequirePayment] = useState(getAppData("enabled")); + const { t } = useLocale(); + const recurringEventDefined = eventType.recurringEvent?.count !== undefined; + + return ( + { + setRequirePayment(enabled); + }} + description={<>Add Paypal payment to your events}> + <> + {recurringEventDefined ? ( + + ) : ( + requirePayment && ( + <> +
+ { + setAppData("price", Number(e.target.value) * 100); + if (currency) { + setAppData("currency", currency); + } + }} + value={price > 0 ? price / 100 : undefined} + /> +
+
+ +