From 3add84a2796113b9dc1d01b435ad1183f3c968d0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Omar=20L=C3=B3pez?=
Date: Wed, 22 Sep 2021 12:36:13 -0600
Subject: [PATCH] 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
---
.env.example | 9 +
calendso.yaml | 9 +-
components/Shell.tsx | 26 +-
components/booking/Slots.tsx | 96 ------
components/booking/pages/AvailabilityPage.tsx | 27 +-
components/booking/pages/BookingPage.tsx | 98 ++++--
components/eventtype/EventTypeDescription.tsx | 37 ++-
ee/README.md | 11 +
ee/components/stripe/Payment.tsx | 122 +++++++
ee/components/stripe/PaymentPage.tsx | 126 ++++++++
ee/lib/stripe/client.ts | 28 ++
ee/lib/stripe/server.ts | 174 ++++++++++
.../api/integrations/stripepayment/add.ts | 48 +++
.../integrations/stripepayment/callback.ts | 37 +++
.../api/integrations/stripepayment/webhook.ts | 135 ++++++++
ee/pages/payment/[uid].tsx | 106 +++++++
lib/calendarClient.ts | 18 +-
lib/core/browser/useDarkMode.tsx | 45 +++
lib/emails/EventOrganizerRefundFailedMail.ts | 65 ++++
lib/emails/EventPaymentMail.ts | 165 ++++++++++
lib/hooks/useSlots.ts | 21 +-
lib/integrations.ts | 7 +-
lib/integrations/getIntegrations.ts | 73 +++++
lib/mutations/bookings/create-booking.ts | 10 +
lib/types/booking.ts | 21 ++
lib/videoClient.ts | 61 ++--
next.config.js | 8 +-
package.json | 7 +
pages/[user].tsx | 3 +
pages/[user]/[type].tsx | 4 +
pages/[user]/book.tsx | 29 +-
pages/api/availability/[user].ts | 20 +-
pages/api/availability/eventtype.ts | 2 +
pages/api/book/confirm.ts | 4 +
pages/api/book/event.ts | 297 +++++++++---------
pages/api/cancel.ts | 44 ++-
pages/api/integrations/stripepayment/add.ts | 1 +
.../integrations/stripepayment/callback.ts | 1 +
.../api/integrations/stripepayment/webhook.ts | 1 +
pages/availability/troubleshoot.tsx | 2 +-
pages/event-types/[type].tsx | 149 ++++++---
pages/event-types/index.tsx | 146 ++++-----
pages/integrations/index.tsx | 97 +-----
pages/payment/[uid].tsx | 9 +
pages/team/[slug]/book.tsx | 26 +-
.../migration.sql | 35 +++
.../migration.sql | 8 +
prisma/schema.prisma | 25 ++
public/integrations/stripe.svg | 16 +
styles/globals.css | 125 ++++----
tsconfig.json | 3 +
yarn.lock | 213 ++++++++++++-
52 files changed, 2214 insertions(+), 636 deletions(-)
delete mode 100644 components/booking/Slots.tsx
create mode 100644 ee/components/stripe/Payment.tsx
create mode 100644 ee/components/stripe/PaymentPage.tsx
create mode 100644 ee/lib/stripe/client.ts
create mode 100644 ee/lib/stripe/server.ts
create mode 100644 ee/pages/api/integrations/stripepayment/add.ts
create mode 100644 ee/pages/api/integrations/stripepayment/callback.ts
create mode 100644 ee/pages/api/integrations/stripepayment/webhook.ts
create mode 100644 ee/pages/payment/[uid].tsx
create mode 100644 lib/core/browser/useDarkMode.tsx
create mode 100644 lib/emails/EventOrganizerRefundFailedMail.ts
create mode 100644 lib/emails/EventPaymentMail.ts
create mode 100644 lib/integrations/getIntegrations.ts
create mode 100644 lib/mutations/bookings/create-booking.ts
create mode 100644 lib/types/booking.ts
create mode 100644 pages/api/integrations/stripepayment/add.ts
create mode 100644 pages/api/integrations/stripepayment/callback.ts
create mode 100644 pages/api/integrations/stripepayment/webhook.ts
create mode 100644 pages/payment/[uid].tsx
create mode 100644 prisma/migrations/20210813142905_event_payment/migration.sql
create mode 100644 prisma/migrations/20210918152354_user_id_slug_fix/migration.sql
create mode 100644 public/integrations/stripe.svg
diff --git a/.env.example b/.env.example
index 1c1126b5..9e2aaa42 100644
--- a/.env.example
+++ b/.env.example
@@ -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=''
# 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=
diff --git a/calendso.yaml b/calendso.yaml
index aec371c9..1fa3cd05 100644
--- a/calendso.yaml
+++ b/calendso.yaml
@@ -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
diff --git a/components/Shell.tsx b/components/Shell.tsx
index c154c19f..a92423df 100644
--- a/components/Shell.tsx
+++ b/components/Shell.tsx
@@ -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"
)}>
@@ -309,25 +312,6 @@ function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean })
)}
-
- {({ active }) => (
-
-
- Feedback
-
- )}
-
diff --git a/components/booking/Slots.tsx b/components/booking/Slots.tsx
deleted file mode 100644
index f3677bde..00000000
--- a/components/booking/Slots.tsx
+++ /dev/null
@@ -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;
diff --git a/components/booking/pages/AvailabilityPage.tsx b/components/booking/pages/AvailabilityPage.tsx
index acbd79a9..663fa6cb 100644
--- a/components/booking/pages/AvailabilityPage.tsx
+++ b/components/booking/pages/AvailabilityPage.tsx
@@ -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
{eventType.length} minutes
+ {eventType.price > 0 && (
+
+
+
+
+
+
+ )}
@@ -159,6 +172,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
{eventType.length} minutes
+ {eventType.price > 0 && (
+
@@ -176,6 +204,18 @@ const BookingPage = (props: any): JSX.Element => {
{props.eventType.length} minutes
+ {props.eventType.price > 0 && (
+
+
+
+
+
+
+ )}
{selectedLocation === LocationType.InPerson && (
diff --git a/components/eventtype/EventTypeDescription.tsx b/components/eventtype/EventTypeDescription.tsx
index f55f77d1..fc110381 100644
--- a/components/eventtype/EventTypeDescription.tsx
+++ b/components/eventtype/EventTypeDescription.tsx
@@ -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()({
+ select: {
+ id: true,
+ length: true,
+ price: true,
+ currency: true,
+ schedulingType: true,
+ description: true,
+ },
+});
+
+type EventType = Prisma.EventTypeGetPayload;
export type EventTypeDescriptionProps = {
eventType: EventType;
@@ -27,6 +48,18 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
1-on-1
)}
+ {eventType.price > 0 && (
+
+
+
+
+
+
+ )}
{eventType.description && (
_❗ 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 `/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 `/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.
diff --git a/ee/components/stripe/Payment.tsx b/ee/components/stripe/Payment.tsx
new file mode 100644
index 00000000..ab1c4fa9
--- /dev/null
+++ b/ee/components/stripe/Payment.tsx
@@ -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({ 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 (
+
+ );
+}
diff --git a/ee/components/stripe/PaymentPage.tsx b/ee/components/stripe/PaymentPage.tsx
new file mode 100644
index 00000000..9835c5d9
--- /dev/null
+++ b/ee/components/stripe/PaymentPage.tsx
@@ -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 = (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 ? (
+
+
+
Payment | {eventName} | Calendso
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Payment
+
+
+
+ You have also received an email with this link, if you want to pay later.
+
+
+
+
What
+
{eventName}
+
When
+
+ {date.format("dddd, DD MMMM YYYY")}
+
+ {date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
+
+ ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
+
+
+ {props.booking.location && (
+ <>
+
Where
+
{location}
+ >
+ )}
+
Price
+
+
+
+
+
+
+
+
+
+ {props.payment.success && !props.payment.refunded && (
+
Paid
+ )}
+ {!props.payment.success && (
+
+
+
+ )}
+ {props.payment.refunded && (
+
Refunded
+ )}
+
+ {!props.profile.hideBranding && (
+
+ )}
+
+
+
+
+
+
+ ) : null;
+};
+
+export default PaymentPage;
diff --git a/ee/lib/stripe/client.ts b/ee/lib/stripe/client.ts
new file mode 100644
index 00000000..3cc96191
--- /dev/null
+++ b/ee/lib/stripe/client.ts
@@ -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;
+
+/**
+ * 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;
diff --git a/ee/lib/stripe/server.ts b/ee/lib/stripe/server.ts
new file mode 100644
index 00000000..ffa526ea
--- /dev/null
+++ b/ee/lib/stripe/server.ts
@@ -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_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;
diff --git a/ee/pages/api/integrations/stripepayment/add.ts b/ee/pages/api/integrations/stripepayment/add.ts
new file mode 100644
index 00000000..1af18238
--- /dev/null
+++ b/ee/pages/api/integrations/stripepayment/add.ts
@@ -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 });
+ }
+}
diff --git a/ee/pages/api/integrations/stripepayment/callback.ts b/ee/pages/api/integrations/stripepayment/callback.ts
new file mode 100644
index 00000000..1485f958
--- /dev/null
+++ b/ee/pages/api/integrations/stripepayment/callback.ts
@@ -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");
+}
diff --git a/ee/pages/api/integrations/stripepayment/webhook.ts b/ee/pages/api/integrations/stripepayment/webhook.ts
new file mode 100644
index 00000000..1cd90815
--- /dev/null
+++ b/ee/pages/api/integrations/stripepayment/webhook.ts
@@ -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 });
+}
diff --git a/ee/pages/payment/[uid].tsx b/ee/pages/payment/[uid].tsx
new file mode 100644
index 00000000..f82e3307
--- /dev/null
+++ b/ee/pages/payment/[uid].tsx
@@ -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;
+
+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,
+ },
+ };
+};
diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts
index d28e5b36..5873c596 100644
--- a/lib/calendarClient.ts
+++ b/lib/calendarClient.ts
@@ -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()({
+ select: { name: true, email: true, timeZone: true },
+});
+export type Person = Prisma.UserGetPayload;
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;
@@ -147,7 +147,11 @@ export interface CalendarApiAdapter {
deleteEvent(uid: string);
- getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise;
+ getAvailability(
+ dateFrom: string,
+ dateTo: string,
+ selectedCalendars: IntegrationCalendar[]
+ ): Promise;
listCalendars(): Promise;
}
diff --git a/lib/core/browser/useDarkMode.tsx b/lib/core/browser/useDarkMode.tsx
new file mode 100644
index 00000000..35a7bad4
--- /dev/null
+++ b/lib/core/browser/useDarkMode.tsx
@@ -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(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;
diff --git a/lib/emails/EventOrganizerRefundFailedMail.ts b/lib/emails/EventOrganizerRefundFailedMail.ts
new file mode 100644
index 00000000..4ac87c13
--- /dev/null
+++ b/lib/emails/EventOrganizerRefundFailedMail.ts
@@ -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(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.
The error message was: '${this.reason}'
PaymentId: '${this.paymentId}'`;
+ }
+
+ protected getAdditionalBody(): string {
+ return "";
+ }
+
+ protected getImage(): string {
+ return ``;
+ }
+
+ protected getSubject(): string {
+ const organizerStart: 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
+ }`;
+ }
+}
diff --git a/lib/emails/EventPaymentMail.ts b/lib/emails/EventPaymentMail.ts
new file mode 100644
index 00000000..f0a61c21
--- /dev/null
+++ b/lib/emails/EventPaymentMail.ts
@@ -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 (
+ `
+
+
+
+
Your meeting is awaiting payment
+
You and any other attendees have been emailed with this information.
+
+
+
+
+
+
+
+ What |
+ ${this.calEvent.type} |
+
+
+ When |
+ ${this.getInviteeStart().format("dddd, LL")} ${this.getInviteeStart().format("h:mma")} (${
+ this.calEvent.attendees[0].timeZone
+ }) |
+
+
+ Who |
+ ${this.calEvent.organizer.name} ${this.calEvent.organizer.email} |
+
+
+ Where |
+ ${this.getLocation()} |
+
+
+ Notes |
+ ${this.calEvent.description} |
+
+
+ ` +
+ this.getAdditionalBody() +
+ "
" +
+ `
+
+
+
+

+
+ `
+ );
+ }
+
+ /**
+ * Adds the video call information to the mail body.
+ *
+ * @protected
+ */
+ protected getLocation(): string {
+ if (this.additionInformation?.hangoutLink) {
+ return `${this.additionInformation?.hangoutLink}
`;
+ }
+
+ if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
+ const locations = this.additionInformation?.entryPoints
+ .map((entryPoint) => {
+ return `
+ Join by ${entryPoint.entryPointType}:
+ ${entryPoint.label}
+ `;
+ })
+ .join("
");
+
+ return `${locations}`;
+ }
+
+ return this.calEvent.location ? `${this.calEvent.location}
` : "";
+ }
+
+ protected getAdditionalBody(): string {
+ return `Pay now`;
+ }
+
+ /**
+ * Returns the payload object for the nodemailer.
+ *
+ * @protected
+ */
+ protected getNodeMailerPayload(): Record {
+ 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(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
+ }
+}
diff --git a/lib/hooks/useSlots.ts b/lib/hooks/useSlots.ts
index 74ae8e38..1cb09951 100644
--- a/lib/hooks/useSlots.ts
+++ b/lib/hooks/useSlots.ts
@@ -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,
});
diff --git a/lib/integrations.ts b/lib/integrations.ts
index 5c2754b6..a088affd 100644
--- a/lib/integrations.ts
+++ b/lib/integrations.ts
@@ -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";
}
diff --git a/lib/integrations/getIntegrations.ts b/lib/integrations/getIntegrations.ts
new file mode 100644
index 00000000..92e63f48
--- /dev/null
+++ b/lib/integrations/getIntegrations.ts
@@ -0,0 +1,73 @@
+import { validJson } from "@lib/jsonUtils";
+import { Prisma } from "@prisma/client";
+
+const credentialData = Prisma.validator()({
+ select: { id: true, type: true, key: true },
+});
+
+type CredentialData = Prisma.CredentialGetPayload;
+
+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, type: string): boolean {
+ return !!integrations.find((i) => i.type === type && !!i.installed && !!i.credential);
+}
+
+export default getIntegrations;
diff --git a/lib/mutations/bookings/create-booking.ts b/lib/mutations/bookings/create-booking.ts
new file mode 100644
index 00000000..727432b4
--- /dev/null
+++ b/lib/mutations/bookings/create-booking.ts
@@ -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("/api/book/event", data);
+
+ return response;
+};
+
+export default createBooking;
diff --git a/lib/types/booking.ts b/lib/types/booking.ts
new file mode 100644
index 00000000..52148c47
--- /dev/null
+++ b/lib/types/booking.ts
@@ -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;
+};
diff --git a/lib/videoClient.ts b/lib/videoClient.ts
index aa4b6c5d..3489bf2c 100644
--- a/lib/videoClient.ts
+++ b/lib/videoClient.ts
@@ -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;
- updateMeeting(uid: string, event: CalendarEvent);
+ updateMeeting(uid: string, event: CalendarEvent): Promise;
deleteMeeting(uid: string): Promise;
- getAvailability(dateFrom, dateTo): Promise;
+ getAvailability(dateFrom: string, dateTo: string): Promise;
}
-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) => {
- switch (cred.type) {
- case "zoom_video":
- return ZoomVideo(cred);
- default:
- return; // unknown credential, could be legacy? In any case, ignore
- }
- })
- .filter(Boolean);
+const videoIntegrations = (withCredentials: Credential[]): VideoApiAdapter[] =>
+ withCredentials.reduce((acc, cred) => {
+ switch (cred.type) {
+ case "zoom_video":
+ acc.push(ZoomVideo(cred));
+ break;
+ default:
+ break;
+ }
+ return acc;
+ }, []);
-const getBusyVideoTimes: (withCredentials) => Promise = (withCredentials) =>
+const getBusyVideoTimes: (withCredentials: Credential[]) => Promise = (withCredentials) =>
Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
results.reduce((acc, availability) => acc.concat(availability), [])
);
diff --git a/next.config.js b/next.config.js
index 38790f82..0b187194 100644
--- a/next.config.js
+++ b/next.config.js
@@ -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",
- },
});
diff --git a/package.json b/package.json
index 1234af71..9d82c073 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pages/[user].tsx b/pages/[user].tsx
index fabb9a08..37a21899 100644
--- a/pages/[user].tsx
+++ b/pages/[user].tsx
@@ -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,
});
diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx
index 932eb4c4..712549c6 100644
--- a/pages/[user]/[type].tsx
+++ b/pages/[user]/[type].tsx
@@ -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,
diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx
index 3b8f6af4..a89d7168 100644
--- a/pages/[user]/book.tsx
+++ b/pages/[user]/book.tsx
@@ -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;
+
+export default function Book(props: BookPageProps) {
return ;
}
-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,
diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts
index eac9d9ed..a133d78a 100644
--- a/pages/api/availability/[user].ts
+++ b/pages/api/availability/[user].ts
@@ -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,
diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts
index a6b336f4..8e809cf1 100644
--- a/pages/api/availability/eventtype.ts
+++ b/pages/api/availability/eventtype.ts
@@ -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) {
diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts
index 1dcf89bf..55d309b2 100644
--- a/pages/api/book/confirm.ts
+++ b/pages/api/book/confirm.ts
@@ -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 {
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,
diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts
index 268c9f21..cf28004b 100644
--- a/pages/api/book/event.ts
+++ b/pages/api/book/event.ts
@@ -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({
+ const userSelect = Prisma.validator()({
+ id: true,
+ email: true,
+ name: true,
+ username: true,
+ timeZone: true,
+ credentials: true,
+ bufferTime: true,
+ });
+
+ const userData = Prisma.validator()({
+ select: userSelect,
+ });
+
+ const eventType = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
select: {
users: {
- select: {
- id: true,
- email: true,
- name: true,
- username: true,
- timeZone: true,
- },
+ 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({
- where: {
- id: eventType.userId,
- },
- select: {
- id: true,
- email: true,
- name: true,
- username: true,
- timeZone: true,
- },
- })
- );
- }
+ if (!eventType) return res.status(404).json({ message: "eventType.notFound" });
- let users: User[] = eventType.users;
+ 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: userSelect,
+ });
+ if (!evenTypeUser) return res.status(404).json({ message: "eventTypeUser.notFound" });
+ users.push(evenTypeUser);
+ }
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({
- where: {
- username,
- },
- select: {
- bookings: {
- where: {
- startTime: {
- gt: new Date(),
- },
- },
- select: {
- id: true,
- },
+ const selectedUsers = reqBody.users || [];
+ const selectedUsersDataWithBookingsCount = await prisma.user.findMany({
+ where: {
+ username: { in: selectedUsers },
+ bookings: {
+ every: {
+ startTime: {
+ gt: new Date(),
},
},
- });
- 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)];
+ },
+ },
+ select: {
+ username: true,
+ _count: {
+ select: { bookings: true },
+ },
+ },
});
+
+ 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,123 +216,120 @@ 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 = {
- uid,
- title: evt.title,
- startTime: dayjs(evt.startTime).toDate(),
- endTime: dayjs(evt.endTime).toDate(),
- description: evt.description,
- confirmed: !eventType.requiresConfirmation || !!rescheduleUid,
- location: evt.location,
- eventType: {
- connect: {
- id: eventTypeId,
+ function createBooking() {
+ return prisma.booking.create({
+ include: {
+ user: {
+ select: { email: true, name: true, timeZone: true },
+ },
+ attendees: true,
},
- },
- attendees: {
- createMany: {
- data: evt.attendees,
+ data: {
+ uid,
+ title: evt.title,
+ startTime: dayjs(evt.startTime).toDate(),
+ endTime: dayjs(evt.endTime).toDate(),
+ description: evt.description,
+ confirmed: !eventType.requiresConfirmation || !!rescheduleUid,
+ location: evt.location,
+ eventType: {
+ connect: {
+ id: eventTypeId,
+ },
+ },
+ attendees: {
+ createMany: {
+ data: evt.attendees,
+ },
+ },
+ user: {
+ connect: {
+ id: users[0].id,
+ },
+ },
},
- },
- user: {
- connect: {
- id: users[0].id,
- },
- },
- };
-
- let booking: Booking | null;
- try {
- booking = await prisma.booking.create({
- data: bookingCreateInput,
});
+ }
+
+ type Booking = Prisma.PromiseReturnType;
+ 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 =>
- 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;
+ 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({
diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts
index 85892857..d9607e73 100644
--- a/pages/api/cancel.ts
+++ b/pages/api/cancel.ts
@@ -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,
diff --git a/pages/api/integrations/stripepayment/add.ts b/pages/api/integrations/stripepayment/add.ts
new file mode 100644
index 00000000..1ad56d5e
--- /dev/null
+++ b/pages/api/integrations/stripepayment/add.ts
@@ -0,0 +1 @@
+export { default } from "@ee/pages/api/integrations/stripepayment/add";
diff --git a/pages/api/integrations/stripepayment/callback.ts b/pages/api/integrations/stripepayment/callback.ts
new file mode 100644
index 00000000..49122d76
--- /dev/null
+++ b/pages/api/integrations/stripepayment/callback.ts
@@ -0,0 +1 @@
+export { default } from "@ee/pages/api/integrations/stripepayment/callback";
diff --git a/pages/api/integrations/stripepayment/webhook.ts b/pages/api/integrations/stripepayment/webhook.ts
new file mode 100644
index 00000000..5c37a033
--- /dev/null
+++ b/pages/api/integrations/stripepayment/webhook.ts
@@ -0,0 +1 @@
+export { default, config } from "@ee/pages/api/integrations/stripepayment/webhook";
diff --git a/pages/availability/troubleshoot.tsx b/pages/availability/troubleshoot.tsx
index 0d215d77..c471b6f0 100644
--- a/pages/availability/troubleshoot.tsx
+++ b/pages/availability/troubleshoot.tsx
@@ -33,7 +33,7 @@ export default function Troubleshoot({ user }) {
return res.json();
})
.then((availableIntervals) => {
- setAvailability(availableIntervals);
+ setAvailability(availableIntervals.busy);
setLoading(false);
})
.catch((e) => {
diff --git a/pages/event-types/[type].tsx b/pages/event-types/[type].tsx
index 16aa2b3d..c8c13bda 100644
--- a/pages/event-types/[type].tsx
+++ b/pages/event-types/[type].tsx
@@ -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) => {
- 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) => {
PERIOD_TYPES.find((s) => s.type === "unlimited")
);
});
+ const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const [hidden, setHidden] = useState(eventType.hidden);
+
const titleRef = useRef(null);
const slugRef = useRef(null);
const requiresConfirmationRef = useRef(null);
const eventNameRef = useRef(null);
const periodDaysRef = useRef(null);
const periodDaysTypeRef = useRef(null);
+ const priceRef = useRef(null);
useEffect(() => {
setSelectedTimeZone(eventType.timeZone);
@@ -192,6 +198,7 @@ const EventTypePage = (props: inferSSRProps) => {
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) => {
users,
}
: {}),
+ price: enteredPrice,
+ currency: currency,
};
updateMutation.mutate(payload);
@@ -861,6 +870,90 @@ const EventTypePage = (props: inferSSRProps) => {
/>
+
+ {hasPaymentIntegration && (
+ <>
+