cal.pub0.org/packages/app-store/paypal/lib/Paypal.ts

360 lines
8.7 KiB
TypeScript

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(opts: { clientId: string; secretKey: string }) {
this.url = IS_PRODUCTION ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
this.clientId = opts.clientId;
this.secretKey = opts.secretKey;
}
private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
this.getAccessToken();
return fetch(`${this.url}${endpoint}`, {
method: "get",
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
...init?.headers,
},
});
};
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> {
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 this.fetcher("/v2/checkout/orders", {
method: "POST",
headers: {
"PayPal-Request-Id": uuidv4(),
},
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 {
const captureResult = await this.fetcher(`/v2/checkout/orders/${orderId}/capture`, {
method: "POST",
});
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> {
const body = {
url: `${WEBAPP_URL}/api/integrations/paypal/webhook`,
event_types: [
{
name: "CHECKOUT.ORDER.APPROVED",
},
{
name: "CHECKOUT.ORDER.COMPLETED",
},
],
};
try {
const response = await this.fetcher(`/v1/notifications/webhooks`, {
method: "POST",
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[]> {
try {
const response = await this.fetcher(`/v1/notifications/webhooks`);
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> {
try {
const response = await this.fetcher(`/v1/notifications/webhooks/${webhookId}`, {
method: "DELETE",
});
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 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 this.fetcher(`/v1/notifications/verify-webhook-signature`, {
method: "POST",
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>;