From 863f02313b22e40de88a5ab6cca3ed720d43d142 Mon Sep 17 00:00:00 2001 From: Edward Fernandez Date: Wed, 29 Dec 2021 15:39:44 -0500 Subject: [PATCH] [CAL-770] Add payment integration --- components/booking/pages/BookingPage.tsx | 20 +- ee/components/stripe/Payment.tsx | 3 +- ee/components/stripe/PaymentPage.tsx | 4 +- ee/lib/stripe/server.ts | 171 ------------------ .../integrations/stripepayment/callback.ts | 11 +- .../api/integrations/stripepayment/portal.ts | 5 +- .../api/integrations/stripepayment/webhook.ts | 5 +- ee/pages/payment/[uid].tsx | 3 +- .../calendar/interfaces/Calendar.ts | 3 +- lib/integrations/getIntegrations.ts | 3 +- lib/integrations/payment/PaymentManager.ts | 25 +++ .../payment/constants/defaults.ts | 1 + .../payment/constants/generals.ts | 7 + .../payment/constants/stripeConstats.ts | 9 + lib/integrations/payment/constants/types.ts | 20 ++ .../payment/interfaces/PaymentMethod.ts | 67 +++++++ .../payment/services/BasePaymentService.ts | 70 +++++++ .../payment/services/StripePaymentService.ts | 125 +++++++++++++ .../integrations/payment/utils/stripeUtils.ts | 26 +-- pages/api/book/confirm.ts | 9 +- pages/api/book/event.ts | 14 +- pages/api/cancel.ts | 10 +- pages/event-types/[type].tsx | 12 +- .../integrations-google-calendar.test.ts | 45 ++--- playwright/lib/globalSetup.ts | 3 + 25 files changed, 403 insertions(+), 268 deletions(-) delete mode 100644 ee/lib/stripe/server.ts create mode 100644 lib/integrations/payment/PaymentManager.ts create mode 100644 lib/integrations/payment/constants/defaults.ts create mode 100644 lib/integrations/payment/constants/generals.ts create mode 100644 lib/integrations/payment/constants/stripeConstats.ts create mode 100644 lib/integrations/payment/constants/types.ts create mode 100644 lib/integrations/payment/interfaces/PaymentMethod.ts create mode 100644 lib/integrations/payment/services/BasePaymentService.ts create mode 100644 lib/integrations/payment/services/StripePaymentService.ts rename ee/lib/stripe/client.ts => lib/integrations/payment/utils/stripeUtils.ts (52%) diff --git a/components/booking/pages/BookingPage.tsx b/components/booking/pages/BookingPage.tsx index 1bd379729b..58e5cc4e1e 100644 --- a/components/booking/pages/BookingPage.tsx +++ b/components/booking/pages/BookingPage.tsx @@ -15,13 +15,13 @@ import { FormattedNumber, IntlProvider } from "react-intl"; import { ReactMultiEmail } from "react-multi-email"; import { useMutation } from "react-query"; -import { createPaymentLink } from "@ee/lib/stripe/client"; - import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; import { ensureArray } from "@lib/ensureArray"; import { useLocale } from "@lib/hooks/useLocale"; import useTheme from "@lib/hooks/useTheme"; +import { PaymentLinkDetail } from "@lib/integrations/payment/interfaces/PaymentMethod"; +import { createPaymentLink } from "@lib/integrations/payment/utils/stripeUtils"; import { LocationType } from "@lib/location"; import createBooking from "@lib/mutations/bookings/create-booking"; import { parseZone } from "@lib/parseZone"; @@ -55,14 +55,14 @@ const BookingPage = (props: BookingPageProps) => { const mutation = useMutation(createBooking, { onSuccess: async ({ attendees, paymentUid, ...responseData }) => { if (paymentUid) { - return await router.push( - createPaymentLink({ - paymentUid, - date, - name: attendees[0].name, - absolute: false, - }) - ); + const paymentLinkDetail = { + paymentUid, + date, + name: attendees[0].name, + absolute: false, + } as PaymentLinkDetail; + + return await router.push(createPaymentLink(paymentLinkDetail)); } const location = (function humanReadableLocation(location) { diff --git a/ee/components/stripe/Payment.tsx b/ee/components/stripe/Payment.tsx index a2cd74116f..512a9bdd0a 100644 --- a/ee/components/stripe/Payment.tsx +++ b/ee/components/stripe/Payment.tsx @@ -4,10 +4,9 @@ import { useRouter } from "next/router"; import { stringify } from "querystring"; import React, { SyntheticEvent, useEffect, useState } from "react"; -import { PaymentData } from "@ee/lib/stripe/server"; - import useDarkMode from "@lib/core/browser/useDarkMode"; import { useLocale } from "@lib/hooks/useLocale"; +import { PaymentData } from "@lib/integrations/payment/constants/types"; import Button from "@components/ui/Button"; diff --git a/ee/components/stripe/PaymentPage.tsx b/ee/components/stripe/PaymentPage.tsx index 375d89624a..e3505faac8 100644 --- a/ee/components/stripe/PaymentPage.tsx +++ b/ee/components/stripe/PaymentPage.tsx @@ -9,11 +9,11 @@ import React, { FC, useEffect, useState } from "react"; import { FormattedNumber, IntlProvider } from "react-intl"; import PaymentComponent from "@ee/components/stripe/Payment"; -import getStripe from "@ee/lib/stripe/client"; import { PaymentPageProps } from "@ee/pages/payment/[uid]"; import { useLocale } from "@lib/hooks/useLocale"; import useTheme from "@lib/hooks/useTheme"; +import { getStripe } from "@lib/integrations/payment/utils/stripeUtils"; dayjs.extend(utc); dayjs.extend(toArray); @@ -108,7 +108,7 @@ const PaymentPage: FC = (props) => { payment={props.payment} eventType={props.eventType} user={props.user} - location={props.booking.location} + location={props.booking.location || ""} /> )} diff --git a/ee/lib/stripe/server.ts b/ee/lib/stripe/server.ts deleted file mode 100644 index eb762be1de..0000000000 --- a/ee/lib/stripe/server.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { PaymentType, Prisma } from "@prisma/client"; -import Stripe from "stripe"; -import { v4 as uuidv4 } from "uuid"; - -import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager"; -import { getErrorFromUnknown } from "@lib/errors"; -import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; -import prisma from "@lib/prisma"; - -import { createPaymentLink } from "./client"; - -export type PaymentInfo = { - link?: string | null; - reason?: string | null; - id?: string | null; -}; - -export type PaymentData = Stripe.Response & { - stripe_publishable_key: string; - stripeAccount: string; -}; - -export type StripeData = Stripe.OAuthToken & { - default_currency: string; -}; - -const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY!; -const paymentFeePercentage = process.env.PAYMENT_FEE_PERCENTAGE!; -const paymentFeeFixed = process.env.PAYMENT_FEE_FIXED!; - -const stripe = new Stripe(stripePrivateKey, { - apiVersion: "2020-08-27", -}); - -export async function handlePayment( - evt: CalendarEvent, - selectedEventType: { - price: number; - currency: string; - }, - stripeCredential: { key: Prisma.JsonValue }, - booking: { - user: { email: string | null; name: string | null; timeZone: string } | null; - id: number; - startTime: { toISOString: () => string }; - uid: string; - } -) { - const paymentFee = Math.round( - selectedEventType.price * parseFloat(`${paymentFeePercentage}`) + parseInt(`${paymentFeeFixed}`) - ); - const { stripe_user_id, stripe_publishable_key } = stripeCredential.key as Stripe.OAuthToken; - - const params: Stripe.PaymentIntentCreateParams = { - amount: selectedEventType.price, - currency: selectedEventType.currency, - payment_method_types: ["card"], - application_fee_amount: paymentFee, - }; - - const paymentIntent = await stripe.paymentIntents.create(params, { stripeAccount: stripe_user_id }); - - const payment = await prisma.payment.create({ - data: { - type: PaymentType.STRIPE, - uid: uuidv4(), - booking: { - connect: { - id: booking.id, - }, - }, - amount: selectedEventType.price, - fee: paymentFee, - currency: selectedEventType.currency, - success: false, - refunded: false, - data: Object.assign({}, paymentIntent, { - stripe_publishable_key, - stripeAccount: stripe_user_id, - }) /* We should treat this */ as PaymentData /* but Prisma doesn't know how to handle it, so it we treat it */ as unknown /* and then */ as Prisma.InputJsonValue, - externalId: paymentIntent.id, - }, - }); - - await sendAwaitingPaymentEmail({ - ...evt, - paymentInfo: { - link: createPaymentLink({ - paymentUid: payment.uid, - name: booking.user?.name, - date: booking.startTime.toISOString(), - }), - }, - }); - - return payment; -} - -export async function refund( - booking: { - id: number; - uid: string; - startTime: Date; - payment: { - id: number; - success: boolean; - refunded: boolean; - externalId: string; - data: Prisma.JsonValue; - type: PaymentType; - }[]; - }, - calEvent: CalendarEvent -) { - try { - const payment = booking.payment.find((e) => e.success && !e.refunded); - if (!payment) return; - - if (payment.type !== PaymentType.STRIPE) { - await handleRefundError({ - event: calEvent, - reason: "cannot refund non Stripe payment", - paymentId: "unknown", - }); - return; - } - - const refund = await stripe.refunds.create( - { - payment_intent: payment.externalId, - }, - { stripeAccount: (payment.data as unknown as PaymentData)["stripeAccount"] } - ); - - if (!refund || refund.status === "failed") { - await handleRefundError({ - event: calEvent, - reason: refund?.failure_reason || "unknown", - paymentId: payment.externalId, - }); - return; - } - - await prisma.payment.update({ - where: { - id: payment.id, - }, - data: { - refunded: true, - }, - }); - } catch (e) { - const err = getErrorFromUnknown(e); - console.error(err, "Refund failed"); - await handleRefundError({ - event: calEvent, - reason: err.message || "unknown", - paymentId: "unknown", - }); - } -} - -async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) { - console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`); - await sendOrganizerPaymentRefundFailedEmail({ - ...opts.event, - paymentInfo: { reason: opts.reason, id: opts.paymentId }, - }); -} - -export default stripe; diff --git a/ee/pages/api/integrations/stripepayment/callback.ts b/ee/pages/api/integrations/stripepayment/callback.ts index 038238eb6d..5ea42217a9 100644 --- a/ee/pages/api/integrations/stripepayment/callback.ts +++ b/ee/pages/api/integrations/stripepayment/callback.ts @@ -2,9 +2,10 @@ import { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import { stringify } from "querystring"; -import stripe, { StripeData } from "@ee/lib/stripe/server"; - import { getSession } from "@lib/auth"; +import { PAYMENT_INTEGRATIONS_TYPES } from "@lib/integrations/payment/constants/generals"; +import { STRIPE } from "@lib/integrations/payment/constants/stripeConstats"; +import { StripeData } from "@lib/integrations/payment/constants/types"; import prisma from "@lib/prisma"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -23,20 +24,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return; } - const response = await stripe.oauth.token({ + const response = await STRIPE.oauth.token({ grant_type: "authorization_code", code: code.toString(), }); const data: StripeData = { ...response, default_currency: "" }; if (response["stripe_user_id"]) { - const account = await stripe.accounts.retrieve(response["stripe_user_id"]); + const account = await STRIPE.accounts.retrieve(response["stripe_user_id"]); data["default_currency"] = account.default_currency; } await prisma.credential.create({ data: { - type: "stripe_payment", + type: PAYMENT_INTEGRATIONS_TYPES.stripe, key: data as unknown as Prisma.InputJsonObject, userId: session.user.id, }, diff --git a/ee/pages/api/integrations/stripepayment/portal.ts b/ee/pages/api/integrations/stripepayment/portal.ts index 759a985d5b..b99beb7b8e 100644 --- a/ee/pages/api/integrations/stripepayment/portal.ts +++ b/ee/pages/api/integrations/stripepayment/portal.ts @@ -1,9 +1,8 @@ import { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; -import stripe from "@ee/lib/stripe/server"; - import { getSession } from "@lib/auth"; +import { STRIPE } from "@lib/integrations/payment/constants/stripeConstats"; import prisma from "@lib/prisma"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -54,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); const return_url = `${process.env.BASE_URL}/settings/billing`; - const stripeSession = await stripe.billingPortal.sessions.create({ + const stripeSession = await STRIPE.billingPortal.sessions.create({ customer: customerId, return_url, }); diff --git a/ee/pages/api/integrations/stripepayment/webhook.ts b/ee/pages/api/integrations/stripepayment/webhook.ts index 2cb67b2255..428b9750d1 100644 --- a/ee/pages/api/integrations/stripepayment/webhook.ts +++ b/ee/pages/api/integrations/stripepayment/webhook.ts @@ -2,13 +2,12 @@ import { buffer } from "micro"; import type { NextApiRequest, NextApiResponse } from "next"; import Stripe from "stripe"; -import stripe from "@ee/lib/stripe/server"; - import { IS_PRODUCTION } from "@lib/config/constants"; import { HttpError as HttpCode } from "@lib/core/http/error"; import { getErrorFromUnknown } from "@lib/errors"; import EventManager from "@lib/events/EventManager"; import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; +import { STRIPE } from "@lib/integrations/payment/constants/stripeConstats"; import prisma from "@lib/prisma"; import { Ensure } from "@lib/types/utils"; @@ -136,7 +135,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const requestBuffer = await buffer(req); const payload = requestBuffer.toString(); - const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET); + const event = STRIPE.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET); const handler = webhookHandlers[event.type]; if (handler) { diff --git a/ee/pages/payment/[uid].tsx b/ee/pages/payment/[uid].tsx index df2932469c..c5bb66bed4 100644 --- a/ee/pages/payment/[uid].tsx +++ b/ee/pages/payment/[uid].tsx @@ -1,8 +1,7 @@ import { GetServerSidePropsContext } from "next"; -import { PaymentData } from "@ee/lib/stripe/server"; - import { asStringOrThrow } from "@lib/asStringOrNull"; +import { PaymentData } from "@lib/integrations/payment/constants/types"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; diff --git a/lib/integrations/calendar/interfaces/Calendar.ts b/lib/integrations/calendar/interfaces/Calendar.ts index 31c8908572..64ba9747ba 100644 --- a/lib/integrations/calendar/interfaces/Calendar.ts +++ b/lib/integrations/calendar/interfaces/Calendar.ts @@ -1,8 +1,7 @@ import { DestinationCalendar, SelectedCalendar, Credential } from "@prisma/client"; import { TFunction } from "next-i18next"; -import { PaymentInfo } from "@ee/lib/stripe/server"; - +import { PaymentInfo } from "@lib/integrations/payment/constants/types"; import { Ensure } from "@lib/types/utils"; import { VideoCallData } from "@lib/videoClient"; diff --git a/lib/integrations/getIntegrations.ts b/lib/integrations/getIntegrations.ts index 9ebedd67be..60139bb7fd 100644 --- a/lib/integrations/getIntegrations.ts +++ b/lib/integrations/getIntegrations.ts @@ -6,6 +6,7 @@ import _ from "lodash"; * https://github.com/microsoft/playwright/issues/7121 */ import { validJson } from "../../lib/jsonUtils"; +import { PAYMENT_INTEGRATIONS_TYPES } from "../integrations/payment/constants/generals"; const credentialData = Prisma.validator()({ select: { id: true, type: true }, @@ -84,7 +85,7 @@ export const ALL_INTEGRATIONS = [ process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && process.env.STRIPE_PRIVATE_KEY ), - type: "stripe_payment", + type: PAYMENT_INTEGRATIONS_TYPES.stripe, title: "Stripe", imageSrc: "integrations/stripe.svg", description: "Collect payments", diff --git a/lib/integrations/payment/PaymentManager.ts b/lib/integrations/payment/PaymentManager.ts new file mode 100644 index 0000000000..71c22e2cd9 --- /dev/null +++ b/lib/integrations/payment/PaymentManager.ts @@ -0,0 +1,25 @@ +import { Credential } from "@prisma/client"; + +import { PAYMENT_INTEGRATIONS_TYPES } from "@lib/integrations/payment/constants/generals"; +import { PaymentMethodServiceType } from "@lib/integrations/payment/constants/types"; +import PaymentService from "@lib/integrations/payment/services/BasePaymentService"; +import StripePaymentService from "@lib/integrations/payment/services/StripePaymentService"; +import logger from "@lib/logger"; + +const log = logger.getChildLogger({ prefix: ["PaymentManager"] }); + +const PAYMENT_METHODS: Record = { + [PAYMENT_INTEGRATIONS_TYPES.stripe]: StripePaymentService, +}; + +export const getPaymentMethod = (credential: Credential): PaymentService | null => { + const { type } = credential; + + const paymentMethod = PAYMENT_METHODS[type]; + if (!paymentMethod) { + log.warn(`payment method of type ${type} does not implemented`); + return null; + } + + return new paymentMethod(credential); +}; diff --git a/lib/integrations/payment/constants/defaults.ts b/lib/integrations/payment/constants/defaults.ts new file mode 100644 index 0000000000..8d56d322b7 --- /dev/null +++ b/lib/integrations/payment/constants/defaults.ts @@ -0,0 +1 @@ +export const DEFAULT_PAYMENT_METHOD_INTEGRATION_NAME = ""; diff --git a/lib/integrations/payment/constants/generals.ts b/lib/integrations/payment/constants/generals.ts new file mode 100644 index 0000000000..02112fe6df --- /dev/null +++ b/lib/integrations/payment/constants/generals.ts @@ -0,0 +1,7 @@ +export const paymentFeeFixed = process.env.PAYMENT_FEE_FIXED!; + +export const paymentFeePercentage = process.env.PAYMENT_FEE_PERCENTAGE!; + +export const PAYMENT_INTEGRATIONS_TYPES = { + stripe: "stripe_payment", +}; diff --git a/lib/integrations/payment/constants/stripeConstats.ts b/lib/integrations/payment/constants/stripeConstats.ts new file mode 100644 index 0000000000..a8afe2a2f5 --- /dev/null +++ b/lib/integrations/payment/constants/stripeConstats.ts @@ -0,0 +1,9 @@ +import Stripe from "stripe"; + +const { STRIPE_PRIVATE_KEY } = process.env; + +export const stripePrivateKey = STRIPE_PRIVATE_KEY!; + +export const STRIPE = new Stripe(stripePrivateKey, { + apiVersion: "2020-08-27", +}); diff --git a/lib/integrations/payment/constants/types.ts b/lib/integrations/payment/constants/types.ts new file mode 100644 index 0000000000..26c9fb0a75 --- /dev/null +++ b/lib/integrations/payment/constants/types.ts @@ -0,0 +1,20 @@ +import Stripe from "stripe"; + +import StripePaymentService from "@lib/integrations/payment/services/StripePaymentService"; + +export type PaymentInfo = { + link?: string | null; + reason?: string | null; + id?: string | null; +}; + +export type PaymentData = Stripe.Response & { + stripe_publishable_key: string; + stripeAccount: string; +}; + +export type PaymentMethodServiceType = typeof StripePaymentService; + +export type StripeData = Stripe.OAuthToken & { + default_currency: string; +}; diff --git a/lib/integrations/payment/interfaces/PaymentMethod.ts b/lib/integrations/payment/interfaces/PaymentMethod.ts new file mode 100644 index 0000000000..d23d0d7d4b --- /dev/null +++ b/lib/integrations/payment/interfaces/PaymentMethod.ts @@ -0,0 +1,67 @@ +import { Payment, PaymentType, Prisma } from "@prisma/client"; + +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; + +import { Maybe } from "@trpc/server"; + +export interface PaymentSelectedEventType { + price: number; + currency: string; +} + +export interface BookingDetail { + user: { + email: string | null; + name: string | null; + timeZone: string; + } | null; + id: number; + startTime: { + toISOString: () => string; + }; + uid: string; +} + +export interface BookingRefundDetail { + id: number; + uid: string; + startTime: Date; + payment: { + id: number; + success: boolean; + refunded: boolean; + externalId: string; + data: Prisma.JsonValue; + type: PaymentType; + }[]; +} + +export interface bookingRefundError { + event: CalendarEvent; + reason: string; + paymentId: string; +} + +export interface PaymentMethodCredential { + key: Prisma.JsonValue; +} + +export interface PaymentLinkDetail { + paymentUid: string; + name?: Maybe; + date?: Maybe; + absolute?: boolean; +} + +export interface PaymentMethod { + handlePayment( + event: CalendarEvent, + selectedEventType: PaymentSelectedEventType, + credential: PaymentMethodCredential, + booking: BookingDetail + ): Promise; + + refund(booking: BookingRefundDetail, event: CalendarEvent): Promise; + + handleRefundError(opts: bookingRefundError): Promise; +} diff --git a/lib/integrations/payment/services/BasePaymentService.ts b/lib/integrations/payment/services/BasePaymentService.ts new file mode 100644 index 0000000000..1d5187d4a1 --- /dev/null +++ b/lib/integrations/payment/services/BasePaymentService.ts @@ -0,0 +1,70 @@ +import { Payment, Credential } from "@prisma/client"; + +import { sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager"; +import { getErrorFromUnknown } from "@lib/errors"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; +import { DEFAULT_PAYMENT_METHOD_INTEGRATION_NAME } from "@lib/integrations/payment/constants/defaults"; +import { + BookingDetail, + BookingRefundDetail, + bookingRefundError, + PaymentMethod, + PaymentMethodCredential, + PaymentSelectedEventType, +} from "@lib/integrations/payment/interfaces/PaymentMethod"; +import logger from "@lib/logger"; + +export default abstract class BasePaymentService implements PaymentMethod { + protected integrationName = DEFAULT_PAYMENT_METHOD_INTEGRATION_NAME; + + log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); + + constructor(_credential: Credential, integrationName: string) { + this.integrationName = integrationName; + } + + handlePayment( + event: CalendarEvent, + selectedEventType: PaymentSelectedEventType, + _credential: PaymentMethodCredential, + booking: BookingDetail + ): Promise { + this.log.info( + `handle payment for ${JSON.stringify(event)}, payment ${JSON.stringify( + selectedEventType + )} and booking ${JSON.stringify(booking)}` + ); + + throw new Error("Method not implemented."); + } + + async refund(booking: BookingRefundDetail, event: CalendarEvent): Promise { + try { + const payment = booking.payment.find((e) => e.success && !e.refunded); + if (!payment) return new Promise((resolve) => resolve()); + + await this.handleRefundError({ + event: event, + reason: "cannot refund non Stripe payment", + paymentId: "unknown", + }); + } catch (e) { + const err = getErrorFromUnknown(e); + console.error(err, "Refund failed"); + await this.handleRefundError({ + event: event, + reason: err.message || "unknown", + paymentId: "unknown", + }); + } + } + + async handleRefundError(opts: bookingRefundError): Promise { + console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`); + + await sendOrganizerPaymentRefundFailedEmail({ + ...opts.event, + paymentInfo: { reason: opts.reason, id: opts.paymentId }, + }); + } +} diff --git a/lib/integrations/payment/services/StripePaymentService.ts b/lib/integrations/payment/services/StripePaymentService.ts new file mode 100644 index 0000000000..94c52cd1ed --- /dev/null +++ b/lib/integrations/payment/services/StripePaymentService.ts @@ -0,0 +1,125 @@ +import { PaymentType, Prisma, Credential } from "@prisma/client"; +import Stripe from "stripe"; +import { v4 as uuidv4 } from "uuid"; + +import { sendAwaitingPaymentEmail } from "@lib/emails/email-manager"; +import { getErrorFromUnknown } from "@lib/errors"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; +import { + paymentFeeFixed, + paymentFeePercentage, + PAYMENT_INTEGRATIONS_TYPES, +} from "@lib/integrations/payment/constants/generals"; +import { STRIPE } from "@lib/integrations/payment/constants/stripeConstats"; +import { PaymentData } from "@lib/integrations/payment/constants/types"; +import { + BookingDetail, + BookingRefundDetail, + PaymentMethodCredential, + PaymentSelectedEventType, +} from "@lib/integrations/payment/interfaces/PaymentMethod"; +import BasePaymentService from "@lib/integrations/payment/services/BasePaymentService"; +import { createPaymentLink } from "@lib/integrations/payment/utils/stripeUtils"; +import prisma from "@lib/prisma"; + +export default class StripePaymentService extends BasePaymentService { + constructor(credential: Credential) { + super(credential, PAYMENT_INTEGRATIONS_TYPES.stripe); + } + + async handlePayment( + event: CalendarEvent, + selectedEventType: PaymentSelectedEventType, + credential: PaymentMethodCredential, + booking: BookingDetail + ) { + const paymentFee = Math.round( + selectedEventType.price * parseFloat(`${paymentFeePercentage}`) + parseInt(`${paymentFeeFixed}`) + ); + const { stripe_user_id, stripe_publishable_key } = credential.key as Stripe.OAuthToken; + + const params: Stripe.PaymentIntentCreateParams = { + amount: selectedEventType.price, + currency: selectedEventType.currency, + payment_method_types: ["card"], + application_fee_amount: paymentFee, + }; + + const paymentIntent = await STRIPE.paymentIntents.create(params, { stripeAccount: stripe_user_id }); + + const payment = await prisma.payment.create({ + data: { + type: PaymentType.STRIPE, + uid: uuidv4(), + booking: { + connect: { + id: booking.id, + }, + }, + amount: selectedEventType.price, + fee: paymentFee, + currency: selectedEventType.currency, + success: false, + refunded: false, + data: Object.assign({}, paymentIntent, { + stripe_publishable_key, + stripeAccount: stripe_user_id, + }) /* We should treat this */ as PaymentData /* but Prisma doesn't know how to handle it, so it we treat it */ as unknown /* and then */ as Prisma.InputJsonValue, + externalId: paymentIntent.id, + }, + }); + + await sendAwaitingPaymentEmail({ + ...event, + paymentInfo: { + link: createPaymentLink({ + paymentUid: payment.uid, + name: booking.user?.name, + date: booking.startTime.toISOString(), + }), + }, + }); + + return payment; + } + + async refund(booking: BookingRefundDetail, event: CalendarEvent): Promise { + try { + const payment = booking.payment.find((e) => e.success && !e.refunded); + if (!payment) return; + + const refund = await STRIPE.refunds.create( + { + payment_intent: payment.externalId, + }, + { stripeAccount: (payment.data as unknown as PaymentData)["stripeAccount"] } + ); + + if (!refund || refund.status === "failed") { + await this.handleRefundError({ + event: event, + reason: refund?.failure_reason || "unknown", + paymentId: payment.externalId, + }); + return; + } + + await prisma.payment.update({ + where: { + id: payment.id, + }, + data: { + refunded: true, + }, + }); + } catch (e) { + const err = getErrorFromUnknown(e); + console.error(err, "Refund failed"); + await this.handleRefundError({ + event: event, + reason: err.message || "unknown", + paymentId: "unknown", + }); + } + } +} diff --git a/ee/lib/stripe/client.ts b/lib/integrations/payment/utils/stripeUtils.ts similarity index 52% rename from ee/lib/stripe/client.ts rename to lib/integrations/payment/utils/stripeUtils.ts index f326e54fb2..b42c8bd1db 100644 --- a/ee/lib/stripe/client.ts +++ b/lib/integrations/payment/utils/stripeUtils.ts @@ -1,36 +1,28 @@ import { loadStripe, Stripe } from "@stripe/stripe-js"; import { stringify } from "querystring"; -import { Maybe } from "@trpc/server"; +import { PaymentLinkDetail } from "@lib/integrations/payment/interfaces/PaymentMethod"; -const stripePublicKey = process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY!; +const { NEXT_PUBLIC_STRIPE_PUBLIC_KEY } = process.env; + +const stripePublicKey = NEXT_PUBLIC_STRIPE_PUBLIC_KEY!; let stripePromise: Promise; -/** - * This is a singleton to ensure we only instantiate Stripe once. - */ -const getStripe = (userPublicKey?: string) => { +export const getStripe = (userPublicKey?: string) => { if (!stripePromise) { stripePromise = loadStripe( userPublicKey || stripePublicKey /* , { - locale: "es-419" TODO: Handle multiple locales, - } */ + locale: "es-419" TODO: Handle multiple locales, + } */ ); } return stripePromise; }; -export function createPaymentLink(opts: { - paymentUid: string; - name?: Maybe; - date?: Maybe; - absolute?: boolean; -}): string { +export const createPaymentLink = (opts: PaymentLinkDetail): string => { const { paymentUid, name, date, absolute = true } = opts; let link = ""; if (absolute) link = process.env.NEXT_PUBLIC_APP_URL!; const query = stringify({ date, name }); return link + `/payment/${paymentUid}?${query}`; -} - -export default getStripe; +}; diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index 82b4496d00..5a0c1dd442 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -1,13 +1,13 @@ -import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client"; +import { Prisma, User, Booking, SchedulingType, BookingStatus, Credential } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; -import { refund } from "@ee/lib/stripe/server"; - import { getSession } from "@lib/auth"; import { sendDeclinedEmails } from "@lib/emails/email-manager"; import { sendScheduledEmails } from "@lib/emails/email-manager"; import EventManager from "@lib/events/EventManager"; import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar"; +import { getPaymentMethod } from "@lib/integrations/payment/PaymentManager"; +import { PAYMENT_INTEGRATIONS_TYPES } from "@lib/integrations/payment/constants/generals"; import logger from "@lib/logger"; import prisma from "@lib/prisma"; import { BookingConfirmBody } from "@lib/types/booking"; @@ -167,7 +167,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.status(204).end(); } else { - await refund(booking, evt); + const paymentMethod = getPaymentMethod({ type: PAYMENT_INTEGRATIONS_TYPES.stripe } as Credential); + await paymentMethod?.refund(booking, evt); await prisma.booking.update({ where: { diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index ed7bb4fc3d..8e458db56d 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -9,8 +9,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; -import { handlePayment } from "@ee/lib/stripe/server"; - import { sendScheduledEmails, sendRescheduledEmails, @@ -23,6 +21,8 @@ import EventManager, { EventResult, PartialReference } from "@lib/events/EventMa import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager"; import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar"; import { BufferedBusyTime } from "@lib/integrations/calendar/interfaces/Office365Calendar"; +import { getPaymentMethod } from "@lib/integrations/payment/PaymentManager"; +import { PAYMENT_INTEGRATIONS_TYPES } from "@lib/integrations/payment/constants/generals"; import logger from "@lib/logger"; import notEmpty from "@lib/notEmpty"; import prisma from "@lib/prisma"; @@ -520,11 +520,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (typeof eventType.price === "number" && eventType.price > 0) { try { - const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment"); + const [firstStripeCredential] = user.credentials.filter( + (cred) => cred.type == PAYMENT_INTEGRATIONS_TYPES.stripe + ); if (!booking.user) booking.user = user; - const payment = await handlePayment(evt, eventType, firstStripeCredential, booking); - res.status(201).json({ ...booking, message: "Payment required", paymentUid: payment.uid }); + const paymentMethod = getPaymentMethod({ type: PAYMENT_INTEGRATIONS_TYPES.stripe } as Credential); + const payment = await paymentMethod?.handlePayment(evt, eventType, firstStripeCredential, booking); + + res.status(201).json({ ...booking, message: "Payment required", paymentUid: payment?.uid }); return; } catch (e) { log.error(`Creating payment failed`, e); diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index a12a24bf55..9bd2250baa 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -1,15 +1,15 @@ -import { BookingStatus } from "@prisma/client"; +import { BookingStatus, Credential } from "@prisma/client"; import async from "async"; import { NextApiRequest, NextApiResponse } from "next"; -import { refund } from "@ee/lib/stripe/server"; - import { asStringOrNull } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { sendCancelledEmails } from "@lib/emails/email-manager"; import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter"; import { getCalendar } from "@lib/integrations/calendar/CalendarManager"; import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; +import { getPaymentMethod } from "@lib/integrations/payment/PaymentManager"; +import { PAYMENT_INTEGRATIONS_TYPES } from "@lib/integrations/payment/constants/generals"; import prisma from "@lib/prisma"; import { deleteMeeting } from "@lib/videoClient"; import sendPayload from "@lib/webhooks/sendPayload"; @@ -165,7 +165,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) uid: bookingToDelete.uid ?? "", language: t, }; - await refund(bookingToDelete, evt); + + const paymentMethod = getPaymentMethod({ type: PAYMENT_INTEGRATIONS_TYPES.stripe } as Credential); + await paymentMethod?.refund(bookingToDelete, evt); await prisma.booking.update({ where: { id: bookingToDelete.id, diff --git a/pages/event-types/[type].tsx b/pages/event-types/[type].tsx index 8c435391ba..0e4e6ce7ef 100644 --- a/pages/event-types/[type].tsx +++ b/pages/event-types/[type].tsx @@ -26,13 +26,13 @@ import { FormattedNumber, IntlProvider } from "react-intl"; import { useMutation } from "react-query"; import Select from "react-select"; -import { StripeData } from "@ee/lib/stripe/server"; - import { asNumberOrUndefined, asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { HttpError } from "@lib/core/http/error"; import { useLocale } from "@lib/hooks/useLocale"; import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations"; +import { PAYMENT_INTEGRATIONS_TYPES } from "@lib/integrations/payment/constants/generals"; +import { StripeData } from "@lib/integrations/payment/constants/types"; import { LocationType } from "@lib/location"; import deleteEventType from "@lib/mutations/event-types/delete-event-type"; import updateEventType from "@lib/mutations/event-types/update-event-type"; @@ -1442,7 +1442,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => if (hasIntegration(integrations, "zoom_video")) { locationOptions.push({ value: LocationType.Zoom, label: "Zoom Video", disabled: true }); } - const hasPaymentIntegration = hasIntegration(integrations, "stripe_payment"); + const hasPaymentIntegration = hasIntegration(integrations, PAYMENT_INTEGRATIONS_TYPES.stripe); if (hasIntegration(integrations, "google_calendar")) { locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" }); } @@ -1450,8 +1450,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => locationOptions.push({ value: LocationType.Daily, label: "Daily.co Video" }); } const currency = - (credentials.find((integration) => integration.type === "stripe_payment")?.key as unknown as StripeData) - ?.default_currency || "usd"; + ( + credentials.find((integration) => integration.type === PAYMENT_INTEGRATIONS_TYPES.stripe) + ?.key as unknown as StripeData + )?.default_currency || "usd"; if (hasIntegration(integrations, "office365_calendar")) { // TODO: Add default meeting option of the office integration. diff --git a/playwright/integrations/calendar/integrations-google-calendar.test.ts b/playwright/integrations/calendar/integrations-google-calendar.test.ts index eb6b7d162d..9aef51b9f0 100644 --- a/playwright/integrations/calendar/integrations-google-calendar.test.ts +++ b/playwright/integrations/calendar/integrations-google-calendar.test.ts @@ -29,55 +29,36 @@ test.describe.serial("Google calendar integration", () => { }); await Promise.all([ - page.waitForNavigation(/*{ url: 'https://accounts.google.com/o/oauth2/v2/auth/identifier?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events&prompt=consent&state=%7B%22returnTo%22%3A%22%2Fintegrations%22%7D&response_type=code&client_id=807439123749-1pjdju71hol2tcmr4ce7h99a6p7bh4b4.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fintegrations%2Fgooglecalendar%2Fcallback&flowName=GeneralOAuthFlow' }*/), - page.click( - 'text=Google CalendarFor personal and business calendarsConnect >> [data-testid="integration-connection-button"]' - ), + page.waitForNavigation({ url: "https://accounts.google.com/o/oauth2/v2/auth/identifier?*" }), + page.click('li:has-text("Google Calendar") >> [data-testid="integration-connection-button"]'), ]); - /** We start the Stripe flow */ - await page.goto( - "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events&prompt=consent&state=%7B%22returnTo%22%3A%22%2Fintegrations%22%7D&response_type=code&client_id=807439123749-1pjdju71hol2tcmr4ce7h99a6p7bh4b4.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fintegrations%2Fgooglecalendar%2Fcallback" - ); - - await page.goto( - "https://accounts.google.com/o/oauth2/v2/auth/identifier?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events&prompt=consent&state=%7B%22returnTo%22%3A%22%2Fintegrations%22%7D&response_type=code&client_id=807439123749-1pjdju71hol2tcmr4ce7h99a6p7bh4b4.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fintegrations%2Fgooglecalendar%2Fcallback&flowName=GeneralOAuthFlow" - ); - - await page.click('[aria-label="Correo electrónico o teléfono"]'); - - await page.fill('[aria-label="Correo electrónico o teléfono"]', "appstest647@gmail.com"); + await page.fill('[id="identifierId"]', "appstest647@gmail.com"); await Promise.all([ - page.waitForNavigation(/*{ url: 'https://accounts.google.com/signin/v2/challenge/pwd?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events&prompt=consent&state=%7B%22returnTo%22%3A%22%2Fintegrations%22%7D&response_type=code&client_id=807439123749-1pjdju71hol2tcmr4ce7h99a6p7bh4b4.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fintegrations%2Fgooglecalendar%2Fcallback&flowName=GeneralOAuthFlow&cid=1&navigationDirection=forward&TL=AM3QAYYRpKiZPgQ3OiJXVKpjvb7oI9B9onW6syFiJlBPLS_ADnrNniEXQL0TsTn-' }*/), - page.click('button:has-text("Siguiente")'), + page.waitForNavigation({ url: "https://accounts.google.com/signin/v2/challenge/pwd?*" }), + page.press('[id="identifierId"]', "Enter"), ]); - await page.click('[aria-label="Ingresa tu contraseña"]'); - - await page.fill('[aria-label="Ingresa tu contraseña"]', "aW4TDL9GQ55_01"); + await page.fill('[type="password"]', "aW4TDL9GQ55_01"); // Press Enter await Promise.all([ - page.waitForNavigation(/*{ url: 'https://accounts.google.com/signin/oauth/warning?authuser=0&part=AJi8hANmAtMQ-UhmjdCwpNNwGOIshO2BwRJhgAPWhuGd8ehakEoTJTx4KICzosVLmJv1CuUzvvyUHYvuEEKzo865V9i7sxDAf1ApvqopHYJUH4-6cTJhTV2B6pP8U-3v2gRMkEhFYzldGgnDZ3WHyWj3Nfg3TOgiRuS2k4KRoeLr-w__O2jrX8JYaLTMHiTghpWcE9WyPmaMPgLuzbFYat1Sne9BkIze5-AiWF1TsyBLr8uLcBN3pKk7y1eh6L4nlu0XKotZ1wnHgXsi06roHn1KK9yeo48-FwghsX2phheIp654vYc12SjELu0dsjWkXTpfj1X22Xc8fQK31xToqf0tdClmuOSDwEacQmgpZJlncChn7tVAopvTkO5v0wxkrGNy7wKQZMDffpvoCxDyV4ew4zNcT6DOn-oRU0ySiMwuOlZqutn23IcTKEdQiLp33JaacRY6JLQGoA86-InikC75xP9stBby6IZ8HmD_uCPdyaQslfMQWAQ&as=S1091477452%3A1640725683048124&client_id=807439123749-1pjdju71hol2tcmr4ce7h99a6p7bh4b4.apps.googleusercontent.com&rapt=AEjHL4PVP3TevMulM-iUTkKiZSr7x-vNMq4Z7tX2XChPaH99eCoXHrEe7rk2EpBMTKTb9ESEo0QDayW5BRFQLy8KALF3tdQXnw#' }*/), - page.press('[aria-label="Ingresa tu contraseña"]', "Enter"), + page.waitForNavigation({ url: "https://accounts.google.com/signin/oauth/v2/consentsummary?*" }), + page.press('[type="password"]', "Enter"), ]); - // Click button:has-text("Continuar") - await Promise.all([ - page.waitForNavigation(/*{ url: 'https://accounts.google.com/signin/oauth/v2/consentsummary?authuser=0&part=AJi8hANvD4yF7dFpqAflbW06NmSRziM4oeh0SM3zrURKqItAdML4h7BPLE8fQJTMIOkC2OHYFapOIn0YHuhFLSI3cVJ0Wvr1eTmnTNx1iX7XfGHBLCe-HzAfYUIHbAbhwhhUGGPBxryZKB92cLXFLVH-ZjFz2Nm2JALBdupdHsKuGMKw5-6AqV-cQqKK5EWtd46QBo2scRMa0DaV0OOBkWap47J7JbSVei8OqkKkenVaWgnZBKp94T7zL39YkxUsuQaHOr1SHdlZai083fRbD0YV5vBkyJZFfpnb-1J1V7D-uCRHloPubKKTzdLcggik0RvfYgbfY4PRJMmAF7rOjCne2ppYlkmitHbehQ7gSP-AIW1GY5t1b7w37ofvMSqhJn9ImEBCeksZO0iWQG9MyQGwnZZVviNr-5TBu8yZr5OeExBqG-wJpbmffM4RG5iSH1Jz1zK2pxS1iENSHiaGDA-vaqin2zOzzHZX1DTdz84CC7ILe_xLGnApTedaFhIgUdBt06ohnAWaqPY4mncmcPKW52TtqY4vAKAK4oE1tIo0d_cLSbQ9M2vk6CcSOeUtSaGj5_KqYpGGH2oFCyTJuwRhRFN8CzbGKG6bH267DAdhJLjHa6hvK5U3ACtrBm2J9PO4khSxwAVCMd0IQL2tTMdzOH3ZPFAfpTK0p5GTR_pHgtljgBOO1lZPXX8pXuAncTFKThy6hQ5z2lfXqQrTRNsNLMZ9Kyb0PIvF-TOVK6eNP1o76OPSJ1nHP690YJRbDaYFCxWBmtd76ar2mVxs4hn_OLMqFqYq9uzPrq6_dFSKtPXglFxJx85T7AILluOglIOqdfdhjpecxA4WzeFCJS7bOBFnasOHBySn2wvfJv_IFaLJTBv7CGUWq-ZyVROVflfipur6g7zZkBu5hvUJR7ZKTdv9vrvtUQ&hl=es&as=S1091477452%3A1640725683048124&client_id=807439123749-1pjdju71hol2tcmr4ce7h99a6p7bh4b4.apps.googleusercontent.com&rapt=AEjHL4PVP3TevMulM-iUTkKiZSr7x-vNMq4Z7tX2XChPaH99eCoXHrEe7rk2EpBMTKTb9ESEo0QDayW5BRFQLy8KALF3tdQXnw' }*/), - page.click('button:has-text("Continuar")'), - ]); + // When connecting an account for the first time, you need to check some chekboxes + // await page.check('[type="checkbox"]'); - // Click button:has-text("Continuar") await Promise.all([ - page.waitForNavigation(/*{ url: 'http://localhost:3000/integrations' }*/), - page.click('button:has-text("Continuar")'), + page.waitForNavigation({ url: "/integrations" }), + page.click('[id="submit_approve_access"]:has(button)'), ]); page.waitForEvent("load"); - /** If Stripe is added correctly we should see the "Disconnect" button */ + /** If Google Calendar is added correctly we should see the "Disconnect" button */ expect( page.locator( `div:has-text("Calendars") + li:has-text("Google Calendar") >> [data-testid="integration-connection-button"]` diff --git a/playwright/lib/globalSetup.ts b/playwright/lib/globalSetup.ts index 273c6b0345..a9da2ad9dd 100644 --- a/playwright/lib/globalSetup.ts +++ b/playwright/lib/globalSetup.ts @@ -1,6 +1,9 @@ import { Browser, chromium } from "@playwright/test"; +import fs from "fs"; async function loginAsUser(username: string, browser: Browser) { + // Skip is file exists + if (fs.existsSync(`playwright/artifacts/${username}StorageState.json`)) return; const page = await browser.newPage(); await page.goto(`${process.env.PLAYWRIGHT_TEST_BASE_URL}/auth/login`); // Click input[name="email"]