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
parent
7fd149fd2e
commit
f2a6d00348
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue