Adds Stripe integration (#717)

* Adds Stripe integration

* Moves Stripe instrucctions to ee

* Adds NEXT_PUBLIC_APP_URL variable

* Adds fallback for NEXT_PUBLIC_APP_URL

* Throws error objects instead

* Improved error handling

* Removes deprecated method

* Bug fixing

* Payment refactoring

* PaymentPage fixes

* Fixes preview links

* More preview link fixes

* Fixes client links

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Omar López 2021-09-22 12:36:13 -06:00 committed by GitHub
parent 43563bc8d5
commit 3add84a279
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 2214 additions and 636 deletions

View file

@ -3,6 +3,7 @@ DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
GOOGLE_API_CREDENTIALS='secret'
BASE_URL='http://localhost:3000'
NEXT_PUBLIC_APP_URL='http://localhost:3000'
# @see: https://github.com/calendso/calendso/issues/263
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
@ -37,6 +38,14 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
# ApiKey for cronjobs
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
# Stripe Config
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
STRIPE_PRIVATE_KEY= # sk_test_...
STRIPE_CLIENT_ID= # ca_...
STRIPE_WEBHOOK_SECRET= # whsec_...
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
# Application Key for symmetric encryption and decryption
# must be 32 bytes for AES256 encryption algorithm
CALENDSO_ENCRYPTION_KEY=

View file

@ -450,7 +450,7 @@ paths:
properties: {}
'500':
description: Internal Server Error
'/api/book/{user}':
'/api/book/event':
post:
description: Creates a booking in the user's calendar.
summary: Creates a booking for a user
@ -480,10 +480,17 @@ paths:
guests:
type: array
items: {}
users:
type: array
items: {}
user:
type: string
notes:
type: string
location:
type: string
paymentUid:
type: string
responses:
'204':
description: No Content

View file

@ -8,7 +8,6 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/t
import { SelectorIcon } from "@heroicons/react/outline";
import {
CalendarIcon,
ChatAltIcon,
ClockIcon,
CogIcon,
ExternalLinkIcon,
@ -268,7 +267,11 @@ function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean })
"w-64 z-10 absolute mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none"
)}>
<div className="py-1">
<a href={"/" + user?.username} className="flex px-4 py-2 text-sm text-neutral-500">
<a
target="_blank"
rel="noopener noreferrer"
href={`${process.env.NEXT_PUBLIC_APP_URL}/${user?.username || ""}`}
className="flex px-4 py-2 text-sm text-neutral-500">
View public page <ExternalLinkIcon className="ml-1 mt-1 w-3 h-3 text-neutral-400" />
</a>
</div>
@ -309,25 +312,6 @@ function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean })
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href="mailto:feedback@cal.com"
className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-neutral-700",
"flex px-4 py-2 text-sm font-medium"
)}>
<ChatAltIcon
className={classNames(
"text-neutral-400 group-hover:text-neutral-500",
"mr-2 flex-shrink-0 h-5 w-5"
)}
aria-hidden="true"
/>
Feedback
</a>
)}
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item>

View file

@ -1,96 +0,0 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
import getSlots from "../../lib/slots";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
dayjs.extend(isBetween);
dayjs.extend(utc);
type Props = {
eventLength: number;
minimumBookingNotice?: number;
date: Dayjs;
workingHours: [];
organizerTimeZone: string;
};
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerTimeZone }: Props) => {
minimumBookingNotice = minimumBookingNotice || 0;
const router = useRouter();
const { user } = router.query;
const [slots, setSlots] = useState([]);
const [isFullyBooked, setIsFullyBooked] = useState(false);
const [hasErrors, setHasErrors] = useState(false);
useEffect(() => {
setSlots([]);
setIsFullyBooked(false);
setHasErrors(false);
fetch(
`/api/availability/${user}?dateFrom=${date.startOf("day").format()}&dateTo=${date
.endOf("day")
.format()}`
)
.then((res) => res.json())
.then(handleAvailableSlots)
.catch((e) => {
console.error(e);
setHasErrors(true);
});
}, [date]);
const handleAvailableSlots = (busyTimes: []) => {
const times = getSlots({
frequency: eventLength,
inviteeDate: date,
workingHours,
minimumBookingNotice,
organizerTimeZone,
});
const timesLengthBeforeConflicts: number = times.length;
// Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) {
busyTimes.every((busyTime): boolean => {
const startTime = dayjs(busyTime.start).utc();
const endTime = dayjs(busyTime.end).utc();
// Check if start times are the same
if (times[i].utc().isSame(startTime)) {
times.splice(i, 1);
}
// Check if time is between start and end times
else if (times[i].utc().isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if slot end time is between start and end time
else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if startTime is between slot
else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) {
times.splice(i, 1);
} else {
return true;
}
return false;
});
}
if (times.length === 0 && timesLengthBeforeConflicts !== 0) {
setIsFullyBooked(true);
}
// Display available times
setSlots(times);
};
return {
slots,
isFullyBooked,
hasErrors,
};
};
export default Slots;

View file

@ -6,7 +6,7 @@ import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import utc from "dayjs/plugin/utc";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, CreditCardIcon, GlobeIcon } from "@heroicons/react/solid";
import DatePicker from "@components/booking/DatePicker";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import PoweredByCalendso from "@components/ui/PoweredByCalendso";
@ -18,6 +18,7 @@ import { HeadSeo } from "@components/seo/head-seo";
import { asStringOrNull } from "@lib/asStringOrNull";
import useTheme from "@lib/hooks/useTheme";
import AvatarGroup from "@components/ui/AvatarGroup";
import { FormattedNumber, IntlProvider } from "react-intl";
dayjs.extend(utc);
dayjs.extend(customParseFormat);
@ -127,6 +128,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{eventType.length} minutes
</div>
{eventType.price > 0 && (
<div>
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</div>
)}
</div>
</div>
</div>
@ -159,6 +172,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{eventType.length} minutes
</p>
{eventType.price > 0 && (
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</p>
)}
<TimezoneDropdown />

View file

@ -1,9 +1,15 @@
import Head from "next/head";
import { useRouter } from "next/router";
import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid";
import {
CalendarIcon,
ClockIcon,
CreditCardIcon,
ExclamationIcon,
LocationMarkerIcon,
} from "@heroicons/react/solid";
import { EventTypeCustomInputType } from "@prisma/client";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import dayjs from "dayjs";
import "react-phone-number-input/style.css";
import PhoneInput from "react-phone-number-input";
@ -15,8 +21,17 @@ import { timeZone } from "@lib/clock";
import useTheme from "@lib/hooks/useTheme";
import AvatarGroup from "@components/ui/AvatarGroup";
import { parseZone } from "@lib/parseZone";
import { createPaymentLink } from "@ee/lib/stripe/client";
import { FormattedNumber, IntlProvider } from "react-intl";
import { BookPageProps } from "../../../pages/[user]/book";
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
import { stringify } from "querystring";
import createBooking from "@lib/mutations/bookings/create-booking";
import { BookingCreateBody } from "@lib/types/booking";
const BookingPage = (props: any): JSX.Element => {
type BookingPageProps = BookPageProps | TeamBookingPageProps;
const BookingPage = (props: BookingPageProps) => {
const router = useRouter();
const { rescheduleUid } = router.query;
const themeLoaded = useTheme(props.profile.theme);
@ -54,7 +69,7 @@ const BookingPage = (props: any): JSX.Element => {
[LocationType.Zoom]: "Zoom Video",
};
const bookingHandler = (event) => {
const _bookingHandler = (event) => {
const book = async () => {
setLoading(true);
setError(false);
@ -79,7 +94,7 @@ const BookingPage = (props: any): JSX.Element => {
notes += event.target.notes.value;
}
const payload = {
const payload: BookingCreateBody = {
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
name: event.target.name.value,
@ -87,13 +102,10 @@ const BookingPage = (props: any): JSX.Element => {
notes: notes,
guests: guestEmails,
eventTypeId: props.eventType.id,
rescheduleUid: rescheduleUid,
timeZone: timeZone(),
};
if (router.query.user) {
payload.user = router.query.user;
}
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
if (typeof router.query.user === "string") payload.user = router.query.user;
if (selectedLocation) {
switch (selectedLocation) {
@ -115,33 +127,49 @@ const BookingPage = (props: any): JSX.Element => {
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
);
/*const res = await */ fetch("/api/book/event", {
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
method: "POST",
const content = await createBooking(payload).catch((e) => {
console.error(e.message);
setLoading(false);
setError(true);
});
// TODO When the endpoint is fixed, change this to await the result again
//if (res.ok) {
let successUrl = `/success?date=${encodeURIComponent(date)}&type=${props.eventType.id}&user=${
props.profile.slug
}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
if (content?.id) {
const params: { [k: string]: any } = {
date,
type: props.eventType.id,
user: props.profile.slug,
reschedule: !!rescheduleUid,
name: payload.name,
};
if (payload["location"]) {
if (payload["location"].includes("integration")) {
successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
params.location = "Web conferencing details to follow.";
} else {
successUrl += "&location=" + encodeURIComponent(payload["location"]);
params.location = payload["location"];
}
}
const query = stringify(params);
let successUrl = `/success?${query}`;
if (content?.paymentUid) {
successUrl = createPaymentLink(content?.paymentUid, payload.name, date, false);
}
await router.push(successUrl);
} else {
setLoading(false);
setError(true);
}
};
event.preventDefault();
book();
};
const bookingHandler = useCallback(_bookingHandler, []);
return (
themeLoaded && (
<div>
@ -176,6 +204,18 @@ const BookingPage = (props: any): JSX.Element => {
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
{props.eventType.price > 0 && (
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
<IntlProvider locale="en">
<FormattedNumber
value={props.eventType.price / 100.0}
style="currency"
currency={props.eventType.currency.toUpperCase()}
/>
</IntlProvider>
</p>
)}
{selectedLocation === LocationType.InPerson && (
<p className="text-gray-500 mb-2">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />

View file

@ -1,7 +1,28 @@
import { EventType, SchedulingType } from "@prisma/client";
import { ClockIcon, InformationCircleIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
import { SchedulingType } from "@prisma/client";
import {
ClockIcon,
CreditCardIcon,
InformationCircleIcon,
UserIcon,
UsersIcon,
} from "@heroicons/react/solid";
import React from "react";
import { Prisma } from "@prisma/client";
import classNames from "@lib/classNames";
import { FormattedNumber, IntlProvider } from "react-intl";
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
select: {
id: true,
length: true,
price: true,
currency: true,
schedulingType: true,
description: true,
},
});
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
export type EventTypeDescriptionProps = {
eventType: EventType;
@ -27,6 +48,18 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
1-on-1
</li>
)}
{eventType.price > 0 && (
<li className="flex whitespace-nowrap">
<CreditCardIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</li>
)}
{eventType.description && (
<li className="flex">
<InformationCircleIcon

View file

@ -14,3 +14,14 @@ Welcome to the Enterprise Edition ("/ee") of Cal.com.
The [/ee](https://github.com/calendso/calendso/tree/main/ee) subfolder is the place for all the **Pro** features from our [hosted](https://cal.com/pricing) plan and [enterprise-grade](https://cal.com/enterprise) features such as SSO, SAML, ADFS, OIDC, SCIM, SIEM, HRIS and much more.
> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/calendso/calendso)). You are not allowed to use this code to host your own version of app.cal.com without obtaining a proper [license](https://cal.com/enterprise) first❗_
## Setting up Stripe
1. Create a stripe account or use an existing one. For testing, you should use all stripe dashboard functions with the Test-Mode toggle in the top right activated.
2. Open [Stripe ApiKeys](https://dashboard.stripe.com/apikeys) save the token starting with `pk_...` to `NEXT_PUBLIC_STRIPE_PUBLIC_KEY` and `sk_...` to `STRIPE_PRIVATE_KEY` in the .env file.
3. Open [Stripe Connect Settings](https://dashboard.stripe.com/settings/connect) and activate OAuth for Standard Accounts
4. Add `<CALENDSO URL>/api/integrations/stripepayment/callback` as redirect URL.
5. Copy your client*id (`ca*...`) to `STRIPE_CLIENT_ID` in the .env file.
6. Open [Stripe Webhooks](https://dashboard.stripe.com/webhooks) and add `<CALENDSO URL>/api/integrations/stripepayment/webhook` as webhook for connected applications.
7. Select all `payment_intent` events for the webhook.
8. Copy the webhook secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` in the .env file.

View file

@ -0,0 +1,122 @@
import React, { useState } from "react";
import { stringify } from "querystring";
import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js";
import Button from "@components/ui/Button";
import { useRouter } from "next/router";
import useDarkMode from "@lib/core/browser/useDarkMode";
import { PaymentData } from "@ee/lib/stripe/server";
const CARD_OPTIONS = {
iconStyle: "solid" as const,
classes: {
base: "block p-2 w-full border-solid border-2 border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus-within:ring-black focus-within:border-black sm:text-sm",
},
style: {
base: {
color: "#000",
iconColor: "#000",
fontFamily: "ui-sans-serif, system-ui",
fontSmoothing: "antialiased",
fontSize: "16px",
"::placeholder": {
color: "#888888",
},
},
},
};
type Props = {
payment: {
data: PaymentData;
};
eventType: { id: number };
user: { username: string | null };
};
type States =
| { status: "idle" }
| { status: "processing" }
| { status: "error"; error: Error }
| { status: "ok" };
export default function PaymentComponent(props: Props) {
const router = useRouter();
const { name, date } = router.query;
const [state, setState] = useState<States>({ status: "idle" });
const stripe = useStripe();
const elements = useElements();
const { isDarkMode } = useDarkMode();
if (isDarkMode) {
CARD_OPTIONS.style.base.color = "#fff";
CARD_OPTIONS.style.base.iconColor = "#fff";
CARD_OPTIONS.style.base["::placeholder"].color = "#fff";
}
const handleChange = async (event) => {
// Listen for changes in the CardElement
// and display any errors as the customer types their card details
setState({ status: "idle" });
if (event.emtpy || event.error)
setState({ status: "error", error: new Error(event.error?.message || "Missing card fields") });
};
const handleSubmit = async (ev) => {
ev.preventDefault();
if (!stripe || !elements) return;
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) {
setState({
status: "error",
error: new Error(`Payment failed: ${payload.error.message}`),
});
} else {
const params: { [k: string]: any } = {
date,
type: props.eventType.id,
user: props.user.username,
name,
};
if (payload["location"]) {
if (payload["location"].includes("integration")) {
params.location = "Web conferencing details to follow.";
} else {
params.location = payload["location"];
}
}
const query = stringify(params);
const successUrl = `/success?${query}`;
await router.push(successUrl);
}
};
return (
<form id="payment-form" className="mt-4" onSubmit={handleSubmit}>
<CardElement id="card-element" options={CARD_OPTIONS} onChange={handleChange} />
<div className="flex mt-2 justify-center">
<Button
type="submit"
disabled={["processing", "error"].includes(state.status)}
loading={state.status === "processing"}
id="submit">
<span id="button-text">
{state.status === "processing" ? <div className="spinner" id="spinner" /> : "Pay now"}
</span>
</Button>
</div>
{state.status === "error" && (
<div className="mt-4 text-gray-700 dark:text-gray-300 text-center" role="alert">
{state.error.message}
</div>
)}
</form>
);
}

View file

@ -0,0 +1,126 @@
import PaymentComponent from "@ee/components/stripe/Payment";
import getStripe from "@ee/lib/stripe/client";
import { PaymentPageProps } from "@ee/pages/payment/[uid]";
import { CreditCardIcon } from "@heroicons/react/solid";
import useTheme from "@lib/hooks/useTheme";
import { Elements } from "@stripe/react-stripe-js";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import Head from "next/head";
import React, { FC, useEffect, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
dayjs.extend(utc);
dayjs.extend(toArray);
dayjs.extend(timezone);
const PaymentPage: FC<PaymentPageProps> = (props) => {
const [is24h, setIs24h] = useState(false);
const [date, setDate] = useState(dayjs.utc(props.booking.startTime));
const { isReady } = useTheme(props.profile.theme);
useEffect(() => {
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
}, []);
const eventName = props.booking.title;
return isReady ? (
<div className="bg-neutral-50 dark:bg-neutral-900 h-screen">
<Head>
<title>Payment | {eventName} | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto py-24">
<div className="fixed z-50 inset-0 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div
className="inline-block align-bottom dark:bg-gray-800 bg-white rounded-sm px-8 pt-5 pb-4 text-left overflow-hidden border border-neutral-200 dark:border-neutral-700 transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:py-6"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<CreditCardIcon className="h-8 w-8 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3
className="text-2xl leading-6 font-semibold dark:text-white text-neutral-900"
id="modal-headline">
Payment
</h3>
<div className="mt-3">
<p className="text-sm text-neutral-600 dark:text-gray-300">
You have also received an email with this link, if you want to pay later.
</p>
</div>
<div className="mt-4 text-gray-700 dark:text-gray-300 border-t border-b dark:border-gray-900 py-4 grid grid-cols-3 text-left">
<div className="font-medium">What</div>
<div className="mb-6 col-span-2">{eventName}</div>
<div className="font-medium">When</div>
<div className="mb-6 col-span-2">
{date.format("dddd, DD MMMM YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
<span className="text-gray-500">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div>
{props.booking.location && (
<>
<div className="font-medium">Where</div>
<div className="col-span-2">{location}</div>
</>
)}
<div className="font-medium">Price</div>
<div className="mb-6 col-span-2">
<IntlProvider locale="en">
<FormattedNumber
value={props.eventType.price / 100.0}
style="currency"
currency={props.eventType.currency.toUpperCase()}
/>
</IntlProvider>
</div>
</div>
</div>
</div>
<div>
{props.payment.success && !props.payment.refunded && (
<div className="mt-4 text-gray-700 dark:text-gray-300 text-center">Paid</div>
)}
{!props.payment.success && (
<Elements stripe={getStripe(props.payment.data.stripe_publishable_key)}>
<PaymentComponent
payment={props.payment}
eventType={props.eventType}
user={props.user}
/>
</Elements>
)}
{props.payment.refunded && (
<div className="mt-4 text-gray-700 dark:text-gray-300 text-center">Refunded</div>
)}
</div>
{!props.profile.hideBranding && (
<div className="mt-4 pt-4 border-t dark:border-gray-900 text-gray-400 text-center text-xs dark:text-white">
<a href="https://cal.com/signup">Create your own booking link with Cal.com</a>
</div>
)}
</div>
</div>
</div>
</div>
</main>
</div>
) : null;
};
export default PaymentPage;

28
ee/lib/stripe/client.ts Normal file
View file

@ -0,0 +1,28 @@
import { loadStripe, Stripe } from "@stripe/stripe-js";
import { stringify } from "querystring";
const stripePublicKey = process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY!;
let stripePromise: Promise<Stripe | null>;
/**
* This is a singleton to ensure we only instantiate Stripe once.
*/
const getStripe = (userPublicKey?: string) => {
if (!stripePromise) {
stripePromise = loadStripe(
userPublicKey || stripePublicKey /* , {
locale: "es-419" TODO: Handle multiple locales,
} */
);
}
return stripePromise;
};
export function createPaymentLink(paymentUid: string, name?: string, date?: string, absolute = true): string {
let link = "";
if (absolute) link = process.env.NEXT_PUBLIC_APP_URL!;
const query = stringify({ date, name });
return link + `/payment/${paymentUid}?${query}`;
}
export default getStripe;

174
ee/lib/stripe/server.ts Normal file
View file

@ -0,0 +1,174 @@
import { CalendarEvent, Person } from "@lib/calendarClient";
import EventOrganizerRefundFailedMail from "@lib/emails/EventOrganizerRefundFailedMail";
import EventPaymentMail from "@lib/emails/EventPaymentMail";
import prisma from "@lib/prisma";
import { PaymentType } from "@prisma/client";
import Stripe from "stripe";
import { JsonValue } from "type-fest";
import { v4 as uuidv4 } from "uuid";
import { createPaymentLink } from "./client";
export type PaymentData = Stripe.Response<Stripe.PaymentIntent> & {
stripe_publishable_key: string;
stripeAccount: string;
};
export type StripeData = Stripe.OAuthToken & {
default_currency: string;
};
const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY!;
const paymentFeePercentage = process.env.PAYMENT_FEE_PERCENTAGE!;
const paymentFeeFixed = process.env.PAYMENT_FEE_FIXED!;
const stripe = new Stripe(stripePrivateKey, {
apiVersion: "2020-08-27",
});
export async function handlePayment(
evt: CalendarEvent,
selectedEventType: {
price: number;
currency: string;
},
stripeCredential: { key: JsonValue },
booking: {
user: { email: string; name: string; timeZone: string };
id: number;
title: string;
description: string;
startTime: { toISOString: () => string };
endTime: { toISOString: () => string };
attendees: Person[];
location?: string;
uid: string;
}
) {
const paymentFee = Math.round(
selectedEventType.price * parseFloat(paymentFeePercentage || "0") + parseInt(paymentFeeFixed || "0")
);
const { stripe_user_id, stripe_publishable_key } = stripeCredential.key as Stripe.OAuthToken;
const params: Stripe.PaymentIntentCreateParams = {
amount: selectedEventType.price,
currency: selectedEventType.currency,
payment_method_types: ["card"],
application_fee_amount: paymentFee,
};
const paymentIntent = await stripe.paymentIntents.create(params, { stripeAccount: stripe_user_id });
const payment = await prisma.payment.create({
data: {
type: PaymentType.STRIPE,
uid: uuidv4(),
bookingId: booking.id,
amount: selectedEventType.price,
fee: paymentFee,
currency: selectedEventType.currency,
success: false,
refunded: false,
data: Object.assign({}, paymentIntent, {
stripe_publishable_key,
stripeAccount: stripe_user_id,
}) as PaymentData as unknown as JsonValue,
externalId: paymentIntent.id,
},
});
const mail = new EventPaymentMail(
createPaymentLink(payment.uid, booking.user.name, booking.startTime.toISOString()),
evt,
booking.uid
);
await mail.sendEmail();
return payment;
}
export async function refund(
booking: {
id: number;
uid: string;
startTime: Date;
payment: {
id: number;
success: boolean;
refunded: boolean;
externalId: string;
data: JsonValue;
type: PaymentType;
}[];
},
calEvent: CalendarEvent
) {
try {
const payment = booking.payment.find((e) => e.success && !e.refunded);
if (!payment) return;
if (payment.type != PaymentType.STRIPE) {
await handleRefundError({
event: calEvent,
booking: booking,
reason: "cannot refund non Stripe payment",
paymentId: "unknown",
});
return;
}
const refund = await stripe.refunds.create(
{
payment_intent: payment.externalId,
},
{ stripeAccount: (payment.data as unknown as PaymentData)["stripeAccount"] }
);
if (!refund || refund.status === "failed") {
await handleRefundError({
event: calEvent,
booking: booking,
reason: refund?.failure_reason || "unknown",
paymentId: payment.externalId,
});
return;
}
await prisma.payment.update({
where: {
id: payment.id,
},
data: {
refunded: true,
},
});
} catch (e) {
console.error(e, "Refund failed");
await handleRefundError({
event: calEvent,
booking: booking,
reason: e.message || "unknown",
paymentId: "unknown",
});
}
}
async function handleRefundError(opts: {
event: CalendarEvent;
booking: { id: number; uid: string };
reason: string;
paymentId: string;
}) {
console.error(`refund failed: ${opts.reason} for booking '${opts.booking.id}'`);
try {
await new EventOrganizerRefundFailedMail(
opts.event,
opts.booking.uid,
opts.reason,
opts.paymentId
).sendEmail();
} catch (e) {
console.error("Error while sending refund error email", e);
}
}
export default stripe;

View file

@ -0,0 +1,48 @@
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
const client_id = process.env.STRIPE_CLIENT_ID;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
// Check that user is authenticated
const session = await getSession({ req: req });
if (!session) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
// Get user
const user = await prisma.user.findUnique({
where: {
id: session.user?.id,
},
select: {
email: true,
name: true,
},
});
const redirect_uri = encodeURI(process.env.BASE_URL + "/api/integrations/stripepayment/callback");
const stripeConnectParams = {
client_id,
scope: "read_write",
response_type: "code",
"stripe_user[email]": user?.email,
"stripe_user[first_name]": user?.name,
redirect_uri,
};
const query = stringify(stripeConnectParams);
/**
* Choose Express or Stantard Stripe accounts
* @url https://stripe.com/docs/connect/accounts
*/
// const url = `https://connect.stripe.com/express/oauth/authorize?${query}`;
const url = `https://connect.stripe.com/oauth/authorize?${query}`;
res.status(200).json({ url });
}
}

View file

@ -0,0 +1,37 @@
import { Prisma } from "@prisma/client";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import stripe, { StripeData } from "@ee/lib/stripe/server";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
// Check that user is authenticated
const session = await getSession({ req: req });
if (!session?.user) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
const response = await stripe.oauth.token({
grant_type: "authorization_code",
code: code.toString(),
});
const data: StripeData = { ...response, default_currency: "" };
if (response["stripe_user_id"]) {
const account = await stripe.accounts.retrieve(response["stripe_user_id"]);
data["default_currency"] = account.default_currency;
}
await prisma.credential.create({
data: {
type: "stripe_payment",
key: data as unknown as Prisma.InputJsonObject,
userId: session.user.id,
},
});
res.redirect("/integrations");
}

View file

@ -0,0 +1,135 @@
import { CalendarEvent } from "@lib/calendarClient";
import EventManager from "@lib/events/EventManager";
import prisma from "@lib/prisma";
import stripe from "@ee/lib/stripe/server";
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { getErrorFromUnknown } from "pages/_error";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export const config = {
api: {
bodyParser: false,
},
};
async function handlePaymentSuccess(event: Stripe.Event) {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const payment = await prisma.payment.update({
where: {
externalId: paymentIntent.id,
},
data: {
success: true,
booking: {
update: {
paid: true,
},
},
},
select: {
bookingId: true,
booking: {
select: {
title: true,
description: true,
startTime: true,
endTime: true,
confirmed: true,
attendees: true,
location: true,
userId: true,
id: true,
uid: true,
paid: true,
user: {
select: {
id: true,
credentials: true,
timeZone: true,
email: true,
name: true,
},
},
},
},
},
});
if (!payment) throw new Error("No payment found");
const { booking } = payment;
if (!booking) throw new Error("No booking found");
const { user } = booking;
if (!user) throw new Error("No user found");
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: { email: user.email!, name: user.name!, timeZone: user.timeZone },
attendees: booking.attendees,
};
if (booking.location) evt.location = booking.location;
if (booking.confirmed) {
const eventManager = new EventManager(user.credentials);
const scheduleResult = await eventManager.create(evt, booking.uid);
await prisma.booking.update({
where: {
id: payment.bookingId,
},
data: {
references: {
create: scheduleResult.referencesToCreate,
},
},
});
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const requestBuffer = await buffer(req);
const sig = req.headers["stripe-signature"];
let event;
if (!sig) {
res.status(400).send(`Webhook Error: missing Stripe signature`);
return;
}
if (!webhookSecret) {
res.status(400).send(`Webhook Error: missing Stripe webhookSecret`);
return;
}
try {
event = stripe.webhooks.constructEvent(requestBuffer.toString(), sig, webhookSecret);
// Handle the event
if (event.type === "payment_intent.succeeded") {
await handlePaymentSuccess(event);
} else {
console.error(`Unhandled event type ${event.type}`);
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: process.env.NODE_ENV === "production" ? undefined : err.stack,
});
return;
}
// Return a response to acknowledge receipt of the event
res.json({ received: true });
}

106
ee/pages/payment/[uid].tsx Normal file
View file

@ -0,0 +1,106 @@
import { PaymentData } from "@ee/lib/stripe/server";
import { asStringOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { GetServerSidePropsContext } from "next";
export type PaymentPageProps = inferSSRProps<typeof getServerSideProps>;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const rawPayment = await prisma.payment.findFirst({
where: {
uid: asStringOrThrow(context.query.uid),
},
select: {
data: true,
success: true,
uid: true,
refunded: true,
bookingId: true,
booking: {
select: {
description: true,
title: true,
startTime: true,
attendees: {
select: {
email: true,
name: true,
},
},
eventTypeId: true,
location: true,
eventType: {
select: {
id: true,
title: true,
description: true,
length: true,
eventName: true,
requiresConfirmation: true,
userId: true,
users: {
select: {
name: true,
username: true,
hideBranding: true,
plan: true,
theme: true,
},
},
team: {
select: {
name: true,
hideBranding: true,
},
},
price: true,
currency: true,
},
},
},
},
},
});
if (!rawPayment) {
return {
notFound: true,
};
}
const { data, booking: _booking, ...restPayment } = rawPayment;
const payment = {
...restPayment,
data: data as unknown as PaymentData,
};
if (!_booking) return { notFound: true };
const { startTime, eventType, ...restBooking } = _booking;
const booking = {
...restBooking,
startTime: startTime.toString(),
};
if (!eventType) return { notFound: true };
const [user] = eventType.users;
if (!user) return { notFound: true };
const profile = {
name: eventType.team?.name || user?.name || null,
theme: (!eventType.team?.name && user?.theme) || null,
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
};
return {
props: {
user,
eventType,
booking,
payment,
profile,
},
};
};

View file

@ -1,7 +1,7 @@
import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import prisma from "./prisma";
import { Credential } from "@prisma/client";
import { Prisma, Credential } from "@prisma/client";
import CalEventParser from "./CalEventParser";
import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger";
@ -107,11 +107,10 @@ const o365Auth = (credential) => {
};
};
interface Person {
name?: string;
email: string;
timeZone: string;
}
const userData = Prisma.validator<Prisma.UserArgs>()({
select: { name: true, email: true, timeZone: true },
});
export type Person = Prisma.UserGetPayload<typeof userData>;
export interface CalendarEvent {
type: string;
@ -140,6 +139,7 @@ export interface IntegrationCalendar {
name: string;
}
type BufferedBusyTime = { start: string; end: string };
export interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<unknown>;
@ -147,7 +147,11 @@ export interface CalendarApiAdapter {
deleteEvent(uid: string);
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<BufferedBusyTime[]>;
listCalendars(): Promise<IntegrationCalendar[]>;
}

View file

@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)";
interface UseDarkModeOutput {
isDarkMode: boolean;
toggle: () => void;
enable: () => void;
disable: () => void;
}
function useDarkMode(defaultValue?: boolean): UseDarkModeOutput {
const getPrefersScheme = (): boolean => {
// Prevents SSR issues
if (typeof window !== "undefined") {
return window.matchMedia(COLOR_SCHEME_QUERY).matches;
}
return !!defaultValue;
};
const [isDarkMode, setDarkMode] = useState<boolean>(getPrefersScheme());
// Update darkMode if os prefers changes
useEffect(() => {
const handler = () => setDarkMode(getPrefersScheme);
const matchMedia = window.matchMedia(COLOR_SCHEME_QUERY);
matchMedia.addEventListener("change", handler);
return () => {
matchMedia.removeEventListener("change", handler);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
isDarkMode,
toggle: () => setDarkMode((prev) => !prev),
enable: () => setDarkMode(true),
disable: () => setDarkMode(false),
};
}
export default useDarkMode;

View file

@ -0,0 +1,65 @@
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import localizedFormat from "dayjs/plugin/localizedFormat";
import EventOrganizerMail from "@lib/emails/EventOrganizerMail";
import { CalendarEvent } from "@lib/calendarClient";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerRefundFailedMail extends EventOrganizerMail {
reason: string;
paymentId: string;
constructor(calEvent: CalendarEvent, uid: string, reason: string, paymentId: string) {
super(calEvent, uid, undefined);
this.reason = reason;
this.paymentId = paymentId;
}
protected getBodyHeader(): string {
return "A refund failed";
}
protected getBodyText(): string {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return `The refund for the event ${this.calEvent.type} with ${
this.calEvent.attendees[0].name
} on ${organizerStart.format("LT dddd, LL")} failed. Please check with your payment provider and ${
this.calEvent.attendees[0].name
} how to handle this.<br>The error message was: '${this.reason}'<br>PaymentId: '${this.paymentId}'`;
}
protected getAdditionalBody(): string {
return "";
}
protected getImage(): string {
return `<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #9b0125"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>`;
}
protected getSubject(): string {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return `Refund failed: ${this.calEvent.attendees[0].name} - ${organizerStart.format("LT dddd, LL")} - ${
this.calEvent.type
}`;
}
}

View file

@ -0,0 +1,165 @@
import dayjs, { Dayjs } from "dayjs";
import EventMail, { AdditionInformation } from "./EventMail";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { CalendarEvent } from "@lib/calendarClient";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
export default class EventPaymentMail extends EventMail {
paymentLink: string;
constructor(
paymentLink: string,
calEvent: CalendarEvent,
uid: string,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid, additionInformation);
this.paymentLink = paymentLink;
}
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #31c48d"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 style="font-weight: 500; color: #161e2e;">Your meeting is awaiting payment</h1>
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p>
<hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup>
<col span="1" style="width: 40%;">
<col span="1" style="width: 60%;">
</colgroup>
<tr>
<td>What</td>
<td>${this.calEvent.type}</td>
</tr>
<tr>
<td>When</td>
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
this.calEvent.attendees[0].timeZone
})</td>
</tr>
<tr>
<td>Who</td>
<td>${this.calEvent.organizer.name}<br /><small>${this.calEvent.organizer.email}</small></td>
</tr>
<tr>
<td>Where</td>
<td>${this.getLocation()}</td>
</tr>
<tr>
<td>Notes</td>
<td>${this.calEvent.description}</td>
</tr>
</table>
` +
this.getAdditionalBody() +
"<br />" +
`
<hr />
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
</body>
`
);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.additionInformation?.hangoutLink) {
return `<a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
}
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
const locations = this.additionInformation?.entryPoints
.map((entryPoint) => {
return `
Join by ${entryPoint.entryPointType}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `${locations}`;
}
return this.calEvent.location ? `${this.calEvent.location}<br /><br />` : "";
}
protected getAdditionalBody(): string {
return `<a href="${this.paymentLink}">Pay now</a>`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `Awaiting Payment: ${this.calEvent.type} with ${
this.calEvent.organizer.name
} on ${this.getInviteeStart().format("dddd, LL")}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: string): void {
console.error("SEND_BOOKING_PAYMENT_ERROR", this.calEvent.attendees[0].email, error);
}
/**
* Returns the inviteeStart value used at multiple points.
*
* @private
*/
protected getInviteeStart(): Dayjs {
return <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}

View file

@ -4,9 +4,20 @@ import { User, SchedulingType } from "@prisma/client";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
import { FreeBusyTime } from "@components/ui/Schedule/Schedule";
dayjs.extend(isBetween);
dayjs.extend(utc);
type AvailabilityUserResponse = {
busy: FreeBusyTime;
workingHours: {
daysOfWeek: number[];
timeZone: string;
startTime: number;
endTime: number;
};
};
type Slot = {
time: Dayjs;
users?: string[];
@ -85,14 +96,18 @@ export const useSlots = (props: UseSlotsProps) => {
}, [date]);
const handleAvailableSlots = async (res) => {
const responseBody = await res.json();
const responseBody: AvailabilityUserResponse = await res.json();
responseBody.workingHours.days = responseBody.workingHours.daysOfWeek;
const workingHours = {
days: responseBody.workingHours.daysOfWeek,
startTime: responseBody.workingHours.startTime,
endTime: responseBody.workingHours.endTime,
};
const times = getSlots({
frequency: eventLength,
inviteeDate: date,
workingHours: [responseBody.workingHours],
workingHours: [workingHours],
minimumBookingNotice,
organizerTimeZone: responseBody.workingHours.timeZone,
});

View file

@ -8,6 +8,8 @@ export function getIntegrationName(name: string) {
return "Zoom";
case "caldav_calendar":
return "CalDav Server";
case "stripe_payment":
return "Stripe";
case "apple_calendar":
return "Apple Calendar";
default:
@ -15,9 +17,12 @@ export function getIntegrationName(name: string) {
}
}
export function getIntegrationType(name: string) {
export function getIntegrationType(name: string): string {
if (name.endsWith("_calendar")) {
return "Calendar";
}
if (name.endsWith("_payment")) {
return "Payment";
}
return "Unknown";
}

View file

@ -0,0 +1,73 @@
import { validJson } from "@lib/jsonUtils";
import { Prisma } from "@prisma/client";
const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
select: { id: true, type: true, key: true },
});
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
function getIntegrations(credentials: CredentialData[]) {
const integrations = [
{
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
credential: credentials.find((integration) => integration.type === "google_calendar") || null,
type: "google_calendar",
title: "Google Calendar",
imageSrc: "integrations/google-calendar.svg",
description: "For personal and business calendars",
},
{
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
type: "office365_calendar",
credential: credentials.find((integration) => integration.type === "office365_calendar") || null,
title: "Office 365 / Outlook.com Calendar",
imageSrc: "integrations/outlook.svg",
description: "For personal and business calendars",
},
{
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
type: "zoom_video",
credential: credentials.find((integration) => integration.type === "zoom_video") || null,
title: "Zoom",
imageSrc: "integrations/zoom.svg",
description: "Video Conferencing",
},
{
installed: true,
type: "caldav_calendar",
credential: credentials.find((integration) => integration.type === "caldav_calendar") || null,
title: "CalDav Server",
imageSrc: "integrations/caldav.svg",
description: "For personal and business calendars",
},
{
installed: true,
type: "apple_calendar",
credential: credentials.find((integration) => integration.type === "apple_calendar") || null,
title: "Apple Calendar",
imageSrc: "integrations/apple-calendar.svg",
description: "For personal and business calendars",
},
{
installed: !!(
process.env.STRIPE_CLIENT_ID &&
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_PRIVATE_KEY
),
type: "stripe_payment",
credential: credentials.find((integration) => integration.type === "stripe_payment") || null,
title: "Stripe",
imageSrc: "integrations/stripe.svg",
description: "Receive payments",
},
];
return integrations;
}
export function hasIntegration(integrations: ReturnType<typeof getIntegrations>, type: string): boolean {
return !!integrations.find((i) => i.type === type && !!i.installed && !!i.credential);
}
export default getIntegrations;

View file

@ -0,0 +1,10 @@
import * as fetch from "@lib/core/http/fetch-wrapper";
import { BookingCreateBody, BookingResponse } from "@lib/types/booking";
const createBooking = async (data: BookingCreateBody) => {
const response = await fetch.post<BookingCreateBody, BookingResponse>("/api/book/event", data);
return response;
};
export default createBooking;

21
lib/types/booking.ts Normal file
View file

@ -0,0 +1,21 @@
import { LocationType } from "@lib/location";
import { Booking } from "@prisma/client";
export type BookingCreateBody = {
email: string;
end: string;
eventTypeId: number;
guests: string[];
location?: LocationType;
name: string;
notes: string;
rescheduleUid?: string;
start: string;
timeZone: string;
users?: string[];
user?: string;
};
export type BookingResponse = Booking & {
paymentUid?: string;
};

View file

@ -17,6 +17,14 @@ const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
const translator = short();
export interface ZoomToken {
scope: "meeting:write";
expires_in: number;
token_type: "bearer";
access_token: string;
refresh_token: string;
}
export interface VideoCallData {
type: string;
id: string;
@ -40,13 +48,14 @@ function handleErrorsRaw(response) {
return response.text();
}
const zoomAuth = (credential) => {
const isExpired = (expiryDate) => expiryDate < +new Date();
const zoomAuth = (credential: Credential) => {
const credentialKey = credential.key as unknown as ZoomToken;
const isExpired = (expiryDate: number) => expiryDate < +new Date();
const authHeader =
"Basic " +
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
const refreshAccessToken = (refreshToken) =>
const refreshAccessToken = (refreshToken: string) =>
fetch("https://zoom.us/oauth/token", {
method: "POST",
headers: {
@ -69,30 +78,30 @@ const zoomAuth = (credential) => {
key: responseBody,
},
});
credential.key.access_token = responseBody.access_token;
credential.key.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
return credential.key.access_token;
credentialKey.access_token = responseBody.access_token;
credentialKey.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
return credentialKey.access_token;
});
return {
getToken: () =>
!isExpired(credential.key.expires_in)
? Promise.resolve(credential.key.access_token)
: refreshAccessToken(credential.key.refresh_token),
!isExpired(credentialKey.expires_in)
? Promise.resolve(credentialKey.access_token)
: refreshAccessToken(credentialKey.refresh_token),
};
};
interface VideoApiAdapter {
createMeeting(event: CalendarEvent): Promise<any>;
updateMeeting(uid: string, event: CalendarEvent);
updateMeeting(uid: string, event: CalendarEvent): Promise<any>;
deleteMeeting(uid: string): Promise<unknown>;
getAvailability(dateFrom, dateTo): Promise<any>;
getAvailability(dateFrom: string, dateTo: string): Promise<any>;
}
const ZoomVideo = (credential): VideoApiAdapter => {
const ZoomVideo = (credential: Credential): VideoApiAdapter => {
const auth = zoomAuth(credential);
const translateEvent = (event: CalendarEvent) => {
@ -148,7 +157,9 @@ const ZoomVideo = (credential): VideoApiAdapter => {
})
)
.catch((err) => {
console.log(err);
console.error(err);
/* Prevents booking failure when Zoom Token is expired */
return [];
});
},
createMeeting: (event: CalendarEvent) =>
@ -186,19 +197,19 @@ const ZoomVideo = (credential): VideoApiAdapter => {
};
// factory
const videoIntegrations = (withCredentials): VideoApiAdapter[] =>
withCredentials
.map((cred) => {
const videoIntegrations = (withCredentials: Credential[]): VideoApiAdapter[] =>
withCredentials.reduce<VideoApiAdapter[]>((acc, cred) => {
switch (cred.type) {
case "zoom_video":
return ZoomVideo(cred);
acc.push(ZoomVideo(cred));
break;
default:
return; // unknown credential, could be legacy? In any case, ignore
break;
}
})
.filter(Boolean);
return acc;
}, []);
const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredentials) =>
const getBusyVideoTimes: (withCredentials: Credential[]) => Promise<unknown[]> = (withCredentials) =>
Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
results.reduce((acc, availability) => acc.concat(availability), [])
);

View file

@ -3,11 +3,14 @@ const withTM = require("next-transpile-modules")(["react-timezone-select"]);
// So we can test deploy previews preview
if (process.env.VERCEL_URL && !process.env.BASE_URL) {
process.env.BASE_URL = process.env.VERCEL_URL;
process.env.BASE_URL = "https://" + process.env.VERCEL_URL;
}
if (process.env.BASE_URL) {
process.env.NEXTAUTH_URL = process.env.BASE_URL + "/api/auth";
}
if (!process.env.NEXT_PUBLIC_APP_URL) {
process.env.NEXT_PUBLIC_APP_URL = process.env.BASE_URL;
}
if (!process.env.EMAIL_FROM) {
console.warn(
@ -67,7 +70,4 @@ module.exports = withTM({
},
];
},
publicRuntimeConfig: {
BASE_URL: process.env.BASE_URL || "http://localhost:3000",
},
});

View file

@ -33,7 +33,10 @@
"@radix-ui/react-slider": "^0.1.0",
"@radix-ui/react-switch": "^0.1.0",
"@radix-ui/react-tooltip": "^0.1.0",
"@stripe/react-stripe-js": "^1.4.1",
"@stripe/stripe-js": "^1.16.0",
"@tailwindcss/forms": "^0.3.3",
"@types/stripe": "^8.0.417",
"async": "^3.2.1",
"bcryptjs": "^2.4.3",
"classnames": "^2.3.1",
@ -46,6 +49,7 @@
"lodash.debounce": "^4.0.8",
"lodash.merge": "^4.6.2",
"lodash.throttle": "^4.1.1",
"micro": "^9.3.4",
"next": "^11.1.1",
"next-auth": "^3.28.0",
"next-seo": "^4.26.0",
@ -59,12 +63,14 @@
"react-dom": "17.0.2",
"react-easy-crop": "^3.5.2",
"react-hot-toast": "^2.1.0",
"react-intl": "^5.20.7",
"react-multi-email": "^0.5.3",
"react-phone-number-input": "^3.1.25",
"react-query": "^3.21.0",
"react-select": "^4.3.1",
"react-timezone-select": "^1.0.7",
"short-uuid": "^4.2.0",
"stripe": "^8.168.0",
"tsdav": "1.0.6",
"tslog": "^3.2.1",
"uuid": "^8.3.2"
@ -79,6 +85,7 @@
"@types/react": "^17.0.18",
"@types/react-dates": "^21.8.3",
"@types/react-select": "^4.0.17",
"@types/uuid": "8.3.1",
"@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.29.2",
"autoprefixer": "^10.3.1",

View file

@ -110,6 +110,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
length: true,
description: true,
hidden: true,
schedulingType: true,
price: true,
currency: true,
},
take: user.plan === "FREE" ? 1 : undefined,
});

View file

@ -56,6 +56,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
availability: true,
description: true,
length: true,
price: true,
currency: true,
users: {
select: {
avatar: true,
@ -92,6 +94,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
availability: true,
description: true,
length: true,
price: true,
currency: true,
users: {
select: {
avatar: true,

View file

@ -1,20 +1,25 @@
import prisma from "@lib/prisma";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import BookingPage from "@components/booking/pages/BookingPage";
import { asStringOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { GetServerSidePropsContext } from "next";
dayjs.extend(utc);
dayjs.extend(timezone);
export default function Book(props: any): JSX.Element {
export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Book(props: BookPageProps) {
return <BookingPage {...props} />;
}
export async function getServerSideProps(context) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const user = await prisma.user.findUnique({
where: {
username: context.query.user,
username: asStringOrThrow(context.query.user),
},
select: {
username: true,
@ -26,9 +31,11 @@ export async function getServerSideProps(context) {
},
});
if (!user) return { notFound: true };
const eventType = await prisma.eventType.findUnique({
where: {
id: parseInt(context.query.type),
id: parseInt(asStringOrThrow(context.query.type)),
},
select: {
id: true,
@ -43,6 +50,8 @@ export async function getServerSideProps(context) {
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
price: true,
currency: true,
disableGuests: true,
users: {
select: {
@ -57,6 +66,8 @@ export async function getServerSideProps(context) {
},
});
if (!eventType) return { notFound: true };
const eventTypeObject = [eventType].map((e) => {
return {
...e,
@ -70,7 +81,7 @@ export async function getServerSideProps(context) {
if (context.query.rescheduleUid) {
booking = await prisma.booking.findFirst({
where: {
uid: context.query.rescheduleUid,
uid: asStringOrThrow(context.query.rescheduleUid),
},
select: {
description: true,

View file

@ -1,10 +1,9 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@lib/prisma";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getBusyCalendarTimes } from "@lib/calendarClient";
import prisma from "@lib/prisma";
// import { getBusyVideoTimes } from "@lib/videoClient";
import dayjs from "dayjs";
import { asStringOrNull } from "@lib/asStringOrNull";
import { User } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = asStringOrNull(req.query.user);
@ -15,9 +14,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "Invalid time range given." });
}
const currentUser: User = await prisma.user.findUnique({
const rawUser = await prisma.user.findUnique({
where: {
username: user,
username: user as string,
},
select: {
credentials: true,
@ -27,14 +26,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: true,
startTime: true,
endTime: true,
selectedCalendars: true,
},
});
const selectedCalendars = await prisma.selectedCalendar.findMany({
where: {
userId: currentUser.id,
},
});
if (!rawUser) throw new Error("No user found");
const { selectedCalendars, ...currentUser } = rawUser;
const busyTimes = await getBusyCalendarTimes(
currentUser.credentials,

View file

@ -89,6 +89,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
periodEndDate: req.body.periodEndDate,
periodCountCalendarDays: req.body.periodCountCalendarDays,
minimumBookingNotice: req.body.minimumBookingNotice,
price: req.body.price,
currency: req.body.currency,
};
if (req.body.schedulingType) {

View file

@ -4,6 +4,7 @@ import prisma from "../../../lib/prisma";
import { CalendarEvent } from "@lib/calendarClient";
import EventRejectionMail from "@lib/emails/EventRejectionMail";
import EventManager from "@lib/events/EventManager";
import { refund } from "@ee/lib/stripe/server";
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const session = await getSession({ req: req });
@ -45,6 +46,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
userId: true,
id: true,
uid: true,
payment: true,
},
});
@ -84,6 +86,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(204).json({ message: "ok" });
} else {
await refund(booking, evt);
await prisma.booking.update({
where: {
id: bookingId,

View file

@ -1,14 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@lib/prisma";
import {
EventType,
User,
SchedulingType,
Credential,
SelectedCalendar,
Booking,
Prisma,
} from "@prisma/client";
import { SchedulingType, Prisma } from "@prisma/client";
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
@ -16,13 +8,15 @@ import { getBusyVideoTimes } from "@lib/videoClient";
import { getEventName } from "@lib/event";
import dayjs from "dayjs";
import logger from "@lib/logger";
import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager";
import EventManager, { CreateUpdateResult, EventResult, PartialReference } from "@lib/events/EventManager";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import isBetween from "dayjs/plugin/isBetween";
import dayjsBusinessDays from "dayjs-business-days";
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
import { handlePayment } from "@ee/lib/stripe/server";
import { BookingCreateBody } from "@lib/types/booking";
dayjs.extend(dayjsBusinessDays);
dayjs.extend(utc);
@ -32,7 +26,8 @@ dayjs.extend(timezone);
const translator = short();
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
function isAvailable(busyTimes, time, length) {
type BufferedBusyTimes = { start: string; end: string }[];
function isAvailable(busyTimes: BufferedBusyTimes, time: string, length: number): boolean {
// Check for conflicts
let t = true;
@ -88,15 +83,16 @@ function isOutOfBounds(
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const eventTypeId = parseInt(req.body.eventTypeId as string);
const reqBody = req.body as BookingCreateBody;
const eventTypeId = reqBody.eventTypeId;
log.debug(`Booking eventType ${eventTypeId} started`);
const isTimeInPast = (time) => {
const isTimeInPast = (time: string): boolean => {
return dayjs(time).isBefore(new Date(), "day");
};
if (isTimeInPast(req.body.start)) {
if (isTimeInPast(reqBody.start)) {
const error = {
errorCode: "BookingDateInPast",
message: "Attempting to create a meeting in the past.",
@ -106,19 +102,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error);
}
const eventType: EventType = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
select: {
users: {
select: {
const userSelect = Prisma.validator<Prisma.UserSelect>()({
id: true,
email: true,
name: true,
username: true,
timeZone: true,
credentials: true,
bufferTime: true,
});
const userData = Prisma.validator<Prisma.UserArgs>()({
select: userSelect,
});
const eventType = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
select: {
users: {
select: userSelect,
},
team: {
select: {
@ -137,71 +141,66 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
periodCountCalendarDays: true,
requiresConfirmation: true,
userId: true,
price: true,
currency: true,
},
});
if (!eventType.users.length && eventType.userId) {
eventType.users.push(
await prisma.user.findUnique({
if (!eventType) return res.status(404).json({ message: "eventType.notFound" });
let users = eventType.users;
/* If this event was pre-relationship migration */
if (!users.length && eventType.userId) {
const evenTypeUser = await prisma.user.findUnique({
where: {
id: eventType.userId,
},
select: {
id: true,
email: true,
name: true,
username: true,
timeZone: true,
},
})
);
select: userSelect,
});
if (!evenTypeUser) return res.status(404).json({ message: "eventTypeUser.notFound" });
users.push(evenTypeUser);
}
let users: User[] = eventType.users;
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
const selectedUsers = req.body.users || [];
// one of these things that can probably be done better
// prisma is not well documented.
users = await Promise.all(
selectedUsers.map(async (username) => {
const user = await prisma.user.findUnique({
const selectedUsers = reqBody.users || [];
const selectedUsersDataWithBookingsCount = await prisma.user.findMany({
where: {
username,
},
select: {
username: { in: selectedUsers },
bookings: {
where: {
every: {
startTime: {
gt: new Date(),
},
},
},
},
select: {
id: true,
},
username: true,
_count: {
select: { bookings: true },
},
},
});
return {
username,
bookingCount: user.bookings.length,
};
})
).then((bookingCounts) => {
if (!bookingCounts.length) {
return users.slice(0, 1);
}
const sorted = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
return [users.find((user) => user.username === sorted[0].username)];
});
const bookingCounts = selectedUsersDataWithBookingsCount.map((userData) => ({
username: userData.username,
bookingCount: userData._count?.bookings || 0,
}));
if (!bookingCounts.length) users.slice(0, 1);
const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username);
users = luckyUser ? [luckyUser] : users;
}
const invitee = [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }];
const guests = req.body.guests.map((guest) => {
const invitee = [{ email: reqBody.email, name: reqBody.name, timeZone: reqBody.timeZone }];
const guests = reqBody.guests.map((guest) => {
const g = {
email: guest,
name: "",
timeZone: req.body.timeZone,
timeZone: reqBody.timeZone,
};
return g;
});
@ -217,35 +216,43 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const attendeesList = [...invitee, ...guests, ...teamMembers];
const seed = `${users[0].username}:${dayjs(req.body.start).utc().format()}`;
const seed = `${users[0].username}:${dayjs(reqBody.start).utc().format()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const evt: CalendarEvent = {
type: eventType.title,
title: getEventName(req.body.name, eventType.title, eventType.eventName),
description: req.body.notes,
startTime: req.body.start,
endTime: req.body.end,
title: getEventName(reqBody.name, eventType.title, eventType.eventName),
description: reqBody.notes,
startTime: reqBody.start,
endTime: reqBody.end,
organizer: {
name: users[0].name,
email: users[0].email,
timeZone: users[0].timeZone,
},
attendees: attendeesList,
location: req.body.location, // Will be processed by the EventManager later.
location: reqBody.location, // Will be processed by the EventManager later.
};
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
evt.team = {
members: users.map((user) => user.name || user.username),
name: eventType.team.name,
members: users.map((user) => user.name || user.username || "Nameless"),
name: eventType.team?.name || "Nameless",
}; // used for invitee emails
}
// Initialize EventManager with credentials
const rescheduleUid = req.body.rescheduleUid;
const rescheduleUid = reqBody.rescheduleUid;
const bookingCreateInput: Prisma.BookingCreateInput = {
function createBooking() {
return prisma.booking.create({
include: {
user: {
select: { email: true, name: true, timeZone: true },
},
attendees: true,
},
data: {
uid,
title: evt.title,
startTime: dayjs(evt.startTime).toDate(),
@ -268,72 +275,61 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: users[0].id,
},
},
};
let booking: Booking | null;
try {
booking = await prisma.booking.create({
data: bookingCreateInput,
},
});
}
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
let booking: Booking | null = null;
try {
booking = await createBooking();
} catch (e) {
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", e.message);
if (e.code === "P2002") {
return res.status(409).json({ message: "booking.conflict" });
res.status(409).json({ message: "booking.conflict" });
return;
}
return res.status(500).end();
res.status(500).end();
return;
}
let results: EventResult[] = [];
let referencesToCreate = [];
const loadUser = async (id): Promise<User> =>
await prisma.user.findUnique({
where: {
id,
},
select: {
id: true,
credentials: true,
timeZone: true,
email: true,
username: true,
name: true,
bufferTime: true,
},
});
let user: User;
for (const currentUser of await Promise.all(users.map((user) => loadUser(user.id)))) {
if (!user) {
user = currentUser;
let referencesToCreate: PartialReference[] = [];
type User = Prisma.UserGetPayload<typeof userData>;
let user: User | null = null;
for (const currentUser of users) {
if (!currentUser) {
console.error(`currentUser not found`);
return;
}
if (!user) user = currentUser;
const selectedCalendars: SelectedCalendar[] = await prisma.selectedCalendar.findMany({
const selectedCalendars = await prisma.selectedCalendar.findMany({
where: {
userId: currentUser.id,
},
});
const credentials: Credential[] = currentUser.credentials;
const credentials = currentUser.credentials;
if (credentials) {
const calendarBusyTimes = await getBusyCalendarTimes(
credentials,
req.body.start,
req.body.end,
reqBody.start,
reqBody.end,
selectedCalendars
);
const videoBusyTimes = await getBusyVideoTimes(credentials);
calendarBusyTimes.push(...videoBusyTimes);
const bufferedBusyTimes = calendarBusyTimes.map((a) => ({
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
let isAvailableToBeBooked = true;
try {
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, req.body.start, eventType.length);
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
} catch {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
@ -352,7 +348,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let timeOutOfBounds = false;
try {
timeOutOfBounds = isOutOfBounds(req.body.start, {
timeOutOfBounds = isOutOfBounds(reqBody.start, {
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodEndDate: eventType.periodEndDate,
@ -395,7 +391,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
log.error(`Booking ${user.name} failed`, error, results);
}
} else if (!eventType.requiresConfirmation) {
} else if (!eventType.requiresConfirmation && !eventType.price) {
// Use EventManager to conditionally use all needed integrations.
const createResults: CreateUpdateResult = await eventManager.create(evt, uid);
@ -416,6 +412,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await new EventOrganizerRequestMail(evt, uid).sendEmail();
}
if (typeof eventType.price === "number" && eventType.price > 0) {
try {
const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment");
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore https://github.com/prisma/prisma/issues/9389 */
if (!booking.user) booking.user = user;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore https://github.com/prisma/prisma/issues/9389 */
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
res.status(201).json({ ...booking, message: "Payment required", paymentUid: payment.uid });
return;
} catch (e) {
log.error(`Creating payment failed`, e);
res.status(500).json({ message: "Payment Failed" });
return;
}
}
log.debug(`Booking ${user.username} completed`);
await prisma.booking.update({

View file

@ -1,9 +1,10 @@
import prisma from "@lib/prisma";
import { deleteEvent } from "@lib/calendarClient";
import { CalendarEvent, deleteEvent } from "@lib/calendarClient";
import { deleteMeeting } from "@lib/videoClient";
import async from "async";
import { BookingStatus } from "@prisma/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { refund } from "@ee/lib/stripe/server";
export default async function handler(req, res) {
// just bail if it not a DELETE
@ -22,6 +23,9 @@ export default async function handler(req, res) {
user: {
select: {
credentials: true,
email: true,
timeZone: true,
name: true,
},
},
attendees: true,
@ -31,6 +35,14 @@ export default async function handler(req, res) {
type: true,
},
},
payment: true,
paid: true,
location: true,
title: true,
description: true,
startTime: true,
endTime: true,
uid: true,
},
});
@ -60,6 +72,36 @@ export default async function handler(req, res) {
}
});
if (bookingToDelete && bookingToDelete.paid) {
const evt: CalendarEvent = {
type: bookingToDelete.title,
title: bookingToDelete.title,
description: bookingToDelete.description ?? "",
startTime: bookingToDelete.startTime.toISOString(),
endTime: bookingToDelete.endTime.toISOString(),
organizer: {
email: bookingToDelete.user?.email ?? "dev@calendso.com",
name: bookingToDelete.user?.name ?? "no user",
timeZone: bookingToDelete.user?.timeZone ?? "",
},
attendees: bookingToDelete.attendees,
location: bookingToDelete.location ?? "",
};
await refund(bookingToDelete, evt);
await prisma.booking.update({
where: {
id: bookingToDelete.id,
},
data: {
rejected: true,
},
});
// We skip the deletion of the event, because that would also delete the payment reference, which we should keep
await apiDeletes;
return res.status(200).json({ message: "Booking successfully deleted." });
}
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: bookingToDelete.id,

View file

@ -0,0 +1 @@
export { default } from "@ee/pages/api/integrations/stripepayment/add";

View file

@ -0,0 +1 @@
export { default } from "@ee/pages/api/integrations/stripepayment/callback";

View file

@ -0,0 +1 @@
export { default, config } from "@ee/pages/api/integrations/stripepayment/webhook";

View file

@ -33,7 +33,7 @@ export default function Troubleshoot({ user }) {
return res.json();
})
.then((availableIntervals) => {
setAvailability(availableIntervals);
setAvailability(availableIntervals.busy);
setLoading(false);
})
.catch((e) => {

View file

@ -3,7 +3,7 @@ import Modal from "@components/Modal";
import React, { useEffect, useRef, useState } from "react";
import Select, { OptionTypeBase } from "react-select";
import prisma from "@lib/prisma";
import { Availability, EventTypeCustomInput, EventTypeCustomInputType, SchedulingType } from "@prisma/client";
import { EventTypeCustomInput, EventTypeCustomInputType, SchedulingType } from "@prisma/client";
import { LocationType } from "@lib/location";
import Shell from "@components/Shell";
import { getSession } from "@lib/auth";
@ -28,7 +28,6 @@ import {
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { validJson } from "@lib/jsonUtils";
import throttle from "lodash.throttle";
import "react-dates/initialize";
import "react-dates/lib/css/_datepicker.css";
@ -38,7 +37,7 @@ import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import { GetServerSidePropsContext } from "next";
import { useMutation } from "react-query";
import { EventTypeInput } from "@lib/types/event-type";
import { AdvancedOptions, EventTypeInput } from "@lib/types/event-type";
import updateEventType from "@lib/mutations/event-types/update-event-type";
import deleteEventType from "@lib/mutations/event-types/delete-event-type";
import showToast from "@lib/notification";
@ -47,8 +46,11 @@ import { defaultAvatarSrc } from "@lib/profile";
import * as RadioArea from "@components/ui/form/radio-area";
import classNames from "@lib/classNames";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { FormattedNumber, IntlProvider } from "react-intl";
import { asStringOrThrow } from "@lib/asStringOrNull";
import Button from "@components/ui/Button";
import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations";
import Stripe from "stripe";
import CheckboxField from "@components/ui/form/CheckboxField";
dayjs.extend(utc);
@ -70,7 +72,8 @@ const PERIOD_TYPES = [
];
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const { eventType, locationOptions, availability, team, teamMembers } = props;
const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } =
props;
const router = useRouter();
const [successModalOpen, setSuccessModalOpen] = useState(false);
@ -172,14 +175,17 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
PERIOD_TYPES.find((s) => s.type === "unlimited")
);
});
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const [hidden, setHidden] = useState<boolean>(eventType.hidden);
const titleRef = useRef<HTMLInputElement>(null);
const slugRef = useRef<HTMLInputElement>(null);
const requiresConfirmationRef = useRef<HTMLInputElement>(null);
const eventNameRef = useRef<HTMLInputElement>(null);
const periodDaysRef = useRef<HTMLInputElement>(null);
const periodDaysTypeRef = useRef<HTMLSelectElement>(null);
const priceRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setSelectedTimeZone(eventType.timeZone);
@ -192,6 +198,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const enteredTitle: string = titleRef.current.value;
const enteredSlug: string = slugRef.current.value;
const enteredPrice = requirePayment ? Math.round(parseFloat(priceRef.current.value) * 100) : 0;
const advancedOptionsPayload: AdvancedOptions = {};
if (requiresConfirmationRef.current) {
@ -223,6 +230,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
users,
}
: {}),
price: enteredPrice,
currency: currency,
};
updateMutation.mutate(payload);
@ -861,6 +870,90 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
/>
</div>
</div>
{hasPaymentIntegration && (
<>
<hr className="border-neutral-200" />
<div className="block sm:flex">
<div className="min-w-44 mb-4 sm:mb-0">
<label
htmlFor="payment"
className="text-sm flex font-medium text-neutral-700 mt-2">
Payment
</label>
</div>
<div className="flex flex-col">
<div className="w-full">
<div className="block sm:flex items-center">
<div className="w-full">
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
onChange={(event) => setRequirePayment(event.target.checked)}
id="requirePayment"
name="requirePayment"
type="checkbox"
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
defaultChecked={requirePayment}
/>
</div>
<div className="ml-3 text-sm">
<p className="text-neutral-900">
Require Payment (0.5% +{" "}
<IntlProvider locale="en">
<FormattedNumber
value={0.1}
style="currency"
currency={currency}
/>
</IntlProvider>{" "}
commission per transaction)
</p>
</div>
</div>
</div>
</div>
</div>
{requirePayment && (
<div className="w-full">
<div className="block sm:flex items-center">
<div className="w-full">
<div className="mt-1 relative rounded-sm shadow-sm">
<input
ref={priceRef}
type="number"
name="price"
id="price"
step="0.01"
required
className="focus:ring-primary-500 focus:border-primary-500 block w-full pl-2 pr-12 sm:text-sm border-gray-300 rounded-sm"
placeholder="Price"
defaultValue={
eventType.price > 0 ? eventType.price / 100.0 : undefined
}
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<span className="text-gray-500 sm:text-sm" id="duration">
{new Intl.NumberFormat("en", {
style: "currency",
currency: currency,
maximumSignificantDigits: 1,
maximumFractionDigits: 0,
})
.format(0)
.replace("0", "")}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</>
)}
</Disclosure.Panel>
</>
)}
@ -899,8 +992,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<button
onClick={() => {
navigator.clipboard.writeText(
window.location.hostname +
"/" +
"https://cal.com/" +
(team ? "team/" + team.slug : eventType.users[0].username) +
"/" +
eventType.slug
@ -1174,6 +1266,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
schedulingType: true,
userId: true,
price: true,
currency: true,
},
});
@ -1208,24 +1302,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
});
const integrations = [
{
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
enabled: credentials.find((integration) => integration.type === "google_calendar") != null,
type: "google_calendar",
title: "Google Calendar",
imageSrc: "integrations/google-calendar.svg",
description: "For personal and business accounts",
},
{
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
type: "office365_calendar",
enabled: credentials.find((integration) => integration.type === "office365_calendar") != null,
title: "Office 365 / Outlook.com Calendar",
imageSrc: "integrations/outlook.svg",
description: "For personal and business accounts",
},
];
const integrations = getIntegrations(credentials);
const locationOptions: OptionTypeBase[] = [
{ value: LocationType.InPerson, label: "Link or In-person meeting" },
@ -1233,27 +1310,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
{ value: LocationType.Zoom, label: "Zoom Video", disabled: true },
];
const hasGoogleCalendarIntegration = integrations.find(
(i) => i.type === "google_calendar" && i.installed === true && i.enabled
);
if (hasGoogleCalendarIntegration) {
const hasPaymentIntegration = hasIntegration(integrations, "stripe_payment");
if (hasIntegration(integrations, "google_calendar")) {
locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" });
}
const currency =
(credentials.find((integration) => integration.type === "stripe_payment")?.key as Stripe.OAuthToken)
?.default_currency || "usd";
const hasOfficeIntegration = integrations.find(
(i) => i.type === "office365_calendar" && i.installed === true && i.enabled
);
if (hasOfficeIntegration) {
if (hasIntegration(integrations, "office365_calendar")) {
// TODO: Add default meeting option of the office integration.
// Assuming it's Microsoft Teams.
}
const getAvailability = (providesAvailability) =>
providesAvailability.availability && providesAvailability.availability.length
? providesAvailability.availability
: null;
type Availability = typeof eventType["availability"];
const getAvailability = (availability: Availability) => (availability?.length ? availability : null);
const availability: Availability[] = getAvailability(eventType) || [];
const availability = getAvailability(eventType.availability) || [];
availability.sort((a, b) => a.startTime - b.startTime);
const eventTypeObject = Object.assign({}, eventType, {
@ -1276,6 +1349,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
availability,
team: eventTypeObject.team || null,
teamMembers,
hasPaymentIntegration,
currency,
},
};
};

View file

@ -43,6 +43,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import React, { Fragment, useRef } from "react";
import { useMutation } from "react-query";
import { Prisma } from "@prisma/client";
type PageProps = inferSSRProps<typeof getServerSideProps>;
type EventType = PageProps["eventTypes"][number];
@ -68,15 +69,15 @@ const EventTypesPage = (props: PageProps) => {
profile,
membershipCount,
}: {
profile: Profile;
profile?: Profile;
membershipCount: MembershipCount;
}) => (
<div className="flex mb-4">
<Link href="/settings/teams">
<a>
<Avatar
displayName={profile.name}
imageSrc={profile.image || undefined}
displayName={profile?.name || ""}
imageSrc={profile?.image || undefined}
size={8}
className="inline mt-1 mr-2"
/>
@ -84,7 +85,7 @@ const EventTypesPage = (props: PageProps) => {
</Link>
<div>
<Link href="/settings/teams">
<a className="font-bold">{profile.name}</a>
<a className="font-bold">{profile?.name || ""}</a>
</Link>
{membershipCount && (
<span className="relative ml-2 text-xs text-neutral-500 -top-px">
@ -98,9 +99,9 @@ const EventTypesPage = (props: PageProps) => {
</Link>
</span>
)}
{typeof window !== "undefined" && (
<Link href={profile.slug!}>
<a className="block text-xs text-neutral-500">{`${window.location.host}/${profile.slug}`}</a>
{typeof window !== "undefined" && profile?.slug && (
<Link href={profile.slug}>
<a className="block text-xs text-neutral-500">{`cal.com/${profile.slug}`}</a>
</Link>
)}
</div>
@ -114,7 +115,7 @@ const EventTypesPage = (props: PageProps) => {
}: {
profile: PageProps["profiles"][number];
readOnly: boolean;
types: EventType[];
types: EventType["eventTypes"];
}) => (
<div className="mb-16 -mx-4 overflow-hidden bg-white border border-gray-200 rounded-sm sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="event-types">
@ -148,19 +149,19 @@ const EventTypesPage = (props: PageProps) => {
<div className="flex-shrink-0 hidden mt-4 sm:flex sm:mt-0 sm:ml-5">
<div className="flex items-center space-x-5 overflow-hidden">
{type.users.length > 1 && (
{type.users?.length > 1 && (
<AvatarGroup
size={8}
truncateAfter={4}
items={type.users.map((organizer) => ({
alt: organizer.name,
image: organizer.avatar,
alt: organizer.name || "",
image: organizer.avatar || "",
}))}
/>
)}
<Tooltip content="Preview">
<a
href={`/${profile.slug}/${type.slug}`}
href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className="p-2 border border-transparent cursor-pointer group text-neutral-400 hover:border-gray-200">
@ -173,7 +174,7 @@ const EventTypesPage = (props: PageProps) => {
onClick={() => {
showToast("Link copied!", "success");
navigator.clipboard.writeText(
`${window.location.origin}/${profile.slug}/${type.slug}`
`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`
);
}}
className="p-2 border border-transparent group text-neutral-400 hover:border-gray-200">
@ -210,7 +211,7 @@ const EventTypesPage = (props: PageProps) => {
<Menu.Item>
{({ active }) => (
<a
href={`/${profile.slug}/${type.slug}`}
href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className={classNames(
@ -231,7 +232,7 @@ const EventTypesPage = (props: PageProps) => {
onClick={() => {
showToast("Link copied!", "success");
navigator.clipboard.writeText(
`${window.location.origin}/${profile.slug}/${type.slug}`
`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`
);
}}
className={classNames(
@ -535,6 +536,29 @@ export async function getServerSideProps(context) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
/**
* This makes the select reusable and type safe.
* @url https://www.prisma.io/docs/concepts/components/prisma-client/advanced-type-safety/prisma-validator#using-the-prismavalidator
* */
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
description: true,
length: true,
schedulingType: true,
slug: true,
hidden: true,
price: true,
currency: true,
users: {
select: {
id: true,
avatar: true,
name: true,
},
},
});
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
@ -568,22 +592,7 @@ export async function getServerSideProps(context) {
},
},
eventTypes: {
select: {
id: true,
title: true,
description: true,
length: true,
schedulingType: true,
slug: true,
hidden: true,
users: {
select: {
id: true,
avatar: true,
name: true,
},
},
},
select: eventTypeSelect,
},
},
},
@ -593,22 +602,7 @@ export async function getServerSideProps(context) {
where: {
team: null,
},
select: {
id: true,
title: true,
description: true,
length: true,
schedulingType: true,
slug: true,
hidden: true,
users: {
select: {
id: true,
avatar: true,
name: true,
},
},
},
select: eventTypeSelect,
},
},
});
@ -637,25 +631,10 @@ export async function getServerSideProps(context) {
where: {
userId: session.user.id,
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
schedulingType: true,
hidden: true,
users: {
select: {
id: true,
avatar: true,
name: true,
},
},
},
select: eventTypeSelect,
});
type EventTypes = (Partial<typeof typesRaw[number]> & {
type EventTypeGroup = {
teamId?: number | null;
profile?: {
slug: typeof user["username"];
@ -666,37 +645,36 @@ export async function getServerSideProps(context) {
membershipCount: number;
readOnly: boolean;
};
eventTypes?: (Partial<typeof user["eventTypes"][number]> & { $disabled?: boolean })[];
})[];
eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[];
};
let eventTypes: EventTypes = [];
let eventTypeGroups: EventTypeGroup[] = [];
const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
const oldItem = hashMap[newItem.id] || {};
hashMap[newItem.id] = { ...oldItem, ...newItem };
return hashMap;
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({
...et,
$disabled: user.plan === "FREE" && index > 0,
}));
eventTypes.push({
eventTypeGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
image: user.avatar,
},
eventTypes: user.eventTypes.concat(typesRaw).map((type, index) =>
user.plan === "FREE" && index > 0
? {
...type,
$disabled: true,
}
: {
...type,
$disabled: false,
}
),
eventTypes: mergedEventTypes,
metadata: {
membershipCount: 1,
readOnly: false,
},
});
eventTypes = ([] as EventTypes).concat(
eventTypes,
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
user.teams.map((membership) => ({
teamId: membership.team.id,
profile: {
@ -716,16 +694,16 @@ export async function getServerSideProps(context) {
createdDate: user.createdDate.toString(),
});
const canAddEvents = user.plan !== "FREE" || eventTypes[0].eventTypes.length < 1;
const canAddEvents = user.plan !== "FREE" || eventTypeGroups[0].eventTypes.length < 1;
return {
props: {
canAddEvents,
user: userObj,
// don't display event teams without event types,
eventTypes: eventTypes.filter((groupBy) => !!groupBy.eventTypes?.length),
eventTypes: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length),
// so we can show a dropdown when the user has teams
profiles: eventTypes.map((group) => ({
profiles: eventTypeGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,

View file

@ -16,21 +16,11 @@ import AddAppleIntegration, {
ADD_APPLE_INTEGRATION_FORM_TITLE,
} from "@lib/integrations/Apple/components/AddAppleIntegration";
import Button from "@components/ui/Button";
import getIntegrations from "@lib/integrations/getIntegrations";
import { GetServerSidePropsContext } from "next";
import { inferSSRProps } from "@lib/types/inferSSRProps";
export type Integration = {
installed: boolean;
credential: unknown;
type: string;
title: string;
imageSrc: string;
description: string;
};
type Props = {
integrations: Integration[];
};
export default function Home({ integrations }: Props) {
export default function Home({ integrations }: inferSSRProps<typeof getServerSideProps>) {
const [, loading] = useSession();
const [selectableCalendars, setSelectableCalendars] = useState([]);
@ -475,21 +465,9 @@ export default function Home({ integrations }: Props) {
);
}
const validJson = (jsonString: string) => {
try {
const o = JSON.parse(jsonString);
if (o && typeof o === "object") {
return o;
}
} catch (e) {
console.error(e);
}
return false;
};
export async function getServerSideProps(context) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getSession(context);
if (!session) {
if (!session?.user?.email) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await prisma.user.findFirst({
@ -498,62 +476,21 @@ export async function getServerSideProps(context) {
},
select: {
id: true,
},
});
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
},
credentials: {
select: {
id: true,
type: true,
key: true,
},
},
},
});
const integrations = [
{
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
credential: credentials.find((integration) => integration.type === "google_calendar") || null,
type: "google_calendar",
title: "Google Calendar",
imageSrc: "integrations/google-calendar.svg",
description: "For personal and business calendars",
},
{
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
type: "office365_calendar",
credential: credentials.find((integration) => integration.type === "office365_calendar") || null,
title: "Office 365 / Outlook.com Calendar",
imageSrc: "integrations/outlook.svg",
description: "For personal and business calendars",
},
{
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
type: "zoom_video",
credential: credentials.find((integration) => integration.type === "zoom_video") || null,
title: "Zoom",
imageSrc: "integrations/zoom.svg",
description: "Video Conferencing",
},
{
installed: true,
type: "caldav_calendar",
credential: credentials.find((integration) => integration.type === "caldav_calendar") || null,
title: "CalDav Server",
imageSrc: "integrations/caldav.svg",
description: "For personal and business calendars",
},
{
installed: true,
type: "apple_calendar",
credential: credentials.find((integration) => integration.type === "apple_calendar") || null,
title: "Apple Calendar",
imageSrc: "integrations/apple-calendar.svg",
description: "For personal and business calendars",
},
];
if (!user) return { redirect: { permanent: false, destination: "/auth/login" } };
const { credentials } = user;
const integrations = getIntegrations(credentials);
return {
props: { integrations },

9
pages/payment/[uid].tsx Normal file
View file

@ -0,0 +1,9 @@
import PaymentPage from "@ee/components/stripe/PaymentPage";
import { getServerSideProps } from "@ee/pages/payment/[uid]";
import { inferSSRProps } from "@lib/types/inferSSRProps";
export default function Payment(props: inferSSRProps<typeof getServerSideProps>) {
return <PaymentPage {...props} />;
}
export { getServerSideProps };

View file

@ -1,22 +1,25 @@
import prisma from "@lib/prisma";
import { EventType } from "@prisma/client";
import "react-phone-number-input/style.css";
import BookingPage from "@components/booking/pages/BookingPage";
import { InferGetServerSidePropsType } from "next";
import { asStringOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { GetServerSidePropsContext } from "next";
import "react-phone-number-input/style.css";
export default function TeamBookingPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
export type TeamBookingPageProps = inferSSRProps<typeof getServerSideProps>;
export default function TeamBookingPage(props: TeamBookingPageProps) {
return <BookingPage {...props} />;
}
export async function getServerSideProps(context) {
const eventTypeId = parseInt(context.query.type);
export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
return {
notFound: true,
} as const;
}
const eventType: EventType = await prisma.eventType.findUnique({
const eventType = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
@ -50,6 +53,8 @@ export async function getServerSideProps(context) {
},
});
if (!eventType) return { notFound: true };
const eventTypeObject = [eventType].map((e) => {
return {
...e,
@ -63,7 +68,7 @@ export async function getServerSideProps(context) {
if (context.query.rescheduleUid) {
booking = await prisma.booking.findFirst({
where: {
uid: context.query.rescheduleUid,
uid: asStringOrThrow(context.query.rescheduleUid),
},
select: {
description: true,
@ -82,7 +87,8 @@ export async function getServerSideProps(context) {
profile: {
...eventTypeObject.team,
slug: "team/" + eventTypeObject.slug,
image: eventTypeObject.team.logo,
image: eventTypeObject.team?.logo || null,
theme: null /* Teams don't have a theme, and `BookingPage` uses it */,
},
eventType: eventTypeObject,
booking,

View file

@ -0,0 +1,35 @@
-- CreateEnum
CREATE TYPE "PaymentType" AS ENUM ('STRIPE');
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "paid" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "currency" TEXT NOT NULL DEFAULT E'usd',
ADD COLUMN "price" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "Payment" (
"id" SERIAL NOT NULL,
"uid" TEXT NOT NULL,
"type" "PaymentType" NOT NULL,
"bookingId" INTEGER NOT NULL,
"amount" INTEGER NOT NULL,
"fee" INTEGER NOT NULL,
"currency" TEXT NOT NULL,
"success" BOOLEAN NOT NULL,
"refunded" BOOLEAN NOT NULL,
"data" JSONB NOT NULL,
"externalId" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Payment.uid_unique" ON "Payment"("uid");
-- CreateIndex
CREATE UNIQUE INDEX "Payment.externalId_unique" ON "Payment"("externalId");
-- AddForeignKey
ALTER TABLE "Payment" ADD FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[userId,slug]` on the table `EventType` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "EventType.userId_slug_unique" ON "EventType"("userId", "slug");

View file

@ -8,6 +8,7 @@ datasource db {
generator client {
provider = "prisma-client-js"
previewFeatures = ["selectRelationCount"]
}
enum SchedulingType {
@ -42,6 +43,9 @@ model EventType {
minimumBookingNotice Int @default(120)
schedulingType SchedulingType?
Schedule Schedule[]
price Int @default(0)
currency String @default("usd")
@@unique([userId, slug])
}
model Credential {
@ -176,6 +180,8 @@ model Booking {
confirmed Boolean @default(true)
rejected Boolean @default(false)
status BookingStatus @default(ACCEPTED)
paid Boolean @default(false)
payment Payment[]
}
model Schedule {
@ -246,3 +252,22 @@ model ReminderMail {
elapsedMinutes Int
createdAt DateTime @default(now())
}
enum PaymentType {
STRIPE
}
model Payment {
id Int @id @default(autoincrement())
uid String @unique
type PaymentType
bookingId Int
booking Booking? @relation(fields: [bookingId], references: [id])
amount Int
fee Int
currency String
success Boolean
refunded Boolean
data Json
externalId String @unique
}

View file

@ -0,0 +1,16 @@
<svg width="183" height="76" viewBox="0 0 183 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M182.4 39.216C182.4 26.2453 176.117 16.0107 164.109 16.0107C152.051 16.0107 144.755 26.2453 144.755 39.1147C144.755 54.3653 153.368 62.0667 165.731 62.0667C171.76 62.0667 176.32 60.6987 179.765 58.7733V48.64C176.32 50.3627 172.368 51.4267 167.352 51.4267C162.437 51.4267 158.08 49.704 157.523 43.7253H182.299C182.299 43.0667 182.4 40.432 182.4 39.216ZM157.371 34.4027C157.371 28.6773 160.867 26.296 164.059 26.296C167.149 26.296 170.443 28.6773 170.443 34.4027H157.371Z" fill="#635BFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M125.197 16.0107C120.232 16.0107 117.04 18.3413 115.267 19.9627L114.608 16.8213H103.461V75.8987L116.128 73.2134L116.179 58.8747C118.003 60.192 120.688 62.0667 125.147 62.0667C134.216 62.0667 142.475 54.7707 142.475 38.7094C142.424 24.016 134.064 16.0107 125.197 16.0107ZM122.157 50.92C119.168 50.92 117.395 49.856 116.179 48.5387L116.128 29.7413C117.445 28.272 119.269 27.2587 122.157 27.2587C126.768 27.2587 129.96 32.4267 129.96 39.064C129.96 45.8534 126.819 50.92 122.157 50.92Z" fill="#635BFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M86.032 13.0213L98.7493 10.2853V0L86.032 2.68533V13.0213Z" fill="#635BFF"/>
<path d="M98.7493 16.872H86.032V61.2053H98.7493V16.872Z" fill="#635BFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.4026 20.6213L71.592 16.872H60.6479V61.2053H73.3146V31.16C76.304 27.2587 81.3706 27.968 82.9413 28.5253V16.872C81.32 16.264 75.3919 15.1493 72.4026 20.6213Z" fill="#635BFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M47.0693 5.87732L34.7067 8.51199L34.656 49.096C34.656 56.5946 40.28 62.1173 47.7787 62.1173C51.9333 62.1173 54.9733 61.3573 56.6453 60.4453V50.16C55.024 50.8187 47.0187 53.1493 47.0187 45.6507V27.664H56.6453V16.872H47.0187L47.0693 5.87732Z" fill="#635BFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.8187 29.7413C12.8187 27.7653 14.44 27.0054 17.1253 27.0054C20.976 27.0054 25.84 28.1707 29.6907 30.248V18.3413C25.4853 16.6693 21.3307 16.0107 17.1253 16.0107C6.84 16.0107 0 21.3813 0 30.3493C0 44.3333 19.2533 42.104 19.2533 48.1333C19.2533 50.464 17.2267 51.224 14.3893 51.224C10.184 51.224 4.81333 49.5014 0.557333 47.1707V59.2293C5.26933 61.256 10.032 62.1174 14.3893 62.1174C24.928 62.1174 32.1733 56.8987 32.1733 47.8293C32.1227 32.7307 12.8187 35.416 12.8187 29.7413Z" fill="#635BFF"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="182.4" height="76" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -133,43 +133,35 @@
}
}
.react-multi-email > [type='text'] {
@apply shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md;
/* hide intercom chat bubble on mobile */
@media only screen and (max-width: 768px) {
.intercom-launcher-frame{
display: none !important;
}
}
.react-multi-email {
margin: 0;
max-width: 100%;
-webkit-box-flex: 1;
-ms-flex: 1 0 auto;
flex: 1 0 auto;
text-align: left;
line-height: 1.25rem;
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
align-content: flex-start;
padding-top: 0.1rem !important;
padding-bottom: 0.1rem !important;
/* padding-left: 0.75rem !important; */
@apply dark:border-black border-white dark:bg-black bg-white;
/* add padding bottom to bottom nav on standalone mode */
@media all and (display-mode: standalone) {
.bottom-nav {
padding-bottom: 24px;
}
}
/* hide intercom chat bubble on mobile */
@media only screen and (max-width: 768px) {
.intercom-launcher-frame{
display: none !important;
}
}
.react-multi-email > [type='text'] {
@apply shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md;
}
.react-multi-email [data-tag] {
box-shadow: none !important;
}
.react-multi-email.focused{
@apply dark:bg-black
}
.react-multi-email.focused > [type='text']{
border: 2px solid #000 !important;
}
.react-multi-email > [type='text']:focus{
box-shadow: none !important;
@apply inline-flex items-center px-2 py-1 my-1 mr-2 border border-transparent text-sm font-medium rounded-md text-gray-900 dark:text-white bg-neutral-200 hover:bg-neutral-100 dark:bg-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;
}
.react-multi-email > span[data-placeholder] {
@ -183,7 +175,7 @@
.react-multi-email.empty > span[data-placeholder] {
display: inline;
color: #000;
color: #646b7a;
}
.react-multi-email.focused > span[data-placeholder] {
@ -197,8 +189,7 @@
}
.react-multi-email [data-tag] {
box-shadow: none !important;
@apply inline-flex items-center px-2 py-1 my-1 mr-2 border border-transparent text-sm font-medium rounded-md text-gray-900 dark:text-white bg-neutral-200 hover:bg-neutral-100 dark:bg-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;
@apply inline-flex items-center px-2 py-1 my-1 mr-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-gray-900 dark:text-white bg-neutral-200 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;
}
.react-multi-email [data-tag] [data-tag-item] {
@ -211,8 +202,6 @@
cursor: pointer;
}
/* !important to override react-select */
.react-select__value-container{
border: 0 !important;

View file

@ -13,6 +13,9 @@
],
"@lib/*": [
"lib/*"
],
"@ee/*": [
"ee/*"
]
},
"allowJs": true,

213
yarn.lock
View file

@ -463,6 +463,76 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@formatjs/ecma402-abstract@1.9.8":
version "1.9.8"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.9.8.tgz#f3dad447fbc7f063f88e2a148b7a353161740e74"
integrity sha512-2U4n11bLmTij/k4ePCEFKJILPYwdMcJTdnKVBi+JMWBgu5O1N+XhCazlE6QXqVO1Agh2Doh0b/9Jf1mSmSVfhA==
dependencies:
"@formatjs/intl-localematcher" "0.2.20"
tslib "^2.1.0"
"@formatjs/fast-memoize@1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.0.tgz#1123bfcc5d21d761f15d8b1c32d10e1b6530355d"
integrity sha512-fObitP9Tlc31SKrPHgkPgQpGo4+4yXfQQITTCNH8AZdEqB7Mq4nPrjpUL/tNGN3lEeJcFxDbi0haX8HM7QvQ8w==
dependencies:
tslib "^2.1.0"
"@formatjs/icu-messageformat-parser@2.0.11":
version "2.0.11"
resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.0.11.tgz#e4ba40b9a8aefc8bccfc96be5906d3bca305b4b3"
integrity sha512-5mWb8U8aulYGwnDZWrr+vdgn5PilvtrqQYQ1pvpgzQes/osi85TwmL2GqTGLlKIvBKD2XNA61kAqXYY95w4LWg==
dependencies:
"@formatjs/ecma402-abstract" "1.9.8"
"@formatjs/icu-skeleton-parser" "1.2.12"
tslib "^2.1.0"
"@formatjs/icu-skeleton-parser@1.2.12":
version "1.2.12"
resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.2.12.tgz#45426eb1448c0c08c931eb9f0672283c0e4d0062"
integrity sha512-DTFxWmEA02ZNW6fsYjGYSADvtrqqjCYF7DSgCmMfaaE0gLP4pCdAgOPE+lkXXU+jP8iCw/YhMT2Seyk/C5lBWg==
dependencies:
"@formatjs/ecma402-abstract" "1.9.8"
tslib "^2.1.0"
"@formatjs/intl-displaynames@5.2.3":
version "5.2.3"
resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-5.2.3.tgz#a0cebc81e89c5414177ade71a2f2388d799ee6e8"
integrity sha512-5BmhSurLbfgdeo0OBcNPPkIS8ikMMYaHe2NclxEQZqcMvrnQzNMNnUE2dDF5vZx+mkvKq77aQYzpc8RfqVsRCQ==
dependencies:
"@formatjs/ecma402-abstract" "1.9.8"
"@formatjs/intl-localematcher" "0.2.20"
tslib "^2.1.0"
"@formatjs/intl-listformat@6.3.3":
version "6.3.3"
resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-6.3.3.tgz#0cb83a012c0ae46876e30589a086695298e0fb5c"
integrity sha512-3nzAKgVS5rePDa5HiH0OwZgAhqxLtzlMc9Pg4QgajRHSP1TqFiMmQnnn52wd3+xVTb7cjZVm3JBnTv51/MhTOg==
dependencies:
"@formatjs/ecma402-abstract" "1.9.8"
"@formatjs/intl-localematcher" "0.2.20"
tslib "^2.1.0"
"@formatjs/intl-localematcher@0.2.20":
version "0.2.20"
resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.20.tgz#782aef53d1c1b6112ee67468dc59f9b8d1ba7b17"
integrity sha512-/Ro85goRZnCojzxOegANFYL0LaDIpdPjAukR7xMTjOtRx+3yyjR0ifGTOW3/Kjhmab3t6GnyHBYWZSudxEOxPA==
dependencies:
tslib "^2.1.0"
"@formatjs/intl@1.14.1":
version "1.14.1"
resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-1.14.1.tgz#03e12f7e2cf557defdd1a5aeb1c143efb8cfbc7b"
integrity sha512-mtL8oBgFwTu0GHFnxaF93fk/zNzNkPzl+27Fwg5AZ88pWHWb7037dpODzoCBnaIVk4FBO5emUn/6jI9Byj8hOw==
dependencies:
"@formatjs/ecma402-abstract" "1.9.8"
"@formatjs/fast-memoize" "1.2.0"
"@formatjs/icu-messageformat-parser" "2.0.11"
"@formatjs/intl-displaynames" "5.2.3"
"@formatjs/intl-listformat" "6.3.3"
intl-messageformat "9.9.1"
tslib "^2.1.0"
"@hapi/accept@5.0.2":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-5.0.2.tgz#ab7043b037e68b722f93f376afb05e85c0699523"
@ -1262,6 +1332,18 @@
resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20"
integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg==
"@stripe/react-stripe-js@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.4.1.tgz#884d59286fff00ba77389b32c045516f65d7a340"
integrity sha512-FjcVrhf72+9fUL3Lz3xi02ni9tzH1A1x6elXlr6tvBDgSD55oPJuodoP8eC7xTnBIKq0olF5uJvgtkJyDCdzjA==
dependencies:
prop-types "^15.7.2"
"@stripe/stripe-js@^1.16.0":
version "1.17.1"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.17.1.tgz#afcb7e86d0b05d1a7af53af89111abd2e8d437ae"
integrity sha512-c9MyDvdi5Xou0j0JPNy86NebtTDfh9o62Ifuzx6GSm2YO0oedBpy51WSyOue2L8Fb+mqESS5gd6mGVEIPUnXsA==
"@tailwindcss/forms@^0.3.3":
version "0.3.3"
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.3.3.tgz#a29d22668804f3dae293dcadbef1aa6315c45b64"
@ -1339,6 +1421,14 @@
dependencies:
"@types/node" "*"
"@types/hoist-non-react-statics@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
dependencies:
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
@ -1393,6 +1483,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"
integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==
"@types/node@>=8.1.0":
version "16.7.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.7.10.tgz#7aa732cc47341c12a16b7d562f519c2383b6d4fc"
integrity sha512-S63Dlv4zIPb8x6MMTgDq5WWRJQe56iBEY0O3SOFA9JrRienkOVDXSXBjjJw6HTNQYSE2JI6GMCR6LVbIMHJVvA==
"@types/node@^14.14.31":
version "14.17.15"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.15.tgz#d5ebfb62a69074ebb85cbe0529ad917bb8f2bae8"
@ -1467,7 +1562,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^17.0.18":
"@types/react@*", "@types/react@17", "@types/react@^17.0.18":
version "17.0.20"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.20.tgz#a4284b184d47975c71658cd69e759b6bd37c3b8c"
integrity sha512-wWZrPlihslrPpcKyCSlmIlruakxr57/buQN1RjlIeaaTWDLtJkTtRW429MoQJergvVKc4IWBpRhWw7YNh/7GVA==
@ -1496,6 +1591,18 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
"@types/stripe@^8.0.417":
version "8.0.417"
resolved "https://registry.yarnpkg.com/@types/stripe/-/stripe-8.0.417.tgz#b651677a9fc33be8ce8fd5bceadd7ca077214244"
integrity sha512-PTuqskh9YKNENnOHGVJBm4sM0zE8B1jZw1JIskuGAPkMB+OH236QeN8scclhYGPA4nG6zTtPXgwpXdp+HPDTVw==
dependencies:
stripe "*"
"@types/uuid@8.3.1":
version "8.3.1"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.1.tgz#1a32969cf8f0364b3d8c8af9cc3555b7805df14f"
integrity sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==
"@types/yargs-parser@*":
version "20.2.1"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129"
@ -1777,6 +1884,11 @@ arch@^2.2.0:
resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11"
integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==
arg@4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0"
integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
@ -2284,6 +2396,11 @@ builtin-status-codes@^3.0.0:
resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=
bytes@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
bytes@3.1.0, bytes@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
@ -2608,6 +2725,11 @@ constants-browserify@1.0.0, constants-browserify@^1.0.0:
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=
content-type@1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
convert-source-map@1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
@ -2930,6 +3052,11 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
depd@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359"
integrity sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=
depd@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
@ -4045,7 +4172,7 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1:
hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@ -4084,6 +4211,16 @@ html-tags@^3.1.0:
resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140"
integrity sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==
http-errors@1.6.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736"
integrity sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=
dependencies:
depd "1.1.1"
inherits "2.0.3"
setprototypeof "1.0.3"
statuses ">= 1.3.1 < 2"
http-errors@1.7.3:
version "1.7.3"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
@ -4146,6 +4283,11 @@ ical.js@^1.4.0:
resolved "https://registry.yarnpkg.com/ical.js/-/ical.js-1.4.0.tgz#fc5619dc55fe03d909bf04362aa0677f4541b9d7"
integrity sha512-ltHZuOFNNjcyEYbzDgjemS7LWIFh2vydJeznxQHUh3dnarbxqOYsWONYteBVAq1MEOHnwXFGN2eskZReHclnrA==
iconv-lite@0.4.19:
version "0.4.19"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
integrity sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -4274,6 +4416,15 @@ internal-slot@^1.0.3:
has "^1.0.3"
side-channel "^1.0.4"
intl-messageformat@9.9.1:
version "9.9.1"
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.9.1.tgz#255d453b0656b4f7e741f31d2b4a95bf2adfe064"
integrity sha512-cuzS/XKHn//hvKka77JKU2dseiVY2dofQjIOZv6ZFxFt4Z9sPXnZ7KQ9Ak2r+4XBCjI04MqJ1PhKs/3X22AkfA==
dependencies:
"@formatjs/fast-memoize" "1.2.0"
"@formatjs/icu-messageformat-parser" "2.0.11"
tslib "^2.1.0"
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@ -4454,6 +4605,11 @@ is-regexp@^1.0.0:
resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069"
integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk=
is-stream@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
is-stream@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
@ -5469,6 +5625,16 @@ merge2@^1.3.0:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micro@^9.3.4:
version "9.3.4"
resolved "https://registry.yarnpkg.com/micro/-/micro-9.3.4.tgz#745a494e53c8916f64fb6a729f8cbf2a506b35ad"
integrity sha512-smz9naZwTG7qaFnEZ2vn248YZq9XR+XoOH3auieZbkhDL4xLOxiE+KqG8qqnBeKfXA9c1uEFGCxPN1D+nT6N7w==
dependencies:
arg "4.1.0"
content-type "1.0.4"
is-stream "1.1.0"
raw-body "2.3.2"
micromatch@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
@ -6451,7 +6617,7 @@ qrcode@^1.4.4:
pngjs "^3.3.0"
yargs "^13.2.4"
qs@^6.7.0:
qs@^6.6.0, qs@^6.7.0:
version "6.10.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a"
integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==
@ -6522,6 +6688,16 @@ randomfill@^1.0.3:
randombytes "^2.0.5"
safe-buffer "^5.1.0"
raw-body@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89"
integrity sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=
dependencies:
bytes "3.0.0"
http-errors "1.6.2"
iconv-lite "0.4.19"
unpipe "1.0.0"
raw-body@2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
@ -6584,6 +6760,22 @@ react-input-autosize@^3.0.0:
dependencies:
prop-types "^15.5.8"
react-intl@^5.20.7:
version "5.20.10"
resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.20.10.tgz#8b0f18a5b76e9f8f5a3ccc993deb0c4cef9dcde0"
integrity sha512-zy0ZQhpjkGsKcK1BFo2HbGM/q8GBVovzoXZGQ76DowR0yr6UzQuPLkrlIrObL2zxIYiDaxaz+hUJaoa2a1xqOQ==
dependencies:
"@formatjs/ecma402-abstract" "1.9.8"
"@formatjs/icu-messageformat-parser" "2.0.11"
"@formatjs/intl" "1.14.1"
"@formatjs/intl-displaynames" "5.2.3"
"@formatjs/intl-listformat" "6.3.3"
"@types/hoist-non-react-statics" "^3.3.1"
"@types/react" "17"
hoist-non-react-statics "^3.3.2"
intl-messageformat "9.9.1"
tslib "^2.1.0"
react-is@17.0.2, react-is@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
@ -7023,6 +7215,11 @@ setimmediate@^1.0.4:
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=
setprototypeof@1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04"
integrity sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=
setprototypeof@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
@ -7229,7 +7426,7 @@ stacktrace-parser@0.1.10:
dependencies:
type-fest "^0.7.1"
"statuses@>= 1.5.0 < 2":
"statuses@>= 1.3.1 < 2", "statuses@>= 1.5.0 < 2":
version "1.5.0"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
@ -7417,6 +7614,14 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
stripe@*, stripe@^8.168.0:
version "8.174.0"
resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.174.0.tgz#91d2e61b0217b1ee9fde2842582e0f1cf1dddc94"
integrity sha512-UFU5TuYH7XwUmSllUIcIKhhsvvhhjw9D6ZwVdfB74wU4VOOaWBiQqszkw6chaEFpdulUmbcAH5eZltV3HwOi7g==
dependencies:
"@types/node" ">=8.1.0"
qs "^6.6.0"
styled-jsx@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-4.0.1.tgz#ae3f716eacc0792f7050389de88add6d5245b9e9"