feat: app paypal payment (#8797)

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
pull/10538/head^2
alannnc 2023-08-11 16:29:48 -07:00 committed by GitHub
parent 9f7152c4eb
commit 471b2687c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1659 additions and 46 deletions

View File

@ -0,0 +1 @@
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";

View File

@ -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",

View File

@ -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 }) => {

View File

@ -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")),

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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"),

View File

@ -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"),

View File

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

View File

@ -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" });
}

View File

@ -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(),
});

View File

@ -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";

View File

@ -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<typeof appDataSchema>();
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 (
<AppCard
returnTo={WEBAPP_URL + asPath}
setAppData={setAppData}
app={app}
switchChecked={requirePayment}
switchOnClick={(enabled) => {
setRequirePayment(enabled);
}}
description={<>Add Paypal 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
label="Price"
labelSrOnly
addOnLeading="$"
addOnSuffix={currency || "No selected currency"}
step="0.01"
min="0.5"
type="number"
required
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
placeholder="Price"
onChange={(e) => {
setAppData("price", Number(e.target.value) * 100);
if (currency) {
setAppData("currency", currency);
}
}}
value={price > 0 ? price / 100 : undefined}
/>
</div>
<div className="mt-5 w-60">
<label className="text-default block text-sm font-medium" htmlFor="currency">
{t("currency")}
</label>
<Select
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
setSelectedCurrency(e);
setAppData("currency", e.value);
}
}}
/>
</div>
<div className="mt-2 w-60">
<label className="text-default 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={seatsEnabled}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
</>
)
)}
</>
</AppCard>
);
};
export default EventTypeAppCard;

View File

@ -0,0 +1,60 @@
import Link from "next/link";
import z from "zod";
interface IPaypalPaymentComponentProps {
payment: {
// Will be parsed on render
data: unknown;
};
}
// Create zod schema for data
const PaymentPaypalDataSchema = z.object({
order: z
.object({
id: z.string(),
status: z.string(),
links: z.array(
z.object({
href: z.string(),
rel: z.string(),
method: z.string(),
})
),
})
.optional(),
capture: z.object({}).optional(),
});
export const PaypalPaymentComponent = (props: IPaypalPaymentComponentProps) => {
const { payment } = props;
const { data } = payment;
const wrongUrl = (
<>
<p className="mt-3 text-center">Couldn&apos;t obtain payment URL</p>
</>
);
const parsedData = PaymentPaypalDataSchema.safeParse(data);
if (!parsedData.success || !parsedData.data?.order?.links) {
return wrongUrl;
}
const paymentUrl = parsedData.data.order.links.find(
(link) => link.rel === "approve" || link.rel === "payer-action"
)?.href;
if (!paymentUrl) {
return wrongUrl;
}
return (
<div className="mt-4 flex h-full w-full flex-col items-center justify-center">
<Link
href={`${paymentUrl}`}
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent bg-[#ffc439] px-12 py-2 text-base
font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
Pay with
<img src="/api/app-store/paypal/paypal-logo.svg" alt="Paypal" className="mx-2 mb-1 mt-2 w-16" />
<span />
</Link>
</div>
);
};

View File

@ -0,0 +1,18 @@
{
"name": "Paypal",
"slug": "paypal",
"type": "paypal_payment",
"logo": "icon.svg",
"url": "https://example.com/link",
"variant": "payment",
"categories": ["payment"],
"publisher": "Cal.com",
"email": "support@cal.com",
"description": "Paypal payment app by Cal.com",
"extendsFeature": "EventType",
"isTemplate": false,
"__createdUsingCli": true,
"imageSrc": "icon.svg",
"__template": "event-type-app-card",
"dirName": "paypal"
}

View File

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

View File

@ -0,0 +1,202 @@
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
import { paymentOptionEnum } from "../zod";
export const paypalCredentialKeysSchema = z.object({
client_id: z.string(),
secret_key: z.string(),
webhook_id: z.string(),
});
export class PaymentService implements IAbstractPaymentService {
private credentials: z.infer<typeof paypalCredentialKeysSchema>;
constructor(credentials: { key: Prisma.JsonValue }) {
this.credentials = paypalCredentialKeysSchema.parse(credentials.key);
}
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) {
throw new Error();
}
const uid = uuidv4();
const paypalClient = new Paypal({
clientId: this.credentials.client_id,
secretKey: this.credentials.secret_key,
});
const orderResult = await paypalClient.createOrder({
referenceId: uid,
amount: payment.amount,
currency: payment.currency,
returnUrl: `${WEBAPP_URL}/api/integrations/paypal/capture?bookingUid=${booking.uid}`,
cancelUrl: `${WEBAPP_URL}/payment/${uid}`,
});
const paymentData = await prisma.payment.create({
data: {
uid,
app: {
connect: {
slug: "paypal",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
externalId: orderResult.id,
currency: payment.currency,
data: Object.assign({}, { order: orderResult }) as unknown as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
},
});
if (!paymentData) {
throw new Error();
}
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> {
// Ensure that the payment service can support the passed payment option
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
throw new Error("Payment option is not compatible with create method");
}
try {
const booking = await prisma.booking.findFirst({
select: {
uid: true,
title: true,
},
where: {
id: bookingId,
},
});
if (!booking) {
throw new Error();
}
const paymentData = await prisma.payment.create({
data: {
uid: uuidv4(),
app: {
connect: {
slug: "paypal",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
currency: payment.currency,
data: {},
fee: 0,
refunded: false,
success: false,
paymentOption: paymentOption || "ON_BOOKING",
},
});
const paypalClient = new Paypal({
clientId: this.credentials.client_id,
secretKey: this.credentials.secret_key,
});
const preference = await paypalClient.createOrder({
referenceId: paymentData.uid,
amount: paymentData.amount,
currency: paymentData.currency,
returnUrl: `${WEBAPP_URL}/booking/${booking.uid}`,
cancelUrl: `${WEBAPP_URL}/payment/${paymentData.uid}`,
intent: "AUTHORIZE",
});
await prisma.payment.update({
where: {
id: paymentData.id,
},
data: {
externalId: preference?.id,
data: Object.assign({}, preference) as unknown as Prisma.InputJsonValue,
},
});
if (!paymentData) {
throw new Error();
}
return paymentData;
} catch (error) {
console.error(error);
throw new Error("Payment could not be created");
}
}
chargeCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: number
): 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(
event: CalendarEvent,
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
startTime: { toISOString: () => string };
uid: string;
},
paymentData: Payment
): Promise<void> {
return Promise.resolve();
}
deletePayment(paymentId: number): Promise<boolean> {
return Promise.resolve(false);
}
}

View File

@ -0,0 +1,395 @@
import type { Prisma } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
class Paypal {
url: string;
clientId: string;
secretKey: string;
accessToken: string | null = null;
expiresAt: number | null = null;
constructor({ clientId, secretKey }: { clientId: string; secretKey: string }) {
this.url = IS_PRODUCTION ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
this.clientId = clientId;
this.secretKey = secretKey;
}
async getAccessToken(): Promise<void> {
if (this.accessToken && this.expiresAt && this.expiresAt > Date.now()) {
return;
}
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.secretKey}`).toString("base64")}`,
};
const body = new URLSearchParams();
body.append("grant_type", "client_credentials");
try {
const response = await fetch(`${this.url}/v1/oauth2/token`, {
method: "POST",
headers,
body,
});
if (response.ok) {
const { access_token, expires_in } = await response.json();
this.accessToken = access_token;
this.expiresAt = Date.now() + expires_in;
} else {
console.error(`Request failed with status ${response.status}`);
}
} catch (error) {
console.error(error);
}
}
// Orders
async createOrder({
referenceId,
amount,
currency,
returnUrl,
cancelUrl,
intent = "CAPTURE",
}: {
referenceId: string;
amount: number;
currency: string;
returnUrl: string;
cancelUrl: string;
intent?: "CAPTURE" | "AUTHORIZE";
}): Promise<CreateOrderResponse> {
// Always get a new access token
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
"PayPal-Request-Id": uuidv4(),
};
const createOrderRequestBody: CreateOrderRequestBody = {
intent,
purchase_units: [
{
reference_id: referenceId,
amount: {
currency_code: currency,
value: (amount / 100).toString(),
},
},
],
payment_source: {
paypal: {
experience_context: {
user_action: "PAY_NOW",
return_url: returnUrl,
cancel_url: cancelUrl,
},
},
},
};
try {
const response = await fetch(`${this.url}/v2/checkout/orders`, {
method: "POST",
headers,
body: JSON.stringify(createOrderRequestBody),
});
if (response.ok) {
const createOrderResponse: CreateOrderResponse = await response.json();
return createOrderResponse;
} else {
console.error(`Request failed with status ${response.status}`);
}
} catch (error) {
console.error(error);
}
return {} as CreateOrderResponse;
}
async captureOrder(orderId: string): Promise<boolean> {
try {
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
};
const captureResult = await fetch(`${this.url}/v2/checkout/orders/${orderId}/capture`, {
method: "POST",
headers,
});
if (captureResult.ok) {
const result = await captureResult.json();
if (result.body.status === "COMPLETED") {
// Get payment reference id
const payment = await prisma.payment.findFirst({
where: {
externalId: orderId,
},
select: {
id: true,
bookingId: true,
data: true,
},
});
if (!payment) {
throw new Error("Payment not found");
}
await prisma.payment.update({
where: {
id: payment?.id,
},
data: {
success: true,
data: Object.assign(
{},
{ ...(payment?.data as Record<string, string | number>), capture: result.body.id }
) as unknown as Prisma.InputJsonValue,
},
});
// Update booking as paid
await prisma.booking.update({
where: {
id: payment.bookingId,
},
data: {
status: "ACCEPTED",
},
});
return true;
}
}
} catch (error) {
console.error(error);
return false;
}
return false;
}
async createWebhook(): Promise<boolean | string> {
// Always get a new access token
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
};
const body = {
url: `${WEBAPP_URL}/api/integrations/paypal/webhook`,
event_types: [
{
name: "CHECKOUT.ORDER.APPROVED",
},
{
name: "CHECKOUT.ORDER.COMPLETED",
},
],
};
try {
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (response.ok) {
const result = await response.json();
return result.id as string;
}
} catch (error) {
console.error(error);
}
return false;
}
async listWebhooks(): Promise<string[]> {
// Always get a new access token
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
};
try {
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
method: "GET",
headers,
});
if (response.ok) {
const { webhooks } = await response.json();
return webhooks
.filter((webhook: { id: string; url: string }) => {
return webhook.url.includes("api/integrations/paypal/webhook");
})
.map((webhook: { id: string }) => webhook.id);
}
} catch (error) {
console.error(error);
return [];
}
return [];
}
async deleteWebhook(webhookId: string): Promise<boolean> {
// Always get a new access token
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
};
try {
const response = await fetch(`${this.url}/v1/notifications/webhooks/${webhookId}`, {
method: "DELETE",
headers,
});
if (response.ok) {
return true;
}
} catch (error) {
console.error(error);
}
return false;
}
async test(): Promise<boolean> {
// Always get a new access token
try {
await this.getAccessToken();
} catch (error) {
console.error(error);
return false;
}
return true;
}
async verifyWebhook(options: WebhookEventVerifyRequest): Promise<void> {
const url = `${this.url}/v1/notifications/verify-webhook-signature`;
const parseRequest = webhookEventVerifyRequestSchema.safeParse(options);
// Webhook event should be parsable
if (!parseRequest.success) {
console.error(parseRequest.error);
throw new Error("Request is malformed");
}
const stringy = JSON.stringify({
auth_algo: options.body.auth_algo,
cert_url: options.body.cert_url,
transmission_id: options.body.transmission_id,
transmission_sig: options.body.transmission_sig,
transmission_time: options.body.transmission_time,
webhook_id: options.body.webhook_id,
});
const bodyToString = stringy.slice(0, -1) + `,"webhook_event":${options.body.webhook_event}` + "}";
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
},
body: bodyToString,
});
if (!response.ok) {
throw response;
}
const data = await response.json();
if (data.verification_status !== "SUCCESS") {
throw data;
}
} catch (err) {
console.error(err);
throw err;
}
}
}
export default Paypal;
interface PurchaseUnit {
amount: {
currency_code: string;
value: string;
};
reference_id: string;
}
interface ExperienceContext {
payment_method_preference?: string;
payment_method_selected?: string;
brand_name?: string;
locale?: string;
landing_page?: string;
shipping_preference?: string;
user_action: string;
return_url: string;
cancel_url: string;
}
interface PaymentSource {
paypal: {
experience_context: ExperienceContext;
};
}
interface CreateOrderRequestBody {
purchase_units: PurchaseUnit[];
intent: string;
payment_source?: PaymentSource;
}
interface Link {
href: string;
rel: string;
method: string;
}
interface CreateOrderResponse {
id: string;
status: string;
payment_source: PaymentSource;
links: Link[];
}
const webhookEventVerifyRequestSchema = z.object({
body: z
.object({
auth_algo: z.string(),
cert_url: z.string(),
transmission_id: z.string(),
transmission_sig: z.string(),
transmission_time: z.string(),
webhook_event: z.string(),
webhook_id: z.string(),
})
.required(),
});
export type WebhookEventVerifyRequest = z.infer<typeof webhookEventVerifyRequestSchema>;

View File

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

View File

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

View File

@ -0,0 +1,21 @@
import type { GetStaticPropsContext } from "next";
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
let clientId = "";
let secretKey = "";
const appKeys = await getAppKeysFromSlug("paypal");
if (typeof appKeys.client_id === "string" && typeof appKeys.secret_key === "string") {
clientId = appKeys.client_id;
secretKey = appKeys.secret_key;
}
return {
props: {
clientId,
secretKey,
},
};
};

View File

@ -0,0 +1,197 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { Toaster } from "react-hot-toast";
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, showToast, TextField } from "@calcom/ui";
export interface PayPalSetupProps {
public_key: string;
access_token: string;
currency: string;
}
export const currencyOptions = [
{ label: "United States dollar", value: "USD" },
{ label: "Australian dollar", value: "AUD" },
{ label: "Brazilian real 2", value: "BRL" },
{ label: "Canadian dollar", value: "CAD" },
{ label: "Chinese Renmenbi 3", value: "CNY" },
{ label: "Czech koruna", value: "CZK" },
{ label: "Danish krone", value: "DKK" },
{ label: "Euro", value: "EUR" },
{ label: "Hong Kong dollar", value: "HKD" },
{ label: "Hungarian forint 1", value: "HUF" },
{ label: "Israeli new shekel", value: "ILS" },
{ label: "Japanese yen 1", value: "JPY" },
{ label: "Malaysian ringgit 3", value: "MYR" },
{ label: "Mexican peso", value: "MXN" },
{ label: "New Taiwan dollar 1", value: "TWD" },
{ label: "New Zealand dollar", value: "NZD" },
{ label: "Norwegian krone", value: "NOK" },
{ label: "Philippine peso", value: "PHP" },
{ label: "Polish złoty", value: "PLN" },
{ label: "Pound sterling", value: "GBP" },
{ label: "Russian ruble", value: "RUB" },
{ label: "Singapore dollar", value: "SGD" },
{ label: "Swedish krona", value: "SEK" },
{ label: "Swiss franc", value: "CHF" },
{ label: "Thai baht", value: "THB" },
];
export default function PayPalSetup(props: PayPalSetupProps) {
const [newClientId, setNewClientId] = useState("");
const [newSecretKey, setNewSecretKey] = useState("");
const router = useRouter();
const { t } = useLocale();
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment" });
const paypalPaymentAppCredentials = integrations.data?.items.find((item) => item.type === "paypal_payment");
const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [false];
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
const saveKeysMutation = trpc.viewer.appsRouter.updateAppCredentials.useMutation({
onSuccess: () => {
showToast(t("keys_have_been_saved"), "success");
router.push("/event-types");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const saveKeys = async (key: { clientId: string; secretKey: string }) => {
if (!key.clientId || !key.secretKey || key.clientId === key.secretKey) {
return false;
}
if (typeof credentialId !== "number") {
return;
}
// Test credentials before saving
const paypalClient = new Paypal({ clientId: key.clientId, secretKey: key.secretKey });
const test = await paypalClient.test();
if (!test) {
return false;
}
// Delete all existing webhooks
const webhooksToDelete = await paypalClient.listWebhooks();
if (webhooksToDelete) {
for (const webhook of webhooksToDelete) {
await paypalClient.deleteWebhook(webhook);
}
}
// Create webhook for this installation
const webhookId = await paypalClient.createWebhook();
if (!webhookId) {
// @TODO: make a button that tries to create the webhook again
console.log("Failed to create webhook", webhookId);
return false;
}
saveKeysMutation.mutate({
credentialId,
key: {
client_id: key.clientId,
secret_key: key.secretKey,
webhook_id: webhookId,
},
});
return true;
};
if (integrations.isLoading) {
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
}
return (
<div className="bg-default flex h-screen">
{showContent ? (
<div className="bg-default border-subtle m-auto max-w-[43em] overflow-auto rounded border pb-10 md:p-10">
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
<div className="invisible md:visible">
<img className="h-11" src="/api/app-store/paypal/icon.svg" alt="Paypal Payment Logo" />
<p className="text-default mt-5 text-lg">Paypal</p>
</div>
<form autoComplete="off" className="mt-5">
<TextField
label="Client Id"
type="text"
name="client_id"
id="client_id"
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
role="presentation"
/>
<TextField
label="Secret Key"
type="password"
name="access_token"
id="access_token"
value={newSecretKey}
autoComplete="new-password"
role="presentation"
onChange={(e) => setNewSecretKey(e.target.value)}
/>
{/* Button to submit */}
<div className="mt-5 flex flex-row justify-end">
<Button
color="secondary"
onClick={() =>
saveKeys({
clientId: newClientId,
secretKey: newSecretKey,
})
}>
{t("save")}
</Button>
</div>
</form>
<div>
<p className="text-lgf text-default mt-5 font-bold">Setup instructions</p>
<ol className="text-default ml-1 list-decimal pl-2">
{/* @TODO: translate */}
<li>
Log into your Paypal Developer account and create a new app{" "}
<a
target="_blank"
href="https://developer.paypal.com/dashboard/applications/"
className="text-orange-600 underline">
{t("here")}
</a>
.
</li>
<li>Choose a name for your application.</li>
<li>Select Online payments solution.</li>
<li>Choose &quot;No&quot; for &quot;Using online platform&quot;.</li>
<li>CheckoutAPI as integration product.</li>
<li>Accept terms and Create APP.</li>
<li>Go back to dashboard, click on new app and copy the credentials.</li>
<li>Paste them on the required field and save them.</li>
<li>You&apos;re all setup.</li>
</ol>
</div>
</div>
</div>
) : (
<div className="ml-5 mt-5">
<div>Paypal</div>
<div className="mt-3">
<Link href="/apps/paypal" passHref={true} legacyBehavior>
<Button>{t("go_to_app_store")}</Button>
</Link>
</div>
</div>
)}
<Toaster position="bottom-right" />
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48">
<path fill="#001C64" d="M37.972 13.82c.107-5.565-4.485-9.837-10.799-9.837H14.115a1.278 1.278 0 0 0-1.262 1.079L7.62 37.758a1.038 1.038 0 0 0 1.025 1.2h7.737l-1.21 7.572a1.038 1.038 0 0 0 1.026 1.2H22.5c.305 0 .576-.11.807-.307.231-.198.269-.471.316-.772l1.85-10.885c.047-.3.2-.69.432-.888.231-.198.433-.306.737-.307H30.5c6.183 0 11.43-4.394 12.389-10.507.678-4.34-1.182-8.287-4.916-10.244Z"/>
<path fill="#0070E0" d="m18.056 26.9-1.927 12.22-1.21 7.664a1.038 1.038 0 0 0 1.026 1.2h6.67a1.278 1.278 0 0 0 1.261-1.079l1.758-11.14a1.277 1.277 0 0 1 1.261-1.078h3.927c6.183 0 11.429-4.51 12.388-10.623.68-4.339-1.504-8.286-5.238-10.244-.01.462-.05.923-.121 1.38-.959 6.112-6.206 10.623-12.389 10.623h-6.145a1.277 1.277 0 0 0-1.261 1.077Z"/>
<path fill="#003087" d="M16.128 39.12h-7.76a1.037 1.037 0 0 1-1.025-1.2l5.232-33.182a1.277 1.277 0 0 1 1.262-1.078h13.337c6.313 0 10.905 4.595 10.798 10.16-1.571-.824-3.417-1.295-5.44-1.295H21.413a1.278 1.278 0 0 0-1.261 1.078L18.057 26.9l-1.93 12.22Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="101" height="32" preserveAspectRatio="xMinYMin meet">
<path fill="#003087" d="M12.237 2.8h-7.8c-.5 0-1 .4-1.1.9l-3.1 20c-.1.4.2.7.6.7h3.7c.5 0 1-.4 1.1-.9l.8-5.4c.1-.5.5-.9 1.1-.9h2.5c5.1 0 8.1-2.5 8.9-7.4.3-2.1 0-3.8-1-5-1.1-1.3-3.1-2-5.7-2Zm.9 7.3c-.4 2.8-2.6 2.8-4.6 2.8h-1.2l.8-5.2c0-.3.3-.5.6-.5h.5c1.4 0 2.7 0 3.4.8.5.4.7 1.1.5 2.1ZM35.437 10h-3.7c-.3 0-.6.2-.6.5l-.2 1-.3-.4c-.8-1.2-2.6-1.6-4.4-1.6-4.1 0-7.6 3.1-8.3 7.5-.4 2.2.1 4.3 1.4 5.7 1.1 1.3 2.8 1.9 4.7 1.9 3.3 0 5.2-2.1 5.2-2.1l-.2 1c-.1.4.2.8.6.8h3.4c.5 0 1-.4 1.1-.9l2-12.8c.1-.2-.3-.6-.7-.6Zm-5.1 7.2c-.4 2.1-2 3.6-4.2 3.6-1.1 0-1.9-.3-2.5-1-.6-.7-.8-1.6-.6-2.6.3-2.1 2.1-3.6 4.2-3.6 1.1 0 1.9.4 2.5 1 .5.7.7 1.6.6 2.6ZM55.337 10h-3.7c-.4 0-.7.2-.9.5l-5.2 7.6-2.2-7.3c-.1-.5-.6-.8-1-.8h-3.7c-.4 0-.8.4-.6.9l4.1 12.1-3.9 5.4c-.3.4 0 1 .5 1h3.7c.4 0 .7-.2.9-.5l12.5-18c.3-.3 0-.9-.5-.9Z"/>
<path fill="#009cde" d="M67.737 2.8h-7.8c-.5 0-1 .4-1.1.9l-3.1 19.9c-.1.4.2.7.6.7h4c.4 0 .7-.3.7-.6l.9-5.7c.1-.5.5-.9 1.1-.9h2.5c5.1 0 8.1-2.5 8.9-7.4.3-2.1 0-3.8-1-5-1.2-1.2-3.1-1.9-5.7-1.9Zm.9 7.3c-.4 2.8-2.6 2.8-4.6 2.8h-1.2l.8-5.2c0-.3.3-.5.6-.5h.5c1.4 0 2.7 0 3.4.8.5.4.6 1.1.5 2.1ZM90.937 10h-3.7c-.3 0-.6.2-.6.5l-.2 1-.3-.4c-.8-1.2-2.6-1.6-4.4-1.6-4.1 0-7.6 3.1-8.3 7.5-.4 2.2.1 4.3 1.4 5.7 1.1 1.3 2.8 1.9 4.7 1.9 3.3 0 5.2-2.1 5.2-2.1l-.2 1c-.1.4.2.8.6.8h3.4c.5 0 1-.4 1.1-.9l2-12.8c0-.2-.3-.6-.7-.6Zm-5.2 7.2c-.4 2.1-2 3.6-4.2 3.6-1.1 0-1.9-.3-2.5-1-.6-.7-.8-1.6-.6-2.6.3-2.1 2.1-3.6 4.2-3.6 1.1 0 1.9.4 2.5 1 .6.7.8 1.6.6 2.6ZM95.337 3.3l-3.2 20.3c-.1.4.2.7.6.7h3.2c.5 0 1-.4 1.1-.9l3.2-19.9c.1-.4-.2-.7-.6-.7h-3.6c-.4 0-.6.2-.7.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,38 @@
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 PaypalPaymentOptions = [
{
label: "on_booking_option",
value: "ON_BOOKING",
},
// @TODO: not required right now
// {
// label: "hold_option",
// value: "HOLD",
// },
];
type PaymentOption = (typeof PaypalPaymentOptions)[number]["value"];
const VALUES: [PaymentOption, ...PaymentOption[]] = [
PaypalPaymentOptions[0].value,
...PaypalPaymentOptions.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(),
})
);
export const appKeysSchema = z.object({});

View File

@ -1,4 +1,4 @@
import type { Booking, Payment, Prisma, PaymentOption } from "@prisma/client";
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
@ -12,7 +12,7 @@ import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
import { paymentOptionEnum } from "../zod";
import { createPaymentLink } from "./client";
import { retrieveOrCreateStripeCustomerByEmail } from "./customer";
import type { StripeSetupIntentData, StripePaymentData } from "./server";
import type { StripePaymentData, StripeSetupIntentData } from "./server";
const stripeCredentialKeysSchema = z.object({
stripe_user_id: z.string(),
@ -38,6 +38,13 @@ export class PaymentService implements IAbstractPaymentService {
});
}
private async getPayment(where: Prisma.PaymentWhereInput) {
const payment = await prisma.payment.findFirst({ where });
if (!payment) throw new Error("Payment not found");
if (!payment.externalId) throw new Error("Payment externalId not found");
return { ...payment, externalId: payment.externalId };
}
/* This method is for creating charges at the time of booking */
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
@ -52,7 +59,7 @@ export class PaymentService implements IAbstractPaymentService {
}
// Load stripe keys
const stripeAppKeys = await prisma?.app.findFirst({
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
},
@ -81,7 +88,7 @@ export class PaymentService implements IAbstractPaymentService {
stripeAccount: this.credentials.stripe_user_id,
});
const paymentData = await prisma?.payment.create({
const paymentData = await prisma.payment.create({
data: {
uid: uuidv4(),
app: {
@ -97,7 +104,6 @@ export class PaymentService implements IAbstractPaymentService {
amount: payment.amount,
currency: payment.currency,
externalId: paymentIntent.id,
data: Object.assign({}, paymentIntent, {
stripe_publishable_key: this.credentials.stripe_publishable_key,
stripeAccount: this.credentials.stripe_user_id,
@ -131,7 +137,7 @@ export class PaymentService implements IAbstractPaymentService {
}
// Load stripe keys
const stripeAppKeys = await prisma?.app.findFirst({
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
},
@ -161,7 +167,7 @@ export class PaymentService implements IAbstractPaymentService {
stripeAccount: this.credentials.stripe_user_id,
});
const paymentData = await prisma?.payment.create({
const paymentData = await prisma.payment.create({
data: {
uid: uuidv4(),
app: {
@ -177,7 +183,6 @@ export class PaymentService implements IAbstractPaymentService {
amount: payment.amount,
currency: payment.currency,
externalId: setupIntent.id,
data: Object.assign(
{},
{
@ -202,7 +207,7 @@ export class PaymentService implements IAbstractPaymentService {
async chargeCard(payment: Payment, _bookingId?: Booking["id"]): Promise<Payment> {
try {
const stripeAppKeys = await prisma?.app.findFirst({
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
},
@ -280,17 +285,11 @@ export class PaymentService implements IAbstractPaymentService {
async refund(paymentId: Payment["id"]): Promise<Payment> {
try {
const payment = await prisma.payment.findFirst({
where: {
id: paymentId,
success: true,
refunded: false,
},
const payment = await this.getPayment({
id: paymentId,
success: true,
refunded: false,
});
if (!payment) {
throw new Error("Payment not found");
}
const refund = await this.stripe.refunds.create(
{
payment_intent: payment.externalId,
@ -345,15 +344,9 @@ export class PaymentService implements IAbstractPaymentService {
async deletePayment(paymentId: Payment["id"]): Promise<boolean> {
try {
const payment = await prisma.payment.findFirst({
where: {
id: paymentId,
},
const payment = await this.getPayment({
id: paymentId,
});
if (!payment) {
throw new Error("Payment not found");
}
const stripeAccount = (payment.data as unknown as StripePaymentData).stripeAccount;
if (!stripeAccount) {

View File

@ -503,7 +503,7 @@ async function handler(req: CustomRequest) {
};
const successPayment = bookingToDelete.payment.find((payment) => payment.success);
if (!successPayment) {
if (!successPayment?.externalId) {
throw new Error("Cannot reject a booking without a successful payment");
}

View File

@ -0,0 +1,338 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import getRawBody from "raw-body";
import * as z from "zod";
import { paypalCredentialKeysSchema } from "@calcom/app-store/paypal/lib";
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
export const config = {
api: {
bodyParser: false,
},
};
async function getEventType(id: number) {
return prisma.eventType.findUnique({
where: {
id,
},
select: {
recurringEvent: true,
requiresConfirmation: true,
},
});
}
export async function handlePaymentSuccess(
payload: z.infer<typeof eventSchema>,
rawPayload: string,
webhookHeaders: WebHookHeadersType
) {
const payment = await prisma.payment.findFirst({
where: {
externalId: payload?.resource?.id,
},
select: {
id: true,
bookingId: true,
success: true,
},
});
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
const booking = await prisma.booking.findUnique({
where: {
id: payment.bookingId,
},
select: {
...bookingMinimalSelect,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
responses: true,
user: {
select: {
id: true,
username: true,
credentials: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
// Probably booking it's already paid from /capture but we need to send confirmation email
const foundCredentials = await findPaymentCredentials(booking.id);
if (!foundCredentials) throw new HttpCode({ statusCode: 204, message: "No credentials found" });
const { webhookId, ...credentials } = foundCredentials;
const paypalClient = new Paypal(credentials);
await paypalClient.getAccessToken();
await paypalClient.verifyWebhook({
body: {
auth_algo: webhookHeaders["paypal-auth-algo"],
cert_url: webhookHeaders["paypal-cert-url"],
transmission_id: webhookHeaders["paypal-transmission-id"],
transmission_sig: webhookHeaders["paypal-transmission-sig"],
transmission_time: webhookHeaders["paypal-transmission-time"],
webhook_id: webhookId,
webhook_event: rawPayload,
},
});
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const { user } = booking;
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
const t = await getTranslation(user.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
booking: booking,
bookingFields: booking.eventType?.bookingFields || null,
}),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
language: { translate: t, locale: user.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
};
if (booking.location) evt.location = booking.location;
const bookingData: Prisma.BookingUpdateInput = {
paid: true,
status: BookingStatus.ACCEPTED,
};
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
if (isConfirmed) {
const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
bookingData.references = { create: scheduleResult.referencesToCreate };
}
if (eventTypeRaw?.requiresConfirmation) {
delete bookingData.status;
}
if (!payment?.success) {
await prisma.payment.update({
where: {
id: payment.id,
},
data: {
success: true,
},
});
}
if (booking.status === "PENDING") {
await prisma.booking.update({
where: {
id: booking.id,
},
data: bookingData,
});
}
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
await handleConfirmation({ user, evt, prisma, bookingId: booking.id, booking, paid: true });
} else {
await sendScheduledEmails({ ...evt });
}
throw new HttpCode({
statusCode: 200,
message: `Booking with id '${booking.id}' was paid and confirmed.`,
});
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const bodyRaw = await getRawBody(req);
const headers = req.headers;
const bodyAsString = bodyRaw.toString();
const parseHeaders = webhookHeadersSchema.safeParse(headers);
if (!parseHeaders.success) {
console.error(parseHeaders.error);
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
}
const parse = eventSchema.safeParse(JSON.parse(bodyAsString));
if (!parse.success) {
console.error(parse.error);
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
}
const { data: parsedPayload } = parse;
if (parsedPayload.event_type === "CHECKOUT.ORDER.APPROVED") {
return await handlePaymentSuccess(parsedPayload, bodyAsString, parseHeaders.data);
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(200).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}
// Return a response to acknowledge receipt of the event
res.status(200).end();
}
const resourceSchema = z
.object({
create_time: z.string(),
id: z.string(),
payment_source: z.object({
paypal: z.object({}).optional(),
}),
intent: z.string(),
payer: z.object({
email_address: z.string(),
payer_id: z.string(),
address: z.object({
country_code: z.string(),
}),
}),
status: z.string().optional(),
})
.passthrough();
const eventSchema = z
.object({
id: z.string(),
create_time: z.string(),
resource_type: z.string(),
event_type: z.string(),
summary: z.string(),
resource: resourceSchema,
status: z.string().optional(),
event_version: z.string(),
resource_version: z.string(),
})
.passthrough();
const webhookHeadersSchema = z
.object({
"paypal-auth-algo": z.string(),
"paypal-cert-url": z.string(),
"paypal-transmission-id": z.string(),
"paypal-transmission-sig": z.string(),
"paypal-transmission-time": z.string(),
})
.passthrough();
type WebHookHeadersType = z.infer<typeof webhookHeadersSchema>;
export const findPaymentCredentials = async (
bookingId: number
): Promise<{ clientId: string; secretKey: string; webhookId: string }> => {
try {
// @TODO: what about team bookings with paypal?
const userFromBooking = await prisma?.booking.findFirst({
where: {
id: bookingId,
},
select: {
id: true,
userId: true,
},
});
if (!userFromBooking) throw new Error("No user found");
const credentials = await prisma?.credential.findFirst({
where: {
appId: "paypal",
userId: userFromBooking?.userId,
},
select: {
key: true,
},
});
if (!credentials) {
throw new Error("No credentials found");
}
const parsedCredentials = paypalCredentialKeysSchema.safeParse(credentials?.key);
if (!parsedCredentials.success) {
throw new Error("Credentials malformed");
}
return {
clientId: parsedCredentials.data.client_id,
secretKey: parsedCredentials.data.secret_key,
webhookId: parsedCredentials.data.webhook_id,
};
} catch (err) {
console.error(err);
return {
clientId: "",
secretKey: "",
webhookId: "",
};
}
};

View File

@ -7,7 +7,6 @@ import type { SyntheticEvent } from "react";
import { useEffect, useState } from "react";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -17,13 +16,14 @@ import type { EventType } from ".prisma/client";
type Props = {
payment: Omit<Payment, "id" | "fee" | "success" | "refunded" | "externalId" | "data"> & {
data: StripePaymentData | StripeSetupIntentData;
data: Record<string, unknown>;
};
eventType: { id: number; successRedirectUrl: EventType["successRedirectUrl"] };
user: { username: string | null };
location?: string | null;
bookingId: number;
bookingUid: string;
clientSecret: string;
};
type States =
@ -162,26 +162,18 @@ const ELEMENT_STYLES_DARK: stripejs.Appearance = {
};
export default function PaymentComponent(props: Props) {
const stripePromise = getStripe((props.payment.data as StripePaymentData).stripe_publishable_key);
const paymentOption = props.payment.paymentOption;
const stripePromise = getStripe(props.payment.data.stripe_publishable_key as any);
const [darkMode, setDarkMode] = useState<boolean>(false);
let clientSecret: string | null;
useEffect(() => {
setDarkMode(window.matchMedia("(prefers-color-scheme: dark)").matches);
}, []);
if (paymentOption === "HOLD" && "setupIntent" in props.payment.data) {
clientSecret = props.payment.data.setupIntent.client_secret;
} else if (!("setupIntent" in props.payment.data)) {
clientSecret = props.payment.data.client_secret;
}
return (
<Elements
stripe={stripePromise}
options={{
clientSecret: clientSecret!,
clientSecret: props.clientSecret,
appearance: darkMode ? ELEMENT_STYLES_DARK : ELEMENT_STYLES,
}}>
<PaymentForm {...props} />

View File

@ -1,4 +1,5 @@
import classNames from "classnames";
import dynamic from "next/dynamic";
import Head from "next/head";
import type { FC } from "react";
import { useEffect, useState } from "react";
@ -15,7 +16,20 @@ import { localStorage } from "@calcom/lib/webstorage";
import { CreditCard } from "@calcom/ui/components/icon";
import type { PaymentPageProps } from "../pages/payment";
import PaymentComponent from "./Payment";
const StripePaymentComponent = dynamic(() => import("./Payment"), {
ssr: false,
});
const PaypalPaymentComponent = dynamic(
() =>
import("@calcom/app-store/paypal/components/PaypalPaymentComponent").then(
(m) => m.PaypalPaymentComponent
),
{
ssr: false,
}
);
const PaymentPage: FC<PaymentPageProps> = (props) => {
const { t, i18n } = useLocale();
@ -119,7 +133,8 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
<div className="text-default mt-4 text-center dark:text-gray-300">{t("paid")}</div>
)}
{props.payment.appId === "stripe" && !props.payment.success && (
<PaymentComponent
<StripePaymentComponent
clientSecret={props.clientSecret}
payment={props.payment}
eventType={props.eventType}
user={props.user}
@ -128,6 +143,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
bookingUid={props.booking.uid}
/>
)}
{props.payment.appId === "paypal" && !props.payment.success && (
<PaypalPaymentComponent payment={props.payment} />
)}
{props.payment.refunded && (
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
)}

View File

@ -1,13 +1,13 @@
import type { Payment } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { ssrInit } from "@server/lib/ssr";
import { ssrInit } from "../../../../../apps/web/server/lib/ssr";
export type PaymentPageProps = inferSSRProps<typeof getServerSideProps>;
@ -88,9 +88,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (!rawPayment) return { notFound: true };
const { data, booking: _booking, ...restPayment } = rawPayment;
const payment = {
...restPayment,
data: data as unknown as StripePaymentData | StripeSetupIntentData,
data: data as Record<string, unknown>,
};
if (!_booking) return { notFound: true };
@ -135,7 +136,28 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
booking,
trpcState: ssr.dehydrate(),
payment,
clientSecret: getClientSecretFromPayment(payment),
profile,
},
};
};
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

@ -509,7 +509,7 @@ model Payment {
success Boolean
refunded Boolean
data Json
externalId String @unique
externalId String? @unique
paymentOption PaymentOption? @default(ON_BOOKING)
@@index([bookingId])

View File

@ -4426,6 +4426,15 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/paypal@workspace:packages/app-store/paypal":
version: 0.0.0-use.local
resolution: "@calcom/paypal@workspace:packages/app-store/paypal"
dependencies:
"@calcom/lib": "*"
"@calcom/types": "*"
languageName: unknown
linkType: soft
"@calcom/ping@workspace:packages/app-store/ping":
version: 0.0.0-use.local
resolution: "@calcom/ping@workspace:packages/app-store/ping"