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
pull/1335/merge
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: ${{ 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

1
.gitignore vendored
View File

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

View File

@ -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 (
<tr>
<tr className="flex">
<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 text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</div>
</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">
{!booking.confirmed && !booking.rejected && (
<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">
{t("unconfirmed")}
</span>
)}
{!booking.confirmed && !booking.rejected && <Tag className="mb-2 mr-2">{t("unconfirmed")}</Tag>}
{!!booking?.eventType?.price && !booking.paid && <Tag className="mb-2 mr-2">Pending payment</Tag>}
<div className="text-sm font-medium text-gray-900">
{startTime}:{" "}
<small className="text-sm text-gray-500">
@ -94,13 +91,14 @@ function BookingListItem(booking: BookingItem) {
</small>
</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.title}
{!!booking?.eventType?.price && !booking.paid && (
<Tag className="hidden ml-2 sm:inline-flex">Pending payment</Tag>
)}
{!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">
{t("unconfirmed")}
</span>
<Tag className="hidden ml-2 sm:inline-flex">{t("unconfirmed")}</Tag>
)}
</div>
{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;

View File

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

View File

@ -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<States>({ 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) => {

View File

@ -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,

View File

@ -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<void>;
@ -117,15 +123,15 @@ const webhookHandlers: Record<string, WebhookHandler | undefined> = {
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}`,
});

View File

@ -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<Prisma.CredentialArgs>()({
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;

View File

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

View File

@ -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: {

View File

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

View File

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

View File

@ -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",

View File

@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { expect, test } from "@playwright/test";
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 Stripe integration");
todo("Can add Google 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) {
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"]

View File

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

View File

@ -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,