Remove hasPaid from user entity and rely only on subscriptionStatus for user privileges (#96)

* fix userStripeId race condition

* small fixes

* fix operations

* Update app/src/server/queries.ts

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>

* Update app/src/server/queries.ts

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>

* fix typos and remove console logs

* Update PricingPage.tsx

* add pricing page tests

* add copying .env.client.example

---------

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>
This commit is contained in:
vincanger 2024-04-22 15:37:20 +02:00 committed by GitHub
parent 2d94e28dd2
commit 60a79e4b93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 216 additions and 114 deletions

View File

@ -39,7 +39,7 @@ jobs:
- name: Set required wasp app env vars to mock values
run: |
cd app
cp .env.server.example .env.server
cp .env.server.example .env.server && cp .env.client.example .env.client
- name: Cache global node modules
uses: actions/cache@v4

View File

@ -1,4 +1,4 @@
# All client-side env vars must start with REACT_APP_ https://wasp-lang.dev/docs/project/env-vars
# Find your test url at https://dashboard.stripe.com/test/settings/billing/portal
REACT_APP_STRIPE_CUSTOMER_PORTAL=
REACT_APP_STRIPE_CUSTOMER_PORTAL=https://billing.stripe.com/...

View File

@ -87,7 +87,6 @@ entity User {=psl
isAdmin Boolean @default(false)
stripeId String?
checkoutSessionId String?
hasPaid Boolean @default(false)
subscriptionTier String?
subscriptionStatus String?
sendEmail Boolean @default(false)

View File

@ -6,12 +6,15 @@ const CheckboxOne = () => {
return (
<div>
<label htmlFor='checkboxLabelFour' className='flex cursor-pointer select-none items-center'>
<div className='relative'>
<label
htmlFor="checkboxLabelOne"
className="flex cursor-pointer select-none items-center"
>
<div className="relative">
<input
type='checkbox'
id='checkboxLabelFour'
className='sr-only'
type="checkbox"
id="checkboxLabelOne"
className="sr-only"
onChange={() => {
setIsChecked(!isChecked);
}}

View File

@ -6,7 +6,7 @@ const CheckboxTwo = () => {
return (
<div>
<label htmlFor='checkboxLabelTwo' className='flex cursor-pointer text-sm text-gray-700 select-none items-center'>
hasPaid:
enabled:
<div className='relative'>
<input
type='checkbox'

View File

@ -3,7 +3,7 @@ import { useState } from 'react';
import { cn } from '../../../shared/utils';
const SwitcherOne = ({ user, updateUserById }: { user?: Partial<User>; updateUserById?: any }) => {
const [enabled, setEnabled] = useState<boolean>(user?.hasPaid || false);
const [enabled, setEnabled] = useState<boolean>(user?.isAdmin || false);
return (
<div className='relative'>
@ -15,7 +15,7 @@ const SwitcherOne = ({ user, updateUserById }: { user?: Partial<User>; updateUse
className='sr-only'
onChange={() => {
setEnabled(!enabled);
updateUserById && updateUserById({ id: user?.id, data: { hasPaid: !enabled } });
updateUserById && updateUserById({ id: user?.id, data: { isAdmin: !enabled } });
}}
/>
<div className='reblock h-8 w-14 rounded-full bg-meta-9 dark:bg-[#5A616B]'></div>

View File

@ -3,19 +3,18 @@ import { useState, useEffect } from 'react';
import SwitcherOne from './SwitcherOne';
import Loader from '../common/Loader';
import DropdownEditDelete from './DropdownEditDelete';
type StatusOptions = 'past_due' | 'canceled' | 'active' | 'deleted';
import { type SubscriptionStatusOptions } from '../../../shared/types';
const UsersTable = () => {
const [skip, setskip] = useState(0);
const [page, setPage] = useState(1);
const [email, setEmail] = useState<string | undefined>(undefined);
const [statusOptions, setStatusOptions] = useState<StatusOptions[]>([]);
const [hasPaidFilter, setHasPaidFilter] = useState<boolean | undefined>(undefined);
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
const [statusOptions, setStatusOptions] = useState<SubscriptionStatusOptions[]>([]);
const { data, isLoading, error } = useQuery(getPaginatedUsers, {
skip,
hasPaidFilter: hasPaidFilter,
emailContains: email,
isAdmin: isAdminFilter,
subscriptionStatus: statusOptions?.length > 0 ? statusOptions : undefined,
});
@ -57,7 +56,7 @@ const UsersTable = () => {
key={opt}
className='z-30 flex items-center my-1 mx-2 py-1 px-2 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
>
{opt}
{opt ? opt : 'has not subscribed'}
<span
onClick={(e) => {
e.stopPropagation();
@ -92,11 +91,12 @@ const UsersTable = () => {
</div>
<select
onChange={(e) => {
const targetValue = e.target.value === '' ? null : e.target.value;
setStatusOptions((prevValue) => {
if (prevValue?.includes(e.target.value as StatusOptions)) {
return prevValue?.filter((val) => val !== e.target.value);
if (prevValue?.includes(targetValue as SubscriptionStatusOptions)) {
return prevValue?.filter((val) => val !== targetValue);
} else if (!!prevValue) {
return [...prevValue, e.target.value as StatusOptions];
return [...prevValue, targetValue as SubscriptionStatusOptions];
} else {
return prevValue;
}
@ -107,9 +107,9 @@ const UsersTable = () => {
className='absolute top-0 left-0 z-20 h-full w-full bg-white opacity-0'
>
<option value=''>Select filters</option>
{['past_due', 'canceled', 'active'].map((status) => {
if (!statusOptions.includes(status as StatusOptions)) {
return <option value={status}>{status}</option>;
{['past_due', 'canceled', 'active', 'deleted', null].map((status) => {
if (!statusOptions.includes(status as SubscriptionStatusOptions)) {
return <option value={status || ''}>{status ? status : 'has not subscribed'}</option>;
}
})}
</select>
@ -127,16 +127,16 @@ const UsersTable = () => {
</span>
</div>
<div className='flex items-center gap-2'>
<label htmlFor='hasPaid-filter' className='block text-sm ml-2 text-gray-700 dark:text-white'>
hasPaid:
<label htmlFor='isAdmin-filter' className='block text-sm ml-2 text-gray-700 dark:text-white'>
isAdmin:
</label>
<select
name='hasPaid-filter'
name='isAdmin-filter'
onChange={(e) => {
if (e.target.value === 'both') {
setHasPaidFilter(undefined);
setIsAdminFilter(undefined);
} else {
setHasPaidFilter(e.target.value === 'true');
setIsAdminFilter(e.target.value === 'true');
}
}}
className='relative z-20 w-full appearance-none rounded border border-stroke bg-white p-2 pl-4 pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'
@ -180,7 +180,7 @@ const UsersTable = () => {
<p className='font-medium'>Stripe ID</p>
</div>
<div className='col-span-1 flex items-center'>
<p className='font-medium'>Has Paid</p>
<p className='font-medium'>Is Admin</p>
</div>
<div className='col-span-1 flex items-center'>
<p className='font-medium'></p>

View File

@ -27,7 +27,7 @@ export default function AccountPage({ user }: { user: User }) {
)}
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500 dark:text-white'>Your Plan</dt>
{user.hasPaid ? (
{!!user.subscriptionStatus ? (
<>
{user.subscriptionStatus !== 'past_due' ? (
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>

View File

@ -129,7 +129,7 @@ const PricingPage = () => {
))}
</ul>
</div>
{!!user && user.hasPaid ? (
{!!user && !!user.subscriptionStatus ? (
<button
onClick={handleCustomerPortalClick}
aria-describedby='manage-subscription'
@ -153,7 +153,7 @@ const PricingPage = () => {
'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400': !tier.bestDeal,
},
{
'cursor-wait': isStripePaymentLoading === tier.id,
'opacity-50 cursor-wait cursor-not-allowed': isStripePaymentLoading === tier.id,
},
'mt-8 block rounded-md py-2 px-3 text-center text-sm dark:text-white font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-yellow-400'
)}

View File

@ -108,9 +108,14 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
}));
try {
if (!context.user.hasPaid && !context.user.credits) {
// check if openai is initialized correctly with the API key
if (openai instanceof Error) {
throw openai;
}
if (!context.user.subscriptionStatus && !context.user.credits) {
throw new HttpError(402, 'User has not paid or is out of credits');
} else if (context.user.credits && !context.user.hasPaid) {
} else if (context.user.credits && !context.user.subscriptionStatus) {
console.log('decrementing credits');
await context.entities.User.update({
where: { id: context.user.id },
@ -122,13 +127,8 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
});
}
// check if openai is initialized correctly with the API key
if (openai instanceof Error) {
throw openai;
}
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
messages: [
{
role: 'system',
@ -222,7 +222,7 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
return JSON.parse(gptArgs);
} catch (error: any) {
if (!context.user.hasPaid && error?.statusCode != 402) {
if (!context.user.subscriptionStatus && error?.statusCode != 402) {
await context.entities.User.update({
where: { id: context.user.id },
data: {

View File

@ -4,7 +4,7 @@ const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
export const getEmailUserFields = defineUserSignupFields({
username: (data: any) => data.email,
isAdmin : (data: any) => adminEmails.includes(data.email),
isAdmin: (data: any) => adminEmails.includes(data.email),
email: (data: any) => data.email,
});
@ -33,4 +33,3 @@ export function getGoogleAuthConfig() {
scopes: ['profile', 'email'], // must include at least 'profile' for Google
};
}

View File

@ -9,6 +9,7 @@ import {
type GetDownloadFileSignedURL,
} from 'wasp/server/operations';
import { getDownloadFileSignedURLFromS3 } from './file-upload/s3Utils.js';
import { type SubscriptionStatusOptions } from '../shared/types.js';
type DailyStatsWithSources = DailyStats & {
sources: PageViewSource[];
@ -100,14 +101,14 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues> = async (_args
type GetPaginatedUsersInput = {
skip: number;
cursor?: number | undefined;
hasPaidFilter: boolean | undefined;
emailContains?: string;
subscriptionStatus?: string[];
isAdmin?: boolean;
subscriptionStatus?: SubscriptionStatusOptions[];
};
type GetPaginatedUsersOutput = {
users: Pick<
User,
'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'hasPaid' | 'subscriptionStatus' | 'stripeId'
'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'subscriptionStatus' | 'stripeId'
>[];
totalPages: number;
};
@ -116,28 +117,48 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
args,
context
) => {
let subscriptionStatus = args.subscriptionStatus?.filter((status) => status !== 'hasPaid');
subscriptionStatus = subscriptionStatus?.length ? subscriptionStatus : undefined;
if (!context.user?.isAdmin) {
throw new HttpError(401);
}
const allSubscriptionStatusOptions = args.subscriptionStatus as Array<string | null> | undefined;
const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null)
let subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as string[] | undefined
const queryResults = await context.entities.User.findMany({
skip: args.skip,
take: 10,
where: {
email: {
contains: args.emailContains || undefined,
mode: 'insensitive',
},
hasPaid: args.hasPaidFilter,
subscriptionStatus: {
in: subscriptionStatus || undefined,
},
AND: [
{
email: {
contains: args.emailContains || undefined,
mode: 'insensitive',
},
isAdmin: args.isAdmin,
},
{
OR: [
{
subscriptionStatus: {
in: subscriptionStatusStrings,
},
},
{
subscriptionStatus: {
equals: hasNotSubscribed,
},
},
],
},
],
},
select: {
id: true,
email: true,
username: true,
isAdmin: true,
lastActiveTimestamp: true,
hasPaid: true,
subscriptionStatus: true,
stripeId: true,
},
@ -148,13 +169,29 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
const totalUserCount = await context.entities.User.count({
where: {
email: {
contains: args.emailContains || undefined,
},
hasPaid: args.hasPaidFilter,
subscriptionStatus: {
in: subscriptionStatus || undefined,
},
AND: [
{
email: {
contains: args.emailContains || undefined,
mode: 'insensitive',
},
isAdmin: args.isAdmin,
},
{
OR: [
{
subscriptionStatus: {
in: subscriptionStatusStrings,
},
},
{
subscriptionStatus: {
equals: hasNotSubscribed,
},
},
],
},
],
},
});
const totalPages = Math.ceil(totalUserCount / 10);

View File

@ -20,9 +20,8 @@ export function createRandomUser() {
lastActiveTimestamp: faker.date.recent(),
isAdmin: false,
stripeId: `cus_${faker.string.uuid()}`,
hasPaid: faker.helpers.arrayElement([true, false]),
sendEmail: false,
subscriptionStatus: faker.helpers.arrayElement(['active', 'canceled', 'past_due', 'deleted']),
subscriptionStatus: faker.helpers.arrayElement(['active', 'canceled', 'past_due', 'deleted', null]),
datePaid: faker.date.recent(),
credits: faker.number.int({ min: 0, max: 3 }),
checkoutSessionId: null,

View File

@ -23,14 +23,15 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
return response.status(400).send(`Webhook Error: ${err.message}`);
}
// let event: Stripe.Event;
let userStripeId: string | null = null;
try {
if (event.type === 'checkout.session.completed') {
console.log('Checkout session completed');
const session = event.data.object as Stripe.Checkout.Session;
userStripeId = session.customer as string;
const userStripeId = session.customer as string;
if (!userStripeId) {
console.log('No userStripeId in session');
return response.status(400).send(`Webhook Error: No userStripeId in session`);
}
const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items'],
@ -48,7 +49,6 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
stripeId: userStripeId,
},
data: {
hasPaid: true,
datePaid: new Date(),
subscriptionTier: TierIds.HOBBY,
},
@ -60,7 +60,6 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
stripeId: userStripeId,
},
data: {
hasPaid: true,
datePaid: new Date(),
subscriptionTier: TierIds.PRO,
},
@ -83,19 +82,19 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
}
} else if (event.type === 'invoice.paid') {
const invoice = event.data.object as Stripe.Invoice;
const userStripeId = invoice.customer as string;
const periodStart = new Date(invoice.period_start * 1000);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
hasPaid: true,
datePaid: periodStart,
},
});
} else if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object as Stripe.Subscription;
userStripeId = subscription.customer as string;
const userStripeId = subscription.customer as string;
if (subscription.status === 'active') {
console.log('Subscription active ', userStripeId);
await context.entities.User.updateMany({
@ -113,7 +112,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
* this is useful if the user's card expires or is canceled and automatic subscription renewal fails
*/
if (subscription.status === 'past_due') {
console.log('Subscription past due: ', userStripeId);
console.log('Subscription past due for user: ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
@ -130,8 +129,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
* https://stripe.com/docs/billing/subscriptions/cancel#events
*/
if (subscription.cancel_at_period_end) {
console.log('Subscription canceled at period end');
console.log('Subscription canceled at period end for user: ', userStripeId);
let customer = await context.entities.User.findFirst({
where: {
stripeId: userStripeId,
@ -164,26 +162,24 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
}
} else if (event.type === 'customer.subscription.deleted') {
const subscription = event.data.object as Stripe.Subscription;
userStripeId = subscription.customer as string;
const userStripeId = subscription.customer as string;
/**
* Stripe will send then finally send a subscription.deleted event when subscription period ends
* https://stripe.com/docs/billing/subscriptions/cancel#events
*/
console.log('Subscription deleted/ended');
console.log('Subscription deleted/ended for user: ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
hasPaid: false,
subscriptionStatus: 'deleted',
},
});
} else {
console.log(`Unhandled event type ${event.type}`);
}
response.json({ received: true });
} catch (err: any) {
response.status(400).send(`Webhook Error: ${err?.message}`);

View File

@ -28,7 +28,6 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
// we don't want to count those users as current paying users
const paidUserCount = await context.entities.User.count({
where: {
hasPaid: true,
subscriptionStatus: 'active',
},
});

View File

@ -3,6 +3,8 @@ export type StripePaymentResult = {
sessionId: string;
};
export type SubscriptionStatusOptions = 'past_due' | 'canceled' | 'active' | 'deleted' | null;
export type Subtask = {
description: string; // detailed breakdown and description of sub-task
time: number; // total time it takes to complete given main task in hours, e.g. 2.75

View File

@ -1,5 +1,5 @@
import { test, expect, type Page } from '@playwright/test';
import { signUserUp, logUserIn, createRandomUser, type User } from './utils';
import { signUserUp, logUserIn, createRandomUser, makeStripePayment, type User} from './utils';
let page: Page;
let testUser: User;
@ -91,36 +91,7 @@ test('AI schedule generation fails on 4th attempt', async () => {
test('Make test payment with Stripe', async () => {
const PLAN_NAME = 'Hobby';
test.slow(); // Stripe payments take a long time to confirm and can cause tests to fail so we use a longer timeout
await page.click('text="Pricing"');
await page.waitForURL('**/pricing');
const buyBtn = page.getByRole('button', { name: 'Buy plan' }).first();
await expect(buyBtn).toBeVisible();
await expect(buyBtn).toBeEnabled();
await buyBtn.click();
await page.waitForURL('https://checkout.stripe.com/**', { waitUntil: 'domcontentloaded' });
await page.fill('input[name="cardNumber"]', '4242424242424242');
await page.getByPlaceholder('MM / YY').fill('1225');
await page.getByPlaceholder('CVC').fill('123');
await page.getByPlaceholder('Full name on card').fill('Test User');
const countrySelect = page.getByLabel('Country or region');
await countrySelect.selectOption('Germany');
// This is a weird edge case where the `payBtn` assertion tests pass, but the button click still isn't registered.
// That's why we wait for stripe responses below to finish loading before clicking the button.
await page.waitForResponse(
(response) => response.url().includes('trusted-types-checker') && response.status() === 200
);
const payBtn = page.getByTestId('hosted-payment-submit-button');
await expect(payBtn).toBeVisible();
await expect(payBtn).toBeEnabled();
await payBtn.click();
await page.waitForURL('**/checkout?success=true');
await page.waitForURL('**/account');
await expect(page.getByText(PLAN_NAME)).toBeVisible();
await makeStripePayment({ test, page, planName: PLAN_NAME });
});
test('User should be able to generate another schedule after payment', async () => {

View File

@ -0,0 +1,64 @@
import { test, expect, type Page } from '@playwright/test';
import { signUserUp, logUserIn, createRandomUser, makeStripePayment, type User } from './utils';
let page: Page;
let testUser: User;
async function logNewUserIn() {
testUser = createRandomUser();
await signUserUp({ page: page, user: testUser });
await logUserIn({ page: page, user: testUser });
}
// We need to run the tests sequentially to check for functionality before and after the user pays.
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.afterAll(async () => {
await page.close();
});
test('User should see Log In to Buy Plan button', async () => {
await page.goto('/pricing');
// There are three tiers on the page, so we want to retrieve the first of the three buttons
const buyPlanButton = page.getByRole('button', { name: 'Log in to buy plan' }).first();
await expect(buyPlanButton).toBeVisible();
await expect(buyPlanButton).toBeEnabled();
await buyPlanButton.click();
await page.waitForURL('**/login');
expect(page.url()).toContain('/login');
});
test('User should see the Buy Plan button before payment', async () => {
// We only need to log the user in once since the tests are running sequentially
// and the same page is being shared between all the tests.
await logNewUserIn();
await page.goto('/pricing');
// There are three tiers on the page, so we want to retrieve the first of the three buttons
const manageSubscriptionButton = page.getByRole('button', { name: 'Buy plan' }).first();
await expect(manageSubscriptionButton).toBeVisible();
await expect(manageSubscriptionButton).toBeEnabled();
});
test('Make test payment with Stripe', async () => {
const PLAN_NAME = 'Hobby';
await page.goto('/');
await makeStripePayment({ test, page, planName: PLAN_NAME });
});
test('User should see the Manage Subscription button after payment', async () => {
await page.goto('/pricing');
// There are three tiers on the page, so we want to retrieve the first of the three buttons
const manageSubscriptionButton = page.getByRole('button', { name: 'Manage Subscription' }).first();
await expect(manageSubscriptionButton).toBeVisible();
await expect(manageSubscriptionButton).toBeEnabled();
await manageSubscriptionButton.click();
// clicking the button should take the user to the Stripe customer portal page with substring 'billing.stripe.com' in a new window
const newTabPromise = page.waitForEvent('popup');
const newTab = await newTabPromise;
await newTab.waitForLoadState();
await expect(newTab).toHaveURL(/^https:\/\/billing\.stripe\.com\//);
});

View File

@ -1,4 +1,4 @@
import { type Page } from '@playwright/test';
import { type Page, test, expect } from '@playwright/test';
import { randomUUID } from 'crypto';
export type User = {
@ -61,3 +61,36 @@ export const createRandomUser = () => {
const email = `${randomUUID()}@test.com`;
return { email, password: DEFAULT_PASSWORD } as User;
};
export const makeStripePayment = async ({ test, page, planName }: { test: any; page: Page; planName: string }) => {
test.slow(); // Stripe payments take a long time to confirm and can cause tests to fail so we use a longer timeout
await page.click('text="Pricing"');
await page.waitForURL('**/pricing');
const buyBtn = page.getByRole('button', { name: 'Buy plan' }).first(); // "Hobby Plan" is the first of three plans
await expect(buyBtn).toBeVisible();
await expect(buyBtn).toBeEnabled();
await buyBtn.click();
await page.waitForURL('https://checkout.stripe.com/**', { waitUntil: 'domcontentloaded' });
await page.fill('input[name="cardNumber"]', '4242424242424242');
await page.getByPlaceholder('MM / YY').fill('1225');
await page.getByPlaceholder('CVC').fill('123');
await page.getByPlaceholder('Full name on card').fill('Test User');
const countrySelect = page.getByLabel('Country or region');
await countrySelect.selectOption('Germany');
// This is a weird edge case where the `payBtn` assertion tests pass, but the button click still isn't registered.
// That's why we wait for stripe responses below to finish loading before clicking the button.
await page.waitForResponse(
(response) => response.url().includes('trusted-types-checker') && response.status() === 200
);
const payBtn = page.getByTestId('hosted-payment-submit-button');
await expect(payBtn).toBeVisible();
await expect(payBtn).toBeEnabled();
await payBtn.click();
await page.waitForURL('**/checkout?success=true');
await page.waitForURL('**/account');
await expect(page.getByText(planName)).toBeVisible();
};