diff --git a/apps/web/components/dialog/DeleteStripeDialogContent.tsx b/apps/web/components/dialog/DeleteStripeDialogContent.tsx new file mode 100644 index 0000000000..18ab4e49df --- /dev/null +++ b/apps/web/components/dialog/DeleteStripeDialogContent.tsx @@ -0,0 +1,78 @@ +import { ExclamationIcon } from "@heroicons/react/outline"; +import { CheckIcon } from "@heroicons/react/solid"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import React, { PropsWithChildren, ReactNode } from "react"; + +import { Button } from "@calcom/ui/Button"; +import { DialogClose, DialogContent } from "@calcom/ui/Dialog"; + +import { useLocale } from "@lib/hooks/useLocale"; + +export type DeleteStripeDialogContentProps = { + confirmBtn?: ReactNode; + cancelAllBookingsBtnText?: string; + removeBtnText?: string; + cancelBtnText?: string; + onConfirm?: (event: React.MouseEvent) => void; + onRemove?: (event: React.MouseEvent) => void; + title: string; + variety?: "danger" | "warning" | "success"; +}; + +export default function DeleteStripeDialogContent(props: PropsWithChildren) { + const { t } = useLocale(); + const { + title, + variety, + confirmBtn = null, + cancelAllBookingsBtnText, + removeBtnText, + cancelBtnText = t("cancel"), + onConfirm, + onRemove, + children, + } = props; + + return ( + +
+ {variety && ( +
+ {variety === "danger" && ( +
+ +
+ )} + {variety === "warning" && ( +
+ +
+ )} + {variety === "success" && ( +
+ +
+ )} +
+ )} +
+ {title} + + {children} + +
+
+
+ + + + + + + + + +
+
+ ); +} diff --git a/apps/web/components/integrations/DisconnectiStripeIntegration.tsx b/apps/web/components/integrations/DisconnectiStripeIntegration.tsx new file mode 100644 index 0000000000..7fa3f268c9 --- /dev/null +++ b/apps/web/components/integrations/DisconnectiStripeIntegration.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { useMutation } from "react-query"; + +import showToast from "@calcom/lib/notification"; +import { ButtonBaseProps } from "@calcom/ui/Button"; +import { Dialog } from "@calcom/ui/Dialog"; + +import DeleteStripeDialogContent from "@components/dialog/DeleteStripeDialogContent"; + +export default function DisconnectiStripeIntegration(props: { + /** Integration credential id */ + id: number; + render: (renderProps: ButtonBaseProps) => JSX.Element; + onOpenChange: (isOpen: boolean) => unknown | Promise; +}) { + const [modalOpen, setModalOpen] = useState(false); + const mutation = useMutation( + async (action: string) => { + const res = await fetch("/api/integrations", { + method: "DELETE", + body: JSON.stringify({ id: props.id, action }), + headers: { + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + throw new Error("Something went wrong"); + } + return res.json(); + }, + { + async onSettled() { + await props.onOpenChange(modalOpen); + }, + onSuccess(data) { + showToast(data.message, "success"); + setModalOpen(false); + }, + } + ); + return ( + <> + + { + mutation.mutate("cancel"); + }} + onRemove={() => { + mutation.mutate("remove"); + }}> + If you have unpaid and unconfirmed bookings, you must choose to cancel them or remove the required + payment field. + + + {props.render({ + onClick() { + setModalOpen(true); + }, + disabled: modalOpen, + loading: mutation.isLoading, + })} + + ); +} diff --git a/apps/web/pages/api/book/confirm.ts b/apps/web/pages/api/book/confirm.ts index e915dbe9e8..4e06c67ec3 100644 --- a/apps/web/pages/api/book/confirm.ts +++ b/apps/web/pages/api/book/confirm.ts @@ -106,6 +106,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) uid: true, payment: true, destinationCalendar: true, + paid: true, recurringEventId: true, }, }); @@ -122,6 +123,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json({ message: "booking already confirmed" }); } + /** When a booking that requires payment its being confirmed but doesn't have any payment, + * we shouldn’t save it on DestinationCalendars + */ + if (booking.payment.length > 0 && !booking.paid) { + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + confirmed: true, + }, + }); + + return res.status(204).end(); + } + const attendeesListPromises = booking.attendees.map(async (attendee) => { return { name: attendee.name, diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index e2f88a180e..2c7585ad7c 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -518,7 +518,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } - return prisma.booking.create(createBookingObj); + /* Validate if there is any stripe_payment credential for this user */ + const stripePaymentCredential = await prisma.credential.findFirst({ + where: { + type: "stripe_payment", + userId: users[0].id, + }, + select: { + id: true, + }, + }); + /** eventType doesn’t require payment then we create a booking + * OR + * stripePaymentCredential is found and price is higher than 0 then we create a booking + */ + if (!eventType.price || (stripePaymentCredential && eventType.price > 0)) { + return prisma.booking.create(createBookingObj); + } + // stripePaymentCredential not found and eventType requires payment we return null + return null; } let results: EventResult[] = []; @@ -619,7 +637,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) let booking: Booking | null = null; try { booking = await createBooking(); - evt.uid = booking.uid; + evt.uid = booking?.uid ?? null; } catch (_err) { const err = getErrorFromUnknown(_err); log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message); @@ -639,7 +657,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (originalRescheduledBooking?.uid) { // Use EventManager to conditionally use all needed integrations. - const updateManager = await eventManager.update(evt, originalRescheduledBooking.uid, booking.id); + const updateManager = await eventManager.update(evt, originalRescheduledBooking.uid, booking?.id); // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. evt.description = eventType.description; @@ -732,7 +750,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ); } - if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) { + if ( + !Number.isNaN(eventType.price) && + eventType.price > 0 && + !originalRescheduledBooking?.paid && + !!booking + ) { try { const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment"); @@ -776,18 +799,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ); await Promise.all(promises); // Avoid passing referencesToCreate with id unique constrain values - await prisma.booking.update({ - where: { - uid: booking.uid, - }, - data: { - references: { - createMany: { - data: referencesToCreate, - }, - }, - }, - }); // refresh hashed link if used const urlSeed = `${users[0].username}:${dayjs(req.body.start).utc().format()}`; const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL)); @@ -802,9 +813,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); } - - // booking successful - return res.status(201).json(booking); + if (booking) { + await prisma.booking.update({ + where: { + uid: booking.uid, + }, + data: { + references: { + createMany: { + data: referencesToCreate, + }, + }, + }, + }); + // booking successful + return res.status(201).json(booking); + } + return res.status(400).json({ message: "There is not a stripe_payment credential" }); } export function getLuckyUsers( diff --git a/apps/web/pages/api/integrations.ts b/apps/web/pages/api/integrations.ts index 06f40cb205..0aa8e4090a 100644 --- a/apps/web/pages/api/integrations.ts +++ b/apps/web/pages/api/integrations.ts @@ -69,6 +69,86 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) data, }); + if (req.body?.action === "cancel" || req.body?.action === "remove") { + try { + const bookingIdsWithPayments = await prisma.booking + .findMany({ + where: { + userId: session?.user?.id, + paid: false, + NOT: { + payment: { + every: { + booking: null, + }, + }, + }, + }, + select: { + id: true, + }, + }) + .then((bookings) => bookings.map((booking) => booking.id)); + const deletePayments = prisma.payment.deleteMany({ + where: { + bookingId: { + in: bookingIdsWithPayments, + }, + success: false, + }, + }); + + const updateBookings = prisma.booking.updateMany({ + where: { + id: { + in: bookingIdsWithPayments, + }, + }, + data: { + status: "CANCELLED", + rejectionReason: "Payment provider got removed", + }, + }); + + const bookingReferences = await prisma.booking + .findMany({ + where: { + confirmed: true, + rejected: false, + }, + select: { + id: true, + }, + }) + .then((bookings) => bookings.map((booking) => booking.id)); + + const deleteBookingReferences = prisma.bookingReference.deleteMany({ + where: { + bookingId: { + in: bookingReferences, + }, + }, + }); + if (req.body?.action === "cancel") { + await prisma.$transaction([deletePayments, updateBookings, deleteBookingReferences]); + } else { + const updateBookings = prisma.booking.updateMany({ + where: { + id: { + in: bookingIdsWithPayments, + }, + }, + data: { + paid: true, + }, + }); + await prisma.$transaction([deletePayments, updateBookings]); + } + } catch (e) { + console.error(e); + res.status(500).json({ message: "Integration could not be deleted" }); + } + } res.status(200).json({ message: "Integration deleted successfully" }); } } diff --git a/apps/web/pages/apps/installed.tsx b/apps/web/pages/apps/installed.tsx index 5a1cbb299c..81c3c703fb 100644 --- a/apps/web/pages/apps/installed.tsx +++ b/apps/web/pages/apps/installed.tsx @@ -22,6 +22,7 @@ import Shell, { ShellSubHeading } from "@components/Shell"; import SkeletonLoader from "@components/apps/SkeletonLoader"; import { CalendarListContainer } from "@components/integrations/CalendarListContainer"; import DisconnectIntegration from "@components/integrations/DisconnectIntegration"; +import DisconnectiStripeIntegration from "@components/integrations/DisconnectiStripeIntegration"; import IntegrationListItem from "@components/integrations/IntegrationListItem"; import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections"; import WebhookListContainer from "@components/webhook/WebhookListContainer"; @@ -35,12 +36,26 @@ function ConnectOrDisconnectIntegrationButton(props: { }) { const { t } = useLocale(); const [credentialId] = props.credentialIds; + const type = props.type; const utils = trpc.useContext(); const handleOpenChange = () => { utils.invalidateQueries(["viewer.integrations"]); }; if (credentialId) { + if (type === "stripe_payment") { + return ( + ( + + )} + onOpenChange={handleOpenChange} + /> + ); + } return ( { test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => { const user = await users.create(); + await user.login(); + await user.getPaymentCredential(); + await users.logout(); const eventType = user.eventTypes.find((e) => e.slug === "paid")!; const booking = await bookings.create(user.id, user.username, eventType.id, { rescheduled: true, diff --git a/packages/ui/Button.tsx b/packages/ui/Button.tsx index de3561c74a..42800c8131 100644 --- a/packages/ui/Button.tsx +++ b/packages/ui/Button.tsx @@ -6,7 +6,7 @@ import classNames from "@calcom/lib/classNames"; type SVGComponent = React.FunctionComponent>; export type ButtonBaseProps = { - color?: "primary" | "secondary" | "minimal" | "warn"; + color?: "primary" | "secondary" | "minimal" | "warn" | "alert" | "alert2"; size?: "base" | "sm" | "lg" | "fab" | "icon"; loading?: boolean; disabled?: boolean; @@ -70,6 +70,14 @@ export const Button = forwardRef