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" )}>
- + View public page
@@ -309,25 +312,6 @@ function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean }) )} - - {({ active }) => ( - - - )} -
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 && ( +

+ + + + +

+ )} diff --git a/components/booking/pages/BookingPage.tsx b/components/booking/pages/BookingPage.tsx index 295d3a6c..a5d810ac 100644 --- a/components/booking/pages/BookingPage.tsx +++ b/components/booking/pages/BookingPage.tsx @@ -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 (payload["location"]) { - if (payload["location"].includes("integration")) { - successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow."); - } else { - successUrl += "&location=" + encodeURIComponent(payload["location"]); - } - } - await router.push(successUrl); + 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")) { + params.location = "Web conferencing details to follow."; + } else { + 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 && (
@@ -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 ( +
    + +
    + +
    + {state.status === "error" && ( +
    + {state.error.message} +
    + )} + + ); +} 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 + + +
    +
    +
    + +
    +
    +
    +
    + ) : 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() + + "
    " + + ` +
    +
    +
    + Cal.com Logo
    + + ` + ); + } + + /** + * 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 && ( + <> +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + 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} + /> +
    +
    +

    + Require Payment (0.5% +{" "} + + + {" "} + commission per transaction) +

    +
    +
    +
    +
    +
    + {requirePayment && ( +
    +
    +
    +
    + 0 ? eventType.price / 100.0 : undefined + } + /> +
    + + {new Intl.NumberFormat("en", { + style: "currency", + currency: currency, + maximumSignificantDigits: 1, + maximumFractionDigits: 0, + }) + .format(0) + .replace("0", "")} + +
    +
    +
    +
    +
    + )} +
    +
    + + )} )} @@ -899,8 +992,7 @@ const EventTypePage = (props: inferSSRProps) => {