diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0450021083..1c9e915164 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,7 +12,13 @@ jobs: GOOGLE_API_CREDENTIALS: "{}" # GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} # CRON_API_KEY: xxx - # CALENDSO_ENCRYPTION_KEY: xxx + CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} + NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }} + STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} + STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} + PAYMENT_FEE_PERCENTAGE: 0.005 + PAYMENT_FEE_FIXED: 10 # NEXTAUTH_URL: xxx # EMAIL_FROM: xxx # EMAIL_SERVER_HOST: xxx @@ -61,8 +67,6 @@ jobs: - run: yarn db-seed - run: yarn test - run: yarn build - - run: yarn start & - - run: npx wait-port 3000 --timeout 10000 - name: Cache playwright binaries uses: actions/cache@v2 diff --git a/.gitignore b/.gitignore index 15f20eea27..055a53d1e8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ yarn-error.log* .env.development.local .env.test.local .env.production.local +.env.* # vercel .vercel diff --git a/components/booking/BookingListItem.tsx b/components/booking/BookingListItem.tsx index 3f218396b0..63df6f8a57 100644 --- a/components/booking/BookingListItem.tsx +++ b/components/booking/BookingListItem.tsx @@ -9,7 +9,7 @@ import { inferQueryOutput, trpc } from "@lib/trpc"; import TableActions, { ActionType } from "@components/ui/TableActions"; -type BookingItem = inferQueryOutput<"viewer.bookings">[number]; +type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number]; function BookingListItem(booking: BookingItem) { const { t, i18n } = useLocale(); @@ -73,20 +73,17 @@ function BookingListItem(booking: BookingItem) { const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); return ( - +
{startTime}
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
- +
- {!booking.confirmed && !booking.rejected && ( - - {t("unconfirmed")} - - )} + {!booking.confirmed && !booking.rejected && {t("unconfirmed")}} + {!!booking?.eventType?.price && !booking.paid && Pending payment}
{startTime}:{" "} @@ -94,13 +91,14 @@ function BookingListItem(booking: BookingItem) {
-
+
{booking.eventType?.team && {booking.eventType.team.name}: } {booking.title} + {!!booking?.eventType?.price && !booking.paid && ( + Pending payment + )} {!booking.confirmed && !booking.rejected && ( - - {t("unconfirmed")} - + {t("unconfirmed")} )}
{booking.description && ( @@ -130,4 +128,13 @@ function BookingListItem(booking: BookingItem) { ); } +const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => { + return ( + + {children} + + ); +}; + export default BookingListItem; diff --git a/components/integrations/CalendarListContainer.tsx b/components/integrations/CalendarListContainer.tsx index 0dbc480282..23e7579ab8 100644 --- a/components/integrations/CalendarListContainer.tsx +++ b/components/integrations/CalendarListContainer.tsx @@ -115,7 +115,7 @@ function ConnectedCalendarsList(props: Props) { ( - )} @@ -143,7 +143,7 @@ function ConnectedCalendarsList(props: Props) { ( - )} @@ -248,7 +248,7 @@ function CalendarList(props: Props) { ( - )} diff --git a/ee/components/stripe/Payment.tsx b/ee/components/stripe/Payment.tsx index 74e5296e74..a2cd74116f 100644 --- a/ee/components/stripe/Payment.tsx +++ b/ee/components/stripe/Payment.tsx @@ -1,9 +1,8 @@ -import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js"; -import { StripeCardElementChangeEvent } from "@stripe/stripe-js"; +import { CardElement, useElements, useStripe } from "@stripe/react-stripe-js"; +import stripejs, { StripeCardElementChangeEvent, StripeElementLocale } from "@stripe/stripe-js"; import { useRouter } from "next/router"; import { stringify } from "querystring"; -import React, { useState } from "react"; -import { SyntheticEvent } from "react"; +import React, { SyntheticEvent, useEffect, useState } from "react"; import { PaymentData } from "@ee/lib/stripe/server"; @@ -12,7 +11,7 @@ import { useLocale } from "@lib/hooks/useLocale"; import Button from "@components/ui/Button"; -const CARD_OPTIONS = { +const CARD_OPTIONS: stripejs.StripeCardElementOptions = { 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-black focus-within:ring-black focus-within:border-black sm:text-sm", @@ -29,7 +28,7 @@ const CARD_OPTIONS = { }, }, }, -}; +} as const; type Props = { payment: { @@ -47,18 +46,23 @@ type States = | { status: "ok" }; export default function PaymentComponent(props: Props) { - const { t } = useLocale(); + const { t, i18n } = useLocale(); const router = useRouter(); const { name, date } = router.query; const [state, setState] = useState({ status: "idle" }); const stripe = useStripe(); const elements = useElements(); + const { isDarkMode } = useDarkMode(); + useEffect(() => { + elements?.update({ locale: i18n.language as StripeElementLocale }); + }, [elements, i18n.language]); + if (isDarkMode) { - CARD_OPTIONS.style.base.color = "#fff"; - CARD_OPTIONS.style.base.iconColor = "#fff"; - CARD_OPTIONS.style.base["::placeholder"].color = "#fff"; + CARD_OPTIONS.style!.base!.color = "#fff"; + CARD_OPTIONS.style!.base!.iconColor = "#fff"; + CARD_OPTIONS.style!.base!["::placeholder"]!.color = "#fff"; } const handleChange = async (event: StripeCardElementChangeEvent) => { diff --git a/ee/lib/stripe/server.ts b/ee/lib/stripe/server.ts index 979396c817..e39b9dd285 100644 --- a/ee/lib/stripe/server.ts +++ b/ee/lib/stripe/server.ts @@ -64,7 +64,11 @@ export async function handlePayment( data: { type: PaymentType.STRIPE, uid: uuidv4(), - bookingId: booking.id, + booking: { + connect: { + id: booking.id, + }, + }, amount: selectedEventType.price, fee: paymentFee, currency: selectedEventType.currency, diff --git a/ee/pages/api/integrations/stripepayment/webhook.ts b/ee/pages/api/integrations/stripepayment/webhook.ts index 126f699857..c5f9162d1a 100644 --- a/ee/pages/api/integrations/stripepayment/webhook.ts +++ b/ee/pages/api/integrations/stripepayment/webhook.ts @@ -6,7 +6,7 @@ import stripe from "@ee/lib/stripe/server"; import { CalendarEvent } from "@lib/calendarClient"; import { IS_PRODUCTION } from "@lib/config/constants"; -import { HttpError } from "@lib/core/http/error"; +import { HttpError as HttpCode } from "@lib/core/http/error"; import { getErrorFromUnknown } from "@lib/errors"; import EventManager from "@lib/events/EventManager"; import prisma from "@lib/prisma"; @@ -31,6 +31,7 @@ async function handlePaymentSuccess(event: Stripe.Event) { booking: { update: { paid: true, + confirmed: true, }, }, }, @@ -97,7 +98,7 @@ async function handlePaymentSuccess(event: Stripe.Event) { await prisma.booking.update({ where: { - id: payment.bookingId, + id: booking.id, }, data: { references: { @@ -106,6 +107,11 @@ async function handlePaymentSuccess(event: Stripe.Event) { }, }); } + + throw new HttpCode({ + statusCode: 200, + message: `Booking with id '${booking.id}' was paid and confirmed.`, + }); } type WebhookHandler = (event: Stripe.Event) => Promise; @@ -117,15 +123,15 @@ const webhookHandlers: Record = { export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { if (req.method !== "POST") { - throw new HttpError({ statusCode: 405, message: "Method Not Allowed" }); + throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" }); } const sig = req.headers["stripe-signature"]; if (!sig) { - throw new HttpError({ statusCode: 400, message: "Missing stripe-signature" }); + throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" }); } if (!process.env.STRIPE_WEBHOOK_SECRET) { - throw new HttpError({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" }); + throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" }); } const requestBuffer = await buffer(req); const payload = requestBuffer.toString(); @@ -137,7 +143,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await handler(event); } else { /** Not really an error, just letting Stripe know that the webhook was received but unhandled */ - throw new HttpError({ + throw new HttpCode({ statusCode: 202, message: `Unhandled Stripe Webhook event type ${event.type}`, }); diff --git a/lib/integrations/getIntegrations.ts b/lib/integrations/getIntegrations.ts index 8d9b1a8aad..9ebedd67be 100644 --- a/lib/integrations/getIntegrations.ts +++ b/lib/integrations/getIntegrations.ts @@ -1,7 +1,11 @@ import { Prisma } from "@prisma/client"; import _ from "lodash"; -import { validJson } from "@lib/jsonUtils"; +/** + * We can't use aliases in playwright tests (yet) + * https://github.com/microsoft/playwright/issues/7121 + */ +import { validJson } from "../../lib/jsonUtils"; const credentialData = Prisma.validator()({ select: { id: true, type: true }, @@ -115,5 +119,8 @@ export function hasIntegration(integrations: IntegrationMeta, type: string): boo (i) => i.type === type && !!i.installed && (type === "daily_video" || i.credentials.length > 0) ); } +export function hasIntegrationInstalled(type: Integration["type"]): boolean { + return ALL_INTEGRATIONS.some((i) => i.type === type && !!i.installed); +} export default getIntegrations; diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts index 5b8f4af700..a17e831981 100644 --- a/pages/api/availability/eventtype.ts +++ b/pages/api/availability/eventtype.ts @@ -5,8 +5,15 @@ import { getSession } from "@lib/auth"; import prisma from "@lib/prisma"; import { WorkingHours } from "@lib/types/schedule"; -function handlePeriodType(periodType: string): PeriodType { - return PeriodType[periodType.toUpperCase()]; +function isPeriodType(keyInput: string): keyInput is PeriodType { + return Object.keys(PeriodType).includes(keyInput); +} + +function handlePeriodType(periodType: string): PeriodType | undefined { + if (typeof periodType !== "string") return undefined; + const passedPeriodType = periodType.toUpperCase(); + if (!isPeriodType(passedPeriodType)) return undefined; + return PeriodType[passedPeriodType]; } function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) { @@ -104,7 +111,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } - if (req.method == "PATCH" || req.method == "POST") { + if (req.method === "PATCH" || req.method === "POST") { const data: Prisma.EventTypeCreateInput | Prisma.EventTypeUpdateInput = { title: req.body.title, slug: req.body.slug.trim(), @@ -116,7 +123,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) locations: req.body.locations, eventName: req.body.eventName, customInputs: handleCustomInputs(req.body.customInputs as EventTypeCustomInput[], req.body.id), - periodType: req.body.periodType ? handlePeriodType(req.body.periodType) : undefined, + periodType: handlePeriodType(req.body.periodType), periodDays: req.body.periodDays, periodStartDate: req.body.periodStartDate, periodEndDate: req.body.periodEndDate, @@ -179,8 +186,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const availabilityToCreate = openingHours.map((openingHour) => ({ - startTime: openingHour.startTime, - endTime: openingHour.endTime, + startTime: new Date(openingHour.startTime), + endTime: new Date(openingHour.endTime), days: openingHour.days, })); diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index 23f08cc6fc..af7a693ccd 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -198,6 +198,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const eventType = await prisma.eventType.findUnique({ + rejectOnNotFound: true, where: { id: eventTypeId, }, @@ -331,7 +332,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) startTime: dayjs(evt.startTime).toDate(), endTime: dayjs(evt.endTime).toDate(), description: evt.description, - confirmed: !eventType?.requiresConfirmation || !!rescheduleUid, + confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid, location: evt.location, eventType: { connect: { diff --git a/pages/bookings/index.tsx b/pages/bookings/index.tsx index f63871c1c9..3dcf15eb16 100644 --- a/pages/bookings/index.tsx +++ b/pages/bookings/index.tsx @@ -1,10 +1,12 @@ +import { NextPageContext } from "next"; + import { getSession } from "@lib/auth"; function RedirectPage() { return null; } -export async function getServerSideProps(context) { +export async function getServerSideProps(context: NextPageContext) { const session = await getSession(context); if (!session?.user?.id) { return { redirect: { permanent: false, destination: "/auth/login" } }; diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index 69176fc5af..3b01b369e0 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -461,7 +461,7 @@ function ConnectOrDisconnectIntegrationButton(props: { ( - )} @@ -488,7 +488,7 @@ function ConnectOrDisconnectIntegrationButton(props: { ( - )} diff --git a/playwright.config.ts b/playwright.config.ts index 91390539bc..665287763c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,12 +5,20 @@ const config: PlaywrightTestConfig = { testDir: "playwright", timeout: 60_000, retries: process.env.CI ? 3 : 0, + reporter: "list", globalSetup: require.resolve("./playwright/lib/globalSetup"), + webServer: { + command: "yarn start", + port: 3000, + timeout: 60_000, + reuseExistingServer: !process.env.CI, + }, use: { baseURL: "http://localhost:3000", locale: "en-US", - trace: "on-first-retry", + trace: "retain-on-failure", headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS, + video: "on-first-retry", contextOptions: { recordVideo: { dir: "playwright/videos", diff --git a/playwright/booking-pages.test.ts b/playwright/booking-pages.test.ts index a5e43b845b..88eeae21a6 100644 --- a/playwright/booking-pages.test.ts +++ b/playwright/booking-pages.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { todo } from "./lib/testUtils"; diff --git a/playwright/integrations-stripe.test.ts b/playwright/integrations-stripe.test.ts new file mode 100644 index 0000000000..44ddab37cd --- /dev/null +++ b/playwright/integrations-stripe.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "@playwright/test"; + +import { hasIntegrationInstalled } from "../lib/integrations/getIntegrations"; +import { todo } from "./lib/testUtils"; + +test.describe.serial("Stripe integration", () => { + test.skip(!hasIntegrationInstalled("stripe_payment"), "It should only run if Stripe is installed"); + + test.describe.serial("Stripe integration dashboard", () => { + test.use({ storageState: "playwright/artifacts/proStorageState.json" }); + + test("Can add Stripe integration", async ({ page }) => { + await page.goto("/integrations"); + /** We should see the "Connect" button for Stripe */ + expect(page.locator(`li:has-text("Stripe") >> [data-testid="integration-connection-button"]`)) + .toContainText("Connect") + .catch(() => { + console.error( + `Make sure Stripe it's properly installed and that an integration hasn't been already added.` + ); + }); + + /** We start the Stripe flow */ + await Promise.all([ + page.waitForNavigation({ url: "https://connect.stripe.com/oauth/v2/authorize?*" }), + await page.click('li:has-text("Stripe") >> [data-testid="integration-connection-button"]'), + ]); + + await Promise.all([ + page.waitForNavigation({ url: "/integrations" }), + /** We skip filling Stripe forms (testing mode only) */ + await page.click('[id="skip-account-app"]'), + ]); + + /** If Stripe is added correctly we should see the "Disconnect" button */ + expect( + page.locator(`li:has-text("Stripe") >> [data-testid="integration-connection-button"]`) + ).toContainText("Disconnect"); + }); + }); + + test("Can book a paid booking", async ({ page }) => { + await page.goto("/pro/paid"); + // Click [data-testid="incrementMonth"] + await page.click('[data-testid="incrementMonth"]'); + // Click [data-testid="day"] + await page.click('[data-testid="day"][data-disabled="false"]'); + // Click [data-testid="time"] + await page.click('[data-testid="time"]'); + // --- fill form + await page.fill('[name="name"]', "Stripe Stripeson"); + await page.fill('[name="email"]', "test@example.com"); + + await Promise.all([ + page.waitForNavigation({ url: "/payment/*" }), + await page.press('[name="email"]', "Enter"), + ]); + + await page.waitForSelector('iframe[src^="https://js.stripe.com/v3/elements-inner-card-"]'); + + // We lookup Stripe's iframe + const stripeFrame = page.frame({ + url: (url) => url.href.startsWith("https://js.stripe.com/v3/elements-inner-card-"), + }); + + if (!stripeFrame) throw new Error("Stripe frame not found"); + + // Fill [placeholder="Card number"] + await stripeFrame.fill('[placeholder="Card number"]', "4242 4242 4242 4242"); + // Fill [placeholder="MM / YY"] + await stripeFrame.fill('[placeholder="MM / YY"]', "12 / 24"); + // Fill [placeholder="CVC"] + await stripeFrame.fill('[placeholder="CVC"]', "111"); + // Fill [placeholder="ZIP"] + await stripeFrame.fill('[placeholder="ZIP"]', "111111"); + // Click button:has-text("Pay now") + await page.click('button:has-text("Pay now")'); + + // Make sure we're navigated to the success page + await page.waitForNavigation({ + url(url) { + return url.pathname.endsWith("/success"); + }, + }); + }); + + todo("Pending payment booking should not be confirmed by default"); + todo("Payment should confirm pending payment booking"); + todo("Payment should trigger a BOOKING_PAID webhook"); + todo("Paid booking should be able to be rescheduled"); + todo("Paid booking should be able to be cancelled"); + todo("Cancelled paid booking should be refunded"); +}); diff --git a/playwright/integrations.test.ts b/playwright/integrations.test.ts index c5ebf4c09c..8654aa6335 100644 --- a/playwright/integrations.test.ts +++ b/playwright/integrations.test.ts @@ -11,8 +11,6 @@ test.describe("integrations", () => { todo("Can add Zoom integration"); - todo("Can add Stripe integration"); - todo("Can add Google Calendar"); todo("Can add Office 365 Calendar"); diff --git a/playwright/lib/globalSetup.ts b/playwright/lib/globalSetup.ts index 576a271b14..273c6b0345 100644 --- a/playwright/lib/globalSetup.ts +++ b/playwright/lib/globalSetup.ts @@ -2,7 +2,7 @@ import { Browser, chromium } from "@playwright/test"; async function loginAsUser(username: string, browser: Browser) { const page = await browser.newPage(); - await page.goto("http://localhost:3000/auth/login"); + await page.goto(`${process.env.PLAYWRIGHT_TEST_BASE_URL}/auth/login`); // Click input[name="email"] await page.click('input[name="email"]'); // Fill input[name="email"] diff --git a/scripts/seed.ts b/scripts/seed.ts index e75b453a39..a4f0bf4cc6 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -223,6 +223,12 @@ async function main() { slug: "60min", length: 60, }, + { + title: "paid", + slug: "paid", + length: 60, + price: 50, + }, ], }); diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index 4a249a3fe3..e0f57638a9 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -334,6 +334,7 @@ const loggedInViewerRouter = createProtectedRouter() endTime: true, eventType: { select: { + price: true, team: { select: { name: true, @@ -342,6 +343,7 @@ const loggedInViewerRouter = createProtectedRouter() }, }, status: true, + paid: true, }, orderBy, take: take + 1,