WIP mercado pago

This commit is contained in:
Alan 2023-04-17 11:37:06 -07:00
parent f26368f405
commit d331d8c5e4
12 changed files with 495 additions and 14 deletions

View File

@ -28,6 +28,7 @@ export const EventTypeAddonMap = {
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),
rainbow: dynamic(() => import("./rainbow/components/EventTypeAppCardInterface")),
stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")),
"mercado_pago": dynamic(() => import("./mercado_pago/components/EventTypeAppCardInterface")),
"booking-pages-tag": dynamic(() =>
import("./templates/booking-pages-tag/components/EventTypeAppCardInterface")
),

View File

@ -10,6 +10,7 @@ const appStore = {
huddle01video: import("./huddle01video"),
jitsivideo: import("./jitsivideo"),
larkcalendar: import("./larkcalendar"),
mercado_pago: import("./mercado_pago"),
office365calendar: import("./office365calendar"),
office365video: import("./office365video"),
plausible: import("./plausible"),

View File

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

View File

@ -0,0 +1,321 @@
import { BookingStatus } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import * as z from "zod";
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 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,
},
});
}
async function getBooking(bookingId: number) {
const booking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: 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" });
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),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
language: { translate: t, locale: user.locale ?? "en" },
id: user.id,
},
attendees: attendeesList,
uid: booking.uid,
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
};
return {
booking,
user,
evt,
eventTypeRaw,
};
}
async function handlePaymentSuccess(payload: z.infer<typeof MercadoPagoPayloadSchema>) {
return true;
const payment = await prisma.payment.findFirst({
where: {
externalId: paymentIntent.id,
},
select: {
id: true,
bookingId: true,
},
});
if (!payment?.bookingId) {
console.log(JSON.stringify(paymentIntent), JSON.stringify(payment));
}
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" });
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;
}
const paymentUpdate = prisma.payment.update({
where: {
id: payment.id,
},
data: {
success: true,
},
});
const bookingUpdate = prisma.booking.update({
where: {
id: booking.id,
},
data: bookingData,
});
await prisma.$transaction([paymentUpdate, bookingUpdate]);
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.`,
});
}
type WebhookHandler = (action: "test.created" | "created") => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"test.created": handlePaymentSuccess,
created: handlePaymentSuccess,
};
const MercadoPagoPayloadSchema = z.object({
action: z.string(),
api_version: z.string(),
application_id: z.string(),
date_created: z.string(),
id: z.string(),
live_mode: z.enum(["false", "true"]),
type: z.literal("test"),
user_id: z.string(),
data: z.object({
id: z.string(),
}),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const { body } = req;
const parse = MercadoPagoPayloadSchema.safeParse(body);
if (!parse.success) {
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
}
const { data: parsedPayload } = parse;
const handler = webhookHandlers[parsedPayload.action];
if (handler) {
await handler(parsedPayload);
} else {
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpCode({
statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`,
});
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}
// Return a response to acknowledge receipt of the event
res.json({ received: true });
}

View File

@ -0,0 +1,15 @@
import Link from "next/link";
import type { IMercadoPagoPaymentComponentProps } from "@calcom/app-store/mercado_pago/types";
import { IS_PRODUCTION } from "@calcom/lib/constants";
export const MercadoPagoPaymentComponent = (props: IMercadoPagoPaymentComponentProps) => {
const { payment, eventType, user, location, bookingId, bookingUid } = props;
const paymentInitLink = IS_PRODUCTION ? payment.data.init_point : payment.data.sandbox_init_point;
return (
<div className="flex h-full w-full flex-col items-center justify-center">
MercadoPago
<Link href={paymentInitLink}>Pay</Link>
</div>
);
};

View File

@ -1,24 +1,22 @@
import type { Booking, Payment, Prisma } from "@prisma/client";
import { PaymentType } from "@prisma/client";
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
import * as MercadoPago from "mercadopago";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import { AbstractPaymentService } from "@calcom/lib/PaymentService";
import type { IAbstractPaymentService } from "@calcom/lib/PaymentService";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
const mercadoPagoCredentialKeysSchema = z.object({
access_token: z.string(),
public_key: z.string(),
});
export class PaymentService extends AbstractPaymentService {
export class PaymentService implements IAbstractPaymentService {
private mercadoPago: typeof MercadoPago;
private credentials: z.infer<typeof mercadoPagoCredentialKeysSchema>;
constructor(credentials: { key: Prisma.JsonValue }) {
super();
this.credentials = mercadoPagoCredentialKeysSchema.parse(credentials.key);
this.mercadoPago = MercadoPago;
@ -66,7 +64,11 @@ export class PaymentService extends AbstractPaymentService {
const paymentData = await prisma?.payment.create({
data: {
uid: uuidv4(),
type: PaymentType.MERCADO_PAGO,
app: {
connect: {
slug: "mercado_pago",
},
},
booking: {
connect: {
id: bookingId,
@ -99,10 +101,40 @@ export class PaymentService extends AbstractPaymentService {
async refund(): Promise<Payment> {
throw new Error("Method not implemented.");
}
getPaymentPaidStatus(): string {
collectCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: number,
bookerEmail: string,
paymentOption: PaymentOption
): Promise<Payment> {
throw new Error("Method not implemented.");
}
getPaymentDetails(): Payment {
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> {
throw new Error("Method not implemented.");
}
deletePayment(paymentId: number): Promise<boolean> {
throw new Error("Method not implemented.");
}
}

View File

@ -0,0 +1,79 @@
export interface IMercadoPagoPaymentComponentProps {
payment: {
amount: number;
appId: string;
bookingId: number;
currency: string;
data: MercadoPagoPaymentResponse;
paymentOption: string;
refunded: boolean;
success: boolean;
uid: string;
};
eventType: EventType;
user: User;
location?: string | null;
bookingId: number;
bookingUid: string;
}
interface MercadoPagoPaymentResponse {
additional_info: string;
auto_return: string;
back_urls: {
failure: string;
pending: string;
success: string;
};
binary_mode: boolean;
client_id: string;
collector_id: number;
coupon_code: null;
coupon_labels: null;
date_created: string;
date_of_expiration: null;
expiration_date_from: null;
expiration_date_to: null;
expires: boolean;
external_reference: string;
id: string;
init_point: string;
internal_metadata: null;
items: object[];
last_updated: null;
marketplace: string;
marketplace_fee: number;
metadata: object;
notification_url: null;
operation_type: string;
payer: {
name: string;
email: string;
phone: object;
address: object;
surname: string;
};
payment_methods: {
installments: null;
default_card_id: null;
default_installments: null;
excluded_payment_types: string[];
excluded_payment_methods: string[];
};
processing_modes: null;
product_id: null;
redirect_urls: {
failure: string;
pending: string;
success: string;
};
sandbox_init_point: string;
shipments: {
receiver_address: object;
default_shipping_method: null;
};
site_id: string;
stripeAccount: string;
stripe_publishable_key: string;
total_amount: null;
}

View File

@ -80,7 +80,7 @@ export default function MercadoPagoSetup(props: IMercadoPagoSetupProps) {
<div className="m-auto max-w-[43em] overflow-auto rounded bg-white pb-10 md:p-10">
<div className="md:flex md:flex-row">
<div className="invisible md:visible">
<img className="h-11" src="/api/app-store/mercado_pago/icon.svg" alt="Zapier Logo" />
<img className="h-11" src="/api/app-store/mercado_pago/icon.svg" alt="Mercado Pago Logo" />
</div>
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
<p className="text-lg">Mercado Pago</p>
@ -94,7 +94,7 @@ export default function MercadoPagoSetup(props: IMercadoPagoSetupProps) {
type="text"
name="public_key"
id="public_key"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
className="block w-full rounded-md border-gray-300 text-black shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
value={newPublicKey}
autoComplete="new-password"
onChange={(e) => setNewPublicKey(e.target.value)}
@ -110,7 +110,7 @@ export default function MercadoPagoSetup(props: IMercadoPagoSetupProps) {
type="password"
name="access_token"
id="access_token"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
className="block w-full rounded-md border-gray-300 text-black shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
value={newAccessToken}
autoComplete="new-password"
onChange={(e) => setNewAccessToken(e.target.value)}
@ -124,6 +124,7 @@ export default function MercadoPagoSetup(props: IMercadoPagoSetupProps) {
<Select
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency?.value}
onChange={(e) => {
if (e) {

View File

@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { getSuccessPageLocationMessage } from "@calcom/app-store/locations";
import { MercadoPagoPaymentComponent } from "@calcom/app-store/mercado_pago/components/MercadoPagoPaymentComponent";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import type { StripePaymentData } from "@calcom/app-store/stripepayment/lib/server";
import dayjs from "@calcom/dayjs";
@ -138,6 +139,16 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
/>
</Elements>
)}
{props.payment.appId === "mercado_pago" && !props.payment.success && (
<MercadoPagoPaymentComponent
payment={props.payment}
eventType={props.eventType}
user={props.user}
location={props.booking.location}
bookingId={props.booking.id}
bookingUid={props.booking.uid}
/>
)}
{props.payment.refunded && (
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
)}

View File

@ -1,4 +1,4 @@
import type { AppCategories, Prisma } from "@prisma/client";
import type { AppCategories, Prisma, PaymentOption } from "@prisma/client";
import appStore from "@calcom/app-store";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
@ -25,6 +25,7 @@ const handlePayment = async (
bookerEmail: string
) => {
const paymentApp = await appStore[paymentAppCredentials?.app?.dirName as keyof typeof appStore];
if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return null;
@ -32,7 +33,7 @@ const handlePayment = async (
const PaymentService = paymentApp.lib.PaymentService;
const paymentInstance = new PaymentService(paymentAppCredentials);
const paymentOption =
const paymentOption: PaymentOption =
selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].paymentOption || "ON_BOOKING";
let paymentData;

View File

@ -3956,6 +3956,7 @@ __metadata:
"@calcom/types": "*"
"@calcom/ui": "*"
"@calcom/zoomvideo": "*"
"@types/mercadopago": ^1.5.8
lodash: ^4.17.21
qs-stringify: ^1.2.1
languageName: unknown
@ -4465,6 +4466,15 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/mercado_pago@workspace:packages/app-store/mercado_pago":
version: 0.0.0-use.local
resolution: "@calcom/mercado_pago@workspace:packages/app-store/mercado_pago"
dependencies:
"@calcom/lib": "*"
"@calcom/types": "*"
languageName: unknown
linkType: soft
"@calcom/n8n@workspace:packages/app-store/n8n":
version: 0.0.0-use.local
resolution: "@calcom/n8n@workspace:packages/app-store/n8n"
@ -4939,6 +4949,7 @@ __metadata:
markdown-it: ^13.0.1
md5: ^2.3.0
memory-cache: ^0.2.0
mercadopago: ^1.5.14
micro: ^10.0.1
mime-types: ^2.1.35
mockdate: ^3.0.5
@ -12788,6 +12799,13 @@ __metadata:
languageName: node
linkType: hard
"@types/mercadopago@npm:^1.5.8":
version: 1.5.8
resolution: "@types/mercadopago@npm:1.5.8"
checksum: 0ad1e1bd98bbfe84ecf31e535aff2eb3551c7ef61d494de0b7b933182ad11d1d4300e3df12f7b179baac0c4cb0d44479f9e4467c5a168e7fd6b5f7b0575597a1
languageName: node
linkType: hard
"@types/micro@npm:7.3.7":
version: 7.3.7
resolution: "@types/micro@npm:7.3.7"