Stripe add the ability to place hold on cards (#8022)
* Add payment option to schema * Add payment option to Stripe zod * Set payment option on event type * Create manual payment intent in Stripe * Set payment option from Stripe app * Add payment option to DB * Pass React.ReactNode to checkbox * Create uncaptured payment intent * WIP * Capture card in setup intent * Show charge card option * Charge card from booking page * Bug fixes * Clean up * Clean up app card * Add no-show fee messaging on booking page * Send payment email on payment & add price * Fix messaging * Create no show fee charged email * Send charge fee collected email * Disable submit on card failure * Clean up * Serverside prevent charging card again if already charged * Only confirm booking if paid for * Type fixes * More type fixes * More type fixes * Type fix * Type fixes * UI changes * Payment component rework * Update apps/web/public/static/locales/en/common.json Co-authored-by: Alex van Andel <me@alexvanandel.com> * Update apps/web/public/static/locales/en/common.json Co-authored-by: Alex van Andel <me@alexvanandel.com> * Update apps/web/components/dialog/ChargeCardDialog.tsx Co-authored-by: Alex van Andel <me@alexvanandel.com> * Update packages/trpc/server/routers/viewer/payments.tsx Co-authored-by: Alex van Andel <me@alexvanandel.com> * Revert GTM config * Adjust payment option dropdown * Show alert when seats are set * Small bug fixes * Create collect card method * clean up * Prevent seats & charge no-show fee to be enabled together * Do not charge no-show fee on unconfirmed bookings * Add check to collect card method * Webhook send request emails * Fix some dark mode colours * Change awaiting payment language * Type fixes * Set height of Select and TextField both to 38px to fix alignment * Fix message seats & payment error message * Type fix --------- Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
co-authored by
Alex van Andel
parent
af0123a606
commit
817e20f11e
@@ -1,22 +1,35 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { FiCreditCard } from "@calcom/ui/components/icon";
|
||||
|
||||
const BookingDescriptionPayment = (props: { eventType: Parameters<typeof getPaymentAppData>[0] }) => {
|
||||
const BookingDescriptionPayment = (props: {
|
||||
eventType: Parameters<typeof getPaymentAppData>[0];
|
||||
t: TFunction;
|
||||
}) => {
|
||||
const paymentAppData = getPaymentAppData(props.eventType);
|
||||
if (!paymentAppData || paymentAppData.price <= 0) return null;
|
||||
|
||||
return (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
{paymentAppData.paymentOption === "HOLD" ? (
|
||||
<>
|
||||
{props.t("no_show_fee_amount", {
|
||||
amount: paymentAppData.price / 100.0,
|
||||
formatParams: { amount: { currency: paymentAppData.currency } },
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,10 +28,20 @@ import {
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
} from "@calcom/ui";
|
||||
import { FiCheck, FiClock, FiMapPin, FiRefreshCcw, FiSend, FiSlash, FiX } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
FiCheck,
|
||||
FiClock,
|
||||
FiMapPin,
|
||||
FiRefreshCcw,
|
||||
FiSend,
|
||||
FiSlash,
|
||||
FiX,
|
||||
FiCreditCard,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
@@ -56,7 +66,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const router = useRouter();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
@@ -116,16 +128,20 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
icon: FiSlash,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
onClick: () => {
|
||||
bookingConfirm(true);
|
||||
},
|
||||
icon: FiCheck,
|
||||
disabled: mutation.isLoading,
|
||||
color: "primary",
|
||||
},
|
||||
// For bookings with payment, only confirm if the booking is paid for
|
||||
...((isPending && !booking?.eventType?.price) || (!!booking?.eventType?.price && booking.paid)
|
||||
? [
|
||||
{
|
||||
id: "confirm",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
onClick: () => {
|
||||
bookingConfirm(true);
|
||||
},
|
||||
icon: FiCheck,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
@@ -184,6 +200,18 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const chargeCardActions: ActionType[] = [
|
||||
{
|
||||
id: "charge_card",
|
||||
label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"),
|
||||
disabled: cardCharged,
|
||||
onClick: () => {
|
||||
setChargeCardDialogIsOpen(true);
|
||||
},
|
||||
icon: FiCreditCard,
|
||||
},
|
||||
];
|
||||
|
||||
if (isTabRecurring && isRecurring) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
@@ -254,6 +282,15 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
/>
|
||||
{booking.paid && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
setIsOpenDialog={setChargeCardDialogIsOpen}
|
||||
bookingId={booking.id}
|
||||
paymentAmount={booking?.payment[0].amount}
|
||||
paymentCurrency={booking?.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{showRecordingsButtons && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
@@ -321,7 +358,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
)}
|
||||
{booking.paid && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green">
|
||||
{t("paid")}
|
||||
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
||||
</Badge>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
@@ -423,6 +460,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
{booking.status === "ACCEPTED" && booking.paid && booking?.payment[0]?.paymentOption === "HOLD" && (
|
||||
<div className="ml-2">
|
||||
<TableActions actions={chargeCardActions} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
|
||||
@@ -124,6 +124,16 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
[timeZone]
|
||||
);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
const paymentAmount = () => {
|
||||
return;
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>;
|
||||
};
|
||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||
@@ -237,13 +247,22 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
{paymentAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
{paymentAppData.paymentOption === "HOLD" ? (
|
||||
<>
|
||||
{t("no_show_fee_amount", {
|
||||
amount: paymentAppData.price / 100.0,
|
||||
formatParams: { amount: { currency: paymentAppData.currency } },
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
|
||||
@@ -535,7 +535,7 @@ const BookingPage = ({
|
||||
{showEventTypeDetails && (
|
||||
<div className="sm:dark:border-darkgray-300 text-default flex flex-col px-6 pt-6 pb-0 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
<BookingDescriptionPayment eventType={eventType} />
|
||||
<BookingDescriptionPayment eventType={eventType} t={t} />
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="dark:text-inverted text-default items-start text-sm font-medium">
|
||||
<FiRefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import { useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { IntlProvider, FormattedNumber } from "react-intl";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { FiCreditCard, FiAlertTriangle } from "@calcom/ui/components/icon";
|
||||
|
||||
interface IRescheduleDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
bookingId: number;
|
||||
paymentAmount: number;
|
||||
paymentCurrency: string;
|
||||
}
|
||||
|
||||
export const ChargeCardDialog = (props: IRescheduleDialog) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
|
||||
const [chargeError, setChargeError] = useState(false);
|
||||
const chargeCardMutation = trpc.viewer.payments.chargeCard.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.bookings.invalidate();
|
||||
setIsOpenDialog(false);
|
||||
showToast("Charge successful", "success");
|
||||
},
|
||||
onError: () => {
|
||||
setChargeError(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<FiCreditCard className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("charge_card")} />
|
||||
<Trans i18nKey="charge_card_dialog_body">
|
||||
<p className="text-sm text-gray-500">
|
||||
You are about to charge the attendee{" "}
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={props.paymentAmount / 100.0}
|
||||
style="currency"
|
||||
currency={props.paymentCurrency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
. Are you sure you want to continue?
|
||||
</p>
|
||||
</Trans>
|
||||
|
||||
{chargeError && (
|
||||
<div className="mt-4 flex text-red-500">
|
||||
<FiAlertTriangle className="mr-2 h-5 w-5 " aria-hidden="true" />
|
||||
<p className="text-sm">{t("error_charging_card")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
data-testid="send_request"
|
||||
disabled={chargeCardMutation.isLoading || chargeError}
|
||||
onClick={() =>
|
||||
chargeCardMutation.mutate({
|
||||
bookingId,
|
||||
})
|
||||
}>
|
||||
<Trans i18nKey="charge_card_confirm">
|
||||
Charge attendee{" "}
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={props.paymentAmount / 100.0}
|
||||
style="currency"
|
||||
currency={props.paymentCurrency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip } from "@calcom/ui";
|
||||
import { Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip, Alert } from "@calcom/ui";
|
||||
import { FiEdit, FiCopy } from "@calcom/ui/components/icon";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
@@ -58,6 +58,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
const noShowFeeEnabled = eventType.metadata.apps?.stripe?.paymentOption === "HOLD";
|
||||
|
||||
useEffect(() => {
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
||||
@@ -262,54 +263,58 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
control={formMethods.control}
|
||||
defaultValue={!!eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
data-testid="offer-seats-toggle"
|
||||
title={t("offer_seats")}
|
||||
description={t("offer_seats_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => {
|
||||
// Enabling seats will disable guests and requiring confirmation until fully supported
|
||||
if (e) {
|
||||
toggleGuests(false);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
toggleGuests(true);
|
||||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
description={t("show_attendees")}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
<>
|
||||
<SettingsToggle
|
||||
data-testid="offer-seats-toggle"
|
||||
title={t("offer_seats")}
|
||||
description={t("offer_seats_description")}
|
||||
checked={value}
|
||||
disabled={noShowFeeEnabled}
|
||||
onCheckedChange={(e) => {
|
||||
// Enabling seats will disable guests and requiring confirmation until fully supported
|
||||
if (e) {
|
||||
toggleGuests(false);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
toggleGuests(true);
|
||||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
description={t("show_attendees")}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -126,41 +126,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(
|
||||
t("event_type_updated_successfully", {
|
||||
eventTypeTitle: eventType.title,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
let message = "";
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
message = `${err.data.code}: You are not able to update this event`;
|
||||
}
|
||||
|
||||
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
|
||||
message = `${err.data.code}: ${err.message}`;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
showToast(message, "error");
|
||||
} else {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
|
||||
startDate: new Date(eventType.periodStartDate || Date.now()),
|
||||
endDate: new Date(eventType.periodEndDate || Date.now()),
|
||||
@@ -235,6 +200,48 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
),
|
||||
});
|
||||
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(
|
||||
t("event_type_updated_successfully", {
|
||||
eventTypeTitle: eventType.title,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
let message = "";
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
message = `${err.data.code}: You are not able to update this event`;
|
||||
}
|
||||
|
||||
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
|
||||
message = `${err.data.code}: ${err.message}`;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
showToast(message, "error");
|
||||
} else {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
|
||||
console.log(formMethods.formState.isSubmitting);
|
||||
|
||||
// formMethods.setFormState((prevState) => ({
|
||||
// ...prevState,
|
||||
// isSubmitting: false,
|
||||
// }));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!formMethods.formState.isDirty) {
|
||||
//TODO: What's the best way to sync the form with backend
|
||||
@@ -337,6 +344,10 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) {
|
||||
throw new Error(t("seats_and_no_show_fee_error"));
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
...input,
|
||||
locations,
|
||||
|
||||
@@ -1693,7 +1693,29 @@
|
||||
"redirect_url_warning": "Adding a redirect will disable the success page. Make sure to mention \"Booking Confirmed\" on your custom success page.",
|
||||
"event_trends": "Event Trends",
|
||||
"clear_filters": "Clear Filters",
|
||||
"hold": "Hold",
|
||||
"on_booking_option": "Collect payment on booking",
|
||||
"hold_option": "Charge no-show fee",
|
||||
"card_held": "Card held",
|
||||
"charge_card": "Charge card",
|
||||
"card_charged": "Card charged",
|
||||
"no_show_fee_amount": "{{amount, currency}} no-show fee",
|
||||
"no_show_fee": "No-show Fee",
|
||||
"submit_card": "Submit card",
|
||||
"submit_payment_information": "Submit payment information",
|
||||
"meeting_awaiting_payment_method": "Your meeting is awaiting a payment method",
|
||||
"no_show_fee_charged_email_subject": "No-show fee of {{amount, currency}} charged for {{title}} at {{date}}",
|
||||
"no_show_fee_charged_text_body": "No-show fee was charged",
|
||||
"no_show_fee_charged_subtitle": "No-show fee of {{amount, currency}} was charged for the following event",
|
||||
"error_charging_card": "Something went wrong charging the no-show fee. Please try again later.",
|
||||
"collect_no_show_fee": "Collect no-show fee",
|
||||
"no_show_fee_charged": "No-show fee charged",
|
||||
"insights": "Insights",
|
||||
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
|
||||
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
|
||||
"card_details": "Card details",
|
||||
"seats_and_no_show_fee_error": "Currently cannot enable seats and charge a no-show fee",
|
||||
"complete_your_booking": "Complete your booking",
|
||||
"complete_your_booking_subject": "Complete your booking: {{title}} on {{date}}",
|
||||
"email_invite_team": "{{email}} has been invited"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export const getEventTypeAppData = <T extends EventTypeAppsList>(
|
||||
price: eventType.price,
|
||||
// Currency default is "usd" in DB.So, it would also be available always
|
||||
currency: eventType.currency,
|
||||
paymentOption: "ON_BOOKING",
|
||||
},
|
||||
rainbow: {
|
||||
enabled: !!(eventType.metadata?.smartContractAddress && eventType.metadata?.blockchainId),
|
||||
|
||||
@@ -7,18 +7,24 @@ import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert, TextField } from "@calcom/ui";
|
||||
import { Alert, TextField, Select } from "@calcom/ui";
|
||||
|
||||
import { paymentOptions } from "../lib/constants";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const { asPath } = useRouter();
|
||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const price = getAppData("price");
|
||||
const currency = getAppData("currency");
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions.find((option) => paymentOption === option.value);
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
const { t } = useLocale();
|
||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
||||
const seatsEnabled = !!eventType.seatsPerTimeSlot;
|
||||
const getCurrencySymbol = (locale: string, currency: string) =>
|
||||
(0)
|
||||
.toLocaleString(locale, {
|
||||
@@ -54,22 +60,43 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
|
||||
) : (
|
||||
requirePayment && (
|
||||
<div className="mt-2 block items-center sm:flex">
|
||||
<TextField
|
||||
label=""
|
||||
addOnLeading={<>{currency ? getCurrencySymbol("en", currency) : ""}</>}
|
||||
step="0.01"
|
||||
min="0.5"
|
||||
type="number"
|
||||
required
|
||||
className="border-default block w-full rounded-sm pl-2 pr-12 text-sm"
|
||||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", Number(e.target.value) * 100);
|
||||
}}
|
||||
value={price > 0 ? price / 100 : undefined}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="mt-2 block items-center justify-start sm:flex sm:space-x-2">
|
||||
<TextField
|
||||
label=""
|
||||
className="h-[38px]"
|
||||
addOnLeading={<>{currency ? getCurrencySymbol("en", currency) : ""}</>}
|
||||
addOnClassname="h-[38px]"
|
||||
step="0.01"
|
||||
min="0.5"
|
||||
type="number"
|
||||
required
|
||||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", Number(e.target.value) * 100);
|
||||
}}
|
||||
value={price > 0 ? price / 100 : undefined}
|
||||
/>
|
||||
<Select<Option>
|
||||
defaultValue={
|
||||
paymentOptionSelectValue
|
||||
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
|
||||
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
|
||||
}
|
||||
options={paymentOptions.map((option) => {
|
||||
return { ...option, label: t(option.label) || option.label };
|
||||
})}
|
||||
onChange={(input) => {
|
||||
if (input) setAppData("paymentOption", input.value);
|
||||
}}
|
||||
className="mb-1 h-[38px] w-full"
|
||||
isDisabled={seatsEnabled}
|
||||
/>
|
||||
</div>
|
||||
{seatsEnabled && paymentOption === "HOLD" && (
|
||||
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Booking, Payment, Prisma } from "@prisma/client";
|
||||
import type { Booking, Payment, Prisma, PaymentOption } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
@@ -9,8 +9,10 @@ import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { paymentOptionEnum } from "../zod";
|
||||
import { createPaymentLink } from "./client";
|
||||
import type { StripePaymentData } from "./server";
|
||||
import { retrieveOrCreateStripeCustomerByEmail } from "./customer";
|
||||
import type { StripeSetupIntentData, StripePaymentData } from "./server";
|
||||
|
||||
const stripeCredentialKeysSchema = z.object({
|
||||
stripe_user_id: z.string(),
|
||||
@@ -36,11 +38,19 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
});
|
||||
}
|
||||
|
||||
/* This method is for creating charges at the time of booking */
|
||||
async create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"]
|
||||
bookingId: Booking["id"],
|
||||
bookerEmail: string,
|
||||
paymentOption: PaymentOption
|
||||
) {
|
||||
try {
|
||||
// Ensure that the payment service can support the passed payment option
|
||||
if (paymentOptionEnum.parse(paymentOption) !== "ON_BOOKING") {
|
||||
throw new Error("Payment option is not compatible with create method");
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
select: {
|
||||
@@ -57,10 +67,17 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
);
|
||||
const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed);
|
||||
|
||||
const customer = await retrieveOrCreateStripeCustomerByEmail(
|
||||
bookerEmail,
|
||||
this.credentials.stripe_user_id
|
||||
);
|
||||
|
||||
const params: Stripe.PaymentIntentCreateParams = {
|
||||
amount: payment.amount,
|
||||
currency: this.credentials.default_currency,
|
||||
payment_method_types: ["card"],
|
||||
application_fee_amount: paymentFee,
|
||||
customer: customer.id,
|
||||
};
|
||||
|
||||
const paymentIntent = await this.stripe.paymentIntents.create(params, {
|
||||
@@ -91,6 +108,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
fee: paymentFee,
|
||||
refunded: false,
|
||||
success: false,
|
||||
paymentOption: paymentOption || "ON_BOOKING",
|
||||
},
|
||||
});
|
||||
if (!paymentData) {
|
||||
@@ -103,6 +121,161 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
}
|
||||
}
|
||||
|
||||
async collectCard(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"],
|
||||
bookerEmail: string,
|
||||
paymentOption: PaymentOption
|
||||
): Promise<Payment> {
|
||||
// Ensure that the payment service can support the passed payment option
|
||||
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
|
||||
throw new Error("Payment option is not compatible with create method");
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
where: {
|
||||
slug: "stripe",
|
||||
},
|
||||
});
|
||||
|
||||
// Parse keys with zod
|
||||
const { client_id, payment_fee_fixed, payment_fee_percentage } = stripeAppKeysSchema.parse(
|
||||
stripeAppKeys?.keys
|
||||
);
|
||||
const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed);
|
||||
|
||||
const customer = await retrieveOrCreateStripeCustomerByEmail(
|
||||
bookerEmail,
|
||||
this.credentials.stripe_user_id
|
||||
);
|
||||
|
||||
const params = {
|
||||
customer: customer.id,
|
||||
payment_method_types: ["card"],
|
||||
metadata: {
|
||||
bookingId,
|
||||
},
|
||||
};
|
||||
|
||||
const setupIntent = await this.stripe.setupIntents.create(params, {
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
});
|
||||
|
||||
const paymentData = await prisma?.payment.create({
|
||||
data: {
|
||||
uid: uuidv4(),
|
||||
app: {
|
||||
connect: {
|
||||
slug: "stripe",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
externalId: setupIntent.id,
|
||||
|
||||
data: Object.assign(
|
||||
{},
|
||||
{
|
||||
setupIntent,
|
||||
stripe_publishable_key: this.credentials.stripe_publishable_key,
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
}
|
||||
) as unknown as Prisma.InputJsonValue,
|
||||
fee: paymentFee,
|
||||
refunded: false,
|
||||
success: false,
|
||||
paymentOption: paymentOption || "ON_BOOKING",
|
||||
},
|
||||
});
|
||||
|
||||
return paymentData;
|
||||
}
|
||||
|
||||
async chargeCard(payment: Payment, bookingId: Booking["id"]): Promise<Payment> {
|
||||
try {
|
||||
const stripeAppKeys = await prisma?.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
where: {
|
||||
slug: "stripe",
|
||||
},
|
||||
});
|
||||
|
||||
const paymentObject = payment.data as unknown as StripeSetupIntentData;
|
||||
|
||||
const setupIntent = paymentObject.setupIntent;
|
||||
|
||||
// Parse keys with zod
|
||||
const { client_id, payment_fee_fixed, payment_fee_percentage } = stripeAppKeysSchema.parse(
|
||||
stripeAppKeys?.keys
|
||||
);
|
||||
|
||||
const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed);
|
||||
|
||||
// Ensure that the stripe customer & payment method still exists
|
||||
const customer = await this.stripe.customers.retrieve(setupIntent.customer as string, {
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
});
|
||||
const paymentMethod = await this.stripe.paymentMethods.retrieve(setupIntent.payment_method as string, {
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new Error(`Stripe customer does not exist for setupIntent ${setupIntent.id}`);
|
||||
}
|
||||
|
||||
if (!paymentMethod) {
|
||||
throw new Error(`Stripe paymentMethod does not exist for setupIntent ${setupIntent.id}`);
|
||||
}
|
||||
|
||||
const params = {
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
application_fee_amount: paymentFee,
|
||||
customer: setupIntent.customer as string,
|
||||
payment_method: setupIntent.payment_method as string,
|
||||
off_session: true,
|
||||
confirm: true,
|
||||
};
|
||||
|
||||
const paymentIntent = await this.stripe.paymentIntents.create(params, {
|
||||
stripeAccount: this.credentials.stripe_user_id,
|
||||
});
|
||||
|
||||
const paymentData = await prisma.payment.update({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
...paymentObject,
|
||||
paymentIntent,
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paymentData) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Payment could not be created");
|
||||
}
|
||||
}
|
||||
|
||||
async update(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
@@ -165,6 +338,9 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
email: booking.user?.email,
|
||||
date: booking.startTime.toISOString(),
|
||||
}),
|
||||
paymentOption: paymentData.paymentOption || "ON_BOOKING",
|
||||
amount: paymentData.amount,
|
||||
currency: paymentData.currency,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export const PREMIUM_MONTHLY_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY || "";
|
||||
export const PREMIUM_PLAN_PRODUCT_ID = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRODUCT_ID || "";
|
||||
export const STRIPE_TEAM_MONTHLY_PRICE_ID = process.env.NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID || "";
|
||||
|
||||
export const paymentOptions = [
|
||||
{
|
||||
label: "on_booking_option",
|
||||
value: "ON_BOOKING",
|
||||
},
|
||||
{
|
||||
label: "hold_option",
|
||||
value: "HOLD",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -82,3 +82,27 @@ export async function deleteStripeCustomer(user: UserType): Promise<string | nul
|
||||
|
||||
return deletedCustomer.id;
|
||||
}
|
||||
|
||||
export async function retrieveOrCreateStripeCustomerByEmail(email: string, stripeAccountId: string) {
|
||||
const customer = await stripe.customers.list(
|
||||
{
|
||||
email,
|
||||
limit: 1,
|
||||
},
|
||||
{
|
||||
stripeAccount: stripeAccountId,
|
||||
}
|
||||
);
|
||||
|
||||
if (customer.data[0]?.id) {
|
||||
return customer.data[0];
|
||||
} else {
|
||||
const newCustomer = await stripe.customers.create(
|
||||
{ email },
|
||||
{
|
||||
stripeAccount: stripeAccountId,
|
||||
}
|
||||
);
|
||||
return newCustomer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export type StripePaymentData = Stripe.Response<Stripe.PaymentIntent> & {
|
||||
stripeAccount: string;
|
||||
};
|
||||
|
||||
export type StripeSetupIntentData = {
|
||||
setupIntent: Stripe.Response<Stripe.SetupIntent>;
|
||||
paymentIntent?: StripePaymentData;
|
||||
};
|
||||
|
||||
export const stripeOAuthTokenSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
|
||||
import { paymentOptions } from "./lib/constants";
|
||||
|
||||
// Extract the payment options enum from paymentOptions
|
||||
// https://stackoverflow.com/a/73825370
|
||||
type PaymentOption = (typeof paymentOptions)[number]["value"];
|
||||
const VALUES: [PaymentOption, ...PaymentOption[]] = [
|
||||
paymentOptions[0].value,
|
||||
...paymentOptions.slice(1).map((option) => option.value),
|
||||
];
|
||||
export const paymentOptionEnum = z.enum(VALUES);
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(
|
||||
z.object({
|
||||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: paymentOptionEnum.optional(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -14,6 +25,6 @@ export const appKeysSchema = z.object({
|
||||
client_secret: z.string().startsWith("sk_").min(1),
|
||||
public_key: z.string().startsWith("pk_").min(1),
|
||||
webhook_secret: z.string().startsWith("whsec_").min(1),
|
||||
payment_fee_fixed: z.number().int().min(0),
|
||||
payment_fee_fixed: z.number().min(0),
|
||||
payment_fee_percentage: z.number().min(0),
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -30,7 +30,7 @@ export type EventTypeAppCardComponentProps = {
|
||||
// Limit what data should be accessible to apps
|
||||
eventType: Pick<
|
||||
z.infer<typeof _EventTypeModel>,
|
||||
"id" | "title" | "description" | "teamId" | "length" | "recurringEvent"
|
||||
"id" | "title" | "description" | "teamId" | "length" | "recurringEvent" | "seatsPerTimeSlot"
|
||||
> & {
|
||||
URL: string;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { Feedback } from "./templates/feedback-email";
|
||||
import FeedbackEmail from "./templates/feedback-email";
|
||||
import type { PasswordReset } from "./templates/forgot-password-email";
|
||||
import ForgotPasswordEmail from "./templates/forgot-password-email";
|
||||
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
|
||||
import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email";
|
||||
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
|
||||
import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email";
|
||||
@@ -285,3 +286,7 @@ export const sendDisabledAppEmail = async ({
|
||||
}) => {
|
||||
await sendEmail(() => new DisabledAppEmail(email, appName, appType, t, title, eventTypeId));
|
||||
};
|
||||
|
||||
export const sendNoShowFeeChargedEmail = async (attendee: Person, evt: CalendarEvent) => {
|
||||
await sendEmail(() => new NoShowFeeChargedEmail(evt, attendee));
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@ import EmailBodyLogo from "./EmailBodyLogo";
|
||||
import EmailHead from "./EmailHead";
|
||||
import EmailScheduledBodyHeaderContent from "./EmailScheduledBodyHeaderContent";
|
||||
import EmailSchedulingBodyDivider from "./EmailSchedulingBodyDivider";
|
||||
import EmailSchedulingBodyHeader, { BodyHeadType } from "./EmailSchedulingBodyHeader";
|
||||
import type { BodyHeadType } from "./EmailSchedulingBodyHeader";
|
||||
import EmailSchedulingBodyHeader from "./EmailSchedulingBodyHeader";
|
||||
import RawHtml from "./RawHtml";
|
||||
import Row from "./Row";
|
||||
|
||||
@@ -20,7 +21,7 @@ export const BaseEmailHtml = (props: {
|
||||
callToAction?: React.ReactNode;
|
||||
subject: string;
|
||||
title?: string;
|
||||
subtitle?: React.ReactNode;
|
||||
subtitle?: React.ReactNode | string;
|
||||
headerType?: BodyHeadType;
|
||||
}) => {
|
||||
return (
|
||||
|
||||
@@ -13,12 +13,22 @@ function ManageLink(props: React.ComponentProps<typeof AttendeeScheduledEmail>)
|
||||
);
|
||||
}
|
||||
|
||||
export const AttendeeAwaitingPaymentEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
|
||||
<AttendeeScheduledEmail
|
||||
title="meeting_awaiting_payment"
|
||||
headerType="calendarCircle"
|
||||
subject="awaiting_payment_subject"
|
||||
callToAction={<ManageLink {...props} />}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
export const AttendeeAwaitingPaymentEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => {
|
||||
return props.calEvent.paymentInfo?.paymentOption === "HOLD" ? (
|
||||
<AttendeeScheduledEmail
|
||||
title="meeting_awaiting_payment_method"
|
||||
headerType="calendarCircle"
|
||||
subject="awaiting_payment_subject"
|
||||
callToAction={<ManageLink {...props} />}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<AttendeeScheduledEmail
|
||||
title="meeting_awaiting_payment"
|
||||
headerType="calendarCircle"
|
||||
subject="awaiting_payment_subject"
|
||||
callToAction={<ManageLink {...props} />}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -80,6 +80,16 @@ export const BaseScheduledEmail = (
|
||||
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
|
||||
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
|
||||
<UserFieldsResponses calEvent={props.calEvent} />
|
||||
{props.calEvent.paymentInfo?.amount && (
|
||||
<Info
|
||||
label={props.calEvent.paymentInfo?.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
|
||||
description={new Intl.NumberFormat(props.attendee.language.locale, {
|
||||
style: "currency",
|
||||
currency: props.calEvent.paymentInfo?.currency || "USD",
|
||||
}).format(props.calEvent.paymentInfo?.amount / 100.0)}
|
||||
withSpacer
|
||||
/>
|
||||
)}
|
||||
</BaseEmailHtml>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { BaseScheduledEmail } from "./BaseScheduledEmail";
|
||||
|
||||
export const NoShowFeeChargedEmail = (
|
||||
props: {
|
||||
calEvent: CalendarEvent;
|
||||
attendee: Person;
|
||||
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
|
||||
) => {
|
||||
const { calEvent } = props;
|
||||
const t = props.attendee.language.translate;
|
||||
|
||||
if (!calEvent.paymentInfo?.amount) throw new Error("No payment info");
|
||||
|
||||
return (
|
||||
<BaseScheduledEmail
|
||||
title={t("no_show_fee_charged_text_body")}
|
||||
headerType="calendarCircle"
|
||||
subtitle={
|
||||
<>
|
||||
{t("no_show_fee_charged_subtitle", {
|
||||
amount: calEvent.paymentInfo.amount / 100,
|
||||
formatParams: { amount: { currency: calEvent.paymentInfo?.currency } },
|
||||
})}
|
||||
</>
|
||||
}
|
||||
timeZone={props.attendee.timeZone}
|
||||
{...props}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -21,4 +21,5 @@ export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
|
||||
export { TeamInviteEmail } from "./TeamInviteEmail";
|
||||
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
|
||||
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
|
||||
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
|
||||
export * from "@calcom/app-store/routing-forms/emails/components";
|
||||
|
||||
@@ -7,7 +7,7 @@ export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
subject: `${this.attendee.language.translate("awaiting_payment_subject", {
|
||||
subject: `${this.attendee.language.translate("complete_your_booking_subject", {
|
||||
title: this.calEvent.title,
|
||||
date: this.getFormattedDate(),
|
||||
})}`,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { renderEmail } from "../";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class NoShowFeeChargedEmail extends AttendeeScheduledEmail {
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
if (!this.calEvent.paymentInfo?.amount) throw new Error("No payment into");
|
||||
return {
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
subject: `${this.attendee.language.translate("no_show_fee_charged_email_subject", {
|
||||
title: this.calEvent.title,
|
||||
date: this.getFormattedDate(),
|
||||
amount: this.calEvent.paymentInfo.amount / 100,
|
||||
formatParams: { amount: { currency: this.calEvent.paymentInfo?.currency } },
|
||||
})}`,
|
||||
html: renderEmail("NoShowFeeChargedEmail", {
|
||||
calEvent: this.calEvent,
|
||||
attendee: this.attendee,
|
||||
}),
|
||||
text: this.getTextBody("no_show_fee_charged_text_body"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1412,7 +1412,8 @@ async function handler(
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
booking
|
||||
booking,
|
||||
bookerEmail
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -1846,17 +1847,18 @@ async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isConfirmedByDefault && noEmail !== true) {
|
||||
const bookingRequiresPayment =
|
||||
!Number.isNaN(paymentAppData.price) &&
|
||||
paymentAppData.price > 0 &&
|
||||
!originalRescheduledBooking?.paid &&
|
||||
!!booking;
|
||||
|
||||
if (!isConfirmedByDefault && noEmail !== true && !bookingRequiresPayment) {
|
||||
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
|
||||
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isNaN(paymentAppData.price) &&
|
||||
paymentAppData.price > 0 &&
|
||||
!originalRescheduledBooking?.paid &&
|
||||
!!booking
|
||||
) {
|
||||
if (bookingRequiresPayment) {
|
||||
// Load credentials.app.categories
|
||||
const credentialPaymentAppCategories = await prisma.credential.findMany({
|
||||
where: {
|
||||
@@ -1892,7 +1894,8 @@ async function handler(
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
booking
|
||||
booking,
|
||||
bookerEmail
|
||||
);
|
||||
|
||||
req.statusCode = 201;
|
||||
|
||||
@@ -56,7 +56,7 @@ export function Calendar(props: CalendarComponentProps) {
|
||||
containerRef={container}
|
||||
/> */}
|
||||
<div className="flex flex-auto">
|
||||
<div className="bg-default sticky left-0 z-10 w-14 flex-none ring-1 ring-muted" />
|
||||
<div className="bg-default ring-muted sticky left-0 z-10 w-14 flex-none ring-1" />
|
||||
<div className="grid flex-auto grid-cols-1 grid-rows-1 ">
|
||||
<HorizontalLines
|
||||
hours={hours}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type Stripe from "stripe";
|
||||
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledEmails } from "@calcom/emails";
|
||||
import { sendScheduledEmails, sendOrganizerRequestEmail, sendAttendeeRequestEmail } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
@@ -35,6 +35,92 @@ async function getEventType(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getBooking(bookingId: number) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
eventType: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
uid: true,
|
||||
paid: true,
|
||||
destinationCalendar: true,
|
||||
status: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
credentials: true,
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
|
||||
|
||||
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
|
||||
let eventTypeRaw: EventTypeRaw | null = null;
|
||||
if (booking.eventTypeId) {
|
||||
eventTypeRaw = await getEventType(booking.eventTypeId);
|
||||
}
|
||||
|
||||
const { user } = booking;
|
||||
|
||||
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
||||
|
||||
const t = await getTranslation(user.locale ?? "en", "common");
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
description: booking.description || undefined,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
||||
organizer: {
|
||||
email: user.email,
|
||||
name: user.name!,
|
||||
timeZone: user.timeZone,
|
||||
language: { translate: t, locale: user.locale ?? "en" },
|
||||
id: user.id,
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
|
||||
};
|
||||
|
||||
return {
|
||||
booking,
|
||||
user,
|
||||
evt,
|
||||
eventTypeRaw,
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||
const payment = await prisma.payment.findFirst({
|
||||
@@ -180,10 +266,61 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
});
|
||||
}
|
||||
|
||||
const handleSetupSuccess = async (event: Stripe.Event) => {
|
||||
const setupIntent = event.data.object as Stripe.SetupIntent;
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
externalId: setupIntent.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment?.data || !payment?.id) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||
|
||||
const { booking, user, evt, eventTypeRaw } = await getBooking(payment.bookingId);
|
||||
|
||||
const bookingData: Prisma.BookingUpdateInput = {
|
||||
paid: true,
|
||||
};
|
||||
|
||||
if (!eventTypeRaw?.requiresConfirmation) {
|
||||
const eventManager = new EventManager(user);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
bookingData.status = BookingStatus.ACCEPTED;
|
||||
}
|
||||
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
data: {
|
||||
data: {
|
||||
...(payment.data as Prisma.JsonObject),
|
||||
setupIntent: setupIntent as unknown as Prisma.JsonObject,
|
||||
},
|
||||
booking: {
|
||||
update: {
|
||||
...bookingData,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// If the card information was already captured in the same customer. Delete the previous payment method
|
||||
|
||||
if (!eventTypeRaw?.requiresConfirmation) {
|
||||
await sendScheduledEmails({ ...evt });
|
||||
} else {
|
||||
await sendOrganizerRequestEmail({ ...evt });
|
||||
await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]);
|
||||
}
|
||||
};
|
||||
|
||||
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
|
||||
|
||||
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
|
||||
"payment_intent.succeeded": handlePaymentSuccess,
|
||||
"setup_intent.succeeded": handleSetupSuccess,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Payment } from "@prisma/client";
|
||||
import { CardElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import type { StripeCardElementChangeEvent, StripeElementLocale } from "@stripe/stripe-js";
|
||||
import type stripejs from "@stripe/stripe-js";
|
||||
@@ -5,10 +6,10 @@ import { useRouter } from "next/router";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { StripePaymentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Button, Checkbox } from "@calcom/ui";
|
||||
|
||||
import type { EventType } from ".prisma/client";
|
||||
|
||||
@@ -32,8 +33,8 @@ const CARD_OPTIONS: stripejs.StripeCardElementOptions = {
|
||||
} as const;
|
||||
|
||||
type Props = {
|
||||
payment: {
|
||||
data: StripePaymentData;
|
||||
payment: Omit<Payment, "id" | "fee" | "success" | "refunded" | "externalId" | "data"> & {
|
||||
data: StripePaymentData | StripeSetupIntentData;
|
||||
};
|
||||
eventType: { id: number; successRedirectUrl: EventType["successRedirectUrl"] };
|
||||
user: { username: string | null };
|
||||
@@ -54,6 +55,8 @@ export default function PaymentComponent(props: Props) {
|
||||
const [state, setState] = useState<States>({ status: "idle" });
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const paymentOption = props.payment.paymentOption;
|
||||
const [holdAcknowledged, setHoldAcknowledged] = useState<boolean>(paymentOption === "HOLD" ? false : true);
|
||||
|
||||
useEffect(() => {
|
||||
elements?.update({ locale: i18n.language as StripeElementLocale });
|
||||
@@ -74,22 +77,33 @@ export default function PaymentComponent(props: Props) {
|
||||
const card = elements.getElement(CardElement);
|
||||
if (!card) return;
|
||||
setState({ status: "processing" });
|
||||
const payload = await stripe.confirmCardPayment(props.payment.data.client_secret!, {
|
||||
payment_method: {
|
||||
card,
|
||||
},
|
||||
});
|
||||
if (payload.error) {
|
||||
|
||||
let payload;
|
||||
const params: { [k: string]: any } = {
|
||||
uid: props.bookingUid,
|
||||
email: router.query.email,
|
||||
};
|
||||
if (paymentOption === "HOLD" && "setupIntent" in props.payment.data) {
|
||||
const setupIntentData = props.payment.data as unknown as StripeSetupIntentData;
|
||||
payload = await stripe.confirmCardSetup(setupIntentData.setupIntent.client_secret!, {
|
||||
payment_method: {
|
||||
card,
|
||||
},
|
||||
});
|
||||
} else if (paymentOption === "ON_BOOKING") {
|
||||
const paymentData = props.payment.data as unknown as StripePaymentData;
|
||||
payload = await stripe.confirmCardPayment(paymentData.client_secret!, {
|
||||
payment_method: {
|
||||
card,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (payload?.error) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: new Error(`Payment failed: ${payload.error.message}`),
|
||||
});
|
||||
} else {
|
||||
const params: { [k: string]: any } = {
|
||||
uid: props.bookingUid,
|
||||
email: router.query.email,
|
||||
};
|
||||
|
||||
if (props.location) {
|
||||
if (props.location.includes("integration")) {
|
||||
params.location = t("web_conferencing_details_to_follow");
|
||||
@@ -107,22 +121,53 @@ export default function PaymentComponent(props: Props) {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form id="payment-form" className="mt-4" onSubmit={handleSubmit}>
|
||||
<CardElement id="card-element" options={CARD_OPTIONS} onChange={handleChange} />
|
||||
<div className="mt-2 flex justify-center">
|
||||
<form id="payment-form" className="bg-subtle mt-4 rounded-md p-6" onSubmit={handleSubmit}>
|
||||
<p className="font-semibold">{t("card_details")}</p>
|
||||
<CardElement
|
||||
className="my-5 bg-white p-2"
|
||||
id="card-element"
|
||||
options={CARD_OPTIONS}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{paymentOption === "HOLD" && (
|
||||
<div className="bg-info mt-2 mb-5 rounded-md p-3">
|
||||
<Checkbox
|
||||
description={t("acknowledge_booking_no_show_fee", {
|
||||
amount: props.payment.amount / 100,
|
||||
formatParams: { amount: { currency: props.payment.currency } },
|
||||
})}
|
||||
onChange={(e) => setHoldAcknowledged(e.target.checked)}
|
||||
descriptionClassName="text-blue-900 font-semibold"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex justify-end space-x-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
disabled={!holdAcknowledged || ["processing", "error"].includes(state.status)}
|
||||
id="cancel">
|
||||
<span id="button-text">{t("cancel")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={["processing", "error"].includes(state.status)}
|
||||
disabled={!holdAcknowledged || ["processing", "error"].includes(state.status)}
|
||||
loading={state.status === "processing"}
|
||||
id="submit">
|
||||
id="submit"
|
||||
className="border-subtle border">
|
||||
<span id="button-text">
|
||||
{state.status === "processing" ? <div className="spinner" id="spinner" /> : t("pay_now")}
|
||||
{state.status === "processing" ? (
|
||||
<div className="spinner" id="spinner" />
|
||||
) : paymentOption === "HOLD" ? (
|
||||
t("submit_card")
|
||||
) : (
|
||||
t("pay_now")
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<div className="text-default mt-4 text-center dark:text-gray-300" role="alert">
|
||||
<div className="mt-4 text-center text-red-900 dark:text-gray-300" role="alert">
|
||||
{state.error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -73,7 +73,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
</span>
|
||||
<div
|
||||
className={classNames(
|
||||
"main bg-default inline-block transform overflow-hidden rounded-lg border border-neutral-200 px-8 pt-5 pb-4 text-left align-bottom transition-all dark:border-neutral-700 dark:bg-gray-800 sm:w-full sm:max-w-lg sm:py-6 sm:align-middle",
|
||||
"main bg-default border-subtle inline-block transform overflow-hidden rounded-lg border px-8 pt-5 pb-4 text-left align-bottom transition-all sm:w-full sm:max-w-lg sm:py-6 sm:align-middle",
|
||||
isEmbed ? "" : "sm:my-8"
|
||||
)}
|
||||
role="dialog"
|
||||
@@ -85,10 +85,8 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3
|
||||
className="text-emphasis dark:text-inverted text-2xl font-semibold leading-6"
|
||||
id="modal-headline">
|
||||
{t("payment")}
|
||||
<h3 className="text-emphasis text-2xl font-semibold leading-6" id="modal-headline">
|
||||
{paymentAppData.paymentOption === "HOLD" ? t("complete_your_booking") : t("payment")}
|
||||
</h3>
|
||||
<div className="text-default mt-4 grid grid-cols-3 border-t border-b py-4 text-left dark:border-gray-900 dark:text-gray-300">
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
@@ -108,8 +106,10 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="font-medium">{t("price")}</div>
|
||||
<div className="col-span-2 mb-6">
|
||||
<div className="font-medium">
|
||||
{props.payment.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-6 font-semibold">
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BookingStatus } from "@prisma/client";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { StripePaymentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
@@ -30,6 +30,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
refunded: true,
|
||||
bookingId: true,
|
||||
appId: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
paymentOption: true,
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -87,7 +90,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { data, booking: _booking, ...restPayment } = rawPayment;
|
||||
const payment = {
|
||||
...restPayment,
|
||||
data: data as unknown as StripePaymentData,
|
||||
data: data as unknown as StripePaymentData | StripeSetupIntentData,
|
||||
};
|
||||
|
||||
if (!_booking) return { notFound: true };
|
||||
|
||||
@@ -41,7 +41,7 @@ const SAMLSSO = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-default sm:mx-0 xl:mt-0">
|
||||
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
||||
<Meta title={t("sso_configuration")} description={t("sso_configuration_description")} />
|
||||
<SSOConfiguration teamId={teamId} />
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ const SAMLSSO = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full bg-default sm:mx-0 xl:mt-0">
|
||||
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
||||
<Meta title={t("sso_configuration")} description={t("sso_configuration_description")} />
|
||||
<SSOConfiguration teamId={null} />
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@ const WorkflowListItem = (props: ItemProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center overflow-hidden rounded-md border border-subtle p-6 px-3 md:p-6">
|
||||
<div className="border-subtle flex w-full items-center overflow-hidden rounded-md border p-6 px-3 md:p-6">
|
||||
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
|
||||
{getActionIcon(
|
||||
workflow.steps,
|
||||
|
||||
@@ -53,7 +53,7 @@ const DateOverrideList = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="rounded border border-subtle" data-testid="date-overrides-list">
|
||||
<ul className="border-subtle rounded border" data-testid="date-overrides-list">
|
||||
{items.sort(sortByDate).map((item, index) => (
|
||||
<li key={item.id} className="flex justify-between border-b px-5 py-4 last:border-b-0">
|
||||
<div>
|
||||
|
||||
@@ -52,7 +52,7 @@ const ScheduleDay = <TFieldValues extends FieldValues>({
|
||||
const watchDayRange = watch(name);
|
||||
|
||||
return (
|
||||
<div className="flex mb-4 w-full flex-col last:mb-0 sm:flex-row sm:px-0">
|
||||
<div className="mb-4 flex w-full flex-col last:mb-0 sm:flex-row sm:px-0">
|
||||
{/* Label & switch container */}
|
||||
<div className="flex h-[36px] items-center justify-between sm:w-32">
|
||||
<div>
|
||||
@@ -179,7 +179,7 @@ export const DayRanges = <TFieldValues extends FieldValues>({
|
||||
<div>
|
||||
{fields.map((field, index: number) => (
|
||||
<Fragment key={field.id}>
|
||||
<div className="flex mb-2 last:mb-0">
|
||||
<div className="mb-2 flex last:mb-0">
|
||||
<Controller name={`${name}.${index}`} render={({ field }) => <TimeRangeField {...field} />} />
|
||||
{index === 0 && (
|
||||
<Button
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { Payment, Prisma, Booking } from "@prisma/client";
|
||||
import type { Payment, Prisma, Booking, PaymentOption } from "@prisma/client";
|
||||
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
export interface IAbstractPaymentService {
|
||||
/* This method is for creating charges at the time of booking */
|
||||
create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"],
|
||||
bookerEmail: string,
|
||||
paymentOption: PaymentOption
|
||||
): Promise<Payment>;
|
||||
/* This method is to collect card details to charge at a later date ex. no-show fees */
|
||||
collectCard(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"],
|
||||
bookerEmail: string,
|
||||
paymentOption: PaymentOption
|
||||
): Promise<Payment>;
|
||||
chargeCard(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"]
|
||||
): Promise<Payment>;
|
||||
|
||||
@@ -210,10 +210,12 @@ export default async function getEventTypeById({
|
||||
const newMetadata = EventTypeMetaDataSchema.parse(metadata || {})!;
|
||||
const apps = newMetadata.apps || {};
|
||||
const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata };
|
||||
const stripeMetaData = getPaymentAppData(eventTypeWithParsedMetadata, true);
|
||||
newMetadata.apps = {
|
||||
...apps,
|
||||
stripe: {
|
||||
...getPaymentAppData(eventTypeWithParsedMetadata, true),
|
||||
...stripeMetaData,
|
||||
paymentOption: stripeMetaData.paymentOption as string,
|
||||
currency:
|
||||
(
|
||||
credentials.find((integration) => integration.type === "stripe_payment")
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { z } from "zod";
|
||||
|
||||
import { getEventTypeAppData } from "@calcom/app-store/_utils/getEventTypeAppData";
|
||||
import type { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
|
||||
import type { appDataSchema } from "@calcom/app-store/stripepayment/zod";
|
||||
import type { appDataSchema, paymentOptionEnum } from "@calcom/app-store/stripepayment/zod";
|
||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
|
||||
export default function getPaymentAppData(
|
||||
@@ -27,6 +27,7 @@ export default function getPaymentAppData(
|
||||
price: number;
|
||||
currency: string;
|
||||
appId: EventTypeAppsList | null;
|
||||
paymentOption: typeof paymentOptionEnum;
|
||||
} | null = null;
|
||||
for (const appId of paymentAppIds) {
|
||||
const appData = getEventTypeAppData(eventType, appId, forcedGet);
|
||||
@@ -39,5 +40,7 @@ export default function getPaymentAppData(
|
||||
}
|
||||
// This is the current expectation of system to have price and currency set always(using DB Level defaults).
|
||||
// Newly added apps code should assume that their app data might not be set.
|
||||
return paymentAppData || { enabled: false, price: 0, currency: "usd", appId: null };
|
||||
return (
|
||||
paymentAppData || { enabled: false, price: 0, currency: "usd", appId: null, paymentOption: "ON_BOOKING" }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ const handlePayment = async (
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
}
|
||||
},
|
||||
bookerEmail: string
|
||||
) => {
|
||||
const paymentApp = await appStore[paymentAppCredentials?.app?.dirName as keyof typeof appStore];
|
||||
if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
|
||||
@@ -30,13 +31,32 @@ const handlePayment = async (
|
||||
}
|
||||
const PaymentService = paymentApp.lib.PaymentService;
|
||||
const paymentInstance = new PaymentService(paymentAppCredentials);
|
||||
const paymentData = await paymentInstance.create(
|
||||
{
|
||||
amount: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].price,
|
||||
currency: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].currency,
|
||||
},
|
||||
booking.id
|
||||
);
|
||||
|
||||
const paymentOption =
|
||||
selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].paymentOption || "ON_BOOKING";
|
||||
|
||||
let paymentData;
|
||||
if (paymentOption === "HOLD") {
|
||||
paymentData = await paymentInstance.collectCard(
|
||||
{
|
||||
amount: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].price,
|
||||
currency: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].currency,
|
||||
},
|
||||
booking.id,
|
||||
bookerEmail,
|
||||
paymentOption
|
||||
);
|
||||
} else {
|
||||
paymentData = await paymentInstance.create(
|
||||
{
|
||||
amount: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].price,
|
||||
currency: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].currency,
|
||||
},
|
||||
booking.id,
|
||||
bookerEmail,
|
||||
paymentOption
|
||||
);
|
||||
}
|
||||
|
||||
if (!paymentData) {
|
||||
console.error("Payment data is null");
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentOption" AS ENUM ('ON_BOOKING', 'HOLD');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" ADD COLUMN "paymentOption" "PaymentOption" DEFAULT 'ON_BOOKING';
|
||||
@@ -412,19 +412,25 @@ model ReminderMail {
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id Int @id @default(autoincrement())
|
||||
uid String @unique
|
||||
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
|
||||
appId String?
|
||||
bookingId Int
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
amount Int
|
||||
fee Int
|
||||
currency String
|
||||
success Boolean
|
||||
refunded Boolean
|
||||
data Json
|
||||
externalId String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
uid String @unique
|
||||
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
|
||||
appId String?
|
||||
bookingId Int
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
amount Int
|
||||
fee Int
|
||||
currency String
|
||||
success Boolean
|
||||
refunded Boolean
|
||||
data Json
|
||||
externalId String @unique
|
||||
paymentOption PaymentOption? @default(ON_BOOKING)
|
||||
}
|
||||
|
||||
enum PaymentOption {
|
||||
ON_BOOKING
|
||||
HOLD
|
||||
}
|
||||
|
||||
enum WebhookTriggerEvents {
|
||||
|
||||
@@ -54,6 +54,7 @@ import { availabilityRouter } from "./viewer/availability";
|
||||
import { bookingsRouter } from "./viewer/bookings";
|
||||
import { deploymentSetupRouter } from "./viewer/deploymentSetup";
|
||||
import { eventTypesRouter } from "./viewer/eventTypes";
|
||||
import { paymentsRouter } from "./viewer/payments";
|
||||
import { slotsRouter } from "./viewer/slots";
|
||||
import { ssoRouter } from "./viewer/sso";
|
||||
import { viewerTeamsRouter } from "./viewer/teams";
|
||||
@@ -1338,6 +1339,7 @@ export const viewerRouter = mergeRouters(
|
||||
appRoutingForms: app_RoutingForms,
|
||||
eth: ethRouter,
|
||||
features: featureFlagRouter,
|
||||
payments: paymentsRouter,
|
||||
appsRouter,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -268,6 +268,14 @@ export const bookingsRouter = router({
|
||||
},
|
||||
status: true,
|
||||
paid: true,
|
||||
payment: {
|
||||
select: {
|
||||
paymentOption: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import appStore from "@calcom/app-store";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendNoShowFeeChargedEmail } from "@calcom/emails";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { router, authedProcedure } from "../../trpc";
|
||||
|
||||
export const paymentsRouter = router({
|
||||
chargeCard: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
bookingId: z.number(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { prisma } = ctx;
|
||||
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
id: input.bookingId,
|
||||
},
|
||||
include: {
|
||||
payment: true,
|
||||
user: true,
|
||||
attendees: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
if (booking.payment[0].success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `The no show fee for ${booking.id} has already been charged.`,
|
||||
});
|
||||
}
|
||||
|
||||
const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common");
|
||||
|
||||
const attendeesListPromises = [];
|
||||
|
||||
for (const attendee of booking.attendees) {
|
||||
const attendeeObject = {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
};
|
||||
|
||||
attendeesListPromises.push(attendeeObject);
|
||||
}
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: (booking?.eventType?.title as string) || booking?.title,
|
||||
title: booking.title,
|
||||
startTime: dayjs(booking.startTime).format(),
|
||||
endTime: dayjs(booking.endTime).format(),
|
||||
organizer: {
|
||||
email: booking.user?.email || "",
|
||||
name: booking.user?.name || "Nameless",
|
||||
timeZone: booking.user?.timeZone || "",
|
||||
language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
paymentInfo: {
|
||||
amount: booking.payment[0].amount,
|
||||
currency: booking.payment[0].currency,
|
||||
paymentOption: booking.payment[0].paymentOption,
|
||||
},
|
||||
};
|
||||
|
||||
const paymentCredential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
appId: booking.payment[0].appId,
|
||||
},
|
||||
include: {
|
||||
app: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paymentCredential?.app) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid payment credential" });
|
||||
}
|
||||
|
||||
const paymentApp = await appStore[paymentCredential?.app?.dirName as keyof typeof appStore];
|
||||
|
||||
if (!("lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Payment service not found" });
|
||||
}
|
||||
|
||||
const PaymentService = paymentApp.lib.PaymentService;
|
||||
const paymentInstance = new PaymentService(paymentCredential);
|
||||
|
||||
try {
|
||||
const paymentData = await paymentInstance.chargeCard(booking.payment[0], booking.id);
|
||||
|
||||
if (!paymentData) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: `Could not generate payment data` });
|
||||
}
|
||||
|
||||
await sendNoShowFeeChargedEmail(attendeesListPromises[0], evt);
|
||||
|
||||
return paymentData;
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error processing payment with error ${err}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
Vendored
+3
@@ -18,6 +18,9 @@ type PaymentInfo = {
|
||||
link?: string | null;
|
||||
reason?: string | null;
|
||||
id?: string | null;
|
||||
paymentOption?: string | null;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
export type Person = {
|
||||
|
||||
@@ -45,7 +45,7 @@ const buttonClasses = cva(
|
||||
fab: "rounded-full justify-center md:rounded-md radix-state-open:rotate-45 md:radix-state-open:rotate-0 transition-transform radix-state-open:shadown-none radix-state-open:ring-0 !shadow-none",
|
||||
},
|
||||
color: {
|
||||
primary: "text-inverted",
|
||||
primary: "",
|
||||
secondary: "text-emphasis",
|
||||
minimal: "text-emphasis",
|
||||
destructive: "",
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
informationIconText?: string;
|
||||
error?: boolean;
|
||||
className?: string;
|
||||
descriptionClassName?: string;
|
||||
};
|
||||
|
||||
const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
@@ -34,7 +35,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="relative flex items-start">
|
||||
<div className="relative flex items-center">
|
||||
{React.createElement(
|
||||
descriptionAsLabel ? "label" : "div",
|
||||
{
|
||||
@@ -45,7 +46,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
),
|
||||
},
|
||||
<>
|
||||
<div className="flex h-5 items-center">
|
||||
<div className="flex h-5 items-end">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
@@ -61,7 +62,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm">{description}</span>
|
||||
<span className={classNames("text-sm", rest.descriptionClassName)}>{description}</span>
|
||||
</>
|
||||
)}
|
||||
{/* {informationIconText && <InfoBadge content={informationIconText}></InfoBadge>} */}
|
||||
|
||||
Reference in New Issue
Block a user