[CAL-770] Add payment integration

feat/CAL-770-improve-stripe-integrations
Edward Fernandez 2021-12-29 15:39:44 -05:00
parent 9ce090f14d
commit 863f02313b
25 changed files with 403 additions and 268 deletions

View File

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

View File

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

View File

@ -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<PaymentPageProps> = (props) => {
payment={props.payment}
eventType={props.eventType}
user={props.user}
location={props.booking.location}
location={props.booking.location || ""}
/>
</Elements>
)}

View File

@ -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.PaymentIntent> & {
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Prisma.CredentialArgs>()({
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",

View File

@ -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<string, PaymentMethodServiceType> = {
[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);
};

View File

@ -0,0 +1 @@
export const DEFAULT_PAYMENT_METHOD_INTEGRATION_NAME = "";

View File

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

View File

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

View File

@ -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.PaymentIntent> & {
stripe_publishable_key: string;
stripeAccount: string;
};
export type PaymentMethodServiceType = typeof StripePaymentService;
export type StripeData = Stripe.OAuthToken & {
default_currency: string;
};

View File

@ -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<string>;
date?: Maybe<string>;
absolute?: boolean;
}
export interface PaymentMethod {
handlePayment(
event: CalendarEvent,
selectedEventType: PaymentSelectedEventType,
credential: PaymentMethodCredential,
booking: BookingDetail
): Promise<Payment>;
refund(booking: BookingRefundDetail, event: CalendarEvent): Promise<void>;
handleRefundError(opts: bookingRefundError): Promise<void>;
}

View File

@ -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<Payment> {
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<void> {
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<void> {
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
await sendOrganizerPaymentRefundFailedEmail({
...opts.event,
paymentInfo: { reason: opts.reason, id: opts.paymentId },
});
}
}

View File

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

View File

@ -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<Stripe | null>;
/**
* 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<string>;
date?: Maybe<string>;
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;
};

View File

@ -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: {

View File

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

View File

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

View File

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

View File

@ -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"]`

View File

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