Fix/unpaid unconfirmed (#2553)

* Fix merge errors

* Errors prettier/prettier

* Update apps/web/pages/api/book/event.ts

Co-authored-by: Miguel Nieto A <39246879+miguelnietoa@users.noreply.github.com>

* Update apps/web/pages/api/book/event.ts

Co-authored-by: Miguel Nieto A <39246879+miguelnietoa@users.noreply.github.com>

* Update apps/web/pages/api/integrations.ts

Co-authored-by: Miguel Nieto A <39246879+miguelnietoa@users.noreply.github.com>

* Fix merge errors

* Errors prettier/prettier

* Update apps/web/pages/api/book/confirm.ts

Co-authored-by: alannnc <alannnc@gmail.com>

* Modal window before delete stripe integration

* ESLint Report

* Test fixes

Co-authored-by: Miguel Nieto A <39246879+miguelnietoa@users.noreply.github.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
pull/2755/head^2
andreaestefania12 2022-05-16 11:27:36 -05:00 committed by GitHub
parent 7fd149fd2e
commit f2a6d00348
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 316 additions and 20 deletions

View File

@ -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<HTMLElement, MouseEvent>) => void;
onRemove?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
title: string;
variety?: "danger" | "warning" | "success";
};
export default function DeleteStripeDialogContent(props: PropsWithChildren<DeleteStripeDialogContentProps>) {
const { t } = useLocale();
const {
title,
variety,
confirmBtn = null,
cancelAllBookingsBtnText,
removeBtnText,
cancelBtnText = t("cancel"),
onConfirm,
onRemove,
children,
} = props;
return (
<DialogContent>
<div className="flex">
{variety && (
<div className="mt-0.5 ltr:mr-3">
{variety === "danger" && (
<div className="mx-auto rounded-full bg-red-100 p-2 text-center">
<ExclamationIcon className="h-5 w-5 text-red-600" />
</div>
)}
{variety === "warning" && (
<div className="mx-auto rounded-full bg-orange-100 p-2 text-center">
<ExclamationIcon className="h-5 w-5 text-orange-600" />
</div>
)}
{variety === "success" && (
<div className="mx-auto rounded-full bg-green-100 p-2 text-center">
<CheckIcon className="h-5 w-5 text-green-600" />
</div>
)}
</div>
)}
<div>
<DialogPrimitive.Title className="font-cal text-xl text-gray-900">{title}</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-neutral-500">
{children}
</DialogPrimitive.Description>
</div>
</div>
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
<DialogClose onClick={onConfirm} asChild>
<Button color="alert">{cancelAllBookingsBtnText}</Button>
</DialogClose>
<DialogClose onClick={onRemove} asChild>
<Button color="alert2">{removeBtnText}</Button>
</DialogClose>
<DialogClose asChild>
<Button color="secondary">{cancelBtnText}</Button>
</DialogClose>
</div>
</DialogContent>
);
}

View File

@ -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<unknown>;
}) {
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 (
<>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DeleteStripeDialogContent
variety="warning"
title="Disconnect Stripe Integration"
cancelAllBookingsBtnText="Cancel all bookings"
removeBtnText="Remove payment"
cancelBtnText="Cancel"
onConfirm={() => {
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.
</DeleteStripeDialogContent>
</Dialog>
{props.render({
onClick() {
setModalOpen(true);
},
disabled: modalOpen,
loading: mutation.isLoading,
})}
</>
);
}

View File

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

View File

@ -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 doesnt 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(

View File

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

View File

@ -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 (
<DisconnectiStripeIntegration
id={credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
{t("disconnect")}
</Button>
)}
onOpenChange={handleOpenChange}
/>
);
}
return (
<DisconnectIntegration
id={credentialId}

View File

@ -132,6 +132,9 @@ test.describe("Reschedule Tests", async () => {
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,

View File

@ -6,7 +6,7 @@ import classNames from "@calcom/lib/classNames";
type SVGComponent = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
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<HTMLAnchorElement | HTMLButtonElement, ButtonPr
(disabled
? "border border-gray-200 text-gray-400 bg-white"
: "border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 hover:text-gray-900 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900 dark:bg-transparent dark:text-white dark:border-gray-800 dark:hover:bg-gray-800"),
color === "alert" &&
(disabled
? "border border-transparent bg-gray-400 text-white"
: "border border-transparent dark:text-darkmodebrandcontrast text-brandcontrast bg-red-600 dark:bg-darkmodebrand hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
color === "alert2" &&
(disabled
? "border border-transparent bg-gray-400 text-white"
: "border border-transparent dark:text-darkmodebrandcontrast text-black bg-yellow-400 dark:bg-darkmodebrand hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
color === "minimal" &&
(disabled
? "text-gray-400 bg-transparent"
@ -78,6 +86,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
(disabled
? "text-gray-400 bg-transparent"
: "text-gray-700 bg-transparent hover:text-red-700 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-50 focus:ring-red-500"),
// set not-allowed cursor if disabled
loading ? "cursor-wait" : disabled ? "cursor-not-allowed" : "",
props.className