Zomars/cal 748 paid bookings are failing (#1335)

* E2E video adjustments

* Adds test to add Stripe integration

* Type fix

* WIP: Payment troubleshooting

* Paid bookings shouldn't be confirmed by default

* Runs stripe test only if installed

* BookingListItem Adjustments

* Pending paid bookings should be unconfirmed

* Attempt to fix paid bookings

* Type fixes

* Type fixes

* Tests fixes

* Adds paid booking to seeder

* Moves stripe tests to own file

* Matches app locale to Stripe's

* Fixes minimun price for testing

* Stripe test fixes

* Fixes stripe frame test

* Added some Stripe TODOs
This commit is contained in:
Omar López 2021-12-17 09:58:23 -07:00 committed by GitHub
parent ca405743fb
commit 21103580f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 201 additions and 51 deletions

View file

@ -12,7 +12,13 @@ jobs:
GOOGLE_API_CREDENTIALS: "{}" GOOGLE_API_CREDENTIALS: "{}"
# GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} # GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
# CRON_API_KEY: xxx # 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 # NEXTAUTH_URL: xxx
# EMAIL_FROM: xxx # EMAIL_FROM: xxx
# EMAIL_SERVER_HOST: xxx # EMAIL_SERVER_HOST: xxx
@ -61,8 +67,6 @@ jobs:
- run: yarn db-seed - run: yarn db-seed
- run: yarn test - run: yarn test
- run: yarn build - run: yarn build
- run: yarn start &
- run: npx wait-port 3000 --timeout 10000
- name: Cache playwright binaries - name: Cache playwright binaries
uses: actions/cache@v2 uses: actions/cache@v2

1
.gitignore vendored
View file

@ -37,6 +37,7 @@ yarn-error.log*
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.*
# vercel # vercel
.vercel .vercel

View file

@ -9,7 +9,7 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
import TableActions, { ActionType } from "@components/ui/TableActions"; import TableActions, { ActionType } from "@components/ui/TableActions";
type BookingItem = inferQueryOutput<"viewer.bookings">[number]; type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
function BookingListItem(booking: BookingItem) { function BookingListItem(booking: BookingItem) {
const { t, i18n } = useLocale(); 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"); const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
return ( return (
<tr> <tr className="flex">
<td className="hidden px-6 py-4 align-top sm:table-cell whitespace-nowrap"> <td className="hidden px-6 py-4 align-top sm:table-cell whitespace-nowrap">
<div className="text-sm leading-6 text-gray-900">{startTime}</div> <div className="text-sm leading-6 text-gray-900">{startTime}</div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")} {dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</div> </div>
</td> </td>
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}> <td className={"px-6 py-4 flex-1" + (booking.rejected ? " line-through" : "")}>
<div className="sm:hidden"> <div className="sm:hidden">
{!booking.confirmed && !booking.rejected && ( {!booking.confirmed && !booking.rejected && <Tag className="mb-2 mr-2">{t("unconfirmed")}</Tag>}
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800"> {!!booking?.eventType?.price && !booking.paid && <Tag className="mb-2 mr-2">Pending payment</Tag>}
{t("unconfirmed")}
</span>
)}
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{startTime}:{" "} {startTime}:{" "}
<small className="text-sm text-gray-500"> <small className="text-sm text-gray-500">
@ -94,13 +91,14 @@ function BookingListItem(booking: BookingItem) {
</small> </small>
</div> </div>
</div> </div>
<div className="text-sm font-medium leading-6 truncate text-neutral-900 max-w-52 md:max-w-96"> <div className="text-sm font-medium leading-6 truncate text-neutral-900 max-w-52 md:max-w-max">
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>} {booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
{booking.title} {booking.title}
{!!booking?.eventType?.price && !booking.paid && (
<Tag className="hidden ml-2 sm:inline-flex">Pending payment</Tag>
)}
{!booking.confirmed && !booking.rejected && ( {!booking.confirmed && !booking.rejected && (
<span className="ml-2 hidden sm:inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800"> <Tag className="hidden ml-2 sm:inline-flex">{t("unconfirmed")}</Tag>
{t("unconfirmed")}
</span>
)} )}
</div> </div>
{booking.description && ( {booking.description && (
@ -130,4 +128,13 @@ function BookingListItem(booking: BookingItem) {
); );
} }
const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => {
return (
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800 ${className}`}>
{children}
</span>
);
};
export default BookingListItem; export default BookingListItem;

View file

@ -115,7 +115,7 @@ function ConnectedCalendarsList(props: Props) {
<DisconnectIntegration <DisconnectIntegration
id={item.credentialId} id={item.credentialId}
render={(btnProps) => ( render={(btnProps) => (
<Button {...btnProps} color="warn"> <Button {...btnProps} color="warn" data-testid="integration-connection-button">
{t("disconnect")} {t("disconnect")}
</Button> </Button>
)} )}
@ -143,7 +143,7 @@ function ConnectedCalendarsList(props: Props) {
<DisconnectIntegration <DisconnectIntegration
id={item.credentialId} id={item.credentialId}
render={(btnProps) => ( render={(btnProps) => (
<Button {...btnProps} color="warn"> <Button {...btnProps} color="warn" data-testid="integration-connection-button">
Disconnect Disconnect
</Button> </Button>
)} )}
@ -248,7 +248,7 @@ function CalendarList(props: Props) {
<ConnectIntegration <ConnectIntegration
type={item.type} type={item.type}
render={(btnProps) => ( render={(btnProps) => (
<Button color="secondary" {...btnProps}> <Button color="secondary" {...btnProps} data-testid="integration-connection-button">
{t("connect")} {t("connect")}
</Button> </Button>
)} )}

View file

@ -1,9 +1,8 @@
import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { CardElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { StripeCardElementChangeEvent } from "@stripe/stripe-js"; import stripejs, { StripeCardElementChangeEvent, StripeElementLocale } from "@stripe/stripe-js";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { stringify } from "querystring"; import { stringify } from "querystring";
import React, { useState } from "react"; import React, { SyntheticEvent, useEffect, useState } from "react";
import { SyntheticEvent } from "react";
import { PaymentData } from "@ee/lib/stripe/server"; import { PaymentData } from "@ee/lib/stripe/server";
@ -12,7 +11,7 @@ import { useLocale } from "@lib/hooks/useLocale";
import Button from "@components/ui/Button"; import Button from "@components/ui/Button";
const CARD_OPTIONS = { const CARD_OPTIONS: stripejs.StripeCardElementOptions = {
iconStyle: "solid" as const, iconStyle: "solid" as const,
classes: { 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", 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 = { type Props = {
payment: { payment: {
@ -47,18 +46,23 @@ type States =
| { status: "ok" }; | { status: "ok" };
export default function PaymentComponent(props: Props) { export default function PaymentComponent(props: Props) {
const { t } = useLocale(); const { t, i18n } = useLocale();
const router = useRouter(); const router = useRouter();
const { name, date } = router.query; const { name, date } = router.query;
const [state, setState] = useState<States>({ status: "idle" }); const [state, setState] = useState<States>({ status: "idle" });
const stripe = useStripe(); const stripe = useStripe();
const elements = useElements(); const elements = useElements();
const { isDarkMode } = useDarkMode(); const { isDarkMode } = useDarkMode();
useEffect(() => {
elements?.update({ locale: i18n.language as StripeElementLocale });
}, [elements, i18n.language]);
if (isDarkMode) { if (isDarkMode) {
CARD_OPTIONS.style.base.color = "#fff"; CARD_OPTIONS.style!.base!.color = "#fff";
CARD_OPTIONS.style.base.iconColor = "#fff"; CARD_OPTIONS.style!.base!.iconColor = "#fff";
CARD_OPTIONS.style.base["::placeholder"].color = "#fff"; CARD_OPTIONS.style!.base!["::placeholder"]!.color = "#fff";
} }
const handleChange = async (event: StripeCardElementChangeEvent) => { const handleChange = async (event: StripeCardElementChangeEvent) => {

View file

@ -64,7 +64,11 @@ export async function handlePayment(
data: { data: {
type: PaymentType.STRIPE, type: PaymentType.STRIPE,
uid: uuidv4(), uid: uuidv4(),
bookingId: booking.id, booking: {
connect: {
id: booking.id,
},
},
amount: selectedEventType.price, amount: selectedEventType.price,
fee: paymentFee, fee: paymentFee,
currency: selectedEventType.currency, currency: selectedEventType.currency,

View file

@ -6,7 +6,7 @@ import stripe from "@ee/lib/stripe/server";
import { CalendarEvent } from "@lib/calendarClient"; import { CalendarEvent } from "@lib/calendarClient";
import { IS_PRODUCTION } from "@lib/config/constants"; 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 { getErrorFromUnknown } from "@lib/errors";
import EventManager from "@lib/events/EventManager"; import EventManager from "@lib/events/EventManager";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
@ -31,6 +31,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
booking: { booking: {
update: { update: {
paid: true, paid: true,
confirmed: true,
}, },
}, },
}, },
@ -97,7 +98,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
await prisma.booking.update({ await prisma.booking.update({
where: { where: {
id: payment.bookingId, id: booking.id,
}, },
data: { data: {
references: { 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<void>; type WebhookHandler = (event: Stripe.Event) => Promise<void>;
@ -117,15 +123,15 @@ const webhookHandlers: Record<string, WebhookHandler | undefined> = {
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try { try {
if (req.method !== "POST") { 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"]; const sig = req.headers["stripe-signature"];
if (!sig) { 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) { 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 requestBuffer = await buffer(req);
const payload = requestBuffer.toString(); const payload = requestBuffer.toString();
@ -137,7 +143,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await handler(event); await handler(event);
} else { } else {
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */ /** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpError({ throw new HttpCode({
statusCode: 202, statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`, message: `Unhandled Stripe Webhook event type ${event.type}`,
}); });

View file

@ -1,7 +1,11 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import _ from "lodash"; 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<Prisma.CredentialArgs>()({ const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
select: { id: true, type: true }, 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) (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; export default getIntegrations;

View file

@ -5,8 +5,15 @@ import { getSession } from "@lib/auth";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { WorkingHours } from "@lib/types/schedule"; import { WorkingHours } from "@lib/types/schedule";
function handlePeriodType(periodType: string): PeriodType { function isPeriodType(keyInput: string): keyInput is PeriodType {
return PeriodType[periodType.toUpperCase()]; 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) { 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 = { const data: Prisma.EventTypeCreateInput | Prisma.EventTypeUpdateInput = {
title: req.body.title, title: req.body.title,
slug: req.body.slug.trim(), slug: req.body.slug.trim(),
@ -116,7 +123,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
locations: req.body.locations, locations: req.body.locations,
eventName: req.body.eventName, eventName: req.body.eventName,
customInputs: handleCustomInputs(req.body.customInputs as EventTypeCustomInput[], req.body.id), 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, periodDays: req.body.periodDays,
periodStartDate: req.body.periodStartDate, periodStartDate: req.body.periodStartDate,
periodEndDate: req.body.periodEndDate, periodEndDate: req.body.periodEndDate,
@ -179,8 +186,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
const availabilityToCreate = openingHours.map((openingHour) => ({ const availabilityToCreate = openingHours.map((openingHour) => ({
startTime: openingHour.startTime, startTime: new Date(openingHour.startTime),
endTime: openingHour.endTime, endTime: new Date(openingHour.endTime),
days: openingHour.days, days: openingHour.days,
})); }));

View file

@ -198,6 +198,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
const eventType = await prisma.eventType.findUnique({ const eventType = await prisma.eventType.findUnique({
rejectOnNotFound: true,
where: { where: {
id: eventTypeId, id: eventTypeId,
}, },
@ -331,7 +332,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
startTime: dayjs(evt.startTime).toDate(), startTime: dayjs(evt.startTime).toDate(),
endTime: dayjs(evt.endTime).toDate(), endTime: dayjs(evt.endTime).toDate(),
description: evt.description, description: evt.description,
confirmed: !eventType?.requiresConfirmation || !!rescheduleUid, confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
location: evt.location, location: evt.location,
eventType: { eventType: {
connect: { connect: {

View file

@ -1,10 +1,12 @@
import { NextPageContext } from "next";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
function RedirectPage() { function RedirectPage() {
return null; return null;
} }
export async function getServerSideProps(context) { export async function getServerSideProps(context: NextPageContext) {
const session = await getSession(context); const session = await getSession(context);
if (!session?.user?.id) { if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } }; return { redirect: { permanent: false, destination: "/auth/login" } };

View file

@ -461,7 +461,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
<DisconnectIntegration <DisconnectIntegration
id={credentialId} id={credentialId}
render={(btnProps) => ( render={(btnProps) => (
<Button {...btnProps} color="warn"> <Button {...btnProps} color="warn" data-testid="integration-connection-button">
{t("disconnect")} {t("disconnect")}
</Button> </Button>
)} )}
@ -488,7 +488,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
<ConnectIntegration <ConnectIntegration
type={props.type} type={props.type}
render={(btnProps) => ( render={(btnProps) => (
<Button color="secondary" {...btnProps}> <Button color="secondary" {...btnProps} data-testid="integration-connection-button">
{t("connect")} {t("connect")}
</Button> </Button>
)} )}

View file

@ -5,12 +5,20 @@ const config: PlaywrightTestConfig = {
testDir: "playwright", testDir: "playwright",
timeout: 60_000, timeout: 60_000,
retries: process.env.CI ? 3 : 0, retries: process.env.CI ? 3 : 0,
reporter: "list",
globalSetup: require.resolve("./playwright/lib/globalSetup"), globalSetup: require.resolve("./playwright/lib/globalSetup"),
webServer: {
command: "yarn start",
port: 3000,
timeout: 60_000,
reuseExistingServer: !process.env.CI,
},
use: { use: {
baseURL: "http://localhost:3000", baseURL: "http://localhost:3000",
locale: "en-US", locale: "en-US",
trace: "on-first-retry", trace: "retain-on-failure",
headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS, headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
video: "on-first-retry",
contextOptions: { contextOptions: {
recordVideo: { recordVideo: {
dir: "playwright/videos", dir: "playwright/videos",

View file

@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { todo } from "./lib/testUtils"; import { todo } from "./lib/testUtils";

View file

@ -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");
});

View file

@ -11,8 +11,6 @@ test.describe("integrations", () => {
todo("Can add Zoom integration"); todo("Can add Zoom integration");
todo("Can add Stripe integration");
todo("Can add Google Calendar"); todo("Can add Google Calendar");
todo("Can add Office 365 Calendar"); todo("Can add Office 365 Calendar");

View file

@ -2,7 +2,7 @@ import { Browser, chromium } from "@playwright/test";
async function loginAsUser(username: string, browser: Browser) { async function loginAsUser(username: string, browser: Browser) {
const page = await browser.newPage(); 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"] // Click input[name="email"]
await page.click('input[name="email"]'); await page.click('input[name="email"]');
// Fill input[name="email"] // Fill input[name="email"]

View file

@ -223,6 +223,12 @@ async function main() {
slug: "60min", slug: "60min",
length: 60, length: 60,
}, },
{
title: "paid",
slug: "paid",
length: 60,
price: 50,
},
], ],
}); });

View file

@ -334,6 +334,7 @@ const loggedInViewerRouter = createProtectedRouter()
endTime: true, endTime: true,
eventType: { eventType: {
select: { select: {
price: true,
team: { team: {
select: { select: {
name: true, name: true,
@ -342,6 +343,7 @@ const loggedInViewerRouter = createProtectedRouter()
}, },
}, },
status: true, status: true,
paid: true,
}, },
orderBy, orderBy,
take: take + 1, take: take + 1,