mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-12 13:59:03 +02:00
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:
parent
2d94e28dd2
commit
60a79e4b93
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@ -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
|
||||
|
@ -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/...
|
@ -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)
|
||||
|
@ -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);
|
||||
}}
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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'>
|
||||
|
@ -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'
|
||||
)}
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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}`);
|
||||
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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 () => {
|
||||
|
64
e2e-tests/tests/pricingPageTests.spec.ts
Normal file
64
e2e-tests/tests/pricingPageTests.spec.ts
Normal 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\//);
|
||||
});
|
@ -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();
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user