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:
Joe Au-Yeung
2023-04-11 23:44:14 +02:00
committed by GitHub
co-authored by Alex van Andel
parent af0123a606
commit 817e20f11e
47 changed files with 1149 additions and 221 deletions
@@ -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>
);
};
+54 -12
View File
@@ -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")} />}
</>
)}
/>
+46 -35
View File
@@ -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(),
+12 -1
View File
@@ -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),
});
+1 -1
View File
@@ -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;
};
+5
View File
@@ -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}
/>
);
};
+1
View File
@@ -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}
+138 -1
View File
@@ -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
+15 -1
View File
@@ -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>;
+3 -1
View File
@@ -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")
+5 -2
View File
@@ -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" }
);
}
+28 -8
View File
@@ -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';
+19 -13
View File
@@ -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 {
+2
View File
@@ -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}`,
});
}
}),
});
+3
View File
@@ -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 = {
+1 -1
View File
@@ -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>} */}