diff --git a/src/pages/PlanPage/PlanPage.test.jsx b/src/pages/PlanPage/PlanPage.test.tsx similarity index 79% rename from src/pages/PlanPage/PlanPage.test.jsx rename to src/pages/PlanPage/PlanPage.test.tsx index b7bbc12378..a5d02f54f9 100644 --- a/src/pages/PlanPage/PlanPage.test.jsx +++ b/src/pages/PlanPage/PlanPage.test.tsx @@ -13,6 +13,9 @@ import config from 'config' import { ThemeContextProvider } from 'shared/ThemeContext' +import { Location } from 'history' +import { UnverifiedPaymentMethodSchema } from 'services/account' +import { z } from 'zod' import PlanPage from './PlanPage' vi.mock('config') @@ -40,9 +43,9 @@ const queryClientV5 = new QueryClientV5({ defaultOptions: { queries: { retry: false } }, }) -let testLocation +let testLocation: Location const wrapper = - (initialEntries = '') => + (initialEntries = ''): React.FC => ({ children }) => ( @@ -79,7 +82,13 @@ afterAll(() => { describe('PlanPage', () => { function setup( - { owner, isSelfHosted = false } = { + { + owner, + isSelfHosted = false, + unverifiedPaymentMethods = [] as z.infer< + typeof UnverifiedPaymentMethodSchema + >[], + } = { owner: { username: 'codecov', isCurrentUserPartOfOrg: true, @@ -92,6 +101,17 @@ describe('PlanPage', () => { server.use( graphql.query('PlanPageData', () => { return HttpResponse.json({ data: { owner } }) + }), + graphql.query('UnverifiedPaymentMethods', () => { + return HttpResponse.json({ + data: { + owner: { + billing: { + unverifiedPaymentMethods, + }, + }, + }, + }) }) ) } @@ -102,7 +122,7 @@ describe('PlanPage', () => { owner: { username: 'codecov', isCurrentUserPartOfOrg: false, - numberOfUploads: null, + numberOfUploads: 0, }, }) }) @@ -120,7 +140,7 @@ describe('PlanPage', () => { owner: { username: 'codecov', isCurrentUserPartOfOrg: false, - numberOfUploads: null, + numberOfUploads: 0, }, }) }) @@ -149,6 +169,34 @@ describe('PlanPage', () => { const tabs = await screen.findByText(/Tabs/) expect(tabs).toBeInTheDocument() }) + + describe('when there are unverified payment methods', () => { + beforeEach(() => { + setup({ + owner: { + username: 'codecov', + isCurrentUserPartOfOrg: true, + numberOfUploads: 30, + }, + unverifiedPaymentMethods: [ + { + paymentMethodId: 'pm_123', + hostedVerificationUrl: 'https://verify.stripe.com', + }, + ], + }) + }) + + it('renders unverified payment method alert', async () => { + render(, { wrapper: wrapper('/plan/gh/codecov') }) + + const alert = await screen.findByText(/Verify Your New Payment Method/) + expect(alert).toBeInTheDocument() + + const link = screen.getByText('Click here') + expect(link).toHaveAttribute('href', 'https://verify.stripe.com') + }) + }) }) describe('testing routes', () => { diff --git a/src/pages/PlanPage/PlanPage.jsx b/src/pages/PlanPage/PlanPage.tsx similarity index 69% rename from src/pages/PlanPage/PlanPage.jsx rename to src/pages/PlanPage/PlanPage.tsx index 97ce11ffae..d194f67660 100644 --- a/src/pages/PlanPage/PlanPage.jsx +++ b/src/pages/PlanPage/PlanPage.tsx @@ -8,7 +8,11 @@ import config from 'config' import { SentryRoute } from 'sentry' +import { useUnverifiedPaymentMethods } from 'services/account/useUnverifiedPaymentMethods' +import { Provider } from 'shared/api/helpers' import { Theme, useThemeContext } from 'shared/ThemeContext' +import A from 'ui/A' +import { Alert } from 'ui/Alert' import LoadingLogo from 'ui/LoadingLogo' import { PlanProvider } from './context' @@ -35,11 +39,21 @@ const Loader = () => ( ) +interface URLParams { + owner: string + provider: Provider +} + function PlanPage() { - const { owner, provider } = useParams() + const { owner, provider } = useParams() const { data: ownerData } = useSuspenseQueryV5( PlanPageDataQueryOpts({ owner, provider }) ) + const { data: unverifiedPaymentMethods } = useUnverifiedPaymentMethods({ + provider, + owner, + }) + const { theme } = useThemeContext() const isDarkMode = theme !== Theme.LIGHT @@ -61,6 +75,11 @@ function PlanPage() { > + {unverifiedPaymentMethods && unverifiedPaymentMethods.length > 0 ? ( + + ) : null} }> @@ -90,4 +109,28 @@ function PlanPage() { ) } +const UnverifiedPaymentMethodAlert = ({ url }: { url?: string | null }) => { + return ( + <> + + Verify Your New Payment Method + + Your new payment method needs to be verified.{' '} + + Click here + {' '} + to complete the process. The verification code may take around 2 days + to appear on your bank statement. + + +
+ + ) +} + export default PlanPage diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.test.tsx index 5dd41c5c1a..17c41a03bf 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.test.tsx @@ -12,8 +12,8 @@ import { MemoryRouter, Route } from 'react-router-dom' import { z } from 'zod' import { PlanUpdatedPlanNotificationContext } from 'pages/PlanPage/context' -import { AccountDetailsSchema, TrialStatuses } from 'services/account' -import { BillingRate, Plans } from 'shared/utils/billing' +import { AccountDetailsSchema } from 'services/account' +import { Plans } from 'shared/utils/billing' import { AlertOptions, type AlertOptionsType } from 'ui/Alert' import CurrentOrgPlan from './CurrentOrgPlan' @@ -36,28 +36,6 @@ const mockNoEnterpriseAccount = { }, } -const mockPlanDataResponse = { - baseUnitPrice: 10, - benefits: [], - billingRate: BillingRate.MONTHLY, - marketingName: 'some-name', - monthlyUploadLimit: 123, - value: Plans.USERS_PR_INAPPM, - trialStatus: TrialStatuses.NOT_STARTED, - trialStartDate: '', - trialEndDate: '', - trialTotalDays: 0, - pretrialUsersCount: 0, - planUserCount: 1, - hasSeatsLeft: true, - isEnterprisePlan: false, - isFreePlan: false, - isProPlan: false, - isSentryPlan: false, - isTeamPlan: false, - isTrialPlan: false, -} - const mockEnterpriseAccountDetailsNinetyPercent = { owner: { account: { @@ -170,10 +148,15 @@ describe('CurrentOrgPlan', () => { graphql.query('EnterpriseAccountDetails', () => { return HttpResponse.json({ data: enterpriseAccountDetails }) }), - graphql.query('GetPlanData', () => { + graphql.query('CurrentOrgPlanPageData', () => { return HttpResponse.json({ data: { - owner: { hasPrivateRepos: true, plan: { ...mockPlanDataResponse } }, + owner: { + plan: { value: Plans.USERS_PR_INAPPM }, + billing: { + unverifiedPaymentMethods: [], + }, + }, }, }) }), diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx index 34cb4fa93e..e5f39617de 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx @@ -2,7 +2,7 @@ import { useSuspenseQuery as useSuspenseQueryV5 } from '@tanstack/react-queryV5' import { useParams } from 'react-router-dom' import { usePlanUpdatedNotification } from 'pages/PlanPage/context' -import { useAccountDetails, usePlanData } from 'services/account' +import { useAccountDetails, useCurrentOrgPlanPageData } from 'services/account' import { Provider } from 'shared/api/helpers' import { getScheduleStart } from 'shared/plan/ScheduledPlanDetails/ScheduledPlanDetails' import A from 'ui/A' @@ -28,7 +28,7 @@ function CurrentOrgPlan() { owner, }) - const { data: planData } = usePlanData({ + const { data } = useCurrentOrgPlanPageData({ provider, owner, }) @@ -40,16 +40,28 @@ function CurrentOrgPlan() { }) ) + const hasUnverifiedPaymentMethods = + !!data?.billing?.unverifiedPaymentMethods?.length + + // awaitingInitialPaymentMethodVerification is true if the + // customer needs to verify a delayed notification payment method + // like ACH for their first subscription + const awaitingInitialPaymentMethodVerification = + !accountDetails?.subscriptionDetail?.defaultPaymentMethod && + hasUnverifiedPaymentMethods + const scheduledPhase = accountDetails?.scheduleDetail?.scheduledPhase - const isDelinquent = accountDetails?.delinquent + const isDelinquent = + accountDetails?.delinquent && !awaitingInitialPaymentMethodVerification const scheduleStart = scheduledPhase ? getScheduleStart(scheduledPhase) : undefined const shouldRenderBillingDetails = - (accountDetails?.planProvider !== 'github' && + !awaitingInitialPaymentMethodVerification && + ((accountDetails?.planProvider !== 'github' && !accountDetails?.rootOrganization) || - accountDetails?.usesInvoice + accountDetails?.usesInvoice) const planUpdatedNotification = usePlanUpdatedNotification() @@ -62,9 +74,11 @@ function CurrentOrgPlan() { subscriptionDetail={accountDetails?.subscriptionDetail} /> ) : null} - + {isDelinquent ? : null} - {planData?.plan ? ( + {data?.plan ? (
{planUpdatedNotification.alertOption && !planUpdatedNotification.isCancellation ? ( diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.test.tsx index 68128812a7..e178ad8984 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.test.tsx @@ -12,9 +12,12 @@ const wrapper = describe('InfoMessageStripeCallback', () => { describe('when rendering without success or cancel in the url', () => { - const { container } = render(, { - wrapper: wrapper('/account/gh/codecov'), - }) + const { container } = render( + , + { + wrapper: wrapper('/account/gh/codecov'), + } + ) it('doesnt render anything', () => { expect(container).toBeEmptyDOMElement() @@ -23,13 +26,29 @@ describe('InfoMessageStripeCallback', () => { describe('when rendering with success in the url', () => { it('renders a success message', async () => { - render(, { - wrapper: wrapper('/account/gh/codecov?success'), - }) + render( + , + { + wrapper: wrapper('/account/gh/codecov?success'), + } + ) await expect( screen.getByText(/Subscription Update Successful/) ).toBeInTheDocument() }) }) + + describe('when hasUnverifiedPaymentMethods is true', () => { + it('does not enders a success message even at ?success', async () => { + const { container } = render( + , + { + wrapper: wrapper('/account/gh/codecov?success'), + } + ) + + expect(container).toBeEmptyDOMElement() + }) + }) }) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.tsx index 64876f80fc..8ba57b06c0 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/InfoMessageStripeCallback/InfoMessageStripeCallback.tsx @@ -5,12 +5,16 @@ import Message from 'old_ui/Message' // Stripe redirects to this page with ?success or ?cancel in the URL // this component takes care of rendering a message if it is successful -function InfoMessageStripeCallback() { +function InfoMessageStripeCallback({ + hasUnverifiedPaymentMethods, +}: { + hasUnverifiedPaymentMethods: boolean +}) { const urlParams = qs.parse(useLocation().search, { ignoreQueryPrefix: true, }) - if ('success' in urlParams) + if ('success' in urlParams && !hasUnverifiedPaymentMethods) return (
diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/useCurrentOrgPlanPageData.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/useCurrentOrgPlanPageData.test.tsx new file mode 100644 index 0000000000..f4e7c77c0e --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/useCurrentOrgPlanPageData.test.tsx @@ -0,0 +1,119 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-queryV5' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { Plans } from 'shared/utils/billing' + +import { useCurrentOrgPlanPageData } from './useCurrentOrgPlanPageData' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +const wrapper: React.FC = ({ children }) => ( + {children} +) + +const server = setupServer() + +beforeAll(() => server.listen()) +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) +afterAll(() => server.close()) + +describe('useCurrentOrgPlanPageData', () => { + function setup(mockData = {}) { + server.use( + graphql.query('CurrentOrgPlanPageData', () => { + return HttpResponse.json({ data: mockData }) + }) + ) + } + + it('returns null when no data is returned', async () => { + setup({}) + + const { result } = renderHook( + () => + useCurrentOrgPlanPageData({ + provider: 'gh', + owner: 'codecov', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + expect(result.current.data).toBe(null) + }) + + it('returns parsed data when valid data is returned', async () => { + setup({ + owner: { + plan: { + value: Plans.USERS_PR_INAPPY, + }, + billing: { + unverifiedPaymentMethods: [ + { + paymentMethodId: 'pm_123', + hostedVerificationUrl: 'https://example.com', + }, + ], + }, + }, + }) + + const { result } = renderHook( + () => + useCurrentOrgPlanPageData({ + provider: 'gh', + owner: 'codecov', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + expect(result.current.data).toEqual({ + plan: { + value: Plans.USERS_PR_INAPPY, + }, + billing: { + unverifiedPaymentMethods: [ + { + paymentMethodId: 'pm_123', + hostedVerificationUrl: 'https://example.com', + }, + ], + }, + }) + }) + + it('returns error when invalid data is returned', async () => { + setup({ + owner: { + plan: { + value: 'INVALID_PLAN', + }, + }, + }) + + const { result } = renderHook( + () => + useCurrentOrgPlanPageData({ + provider: 'gh', + owner: 'codecov', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + expect(result.current.error).toEqual({ + status: 404, + data: {}, + dev: 'useCurrentOrgPlanPageData - 404 failed to parse', + }) + }) +}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/useCurrentOrgPlanPageData.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/useCurrentOrgPlanPageData.tsx new file mode 100644 index 0000000000..9a19d991d0 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/useCurrentOrgPlanPageData.tsx @@ -0,0 +1,85 @@ +import { useQuery } from '@tanstack/react-queryV5' +import { z } from 'zod' + +import Api from 'shared/api' +import { NetworkErrorObject, rejectNetworkError } from 'shared/api/helpers' +import { Plans } from 'shared/utils/billing' + +const PlanSchema = z.object({ + value: z.nativeEnum(Plans), +}) + +const UnverifiedPaymentMethodSchema = z.object({ + paymentMethodId: z.string(), + hostedVerificationUrl: z.string().nullish(), +}) + +const CurrentOrgPlanPageDataSchema = z + .object({ + owner: z + .object({ + plan: PlanSchema.nullish(), + billing: z + .object({ + unverifiedPaymentMethods: z.array(UnverifiedPaymentMethodSchema), + }) + .nullish(), + }) + .nullish(), + }) + .nullish() + +interface UseCurrentOrgPlanPageDataArgs { + provider: string + owner: string + opts?: { + enabled?: boolean + } +} + +const query = ` + query CurrentOrgPlanPageData($owner: String!) { + owner(username: $owner) { + plan { + value + } + billing { + unverifiedPaymentMethods { + paymentMethodId + hostedVerificationUrl + } + } + } + } +` + +export const useCurrentOrgPlanPageData = ({ + provider, + owner, + opts, +}: UseCurrentOrgPlanPageDataArgs) => + useQuery({ + queryKey: ['CurrentOrgPlanPageData', provider, owner, query], + queryFn: ({ signal }) => + Api.graphql({ + provider, + query, + signal, + variables: { + owner, + }, + }).then((res) => { + const parsedRes = CurrentOrgPlanPageDataSchema.safeParse(res?.data) + if (!parsedRes.success) { + return rejectNetworkError({ + status: 404, + error: parsedRes.error, + data: {}, + dev: 'useCurrentOrgPlanPageData - 404 failed to parse', + } satisfies NetworkErrorObject) + } + + return parsedRes.data?.owner ?? null + }), + ...(!!opts && opts), + }) diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.test.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.test.tsx index 4a7ad77ffd..9e73679207 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.test.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.test.tsx @@ -1,4 +1,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + QueryClientProvider as QueryClientProviderV5, + QueryClient as QueryClientV5, +} from '@tanstack/react-queryV5' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { graphql, http, HttpResponse } from 'msw' @@ -233,6 +237,15 @@ const queryClient = new QueryClient({ }, }, }) + +const queryClientV5 = new QueryClientV5({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + const server = setupServer() beforeAll(() => { @@ -241,6 +254,7 @@ beforeAll(() => { afterEach(() => { queryClient.clear() + queryClientV5.clear() server.resetHandlers() vi.clearAllMocks() }) @@ -256,20 +270,22 @@ const wrapper: ( ) => React.FC = (initialEntries = ['/gh/codecov']) => ({ children }) => ( - - - - {children} - - { - testLocation = location - return null - }} - /> - - + + + + + {children} + + { + testLocation = location + return null + }} + /> + + + ) type SetupArgs = { @@ -281,6 +297,7 @@ type SetupArgs = { hasSentryPlans?: boolean monthlyPlan?: boolean planUserCount?: number + hasUnverifiedPaymentMethod?: boolean } describe('UpgradeForm', () => { @@ -293,6 +310,7 @@ describe('UpgradeForm', () => { hasSentryPlans = false, monthlyPlan = true, planUserCount = 1, + hasUnverifiedPaymentMethod = false, }: SetupArgs) { const addNotification = vi.fn() const user = userEvent.setup() @@ -384,6 +402,24 @@ describe('UpgradeForm', () => { }, }, }) + }), + graphql.query('UnverifiedPaymentMethods', () => { + return HttpResponse.json({ + data: { + owner: { + billing: { + unverifiedPaymentMethods: hasUnverifiedPaymentMethod + ? [ + { + paymentMethodId: 'asdf', + hostedVerficationUrl: 'https://stripe.com', + }, + ] + : null, + }, + }, + }, + }) }) ) @@ -391,6 +427,59 @@ describe('UpgradeForm', () => { } describe('when rendered', () => { + describe('when user has unverified payment methods', () => { + const props = { + setSelectedPlan: vi.fn(), + selectedPlan: proPlanYear, + } + + it('shows modal when form is submitted', async () => { + const { user } = setup({ + planValue: Plans.USERS_BASIC, + hasUnverifiedPaymentMethod: true, + }) + render(, { wrapper: wrapper() }) + + const proceedToCheckoutButton = await screen.findByRole('button', { + name: /Proceed to checkout/, + }) + await user.click(proceedToCheckoutButton) + + const modal = await screen.findByText( + /Are you sure you want to abandon this upgrade and start a new one/, + { + exact: false, + } + ) + expect(modal).toBeInTheDocument() + }) + + it('does not show modal when no unverified payment methods', async () => { + const { user } = setup({ + planValue: Plans.USERS_BASIC, + hasUnverifiedPaymentMethod: false, + }) + render(, { wrapper: wrapper() }) + + const input = await screen.findByRole('spinbutton') + await user.type(input, '{backspace}{backspace}{backspace}') + await user.type(input, '20') + + const proceedToCheckoutButton = await screen.findByRole('button', { + name: /Proceed to checkout/, + }) + await user.click(proceedToCheckoutButton) + + const modal = screen.queryByText( + /Are you sure you want to abandon this upgrade and start a new one/, + { + exact: false, + } + ) + expect(modal).not.toBeInTheDocument() + }) + }) + describe('when the user has a basic plan', () => { const props = { setSelectedPlan: vi.fn(), diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.tsx index 86a8186aec..519ea65438 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { useParams } from 'react-router-dom' @@ -8,6 +8,7 @@ import { useAccountDetails, useAvailablePlans, usePlanData, + useUnverifiedPaymentMethods, } from 'services/account' import { Provider } from 'shared/api/helpers' import { canApplySentryUpgrade, getNextBillingDate } from 'shared/utils/billing' @@ -23,6 +24,7 @@ import { useUpgradeControls } from './hooks' import PlanTypeOptions from './PlanTypeOptions' import UpdateBlurb from './UpdateBlurb/UpdateBlurb' import UpdateButton from './UpdateButton' +import UpgradeFormModal from './UpgradeFormModal' type URLParams = { provider: Provider @@ -44,7 +46,14 @@ function UpgradeForm({ selectedPlan, setSelectedPlan }: UpgradeFormProps) { const { data: accountDetails } = useAccountDetails({ provider, owner }) const { data: plans } = useAvailablePlans({ provider, owner }) const { data: planData } = usePlanData({ owner, provider }) + const { data: unverifiedPaymentMethods } = useUnverifiedPaymentMethods({ + provider, + owner, + }) const { upgradePlan } = useUpgradeControls() + const [showModal, setShowModal] = useState(false) + const [formData, setFormData] = useState() + const [isUpgrading, setIsUpgrading] = useState(false) const isSentryUpgrade = canApplySentryUpgrade({ isEnterprisePlan: planData?.plan?.isEnterprisePlan, plans, @@ -90,10 +99,20 @@ function UpgradeForm({ selectedPlan, setSelectedPlan }: UpgradeFormProps) { trigger('seats') }, [newPlan, trigger]) + const onSubmit = handleSubmit((data) => { + if (unverifiedPaymentMethods?.length) { + setFormData(data) + setShowModal(true) + } else { + setIsUpgrading(true) + upgradePlan(data) + } + }) + return (

Organization

@@ -119,6 +138,18 @@ function UpgradeForm({ selectedPlan, setSelectedPlan }: UpgradeFormProps) { nextBillingDate={getNextBillingDate(accountDetails)!} /> + {showModal && formData && ( + setShowModal(false)} + onConfirm={() => { + setIsUpgrading(true) + upgradePlan(formData) + }} + url={unverifiedPaymentMethods?.[0]?.hostedVerificationUrl || ''} + isUpgrading={isUpgrading} + /> + )} ) } diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeFormModal.test.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeFormModal.test.tsx new file mode 100644 index 0000000000..28aadfd0f4 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeFormModal.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import UpgradeFormModal from './UpgradeFormModal' + +describe('UpgradeFormModal', () => { + const mockOnClose = vi.fn() + const mockOnConfirm = vi.fn() + const mockUrl = 'https://verify.stripe.com' + + const setup = (isUpgrading = false) => { + return render( + + ) + } + + beforeEach(() => { + vi.resetAllMocks() + }) + + it('renders modal with correct content', () => { + setup() + + expect(screen.getByText('Incomplete Plan Upgrade')).toBeInTheDocument() + expect( + screen.getByText( + /You have a pending plan upgrade awaiting payment verification/ + ) + ).toBeInTheDocument() + expect(screen.getByText('here')).toHaveAttribute('href', mockUrl) + expect( + screen.getByText( + /Are you sure you want to abandon this upgrade and start a new one/ + ) + ).toBeInTheDocument() + }) + + it('calls onClose when cancel button is clicked', async () => { + setup() + const utils = userEvent.setup() + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }) + await utils.click(cancelButton) + + expect(mockOnClose).toHaveBeenCalled() + }) + + it('calls onConfirm when confirm button is clicked', async () => { + setup() + const utils = userEvent.setup() + + const confirmButton = screen.getByRole('button', { + name: 'Yes, Start New Upgrade', + }) + await utils.click(confirmButton) + + expect(mockOnConfirm).toHaveBeenCalled() + }) + + describe('when isUpgrading is true', () => { + it('disables buttons and shows processing text', () => { + setup(true) + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }) + const confirmButton = screen.getByRole('button', { + name: 'Processing...', + }) + + expect(cancelButton).toBeDisabled() + expect(confirmButton).toBeDisabled() + }) + }) +}) diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeFormModal.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeFormModal.tsx new file mode 100644 index 0000000000..effcf2082f --- /dev/null +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeFormModal.tsx @@ -0,0 +1,73 @@ +import A from 'ui/A' +import Button from 'ui/Button' +import Icon from 'ui/Icon' +import Modal from 'ui/Modal' + +interface UpgradeModalProps { + isOpen: boolean + onClose: () => void + onConfirm: () => void + url: string + isUpgrading?: boolean +} + +const UpgradeFormModal = ({ + isOpen, + onClose, + onConfirm, + url, + isUpgrading = false, +}: UpgradeModalProps) => ( + + + Incomplete Plan Upgrade +

+ } + body={ +
+
+ You have a pending plan upgrade awaiting payment verification. Verify + payment{' '} + + here + + . +
+

+ Are you sure you want to abandon this upgrade and start a new one? + This action cannot be undone. +

+
+ } + footer={ +
+ + +
+ } + /> +) + +export default UpgradeFormModal diff --git a/src/services/account/index.ts b/src/services/account/index.ts index d1518703b7..30e3552fb1 100644 --- a/src/services/account/index.ts +++ b/src/services/account/index.ts @@ -4,11 +4,13 @@ export * from './useAutoActivate' export * from './useAvailablePlans' export * from './useCancelPlan' export * from './useCreateStripeSetupIntent' +export * from '../../pages/PlanPage/subRoutes/CurrentOrgPlan/useCurrentOrgPlanPageData' export * from './useEraseAccount' export * from './useInvoice' export * from './useInvoices' export * from './usePlanData' export * from './useSentryToken' +export * from './useUnverifiedPaymentMethods' export * from './useUpdateBillingEmail' export * from './useUpdateCard' export * from './useUpdatePaymentMethod' diff --git a/src/services/account/useUnverifiedPaymentMethods.test.tsx b/src/services/account/useUnverifiedPaymentMethods.test.tsx new file mode 100644 index 0000000000..f0d9b484f3 --- /dev/null +++ b/src/services/account/useUnverifiedPaymentMethods.test.tsx @@ -0,0 +1,135 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-queryV5' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import React from 'react' +import { MemoryRouter, Route } from 'react-router-dom' +import { z } from 'zod' + +import { + UnverifiedPaymentMethodSchema, + useUnverifiedPaymentMethods, +} from './useUnverifiedPaymentMethods' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +const wrapper = + (initialEntries = '/gh'): React.FC => + ({ children }) => ( + + + {children} + + + ) + +const provider = 'gh' +const owner = 'codecov' + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +describe('useUnverifiedPaymentMethods', () => { + function setup( + unverifiedPaymentMethods: z.infer[] + ) { + server.use( + graphql.query('UnverifiedPaymentMethods', () => { + return HttpResponse.json({ + data: { + owner: { + billing: { + unverifiedPaymentMethods, + }, + }, + }, + }) + }) + ) + } + + describe('when called', () => { + describe('on success', () => { + it('returns empty array when no unverified payment methods exist', async () => { + setup([]) + const { result } = renderHook( + () => useUnverifiedPaymentMethods({ provider, owner }), + { wrapper: wrapper() } + ) + + await waitFor(() => expect(result.current.data).toEqual([])) + }) + + it('returns array of unverified payment methods', async () => { + const unverifiedPaymentMethods = [ + { + paymentMethodId: 'pm_123', + hostedVerificationUrl: 'https://example.com/verify', + }, + ] + setup(unverifiedPaymentMethods) + + const { result } = renderHook( + () => useUnverifiedPaymentMethods({ provider, owner }), + { wrapper: wrapper() } + ) + + await waitFor(() => + expect(result.current.data).toEqual([ + { + paymentMethodId: 'pm_123', + hostedVerificationUrl: 'https://example.com/verify', + }, + ]) + ) + }) + }) + + describe('on fail', () => { + beforeAll(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + it('fails to parse if bad data', async () => { + setup([ + { + // @ts-expect-error - force fail with wrong key + wrongKey: 'wrongData', + }, + ]) + + const { result } = renderHook( + () => useUnverifiedPaymentMethods({ provider, owner }), + { wrapper: wrapper() } + ) + + await waitFor(() => expect(result.current.error).toBeTruthy()) + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + data: {}, + dev: 'useHasUnverifiedPaymentMethods - 404 failed to parse', + }) + ) + }) + }) + }) +}) diff --git a/src/services/account/useUnverifiedPaymentMethods.tsx b/src/services/account/useUnverifiedPaymentMethods.tsx new file mode 100644 index 0000000000..2b2d2aa001 --- /dev/null +++ b/src/services/account/useUnverifiedPaymentMethods.tsx @@ -0,0 +1,71 @@ +import { useQuery } from '@tanstack/react-queryV5' +import { z } from 'zod' + +import Api from 'shared/api' +import { Provider, rejectNetworkError } from 'shared/api/helpers' + +const query = ` +query UnverifiedPaymentMethods($owner: String!) { + owner(username: $owner) { + billing { + unverifiedPaymentMethods { + paymentMethodId + hostedVerificationUrl + } + } + } +} +` + +export const UnverifiedPaymentMethodSchema = z.object({ + paymentMethodId: z.string(), + hostedVerificationUrl: z.string().nullish(), +}) + +const UnverifiedPaymentMethodsSchema = z.object({ + owner: z + .object({ + billing: z + .object({ + unverifiedPaymentMethods: z + .array(UnverifiedPaymentMethodSchema) + .nullish(), + }) + .nullish(), + }) + .nullish(), +}) + +interface UseUnverifiedPaymentMethodsArgs { + provider: Provider + owner: string +} + +export const useUnverifiedPaymentMethods = ({ + provider, + owner, +}: UseUnverifiedPaymentMethodsArgs) => + useQuery({ + queryKey: ['UnverifiedPaymentMethods', provider, owner], + queryFn: ({ signal }) => + Api.graphql({ + provider, + query, + signal, + variables: { + owner, + }, + }).then((res) => { + const parsedData = UnverifiedPaymentMethodsSchema.safeParse(res?.data) + + if (!parsedData.success) { + return rejectNetworkError({ + status: 404, + data: {}, + dev: 'useHasUnverifiedPaymentMethods - 404 failed to parse', + }) + } + + return parsedData.data.owner?.billing?.unverifiedPaymentMethods ?? [] + }), + })